fix(s3): return stored content language on local GET (#7755)

This commit is contained in:
cxymds
2026-09-14 08:38:19 +08:00
committed by GitHub
parent 5dd92bd783
commit cb3efc4a98
2 changed files with 250 additions and 67 deletions
@@ -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();
+92 -67
View File
@@ -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]