Skip to main content

faucet_source_s3/
verify.rs

1//! Build the [`IntegrityCheck`] set for an S3 object read (#161).
2//!
3//! Pure decision + digest logic kept out of [`stream`](crate::stream) so it is
4//! unit testable without an S3 endpoint. The checks are exercised against
5//! in-memory bodies via [`faucet_core::VerifyingReader`].
6//!
7//! - [`length_check`] guards against a cleanly-truncated transfer (default-on).
8//! - [`checksum_check`] verifies the body against the strongest checksum S3
9//!   advertises for the object (opt-in). S3 returns each checksum base64-encoded
10//!   (`x-amz-checksum-*`); the non-multipart `ETag` is a hex MD5.
11
12use base64::Engine as _;
13use base64::engine::general_purpose::STANDARD as B64;
14use faucet_core::{IntegrityCheck, LengthCheck};
15
16// CRC algorithm definitions held as `static` so the streaming `Digest`s can
17// borrow them for `'static` and live inside the boxed checks.
18static CRC32: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISO_HDLC);
19static CRC32C: crc::Crc<u32> = crc::Crc::<u32>::new(&crc::CRC_32_ISCSI);
20static CRC64NVME: crc::Crc<u64> = crc::Crc::<u64>::new(&crc::CRC_64_NVME);
21
22/// Length check for one object body. `None` when verification is disabled or
23/// the store reported no usable (non-negative) `Content-Length`.
24pub fn length_check(
25    content_length: Option<i64>,
26    verify_length: bool,
27) -> Option<Box<dyn IntegrityCheck>> {
28    if verify_length
29        && let Some(len) = content_length
30        && len >= 0
31    {
32        return Some(Box::new(LengthCheck::new(len as u64)));
33    }
34    None
35}
36
37/// The checksum values an S3 `GetObject` (with `ChecksumMode::Enabled`) may
38/// return. Each `checksum_*` is base64; `etag` is the raw ETag header.
39#[derive(Debug, Default, Clone)]
40pub struct S3Checksums {
41    pub crc32: Option<String>,
42    pub crc32c: Option<String>,
43    pub crc64nvme: Option<String>,
44    pub sha256: Option<String>,
45    pub etag: Option<String>,
46}
47
48impl S3Checksums {
49    /// True when at least one field that [`checksum_check`] can verify is
50    /// present (used to distinguish "no checksum advertised" from "only an
51    /// unsupported algorithm advertised").
52    pub fn has_verifiable(&self) -> bool {
53        self.sha256.is_some()
54            || self.crc64nvme.is_some()
55            || self.crc32c.is_some()
56            || self.crc32.is_some()
57            || self.etag.as_deref().and_then(non_multipart_md5).is_some()
58    }
59}
60
61/// Build a checksum check from the strongest advertised algorithm we can
62/// compute, or `None` when the object carries no checksum we support.
63pub fn checksum_check(c: &S3Checksums) -> Option<Box<dyn IntegrityCheck>> {
64    if let Some(b64) = &c.sha256 {
65        return Some(Box::new(Sha256Check::new(b64.clone())));
66    }
67    if let Some(b64) = &c.crc64nvme {
68        return Some(Box::new(CrcCheck::new_u64(
69            &CRC64NVME,
70            "CRC64NVME",
71            b64.clone(),
72        )));
73    }
74    if let Some(b64) = &c.crc32c {
75        return Some(Box::new(CrcCheck::new_u32(&CRC32C, "CRC32C", b64.clone())));
76    }
77    if let Some(b64) = &c.crc32 {
78        return Some(Box::new(CrcCheck::new_u32(&CRC32, "CRC32", b64.clone())));
79    }
80    if let Some(tag) = &c.etag
81        && let Some(hex) = non_multipart_md5(tag)
82    {
83        return Some(Box::new(Md5HexCheck::new(hex)));
84    }
85    None
86}
87
88/// Extract a lowercase hex MD5 from an ETag when it is a simple (non-multipart)
89/// upload. A multipart ETag has the form `"<hex>-<partcount>"` and is **not**
90/// the MD5 of the object body, so it is rejected here.
91fn non_multipart_md5(etag: &str) -> Option<String> {
92    let t = etag.trim().trim_matches('"');
93    if t.len() == 32 && t.bytes().all(|b| b.is_ascii_hexdigit()) {
94        Some(t.to_ascii_lowercase())
95    } else {
96        None
97    }
98}
99
100fn mismatch(name: &str, got: &str, want: &str) -> Result<(), String> {
101    if got.eq_ignore_ascii_case(want) {
102        Ok(())
103    } else {
104        Err(format!(
105            "{name} checksum mismatch: object store advertised {want} but body computed {got} \
106             (corrupted transfer)"
107        ))
108    }
109}
110
111/// Streaming CRC check (32- or 64-bit) comparing against a base64 of the
112/// big-endian digest, matching how S3 encodes `x-amz-checksum-*`.
113struct CrcCheck {
114    digest: CrcDigest,
115    name: &'static str,
116    expected_b64: String,
117}
118enum CrcDigest {
119    W32(crc::Digest<'static, u32>),
120    W64(crc::Digest<'static, u64>),
121}
122impl CrcCheck {
123    fn new_u32(crc: &'static crc::Crc<u32>, name: &'static str, expected_b64: String) -> Self {
124        Self {
125            digest: CrcDigest::W32(crc.digest()),
126            name,
127            expected_b64,
128        }
129    }
130    fn new_u64(crc: &'static crc::Crc<u64>, name: &'static str, expected_b64: String) -> Self {
131        Self {
132            digest: CrcDigest::W64(crc.digest()),
133            name,
134            expected_b64,
135        }
136    }
137}
138impl IntegrityCheck for CrcCheck {
139    fn update(&mut self, chunk: &[u8]) {
140        match &mut self.digest {
141            CrcDigest::W32(d) => d.update(chunk),
142            CrcDigest::W64(d) => d.update(chunk),
143        }
144    }
145    fn finalize(self: Box<Self>, _total: u64) -> Result<(), String> {
146        let me = *self;
147        let got = match me.digest {
148            CrcDigest::W32(d) => B64.encode(d.finalize().to_be_bytes()),
149            CrcDigest::W64(d) => B64.encode(d.finalize().to_be_bytes()),
150        };
151        mismatch(me.name, &got, &me.expected_b64)
152    }
153}
154
155/// Streaming SHA-256 check comparing against a base64 of the digest.
156struct Sha256Check {
157    hasher: sha2::Sha256,
158    expected_b64: String,
159}
160impl Sha256Check {
161    fn new(expected_b64: String) -> Self {
162        use sha2::Digest as _;
163        Self {
164            hasher: sha2::Sha256::new(),
165            expected_b64,
166        }
167    }
168}
169impl IntegrityCheck for Sha256Check {
170    fn update(&mut self, chunk: &[u8]) {
171        use sha2::Digest as _;
172        self.hasher.update(chunk);
173    }
174    fn finalize(self: Box<Self>, _total: u64) -> Result<(), String> {
175        use sha2::Digest as _;
176        let got = B64.encode(self.hasher.finalize());
177        mismatch("SHA256", &got, &self.expected_b64)
178    }
179}
180
181/// Streaming MD5 check comparing against a lowercase hex digest (S3 ETag).
182struct Md5HexCheck {
183    hasher: md5::Md5,
184    expected_hex: String,
185}
186impl Md5HexCheck {
187    fn new(expected_hex: String) -> Self {
188        use md5::Digest as _;
189        Self {
190            hasher: md5::Md5::new(),
191            expected_hex,
192        }
193    }
194}
195impl IntegrityCheck for Md5HexCheck {
196    fn update(&mut self, chunk: &[u8]) {
197        use md5::Digest as _;
198        self.hasher.update(chunk);
199    }
200    fn finalize(self: Box<Self>, _total: u64) -> Result<(), String> {
201        use md5::Digest as _;
202        let digest = self.hasher.finalize();
203        let got: String = digest.iter().map(|b| format!("{b:02x}")).collect();
204        mismatch("MD5(ETag)", &got, &self.expected_hex)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use faucet_core::VerifyingReader;
212    use tokio::io::AsyncReadExt as _;
213
214    async fn reads_ok(check: Option<Box<dyn IntegrityCheck>>, body: &[u8]) -> bool {
215        let checks: Vec<Box<dyn IntegrityCheck>> = check.into_iter().collect();
216        let mut reader = VerifyingReader::new(body, checks);
217        let mut out = Vec::new();
218        reader.read_to_end(&mut out).await.is_ok()
219    }
220
221    fn b64_of_hex(hex: &str) -> String {
222        let bytes: Vec<u8> = (0..hex.len())
223            .step_by(2)
224            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
225            .collect();
226        B64.encode(bytes)
227    }
228
229    // ---- length_check ----
230
231    #[tokio::test]
232    async fn length_truncation_rejected() {
233        assert!(!reads_ok(length_check(Some(10), true), b"hello").await);
234    }
235    #[tokio::test]
236    async fn length_complete_passes() {
237        assert!(reads_ok(length_check(Some(5), true), b"hello").await);
238    }
239    #[tokio::test]
240    async fn length_none_when_disabled() {
241        assert!(length_check(Some(10), false).is_none());
242    }
243    #[tokio::test]
244    async fn length_none_when_unknown_or_negative() {
245        assert!(length_check(None, true).is_none());
246        assert!(length_check(Some(-1), true).is_none());
247    }
248
249    // ---- checksum_check: independent published digests of b"hello" ----
250    // md5    = 5d41402abc4b2a76b9719d911017c592
251    // sha256 = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
252    // crc32  = 0x3610a686 (CRC-32/ISO-HDLC, the zip CRC)
253    // crc32c = 0x9a71bb4c (CRC-32C / iSCSI)
254
255    #[tokio::test]
256    async fn sha256_match_and_mismatch() {
257        let want = b64_of_hex("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824");
258        let good = S3Checksums {
259            sha256: Some(want),
260            ..Default::default()
261        };
262        assert!(reads_ok(checksum_check(&good), b"hello").await);
263        let bad = S3Checksums {
264            sha256: Some(b64_of_hex("00")),
265            ..Default::default()
266        };
267        assert!(!reads_ok(checksum_check(&bad), b"hello").await);
268    }
269
270    #[tokio::test]
271    async fn crc32_match() {
272        let want = B64.encode(0x3610a686u32.to_be_bytes());
273        let c = S3Checksums {
274            crc32: Some(want),
275            ..Default::default()
276        };
277        assert!(reads_ok(checksum_check(&c), b"hello").await);
278    }
279
280    #[tokio::test]
281    async fn crc32c_match_and_mismatch() {
282        let want = B64.encode(0x9a71bb4cu32.to_be_bytes());
283        let good = S3Checksums {
284            crc32c: Some(want),
285            ..Default::default()
286        };
287        assert!(reads_ok(checksum_check(&good), b"hello").await);
288        let bad = S3Checksums {
289            crc32c: Some(B64.encode(0xdeadbeefu32.to_be_bytes())),
290            ..Default::default()
291        };
292        assert!(!reads_ok(checksum_check(&bad), b"hello").await);
293    }
294
295    #[tokio::test]
296    async fn crc64nvme_round_trips() {
297        // No widely-published vector; compute via the same algorithm and prove
298        // the wiring (encode → compare) matches.
299        let want = B64.encode(CRC64NVME.checksum(b"hello").to_be_bytes());
300        let c = S3Checksums {
301            crc64nvme: Some(want),
302            ..Default::default()
303        };
304        assert!(reads_ok(checksum_check(&c), b"hello").await);
305    }
306
307    #[tokio::test]
308    async fn etag_md5_match_when_non_multipart() {
309        let c = S3Checksums {
310            etag: Some("\"5d41402abc4b2a76b9719d911017c592\"".into()),
311            ..Default::default()
312        };
313        assert!(reads_ok(checksum_check(&c), b"hello").await);
314    }
315
316    #[test]
317    fn multipart_etag_is_not_treated_as_md5() {
318        // ETag with a `-N` part suffix is not the body MD5.
319        assert!(non_multipart_md5("\"d41d8cd98f00b204e9800998ecf8427e-3\"").is_none());
320        assert!(non_multipart_md5("\"5d41402abc4b2a76b9719d911017c592\"").is_some());
321    }
322
323    #[test]
324    fn priority_prefers_sha256_over_weaker() {
325        let c = S3Checksums {
326            sha256: Some(b64_of_hex(
327                "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
328            )),
329            crc32: Some("bogus".into()),
330            etag: Some("\"5d41402abc4b2a76b9719d911017c592\"".into()),
331            ..Default::default()
332        };
333        // sha256 wins and is correct → passes despite the bogus crc32 value.
334        assert!(c.has_verifiable());
335        assert!(checksum_check(&c).is_some());
336    }
337
338    #[test]
339    fn no_checksum_yields_none() {
340        let empty = S3Checksums::default();
341        assert!(!empty.has_verifiable());
342        assert!(checksum_check(&empty).is_none());
343        // An only-multipart ETag is also "no verifiable checksum".
344        let mp = S3Checksums {
345            etag: Some("\"abc-2\"".into()),
346            ..Default::default()
347        };
348        assert!(!mp.has_verifiable());
349        assert!(checksum_check(&mp).is_none());
350    }
351}