Skip to main content

arkhe_forge_platform/wal_export/
mod.rs

1//! WAL streaming export — incremental record append for snapshot / backup.
2//!
3//! # Three firm requirements
4//!
5//! 1. **Fixed-width `u64` big-endian length prefix** before every record
6//!    in the stream — deterministic + cross-platform; varint routes
7//!    through a separate determinism verify path.
8//! 2. **Bounds-check on the length prefix** before any deref of record
9//!    bytes — `0 < prefix ≤ `[`MAX_RECORD_BYTES`]. Malformed prefixes
10//!    MUST be rejected with
11//!    [`WalExportError::InvalidFraming`]`(`[`InvalidFramingReason`]`)`.
12//!    Pattern mirrors the host-fn `(ptr, len)` bounds-check contract.
13//! 3. **Stream header magic** [`STREAM_HEADER_MAGIC`] (`ARKHEXP1`)
14//!    pinned at the beginning of every export stream so truncation can
15//!    be distinguished from "the consumer is reading garbage that
16//!    happens to start with a valid length prefix".
17//!
18//! # Type-level append-only invariant (A14 projection)
19//!
20//! [`WalRecordSink`] exposes only [`WalRecordSink::append_record`] and
21//! [`WalRecordSink::flush`]. **Seek / truncate / rewrite operations are
22//! deliberately absent from the trait surface** — external callers
23//! cannot invoke them through the trait, and the L0 A14 append-only
24//! invariant is projected into the L2 export layer **without touching
25//! L0 source**. Concrete implementations MAY add private internal
26//! positioning helpers but MUST NOT expose them on the public surface.
27//!
28//! The caller passes each record's kind-agnostic monotonic `seq`
29//! explicitly (production callers read it through the typed
30//! `arkhe_kernel::WalRecord::seq()` accessor); the sink enforces strict
31//! `+1` succession and never parses the payload bytes itself.
32//!
33//! # DO NOT TOUCH #7 (postcard field order) invariant
34//!
35//! The streaming format wraps **unmodified L0 record bytes**. The
36//! length-prefix + stream-header framing wraps the protected payload
37//! from the outside; the kernel's frozen record layout (the
38//! kind-discriminated `Submit`/`Step` content and its per-variant field
39//! order — L0 Layer A item 7) passes through bit-exact. This framing
40//! layer is payload-agnostic by design: the only coupling to the L0
41//! schema is the typed `WalRecord::seq()` call at the producer call
42//! site, which the compiler — not a byte-offset assumption — checks.
43//!
44//! # Forward-compatibility
45//!
46//! All public types are `#[non_exhaustive]` — adding variants or fields
47//! does not break external matchers. [`WalRecordSink`] trait may grow
48//! additional methods only if they preserve the append-only invariant
49//! (no seek / truncate / rewrite).
50//!
51//! # Stability
52//!
53//! Wire-format surface frozen. Subsequent magic variants are added
54//! through migration mechanisms, never silent reuse — see
55//! [`StreamMagic::recognize`] for the reader-side dispatch.
56
57use std::fmt;
58
59pub mod buffered_sink;
60pub mod reader;
61
62pub use buffered_sink::BufferedWalSink;
63pub use reader::{StreamingWalReader, WalStreamReader};
64
65#[cfg(test)]
66mod wire_stability;
67
68#[cfg(test)]
69mod round_trip_tests;
70
71/// Magic bytes pinned at the start of every WAL export stream.
72///
73/// `ARKHEXP1` = "ArKhe EXPort version 1". Distinct from L0
74/// `WalHeader::MAGIC` (`ARKHEWAL`) — the streaming export is a
75/// separate transport format that wraps L0 records, so the stream-
76/// header magic is independent of the WAL header magic. A future
77/// version bump (`ARKHEXP2`, …) is a wire-format break and must be
78/// handled by an explicit migration mechanism, not silent reuse —
79/// see [`StreamMagic`] for the structured cross-version dispatch
80/// surface that makes the bump path mechanical.
81pub const STREAM_HEADER_MAGIC: [u8; 8] = *b"ARKHEXP1";
82
83/// Stream-header magic version dispatch — cross-version
84/// forward-compat dispatch surface. Each enum variant maps to a distinct
85/// 8-byte ASCII tag at the start of an exported stream; the reader
86/// dispatches on the recognised tag to select the corresponding
87/// frame-format parser.
88///
89/// `V1` (the `ARKHEXP1` baseline) is the only currently-reserved
90/// variant. Additional variants (`V2` / `V3` / …) may be added
91/// alongside this enum and the reader-side `recognize` lookup. The
92/// `#[non_exhaustive]` attribute makes the additive expansion
93/// non-breaking for external matchers.
94///
95/// **Wire-stability invariant**: the byte mapping for `V1` is pinned
96/// at `*b"ARKHEXP1"` (golden hex vector
97/// `0x41 0x52 0x4B 0x48 0x45 0x58 0x50 0x31`). Any change to the V1
98/// byte pattern is a wire-format break and requires the same
99/// migration mechanism as a new variant.
100#[derive(Debug, Clone, Copy, Eq, PartialEq)]
101#[non_exhaustive]
102pub enum StreamMagic {
103    /// `ARKHEXP1` — wire baseline. Currently the only supported
104    /// version.
105    V1,
106}
107
108impl StreamMagic {
109    /// Return the 8-byte tag for this version. `const fn` so callers
110    /// can pin the byte pattern at compile time + use the result in
111    /// `const` contexts.
112    #[must_use]
113    pub const fn bytes(self) -> &'static [u8; 8] {
114        match self {
115            Self::V1 => b"ARKHEXP1",
116        }
117    }
118
119    /// Recognise a stream-header magic tag — `Some(variant)` when the
120    /// 8-byte input matches a supported version, `None` for any
121    /// unknown tag (caller surfaces via
122    /// [`WalExportError::UnsupportedStreamVersion`]).
123    ///
124    /// **Fail-fast posture**: this lookup runs against a stack 8-byte
125    /// array — no heap allocation, no buffer slicing beyond the
126    /// caller's already-read header bytes.
127    #[must_use]
128    pub fn recognize(bytes: &[u8; 8]) -> Option<Self> {
129        if bytes == b"ARKHEXP1" {
130            Some(Self::V1)
131        } else {
132            None
133        }
134    }
135}
136
137/// Maximum byte length accepted in a single record's length prefix.
138///
139/// 16 MiB fail-secure bound — any record whose length prefix exceeds
140/// this is rejected as malformed framing per cryptographer firm
141/// requirement #2 (bounds-check before deref). Production WAL records
142/// sit far below the threshold (typical record < 64 KiB); 16 MiB =
143/// ~256× typical, ample for legitimate growth and tight enough to
144/// short-circuit attacker-supplied prefix-overflow + memory-DoS
145/// before any deref attempt.
146///
147/// **Rationale (fail-secure pattern)**: the bound serves both
148/// (a) anti-overflow (length prefix arithmetic) and (b) anti-memory-
149/// DoS (allocation cap before record-bytes deref). Choosing the
150/// tighter of the two acceptable bounds (16 MiB vs 1 GiB) closes the
151/// memory-DoS attack vector with zero impact on legitimate workloads.
152///
153/// **Absolute vs per-sink soft cap**: this constant is the **absolute
154/// ceiling** baked into the wire-format contract. Concrete sinks MAY
155/// impose tighter per-deployment soft caps via their own
156/// configuration (e.g., a 1 MiB sink for embedded targets) and
157/// surface those rejections through [`WalExportError::BufferOverflow`].
158/// The hard ceiling here remains 16 MiB irrespective of sink
159/// configuration.
160pub const MAX_RECORD_BYTES: u64 = 1 << 24;
161
162/// Top-level streaming export error surface.
163///
164/// `#[non_exhaustive]` — variants may be added (e.g., compression /
165/// signature validation surfaces) without breaking external matchers.
166#[derive(Debug)]
167#[non_exhaustive]
168pub enum WalExportError {
169    /// Underlying sink I/O failure — operator-side disk / network /
170    /// buffer error. Carries the upstream `std::io::Error` for
171    /// diagnosis.
172    Io(std::io::Error),
173    /// Length-prefix or stream-header framing rejection. See
174    /// [`InvalidFramingReason`] for the specific malformation.
175    InvalidFraming(InvalidFramingReason),
176    /// Append-only invariant violated — caller attempted to submit a
177    /// record whose declared `seq` is not the strict successor of the
178    /// most recently appended record's `seq`. Surface enforcement: the
179    /// type-level invariant (no seek / truncate / rewrite methods)
180    /// prevents the *machinery* from rewriting, and this runtime-side
181    /// check prevents the *caller* from re-ordering.
182    ///
183    /// **Operator response**: fix the caller — the seq ordering
184    /// invariant was violated by upstream. Distinguish from
185    /// [`Self::SeqExhausted`] (intrinsic limit, rotate stream).
186    AppendOnlyViolation {
187        /// Expected next `seq` (most recent + 1, or 0 for a fresh
188        /// stream).
189        expected_seq: u64,
190        /// `seq` carried by the offending record.
191        got_seq: u64,
192        /// **Forensic field**: most recently appended `seq`, if any.
193        /// `None` means no record had been appended yet at the time
194        /// of the violation (fresh stream — and `expected_seq` will
195        /// be `0`). Gives the operator the prior high-water mark
196        /// independent of the violation triple, useful when the
197        /// violation post-dates a long sequence of appends and the
198        /// operator wants the prior-state context without re-deriving
199        /// `expected_seq - 1`.
200        previous_seq: Option<u64>,
201    },
202    /// Sequence space exhausted — the most recently appended `seq`
203    /// is `u64::MAX`, so any further append would require an
204    /// arithmetic wraparound. Distinct from
205    /// [`Self::AppendOnlyViolation`] because it signals an intrinsic
206    /// limit rather than a caller ordering error. Architecturally
207    /// unreachable on any real-world stream (u64::MAX appends at
208    /// 1ns/append exceeds the universe lifetime by ~17 orders of
209    /// magnitude), but the explicit variant makes the fail-secure
210    /// posture mechanical rather than implicit.
211    ///
212    /// **Operator response**: rotate the stream — start a new
213    /// stream, this one has exhausted its u64 seq space. The current
214    /// stream's `last_seq = u64::MAX` state is preserved (no
215    /// corruption), so the operator may rotate at leisure.
216    SeqExhausted {
217        /// The exhausted seq (always `u64::MAX`).
218        last_seq: u64,
219    },
220    /// Stream header magic recognised no supported version. The reader
221    /// (or framing-validation caller) read 8 bytes at the stream start,
222    /// asked [`StreamMagic::recognize`] to dispatch, and the lookup
223    /// returned `None`. Distinct from
224    /// [`InvalidFramingReason::HeaderMissing`] because that variant
225    /// signals "no magic at all" (truncated header) —
226    /// `UnsupportedStreamVersion` signals "magic present but
227    /// unrecognised" (e.g., a stream emitted by an unrecognised future
228    /// writer tag).
229    ///
230    /// **Operator response**: upgrade the reader, or downgrade the
231    /// writer to a known version. The carrier `magic` field gives
232    /// the operator the exact 8-byte tag for triage.
233    UnsupportedStreamVersion {
234        /// The 8-byte tag at stream start that did not match any
235        /// recognised [`StreamMagic`] variant.
236        magic: [u8; 8],
237    },
238    /// Internal buffer capacity exhausted before flush. Caller should
239    /// flush to drain. Concrete sink implementations choose their own
240    /// `capacity` — [`BufferedWalSink`] defaults to [`MAX_RECORD_BYTES`]
241    /// plus the 16-byte framing overhead and
242    /// exposes configuration knobs through the constructor.
243    BufferOverflow {
244        /// Sink's configured buffer capacity in bytes.
245        capacity: usize,
246        /// Bytes the caller attempted to append at overflow time.
247        requested: usize,
248        /// **Forensic field**: bytes already buffered at overflow
249        /// time. Combined with `capacity` and `requested`, gives the
250        /// operator the full sizing context: `current_buffer +
251        /// requested > capacity` is the predicate that triggered
252        /// overflow.
253        current_buffer: usize,
254    },
255}
256
257/// Reasons a streamed framing wrapper is rejected.
258///
259/// Distinct from [`WalExportError`] so callers can match the framing
260/// failure mode without needing to reach into a tuple variant.
261/// [`BufferedWalSink`] uses the variants individually when
262/// constructing rejection paths.
263#[derive(Debug)]
264#[non_exhaustive]
265pub enum InvalidFramingReason {
266    /// Length prefix exceeds [`MAX_RECORD_BYTES`]. Carries both the
267    /// observed prefix and the bound for caller diagnostics.
268    ///
269    /// **Operator response**: investigate hostile stream injection
270    /// or fragment producer. The 16 MiB ceiling is a fail-secure
271    /// hard bound — a legitimate producer should never emit a frame
272    /// at this size.
273    LengthExceedsMax {
274        /// The prefix value that triggered rejection.
275        prefix: u64,
276        /// The bound that was exceeded — equals [`MAX_RECORD_BYTES`].
277        max: u64,
278    },
279    /// Length prefix is zero — empty records are disallowed.
280    ///
281    /// Every frame must carry a payload; the reader rejects defensively
282    /// to prevent silent-no-op streams.
283    ///
284    /// **Operator response**: re-emit (the producer should never emit
285    /// a length-zero frame).
286    LengthZero,
287    /// Stream truncated — bytes ended mid-record before the full
288    /// length-prefixed payload arrived. Distinguishes operator-side
289    /// disk failure from valid-but-shorter streams.
290    ///
291    /// **Operator response**: re-emit; source likely truncated mid-
292    /// write (disk full, network drop, process crash). The reader
293    /// surfaces this distinct from `HeaderMissing` so the operator
294    /// can distinguish "never started" from "started but cut off".
295    Truncated,
296    /// Stream header magic missing or mismatched at stream start.
297    /// Triggered when the leading 8 bytes do not equal
298    /// [`STREAM_HEADER_MAGIC`].
299    ///
300    /// **Operator response**: verify the source stream is an
301    /// ARKHEXP-format export (not, e.g., an L0 WAL file or an
302    /// unrelated binary). Distinct from
303    /// [`crate::wal_export::WalExportError::UnsupportedStreamVersion`]
304    /// which signals "magic present but version unrecognised".
305    HeaderMissing,
306}
307
308impl fmt::Display for WalExportError {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        match self {
311            Self::Io(e) => write!(f, "wal export io: {e}"),
312            Self::InvalidFraming(r) => write!(f, "wal export framing rejected: {r}"),
313            Self::AppendOnlyViolation {
314                expected_seq,
315                got_seq,
316                previous_seq,
317            } => match previous_seq {
318                Some(prev) => write!(
319                    f,
320                    "wal export append-only violation: expected seq {expected_seq}, got {got_seq} (last appended {prev})"
321                ),
322                None => write!(
323                    f,
324                    "wal export append-only violation: expected seq {expected_seq}, got {got_seq} (fresh stream, no prior appends)"
325                ),
326            },
327            Self::SeqExhausted { last_seq } => write!(
328                f,
329                "wal export seq space exhausted at last_seq {last_seq} — rotate stream"
330            ),
331            Self::UnsupportedStreamVersion { magic } => write!(
332                f,
333                "wal export unsupported stream version: magic {magic:?} not recognised"
334            ),
335            Self::BufferOverflow {
336                capacity,
337                requested,
338                current_buffer,
339            } => write!(
340                f,
341                "wal export buffer overflow: capacity {capacity} bytes, current {current_buffer}, requested {requested}"
342            ),
343        }
344    }
345}
346
347impl fmt::Display for InvalidFramingReason {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        match self {
350            Self::LengthExceedsMax { prefix, max } => {
351                write!(f, "length prefix {prefix} exceeds maximum {max} bytes")
352            }
353            Self::LengthZero => write!(f, "length prefix is zero — empty records disallowed"),
354            Self::Truncated => write!(f, "stream truncated mid-record"),
355            Self::HeaderMissing => write!(
356                f,
357                "stream header magic missing or mismatched (expected ARKHEXP1)"
358            ),
359        }
360    }
361}
362
363impl std::error::Error for WalExportError {
364    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
365        match self {
366            Self::Io(e) => Some(e),
367            Self::InvalidFraming(_)
368            | Self::AppendOnlyViolation { .. }
369            | Self::SeqExhausted { .. }
370            | Self::UnsupportedStreamVersion { .. }
371            | Self::BufferOverflow { .. } => None,
372        }
373    }
374}
375
376impl std::error::Error for InvalidFramingReason {}
377
378impl From<std::io::Error> for WalExportError {
379    fn from(e: std::io::Error) -> Self {
380        Self::Io(e)
381    }
382}
383
384/// Append-only sink for streaming WAL records.
385///
386/// Implementations append fully-encoded record bytes (postcard
387/// `WalRecord` output from `arkhe_kernel::persist::Wal::serialize`)
388/// one record at a time, framing each with a fixed-width `u64` BE
389/// length prefix and following the stream-header pin (firm
390/// requirement #1 + #3). The trait deliberately exposes neither
391/// seek nor truncate nor rewrite — A14 append-only is type-level
392/// enforced by the absence of those methods.
393///
394/// Implementations are responsible for:
395///
396/// - validating the length prefix bounds (`0 < len ≤
397///   `[`MAX_RECORD_BYTES`]) before deref (firm requirement #2);
398/// - emitting the [`STREAM_HEADER_MAGIC`] pin at stream start;
399/// - rejecting out-of-sequence `seq` with
400///   [`WalExportError::AppendOnlyViolation`] (caller-side append-only);
401/// - durable persistence on [`flush`](Self::flush) — concrete
402///   implementations decide the persistence model (fsync per record
403///   vs batched fsync on flush).
404///
405/// `Send + Sync` are not required by the trait itself — concrete sinks
406/// may be `!Sync` if they wrap interior-mutable state. The reference
407/// implementation [`BufferedWalSink`] is `Send` but `!Sync` (uses an
408/// internal `&mut` cursor into the buffer).
409pub trait WalRecordSink {
410    /// Append one record's encoded bytes to the sink under its declared
411    /// monotonic `seq`. The implementation frames the bytes (length
412    /// prefix + payload) and records them in append order. Callers MUST
413    /// submit records in strict `+1` `seq` order; out-of-order
414    /// submissions trip [`WalExportError::AppendOnlyViolation`].
415    ///
416    /// `seq` is the record's kind-agnostic monotonic sequence —
417    /// production callers read it from the typed
418    /// `arkhe_kernel::WalRecord::seq()` accessor, so the L0 schema
419    /// coupling is compiler-checked rather than parsed out of the
420    /// payload. The bytes parameter SHOULD be the postcard encoding of
421    /// an L0 `WalRecord`; the sink treats it as opaque. The
422    /// wire-stability test `postcard_record_bytes_preserved_bit_exact`
423    /// verifies that streamed bytes pass through without any rewrite
424    /// (DO NOT TOUCH #7 projection).
425    fn append_record(&mut self, seq: u64, record_bytes: &[u8]) -> Result<(), WalExportError>;
426
427    /// Flush buffered records to the underlying durable storage.
428    /// Returns `Ok(())` once the implementation's durability contract
429    /// is satisfied (fsync completed, network ack received, etc.).
430    /// Implementations are free to flush eagerly inside
431    /// [`append_record`](Self::append_record) — `flush` then becomes
432    /// a no-op.
433    fn flush(&mut self) -> Result<(), WalExportError>;
434}
435
436#[cfg(test)]
437#[allow(clippy::expect_used, clippy::unwrap_used)]
438mod tests {
439    use super::*;
440
441    /// `STREAM_HEADER_MAGIC` is the 8-byte ASCII tag pinned at the
442    /// start of every stream. Wire-stable — a value change is a
443    /// wire-format break and requires a new magic value plus migration,
444    /// not silent reuse.
445    #[test]
446    fn stream_header_magic_is_arkhexp1() {
447        assert_eq!(&STREAM_HEADER_MAGIC, b"ARKHEXP1");
448    }
449
450    /// `StreamMagic::V1` byte mapping pinned at the wire-stability
451    /// golden vector (`0x41 0x52 0x4B 0x48 0x45 0x58 0x50 0x31`).
452    /// Drift between the enum dispatch and the legacy
453    /// `STREAM_HEADER_MAGIC` constant trips this test — keeping the
454    /// byte tag invariant across the cross-version surface.
455    #[test]
456    fn stream_magic_v1_bytes_match_legacy_constant() {
457        assert_eq!(StreamMagic::V1.bytes(), &STREAM_HEADER_MAGIC);
458        assert_eq!(
459            StreamMagic::V1.bytes(),
460            &[0x41, 0x52, 0x4B, 0x48, 0x45, 0x58, 0x50, 0x31]
461        );
462    }
463
464    /// `StreamMagic::recognize` returns `Some(V1)` for the ARKHEXP1
465    /// byte tag and `None` for anything else. Sample `None` cases
466    /// include an unallocated tag (`ARKHEXP2`) and an L0 WAL magic
467    /// mistakenly fed to the export reader (`ARKHEWAL`) — both must
468    /// reject without heap alloc.
469    #[test]
470    fn stream_magic_recognize_v1_and_rejects_unknown() {
471        assert_eq!(StreamMagic::recognize(b"ARKHEXP1"), Some(StreamMagic::V1));
472        // Unallocated tag — must reject without heap alloc.
473        assert_eq!(StreamMagic::recognize(b"ARKHEXP2"), None);
474        // L0 WAL magic — wrong transport, must reject.
475        assert_eq!(StreamMagic::recognize(b"ARKHEWAL"), None);
476        // Random eight bytes.
477        assert_eq!(StreamMagic::recognize(b"\0\0\0\0\0\0\0\0"), None);
478    }
479
480    /// `UnsupportedStreamVersion` Display surface distinguishable +
481    /// carries the offending magic for operator triage.
482    #[test]
483    fn unsupported_stream_version_display_distinguishes_with_magic() {
484        let err = WalExportError::UnsupportedStreamVersion {
485            magic: *b"ARKHEXP2",
486        };
487        let msg = err.to_string();
488        assert!(msg.contains("unsupported stream version"));
489        assert!(msg.contains("not recognised"));
490        // The offending magic appears in the message via Debug repr
491        // so operators can pin the exact byte pattern.
492        assert!(msg.contains("65")); // 0x41 is 'A' = 65 in decimal Debug
493    }
494
495    /// `STREAM_HEADER_MAGIC` distinct from L0 `WalHeader::MAGIC`
496    /// (`ARKHEWAL`) — separate transport format, separate magic.
497    #[test]
498    fn stream_header_magic_distinct_from_l0_wal_magic() {
499        assert_ne!(
500            &STREAM_HEADER_MAGIC,
501            &arkhe_kernel::persist::WalHeader::MAGIC
502        );
503    }
504
505    /// `MAX_RECORD_BYTES` pinned at 16 MiB (fail-secure, ~256× typical
506    /// record size). Bound short-circuits attacker-supplied length-
507    /// prefix overflow + memory-DoS before any deref attempt.
508    #[test]
509    fn max_record_bytes_is_sixteen_mib() {
510        assert_eq!(MAX_RECORD_BYTES, 1 << 24);
511        assert_eq!(MAX_RECORD_BYTES, 16_777_216);
512    }
513
514    /// `WalExportError` implements `Display` + `Error` + `Send + Sync`
515    /// — standard error-type expectations. `Send + Sync` lets the
516    /// error cross thread / async boundaries when sinks operate
517    /// off-runtime-thread.
518    #[test]
519    fn wal_export_error_implements_standard_error_traits() {
520        fn assert_send_sync<T: Send + Sync>() {}
521        fn assert_error<T: std::error::Error>() {}
522        assert_send_sync::<WalExportError>();
523        assert_error::<WalExportError>();
524        assert_send_sync::<InvalidFramingReason>();
525        assert_error::<InvalidFramingReason>();
526    }
527
528    /// Display surface — each variant produces a non-empty
529    /// distinguishable message. The exact strings are operator-facing
530    /// log lines, not part of the wire protocol; the wire-stability
531    /// tests do not verify them bit-exact.
532    #[test]
533    fn wal_export_error_display_is_distinguishable() {
534        let io_err = WalExportError::Io(std::io::Error::other("test"));
535        let framing_err = WalExportError::InvalidFraming(InvalidFramingReason::LengthZero);
536        let append_err = WalExportError::AppendOnlyViolation {
537            expected_seq: 5,
538            got_seq: 3,
539            previous_seq: Some(4),
540        };
541        let exhausted_err = WalExportError::SeqExhausted { last_seq: u64::MAX };
542        let buf_err = WalExportError::BufferOverflow {
543            capacity: 1024,
544            requested: 2048,
545            current_buffer: 768,
546        };
547
548        let io_msg = io_err.to_string();
549        let framing_msg = framing_err.to_string();
550        let append_msg = append_err.to_string();
551        let exhausted_msg = exhausted_err.to_string();
552        let buf_msg = buf_err.to_string();
553
554        assert!(io_msg.contains("io"));
555        assert!(framing_msg.contains("framing"));
556        assert!(append_msg.contains("append-only"));
557        assert!(append_msg.contains('5'));
558        assert!(append_msg.contains('3'));
559        assert!(append_msg.contains('4')); // previous_seq forensic
560        assert!(exhausted_msg.contains("exhausted"));
561        assert!(exhausted_msg.contains("rotate"));
562        assert!(buf_msg.contains("buffer overflow"));
563        assert!(buf_msg.contains("1024"));
564        assert!(buf_msg.contains("2048"));
565        assert!(buf_msg.contains("768")); // current_buffer forensic
566    }
567
568    /// `AppendOnlyViolation` with `previous_seq: None` (fresh stream)
569    /// renders a distinguishable Display string. The fresh-stream
570    /// forensic case is what an operator hits when the caller submits
571    /// a non-zero seq as the first record (e.g., resuming a stream
572    /// from a stale checkpoint).
573    #[test]
574    fn append_only_violation_fresh_stream_display_distinguishes_from_some_case() {
575        let fresh = WalExportError::AppendOnlyViolation {
576            expected_seq: 0,
577            got_seq: 7,
578            previous_seq: None,
579        };
580        let after = WalExportError::AppendOnlyViolation {
581            expected_seq: 5,
582            got_seq: 3,
583            previous_seq: Some(4),
584        };
585        let fresh_msg = fresh.to_string();
586        let after_msg = after.to_string();
587        assert!(fresh_msg.contains("fresh stream"));
588        assert!(fresh_msg.contains("no prior appends"));
589        assert!(after_msg.contains("last appended"));
590        assert!(after_msg.contains('4'));
591        assert_ne!(fresh_msg, after_msg);
592    }
593
594    /// Each `InvalidFramingReason` variant produces a distinguishable
595    /// Display message. Operators triage on these strings.
596    #[test]
597    fn invalid_framing_reason_display_is_distinguishable() {
598        let exceeds = InvalidFramingReason::LengthExceedsMax {
599            prefix: MAX_RECORD_BYTES + 1,
600            max: MAX_RECORD_BYTES,
601        };
602        let zero = InvalidFramingReason::LengthZero;
603        let trunc = InvalidFramingReason::Truncated;
604        let hdr = InvalidFramingReason::HeaderMissing;
605
606        assert!(exceeds.to_string().contains("exceeds maximum"));
607        assert!(zero.to_string().contains("zero"));
608        assert!(trunc.to_string().contains("truncated"));
609        assert!(hdr.to_string().contains("ARKHEXP1"));
610    }
611
612    /// `WalExportError::source()` exposes the underlying `std::io::Error`
613    /// when present so error-chain printers / observability hooks can
614    /// drill down. Non-IO variants return `None`.
615    #[test]
616    fn wal_export_error_source_chains_through_io() {
617        use std::error::Error;
618        let io_err = WalExportError::Io(std::io::Error::other("test"));
619        let framing_err = WalExportError::InvalidFraming(InvalidFramingReason::LengthZero);
620
621        assert!(io_err.source().is_some());
622        assert!(framing_err.source().is_none());
623    }
624
625    /// `From<std::io::Error>` — `?`-friendly conversion at concrete
626    /// sink implementation sites (buffered sink + round-trip fixtures).
627    #[test]
628    fn from_io_error_lifts_to_wal_export_error() {
629        let io_err = std::io::Error::other("test");
630        let lifted: WalExportError = io_err.into();
631        assert!(matches!(lifted, WalExportError::Io(_)));
632    }
633
634    /// No-op stub `WalRecordSink` impl — confirms the trait is
635    /// satisfiable without seek / truncate. Concrete impl is
636    /// [`BufferedWalSink`]. Test asserts the trait's *method shape*
637    /// alone.
638    struct NoopSink;
639
640    impl WalRecordSink for NoopSink {
641        fn append_record(&mut self, _seq: u64, _record_bytes: &[u8]) -> Result<(), WalExportError> {
642            Ok(())
643        }
644        fn flush(&mut self) -> Result<(), WalExportError> {
645            Ok(())
646        }
647    }
648
649    /// Trait surface satisfiable without any positioning operation.
650    /// If a future change to [`WalRecordSink`] adds a seek / truncate
651    /// method, this test compiles trivially but its rationale
652    /// docstring is the trip-wire for human review.
653    #[test]
654    fn wal_record_sink_satisfiable_without_seek() {
655        let mut sink = NoopSink;
656        sink.append_record(1, b"sample")
657            .expect("noop sink succeeds");
658        sink.flush().expect("noop flush");
659    }
660}