diff --git a/crates/e2e_test/src/copy_object_metadata_test.rs b/crates/e2e_test/src/copy_object_metadata_test.rs index 289c2abc2..ac416c750 100644 --- a/crates/e2e_test/src/copy_object_metadata_test.rs +++ b/crates/e2e_test/src/copy_object_metadata_test.rs @@ -18,10 +18,14 @@ mod tests { use crate::common::{RustFSTestEnvironment, init_logging}; use aws_sdk_s3::error::ProvideErrorMetadata; + use aws_sdk_s3::presigning::PresigningConfig; use aws_sdk_s3::primitives::{ByteStream, DateTime, DateTimeFormat}; use aws_sdk_s3::types::{ BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration, }; + use flate2::{Compression, read::GzDecoder, write::GzEncoder}; + use std::io::{Read, Write}; + use std::time::Duration; use tracing::info; #[tokio::test] @@ -108,6 +112,24 @@ mod tests { copied_head.metadata().and_then(|metadata| metadata.get("stale")), Some(&"must-be-removed".to_string()) ); + let copied_get = client + .get_object() + .bucket(bucket) + .key(copied_key) + .send() + .await + .expect("GET failed after default copy"); + assert_eq!(copied_get.content_language(), Some("en-US")); + assert_eq!( + copied_get + .body + .collect() + .await + .expect("Failed to collect copied body") + .into_bytes() + .as_ref(), + content, + ); client .copy_object() @@ -241,6 +263,7 @@ mod tests { .send() .await .expect("GET failed after self-copy"); + assert_eq!(get_resp.content_language(), Some("fr-FR")); let body = get_resp .body .collect() @@ -286,6 +309,7 @@ mod tests { .send() .await .expect("GET failed after empty metadata replacement"); + assert_eq!(empty_get_resp.content_language(), None); let empty_body = empty_get_resp .body .collect() @@ -522,9 +546,143 @@ mod tests { multipart_body ); + assert_versioned_content_language_round_trip(&client, bucket).await; env.stop_server(); } + async fn assert_versioned_content_language_round_trip(client: &aws_sdk_s3::Client, bucket: &str) { + let http = reqwest::Client::builder() + .no_proxy() + .no_gzip() + .build() + .expect("Failed to build HTTP client without gzip decoding"); + let original = b"Content-Language must follow the requested object version."; + for encoding in [None, Some("gzip")] { + let key = format!("language-{}.bin", encoding.unwrap_or("plain")); + let payload = if encoding.is_some() { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(original).expect("Failed to encode gzip fixture"); + encoder.finish().expect("Failed to finish gzip fixture") + } else { + original.to_vec() + }; + let mut versions = Vec::new(); + for language in [Some("zh-CN"), None, Some("en-US")] { + let put = client + .put_object() + .bucket(bucket) + .key(&key) + .content_type("application/octet-stream") + .set_content_encoding(encoding.map(str::to_string)) + .set_content_language(language.map(str::to_string)) + .metadata("content-language", "user-language") + .body(ByteStream::from(payload.clone())) + .send() + .await + .expect("Failed to write versioned language fixture"); + versions.push((put.version_id().expect("PUT must return a version ID").to_string(), language)); + } + + let current_version = &versions.last().expect("Fixture has three versions").0; + let reads = std::iter::once((None, Some("en-US"))) + .chain(versions.iter().map(|(version, language)| (Some(version.as_str()), *language))); + for (version, language) in reads { + let expected_version = version.unwrap_or(current_version); + let head = client + .head_object() + .bucket(bucket) + .key(&key) + .set_version_id(version.map(str::to_string)) + .send() + .await + .expect("HEAD failed for language fixture"); + assert_eq!(head.content_language(), language); + assert_eq!(head.version_id(), Some(expected_version)); + + for range in [None, Some("bytes=1-7")] { + let expected_body = if range.is_some() { &payload[1..8] } else { payload.as_slice() }; + let content_range = range.map(|_| format!("bytes 1-7/{}", payload.len())); + let request = client + .get_object() + .bucket(bucket) + .key(&key) + .set_version_id(version.map(str::to_string)) + .set_range(range.map(str::to_string)); + let get = request.clone().send().await.expect("GET failed for language fixture"); + assert_eq!(get.content_language(), language); + assert_eq!(get.content_type(), Some("application/octet-stream")); + assert_eq!(get.content_encoding(), encoding); + assert_eq!( + get.content_length(), + Some(i64::try_from(expected_body.len()).expect("Fixture length fits i64")) + ); + assert_eq!(get.content_range(), content_range.as_deref()); + assert_eq!(get.version_id(), Some(expected_version)); + assert_eq!(get.e_tag(), head.e_tag()); + assert_eq!( + get.metadata() + .and_then(|metadata| metadata.get("content-language")) + .map(String::as_str), + Some("user-language") + ); + assert_eq!( + get.body + .collect() + .await + .expect("Failed to collect GET body") + .into_bytes() + .as_ref(), + expected_body + ); + + for override_language in [None, Some("fr-FR")] { + let presigned = request + .clone() + .set_response_content_language(override_language.map(str::to_string)) + .presigned(PresigningConfig::expires_in(Duration::from_secs(300)).expect("Valid signature lifetime")) + .await + .expect("Failed to presign language GET"); + let mut raw_request = http.get(presigned.uri()); + for (name, value) in presigned.headers() { + raw_request = raw_request.header(name, value); + } + let response = raw_request.send().await.expect("Presigned GET failed"); + let status = if range.is_some() { + reqwest::StatusCode::PARTIAL_CONTENT + } else { + reqwest::StatusCode::OK + }; + assert_eq!(response.status(), status); + let header = |name: &str| { + response + .headers() + .get(name) + .map(|value| value.to_str().expect("ASCII response header")) + }; + assert_eq!(header("content-language"), override_language.or(language)); + assert_eq!(header("x-amz-meta-content-language"), Some("user-language")); + assert_eq!(header("content-encoding"), encoding); + assert_eq!(header("content-range"), content_range.as_deref()); + assert_eq!(header("x-amz-version-id"), Some(expected_version)); + assert_eq!( + response.content_length(), + Some(u64::try_from(expected_body.len()).expect("Fixture length fits u64")) + ); + let body = response.bytes().await.expect("Failed to read wire body"); + assert_eq!(body.as_ref(), expected_body); + if encoding.is_some() && range.is_none() { + let mut decoded = Vec::new(); + GzDecoder::new(body.as_ref()) + .read_to_end(&mut decoded) + .expect("GET body must contain actual gzip bytes"); + assert_eq!(decoded, original); + } + } + } + } + } + } + #[tokio::test] async fn invalid_replacement_metadata_does_not_mutate_destination() { init_logging(); diff --git a/rustfs/src/app/object/get.rs b/rustfs/src/app/object/get.rs index 85e11f35b..24aaf950d 100644 --- a/rustfs/src/app/object/get.rs +++ b/rustfs/src/app/object/get.rs @@ -3703,6 +3703,7 @@ impl DefaultObjectUsecase { last_modified, content_type, content_encoding: info.content_encoding.clone(), + content_language: info.user_defined.get("content-language").cloned(), cache_control, content_disposition, content_range, @@ -10568,77 +10569,101 @@ mod tests { #[tokio::test] async fn build_get_object_output_context_returns_standard_headers() { - let mut metadata = HashMap::new(); - metadata.insert("cache-control".to_string(), "public, max-age=259200".to_string()); - metadata.insert("content-disposition".to_string(), "attachment; filename=\"demo.png\"".to_string()); + for (content_language, user_language) in [ + (Some("zh-CN"), None), + (None, None), + (Some(""), None), + (Some("zh-CN"), Some("fr-FR")), + (None, Some("fr-FR")), + ] { + let mut metadata = HashMap::new(); + metadata.insert("cache-control".to_string(), "public, max-age=259200".to_string()); + metadata.insert("content-disposition".to_string(), "attachment; filename=\"demo.png\"".to_string()); + if let Some(language) = content_language { + metadata.insert("content-language".to_string(), language.to_string()); + } + if let Some(language) = user_language { + metadata.insert("x-amz-meta-content-language".to_string(), language.to_string()); + } - let info = ObjectInfo { - bucket: "test-bucket".to_string(), - name: "path/raw".to_string(), - user_defined: Arc::new(metadata), - ..Default::default() - }; + let info = ObjectInfo { + bucket: "test-bucket".to_string(), + name: "path/raw".to_string(), + user_defined: Arc::new(metadata), + ..Default::default() + }; - let input = GetObjectInput::builder() - .bucket("test-bucket".to_string()) - .key("path/raw".to_string()) - .build() - .unwrap(); - let req = build_request(input, Method::GET); - let usecase = DefaultObjectUsecase::without_context(); - let queue_status = concurrency::IoQueueStatus::default(); + let input = GetObjectInput::builder() + .bucket("test-bucket".to_string()) + .key("path/raw".to_string()) + .build() + .unwrap(); + let req = build_request(input, Method::GET); + let usecase = DefaultObjectUsecase::without_context(); + let queue_status = concurrency::IoQueueStatus::default(); - let context = usecase - .build_get_object_output_context( - &req, - get_concurrency_manager(), - "test-bucket", - "path/raw", - info.clone(), - Some(info), - wrap_reader(tokio::io::empty()), - Some(Bytes::new()), - false, - false, - true, - None, - None, - None, - 0, - None, - "req-output-content-disposition", - None, - None, - None, - None, - false, - Duration::ZERO, - 0.0, - &queue_status, - 1, - None, - GetObjectBodyLifecycle::disabled(), - |_| panic!("a buffered output must not initialize streaming resume state"), - ) - .await - .expect("get object output context"); + let context = usecase + .build_get_object_output_context( + &req, + get_concurrency_manager(), + "test-bucket", + "path/raw", + info.clone(), + Some(info), + wrap_reader(tokio::io::empty()), + Some(Bytes::new()), + false, + false, + true, + None, + None, + None, + 0, + None, + "req-output-content-disposition", + None, + None, + None, + None, + false, + Duration::ZERO, + 0.0, + &queue_status, + 1, + None, + GetObjectBodyLifecycle::disabled(), + |_| panic!("a buffered output must not initialize streaming resume state"), + ) + .await + .expect("get object output context"); - assert_eq!(context.output.cache_control.as_deref(), Some("public, max-age=259200")); - assert_eq!(context.output.content_disposition.as_deref(), Some("attachment; filename=\"demo.png\"")); - assert!( - !context - .output - .metadata - .as_ref() - .is_some_and(|metadata| metadata.contains_key("cache-control")) - ); - assert!( - !context - .output - .metadata - .as_ref() - .is_some_and(|metadata| metadata.contains_key("content-disposition")) - ); + assert_eq!(context.output.cache_control.as_deref(), Some("public, max-age=259200")); + assert_eq!(context.output.content_disposition.as_deref(), Some("attachment; filename=\"demo.png\"")); + assert_eq!(context.output.content_language.as_deref(), content_language); + assert_eq!( + context + .output + .metadata + .as_ref() + .and_then(|metadata| metadata.get("content-language")) + .map(String::as_str), + user_language, + ); + assert!( + !context + .output + .metadata + .as_ref() + .is_some_and(|metadata| metadata.contains_key("cache-control")) + ); + assert!( + !context + .output + .metadata + .as_ref() + .is_some_and(|metadata| metadata.contains_key("content-disposition")) + ); + } } #[tokio::test]