From 624463c5bab0693bcef13fd1c63c40f7fc85edbf Mon Sep 17 00:00:00 2001 From: Tyler Hillery Date: Wed, 23 Sep 2026 13:04:05 -0500 Subject: [PATCH] fix(s3): default CopyObject content-type to binary/octet-stream on REPLACE (#8073) fix: default CopyObject content-type to binary/octet-stream on REPLACE --- .../e2e_test/src/copy_object_metadata_test.rs | 22 ++++++++++++++++--- rustfs/src/app/object/copy.rs | 11 ++++++++-- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/crates/e2e_test/src/copy_object_metadata_test.rs b/crates/e2e_test/src/copy_object_metadata_test.rs index ac416c750..e11082417 100644 --- a/crates/e2e_test/src/copy_object_metadata_test.rs +++ b/crates/e2e_test/src/copy_object_metadata_test.rs @@ -298,7 +298,11 @@ mod tests { assert_eq!(empty_head_resp.content_disposition(), None); assert_eq!(empty_head_resp.content_encoding(), None); assert_eq!(empty_head_resp.content_language(), None); - assert_eq!(empty_head_resp.content_type(), None); + assert_eq!( + empty_head_resp.content_type(), + Some("binary/octet-stream"), + "REPLACE without an explicit content type must default to binary/octet-stream, matching S3" + ); assert_eq!(empty_head_resp.expires_string(), None); assert_eq!(empty_head_resp.website_redirect_location(), None); @@ -394,7 +398,15 @@ mod tests { assert_eq!(head.content_disposition(), (field == "content-disposition").then_some("attachment")); assert_eq!(head.content_encoding(), (field == "content-encoding").then_some("gzip")); assert_eq!(head.content_language(), (field == "content-language").then_some("de")); - assert_eq!(head.content_type(), (field == "content-type").then_some("text/field")); + assert_eq!( + head.content_type(), + Some(if field == "content-type" { + "text/field" + } else { + "binary/octet-stream" + }), + "REPLACE without an explicit content type must default to binary/octet-stream, matching S3" + ); assert_eq!( head.expires_string(), (field == "expires").then_some(replacement_expires_http_date.as_str()) @@ -420,7 +432,11 @@ mod tests { .send() .await .expect("HEAD failed for metadata collision case"); - assert_eq!(collision_head.content_type(), None); + assert_eq!( + collision_head.content_type(), + Some("binary/octet-stream"), + "user-metadata under the reserved 'content-type' key must not satisfy the system content-type default" + ); assert_eq!(collision_head.content_encoding(), None); assert_eq!( collision_head.metadata().and_then(|metadata| metadata.get("content-type")), diff --git a/rustfs/src/app/object/copy.rs b/rustfs/src/app/object/copy.rs index 614fc1b85..4ce0774ff 100644 --- a/rustfs/src/app/object/copy.rs +++ b/rustfs/src/app/object/copy.rs @@ -607,9 +607,16 @@ 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 let Some(replacement_metadata) = replacement_metadata { + if let Some(mut replacement_metadata) = replacement_metadata { + // S3 defaults an object's content type to `binary/octet-stream` when none is + // supplied. REPLACE must apply that same default instead of leaving + // content-type unset. + if !replacement_metadata.contains_key("content-type") { + replacement_metadata.insert("content-type".to_string(), "binary/octet-stream".to_string()); + } + let effective_content_type = replacement_metadata.get("content-type").cloned(); user_defined = replacement_metadata; - src_info.content_type = content_type.clone(); + src_info.content_type = effective_content_type; src_info.content_encoding = content_encoding.as_deref().and_then(normalize_content_encoding_for_storage); src_info.expires = expires_timestamp.map(OffsetDateTime::from); } else if metadata_directive.is_some() || website_redirect_location.is_some() {