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
+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,