fix(s3): honor CopyObject replacement metadata (#5168)

Fixes rustfs/backlog#1463.
This commit is contained in:
cxymds
2026-07-24 12:33:33 +08:00
committed by GitHub
parent 36e97aba26
commit cb344a3c77
6 changed files with 692 additions and 56 deletions
+129 -42
View File
@@ -82,8 +82,8 @@ use super::storage_api::object_usecase::object_cache::{GetObjectBodyCacheHookLoo
use super::storage_api::object_usecase::object_utils::to_s3s_etag;
use super::storage_api::object_usecase::options::{
copy_dst_opts, copy_src_opts, del_opts, extract_metadata, extract_metadata_from_mime_with_object_name,
filter_object_metadata, get_content_sha256_with_query, get_opts, normalize_content_encoding_for_storage, put_opts,
validate_archive_content_encoding,
filter_object_metadata, get_content_sha256_with_query, get_opts, namespace_reserved_user_metadata,
normalize_content_encoding_for_storage, put_opts, validate_archive_content_encoding,
};
use super::storage_api::object_usecase::request_context::{self, spawn_traced};
use super::storage_api::object_usecase::s3_api::multipart::parse_list_parts_params;
@@ -2695,6 +2695,52 @@ where
Ok(())
}
fn insert_expires_metadata(metadata: &mut HashMap<String, String>, expires: Option<&Timestamp>) -> S3Result<()> {
if let Some(expires) = expires {
let mut formatted = Vec::new();
expires
.format(TimestampFormat::HttpDate, &mut formatted)
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid expires timestamp: {e}"))))?;
metadata.insert("expires".to_string(), String::from_utf8_lossy(&formatted).into_owned());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn apply_standard_object_metadata(
metadata: &mut HashMap<String, String>,
cache_control: Option<&str>,
content_disposition: Option<&str>,
content_encoding: Option<&str>,
content_language: Option<&str>,
content_type: Option<&str>,
expires: Option<&Timestamp>,
website_redirect_location: Option<&str>,
) -> S3Result<()> {
if let Some(cache_control) = cache_control {
metadata.insert("cache-control".to_string(), cache_control.to_string());
}
if let Some(content_disposition) = content_disposition {
metadata.insert("content-disposition".to_string(), content_disposition.to_string());
}
if let Some(content_encoding) = content_encoding
&& let Some(normalized_content_encoding) = normalize_content_encoding_for_storage(content_encoding)
{
metadata.insert("content-encoding".to_string(), normalized_content_encoding);
}
if let Some(content_language) = content_language {
metadata.insert("content-language".to_string(), content_language.to_string());
}
if let Some(content_type) = content_type {
metadata.insert("content-type".to_string(), content_type.to_string());
}
insert_expires_metadata(metadata, expires)?;
if let Some(website_redirect_location) = website_redirect_location {
metadata.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location.to_string());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn apply_put_request_metadata(
metadata: &mut HashMap<String, String>,
@@ -2710,33 +2756,16 @@ fn apply_put_request_metadata(
tagging: Option<TaggingHeader>,
storage_class: Option<StorageClass>,
) -> S3Result<()> {
if let Some(cache_control) = cache_control {
metadata.insert("cache-control".to_string(), cache_control);
}
if let Some(content_disposition) = content_disposition {
metadata.insert("content-disposition".to_string(), content_disposition);
}
if let Some(content_encoding) = content_encoding
&& let Some(normalized_content_encoding) = normalize_content_encoding_for_storage(&content_encoding)
{
metadata.insert("content-encoding".to_string(), normalized_content_encoding);
}
if let Some(content_language) = content_language {
metadata.insert("content-language".to_string(), content_language);
}
if let Some(content_type) = content_type {
metadata.insert("content-type".to_string(), content_type);
}
if let Some(expires) = expires {
let mut formatted = Vec::new();
expires
.format(TimestampFormat::HttpDate, &mut formatted)
.map_err(|e| ApiError::from(StorageError::other(format!("Invalid expires timestamp: {e}"))))?;
metadata.insert("expires".to_string(), String::from_utf8_lossy(&formatted).into_owned());
}
if let Some(website_redirect_location) = website_redirect_location {
metadata.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location);
}
apply_standard_object_metadata(
metadata,
cache_control.as_deref(),
content_disposition.as_deref(),
content_encoding.as_deref(),
content_language.as_deref(),
content_type.as_deref(),
expires.as_ref(),
website_redirect_location.as_deref(),
)?;
if let Some(tags) = tagging {
metadata.insert(AMZ_OBJECT_TAGGING.to_owned(), tags);
}
@@ -5740,7 +5769,13 @@ impl DefaultObjectUsecase {
tagging_directive,
copy_source_if_match,
copy_source_if_none_match,
cache_control,
content_disposition,
content_encoding,
content_language,
content_type,
expires,
website_redirect_location,
object_lock_legal_hold_status,
object_lock_mode,
object_lock_retain_until_date,
@@ -5784,6 +5819,47 @@ impl DefaultObjectUsecase {
validate_object_key(&src_key, "COPY (source)")?;
validate_object_key(&key, "COPY (dest)")?;
validate_table_catalog_object_mutation(&bucket, &key).await?;
let replaces_metadata = match metadata_directive.as_ref().map(|directive| directive.as_str()) {
None | Some(MetadataDirective::COPY) => false,
Some(MetadataDirective::REPLACE) => true,
Some(_) => {
return Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
"The MetadataDirective header is invalid".to_string(),
));
}
};
let has_replacement_metadata = metadata.is_some()
|| cache_control.is_some()
|| content_disposition.is_some()
|| content_encoding.is_some()
|| content_language.is_some()
|| content_type.is_some()
|| expires.is_some();
if has_replacement_metadata && !replaces_metadata {
return Err(S3Error::with_message(
S3ErrorCode::InvalidRequest,
"Replacement metadata requires the REPLACE metadata directive".to_string(),
));
}
let replacement_metadata = if replaces_metadata {
validate_archive_content_encoding(&key, content_type.as_deref(), content_encoding.as_deref())?;
let mut replacement_metadata = metadata.unwrap_or_default();
namespace_reserved_user_metadata(&mut replacement_metadata);
apply_standard_object_metadata(
&mut replacement_metadata,
cache_control.as_deref(),
content_disposition.as_deref(),
content_encoding.as_deref(),
content_language.as_deref(),
content_type.as_deref(),
expires.as_ref(),
website_redirect_location.as_deref(),
)?;
Some(replacement_metadata)
} else {
None
};
// AWS S3 allows self-copy when metadata directive is REPLACE (used to update metadata in-place),
// when an explicit storage class change is requested, or when restoring a specific historical
@@ -5794,7 +5870,7 @@ impl DefaultObjectUsecase {
tagging_directive.as_ref(),
)?;
if metadata_directive.as_ref().map(|d| d.as_str()) != Some(MetadataDirective::REPLACE)
if !replaces_metadata
&& tagging_directive.as_ref().map(TaggingDirective::as_str) != Some(TaggingDirective::REPLACE)
&& storage_class.is_none()
&& version_id.is_none()
@@ -5949,13 +6025,18 @@ 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());
if !replaces_metadata {
let source_expires = src_info.expires.map(Timestamp::from);
insert_expires_metadata(&mut user_defined, source_expires.as_ref())?;
}
strip_managed_encryption_metadata(&mut user_defined);
if let Some(ref sc) = storage_class {
src_info.storage_class = Some(sc.as_str().to_string());
user_defined.insert(AMZ_STORAGE_CLASS.to_string(), sc.as_str().to_string());
}
let destination_storage_class = storage_class
.as_ref()
.map(StorageClass::as_str)
.unwrap_or(storageclass::STANDARD);
src_info.storage_class = Some(destination_storage_class.to_string());
let actual_size = src_info.get_actual_size().map_err(ApiError::from)?;
@@ -5981,17 +6062,23 @@ impl DefaultObjectUsecase {
// Handle MetadataDirective REPLACE: replace user metadata while preserving system metadata.
// System metadata (compression, encryption) is added after this block to ensure
// it's not cleared by the REPLACE operation.
if metadata_directive.as_ref().map(|d| d.as_str()) == Some(MetadataDirective::REPLACE) {
user_defined.clear();
if let Some(metadata) = metadata {
user_defined.extend(metadata);
}
if let Some(ct) = content_type {
src_info.content_type = Some(ct.clone());
user_defined.insert("content-type".to_string(), ct);
if let Some(replacement_metadata) = replacement_metadata {
user_defined = replacement_metadata;
src_info.content_type = content_type.clone();
src_info.content_encoding = content_encoding.as_deref().and_then(normalize_content_encoding_for_storage);
src_info.expires = expires.map(OffsetDateTime::from);
} else if metadata_directive.is_some() || website_redirect_location.is_some() {
user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_WEBSITE_REDIRECT_LOCATION));
if let Some(website_redirect_location) = website_redirect_location {
user_defined.insert(AMZ_WEBSITE_REDIRECT_LOCATION.to_string(), website_redirect_location);
}
}
user_defined.retain(|key, _| !key.eq_ignore_ascii_case(AMZ_STORAGE_CLASS));
if destination_storage_class != storageclass::STANDARD {
user_defined.insert(AMZ_STORAGE_CLASS.to_string(), destination_storage_class.to_string());
}
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());
+2 -2
View File
@@ -875,8 +875,8 @@ pub(crate) mod options {
pub(crate) use crate::storage::storage_api::options_consumer::{
copy_dst_opts, copy_src_opts, del_opts, extract_metadata, extract_metadata_from_mime,
extract_metadata_from_mime_with_object_name, filter_object_metadata, get_complete_multipart_upload_opts,
get_content_sha256_with_query, get_opts, normalize_content_encoding_for_storage, parse_copy_source_range, put_opts,
validate_archive_content_encoding,
get_content_sha256_with_query, get_opts, namespace_reserved_user_metadata, normalize_content_encoding_for_storage,
parse_copy_source_range, put_opts, validate_archive_content_encoding,
};
}
+87 -4
View File
@@ -483,6 +483,31 @@ fn archive_content_encoding_strict_mode() -> bool {
rustfs_utils::get_env_bool(ENV_REJECT_ARCHIVE_CONTENT_ENCODING, false)
}
const USER_METADATA_PREFIXES: &[&str] = &["x-amz-meta-", "x-rustfs-meta-", "x-minio-meta-"];
const CANONICAL_USER_METADATA_PREFIX: &str = "x-amz-meta-";
fn is_reserved_user_metadata_key(key: &str) -> bool {
SUPPORTED_HEADERS.iter().any(|header| key.eq_ignore_ascii_case(header))
|| starts_with_ignore_ascii_case(key, "x-amz-")
|| starts_with_ignore_ascii_case(key, RUSTFS_INTERNAL_PREFIX)
|| starts_with_ignore_ascii_case(key, MINIO_INTERNAL_PREFIX)
}
fn stored_user_metadata_key(key: &str) -> String {
if is_reserved_user_metadata_key(key) {
format!("{CANONICAL_USER_METADATA_PREFIX}{key}")
} else {
key.to_owned()
}
}
pub(crate) fn namespace_reserved_user_metadata(metadata: &mut HashMap<String, String>) {
*metadata = std::mem::take(metadata)
.into_iter()
.map(|(key, value)| (stored_user_metadata_key(&key), value))
.collect();
}
/// Extracts metadata from headers and returns it as a HashMap with object name for MIME type detection.
pub fn extract_metadata_from_mime_with_object_name(
headers: &HeaderMap<HeaderValue>,
@@ -490,8 +515,6 @@ pub fn extract_metadata_from_mime_with_object_name(
skip_content_type: bool,
object_name: Option<&str>,
) {
const USER_METADATA_PREFIXES: &[&str] = &["x-amz-meta-", "x-rustfs-meta-", "x-minio-meta-"];
for (k, v) in headers.iter() {
if k.as_str() == "content-type" && skip_content_type {
continue;
@@ -505,7 +528,7 @@ pub fn extract_metadata_from_mime_with_object_name(
continue;
}
metadata.insert(key.to_owned(), String::from_utf8_lossy(v.as_bytes()).to_string());
metadata.insert(stored_user_metadata_key(key), String::from_utf8_lossy(v.as_bytes()).to_string());
continue;
}
@@ -599,6 +622,26 @@ pub(crate) fn filter_object_metadata(metadata: &HashMap<String, String>) -> Opti
let mut filtered_metadata = None;
for (k, v) in metadata {
if starts_with_ignore_ascii_case(k, "x-amz-meta-internal-")
|| k.eq_ignore_ascii_case(AMZ_META_UNENCRYPTED_CONTENT_MD5)
|| k.eq_ignore_ascii_case(AMZ_META_UNENCRYPTED_CONTENT_LENGTH)
{
continue;
}
if let Some(key) = USER_METADATA_PREFIXES.iter().find_map(|prefix| {
k.get(..prefix.len())
.filter(|head| head.eq_ignore_ascii_case(prefix))
.map(|_| &k[prefix.len()..])
}) {
if !key.is_empty() {
filtered_metadata
.get_or_insert_with(HashMap::new)
.insert(key.to_owned(), v.clone());
}
continue;
}
if should_skip_object_metadata_key(k, v, EXCLUDED_HEADERS) {
continue;
}
@@ -857,7 +900,8 @@ mod tests {
ENV_REJECT_ARCHIVE_CONTENT_ENCODING, ReplicationStatusType, SUPPORTED_HEADERS, copy_dst_opts, copy_src_opts, del_opts,
detect_content_type_from_object_name, extract_metadata, extract_metadata_from_mime,
extract_metadata_from_mime_with_object_name, filter_object_metadata, get_complete_multipart_upload_opts,
get_default_opts, get_opts, parse_copy_source_range, put_opts, put_opts_from_headers, validate_archive_content_encoding,
get_default_opts, get_opts, namespace_reserved_user_metadata, parse_copy_source_range, put_opts, put_opts_from_headers,
validate_archive_content_encoding,
};
use http::{HeaderMap, HeaderValue};
use rustfs_utils::http::{
@@ -1573,6 +1617,45 @@ mod tests {
assert_eq!(filtered.get("custom-key"), Some(&"custom-value".to_string()));
}
#[test]
fn test_user_metadata_cannot_shadow_standard_or_internal_metadata() {
let mut headers = HeaderMap::new();
headers.insert("content-encoding", HeaderValue::from_static("br"));
headers.insert("x-amz-meta-content-encoding", HeaderValue::from_static("user-encoding"));
headers.insert("x-amz-meta-x-rustfs-internal-healing", HeaderValue::from_static("user-value"));
let mut metadata = HashMap::new();
extract_metadata_from_mime(&headers, &mut metadata);
assert_eq!(metadata.get("content-encoding"), Some(&"br".to_string()));
assert_eq!(metadata.get("x-amz-meta-content-encoding"), Some(&"user-encoding".to_string()));
assert_eq!(metadata.get("x-amz-meta-x-rustfs-internal-healing"), Some(&"user-value".to_string()));
let filtered = filter_object_metadata(&metadata).expect("user metadata should remain");
assert_eq!(filtered.get("content-encoding"), Some(&"user-encoding".to_string()));
assert_eq!(filtered.get("x-rustfs-internal-healing"), Some(&"user-value".to_string()));
let mut copied_metadata = HashMap::from([
("content-type".to_string(), "user-type".to_string()),
("x-amz-meta-content-type".to_string(), "nested-user-type".to_string()),
("x-amz-storage-class".to_string(), "user-class".to_string()),
]);
namespace_reserved_user_metadata(&mut copied_metadata);
assert_eq!(copied_metadata.get("x-amz-meta-content-type"), Some(&"user-type".to_string()));
assert_eq!(
copied_metadata.get("x-amz-meta-x-amz-meta-content-type"),
Some(&"nested-user-type".to_string())
);
assert_eq!(copied_metadata.get("x-amz-meta-x-amz-storage-class"), Some(&"user-class".to_string()));
let legacy_metadata = HashMap::from([
("x-amz-meta-internal-secret".to_string(), "must-not-leak".to_string()),
("x-amz-meta-x-amz-unencrypted-content-md5".to_string(), "must-not-leak".to_string()),
("x-amz-meta-project".to_string(), "rustfs".to_string()),
]);
let filtered_legacy = filter_object_metadata(&legacy_metadata).expect("safe user metadata should remain");
assert_eq!(filtered_legacy, HashMap::from([("project".to_string(), "rustfs".to_string())]));
}
#[test]
fn test_detect_content_type_from_object_name() {
// Test Parquet files (our custom handling)
+2 -2
View File
@@ -183,8 +183,8 @@ pub(crate) mod options_consumer {
pub(crate) use super::super::options::{
copy_dst_opts, copy_src_opts, del_opts, extract_metadata, extract_metadata_from_mime,
extract_metadata_from_mime_with_object_name, filter_object_metadata, get_complete_multipart_upload_opts,
get_content_sha256_with_query, get_opts, normalize_content_encoding_for_storage, parse_copy_source_range, put_opts,
validate_archive_content_encoding,
get_content_sha256_with_query, get_opts, namespace_reserved_user_metadata, normalize_content_encoding_for_storage,
parse_copy_source_range, put_opts, validate_archive_content_encoding,
};
pub(crate) mod contract {