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:
Zhengchao An
2026-07-18 07:33:35 +08:00
committed by GitHub
parent 314c17205e
commit edfb3c134f
2 changed files with 166 additions and 0 deletions
@@ -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();
}
}
+24
View File
@@ -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,