1use base64::Engine as _;
13use base64::engine::general_purpose::STANDARD as B64;
14use faucet_core::{IntegrityCheck, LengthCheck};
15
16static 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
22pub 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#[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 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
61pub 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
88fn 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
111struct 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
155struct 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
181struct 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 #[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 #[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 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 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 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 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}