From 95c06966cc2fc6f42915d2af2d46305c8db65fc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=94=90=E5=B0=8F=E9=B8=AD?= Date: Wed, 16 Sep 2026 12:05:04 +0800 Subject: [PATCH] perf(rustfs): skip redundant content-sha256 pass on signed PUTs (#7928) A SigV4 header-signed or presigned PUT whose x-amz-content-sha256 header carries a real digest is already wrapped in a hash-checking UploadStream by the s3s auth layer, so the storage-side HashReader was hashing the same payload a second time. Hand the digest to the storage layer only when it arrives in a presigned query string alone, the one case s3s does not read. --- rustfs/src/storage/options.rs | 87 ++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/rustfs/src/storage/options.rs b/rustfs/src/storage/options.rs index cc607434a..35383cfc0 100644 --- a/rustfs/src/storage/options.rs +++ b/rustfs/src/storage/options.rs @@ -1043,9 +1043,25 @@ pub fn parse_copy_source_range(range_str: &str) -> S3Result { Err(s3_error!(InvalidArgument, "Invalid range format")) } } +/// Returns the `x-amz-content-sha256` digest the storage layer still has to +/// verify itself, or `None` when the payload hash has already been enforced. +/// +/// The s3s auth layer wraps a single-chunk body in a hash-checking +/// `UploadStream` whenever the `x-amz-content-sha256` *header* carries a real +/// digest, for both header-signed and presigned SigV4 requests, and rejects a +/// malformed header outright. Every request that reaches a handler with that +/// header has therefore already had its body verified (or is about to fail in +/// the body stream with the same `BadDigest` mapping), so hashing it again in +/// `HashReader` would cost a second full SHA-256 pass per PUT for no extra +/// protection. Streaming payloads are verified chunk by chunk by s3s and never +/// produced a digest here. +/// +/// s3s reads the header only. The one case it does not cover is a presigned +/// request whose digest arrives in the query string alone, which is why that +/// path still hands the digest to the storage layer. pub(crate) fn get_content_sha256_with_query(headers: &HeaderMap, query: Option<&str>) -> Option { match get_request_auth_type_with_query(headers, query) { - AuthType::Presigned | AuthType::Signed => { + AuthType::Presigned if !headers.contains_key(AMZ_CONTENT_SHA256) => { if skip_content_sha256_cksum_with_query(headers, query) { None } else { @@ -2747,4 +2763,73 @@ mod tests { "disguised key must be namespaced back under x-amz-meta-: {metadata:?}" ); } + + const PAYLOAD_SHA256: &str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"; + const PRESIGNED_QUERY: &str = "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ak%2F20260916%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Signature=deadbeef"; + + fn signed_v4_headers(content_sha256: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + HeaderValue::from_static( + "AWS4-HMAC-SHA256 Credential=ak/20260916/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=deadbeef", + ), + ); + headers.insert("x-amz-content-sha256", HeaderValue::from_static(content_sha256)); + headers + } + + /// The s3s auth layer already wraps a header-signed single-chunk body in a + /// hash-checking stream, so the storage layer must not schedule a second + /// SHA-256 pass over the same payload. + #[test] + fn signed_single_chunk_put_leaves_payload_sha256_to_the_auth_layer() { + let headers = signed_v4_headers(PAYLOAD_SHA256); + assert_eq!(super::get_content_sha256_with_query(&headers, None), None); + } + + #[test] + fn streaming_signed_put_never_hands_a_digest_to_the_storage_layer() { + for value in [ + "STREAMING-AWS4-HMAC-SHA256-PAYLOAD", + "STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER", + "STREAMING-UNSIGNED-PAYLOAD-TRAILER", + "UNSIGNED-PAYLOAD", + ] { + let headers = signed_v4_headers(value); + assert_eq!(super::get_content_sha256_with_query(&headers, None), None, "{value}"); + } + } + + /// s3s verifies the header digest of a presigned request too, so a header + /// digest is left to the auth layer exactly like the header-signed case. + #[test] + fn presigned_request_with_header_digest_leaves_payload_sha256_to_the_auth_layer() { + let mut headers = HeaderMap::new(); + headers.insert("x-amz-content-sha256", HeaderValue::from_static(PAYLOAD_SHA256)); + assert_eq!(super::get_content_sha256_with_query(&headers, Some(PRESIGNED_QUERY)), None); + } + + /// s3s only reads the header, so a digest that arrives in the query string + /// alone is the one case the storage layer still verifies itself. + #[test] + fn presigned_request_with_query_only_digest_is_still_verified_by_the_storage_layer() { + let headers = HeaderMap::new(); + + let query = format!("{PRESIGNED_QUERY}&x-amz-content-sha256={PAYLOAD_SHA256}"); + assert_eq!( + super::get_content_sha256_with_query(&headers, Some(&query)), + Some(PAYLOAD_SHA256.to_string()) + ); + + let query = format!("{PRESIGNED_QUERY}&x-amz-content-sha256=UNSIGNED-PAYLOAD"); + assert_eq!(super::get_content_sha256_with_query(&headers, Some(&query)), None); + } + + #[test] + fn anonymous_request_hands_no_digest_to_the_storage_layer() { + let mut headers = HeaderMap::new(); + headers.insert("x-amz-content-sha256", HeaderValue::from_static(PAYLOAD_SHA256)); + assert_eq!(super::get_content_sha256_with_query(&headers, None), None); + } }