mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-05 19:55:37 +00:00
8bf569899a
* fix(ecstore): persist merged checksum type for full-object multipart complete_multipart_upload built the object-level checksum record from a ChecksumType copied before the MULTIPART / INCLUDES_MULTIPART flags were merged in. ChecksumType::merge takes &mut self, so the merge updated the local variable while the copy already inside the Checksum struct stayed behind. The composite branch rebuilt the Checksum from the merged type and was unaffected; the full-object branch never rebuilt it, so those flags never reached disk. rustfs_rio::read_checksums only sets its multipart flag and only emits the "x-amz-checksum-type" = "FULL_OBJECT" entry inside its MULTIPART branch, so a full-object multipart object read back as non-multipart with no type entry, and GetObject and HeadObject answered with no x-amz-checksum-type header at all where AWS returns FULL_OBJECT. Hand the full-object branch the merged type instead of rebuilding the Checksum: the value must stay the running merge produced by add_part, because hashing the concatenated part digests would yield the COMPOSITE value, a different number than the one the client sent. The serialization now lives in multipart_object_checksum_record so both shapes are covered by unit tests. Records written by earlier builds carry the bare algorithm type with no MULTIPART flags and no trailing part block; they keep reading back to the same checksum value, and the FULL_OBJECT reader arm predates this change so older peers parse the new record shape correctly too. Found while root-causing rustfs#6825. * fix(s3): reject contradicting multipart checksum type as client error A CompleteMultipartUpload declaring an x-amz-checksum-type that contradicts the type recorded at CreateMultipartUpload answered 500 InternalError, telling the caller to retry a request that can only ever fail. The storage layer does refuse the combination, but through a generic error that maps to InternalError. Validate the header against the recorded type in the usecase, where the upload metadata returned by get_multipart_info is already in hand, and answer InvalidRequest naming both types, matching AWS. The storage-layer check stays as a backstop for non-HTTP callers. Uploads created without a checksum algorithm record no type, so there is nothing to contradict and the header is left alone rather than newly rejected. Replication is unaffected: replication_put_object_options already excludes x-amz-checksum-type from the metadata it forwards. * test(e2e): cover full-object multipart checksum type round-trip Adds an end-to-end test that a CRC32 FULL_OBJECT multipart upload reports x-amz-checksum-type: FULL_OBJECT and the unsuffixed full-object value on both GetObject and HeadObject, and one that a CompleteMultipartUpload contradicting the recorded type is rejected as InvalidRequest while leaving the upload intact. Extends the existing CRC64NVME multipart test with the same checksum-type assertion. * fix(s3): keep checksum-type validation off the s3s error macro The s3s footprint ratchet (scripts/check_s3s_footprint.sh) counts s3_error! invocation lines and is lower-only: new code must route through the gateway abstractions rather than widen the direct s3s surface the s3gate migration is shrinking. Raise the contradiction through ApiError::invalid_request instead. The response is byte-for-byte identical -- From<ApiError> for S3Error carries the InvalidRequest code and the message through unchanged -- and the usecase already returns ApiError elsewhere, so this is the idiomatic path rather than a way around the counter. The explanatory comment deliberately says "the s3s error macro" instead of naming the macro: the ratchet counts raw matches, so spelling it out in a comment tripped the same check.
917 lines
38 KiB
Rust
917 lines
38 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.
|
|
|
|
//! E2E tests for PutObject and MultipartUpload with checksums (Content-MD5, x-amz-checksum-*).
|
|
//! Verifies that uploads with Content-MD5 and x-amz-checksum-sha256 succeed and content is correct.
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
|
use aws_sdk_s3::Client;
|
|
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::{
|
|
ChecksumAlgorithm, ChecksumMode, ChecksumType as SdkChecksumType, CompletedMultipartUpload, CompletedPart,
|
|
ServerSideEncryption,
|
|
};
|
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
|
use md5::{Digest as Md5Digest, Md5};
|
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
|
use sha2::Sha256;
|
|
use tracing::info;
|
|
|
|
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
|
|
env.create_s3_client()
|
|
}
|
|
|
|
/// Client with boto/SDK automatic checksum calculation disabled, so the ONLY
|
|
/// checksum on the wire is the one the test injects. Needed because the SDK's
|
|
/// default (crc32) would otherwise collide with the additional-algorithm header
|
|
/// we inject via `mutate_request` for XXHash/SHA-512/MD5 (which have no typed
|
|
/// SDK builder). Mirrors the boto3 `request_checksum_calculation=when_required`.
|
|
fn create_s3_client_no_auto_checksum(env: &RustFSTestEnvironment) -> Client {
|
|
let creds = Credentials::new(&env.access_key, &env.secret_key, None, None, "e2e-additional-checksum");
|
|
let config = aws_sdk_s3::Config::builder()
|
|
.credentials_provider(creds)
|
|
.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();
|
|
Client::from_conf(config)
|
|
}
|
|
|
|
async fn create_bucket(client: &Client, bucket: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
match client.create_bucket().bucket(bucket).send().await {
|
|
Ok(_) => {
|
|
info!("Bucket {} created successfully", bucket);
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
if e.to_string().contains("BucketAlreadyOwnedByYou") || e.to_string().contains("BucketAlreadyExists") {
|
|
info!("Bucket {} already exists", bucket);
|
|
Ok(())
|
|
} else {
|
|
Err(Box::new(e))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn content_md5_base64(body: &[u8]) -> String {
|
|
let mut hasher = Md5::new();
|
|
hasher.update(body);
|
|
let digest = hasher.finalize();
|
|
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
|
}
|
|
|
|
fn checksum_sha256_base64(body: &[u8]) -> String {
|
|
let digest = Sha256::digest(body);
|
|
base64_simd::STANDARD.encode_to_string(digest.as_slice())
|
|
}
|
|
|
|
fn checksum_crc64nvme_base64(body: &[u8]) -> String {
|
|
Checksum::new_from_data(RioChecksumType::CRC64_NVME, body)
|
|
.expect("crc64nvme checksum")
|
|
.encoded
|
|
}
|
|
|
|
/// PutObject with Content-MD5: upload succeeds and GetObject returns same content.
|
|
#[tokio::test]
|
|
async fn test_put_object_with_content_md5() {
|
|
init_logging();
|
|
info!("TEST: PutObject with Content-MD5");
|
|
|
|
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(&env);
|
|
let bucket = "test-checksum-md5";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
let key = "obj-with-md5.txt";
|
|
let content = b"Hello world with Content-MD5 checksum";
|
|
let content_md5 = content_md5_base64(content);
|
|
|
|
let result = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.body(ByteStream::from_static(content))
|
|
.content_md5(&content_md5)
|
|
.send()
|
|
.await;
|
|
|
|
assert!(result.is_ok(), "PutObject with Content-MD5 failed: {:?}", result.err());
|
|
|
|
let get_result = client.get_object().bucket(bucket).key(key).send().await;
|
|
assert!(get_result.is_ok(), "GetObject failed: {:?}", get_result.err());
|
|
let body_bytes = get_result.unwrap().body.collect().await.expect("collect body").into_bytes();
|
|
assert_eq!(body_bytes.as_ref(), content, "GetObject body must match uploaded content");
|
|
info!("PASSED: PutObject with Content-MD5 and GetObject content match");
|
|
}
|
|
|
|
/// PutObject with x-amz-checksum-sha256: upload succeeds and GetObject returns same content.
|
|
#[tokio::test]
|
|
async fn test_put_object_with_checksum_sha256() {
|
|
init_logging();
|
|
info!("TEST: PutObject with x-amz-checksum-sha256");
|
|
|
|
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(&env);
|
|
let bucket = "test-checksum-sha256";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
let key = "obj-with-sha256.txt";
|
|
let content = b"Hello world with x-amz-checksum-sha256";
|
|
let checksum = checksum_sha256_base64(content);
|
|
|
|
let result = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.body(ByteStream::from_static(content))
|
|
.checksum_sha256(&checksum)
|
|
.send()
|
|
.await;
|
|
|
|
assert!(result.is_ok(), "PutObject with checksum_sha256 failed: {:?}", result.err());
|
|
|
|
let get_result = client.get_object().bucket(bucket).key(key).send().await;
|
|
assert!(get_result.is_ok(), "GetObject failed: {:?}", get_result.err());
|
|
let body_bytes = get_result.unwrap().body.collect().await.expect("collect body").into_bytes();
|
|
assert_eq!(body_bytes.as_ref(), content, "GetObject body must match uploaded content");
|
|
info!("PASSED: PutObject with checksum_sha256 and GetObject content match");
|
|
}
|
|
|
|
/// Regression test for issue #4341 (part 1: verify-on-write):
|
|
/// PutObject with a SHA256 checksum that does NOT match the body must be
|
|
/// rejected (BadDigest / checksum mismatch), NOT accepted with HTTP 200.
|
|
#[tokio::test]
|
|
async fn test_put_object_rejects_mismatched_sha256() {
|
|
init_logging();
|
|
info!("TEST: PutObject rejects mismatched x-amz-checksum-sha256 (issue #4341)");
|
|
|
|
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(&env);
|
|
let bucket = "test-checksum-sha256-mismatch";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
let key = "obj-bad-sha256.txt";
|
|
let content = b"Body bytes that do NOT match the declared checksum";
|
|
// Checksum of a DIFFERENT payload -> deliberate mismatch.
|
|
let wrong_checksum = checksum_sha256_base64(b"some other payload entirely");
|
|
|
|
let result = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.body(ByteStream::from_static(content))
|
|
.checksum_sha256(&wrong_checksum)
|
|
.send()
|
|
.await;
|
|
|
|
let error = result.expect_err("PutObject with a mismatched SHA256 must be rejected (issue #4341)");
|
|
assert_eq!(
|
|
error.raw_response().map(|response| response.status().as_u16()),
|
|
Some(400),
|
|
"Mismatched SHA256 must return HTTP 400, got {error:?}"
|
|
);
|
|
assert_eq!(
|
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
|
Some("BadDigest"),
|
|
"Mismatched SHA256 must return BadDigest, got {error:?}"
|
|
);
|
|
|
|
// And the object must not have been stored.
|
|
let error = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.send()
|
|
.await
|
|
.expect_err("Object must not exist after a rejected mismatched-checksum PutObject");
|
|
assert_eq!(
|
|
error.raw_response().map(|response| response.status().as_u16()),
|
|
Some(404),
|
|
"Rejected mismatched-checksum PutObject absence probe must return HTTP 404, got {error:?}"
|
|
);
|
|
info!("PASSED: PutObject rejects mismatched SHA256 and stores nothing");
|
|
}
|
|
|
|
/// Regression test for issue #4341 (part 2: retrieve-on-HEAD):
|
|
/// After PutObject with a correct SHA256 checksum, HeadObject with
|
|
/// ChecksumMode=ENABLED must return that stored base64 SHA256 digest.
|
|
#[tokio::test]
|
|
async fn test_head_object_returns_stored_sha256() {
|
|
init_logging();
|
|
info!("TEST: HeadObject returns stored SHA256 with ChecksumMode=ENABLED (issue #4341)");
|
|
|
|
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(&env);
|
|
let bucket = "test-checksum-sha256-head";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
let key = "obj-head-sha256.txt";
|
|
let content = b"Retrieve my SHA256 checksum on HEAD";
|
|
let checksum = checksum_sha256_base64(content);
|
|
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.body(ByteStream::from_static(content))
|
|
.checksum_sha256(&checksum)
|
|
.send()
|
|
.await
|
|
.expect("PutObject with correct SHA256 failed");
|
|
|
|
let head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("HeadObject failed");
|
|
|
|
assert_eq!(
|
|
head.checksum_sha256(),
|
|
Some(checksum.as_str()),
|
|
"HeadObject with ChecksumMode=ENABLED must return the stored base64 SHA256 (issue #4341)"
|
|
);
|
|
info!("PASSED: HeadObject returns stored SHA256 digest");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_head_object_returns_sse_s3_checksum() {
|
|
init_logging();
|
|
|
|
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
|
env.start_rustfs_server_with_env(
|
|
vec![],
|
|
&[
|
|
("RUSTFS_SSE_S3_MASTER_KEY", "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="),
|
|
("RUSTFS_CONSOLE_ENABLE", "false"),
|
|
],
|
|
)
|
|
.await
|
|
.expect("Failed to start RustFS");
|
|
|
|
let client = create_s3_client(&env);
|
|
let bucket = "test-sse-s3-checksum-head";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
let put = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key("encrypted.txt")
|
|
.body(ByteStream::from_static(b"encrypted checksum"))
|
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 PutObject with CRC32 failed");
|
|
let expected = put.checksum_crc32().expect("PutObject must return CRC32");
|
|
|
|
let head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key("encrypted.txt")
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 HeadObject failed");
|
|
|
|
assert_eq!(head.checksum_crc32(), Some(expected));
|
|
|
|
client
|
|
.copy_object()
|
|
.bucket(bucket)
|
|
.key("encrypted-copy.txt")
|
|
.copy_source(format!("{bucket}/encrypted.txt"))
|
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 CopyObject failed");
|
|
let copy_head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key("encrypted-copy.txt")
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 copied HeadObject failed");
|
|
|
|
assert_eq!(copy_head.checksum_crc32(), Some(expected));
|
|
|
|
let multipart_key = "encrypted-multipart.txt";
|
|
let create = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.server_side_encryption(ServerSideEncryption::Aes256)
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 CreateMultipartUpload with CRC32 failed");
|
|
let upload_id = create.upload_id().expect("CreateMultipartUpload must return an upload ID");
|
|
let part = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.upload_id(upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from_static(b"encrypted multipart checksum"))
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 UploadPart with CRC32 failed");
|
|
let completed_part = CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(part.e_tag().expect("UploadPart must return an ETag"))
|
|
.checksum_crc32(part.checksum_crc32().expect("UploadPart must return CRC32"))
|
|
.build();
|
|
let complete = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.upload_id(upload_id)
|
|
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 CompleteMultipartUpload with CRC32 failed");
|
|
let expected_multipart = complete.checksum_crc32().expect("CompleteMultipartUpload must return CRC32");
|
|
let multipart_head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("SSE-S3 multipart HeadObject failed");
|
|
|
|
assert_eq!(multipart_head.checksum_crc32(), Some(expected_multipart));
|
|
}
|
|
|
|
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
|
|
/// Uses part size >= 5MB (server minimum) for two parts.
|
|
#[tokio::test]
|
|
async fn test_multipart_upload_with_checksum() {
|
|
init_logging();
|
|
info!("TEST: MultipartUpload with checksum (checksum_sha256 on parts)");
|
|
|
|
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(&env);
|
|
let bucket = "test-multipart-checksum";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
let key = "multipart-with-checksum.bin";
|
|
const PART_SIZE: usize = 6 * 1024 * 1024; // 6 MB per part (>= 5MB minimum)
|
|
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 256) as u8).collect();
|
|
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 1) % 256) as u8).collect();
|
|
let full_content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
|
|
|
let create_result = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.checksum_algorithm(aws_sdk_s3::types::ChecksumAlgorithm::Sha256)
|
|
.send()
|
|
.await
|
|
.expect("Failed to create multipart upload");
|
|
|
|
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
|
|
|
|
let checksum1 = checksum_sha256_base64(&part1);
|
|
let upload1 = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(&upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(part1.clone()))
|
|
.checksum_sha256(&checksum1)
|
|
.send()
|
|
.await
|
|
.expect("Failed to upload part 1");
|
|
|
|
let etag1 = upload1.e_tag().expect("No etag part 1").to_string();
|
|
let checksum_sha256_1 = upload1.checksum_sha256().map(|s| s.to_string());
|
|
|
|
let checksum2 = checksum_sha256_base64(&part2);
|
|
let upload2 = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(&upload_id)
|
|
.part_number(2)
|
|
.body(ByteStream::from(part2.clone()))
|
|
.checksum_sha256(&checksum2)
|
|
.send()
|
|
.await
|
|
.expect("Failed to upload part 2");
|
|
|
|
let etag2 = upload2.e_tag().expect("No etag part 2").to_string();
|
|
let checksum_sha256_2 = upload2.checksum_sha256().map(|s| s.to_string());
|
|
|
|
let mut part1_builder = CompletedPart::builder().part_number(1).e_tag(etag1);
|
|
if let Some(ref cs) = checksum_sha256_1 {
|
|
part1_builder = part1_builder.checksum_sha256(cs);
|
|
}
|
|
let mut part2_builder = CompletedPart::builder().part_number(2).e_tag(etag2);
|
|
if let Some(ref cs) = checksum_sha256_2 {
|
|
part2_builder = part2_builder.checksum_sha256(cs);
|
|
}
|
|
|
|
let completed_parts = vec![part1_builder.build(), part2_builder.build()];
|
|
let completed_upload = CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build();
|
|
|
|
let complete_result = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(&upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await;
|
|
|
|
assert!(complete_result.is_ok(), "CompleteMultipartUpload failed: {:?}", complete_result.err());
|
|
|
|
let get_result = client.get_object().bucket(bucket).key(key).send().await;
|
|
assert!(get_result.is_ok(), "GetObject failed: {:?}", get_result.err());
|
|
let body_bytes = get_result.unwrap().body.collect().await.expect("collect body").into_bytes();
|
|
assert_eq!(
|
|
body_bytes.as_ref(),
|
|
full_content.as_slice(),
|
|
"GetObject body must match concatenated parts"
|
|
);
|
|
info!("PASSED: MultipartUpload with checksum and GetObject content match");
|
|
}
|
|
|
|
/// Regression test for issue #2282:
|
|
/// CRC64NVME full-object checksum should match between direct PutObject and multipart upload.
|
|
#[tokio::test]
|
|
async fn test_crc64nvme_matches_between_put_object_and_multipart_upload() {
|
|
init_logging();
|
|
info!("TEST: CRC64NVME matches between direct PutObject and multipart upload");
|
|
|
|
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(&env);
|
|
let bucket = "test-crc64nvme-multipart-match";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
const PART_SIZE: usize = 6 * 1024 * 1024;
|
|
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 251) as u8).collect();
|
|
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 17) % 251) as u8).collect();
|
|
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
|
|
|
let direct_key = "crc64nvme-direct.bin";
|
|
let multipart_key = "crc64nvme-multipart.bin";
|
|
let full_checksum = checksum_crc64nvme_base64(&content);
|
|
let part1_checksum = checksum_crc64nvme_base64(&part1);
|
|
let part2_checksum = checksum_crc64nvme_base64(&part2);
|
|
|
|
client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(direct_key)
|
|
.body(ByteStream::from(content.clone()))
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc64Nvme)
|
|
.checksum_crc64_nvme(full_checksum.clone())
|
|
.send()
|
|
.await
|
|
.expect("Failed to put direct object with CRC64NVME");
|
|
|
|
let create_result = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc64Nvme)
|
|
.send()
|
|
.await
|
|
.expect("Failed to create multipart upload");
|
|
|
|
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
|
|
|
|
let upload1 = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.upload_id(&upload_id)
|
|
.part_number(1)
|
|
.body(ByteStream::from(part1.clone()))
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc64Nvme)
|
|
.checksum_crc64_nvme(part1_checksum)
|
|
.send()
|
|
.await
|
|
.expect("Failed to upload multipart part 1");
|
|
|
|
let upload2 = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.upload_id(&upload_id)
|
|
.part_number(2)
|
|
.body(ByteStream::from(part2.clone()))
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc64Nvme)
|
|
.checksum_crc64_nvme(part2_checksum)
|
|
.send()
|
|
.await
|
|
.expect("Failed to upload multipart part 2");
|
|
|
|
let completed_upload = CompletedMultipartUpload::builder()
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(1)
|
|
.e_tag(upload1.e_tag().expect("No etag for part 1"))
|
|
.checksum_crc64_nvme(upload1.checksum_crc64_nvme().expect("No CRC64NVME for part 1"))
|
|
.build(),
|
|
)
|
|
.parts(
|
|
CompletedPart::builder()
|
|
.part_number(2)
|
|
.e_tag(upload2.e_tag().expect("No etag for part 2"))
|
|
.checksum_crc64_nvme(upload2.checksum_crc64_nvme().expect("No CRC64NVME for part 2"))
|
|
.build(),
|
|
)
|
|
.build();
|
|
|
|
client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.upload_id(&upload_id)
|
|
.multipart_upload(completed_upload)
|
|
.send()
|
|
.await
|
|
.expect("Failed to complete multipart upload");
|
|
|
|
let direct_head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(direct_key)
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("Failed to head direct object");
|
|
|
|
let multipart_head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(multipart_key)
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("Failed to head multipart object");
|
|
|
|
assert_eq!(
|
|
direct_head.checksum_crc64_nvme(),
|
|
Some(full_checksum.as_str()),
|
|
"Direct object should report the uploaded full-object CRC64NVME"
|
|
);
|
|
assert_eq!(
|
|
multipart_head.checksum_crc64_nvme(),
|
|
Some(full_checksum.as_str()),
|
|
"Multipart object should report the same full-object CRC64NVME as direct upload"
|
|
);
|
|
assert_eq!(
|
|
multipart_head.checksum_type(),
|
|
Some(&SdkChecksumType::FullObject),
|
|
"Multipart object with a full-object checksum must report FULL_OBJECT"
|
|
);
|
|
}
|
|
|
|
/// Create a CRC32 FULL_OBJECT multipart upload and upload every part, returning
|
|
/// the upload id and the `CompletedPart` list ready for CompleteMultipartUpload.
|
|
async fn start_full_object_crc32_upload(
|
|
client: &Client,
|
|
bucket: &str,
|
|
key: &str,
|
|
parts: &[&Vec<u8>],
|
|
) -> (String, Vec<CompletedPart>) {
|
|
let create_result = client
|
|
.create_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
|
.checksum_type(SdkChecksumType::FullObject)
|
|
.send()
|
|
.await
|
|
.expect("Failed to create multipart upload");
|
|
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
|
|
|
|
let mut completed_parts = Vec::new();
|
|
for (index, part) in parts.iter().enumerate() {
|
|
let part_number = index as i32 + 1;
|
|
let uploaded = client
|
|
.upload_part()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(&upload_id)
|
|
.part_number(part_number)
|
|
.body(ByteStream::from((*part).clone()))
|
|
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("Failed to upload part {part_number}: {e:?}"));
|
|
completed_parts.push(
|
|
CompletedPart::builder()
|
|
.part_number(part_number)
|
|
.e_tag(uploaded.e_tag().expect("No etag for part"))
|
|
.checksum_crc32(uploaded.checksum_crc32().expect("No CRC32 for part"))
|
|
.build(),
|
|
);
|
|
}
|
|
|
|
(upload_id, completed_parts)
|
|
}
|
|
|
|
/// A multipart upload completed with a **full-object** checksum must report
|
|
/// `x-amz-checksum-type: FULL_OBJECT` on both GET and HEAD, the way AWS does.
|
|
///
|
|
/// `complete_multipart_upload` used to persist the object-level checksum
|
|
/// record with the pre-merge checksum type, so the MULTIPART /
|
|
/// INCLUDES_MULTIPART flags never reached disk. `rustfs_rio::read_checksums`
|
|
/// only emits the FULL_OBJECT entry inside its MULTIPART branch, so these
|
|
/// objects came back from GET and HEAD with no checksum-type header at all.
|
|
/// Found while root-causing rustfs#6825.
|
|
#[tokio::test]
|
|
async fn test_full_object_multipart_reports_full_object_checksum_type() {
|
|
init_logging();
|
|
info!("TEST: full-object multipart upload round-trips x-amz-checksum-type: FULL_OBJECT");
|
|
|
|
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(&env);
|
|
let bucket = "test-full-object-checksum-type";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
const PART_SIZE: usize = 5 * 1024 * 1024;
|
|
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 241) as u8).collect();
|
|
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 29) % 241) as u8).collect();
|
|
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
|
|
|
// CRC32 with an explicit FULL_OBJECT type: the object checksum is the
|
|
// CRC32 of the whole object, not the composite hash of the part digests.
|
|
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
|
.expect("crc32 checksum")
|
|
.encoded;
|
|
|
|
let key = "full-object-multipart.bin";
|
|
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
|
|
|
client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(&upload_id)
|
|
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
|
// Restate the full-object intent and value on CompleteMultipartUpload,
|
|
// exactly as an AWS SDK client does: `x-amz-checksum-type: FULL_OBJECT`
|
|
// plus `x-amz-checksum-crc32`, with no `x-amz-checksum-algorithm`
|
|
// header (CompleteMultipartUpload has no such member).
|
|
.checksum_type(SdkChecksumType::FullObject)
|
|
.checksum_crc32(full_object_crc32.clone())
|
|
.send()
|
|
.await
|
|
.expect("Failed to complete multipart upload");
|
|
|
|
let head = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("Failed to head object");
|
|
assert_eq!(
|
|
head.checksum_type(),
|
|
Some(&SdkChecksumType::FullObject),
|
|
"HeadObject must report x-amz-checksum-type: FULL_OBJECT"
|
|
);
|
|
assert_eq!(
|
|
head.checksum_crc32(),
|
|
Some(full_object_crc32.as_str()),
|
|
"HeadObject must report the full-object CRC32, with no -<parts> suffix"
|
|
);
|
|
|
|
let get = client
|
|
.get_object()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.checksum_mode(ChecksumMode::Enabled)
|
|
.send()
|
|
.await
|
|
.expect("Failed to get object");
|
|
assert_eq!(
|
|
get.checksum_type(),
|
|
Some(&SdkChecksumType::FullObject),
|
|
"GetObject must report x-amz-checksum-type: FULL_OBJECT"
|
|
);
|
|
assert_eq!(
|
|
get.checksum_crc32(),
|
|
Some(full_object_crc32.as_str()),
|
|
"GetObject must report the full-object CRC32, with no -<parts> suffix"
|
|
);
|
|
|
|
let body = get.body.collect().await.expect("Failed to read body").into_bytes();
|
|
assert_eq!(body.as_ref(), content.as_slice(), "GetObject body must match the uploaded content");
|
|
|
|
info!("PASSED: full-object multipart reports FULL_OBJECT on GET and HEAD");
|
|
}
|
|
|
|
/// Declaring a checksum type on CompleteMultipartUpload that contradicts the
|
|
/// one recorded at CreateMultipartUpload must be rejected, and rejected as a
|
|
/// client error (4xx), not a server error.
|
|
#[tokio::test]
|
|
async fn test_complete_multipart_rejects_contradicting_checksum_type() {
|
|
init_logging();
|
|
info!("TEST: CompleteMultipartUpload rejects a checksum type that contradicts the upload");
|
|
|
|
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(&env);
|
|
let bucket = "test-checksum-type-mismatch";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
const PART_SIZE: usize = 5 * 1024 * 1024;
|
|
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 239) as u8).collect();
|
|
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 31) % 239) as u8).collect();
|
|
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
|
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
|
.expect("crc32 checksum")
|
|
.encoded;
|
|
|
|
let key = "checksum-type-mismatch.bin";
|
|
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
|
|
|
let err = client
|
|
.complete_multipart_upload()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(&upload_id)
|
|
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
|
// The upload was created as FULL_OBJECT; claiming COMPOSITE here
|
|
// contradicts it.
|
|
.checksum_type(SdkChecksumType::Composite)
|
|
.checksum_crc32(full_object_crc32.clone())
|
|
.send()
|
|
.await
|
|
.expect_err("COMPOSITE on a FULL_OBJECT upload must be rejected");
|
|
|
|
let service_err = err.into_service_error();
|
|
let code = service_err.meta().code().unwrap_or("<no code>").to_string();
|
|
let message = service_err.meta().message().unwrap_or_default().to_string();
|
|
|
|
// Before the fix the storage layer refused the combination with a generic
|
|
// error and the caller got `500 InternalError` -- "please try again" for a
|
|
// request that can only ever fail.
|
|
assert_eq!(
|
|
code, "InvalidRequest",
|
|
"a contradicting checksum type is a client error, got {code}: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("FULL_OBJECT") && message.contains("COMPOSITE"),
|
|
"the message must name the recorded and requested types, got {message}"
|
|
);
|
|
|
|
// The upload is untouched by the rejected completion, so a well-formed
|
|
// retry on the same upload id still succeeds.
|
|
let listed = client
|
|
.list_parts()
|
|
.bucket(bucket)
|
|
.key(key)
|
|
.upload_id(&upload_id)
|
|
.send()
|
|
.await
|
|
.expect("upload must survive the rejected completion");
|
|
assert_eq!(listed.parts().len(), 2, "both parts must still be listed after the rejection");
|
|
|
|
info!("PASSED: contradicting checksum type rejected as InvalidRequest");
|
|
}
|
|
|
|
/// Integration test for the AWS 2026-04 additional checksum algorithms
|
|
/// (XXHash3/64/128, SHA-512, MD5). aws_sdk_s3 has no typed builder for these, so the
|
|
/// `x-amz-checksum-<algo>` header is injected via `mutate_request` (value computed by
|
|
/// rustfs-rio, which is byte-for-byte identical to awscrt). Verifies the server
|
|
/// verifies-on-write: a correct value is accepted and the object stored; a wrong
|
|
/// value is rejected with BadDigest and nothing is stored. Full HEAD/GET header
|
|
/// echo round-trip is additionally exercised by the boto3+awscrt e2e.
|
|
#[tokio::test]
|
|
async fn test_additional_checksums_verify_on_write() {
|
|
init_logging();
|
|
info!("TEST: additional checksums (XXHash3/64/128, SHA-512, MD5) verify-on-write");
|
|
|
|
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 = "test-additional-checksums";
|
|
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
|
|
|
let content: &[u8] = b"additional-checksum verify-on-write payload for xxhash/sha512/md5";
|
|
|
|
for (ty, header) in [
|
|
(RioChecksumType::XXHASH3, "x-amz-checksum-xxhash3"),
|
|
(RioChecksumType::XXHASH64, "x-amz-checksum-xxhash64"),
|
|
(RioChecksumType::XXHASH128, "x-amz-checksum-xxhash128"),
|
|
(RioChecksumType::SHA512, "x-amz-checksum-sha512"),
|
|
(RioChecksumType::MD5, "x-amz-checksum-md5"),
|
|
] {
|
|
// Correct checksum -> accepted, and the object is stored intact.
|
|
let good = Checksum::new_from_data(ty, content).expect("compute checksum").encoded;
|
|
let ok_key = format!("ok-{header}");
|
|
let put = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(&ok_key)
|
|
.body(ByteStream::from_static(content))
|
|
.customize()
|
|
.mutate_request(move |req| {
|
|
req.headers_mut().insert(header, good.clone());
|
|
})
|
|
.send()
|
|
.await;
|
|
assert!(put.is_ok(), "{header}: correct checksum must be accepted: {:?}", put.err());
|
|
let got = client
|
|
.get_object()
|
|
.bucket(bucket)
|
|
.key(&ok_key)
|
|
.send()
|
|
.await
|
|
.expect("GetObject");
|
|
let body = got.body.collect().await.expect("collect body").into_bytes();
|
|
assert_eq!(body.as_ref(), content, "{header}: stored body must match uploaded content");
|
|
|
|
// Wrong checksum -> rejected with BadDigest, and nothing is stored.
|
|
let bad = Checksum::new_from_data(ty, b"a totally different payload")
|
|
.expect("compute checksum")
|
|
.encoded;
|
|
let bad_key = format!("bad-{header}");
|
|
let put_bad = client
|
|
.put_object()
|
|
.bucket(bucket)
|
|
.key(&bad_key)
|
|
.body(ByteStream::from_static(content))
|
|
.customize()
|
|
.mutate_request(move |req| {
|
|
req.headers_mut().insert(header, bad.clone());
|
|
})
|
|
.send()
|
|
.await;
|
|
let error = put_bad.expect_err("a mismatched checksum must be rejected");
|
|
assert_eq!(
|
|
error.raw_response().map(|response| response.status().as_u16()),
|
|
Some(400),
|
|
"{header}: mismatched checksum must return HTTP 400, got {error:?}"
|
|
);
|
|
assert_eq!(
|
|
error.as_service_error().and_then(ProvideErrorMetadata::code),
|
|
Some("BadDigest"),
|
|
"{header}: mismatched checksum must return BadDigest, got {error:?}"
|
|
);
|
|
let error = client
|
|
.head_object()
|
|
.bucket(bucket)
|
|
.key(&bad_key)
|
|
.send()
|
|
.await
|
|
.expect_err("nothing must be stored after a rejected PutObject");
|
|
assert_eq!(
|
|
error.raw_response().map(|response| response.status().as_u16()),
|
|
Some(404),
|
|
"{header}: rejected PutObject absence probe must return HTTP 404, got {error:?}"
|
|
);
|
|
|
|
info!("PASSED additional-checksum verify-on-write: {header}");
|
|
}
|
|
}
|
|
}
|