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}