From edfb3c134fd41a2c5f505c259b6c68e1f1f2a3a8 Mon Sep 17 00:00:00 2001 From: Zhengchao An Date: Sat, 18 Jul 2026 07:33:35 +0800 Subject: [PATCH] fix(s3): return x-amz-copy-source-version-id for versioned CopyObject source (#4985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/copy_object_version_restore_test.rs | 142 ++++++++++++++++++ rustfs/src/app/object_usecase.rs | 24 +++ 2 files changed, 166 insertions(+) diff --git a/crates/e2e_test/src/copy_object_version_restore_test.rs b/crates/e2e_test/src/copy_object_version_restore_test.rs index 11a19e9bf..25958bc35 100644 --- a/crates/e2e_test/src/copy_object_version_restore_test.rs +++ b/crates/e2e_test/src/copy_object_version_restore_test.rs @@ -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(); + } } diff --git a/rustfs/src/app/object_usecase.rs b/rustfs/src/app/object_usecase.rs index 199120ef0..ab46736e6 100644 --- a/rustfs/src/app/object_usecase.rs +++ b/rustfs/src/app/object_usecase.rs @@ -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,