use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use base64::Engine as _;
use bytes::Bytes;
use crc32fast::Hasher as Crc32Hasher;
use futures::{Stream, StreamExt};
use md5::{Digest as Md5Digest, Md5};
use s3s::dto::StreamingBlob;
use s3s::stream::{ByteStream, RemainingLength};
use s3s::{S3Error, S3ErrorCode, S3Result};
use sha1::Sha1;
use sha2::Sha256;
use std::sync::Mutex;
#[derive(Debug, Clone)]
pub struct StreamingChecksumError {
pub algorithm: &'static str,
}
impl std::fmt::Display for StreamingChecksumError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"client-supplied {} did not match the streamed body",
self.algorithm
)
}
}
impl std::error::Error for StreamingChecksumError {}
#[derive(Debug, Default, Clone)]
pub struct ClientChecksums {
content_md5: Option<[u8; 16]>,
crc32: Option<[u8; 4]>,
crc32c: Option<[u8; 4]>,
sha1: Option<[u8; 20]>,
sha256: Option<[u8; 32]>,
crc64nvme: Option<[u8; 8]>,
}
impl ClientChecksums {
pub fn any(&self) -> bool {
self.content_md5.is_some()
|| self.crc32.is_some()
|| self.crc32c.is_some()
|| self.sha1.is_some()
|| self.sha256.is_some()
|| self.crc64nvme.is_some()
}
pub fn from_request_fields(
content_md5: Option<&str>,
crc32: Option<&str>,
crc32c: Option<&str>,
sha1: Option<&str>,
sha256: Option<&str>,
crc64nvme: Option<&str>,
) -> S3Result<Self> {
let b64 = base64::engine::general_purpose::STANDARD;
let decode_fixed = |val: &str, expected_len: usize, label: &str| -> S3Result<Vec<u8>> {
let v = b64.decode(val).map_err(|_| {
S3Error::with_message(S3ErrorCode::InvalidDigest, format!("malformed {label}"))
})?;
if v.len() != expected_len {
return Err(S3Error::with_message(
S3ErrorCode::InvalidDigest,
format!("{label} must decode to {expected_len} bytes"),
));
}
Ok(v)
};
let mut out = ClientChecksums::default();
if let Some(v) = content_md5 {
let bytes = decode_fixed(v, 16, "Content-MD5")?;
let mut arr = [0u8; 16];
arr.copy_from_slice(&bytes);
out.content_md5 = Some(arr);
}
if let Some(v) = crc32 {
let bytes = decode_fixed(v, 4, "x-amz-checksum-crc32")?;
let mut arr = [0u8; 4];
arr.copy_from_slice(&bytes);
out.crc32 = Some(arr);
}
if let Some(v) = crc32c {
let bytes = decode_fixed(v, 4, "x-amz-checksum-crc32c")?;
let mut arr = [0u8; 4];
arr.copy_from_slice(&bytes);
out.crc32c = Some(arr);
}
if let Some(v) = sha1 {
let bytes = decode_fixed(v, 20, "x-amz-checksum-sha1")?;
let mut arr = [0u8; 20];
arr.copy_from_slice(&bytes);
out.sha1 = Some(arr);
}
if let Some(v) = sha256 {
let bytes = decode_fixed(v, 32, "x-amz-checksum-sha256")?;
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
out.sha256 = Some(arr);
}
if let Some(v) = crc64nvme {
let bytes = decode_fixed(v, 8, "x-amz-checksum-crc64nvme")?;
let mut arr = [0u8; 8];
arr.copy_from_slice(&bytes);
out.crc64nvme = Some(arr);
}
Ok(out)
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct WhichHashers {
pub content_md5: bool,
pub crc32: bool,
pub crc32c: bool,
pub sha1: bool,
pub sha256: bool,
pub crc64nvme: bool,
}
impl WhichHashers {
pub fn any(&self) -> bool {
self.content_md5 || self.crc32 || self.crc32c || self.sha1 || self.sha256 || self.crc64nvme
}
pub fn or(self, other: Self) -> Self {
Self {
content_md5: self.content_md5 || other.content_md5,
crc32: self.crc32 || other.crc32,
crc32c: self.crc32c || other.crc32c,
sha1: self.sha1 || other.sha1,
sha256: self.sha256 || other.sha256,
crc64nvme: self.crc64nvme || other.crc64nvme,
}
}
pub fn from_trailer_header(value: &str) -> Self {
let mut out = Self::default();
for raw in value.split(',') {
let name = raw.trim();
if name.eq_ignore_ascii_case("x-amz-checksum-crc32") {
out.crc32 = true;
} else if name.eq_ignore_ascii_case("x-amz-checksum-crc32c") {
out.crc32c = true;
} else if name.eq_ignore_ascii_case("x-amz-checksum-sha1") {
out.sha1 = true;
} else if name.eq_ignore_ascii_case("x-amz-checksum-sha256") {
out.sha256 = true;
} else if name.eq_ignore_ascii_case("x-amz-checksum-crc64nvme") {
out.crc64nvme = true;
}
}
out
}
}
impl ClientChecksums {
pub fn which_hashers(&self) -> WhichHashers {
WhichHashers {
content_md5: self.content_md5.is_some(),
crc32: self.crc32.is_some(),
crc32c: self.crc32c.is_some(),
sha1: self.sha1.is_some(),
sha256: self.sha256.is_some(),
crc64nvme: self.crc64nvme.is_some(),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct ComputedDigests {
pub content_md5: Option<[u8; 16]>,
pub crc32_be: Option<[u8; 4]>,
pub crc32c_be: Option<[u8; 4]>,
pub sha1: Option<[u8; 20]>,
pub sha256: Option<[u8; 32]>,
pub crc64nvme_be: Option<[u8; 8]>,
}
impl ComputedDigests {
pub fn compare_b64(&self, algorithm: &str, claim_b64: &str) -> S3Result<()> {
let b64 = base64::engine::general_purpose::STANDARD;
let want = b64.decode(claim_b64).map_err(|_| {
S3Error::with_message(S3ErrorCode::InvalidDigest, format!("malformed {algorithm}"))
})?;
let bad = || {
let code =
S3ErrorCode::from_bytes(b"BadDigest").unwrap_or(S3ErrorCode::InvalidArgument);
S3Error::with_message(
code,
format!("client-supplied {algorithm} did not match the received body"),
)
};
let len_err = |expected: usize| {
S3Error::with_message(
S3ErrorCode::InvalidDigest,
format!("{algorithm} must decode to {expected} bytes"),
)
};
let lc = algorithm.to_ascii_lowercase();
match lc.as_str() {
"content-md5" => {
if want.len() != 16 {
return Err(len_err(16));
}
if let Some(got) = self.content_md5
&& got[..] == want[..]
{
return Ok(());
}
Err(bad())
}
"x-amz-checksum-crc32" => {
if want.len() != 4 {
return Err(len_err(4));
}
if let Some(got) = self.crc32_be
&& got[..] == want[..]
{
return Ok(());
}
Err(bad())
}
"x-amz-checksum-crc32c" => {
if want.len() != 4 {
return Err(len_err(4));
}
if let Some(got) = self.crc32c_be
&& got[..] == want[..]
{
return Ok(());
}
Err(bad())
}
"x-amz-checksum-sha1" => {
if want.len() != 20 {
return Err(len_err(20));
}
if let Some(got) = self.sha1
&& got[..] == want[..]
{
return Ok(());
}
Err(bad())
}
"x-amz-checksum-sha256" => {
if want.len() != 32 {
return Err(len_err(32));
}
if let Some(got) = self.sha256
&& got[..] == want[..]
{
return Ok(());
}
Err(bad())
}
"x-amz-checksum-crc64nvme" => {
if want.len() != 8 {
return Err(len_err(8));
}
if let Some(got) = self.crc64nvme_be
&& got[..] == want[..]
{
return Ok(());
}
Err(bad())
}
_ => Err(S3Error::with_message(
S3ErrorCode::InvalidArgument,
format!("unknown checksum trailer: {algorithm}"),
)),
}
}
}
pub type DigestHandle = Arc<Mutex<Option<ComputedDigests>>>;
struct HasherSet {
expected: ClientChecksums,
which: WhichHashers,
crc32: Crc32Hasher,
crc32c_acc: u32,
crc64nvme_acc: u64,
md5: Md5,
sha1: Sha1,
sha256: Sha256,
}
impl HasherSet {
fn new(expected: ClientChecksums, which: WhichHashers) -> Self {
Self {
expected,
which,
crc32: Crc32Hasher::new(),
crc32c_acc: 0,
crc64nvme_acc: !0u64,
md5: Md5::new(),
sha1: Sha1::new(),
sha256: Sha256::new(),
}
}
fn update(&mut self, chunk: &[u8]) {
if self.which.crc32 {
self.crc32.update(chunk);
}
if self.which.crc32c {
self.crc32c_acc = crc32c::crc32c_append(self.crc32c_acc, chunk);
}
if self.which.crc64nvme {
self.crc64nvme_acc = crc64_nvme_append(self.crc64nvme_acc, chunk);
}
if self.which.content_md5 {
self.md5.update(chunk);
}
if self.which.sha1 {
self.sha1.update(chunk);
}
if self.which.sha256 {
self.sha256.update(chunk);
}
}
fn finalize(self) -> ComputedDigests {
let mut out = ComputedDigests::default();
if self.which.content_md5 {
let d = self.md5.finalize();
let mut arr = [0u8; 16];
arr.copy_from_slice(&d);
out.content_md5 = Some(arr);
}
if self.which.crc32 {
out.crc32_be = Some(self.crc32.finalize().to_be_bytes());
}
if self.which.crc32c {
out.crc32c_be = Some(self.crc32c_acc.to_be_bytes());
}
if self.which.sha1 {
let d = self.sha1.finalize();
let mut arr = [0u8; 20];
arr.copy_from_slice(&d);
out.sha1 = Some(arr);
}
if self.which.sha256 {
let d = self.sha256.finalize();
let mut arr = [0u8; 32];
arr.copy_from_slice(&d);
out.sha256 = Some(arr);
}
if self.which.crc64nvme {
out.crc64nvme_be = Some((!self.crc64nvme_acc).to_be_bytes());
}
out
}
fn compare_header_claims(
digests: &ComputedDigests,
expected: &ClientChecksums,
) -> Result<(), StreamingChecksumError> {
if let (Some(want), Some(got)) = (expected.content_md5, digests.content_md5)
&& got != want
{
return Err(StreamingChecksumError {
algorithm: "Content-MD5",
});
}
if let (Some(want), Some(got)) = (expected.crc32, digests.crc32_be)
&& got != want
{
return Err(StreamingChecksumError {
algorithm: "x-amz-checksum-crc32",
});
}
if let (Some(want), Some(got)) = (expected.crc32c, digests.crc32c_be)
&& got != want
{
return Err(StreamingChecksumError {
algorithm: "x-amz-checksum-crc32c",
});
}
if let (Some(want), Some(got)) = (expected.sha1, digests.sha1)
&& got != want
{
return Err(StreamingChecksumError {
algorithm: "x-amz-checksum-sha1",
});
}
if let (Some(want), Some(got)) = (expected.sha256, digests.sha256)
&& got != want
{
return Err(StreamingChecksumError {
algorithm: "x-amz-checksum-sha256",
});
}
if let (Some(want), Some(got)) = (expected.crc64nvme, digests.crc64nvme_be)
&& got != want
{
return Err(StreamingChecksumError {
algorithm: "x-amz-checksum-crc64nvme",
});
}
Ok(())
}
}
fn crc64_nvme_append(init: u64, bytes: &[u8]) -> u64 {
use std::sync::OnceLock;
static TABLE: OnceLock<[u64; 256]> = OnceLock::new();
let tbl = TABLE.get_or_init(|| {
const POLY_REFLECTED: u64 = 0x9a6c_9329_ac4b_c9b5;
let mut t = [0u64; 256];
let mut i = 0usize;
while i < 256 {
let mut c = i as u64;
let mut j = 0;
while j < 8 {
c = if c & 1 != 0 {
(c >> 1) ^ POLY_REFLECTED
} else {
c >> 1
};
j += 1;
}
t[i] = c;
i += 1;
}
t
});
let mut crc = init;
for &b in bytes {
let idx = ((crc as u8) ^ b) as usize;
crc = (crc >> 8) ^ tbl[idx];
}
crc
}
struct TeeStream {
inner: StreamingBlob,
state: Arc<Mutex<TeeState>>,
digests_out: DigestHandle,
}
struct TeeState {
hashers: Option<HasherSet>,
}
impl Stream for TeeStream {
type Item = Result<Bytes, s3s::StdError>;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let this = self.get_mut();
match this.inner.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(chunk))) => {
let mut guard = this.state.lock().expect("tee hasher lock poisoned");
if let Some(h) = guard.hashers.as_mut() {
h.update(&chunk);
}
Poll::Ready(Some(Ok(chunk)))
}
Poll::Ready(Some(Err(e))) => {
let mut guard = this.state.lock().expect("tee hasher lock poisoned");
guard.hashers = None;
Poll::Ready(Some(Err(e)))
}
Poll::Ready(None) => {
let mut guard = this.state.lock().expect("tee hasher lock poisoned");
if let Some(hashers) = guard.hashers.take() {
let expected_header_claims = hashers.expected.clone();
let digests = hashers.finalize();
*this
.digests_out
.lock()
.expect("digest handle lock poisoned") = Some(digests.clone());
if let Err(mismatch) =
HasherSet::compare_header_claims(&digests, &expected_header_claims)
{
let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, mismatch);
let boxed: s3s::StdError = Box::new(io_err);
return Poll::Ready(Some(Err(boxed)));
}
}
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
impl ByteStream for TeeStream {
fn remaining_length(&self) -> RemainingLength {
self.inner.remaining_length()
}
}
pub fn tee_into_hashers_with_handle(
inner: StreamingBlob,
expected: ClientChecksums,
which: WhichHashers,
) -> (StreamingBlob, DigestHandle) {
let digests_out: DigestHandle = Arc::new(Mutex::new(None));
let state = Arc::new(Mutex::new(TeeState {
hashers: Some(HasherSet::new(expected, which)),
}));
let tee = TeeStream {
inner,
state,
digests_out: Arc::clone(&digests_out),
};
(StreamingBlob::new(tee), digests_out)
}
pub fn tee_into_hashers(inner: StreamingBlob, expected: ClientChecksums) -> StreamingBlob {
let which = expected.which_hashers();
let (blob, _handle) = tee_into_hashers_with_handle(inner, expected, which);
blob
}
pub fn compute_digests(body: &[u8], which: WhichHashers) -> ComputedDigests {
let mut hashers = HasherSet::new(ClientChecksums::default(), which);
hashers.update(body);
hashers.finalize()
}
pub fn extract_streaming_checksum_error(err: &std::io::Error) -> Option<&'static str> {
if let Some(inner) = err.get_ref()
&& let Some(s) = inner.downcast_ref::<StreamingChecksumError>()
{
return Some(s.algorithm);
}
if let Some(inner) = err.get_ref()
&& let Some(nested_io) = inner.downcast_ref::<std::io::Error>()
&& let Some(deeper) = nested_io.get_ref()
&& let Some(s) = deeper.downcast_ref::<StreamingChecksumError>()
{
return Some(s.algorithm);
}
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(err);
while let Some(e) = src {
if let Some(s) = e.downcast_ref::<StreamingChecksumError>() {
return Some(s.algorithm);
}
src = e.source();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
use futures::stream;
fn b64encode(b: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(b)
}
fn make_chunked_blob(chunks: Vec<Bytes>) -> StreamingBlob {
let stream = stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>));
StreamingBlob::wrap(stream)
}
async fn drain(blob: StreamingBlob) -> Result<Vec<u8>, String> {
let mut s = blob;
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
let chunk = chunk.map_err(|e| format!("{e}"))?;
out.extend_from_slice(&chunk);
}
Ok(out)
}
#[tokio::test]
async fn tee_with_no_claims_is_passthrough() {
let body = Bytes::from_static(b"hello streaming s4");
let blob = make_chunked_blob(vec![body.clone()]);
let wrapped = tee_into_hashers(blob, ClientChecksums::default());
let got = drain(wrapped).await.unwrap();
assert_eq!(got, body.to_vec());
}
#[tokio::test]
async fn crc32c_match_yields_full_body() {
let body: Vec<u8> = (0..50_000u32).map(|i| i as u8).collect();
let crc = crc32c::crc32c(&body).to_be_bytes();
let claims = ClientChecksums::from_request_fields(
None,
None,
Some(&b64encode(&crc)),
None,
None,
None,
)
.unwrap();
let blob = make_chunked_blob(vec![
Bytes::copy_from_slice(&body[..20_000]),
Bytes::copy_from_slice(&body[20_000..]),
]);
let wrapped = tee_into_hashers(blob, claims);
let got = drain(wrapped).await.unwrap();
assert_eq!(got, body);
}
#[tokio::test]
async fn crc32c_mismatch_fires_at_eof() {
let body: Vec<u8> = vec![b'a'; 4096];
let wrong_crc = (crc32c::crc32c(&body) ^ 0xFFFF_FFFF).to_be_bytes();
let claims = ClientChecksums::from_request_fields(
None,
None,
Some(&b64encode(&wrong_crc)),
None,
None,
None,
)
.unwrap();
let blob = make_chunked_blob(vec![Bytes::copy_from_slice(&body)]);
let wrapped = tee_into_hashers(blob, claims);
let err = drain(wrapped).await.unwrap_err();
assert!(
err.contains("x-amz-checksum-crc32c"),
"error must name the failing algorithm, got: {err}"
);
}
#[tokio::test]
async fn sha256_match_succeeds_across_many_small_chunks() {
let body: Vec<u8> = (0..123_456u32).map(|i| (i ^ 0x5a) as u8).collect();
let digest = {
let mut h = Sha256::new();
h.update(&body);
h.finalize()
};
let claims = ClientChecksums::from_request_fields(
None,
None,
None,
None,
Some(&b64encode(&digest)),
None,
)
.unwrap();
let chunks: Vec<Bytes> = body.chunks(1024).map(Bytes::copy_from_slice).collect();
let blob = make_chunked_blob(chunks);
let wrapped = tee_into_hashers(blob, claims);
let got = drain(wrapped).await.unwrap();
assert_eq!(got, body);
}
#[tokio::test]
async fn multi_algorithm_one_wrong_fires() {
let body = vec![0u8; 8192];
let crc32c_be = crc32c::crc32c(&body).to_be_bytes();
let mut sha = Sha256::new();
sha.update(&body);
let sha_correct = sha.finalize();
let mut sha_wrong = sha_correct.to_vec();
sha_wrong[0] ^= 0xFF;
let claims = ClientChecksums::from_request_fields(
None,
None,
Some(&b64encode(&crc32c_be)),
None,
Some(&b64encode(&sha_wrong)),
None,
)
.unwrap();
let blob = make_chunked_blob(vec![Bytes::copy_from_slice(&body)]);
let wrapped = tee_into_hashers(blob, claims);
let err = drain(wrapped).await.unwrap_err();
assert!(
err.contains("x-amz-checksum-sha256"),
"expected sha256 mismatch, got: {err}"
);
}
#[test]
fn from_request_fields_rejects_malformed_base64() {
let err = ClientChecksums::from_request_fields(
None,
None,
Some("not-base-64!!!"),
None,
None,
None,
)
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidDigest);
}
#[test]
fn from_request_fields_rejects_wrong_length() {
let too_short = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3]);
let err =
ClientChecksums::from_request_fields(None, None, Some(&too_short), None, None, None)
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidDigest);
}
#[test]
fn crc64_nvme_empty_input_is_zero() {
let crc = crc64_nvme_append(!0u64, b"");
assert_eq!(!crc, 0u64, "NVMe empty-input CRC must be 0");
}
#[test]
fn extract_recovers_algorithm() {
let mismatch = StreamingChecksumError {
algorithm: "x-amz-checksum-crc32c",
};
let io = std::io::Error::new(std::io::ErrorKind::InvalidData, mismatch);
assert_eq!(
extract_streaming_checksum_error(&io),
Some("x-amz-checksum-crc32c")
);
}
#[test]
fn extract_returns_none_for_unrelated_io_error() {
let io = std::io::Error::other("unrelated");
assert_eq!(extract_streaming_checksum_error(&io), None);
}
#[test]
fn which_hashers_from_trailer_header_parses_all_known_names() {
let w = WhichHashers::from_trailer_header(
"x-amz-checksum-crc32, X-Amz-Checksum-Crc32c, x-amz-trailer-signature",
);
assert!(w.crc32);
assert!(w.crc32c);
assert!(!w.sha1);
assert!(!w.sha256);
assert!(!w.crc64nvme);
assert!(!w.content_md5);
let w2 = WhichHashers::from_trailer_header("x-amz-checksum-sha256");
assert!(w2.sha256);
assert!(!w2.crc32c);
let w3 = WhichHashers::from_trailer_header("x-amz-checksum-crc64nvme");
assert!(w3.crc64nvme);
}
#[tokio::test]
async fn tee_with_handle_stashes_digests_for_trailer_compare() {
let body: Vec<u8> = vec![7u8; 9000];
let which = WhichHashers {
crc32c: true,
sha256: true,
..Default::default()
};
let blob = make_chunked_blob(vec![Bytes::copy_from_slice(&body)]);
let (wrapped, handle) =
tee_into_hashers_with_handle(blob, ClientChecksums::default(), which);
let got = drain(wrapped).await.unwrap();
assert_eq!(got, body);
let computed = handle.lock().unwrap().clone().expect("digests stashed");
let expected_crc32c = crc32c::crc32c(&body).to_be_bytes();
assert_eq!(computed.crc32c_be, Some(expected_crc32c));
let expected_sha256 = {
let mut h = Sha256::new();
h.update(&body);
h.finalize()
};
assert_eq!(computed.sha256.unwrap(), expected_sha256[..]);
}
#[test]
fn computed_digests_compare_b64_match_and_mismatch() {
let body = b"sample-bytes";
let d = ComputedDigests {
crc32c_be: Some(crc32c::crc32c(body).to_be_bytes()),
..Default::default()
};
d.compare_b64(
"x-amz-checksum-crc32c",
&b64encode(&crc32c::crc32c(body).to_be_bytes()),
)
.expect("match must succeed");
let err = d
.compare_b64("x-amz-checksum-crc32c", &b64encode(&[0u8; 4]))
.unwrap_err();
assert_eq!(err.code().as_str(), "BadDigest");
let err = d
.compare_b64("x-amz-checksum-crc32c", "@@@not-b64@@@")
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidDigest);
let err = d
.compare_b64("x-amz-checksum-crc32c", &b64encode(&[0u8; 8]))
.unwrap_err();
assert_eq!(err.code(), &S3ErrorCode::InvalidDigest);
}
#[test]
fn computed_digests_compare_b64_against_unhashed_algorithm_rejects() {
let d = ComputedDigests::default(); let err = d
.compare_b64("x-amz-checksum-sha256", &b64encode(&[0u8; 32]))
.unwrap_err();
assert_eq!(err.code().as_str(), "BadDigest");
}
#[test]
fn computed_digests_compare_b64_case_insensitive_algorithm() {
let body = b"sample";
let d = ComputedDigests {
crc32c_be: Some(crc32c::crc32c(body).to_be_bytes()),
..Default::default()
};
let want = b64encode(&crc32c::crc32c(body).to_be_bytes());
for variant in [
"x-amz-checksum-crc32c",
"X-Amz-Checksum-Crc32c",
"X-AMZ-CHECKSUM-CRC32C",
"x-AMZ-checksum-CRC32C",
] {
d.compare_b64(variant, &want)
.unwrap_or_else(|e| panic!("variant {variant} must match, got {e:?}"));
}
}
}