mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-03 20:20:29 +00:00
fix(s3): return stored content language on local GET (#7755)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user