Skip to main content

arkhe_forge_platform/wal_export/
reader.rs

1//! `WalStreamReader` — production reader-side surface.
2//!
3//! Consumes the byte stream produced by [`super::BufferedWalSink`] (or
4//! any `[u64 BE length prefix][payload]`-framed sequence beginning
5//! with a recognised [`StreamMagic`] tag) and yields per-record
6//! payload borrows. The complement to the writer-side
7//! [`super::BufferedWalSink`].
8//!
9//! # Streaming-iterator pattern
10//!
11//! `WalStreamReader::next_record` returns `Option<&[u8]>` where the
12//! borrow is tied to `&mut self` — the slice is valid until the next
13//! call mutates the internal buffer. Callers decode the borrowed
14//! payload (typically via `postcard::from_bytes::<WalRecord>`) inside
15//! the loop iteration and must not retain the slice across iterations.
16//! This avoids the per-record allocation that an owned `Vec<u8>`
17//! return would force, at the cost of giving up `std::iter::Iterator`
18//! (whose lifetime contract precludes the borrow tie).
19//!
20//! # Fail-secure framing posture
21//!
22//! Five reject paths cover the framing surface:
23//!
24//! 1. **Unknown magic** at stream open → [`WalExportError::UnsupportedStreamVersion`].
25//!    Stack-only 8-byte read; no payload-sized allocation pre-rejection
26//!    (fail-fast — the fixed `BufReader` working buffer is the only
27//!    allocation, independent of stream contents).
28//! 2. **Truncated header** (fewer than 8 bytes) at open →
29//!    [`InvalidFramingReason::HeaderMissing`] via `Truncated` mapping.
30//! 3. **Length prefix exceeds bound** → [`InvalidFramingReason::LengthExceedsMax`]
31//!    rejection BEFORE allocating the payload buffer (16 MiB
32//!    fail-secure ceiling).
33//! 4. **Length prefix zero** → [`InvalidFramingReason::LengthZero`].
34//! 5. **Premature EOF mid-record** (length prefix complete but
35//!    payload short) → [`InvalidFramingReason::Truncated`].
36//!
37//! Clean EOF (zero bytes available at the start of the next record)
38//! returns `Ok(None)` — distinguishable from torn headers via a
39//! single-byte probe before committing to the full 8-byte read.
40//!
41//! # Bridge to integration round-trip tests
42//!
43//! The sibling `round_trip_tests` module's `parse_stream` test helper
44//! duplicates the framing logic to keep that module independent of
45//! this reader API; this module hosts the production-grade equivalent
46//! for non-test callers.
47
48use std::io::{BufReader, ErrorKind, Read};
49
50use super::{InvalidFramingReason, StreamMagic, WalExportError, MAX_RECORD_BYTES};
51
52/// Reader-side trait — `next_record` plus `cumulative_position`.
53///
54/// The `next_record` lifetime tie (`&mut self` borrow → `&[u8]`
55/// borrow on the return) makes this trait a *streaming iterator*
56/// rather than a `std::iter::Iterator`. Callers iterate via:
57///
58/// ```ignore
59/// while let Some(payload) = reader.next_record()? {
60///     // decode `payload` here; the slice expires next iteration
61/// }
62/// ```
63pub trait WalStreamReader {
64    /// Read the next record's payload bytes.
65    ///
66    /// Returns:
67    /// - `Ok(Some(payload))` — a borrow into the reader's internal
68    ///   buffer, valid until the next call.
69    /// - `Ok(None)` — clean EOF, no more records.
70    /// - `Err(WalExportError::InvalidFraming(...))` — framing reject
71    ///   per the five fail-secure paths documented above.
72    /// - `Err(WalExportError::Io(...))` — underlying I/O failure.
73    fn next_record(&mut self) -> Result<Option<&[u8]>, WalExportError>;
74
75    /// Cumulative byte count consumed from the underlying reader,
76    /// including the 8-byte stream header magic + each record's
77    /// `[8 byte length prefix][payload]` frame. Useful for forensic
78    /// "where did the parse fail" reporting and for operator metrics
79    /// (bytes-processed throughput).
80    fn cumulative_position(&self) -> u64;
81}
82
83/// Concrete `WalStreamReader` impl over an arbitrary `std::io::Read`.
84///
85/// Typical instantiations:
86/// - `StreamingWalReader<std::fs::File>` — on-disk archive reader
87/// - `StreamingWalReader<&[u8]>` — in-memory test fixture (the common
88///   case in this module's own test suite)
89///
90/// The underlying reader is wrapped in a [`BufReader`], so the
91/// per-record framing reads (1-byte EOF probe + 7-byte length-prefix
92/// remainder) hit an in-memory buffer rather than issuing three raw
93/// `read` calls per record against the source — file-backed streams
94/// are efficient by construction, no caller-side wrapping needed.
95#[derive(Debug)]
96pub struct StreamingWalReader<R: Read> {
97    reader: BufReader<R>,
98    /// Internal buffer reused across `next_record` calls. Sized up
99    /// to fit each record's payload; capacity grows monotonically
100    /// (no shrink) so steady-state throughput avoids reallocation
101    /// once the largest record so far has been seen.
102    buffer: Vec<u8>,
103    /// Cumulative bytes consumed from `reader`.
104    cumulative_pos: u64,
105    /// The recognised stream magic (always `V1` currently; future
106    /// versions add variants without breaking the reader's frame
107    /// loop, which is version-agnostic at the framing layer).
108    magic: StreamMagic,
109}
110
111impl<R: Read> StreamingWalReader<R> {
112    /// Open a reader by consuming + dispatching the 8-byte stream
113    /// header magic.
114    ///
115    /// **Fail-fast posture**: the 8-byte header is read into a stack
116    /// array; an unrecognised magic yields
117    /// [`WalExportError::UnsupportedStreamVersion`] with no
118    /// payload-sized allocation pre-rejection (the only allocation
119    /// before validation is the fixed-size `BufReader` working buffer,
120    /// independent of stream contents). A truncated header (fewer than
121    /// 8 bytes) yields [`InvalidFramingReason::HeaderMissing`].
122    pub fn open_v1(reader: R) -> Result<Self, WalExportError> {
123        let mut reader = BufReader::new(reader);
124        let mut magic_bytes = [0u8; 8];
125        match reader.read_exact(&mut magic_bytes) {
126            Ok(()) => {}
127            Err(e) if e.kind() == ErrorKind::UnexpectedEof => {
128                return Err(WalExportError::InvalidFraming(
129                    InvalidFramingReason::HeaderMissing,
130                ));
131            }
132            Err(e) => return Err(WalExportError::Io(e)),
133        }
134        let magic = StreamMagic::recognize(&magic_bytes)
135            .ok_or(WalExportError::UnsupportedStreamVersion { magic: magic_bytes })?;
136        Ok(Self {
137            reader,
138            buffer: Vec::new(),
139            cumulative_pos: 8,
140            magic,
141        })
142    }
143
144    /// Recognised stream magic version. Currently always
145    /// [`StreamMagic::V1`].
146    #[must_use]
147    pub fn magic(&self) -> StreamMagic {
148        self.magic
149    }
150}
151
152impl<R: Read> WalStreamReader for StreamingWalReader<R> {
153    fn next_record(&mut self) -> Result<Option<&[u8]>, WalExportError> {
154        // Step 1: probe one byte to distinguish clean EOF (Ok(0))
155        // from a torn length prefix (UnexpectedEof on the remaining
156        // 7 bytes). `read_exact` would conflate the two.
157        let mut first_byte = [0u8; 1];
158        let n = self
159            .reader
160            .read(&mut first_byte)
161            .map_err(WalExportError::Io)?;
162        if n == 0 {
163            return Ok(None);
164        }
165
166        // Step 2: read the remaining 7 bytes of the length prefix.
167        // A short read here = torn frame = `Truncated`.
168        let mut rest = [0u8; 7];
169        self.reader.read_exact(&mut rest).map_err(|e| {
170            if e.kind() == ErrorKind::UnexpectedEof {
171                WalExportError::InvalidFraming(InvalidFramingReason::Truncated)
172            } else {
173                WalExportError::Io(e)
174            }
175        })?;
176
177        // Step 3: assemble + validate the length.
178        let mut len_bytes = [0u8; 8];
179        len_bytes[0] = first_byte[0];
180        len_bytes[1..].copy_from_slice(&rest);
181        let len = u64::from_be_bytes(len_bytes);
182        self.cumulative_pos += 8;
183
184        if len == 0 {
185            return Err(WalExportError::InvalidFraming(
186                InvalidFramingReason::LengthZero,
187            ));
188        }
189        if len > MAX_RECORD_BYTES {
190            return Err(WalExportError::InvalidFraming(
191                InvalidFramingReason::LengthExceedsMax {
192                    prefix: len,
193                    max: MAX_RECORD_BYTES,
194                },
195            ));
196        }
197
198        // Step 4: read the payload BEFORE returning a borrow.
199        // `len` already validated above so the `as usize` is safe on
200        // any 64-bit (or smaller) platform: 16 MiB fits in usize on
201        // every supported target.
202        self.buffer.resize(len as usize, 0);
203        self.reader.read_exact(&mut self.buffer).map_err(|e| {
204            if e.kind() == ErrorKind::UnexpectedEof {
205                WalExportError::InvalidFraming(InvalidFramingReason::Truncated)
206            } else {
207                WalExportError::Io(e)
208            }
209        })?;
210        self.cumulative_pos += len;
211
212        Ok(Some(&self.buffer))
213    }
214
215    fn cumulative_position(&self) -> u64 {
216        self.cumulative_pos
217    }
218}
219
220#[cfg(test)]
221#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
222mod tests {
223    use super::super::buffered_sink::BufferedWalSink;
224    use super::super::{InvalidFramingReason, StreamMagic, WalExportError, WalRecordSink};
225    use super::*;
226    use std::io::Cursor;
227
228    /// Build a synthetic record payload: a postcard-encoded marker
229    /// `u64` followed by `padding` zero bytes. The payload is opaque to
230    /// the sink (the seq is passed explicitly on append); the embedded
231    /// marker lets reader tests assert which record they decoded.
232    fn synth_record(marker: u64, padding: usize) -> Vec<u8> {
233        let mut bytes = postcard::to_stdvec(&marker).unwrap();
234        bytes.extend(std::iter::repeat_n(0u8, padding));
235        bytes
236    }
237
238    /// Encode `n` records via `BufferedWalSink<Vec<u8>>` and return the
239    /// flushed byte stream. Record `i` carries marker `i` and is
240    /// appended under seq `i`.
241    fn build_stream(n: u64) -> Vec<u8> {
242        let mut sink = BufferedWalSink::new(Vec::<u8>::new());
243        for i in 0..n {
244            let rec = synth_record(i, 4);
245            sink.append_record(i, &rec).expect("append OK");
246        }
247        sink.flush().expect("flush OK");
248        sink.into_writer_for_test()
249    }
250
251    /// Round-trip: writer-emitted stream parses back to the original
252    /// record sequence via `StreamingWalReader::open_v1` +
253    /// `next_record`.
254    #[test]
255    fn writer_to_reader_round_trip_three_records() {
256        let stream = build_stream(3);
257        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
258        assert_eq!(reader.magic(), StreamMagic::V1);
259
260        let mut decoded_seqs = Vec::new();
261        while let Some(payload) = reader.next_record().expect("next OK") {
262            let seq: u64 = postcard::from_bytes(payload).expect("postcard OK");
263            decoded_seqs.push(seq);
264        }
265        assert_eq!(decoded_seqs, vec![0, 1, 2]);
266        // Header + 3 frames each = (8 + N) bytes.
267        let header_len = 8u64;
268        let per_record_len = 8 + synth_record(0, 4).len() as u64;
269        assert_eq!(
270            reader.cumulative_position(),
271            header_len + 3 * per_record_len
272        );
273    }
274
275    /// `open_v1` on an empty stream returns `HeaderMissing` —
276    /// 0-byte input cannot satisfy the 8-byte magic read.
277    #[test]
278    fn open_v1_empty_stream_rejected_with_header_missing() {
279        let result = StreamingWalReader::open_v1(Cursor::new(Vec::<u8>::new()));
280        assert!(matches!(
281            result,
282            Err(WalExportError::InvalidFraming(
283                InvalidFramingReason::HeaderMissing
284            ))
285        ));
286    }
287
288    /// `open_v1` on a 7-byte truncated header returns `HeaderMissing`.
289    #[test]
290    fn open_v1_truncated_header_rejected_with_header_missing() {
291        let truncated: &[u8] = b"ARKHEXP"; // 7 bytes, missing trailing '1'
292        let result = StreamingWalReader::open_v1(Cursor::new(truncated));
293        assert!(matches!(
294            result,
295            Err(WalExportError::InvalidFraming(
296                InvalidFramingReason::HeaderMissing
297            ))
298        ));
299    }
300
301    /// `open_v1` on an unrecognised magic returns
302    /// `UnsupportedStreamVersion` with the offending bytes carried.
303    #[test]
304    fn open_v1_unknown_magic_rejected_with_unsupported_version() {
305        let unknown: &[u8] = b"ARKHEXP9"; // unrecognised version tag
306        let result = StreamingWalReader::open_v1(Cursor::new(unknown));
307        match result {
308            Err(WalExportError::UnsupportedStreamVersion { magic }) => {
309                assert_eq!(&magic, b"ARKHEXP9");
310            }
311            other => panic!("expected UnsupportedStreamVersion, got {other:?}"),
312        }
313    }
314
315    /// L0 WAL magic (`ARKHEWAL`) misfed to the stream reader is
316    /// rejected as `UnsupportedStreamVersion` (transport mismatch
317    /// defence).
318    #[test]
319    fn open_v1_l0_wal_magic_rejected_with_unsupported_version() {
320        let l0_magic = arkhe_kernel::persist::WalHeader::MAGIC;
321        let result = StreamingWalReader::open_v1(Cursor::new(&l0_magic[..]));
322        assert!(matches!(
323            result,
324            Err(WalExportError::UnsupportedStreamVersion { .. })
325        ));
326    }
327
328    /// A record-less sink flush produces a header-only stream that the
329    /// reader opens and reads as zero records — the empty-WAL export
330    /// round-trip (firm requirement #3 on the writer side, clean EOF on
331    /// the reader side).
332    #[test]
333    fn next_record_after_header_only_returns_none_clean_eof() {
334        let mut sink = BufferedWalSink::new(Vec::<u8>::new());
335        sink.flush().expect("flush OK");
336        let stream = sink.into_writer_for_test();
337        assert_eq!(
338            stream,
339            StreamMagic::V1.bytes().to_vec(),
340            "zero-append flush emits exactly the header magic"
341        );
342        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
343        assert!(matches!(reader.next_record(), Ok(None)));
344        assert_eq!(reader.cumulative_position(), 8);
345    }
346
347    /// Length prefix zero rejected with `LengthZero` (mid-stream).
348    #[test]
349    fn next_record_length_zero_rejected() {
350        let mut stream = Vec::new();
351        stream.extend_from_slice(StreamMagic::V1.bytes());
352        stream.extend_from_slice(&0u64.to_be_bytes()); // explicit length-zero frame
353        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
354        assert!(matches!(
355            reader.next_record(),
356            Err(WalExportError::InvalidFraming(
357                InvalidFramingReason::LengthZero
358            ))
359        ));
360    }
361
362    /// Length prefix exceeding `MAX_RECORD_BYTES` rejected with
363    /// `LengthExceedsMax` (no payload alloc attempted — the bound
364    /// check fires before `Vec::resize`).
365    #[test]
366    fn next_record_length_exceeds_max_rejected_pre_alloc() {
367        let mut stream = Vec::new();
368        stream.extend_from_slice(StreamMagic::V1.bytes());
369        // 17 MiB length prefix — exceeds 16 MiB ceiling.
370        let oversize = MAX_RECORD_BYTES + 1;
371        stream.extend_from_slice(&oversize.to_be_bytes());
372        // Note: NO payload bytes follow — the reader must reject on
373        // length prefix alone, never reach `read_exact(&mut buffer)`.
374        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
375        match reader.next_record() {
376            Err(WalExportError::InvalidFraming(InvalidFramingReason::LengthExceedsMax {
377                prefix,
378                max,
379            })) => {
380                assert_eq!(prefix, oversize);
381                assert_eq!(max, MAX_RECORD_BYTES);
382            }
383            other => panic!("expected LengthExceedsMax, got {other:?}"),
384        }
385    }
386
387    /// Truncated mid-length-prefix (4 of 8 bytes available) rejected
388    /// with `Truncated`.
389    #[test]
390    fn next_record_truncated_mid_length_prefix_rejected() {
391        let mut stream = Vec::new();
392        stream.extend_from_slice(StreamMagic::V1.bytes());
393        stream.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); // only 4 of 8 length bytes
394        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
395        assert!(matches!(
396            reader.next_record(),
397            Err(WalExportError::InvalidFraming(
398                InvalidFramingReason::Truncated
399            ))
400        ));
401    }
402
403    /// Truncated mid-payload (length says N bytes but only N-3
404    /// available) rejected with `Truncated`.
405    #[test]
406    fn next_record_truncated_mid_payload_rejected() {
407        let mut stream = Vec::new();
408        stream.extend_from_slice(StreamMagic::V1.bytes());
409        // Length prefix = 10 bytes; supply only 6.
410        stream.extend_from_slice(&10u64.to_be_bytes());
411        stream.extend_from_slice(&[0u8; 6]);
412        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
413        assert!(matches!(
414            reader.next_record(),
415            Err(WalExportError::InvalidFraming(
416                InvalidFramingReason::Truncated
417            ))
418        ));
419    }
420
421    /// Reader resumes correctly across multiple successful records,
422    /// and `cumulative_position` tracks total bytes consumed.
423    #[test]
424    fn cumulative_position_tracks_per_record_advance() {
425        let stream = build_stream(2);
426        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
427        let header = 8u64;
428        let frame = 8u64 + synth_record(0, 4).len() as u64;
429        assert_eq!(reader.cumulative_position(), header);
430
431        let _ = reader.next_record().expect("rec0").expect("Some");
432        assert_eq!(reader.cumulative_position(), header + frame);
433
434        let _ = reader.next_record().expect("rec1").expect("Some");
435        assert_eq!(reader.cumulative_position(), header + 2 * frame);
436
437        assert!(matches!(reader.next_record(), Ok(None)));
438        assert_eq!(reader.cumulative_position(), header + 2 * frame);
439    }
440
441    /// Multiple sequential `next_record` calls correctly invalidate
442    /// the previous borrow: each call returns a fresh slice into the
443    /// reused internal buffer. (Compile-fail behaviour around
444    /// retaining an old borrow across a call is the point of the
445    /// streaming-iterator pattern; this test exercises the runtime
446    /// behaviour.)
447    #[test]
448    fn next_record_borrow_refreshes_on_each_call() {
449        let stream = build_stream(3);
450        let mut reader = StreamingWalReader::open_v1(Cursor::new(&stream)).expect("open OK");
451
452        // Iterate; each call replaces the buffer contents with the new
453        // record's payload. We capture decoded values into owned data
454        // (Vec<u64>) to release the borrow before the next call.
455        let mut seqs = Vec::new();
456        while let Some(payload) = reader.next_record().expect("next OK") {
457            let s: u64 = postcard::from_bytes(payload).expect("decode");
458            seqs.push(s);
459        }
460        assert_eq!(seqs, vec![0u64, 1, 2]);
461    }
462
463    /// `WalStreamReader` dyn-trait object usability check (Send +
464    /// concrete-type erasure for higher-order callers).
465    #[test]
466    fn wal_stream_reader_trait_object_usable() {
467        let stream = build_stream(1);
468        let cursor = Cursor::new(stream);
469        let reader = StreamingWalReader::open_v1(cursor).expect("open OK");
470        let mut boxed: Box<dyn WalStreamReader> = Box::new(reader);
471        let payload = boxed.next_record().expect("next").expect("Some");
472        let seq: u64 = postcard::from_bytes(payload).expect("decode");
473        assert_eq!(seq, 0);
474    }
475}