Files
rustfs/crates/e2e_test/src/copy_object_checksum_test.rs
T

709 lines
28 KiB
Rust

// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! CopyObject checksum compatibility tests. Covers all supported algorithms,
//! source-checksum preservation, explicit override, and fail-closed handling of
//! unsupported algorithms before destination mutation.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketVersioningStatus, ChecksumAlgorithm, ChecksumMode, ChecksumType, CompletedMultipartUpload, CompletedPart,
VersioningConfiguration,
};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
use serial_test::serial;
use sha2::{Digest, Sha256};
use tracing::info;
async fn create_versioned_bucket(client: &aws_sdk_s3::Client, bucket: &str) {
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");
}
fn create_s3_client_no_auto_checksum(env: &RustFSTestEnvironment) -> aws_sdk_s3::Client {
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "copy-checksum-e2e");
let config = aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new("us-east-1"))
.endpoint_url(format!("http://{}", env.address))
.force_path_style(true)
.behavior_version_latest()
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
.http_client(SmithyHttpClientBuilder::new().build_http())
.build();
aws_sdk_s3::Client::from_conf(config)
}
fn algorithms() -> [(ChecksumAlgorithm, RioChecksumType); 10] {
[
(ChecksumAlgorithm::Crc32, RioChecksumType::CRC32),
(ChecksumAlgorithm::Crc32C, RioChecksumType::CRC32C),
(ChecksumAlgorithm::Crc64Nvme, RioChecksumType::CRC64_NVME),
(ChecksumAlgorithm::Sha1, RioChecksumType::SHA1),
(ChecksumAlgorithm::Sha256, RioChecksumType::SHA256),
(ChecksumAlgorithm::Md5, RioChecksumType::MD5),
(ChecksumAlgorithm::Sha512, RioChecksumType::SHA512),
(ChecksumAlgorithm::Xxhash3, RioChecksumType::XXHASH3),
(ChecksumAlgorithm::Xxhash64, RioChecksumType::XXHASH64),
(ChecksumAlgorithm::Xxhash128, RioChecksumType::XXHASH128),
]
}
fn result_checksums(result: &aws_sdk_s3::types::CopyObjectResult) -> [Option<&str>; 10] {
[
result.checksum_crc32(),
result.checksum_crc32_c(),
result.checksum_crc64_nvme(),
result.checksum_sha1(),
result.checksum_sha256(),
result.checksum_md5(),
result.checksum_sha512(),
result.checksum_xxhash3(),
result.checksum_xxhash64(),
result.checksum_xxhash128(),
]
}
fn head_checksums(output: &aws_sdk_s3::operation::head_object::HeadObjectOutput) -> [Option<&str>; 10] {
[
output.checksum_crc32(),
output.checksum_crc32_c(),
output.checksum_crc64_nvme(),
output.checksum_sha1(),
output.checksum_sha256(),
output.checksum_md5(),
output.checksum_sha512(),
output.checksum_xxhash3(),
output.checksum_xxhash64(),
output.checksum_xxhash128(),
]
}
#[tokio::test]
#[serial]
async fn test_copy_supports_all_checksum_algorithms() {
init_logging();
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 = create_s3_client_no_auto_checksum(&env);
let src_bucket = "copy-all-checksums-src";
let dst_bucket = "copy-all-checksums-dst";
let src_key = "objects/source.bin";
let content = b"deterministic CopyObject payload for all ten checksum algorithms";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
client
.put_object()
.bucket(src_bucket)
.key(src_key)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source failed");
for (index, (sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
let expected = Checksum::new_from_data(rio_algorithm, content)
.expect("supported checksum must be computable")
.encoded;
let dst_key = format!("objects/destination-{index}.bin");
let copy = client
.copy_object()
.bucket(dst_bucket)
.key(&dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.checksum_algorithm(sdk_algorithm)
.send()
.await
.expect("CopyObject with supported checksum must succeed");
let result = copy.copy_object_result().expect("CopyObject result");
let checksums = result_checksums(result);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: response checksum");
assert_eq!(
checksums.iter().filter(|checksum| checksum.is_some()).count(),
1,
"{rio_algorithm}: only the requested checksum may be returned"
);
let head = client
.head_object()
.bucket(dst_bucket)
.key(&dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
let checksums = head_checksums(&head);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: persisted checksum");
assert_eq!(
checksums.iter().filter(|checksum| checksum.is_some()).count(),
1,
"{rio_algorithm}: destination must persist only the requested checksum"
);
let body = client
.get_object()
.bucket(dst_bucket)
.key(&dst_key)
.send()
.await
.expect("GET destination failed")
.body
.collect()
.await
.expect("collect destination body")
.into_bytes();
assert_eq!(body.as_ref(), content, "{rio_algorithm}: full copied body");
}
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
init_logging();
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 = create_s3_client_no_auto_checksum(&env);
let src_bucket = "copy-preserve-all-src";
let dst_bucket = "copy-preserve-all-dst";
let content = b"source checksum preservation payload for all ten algorithms";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
for (index, (_sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
let expected = Checksum::new_from_data(rio_algorithm, content)
.expect("supported checksum must be computable")
.encoded;
let checksum_header = rio_algorithm.key().expect("supported checksum header");
let request_checksum = expected.clone();
let src_key = format!("objects/source-{index}.bin");
let dst_key = format!("objects/destination-{index}.bin");
client
.put_object()
.bucket(src_bucket)
.key(&src_key)
.body(ByteStream::from_static(content))
.customize()
.mutate_request(move |request| {
request.headers_mut().insert(checksum_header, request_checksum.clone());
})
.send()
.await
.expect("PUT checksummed source failed");
let copy = client
.copy_object()
.bucket(dst_bucket)
.key(&dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.send()
.await
.expect("CopyObject without algorithm must succeed");
let result = copy.copy_object_result().expect("CopyObject result");
let checksums = result_checksums(result);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved response checksum");
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
let head = client
.head_object()
.bucket(dst_bucket)
.key(&dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
let checksums = head_checksums(&head);
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved stored checksum");
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
}
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
init_logging();
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 = create_s3_client_no_auto_checksum(&env);
let bucket = "copy-preserve-composite";
let source_key = "objects/multipart-source.bin";
let destination_key = "objects/copied-multipart.bin";
let content = b"multipart source checksum must remain composite";
create_versioned_bucket(&client, bucket).await;
let created = client
.create_multipart_upload()
.bucket(bucket)
.key(source_key)
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.send()
.await
.expect("CreateMultipartUpload failed");
let upload_id = created.upload_id().expect("multipart upload ID");
let checksum = Checksum::new_from_data(RioChecksumType::SHA256, content)
.expect("SHA256 checksum")
.encoded;
let uploaded = client
.upload_part()
.bucket(bucket)
.key(source_key)
.upload_id(upload_id)
.part_number(1)
.checksum_sha256(&checksum)
.body(ByteStream::from_static(content))
.send()
.await
.expect("UploadPart failed");
let completed_part = CompletedPart::builder()
.part_number(1)
.e_tag(uploaded.e_tag().expect("part ETag"))
.checksum_sha256(uploaded.checksum_sha256().expect("part checksum"))
.build();
client
.complete_multipart_upload()
.bucket(bucket)
.key(source_key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
.send()
.await
.expect("CompleteMultipartUpload failed");
let source_head = client
.head_object()
.bucket(bucket)
.key(source_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD multipart source failed");
let source_checksum = source_head.checksum_sha256().expect("multipart source checksum");
assert_eq!(source_head.checksum_type(), Some(&ChecksumType::Composite));
let copied = client
.copy_object()
.bucket(bucket)
.key(destination_key)
.copy_source(format!("{bucket}/{source_key}"))
.send()
.await
.expect("CopyObject without algorithm failed");
let result = copied.copy_object_result().expect("CopyObject result");
assert_eq!(result.checksum_sha256(), Some(source_checksum));
assert_eq!(result.checksum_type(), Some(&ChecksumType::Composite));
let destination_head = client
.head_object()
.bucket(bucket)
.key(destination_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD copied multipart object failed");
assert_eq!(destination_head.checksum_sha256(), Some(source_checksum));
assert_eq!(destination_head.checksum_type(), Some(&ChecksumType::Composite));
env.stop_server();
}
#[tokio::test]
#[serial]
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
init_logging();
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 bucket = "copy-reject-unknown-checksum";
let src_key = "objects/source.bin";
let dst_key = "objects/destination.bin";
let source = b"source must never replace destination";
let destination = b"pre-existing destination must remain byte-for-byte unchanged";
let expected = Checksum::new_from_data(RioChecksumType::SHA256, destination)
.expect("SHA256 checksum")
.encoded;
create_versioned_bucket(&client, bucket).await;
client
.put_object()
.bucket(bucket)
.key(src_key)
.body(ByteStream::from_static(source))
.send()
.await
.expect("PUT source failed");
let original = client
.put_object()
.bucket(bucket)
.key(dst_key)
.metadata("state", "original")
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.body(ByteStream::from_static(destination))
.send()
.await
.expect("PUT destination failed");
let original_version = original.version_id().expect("versioned PUT must return a version id");
let missing_source_error = client
.copy_object()
.bucket(bucket)
.key(dst_key)
.copy_source(format!("{bucket}/objects/missing-source.bin"))
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
.send()
.await
.expect_err("checksum validation must precede source lookup");
assert_eq!(
missing_source_error.as_service_error().and_then(|value| value.code()),
Some("InvalidArgument")
);
assert_eq!(missing_source_error.raw_response().map(|response| response.status().as_u16()), Some(400));
let error = client
.copy_object()
.bucket(bucket)
.key(dst_key)
.copy_source(format!("{bucket}/{src_key}"))
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
.send()
.await
.expect_err("unsupported checksum algorithm must fail");
assert_eq!(error.as_service_error().and_then(|value| value.code()), Some("InvalidArgument"));
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
let head = client
.head_object()
.bucket(bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD unchanged destination");
assert_eq!(head.version_id(), Some(original_version));
assert_eq!(
head.metadata().and_then(|metadata| metadata.get("state").map(String::as_str)),
Some("original")
);
assert_eq!(head.checksum_sha256(), Some(expected.as_str()));
let body = client
.get_object()
.bucket(bucket)
.key(dst_key)
.send()
.await
.expect("GET unchanged destination")
.body
.collect()
.await
.expect("collect unchanged destination")
.into_bytes();
assert_eq!(body.as_ref(), destination);
env.stop_server();
}
/// Requested algorithm: a CopyObject asking for SHA256 must compute it over the copied
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
/// HEAD on the destination returns the identical value.
#[tokio::test]
#[serial]
async fn test_copy_with_checksum_algorithm_returns_and_persists_sha256() {
init_logging();
info!("Issue #4996: CopyObject with ChecksumAlgorithm=SHA256 must return and persist the checksum");
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-checksum-req-src";
let dst_bucket = "copy-checksum-req-dst";
let src_key = "objects/source.bin";
let dst_key = "objects/dest.bin";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
let content = b"deterministic synthetic payload for copy-object checksum #4996";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
client
.put_object()
.bucket(src_bucket)
.key(src_key)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source failed");
let copy_out = client
.copy_object()
.bucket(dst_bucket)
.key(dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.send()
.await
.expect("CopyObject with ChecksumAlgorithm must succeed");
// (3) The response must carry the freshly computed SHA-256 of the copied bytes.
let result = copy_out
.copy_object_result()
.expect("issue #4996: CopyObject must return a CopyObjectResult");
assert_eq!(
result.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: CopyObjectResult.ChecksumSHA256 must equal the SHA-256 of the copied bytes"
);
// (4) A checksum-mode HEAD on the destination must return the same SHA-256.
let head = client
.head_object()
.bucket(dst_bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
assert_eq!(
head.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: destination checksum-mode HEAD must return the same SHA-256 the copy reported"
);
env.stop_server();
}
/// No algorithm requested: when the source object already carries a checksum, the copy must
/// preserve it on the destination (AWS default), visible via a checksum-mode HEAD.
#[tokio::test]
#[serial]
async fn test_copy_without_algorithm_preserves_source_checksum() {
init_logging();
info!("Issue #4996: CopyObject without ChecksumAlgorithm must preserve the source object's checksum");
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-checksum-preserve-src";
let dst_bucket = "copy-checksum-preserve-dst";
let src_key = "objects/source.bin";
let dst_key = "objects/dest.bin";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
let content = b"another deterministic payload whose source checksum must survive the copy";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
// Store the source WITH a SHA-256 checksum so it has one to preserve.
let put_src = client
.put_object()
.bucket(src_bucket)
.key(src_key)
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source with checksum failed");
assert_eq!(
put_src.checksum_sha256(),
Some(expected_sha256.as_str()),
"source PUT must report the SHA-256 it stored"
);
// Copy WITHOUT specifying a checksum algorithm.
let copy_out = client
.copy_object()
.bucket(dst_bucket)
.key(dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.send()
.await
.expect("CopyObject without ChecksumAlgorithm must succeed");
// The response should echo the preserved source checksum.
let result = copy_out
.copy_object_result()
.expect("issue #4996: CopyObject must return a CopyObjectResult");
assert_eq!(
result.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: a no-algorithm copy must preserve and report the source object's SHA-256"
);
// And a checksum-mode HEAD on the destination must return that same preserved SHA-256.
let head = client
.head_object()
.bucket(dst_bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
assert_eq!(
head.checksum_sha256(),
Some(expected_sha256.as_str()),
"issue #4996: destination checksum-mode HEAD must return the preserved source SHA-256"
);
env.stop_server();
}
/// Requested algorithm differs from the source's: a source stored with SHA256, copied while
/// requesting CRC32, must return/persist the freshly computed CRC32 and must NOT carry the
/// source's SHA256 through. Guards the request-over-source precedence and the destination's
/// checksum-not-inherited path, and exercises the CRC32 code path (a different branch of
/// ChecksumType::from_string than SHA256).
#[tokio::test]
#[serial]
async fn test_copy_requested_algorithm_overrides_source_checksum() {
init_logging();
info!("Issue #4996: a requested CopyObject checksum algorithm must override the source object's algorithm");
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-checksum-override-src";
let dst_bucket = "copy-checksum-override-dst";
let src_key = "objects/source.bin";
let ref_key = "objects/reference-crc32.bin";
let dst_key = "objects/dest.bin";
create_versioned_bucket(&client, src_bucket).await;
create_versioned_bucket(&client, dst_bucket).await;
let content = b"payload whose copy must be re-checksummed with a different algorithm";
let expected_sha256 = BASE64.encode(Sha256::digest(content));
// Source is stored WITH a SHA-256 checksum.
client
.put_object()
.bucket(src_bucket)
.key(src_key)
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.body(ByteStream::from_static(content))
.send()
.await
.expect("PUT source with SHA256 failed");
// Establish the canonical CRC32 the server computes for this content via a reference PUT,
// so the copy's CRC32 can be asserted against an exact server-computed value.
let ref_put = client
.put_object()
.bucket(src_bucket)
.key(ref_key)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.body(ByteStream::from_static(content))
.send()
.await
.expect("reference PUT with CRC32 failed");
let expected_crc32 = ref_put
.checksum_crc32()
.expect("reference PUT must report a CRC32")
.to_string();
// Copy the SHA256 source while requesting CRC32.
let copy_out = client
.copy_object()
.bucket(dst_bucket)
.key(dst_key)
.copy_source(format!("{src_bucket}/{src_key}"))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("CopyObject requesting a different algorithm must succeed");
let result = copy_out
.copy_object_result()
.expect("issue #4996: CopyObject must return a CopyObjectResult");
// The requested CRC32 must be computed and returned.
assert_eq!(
result.checksum_crc32(),
Some(expected_crc32.as_str()),
"issue #4996: a requested CRC32 must be computed fresh over the copied bytes"
);
// The source's SHA256 must NOT leak through — the requested algorithm wins.
assert_eq!(
result.checksum_sha256(),
None,
"issue #4996: the source object's SHA256 must not be inherited when a different algorithm is requested"
);
assert_ne!(
result.checksum_crc32(),
Some(expected_sha256.as_str()),
"sanity: CRC32 field must not carry the SHA256 value"
);
// The destination must persist CRC32 (and only CRC32) for a checksum-mode HEAD.
let head = client
.head_object()
.bucket(dst_bucket)
.key(dst_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("HEAD destination failed");
assert_eq!(
head.checksum_crc32(),
Some(expected_crc32.as_str()),
"issue #4996: destination checksum-mode HEAD must return the requested CRC32"
);
assert_eq!(
head.checksum_sha256(),
None,
"issue #4996: destination must not report the source's SHA256 after an override copy"
);
env.stop_server();
}
}