From 0893c05540053592f16ada9609a3dc8a10bf34ab Mon Sep 17 00:00:00 2001 From: Henry Guo Date: Thu, 14 May 2026 13:34:26 +0800 Subject: [PATCH] fix(ecstore): use hex sha256 for delete objects (#2958) Co-authored-by: houseme --- crates/ecstore/src/client/api_remove.rs | 114 +++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/crates/ecstore/src/client/api_remove.rs b/crates/ecstore/src/client/api_remove.rs index c62d1c324..cd2390a49 100644 --- a/crates/ecstore/src/client/api_remove.rs +++ b/crates/ecstore/src/client/api_remove.rs @@ -340,7 +340,7 @@ impl TransitionClient { content_body: ReaderImpl::Body(Bytes::from(remove_bytes.clone())), content_length: remove_bytes.len() as i64, content_md5_base64: base64_encode(&HashAlgorithm::Md5.hash_encode(&remove_bytes).as_ref()), - content_sha256_hex: base64_encode(&HashAlgorithm::SHA256.hash_encode(&remove_bytes).as_ref()), + content_sha256_hex: rustfs_utils::hex(HashAlgorithm::SHA256.hash_encode(&remove_bytes)), custom_header: headers, object_name: "".to_string(), stream_sha256: false, @@ -742,3 +742,115 @@ pub async fn process_remove_multi_objects_response( fn has_invalid_xml_char(str: &str) -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::{ + credentials::{Credentials, SignatureType, Static, Value}, + transition_api::{BucketLookupType, Options}, + }; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + + async fn capture_delete_objects_sha256_header() -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = listener.local_addr().unwrap().to_string(); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + loop { + let read = stream.read(&mut buffer).await.unwrap(); + assert_ne!(read, 0, "connection closed before request headers were received"); + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + let request = String::from_utf8_lossy(&request); + let sha256_header = request + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("x-amz-content-sha256") + .then(|| value.trim().to_string()) + }) + .expect("delete objects request should include X-Amz-Content-Sha256"); + + let response_body = r#"object.txt"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + stream.write_all(response.as_bytes()).await.unwrap(); + sha256_header + }); + + (endpoint, task) + } + + #[tokio::test] + async fn multi_object_delete_request_uses_lowercase_hex_sha256_header() { + let objects = vec![ObjectInfo { + bucket: "bucket".to_string(), + name: "object.txt".to_string(), + ..Default::default() + }]; + let body = generate_remove_multi_objects_request(&objects); + let expected = rustfs_utils::hex(HashAlgorithm::SHA256.hash_encode(&body)); + let (endpoint, header_task) = capture_delete_objects_sha256_header().await; + let client = TransitionClient::new( + &endpoint, + Options { + creds: Credentials::new(Static(Value { + access_key_id: "access-key".to_string(), + secret_access_key: "secret-key".to_string(), + signer_type: SignatureType::SignatureV4, + ..Default::default() + })), + region: "us-east-1".to_string(), + bucket_lookup: BucketLookupType::BucketLookupPath, + max_retries: 1, + ..Default::default() + }, + "", + ) + .await + .unwrap(); + let (objects_tx, objects_rx) = mpsc::channel(1); + let (result_tx, mut result_rx) = mpsc::channel(1); + + objects_tx.send(objects[0].clone()).await.unwrap(); + drop(objects_tx); + + client + .remove_objects_inner( + "bucket", + objects_rx, + &result_tx, + RemoveObjectsOptions { + governance_bypass: false, + }, + ) + .await + .unwrap(); + drop(result_tx); + + let header = header_task.await.unwrap(); + + assert_eq!(header, expected); + assert_eq!(header.len(), 64); + assert!( + header + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + ); + assert_ne!(header, base64_encode(&HashAlgorithm::SHA256.hash_encode(&body).as_ref())); + assert!(result_rx.recv().await.is_some()); + } +}