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}