fix(s3): honor CopyObject tagging directives (#5165)

Closes rustfs/backlog#1462.
This commit is contained in:
cxymds
2026-07-24 10:30:43 +08:00
committed by GitHub
parent 0269c47bc6
commit a2fc6e15df
11 changed files with 797 additions and 37 deletions
+1
View File
@@ -179,6 +179,7 @@ uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
zip = { workspace = true }
libc = { workspace = true }
rand = { workspace = true, features = ["serde"] }
regex = { workspace = true }
aes-gcm = { workspace = true, features = ["rand_core"] }
chacha20poly1305 = { workspace = true }
+16 -1
View File
@@ -160,7 +160,7 @@ use s3s::dto::{
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ObjectPart, PutObjectInput,
PutObjectOutput, Range, RequestCharged, RestoreObjectInput, RestoreObjectOutput, RestoreStatus, SSECustomerAlgorithm,
SSECustomerKeyMD5, SSEKMSKeyId, SelectObjectContentInput, SelectObjectContentOutput, ServerSideEncryption, StorageClass,
StreamingBlob, TaggingHeader, Timestamp, TimestampFormat, WebsiteRedirectLocation,
StreamingBlob, TaggingDirective, TaggingHeader, Timestamp, TimestampFormat, WebsiteRedirectLocation,
};
use s3s::header::{X_AMZ_RESTORE, X_AMZ_RESTORE_OUTPUT_PATH};
use s3s::stream::{ByteStream, DynByteStream, RemainingLength};
@@ -5736,6 +5736,8 @@ impl DefaultObjectUsecase {
copy_source_sse_customer_key_md5,
metadata_directive,
metadata,
tagging,
tagging_directive,
copy_source_if_match,
copy_source_if_none_match,
content_type,
@@ -5787,7 +5789,13 @@ impl DefaultObjectUsecase {
// when an explicit storage class change is requested, or when restoring a specific historical
// version onto the current key (source carries a versionId). Reject only a true no-op self-copy
// where none of these apply (issue #4238).
let replacement_tags = super::storage_api::object_usecase::s3_api::tagging::resolve_copy_object_tags(
tagging.as_deref(),
tagging_directive.as_ref(),
)?;
if metadata_directive.as_ref().map(|d| d.as_str()) != Some(MetadataDirective::REPLACE)
&& tagging_directive.as_ref().map(TaggingDirective::as_str) != Some(TaggingDirective::REPLACE)
&& storage_class.is_none()
&& version_id.is_none()
&& src_bucket == bucket
@@ -5940,6 +5948,7 @@ impl DefaultObjectUsecase {
// Extract user_defined from Arc for mutation; it will be re-wrapped after all edits.
let mut user_defined = (*src_info.user_defined).clone();
let effective_tags = replacement_tags.unwrap_or_else(|| (*src_info.user_tags).clone());
strip_managed_encryption_metadata(&mut user_defined);
@@ -5983,6 +5992,12 @@ impl DefaultObjectUsecase {
}
}
user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_OBJECT_TAGGING));
if !effective_tags.is_empty() {
user_defined.insert(AMZ_OBJECT_TAGGING.to_string(), effective_tags.clone());
}
src_info.user_tags = Arc::new(effective_tags);
let has_explicit_object_lock_retention = object_lock_mode.is_some() || object_lock_retain_until_date.is_some();
remove_object_lock_metadata_for_copy(&mut user_defined);
if let Some(object_lock_metadata) = build_put_like_object_lock_metadata(
+4
View File
@@ -928,6 +928,10 @@ pub(crate) mod s3_api {
parse_list_multipart_uploads_params, parse_list_parts_params, parse_upload_part_number,
};
}
pub(crate) mod tagging {
pub(crate) use crate::storage::storage_api::s3_api_consumer::tagging::resolve_copy_object_tags;
}
}
pub(crate) mod admin_usecase {
+88 -19
View File
@@ -24,6 +24,7 @@ use crate::server::RemoteAddr;
use crate::storage::request_context::RequestContext;
use crate::storage::storage_api::access_consumer::contract::bucket::BucketOperations;
use crate::storage::storage_api::runtime_sources_consumer::runtime_sources;
use http::HeaderMap;
use metrics::counter;
use rustfs_iam::error::Error as IamError;
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
@@ -246,6 +247,57 @@ fn merge_list_bucket_query_conditions(action: Action, query: Option<&str>, condi
}
}
fn merge_request_object_tag_conditions(
action: Action,
headers: &HeaderMap,
conditions: &mut HashMap<String, Vec<String>>,
) -> S3Result<()> {
if action != Action::S3Action(S3Action::PutObjectAction) {
return Ok(());
}
let Some(tagging) = headers.get("x-amz-tagging").and_then(|value| value.to_str().ok()) else {
return Ok(());
};
let mut tag_keys = Vec::new();
for tag in crate::storage::s3_api::tagging::parse_object_tag_header(tagging)? {
let Some(key) = tag.key else {
continue;
};
let Some(value) = tag.value else {
continue;
};
tag_keys.push(key.clone());
conditions.entry(format!("RequestObjectTag/{key}")).or_default().push(value);
}
conditions.insert("RequestObjectTagKeys".to_string(), tag_keys);
Ok(())
}
fn authorization_conditions<T>(
req: &S3Request<T>,
cred: &rustfs_credentials::Credentials,
version_id: Option<&str>,
region: Option<s3s::region::Region>,
remote_addr: Option<std::net::SocketAddr>,
client_info: Option<&ClientInfo>,
action: Action,
) -> S3Result<HashMap<String, Vec<String>>> {
let mut conditions = get_condition_values_with_query_and_client_info(
&req.headers,
cred,
version_id,
region,
remote_addr,
req.uri.query(),
client_info,
);
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
merge_request_object_tag_conditions(action, &req.headers, &mut conditions)?;
Ok(conditions)
}
fn auth_fs() -> &'static FS {
static AUTH_FS: OnceLock<FS> = OnceLock::new();
AUTH_FS.get_or_init(FS::new)
@@ -358,16 +410,7 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
let default_claims = HashMap::new();
let claims = cred.claims.as_ref().unwrap_or(&default_claims);
let client_info = req.extensions.get::<ClientInfo>();
let mut conditions = get_condition_values_with_query_and_client_info(
&req.headers,
cred,
version_id.as_deref(),
None,
remote_addr,
req.uri.query(),
client_info,
);
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
let mut conditions = authorization_conditions(req, cred, version_id.as_deref(), None, remote_addr, client_info, action)?;
let action_args = Args {
account: &cred.access_key,
@@ -579,16 +622,15 @@ pub async fn authorize_request<T>(req: &mut S3Request<T>, action: Action) -> S3R
} else {
let default_cred = rustfs_credentials::Credentials::default();
let client_info = req.extensions.get::<ClientInfo>();
let mut conditions = get_condition_values_with_query_and_client_info(
&req.headers,
let mut conditions = authorization_conditions(
req,
&default_cred,
version_id.as_deref(),
req.region.clone(),
remote_addr,
req.uri.query(),
client_info,
);
merge_list_bucket_query_conditions(action, req.uri.query(), &mut conditions);
action,
)?;
let no_groups: Option<Vec<String>> = None;
let bucket_tag_hint = if !bucket.is_empty() && !object.is_empty() {
@@ -2105,12 +2147,12 @@ mod tests {
bucket_policy_needs_existing_object_tag_from_hint, classify_bucket_policy_raw_load_error,
complete_multipart_upload_authorize_action, get_bucket_policy_authorize_action, has_write_offset_bytes_header,
legal_hold_write_requested, list_parts_authorize_action, load_bucket_policy_existing_object_tag_hint,
merge_list_bucket_query_conditions, owner_can_bypass_policy_deny, post_object_authorize_action,
put_bucket_policy_authorize_action, request_context_from_req, retention_write_requested, secondary_tag_hint_action,
table_data_plane_admin_action, validate_post_object_success_controls, versioned_read_action,
merge_list_bucket_query_conditions, merge_request_object_tag_conditions, owner_can_bypass_policy_deny,
post_object_authorize_action, put_bucket_policy_authorize_action, request_context_from_req, retention_write_requested,
secondary_tag_hint_action, table_data_plane_admin_action, validate_post_object_success_controls, versioned_read_action,
};
use crate::error::ApiError;
use http::{Extensions, HeaderMap, Method, Uri};
use http::{Extensions, HeaderMap, HeaderValue, Method, Uri};
use rustfs_policy::policy::action::{Action, S3Action};
use rustfs_policy::policy::{BucketPolicy, bucket_policy_uses_existing_object_tag_conditions};
use s3s::{S3ErrorCode, S3Request, dto::*};
@@ -2323,6 +2365,33 @@ mod tests {
assert!(conditions.is_empty());
}
#[test]
fn test_merge_request_object_tag_conditions_applies_only_to_put_object() {
let mut headers = HeaderMap::new();
headers.insert("x-amz-tagging", HeaderValue::from_static("classification=restricted&label=copy%20test"));
let mut source_conditions = HashMap::new();
merge_request_object_tag_conditions(Action::S3Action(S3Action::GetObjectAction), &headers, &mut source_conditions)
.expect("GetObject condition construction should succeed");
assert!(
source_conditions.is_empty(),
"destination request tags must not affect CopyObject source authorization"
);
let mut destination_conditions = HashMap::new();
merge_request_object_tag_conditions(Action::S3Action(S3Action::PutObjectAction), &headers, &mut destination_conditions)
.expect("PutObject condition construction should succeed");
assert_eq!(
destination_conditions.get("RequestObjectTag/classification"),
Some(&vec!["restricted".to_string()])
);
assert_eq!(destination_conditions.get("RequestObjectTag/label"), Some(&vec!["copy test".to_string()]));
assert_eq!(
destination_conditions.get("RequestObjectTagKeys"),
Some(&vec!["classification".to_string(), "label".to_string()])
);
}
#[test]
fn test_object_tag_conditions_key_format() {
let mut tags = HashMap::new();
+209 -15
View File
@@ -12,14 +12,27 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use s3s::dto::Tag;
use percent_encoding::percent_decode_str;
use s3s::dto::{Tag, TaggingDirective};
use s3s::{S3Error, S3ErrorCode, S3Result};
use std::collections::HashSet;
use std::sync::OnceLock;
use url::form_urlencoded;
use crate::storage::storage_api::encode_tags;
fn invalid_tag_error(message: &str) -> S3Error {
S3Error::with_message(S3ErrorCode::InvalidTag, message.to_string())
}
fn is_valid_tag_text(value: &str) -> bool {
static VALID_OBJECT_TAG_TEXT: OnceLock<Result<regex::Regex, regex::Error>> = OnceLock::new();
VALID_OBJECT_TAG_TEXT
.get_or_init(|| regex::Regex::new(r"^[\p{L}\p{N}\p{Z}+\-=._:/@]*$"))
.as_ref()
.is_ok_and(|pattern| pattern.is_match(value))
}
pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
if tag_set.len() > 10 {
return Err(invalid_tag_error("Cannot have more than 10 tags per object"));
@@ -33,8 +46,8 @@ pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
.filter(|key| !key.is_empty())
.ok_or_else(|| invalid_tag_error("Tag key cannot be empty"))?;
if key.len() > 128 {
return Err(invalid_tag_error("Tag key is too long, maximum allowed length is 128 characters"));
if key.encode_utf16().count() > 128 || !is_valid_tag_text(key) {
return Err(invalid_tag_error("The TagKey you have provided is invalid"));
}
let value = tag
@@ -42,8 +55,8 @@ pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
.as_deref()
.ok_or_else(|| invalid_tag_error("Tag value cannot be null"))?;
if value.len() > 256 {
return Err(invalid_tag_error("Tag value is too long, maximum allowed length is 256 characters"));
if value.encode_utf16().count() > 256 || !is_valid_tag_text(value) {
return Err(invalid_tag_error("The TagValue you have provided is invalid"));
}
if !tag_keys.insert(key) {
@@ -54,11 +67,80 @@ pub(crate) fn validate_object_tag_set(tag_set: &[Tag]) -> S3Result<()> {
Ok(())
}
fn validate_tag_component_encoding(component: &str) -> S3Result<()> {
let bytes = component.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
if index + 2 >= bytes.len() || !bytes[index + 1].is_ascii_hexdigit() || !bytes[index + 2].is_ascii_hexdigit() {
return Err(invalid_tag_error("The Tagging header contains invalid percent encoding"));
}
index += 3;
} else {
index += 1;
}
}
percent_decode_str(component)
.decode_utf8()
.map_err(|_| invalid_tag_error("The Tagging header contains invalid UTF-8"))?;
Ok(())
}
pub(crate) fn parse_object_tag_header(tagging: &str) -> S3Result<Vec<Tag>> {
let mut tag_set = Vec::with_capacity(10);
for pair in tagging.split('&').filter(|pair| !pair.is_empty()) {
if tag_set.len() == 10 {
return Err(invalid_tag_error("Cannot have more than 10 tags per object"));
}
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
validate_tag_component_encoding(key)?;
validate_tag_component_encoding(value)?;
let mut parsed = form_urlencoded::parse(pair.as_bytes());
if let Some((key, value)) = parsed.next() {
tag_set.push(Tag {
key: Some(key.into_owned()),
value: Some(value.into_owned()),
});
}
}
validate_object_tag_set(&tag_set)?;
Ok(tag_set)
}
pub(crate) fn parse_copy_object_tags(tagging: &str) -> S3Result<String> {
parse_object_tag_header(tagging).map(encode_tags)
}
pub(crate) fn resolve_copy_object_tags(
tagging: Option<&str>,
tagging_directive: Option<&TaggingDirective>,
) -> S3Result<Option<String>> {
match tagging_directive.map(TaggingDirective::as_str) {
None | Some(TaggingDirective::COPY) => {
if tagging.is_some() {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
"The Tagging header requires the REPLACE tagging directive".to_string(),
));
}
Ok(None)
}
Some(TaggingDirective::REPLACE) => parse_copy_object_tags(tagging.unwrap_or_default()).map(Some),
Some(_) => Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
"The TaggingDirective header is invalid".to_string(),
)),
}
}
#[cfg(test)]
mod tests {
use super::validate_object_tag_set;
use super::{parse_copy_object_tags, resolve_copy_object_tags, validate_object_tag_set};
use s3s::S3ErrorCode;
use s3s::dto::Tag;
use s3s::dto::{Tag, TaggingDirective};
fn tag(key: Option<&str>, value: Option<&str>) -> Tag {
Tag {
@@ -95,10 +177,7 @@ mod tests {
let long_key = "k".repeat(129);
let err = validate_object_tag_set(&[tag(Some(&long_key), Some("v1"))]).expect_err("too long tag key must be rejected");
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
assert!(
err.to_string()
.contains("Tag key is too long, maximum allowed length is 128 characters")
);
assert!(err.to_string().contains("The TagKey you have provided is invalid"));
}
#[test]
@@ -114,10 +193,7 @@ mod tests {
let err =
validate_object_tag_set(&[tag(Some("k1"), Some(&long_value))]).expect_err("too long tag value must be rejected");
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
assert!(
err.to_string()
.contains("Tag value is too long, maximum allowed length is 256 characters")
);
assert!(err.to_string().contains("The TagValue you have provided is invalid"));
}
#[test]
@@ -127,4 +203,122 @@ mod tests {
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
assert!(err.to_string().contains("Cannot provide multiple Tags with the same key"));
}
#[test]
fn test_validate_object_tag_set_accepts_maximum_lengths_and_allowed_characters() {
let key = "𐐀".repeat(64);
let value = format!("{}=", "v".repeat(255));
assert!(validate_object_tag_set(&[tag(Some(&key), Some(&value))]).is_ok());
}
#[test]
fn test_validate_object_tag_set_counts_utf16_code_units() {
let too_long_key = "𐐀".repeat(65);
let err = validate_object_tag_set(&[tag(Some(&too_long_key), Some("value"))])
.expect_err("a key longer than 128 UTF-16 code units must be rejected");
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
}
#[test]
fn test_validate_object_tag_set_rejects_unsupported_characters() {
let key_err = validate_object_tag_set(&[tag(Some("invalid?key"), Some("value"))]).expect_err("unsupported key character");
assert_eq!(*key_err.code(), S3ErrorCode::InvalidTag);
let value_err =
validate_object_tag_set(&[tag(Some("key"), Some("invalid&value"))]).expect_err("unsupported value character");
assert_eq!(*value_err.code(), S3ErrorCode::InvalidTag);
let combining_mark_err = validate_object_tag_set(&[tag(Some("invalid\u{0345}key"), Some("value"))])
.expect_err("a combining mark outside the S3 letter/number/separator categories must be rejected");
assert_eq!(*combining_mark_err.code(), S3ErrorCode::InvalidTag);
}
#[test]
fn test_parse_copy_object_tags_accepts_encoded_tag_set() {
let tags =
parse_copy_object_tags("project=rustfs&label=copy%20test").expect("valid URL-encoded CopyObject tags should parse");
assert_eq!(tags, "project=rustfs&label=copy+test");
}
#[test]
fn test_parse_copy_object_tags_accepts_empty_replacement() {
let tags = parse_copy_object_tags("").expect("an empty replacement tag set should parse");
assert!(tags.is_empty());
}
#[test]
fn test_parse_copy_object_tags_rejects_invalid_percent_encoding() {
let err = parse_copy_object_tags("project=rustfs%ZZ")
.expect_err("invalid percent encoding must be rejected before CopyObject writes");
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
}
#[test]
fn test_parse_copy_object_tags_rejects_duplicate_keys() {
let err = parse_copy_object_tags("project=rustfs&project=cli").expect_err("duplicate tag keys must be rejected");
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
}
#[test]
fn test_parse_copy_object_tags_stops_at_object_tag_limit() {
let tagging = (0..11)
.map(|index| format!("k{index}=v{index}"))
.collect::<Vec<_>>()
.join("&");
let err = parse_copy_object_tags(&tagging).expect_err("the eleventh object tag must be rejected");
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
assert!(err.to_string().contains("Cannot have more than 10 tags per object"));
}
#[test]
fn test_parse_copy_object_tags_rejects_control_whitespace() {
let err = parse_copy_object_tags("project=line%0Abreak").expect_err("control whitespace is not valid in S3 object tags");
assert_eq!(*err.code(), S3ErrorCode::InvalidTag);
}
#[test]
fn test_resolve_copy_object_tags_preserves_source_for_default_and_copy() {
assert_eq!(
resolve_copy_object_tags(None, None).expect("default directive should preserve source tags"),
None
);
let copy = TaggingDirective::from_static(TaggingDirective::COPY);
assert_eq!(
resolve_copy_object_tags(None, Some(&copy)).expect("COPY directive should preserve source tags"),
None
);
}
#[test]
fn test_resolve_copy_object_tags_replaces_with_exact_requested_set() {
let replace = TaggingDirective::from_static(TaggingDirective::REPLACE);
assert_eq!(
resolve_copy_object_tags(Some("project=rustfs"), Some(&replace))
.expect("REPLACE directive should accept a valid tag set"),
Some("project=rustfs".to_string())
);
assert_eq!(
resolve_copy_object_tags(None, Some(&replace)).expect("REPLACE without Tagging should clear tags"),
Some(String::new())
);
}
#[test]
fn test_resolve_copy_object_tags_rejects_discarded_or_unknown_requests() {
let err = resolve_copy_object_tags(Some("project=rustfs"), None)
.expect_err("a Tagging header without REPLACE must not be silently discarded");
assert_eq!(*err.code(), S3ErrorCode::InvalidRequest);
let unknown = TaggingDirective::from_static("UNKNOWN");
let err = resolve_copy_object_tags(None, Some(&unknown)).expect_err("an unknown tagging directive must fail");
assert_eq!(*err.code(), S3ErrorCode::InvalidArgument);
}
}
+4
View File
@@ -311,6 +311,10 @@ pub(crate) mod s3_api_consumer {
super::super::to_s3s_etag(etag)
}
}
pub(crate) mod tagging {
pub(crate) use super::super::super::s3_api::tagging::resolve_copy_object_tags;
}
}
pub(crate) mod sse_consumer {