use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as B64;
use faucet_core::{IntegrityCheck, LengthCheck};
static CRC32: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
static CRC32C: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISCSI);
static CRC64NVME: crc::Crc<u64> = crc::Crc::<u64>::new(&crc::CRC_64_NVME);
pub fn length_check(
content_length: Option<i64>,
verify_length: bool,
) -> Option<Box<dyn IntegrityCheck>> {
if verify_length
&& let Some(len) = content_length
&& len >= 0
{
return Some(Box::new(LengthCheck::new(len as u64)));
}
None
}
#[derive(Debug, Default, Clone)]
pub struct S3Checksums {
pub crc32: Option<String>,
pub crc32c: Option<String>,
pub crc64nvme: Option<String>,
pub sha256: Option<String>,
pub etag: Option<String>,
}
impl S3Checksums {
pub fn has_verifiable(&self) -> bool {
self.sha256.is_some()
|| self.crc64nvme.is_some()
|| self.crc32c.is_some()
|| self.crc32.is_some()
|| self.etag.as_deref().and_then(non_multipart_md5).is_some()
}
}
pub fn checksum_check(c: &S3Checksums) -> Option<Box<dyn IntegrityCheck>> {
if let Some(b64) = &c.sha256 {
return Some(Box::new(Sha256Check::new(b64.clone())));
}
if let Some(b64) = &c.crc64nvme {
return Some(Box::new(CrcCheck::new_u64(
&CRC64NVME,
"CRC64NVME",
b64.clone(),
)));
}
if let Some(b64) = &c.crc32c {
return Some(Box::new(CrcCheck::new_u32(&CRC32C, "CRC32C", b64.clone())));
}
if let Some(b64) = &c.crc32 {
return Some(Box::new(CrcCheck::new_u32(&CRC32, "CRC32", b64.clone())));
}
if let Some(tag) = &c.etag
&& let Some(hex) = non_multipart_md5(tag)
{
return Some(Box::new(Md5HexCheck::new(hex)));
}
None
}
fn non_multipart_md5(etag: &str) -> Option<String> {
let t = etag.trim().trim_matches('"');
if t.len() == 32 && t.bytes().all(|b| b.is_ascii_hexdigit()) {
Some(t.to_ascii_lowercase())
} else {
None
}
}
fn mismatch(name: &str, got: &str, want: &str) -> Result<(), String> {
if got.eq_ignore_ascii_case(want) {
Ok(())
} else {
Err(format!(
"{name} checksum mismatch: object store advertised {want} but body computed {got} \
(corrupted transfer)"
))
}
}
struct CrcCheck {
digest: CrcDigest,
name: &'static str,
expected_b64: String,
}
enum CrcDigest {
W32(crc::Digest<'static, u32>),
W64(crc::Digest<'static, u64>),
}
impl CrcCheck {
fn new_u32(crc: &'static crc::Crc<u32>, name: &'static str, expected_b64: String) -> Self {
Self {
digest: CrcDigest::W32(crc.digest()),
name,
expected_b64,
}
}
fn new_u64(crc: &'static crc::Crc<u64>, name: &'static str, expected_b64: String) -> Self {
Self {
digest: CrcDigest::W64(crc.digest()),
name,
expected_b64,
}
}
}
impl IntegrityCheck for CrcCheck {
fn update(&mut self, chunk: &[u8]) {
match &mut self.digest {
CrcDigest::W32(d) => d.update(chunk),
CrcDigest::W64(d) => d.update(chunk),
}
}
fn finalize(self: Box<Self>, _total: u64) -> Result<(), String> {
let me = *self;
let got = match me.digest {
CrcDigest::W32(d) => B64.encode(d.finalize().to_be_bytes()),
CrcDigest::W64(d) => B64.encode(d.finalize().to_be_bytes()),
};
mismatch(me.name, &got, &me.expected_b64)
}
}
struct Sha256Check {
hasher: sha2::Sha256,
expected_b64: String,
}
impl Sha256Check {
fn new(expected_b64: String) -> Self {
use sha2::Digest as _;
Self {
hasher: sha2::Sha256::new(),
expected_b64,
}
}
}
impl IntegrityCheck for Sha256Check {
fn update(&mut self, chunk: &[u8]) {
use sha2::Digest as _;
self.hasher.update(chunk);
}
fn finalize(self: Box<Self>, _total: u64) -> Result<(), String> {
use sha2::Digest as _;
let got = B64.encode(self.hasher.finalize());
mismatch("SHA256", &got, &self.expected_b64)
}
}
struct Md5HexCheck {
hasher: md5::Md5,
expected_hex: String,
}
impl Md5HexCheck {
fn new(expected_hex: String) -> Self {
use md5::Digest as _;
Self {
hasher: md5::Md5::new(),
expected_hex,
}
}
}
impl IntegrityCheck for Md5HexCheck {
fn update(&mut self, chunk: &[u8]) {
use md5::Digest as _;
self.hasher.update(chunk);
}
fn finalize(self: Box<Self>, _total: u64) -> Result<(), String> {
use md5::Digest as _;
let digest = self.hasher.finalize();
let got: String = digest.iter().map(|b| format!("{b:02x}")).collect();
mismatch("MD5(ETag)", &got, &self.expected_hex)
}
}
#[cfg(test)]
mod tests {
use super::*;
use faucet_core::VerifyingReader;
use tokio::io::AsyncReadExt as _;
async fn reads_ok(check: Option<Box<dyn IntegrityCheck>>, body: &[u8]) -> bool {
let checks: Vec<Box<dyn IntegrityCheck>> = check.into_iter().collect();
let mut reader = VerifyingReader::new(body, checks);
let mut out = Vec::new();
reader.read_to_end(&mut out).await.is_ok()
}
fn b64_of_hex(hex: &str) -> String {
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
.collect();
B64.encode(bytes)
}
#[tokio::test]
async fn length_truncation_rejected() {
assert!(!reads_ok(length_check(Some(10), true), b"hello").await);
}
#[tokio::test]
async fn length_complete_passes() {
assert!(reads_ok(length_check(Some(5), true), b"hello").await);
}
#[tokio::test]
async fn length_none_when_disabled() {
assert!(length_check(Some(10), false).is_none());
}
#[tokio::test]
async fn length_none_when_unknown_or_negative() {
assert!(length_check(None, true).is_none());
assert!(length_check(Some(-1), true).is_none());
}
#[tokio::test]
async fn sha256_match_and_mismatch() {
let want = b64_of_hex("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
let good = S3Checksums {
sha256: Some(want),
..Default::default()
};
assert!(reads_ok(checksum_check(&good), b"hello").await);
let bad = S3Checksums {
sha256: Some(b64_of_hex("00")),
..Default::default()
};
assert!(!reads_ok(checksum_check(&bad), b"hello").await);
}
#[tokio::test]
async fn crc32_match() {
let want = B64.encode(0x3610a686u32.to_be_bytes());
let c = S3Checksums {
crc32: Some(want),
..Default::default()
};
assert!(reads_ok(checksum_check(&c), b"hello").await);
}
#[tokio::test]
async fn crc32c_match_and_mismatch() {
let want = B64.encode(0x9a71bb4cu32.to_be_bytes());
let good = S3Checksums {
crc32c: Some(want),
..Default::default()
};
assert!(reads_ok(checksum_check(&good), b"hello").await);
let bad = S3Checksums {
crc32c: Some(B64.encode(0xdeadbeefu32.to_be_bytes())),
..Default::default()
};
assert!(!reads_ok(checksum_check(&bad), b"hello").await);
}
#[tokio::test]
async fn crc64nvme_round_trips() {
let want = B64.encode(CRC64NVME.checksum(b"hello").to_be_bytes());
let c = S3Checksums {
crc64nvme: Some(want),
..Default::default()
};
assert!(reads_ok(checksum_check(&c), b"hello").await);
}
#[tokio::test]
async fn etag_md5_match_when_non_multipart() {
let c = S3Checksums {
etag: Some("\"5d41402abc4b2a76b9719d911017c592\"".into()),
..Default::default()
};
assert!(reads_ok(checksum_check(&c), b"hello").await);
}
#[test]
fn multipart_etag_is_not_treated_as_md5() {
assert!(non_multipart_md5("\"d41d8cd98f00b204e9800998ecf8427e-3\"").is_none());
assert!(non_multipart_md5("\"5d41402abc4b2a76b9719d911017c592\"").is_some());
}
#[test]
fn priority_prefers_sha256_over_weaker() {
let c = S3Checksums {
sha256: Some(b64_of_hex(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
)),
crc32: Some("bogus".into()),
etag: Some("\"5d41402abc4b2a76b9719d911017c592\"".into()),
..Default::default()
};
assert!(c.has_verifiable());
assert!(checksum_check(&c).is_some());
}
#[test]
fn no_checksum_yields_none() {
let empty = S3Checksums::default();
assert!(!empty.has_verifiable());
assert!(checksum_check(&empty).is_none());
let mp = S3Checksums {
etag: Some("\"abc-2\"".into()),
..Default::default()
};
assert!(!mp.has_verifiable());
assert!(checksum_check(&mp).is_none());
}
}