Skip to main content

faucet_core/
verify.rs

1//! Read-time integrity verification for streaming object bodies.
2//!
3//! Object-store sources (S3, GCS) read an object body through an async reader
4//! and trust the SDK to either deliver the whole body or surface an error. A
5//! transfer that terminates early but *cleanly* (a truncated stream that still
6//! yields EOF) would otherwise be parsed and emitted as a complete object —
7//! silent data loss with no error (#161).
8//!
9//! [`VerifyingReader`] wraps the **raw** body reader (below any decompression
10//! layer, so byte counts and checksums cover the *stored* bytes, not the
11//! decoded ones) and runs a set of [`IntegrityCheck`]s once the underlying
12//! reader reaches EOF. A failed check surfaces as an [`io::Error`] of kind
13//! [`InvalidData`](std::io::ErrorKind::InvalidData) on the final read, which
14//! the connector maps to [`FaucetError::Source`](crate::FaucetError::Source).
15//!
16//! The built-in [`LengthCheck`] guards against truncation. Connector crates
17//! implement [`IntegrityCheck`] over their own hash libraries for opt-in
18//! checksum verification.
19
20use std::io;
21use std::pin::Pin;
22use std::task::{Context, Poll};
23use tokio::io::{AsyncRead, ReadBuf};
24
25/// A check fed the raw bytes of an object body as they stream past, and asked
26/// to validate once the underlying reader reaches EOF.
27pub trait IntegrityCheck: Send {
28    /// Observe a chunk of body bytes. Called zero or more times, in order,
29    /// before [`finalize`](IntegrityCheck::finalize).
30    fn update(&mut self, chunk: &[u8]);
31
32    /// Validate at EOF. `total` is the number of bytes observed across every
33    /// [`update`](IntegrityCheck::update) call. Return `Err(reason)` to fail
34    /// the read; the reason is surfaced as an [`io::Error`] of kind
35    /// [`InvalidData`](std::io::ErrorKind::InvalidData).
36    fn finalize(self: Box<Self>, total: u64) -> Result<(), String>;
37}
38
39/// Built-in [`IntegrityCheck`] that fails when the number of bytes read does
40/// not match the length advertised by the object store (`Content-Length` for
41/// S3, object `size` for GCS). Catches a cleanly-truncated transfer that would
42/// otherwise be accepted as a complete object.
43#[derive(Debug, Clone, Copy)]
44pub struct LengthCheck {
45    expected: u64,
46}
47
48impl LengthCheck {
49    /// Create a length check against the advertised body length in bytes.
50    pub fn new(expected: u64) -> Self {
51        Self { expected }
52    }
53}
54
55impl IntegrityCheck for LengthCheck {
56    fn update(&mut self, _chunk: &[u8]) {}
57
58    fn finalize(self: Box<Self>, total: u64) -> Result<(), String> {
59        if total == self.expected {
60            Ok(())
61        } else {
62            Err(format!(
63                "body length mismatch: object store advertised {} byte(s) but read {} \
64                 (truncated or corrupted transfer)",
65                self.expected, total
66            ))
67        }
68    }
69}
70
71/// An [`AsyncRead`] adapter that observes every byte read from `inner` and runs
72/// the configured [`IntegrityCheck`]s at EOF. Wrap the **raw** network reader
73/// before any `BufReader` / decompression layer.
74pub struct VerifyingReader<R> {
75    inner: R,
76    checks: Vec<Box<dyn IntegrityCheck>>,
77    total: u64,
78    finalized: bool,
79}
80
81impl<R> VerifyingReader<R> {
82    /// Wrap `inner`, running `checks` at EOF. An empty `checks` vec is a
83    /// transparent passthrough.
84    pub fn new(inner: R, checks: Vec<Box<dyn IntegrityCheck>>) -> Self {
85        Self {
86            inner,
87            checks,
88            total: 0,
89            finalized: false,
90        }
91    }
92}
93
94impl<R: AsyncRead + Unpin> AsyncRead for VerifyingReader<R> {
95    fn poll_read(
96        self: Pin<&mut Self>,
97        cx: &mut Context<'_>,
98        buf: &mut ReadBuf<'_>,
99    ) -> Poll<io::Result<()>> {
100        let this = self.get_mut();
101        let before = buf.filled().len();
102        match Pin::new(&mut this.inner).poll_read(cx, buf) {
103            Poll::Ready(Ok(())) => {
104                let filled = buf.filled();
105                let n = filled.len() - before;
106                if n > 0 {
107                    // Bytes were produced: count them and feed every check the
108                    // raw chunk, then yield to the caller as usual.
109                    let chunk = &filled[before..];
110                    this.total += n as u64;
111                    for check in &mut this.checks {
112                        check.update(chunk);
113                    }
114                    Poll::Ready(Ok(()))
115                } else if this.finalized {
116                    // EOF already validated on a prior poll; report clean EOF.
117                    Poll::Ready(Ok(()))
118                } else {
119                    // First EOF: run every check exactly once. A failure is
120                    // surfaced as an InvalidData error on this final read.
121                    this.finalized = true;
122                    for check in std::mem::take(&mut this.checks) {
123                        if let Err(reason) = check.finalize(this.total) {
124                            return Poll::Ready(Err(io::Error::new(
125                                io::ErrorKind::InvalidData,
126                                reason,
127                            )));
128                        }
129                    }
130                    Poll::Ready(Ok(()))
131                }
132            }
133            other => other,
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use tokio::io::AsyncReadExt as _;
142
143    /// Test-only digest check: sums observed bytes and compares the running
144    /// total at EOF. Exercises the `update`/`finalize` path the way a real
145    /// CRC/SHA check does, without pulling a hash crate into core's tests.
146    struct SumCheck {
147        expected: u64,
148        running: u64,
149    }
150    impl SumCheck {
151        fn new(expected: u64) -> Self {
152            Self {
153                expected,
154                running: 0,
155            }
156        }
157    }
158    impl IntegrityCheck for SumCheck {
159        fn update(&mut self, chunk: &[u8]) {
160            self.running += chunk.iter().map(|b| *b as u64).sum::<u64>();
161        }
162        fn finalize(self: Box<Self>, _total: u64) -> Result<(), String> {
163            if self.running == self.expected {
164                Ok(())
165            } else {
166                Err(format!(
167                    "checksum mismatch: expected {}, computed {}",
168                    self.expected, self.running
169                ))
170            }
171        }
172    }
173
174    /// A reader that yields exactly one byte per `poll_read`, to prove the
175    /// verifier accumulates correctly across many partial reads.
176    struct OneByteAtATime {
177        data: Vec<u8>,
178        pos: usize,
179    }
180    impl AsyncRead for OneByteAtATime {
181        fn poll_read(
182            self: Pin<&mut Self>,
183            _cx: &mut Context<'_>,
184            buf: &mut ReadBuf<'_>,
185        ) -> Poll<io::Result<()>> {
186            let this = self.get_mut();
187            if this.pos < this.data.len() {
188                buf.put_slice(&this.data[this.pos..this.pos + 1]);
189                this.pos += 1;
190            }
191            Poll::Ready(Ok(()))
192        }
193    }
194
195    #[tokio::test]
196    async fn digest_mismatch_fails() {
197        let data: &[u8] = &[1, 2, 3]; // sum = 6
198        let mut reader = VerifyingReader::new(data, vec![Box::new(SumCheck::new(99))]);
199        let mut out = Vec::new();
200        let err = reader
201            .read_to_end(&mut out)
202            .await
203            .expect_err("wrong checksum must fail the read");
204        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
205        assert!(err.to_string().contains("checksum mismatch"), "got: {err}");
206    }
207
208    #[tokio::test]
209    async fn digest_match_passes() {
210        let data: &[u8] = &[1, 2, 3]; // sum = 6
211        let mut reader = VerifyingReader::new(data, vec![Box::new(SumCheck::new(6))]);
212        let mut out = Vec::new();
213        reader
214            .read_to_end(&mut out)
215            .await
216            .expect("matching checksum must pass");
217        assert_eq!(out, vec![1, 2, 3]);
218    }
219
220    #[tokio::test]
221    async fn accumulates_across_one_byte_reads() {
222        // length + digest must both be correct even when the body arrives one
223        // byte per poll (incremental `update`, not a single final chunk).
224        let body = vec![10u8, 20, 30, 40]; // len 4, sum 100
225        let reader = OneByteAtATime {
226            data: body.clone(),
227            pos: 0,
228        };
229        let mut reader = VerifyingReader::new(
230            reader,
231            vec![Box::new(LengthCheck::new(4)), Box::new(SumCheck::new(100))],
232        );
233        let mut out = Vec::new();
234        reader
235            .read_to_end(&mut out)
236            .await
237            .expect("incremental reads must verify");
238        assert_eq!(out, body);
239    }
240
241    #[tokio::test]
242    async fn first_failing_check_surfaces() {
243        // Length is correct (3) but the digest is wrong → the digest error is
244        // what surfaces (length runs first and passes, digest second fails).
245        let data: &[u8] = &[1, 2, 3];
246        let mut reader = VerifyingReader::new(
247            data,
248            vec![Box::new(LengthCheck::new(3)), Box::new(SumCheck::new(0))],
249        );
250        let mut out = Vec::new();
251        let err = reader.read_to_end(&mut out).await.expect_err("must fail");
252        assert!(err.to_string().contains("checksum mismatch"), "got: {err}");
253    }
254
255    #[tokio::test]
256    async fn empty_body_expecting_zero_passes() {
257        let data: &[u8] = &[];
258        let mut reader = VerifyingReader::new(data, vec![Box::new(LengthCheck::new(0))]);
259        let mut out = Vec::new();
260        let n = reader
261            .read_to_end(&mut out)
262            .await
263            .expect("empty body, len 0");
264        assert_eq!(n, 0);
265    }
266
267    #[tokio::test]
268    async fn empty_body_expecting_nonzero_fails() {
269        let data: &[u8] = &[];
270        let mut reader = VerifyingReader::new(data, vec![Box::new(LengthCheck::new(5))]);
271        let mut out = Vec::new();
272        let err = reader
273            .read_to_end(&mut out)
274            .await
275            .expect_err("an empty body advertised as 5 bytes must fail");
276        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
277    }
278
279    #[tokio::test]
280    async fn overlong_read_fails() {
281        // More bytes than advertised is also a mismatch (store inconsistency).
282        let data: &[u8] = b"helloworld"; // 10 bytes
283        let mut reader = VerifyingReader::new(data, vec![Box::new(LengthCheck::new(4))]);
284        let mut out = Vec::new();
285        let err = reader
286            .read_to_end(&mut out)
287            .await
288            .expect_err("reading more than advertised must fail");
289        assert!(err.to_string().contains("length mismatch"), "got: {err}");
290    }
291
292    #[tokio::test]
293    async fn no_checks_is_transparent_passthrough() {
294        let data: &[u8] = b"passthrough";
295        let mut reader = VerifyingReader::new(data, Vec::new());
296        let mut out = Vec::new();
297        reader.read_to_end(&mut out).await.expect("no checks = ok");
298        assert_eq!(out, b"passthrough");
299    }
300
301    #[tokio::test]
302    async fn short_read_fails_length_check() {
303        let data: &[u8] = b"hello"; // 5 bytes
304        let mut reader = VerifyingReader::new(data, vec![Box::new(LengthCheck::new(10))]);
305        let mut out = Vec::new();
306        let err = reader
307            .read_to_end(&mut out)
308            .await
309            .expect_err("a 5-byte body against an advertised 10 must fail");
310        assert_eq!(err.kind(), io::ErrorKind::InvalidData);
311        assert!(
312            err.to_string().contains("length mismatch"),
313            "unexpected error: {err}"
314        );
315    }
316
317    #[tokio::test]
318    async fn exact_length_passes() {
319        let data: &[u8] = b"helloworld"; // 10 bytes
320        let mut reader = VerifyingReader::new(data, vec![Box::new(LengthCheck::new(10))]);
321        let mut out = Vec::new();
322        let n = reader
323            .read_to_end(&mut out)
324            .await
325            .expect("exact-length body must succeed");
326        assert_eq!(n, 10);
327        assert_eq!(out, b"helloworld");
328    }
329}