mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-27 08:38:58 +00:00
fix(s3): return x-amz-copy-source-version-id for versioned CopyObject source (#4985)
For a CopyObject whose source carries versioning, the response must echo the exact source version copied via x-amz-copy-source-version-id (SDK CopySourceVersionId), kept distinct from the newly created destination x-amz-version-id. RustFS set the destination version_id but left copy_source_version_id unset, so AWS SDK clients could not prove which source version was copied — the same field UploadPartCopy already populates. Populate CopyObjectOutput.copy_source_version_id from the version actually read from the source, gated on the source bucket carrying versioning (enabled or suspended) and rendering the null version as "null", mirroring the GET/HEAD convention. Add an e2e regression test that copies a non-latest source version and asserts the source-version header equals the requested version, the destination header is a distinct new version, the destination bytes/size match the copied version, and the source version remains present. Fixes #4976
This commit is contained in:
@@ -160,4 +160,146 @@ mod tests {
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
|
||||
/// Regression test for Issue #4976: a versioned-source CopyObject must echo the exact source
|
||||
/// version copied via `x-amz-copy-source-version-id` (SDK `CopySourceVersionId`), kept distinct
|
||||
/// from the newly created destination `x-amz-version-id`.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_copy_of_non_latest_source_version_returns_copy_source_version_id() {
|
||||
init_logging();
|
||||
info!("Issue #4976: versioned CopyObject must return x-amz-copy-source-version-id for the exact source version");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = env.create_s3_client();
|
||||
let src_bucket = "copy-source-version-header-src";
|
||||
let dst_bucket = "copy-source-version-header-dst";
|
||||
let src_key = "reports/quarantined.bin";
|
||||
let dst_key = "reports/promoted.bin";
|
||||
|
||||
for bucket in [src_bucket, dst_bucket] {
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create bucket");
|
||||
client
|
||||
.put_bucket_versioning()
|
||||
.bucket(bucket)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to enable versioning");
|
||||
}
|
||||
|
||||
// Source version 1: the exact (non-latest) version we will copy.
|
||||
let v1_content = b"quarantined payload -- source version one (target of the copy)";
|
||||
let put_v1 = client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(v1_content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source v1 failed");
|
||||
let v1_id = put_v1.version_id().expect("source v1 must have a version id").to_string();
|
||||
|
||||
// Source version 2: becomes the latest, so v1 is deliberately NOT the current version.
|
||||
let v2_content = b"quarantined payload -- source version two (now current, must be ignored)";
|
||||
let put_v2 = client
|
||||
.put_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.body(ByteStream::from_static(v2_content))
|
||||
.send()
|
||||
.await
|
||||
.expect("PUT source v2 failed");
|
||||
let v2_id = put_v2.version_id().expect("source v2 must have a version id").to_string();
|
||||
assert_ne!(v1_id, v2_id, "the two source puts must produce distinct versions");
|
||||
|
||||
// Copy the exact NON-LATEST source version into the destination bucket.
|
||||
let copy_out = client
|
||||
.copy_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.copy_source(format!("{src_bucket}/{src_key}?versionId={v1_id}"))
|
||||
.send()
|
||||
.await
|
||||
.expect("CopyObject of a versioned source must succeed");
|
||||
|
||||
// (3) The response must echo the exact source version copied.
|
||||
let copy_source_version_id = copy_out
|
||||
.copy_source_version_id()
|
||||
.expect("issue #4976: response must include x-amz-copy-source-version-id for a versioned source");
|
||||
assert_eq!(
|
||||
copy_source_version_id, v1_id,
|
||||
"x-amz-copy-source-version-id must equal the exact source version requested, not the latest source version"
|
||||
);
|
||||
assert_ne!(
|
||||
copy_source_version_id, v2_id,
|
||||
"x-amz-copy-source-version-id must not be the latest source version"
|
||||
);
|
||||
|
||||
// (4) The destination header must identify a distinct, newly created version.
|
||||
let dst_version_id = copy_out
|
||||
.version_id()
|
||||
.expect("destination copy must create a new version id")
|
||||
.to_string();
|
||||
assert!(!dst_version_id.is_empty(), "destination version id must be present");
|
||||
assert_ne!(dst_version_id, v1_id, "destination version must be distinct from the source version");
|
||||
assert_ne!(
|
||||
dst_version_id, v2_id,
|
||||
"destination version must be distinct from the source latest version"
|
||||
);
|
||||
|
||||
// (5) The destination must hold the exact bytes/size of the copied (v1) source version.
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.send()
|
||||
.await
|
||||
.expect("HEAD destination failed");
|
||||
assert_eq!(
|
||||
head.content_length(),
|
||||
Some(v1_content.len() as i64),
|
||||
"destination size must equal the copied source version (v1)"
|
||||
);
|
||||
|
||||
let get_dst = client
|
||||
.get_object()
|
||||
.bucket(dst_bucket)
|
||||
.key(dst_key)
|
||||
.version_id(&dst_version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET destination failed");
|
||||
let dst_body = get_dst.body.collect().await.expect("collect destination body").into_bytes();
|
||||
assert_eq!(
|
||||
dst_body.as_ref(),
|
||||
v1_content,
|
||||
"destination bytes must exactly equal the copied source version (v1), not the latest (v2)"
|
||||
);
|
||||
|
||||
// (6) The source version copied from must remain present and independently readable.
|
||||
let get_src_v1 = client
|
||||
.get_object()
|
||||
.bucket(src_bucket)
|
||||
.key(src_key)
|
||||
.version_id(&v1_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("GET source v1 failed after copy");
|
||||
let src_v1_body = get_src_v1.body.collect().await.expect("collect source v1 body").into_bytes();
|
||||
assert_eq!(src_v1_body.as_ref(), v1_content, "source v1 must remain intact after the copy");
|
||||
|
||||
env.stop_server();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5136,6 +5136,11 @@ impl DefaultObjectUsecase {
|
||||
|
||||
let mut src_info = gr.object_info.clone();
|
||||
|
||||
// Capture the version actually read from the source before src_info is mutated/consumed
|
||||
// below. This is the exact source version copied (issue #4976): the response must echo it
|
||||
// via x-amz-copy-source-version-id, distinct from the destination version_id.
|
||||
let src_resolved_version_id = src_info.version_id;
|
||||
|
||||
// Validate copy source conditions
|
||||
if let Some(if_match) = copy_source_if_match {
|
||||
if let Some(ref etag) = src_info.etag {
|
||||
@@ -5293,6 +5298,24 @@ impl DefaultObjectUsecase {
|
||||
let raw_dest_version = oi.version_id.map(|v| v.to_string());
|
||||
let dest_version = if dest_versioned { raw_dest_version } else { None };
|
||||
|
||||
// Echo the source version that was copied via x-amz-copy-source-version-id (issue #4976).
|
||||
// AWS/MinIO return this whenever the source bucket carries versioning (enabled or
|
||||
// suspended); render the null version as "null" like GET/HEAD do. This is the exact source
|
||||
// version, kept distinct from the destination version_id above.
|
||||
let src_versioned = BucketVersioningSys::prefix_enabled(&src_bucket, &src_key).await
|
||||
|| BucketVersioningSys::prefix_suspended(&src_bucket, &src_key).await;
|
||||
let copy_source_version_id = if src_versioned {
|
||||
src_resolved_version_id.map(|vid| {
|
||||
if vid == Uuid::nil() {
|
||||
"null".to_string()
|
||||
} else {
|
||||
vid.to_string()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// warn!("copy_object oi {:?}", &oi);
|
||||
let object_info = oi.clone();
|
||||
let copy_object_result = CopyObjectResult {
|
||||
@@ -5303,6 +5326,7 @@ impl DefaultObjectUsecase {
|
||||
|
||||
let output = CopyObjectOutput {
|
||||
copy_object_result: Some(copy_object_result),
|
||||
copy_source_version_id,
|
||||
server_side_encryption: effective_sse,
|
||||
ssekms_key_id: effective_kms_key_id,
|
||||
sse_customer_algorithm,
|
||||
|
||||
Reference in New Issue
Block a user