Skip to main content

vyre_foundation/serial/
envelope.rs

1//! Reusable on-wire envelope for `vyre`-level serializable types.
2//!
3//! Higher layers ship their own binary payloads, including `CompiledDfa` in
4//! `vyre-primitives`, `GpuLiteralSet` in `vyre-libs`, and scan databases in
5//! `vyre-scan`. Each format needs the same framing operations:
6//!
7//! 1. Write a 4-byte magic and little-endian `u32` version header.
8//! 2. Emit length-prefixed byte sections.
9//! 3. Emit length-prefixed little-endian word arrays.
10//! 4. Decode typed version, truncation, and corruption errors.
11//!
12//! This module owns those operations so fixes propagate to every consumer.
13//!
14//! # Layered usage
15//!
16//! - `WireWriter` builds the blob; consumers compose multiple sections
17//!   in order.
18//! - `WireReader` decodes; consumers pull the same sections in the same
19//!   order.
20//! - [`EnvelopeError`] carries every failure mode; consumers should
21//!   forward it (or wrap into their own error enum) without redefining
22//!   the variants.
23//!
24//! The envelope itself is **not** content-aware. Consumers wrap it with
25//! their own magic + version constants so two unrelated payloads
26//! (e.g. a DFA and a literal set) cannot be confused at decode time.
27
28use std::error::Error;
29use std::fmt;
30
31/// Errors returned from [`WireReader`] decode operations. Variants are
32/// non-exhaustive so additive framing variants stay backward-compatible.
33#[derive(Debug, Clone)]
34#[non_exhaustive]
35pub enum EnvelopeError {
36    /// Payload ended before the requested section was fully read.
37    Truncated {
38        /// Byte offset the decoder needed to reach.
39        needed: usize,
40        /// Bytes actually present in the input slice.
41        got: usize,
42    },
43    /// First four bytes did not match the consumer's expected magic.
44    BadMagic {
45        /// Magic the consumer expected.
46        expected: [u8; 4],
47        /// Magic actually present in the blob.
48        found: [u8; 4],
49    },
50    /// Wire version header did not match the consumer's expected
51    /// version. Primary signal for cache invalidation: a
52    /// `VersionMismatch` is the consumer's cue to discard the cache and
53    /// recompile from source.
54    VersionMismatch {
55        /// Wire version the consumer's build understands.
56        expected: u32,
57        /// Wire version recorded in the blob's header.
58        found: u32,
59    },
60    /// A section or word-array length could not fit in the envelope's
61    /// `u32` length prefix.
62    SectionTooLarge {
63        /// Length the caller attempted to encode.
64        len: usize,
65        /// Maximum length representable by the wire format.
66        max: usize,
67    },
68}
69
70impl fmt::Display for EnvelopeError {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::Truncated { needed, got } => write!(
74                f,
75                "wire envelope truncated: needed {needed} bytes, got {got}. \
76                 Fix: regenerate the cache."
77            ),
78            Self::BadMagic { expected, found } => write!(
79                f,
80                "wire envelope magic mismatch: expected {expected:?}, found {found:?}. \
81                 Fix: this blob was not produced by the matching consumer."
82            ),
83            Self::VersionMismatch { expected, found } => write!(
84                f,
85                "wire envelope version {found} does not match runtime {expected}. \
86                 Fix: discard the cache and rebuild from source."
87            ),
88            Self::SectionTooLarge { len, max } => write!(
89                f,
90                "wire envelope section length {len} exceeds maximum {max}. \
91                 Fix: split the payload into smaller sections."
92            ),
93        }
94    }
95}
96
97impl Error for EnvelopeError {}
98
99/// Build a typed binary blob with magic + version + sections.
100///
101/// Consumers create one writer, push sections in their declared order,
102/// then call `into_bytes`. Section order is the consumer's contract;
103/// the envelope itself only enforces the framing.
104#[derive(Debug)]
105pub struct WireWriter {
106    out: Vec<u8>,
107}
108
109impl WireWriter {
110    /// Start a writer with the given magic + version header. The header
111    /// is emitted immediately so consumers can read offsets predictably.
112    #[must_use]
113    pub fn new(magic: &[u8; 4], version: u32) -> Self {
114        let mut out = Vec::with_capacity(8);
115        out.extend_from_slice(magic);
116        out.extend_from_slice(&version.to_le_bytes());
117        Self { out }
118    }
119
120    /// Append a length-prefixed byte section. The length is encoded as
121    /// a little-endian `u32` (bound: 4 GiB per section).
122    ///
123    /// # Errors
124    ///
125    /// Returns [`EnvelopeError::SectionTooLarge`] when the byte count cannot
126    /// fit in the envelope's `u32` length prefix.
127    pub fn write_section(&mut self, bytes: &[u8]) -> Result<(), EnvelopeError> {
128        let len = u32::try_from(bytes.len()).map_err(|_| EnvelopeError::SectionTooLarge {
129            len: bytes.len(),
130            max: u32::MAX as usize,
131        })?;
132        self.out.extend_from_slice(&len.to_le_bytes());
133        self.out.extend_from_slice(bytes);
134        Ok(())
135    }
136
137    /// Append a length-prefixed `u32` word array. Each word is encoded
138    /// little-endian; the prefix counts WORDS, not bytes.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`EnvelopeError::SectionTooLarge`] when the word count cannot
143    /// fit in the envelope's `u32` length prefix.
144    pub fn write_words(&mut self, words: &[u32]) -> Result<(), EnvelopeError> {
145        let len = u32::try_from(words.len()).map_err(|_| EnvelopeError::SectionTooLarge {
146            len: words.len(),
147            max: u32::MAX as usize,
148        })?;
149        self.out.extend_from_slice(&len.to_le_bytes());
150        for w in words {
151            self.out.extend_from_slice(&w.to_le_bytes());
152        }
153        Ok(())
154    }
155
156    /// Append a single little-endian `u32`. Useful for fixed-width
157    /// header fields (state counts, capability flags, etc.) that don't
158    /// need a length prefix.
159    pub fn write_u32(&mut self, value: u32) {
160        self.out.extend_from_slice(&value.to_le_bytes());
161    }
162
163    /// Consume the writer and return the underlying bytes.
164    #[must_use]
165    pub fn into_bytes(self) -> Vec<u8> {
166        self.out
167    }
168}
169
170/// Decode a typed binary blob produced by [`WireWriter`].
171#[derive(Debug)]
172pub struct WireReader<'a> {
173    src: &'a [u8],
174    cursor: usize,
175}
176
177impl<'a> WireReader<'a> {
178    /// Begin a reader; validates the 8-byte magic + version header.
179    /// Consumers MUST call this and propagate the error before reading
180    /// any sections  -  sections after a bad header cannot be trusted.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`EnvelopeError::Truncated`] for incomplete headers,
185    /// [`EnvelopeError::BadMagic`] for magic mismatches, or
186    /// [`EnvelopeError::VersionMismatch`] for stale/corrupt versions.
187    pub fn new(
188        bytes: &'a [u8],
189        expected_magic: &[u8; 4],
190        expected_version: u32,
191    ) -> Result<Self, EnvelopeError> {
192        if bytes.len() < 8 {
193            return Err(EnvelopeError::Truncated {
194                needed: 8,
195                got: bytes.len(),
196            });
197        }
198        let mut found_magic = [0u8; 4];
199        found_magic.copy_from_slice(&bytes[0..4]);
200        if &found_magic != expected_magic {
201            return Err(EnvelopeError::BadMagic {
202                expected: *expected_magic,
203                found: found_magic,
204            });
205        }
206        let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
207        if version != expected_version {
208            return Err(EnvelopeError::VersionMismatch {
209                expected: expected_version,
210                found: version,
211            });
212        }
213        Ok(Self {
214            src: bytes,
215            cursor: 8,
216        })
217    }
218
219    /// Read a length-prefixed byte section.
220    ///
221    /// # Errors
222    ///
223    /// Returns [`EnvelopeError::Truncated`] when the length prefix or section
224    /// bytes exceed the remaining input.
225    pub fn read_section(&mut self) -> Result<&'a [u8], EnvelopeError> {
226        let n = self.read_u32()? as usize;
227        if self.src.len() < self.cursor + n {
228            return Err(EnvelopeError::Truncated {
229                needed: self.cursor + n,
230                got: self.src.len(),
231            });
232        }
233        let slice = &self.src[self.cursor..self.cursor + n];
234        self.cursor += n;
235        Ok(slice)
236    }
237
238    /// Read a length-prefixed `u32` word array.
239    ///
240    /// # Errors
241    ///
242    /// Returns [`EnvelopeError::Truncated`] when the length prefix or encoded
243    /// words exceed the remaining input.
244    pub fn read_words(&mut self) -> Result<Vec<u32>, EnvelopeError> {
245        let n_words = self.read_u32()? as usize;
246        let bytes_needed = n_words * 4;
247        if self.src.len() < self.cursor + bytes_needed {
248            return Err(EnvelopeError::Truncated {
249                needed: self.cursor + bytes_needed,
250                got: self.src.len(),
251            });
252        }
253        let mut v = Vec::with_capacity(n_words);
254        for _ in 0..n_words {
255            let w = u32::from_le_bytes([
256                self.src[self.cursor],
257                self.src[self.cursor + 1],
258                self.src[self.cursor + 2],
259                self.src[self.cursor + 3],
260            ]);
261            v.push(w);
262            self.cursor += 4;
263        }
264        Ok(v)
265    }
266
267    /// Read a single little-endian `u32` (no length prefix).
268    ///
269    /// # Errors
270    ///
271    /// Returns [`EnvelopeError::Truncated`] when fewer than four bytes remain.
272    pub fn read_u32(&mut self) -> Result<u32, EnvelopeError> {
273        if self.src.len() < self.cursor + 4 {
274            return Err(EnvelopeError::Truncated {
275                needed: self.cursor + 4,
276                got: self.src.len(),
277            });
278        }
279        let n = u32::from_le_bytes([
280            self.src[self.cursor],
281            self.src[self.cursor + 1],
282            self.src[self.cursor + 2],
283            self.src[self.cursor + 3],
284        ]);
285        self.cursor += 4;
286        Ok(n)
287    }
288}
289
290/// Generic round-trip / robustness assertion helpers for any
291/// wire-format consumer.
292///
293/// Every type that ships its own `to_bytes` / `from_bytes` pair on top
294/// of this envelope used to write the same five tests:
295///   1. `round_trip`
296///   2. `rejects_bad_magic`
297///   3. `rejects_version_mismatch`
298///   4. `rejects_truncated_header`
299///   5. `rejects_truncated_section`
300///
301/// These helpers reduce that to one call per type. Consumers call
302/// `assert_envelope_roundtrip(&value)` and the helper drives the full
303/// suite. The bound `T: WireRoundTrip` is provided by consumers as a
304/// thin trait that exposes the type's `to_bytes` / `from_bytes` plus
305/// its declared magic + version.
306pub mod test_helpers {
307    use super::{EnvelopeError, WireWriter};
308
309    /// Adapter trait consumers implement to plug their wire format
310    /// into [`assert_envelope_roundtrip`]. The `to_bytes` and
311    /// `from_bytes` methods are forwarded to the type's own; the
312    /// `MAGIC` / `VERSION` consts let the helpers fabricate
313    /// deliberately-corrupted blobs.
314    pub trait WireRoundTrip: Sized {
315        /// Wire-format magic the type stamps on every blob.
316        const MAGIC: [u8; 4];
317        /// Wire version the type stamps on every blob.
318        const VERSION: u32;
319        /// Encoder error type. Not exercised here  -  consumers pre-
320        /// validate that `to_bytes` returns `Ok` for the sample.
321        type EncodeError: std::fmt::Debug;
322        /// Decoder error type. Used to confirm that mutated blobs
323        /// surface as typed errors instead of panics.
324        type DecodeError: std::fmt::Debug;
325
326        /// Encode a sample value.
327        ///
328        /// # Errors
329        /// Forwarded from the type's own encoder.
330        fn to_bytes(&self) -> Result<Vec<u8>, Self::EncodeError>;
331
332        /// Decode a previously-encoded blob.
333        ///
334        /// # Errors
335        /// Forwarded from the type's own decoder.
336        fn from_bytes(bytes: &[u8]) -> Result<Self, Self::DecodeError>;
337
338        /// Comparison hook so the helper can assert structural equality
339        /// after a round trip without requiring `PartialEq` on the type
340        /// itself (some engines hold non-comparable buffers / programs).
341        fn structurally_eq(&self, other: &Self) -> bool;
342    }
343
344    /// Drive the standard wire-format assertion suite against `sample`.
345    ///
346    /// Asserts:
347    ///   - encode succeeds
348    ///   - decode of the encoded bytes returns a value that
349    ///     `structurally_eq`s the original
350    ///   - mutating the magic byte produces a typed decode error
351    ///   - mutating the version dword produces a typed decode error
352    ///   - truncating the trailing byte produces a typed decode error
353    ///   - feeding an 8-byte buffer (header only, zero sections) is a
354    ///     decoder concern  -  helper does NOT assert success/failure
355    ///     because section-counts vary by consumer.
356    ///
357    /// Intentionally panics on assertion failure (this is a test
358    /// helper, not a runtime path).
359    ///
360    /// # Panics
361    ///
362    /// Panics when encoding/decoding fails for the supplied valid sample, when
363    /// the encoded header is malformed, or when corruption/truncation does not
364    /// surface as a typed decode error.
365    pub fn assert_envelope_roundtrip<T>(sample: &T)
366    where
367        T: WireRoundTrip + std::fmt::Debug,
368    {
369        let encoded = sample.to_bytes();
370        assert!(
371            encoded.is_ok(),
372            "Fix: encode sample; restore this invariant before continuing: {encoded:?}"
373        );
374        let Ok(bytes) = encoded else {
375            return;
376        };
377        assert!(
378            bytes.len() >= 8,
379            "wire blob must include at least the 8-byte header"
380        );
381        assert_eq!(
382            &bytes[0..4],
383            T::MAGIC.as_slice(),
384            "magic mismatch in encoded blob"
385        );
386        let version_field = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
387        let expected_version = T::VERSION;
388        assert!(
389            version_field == expected_version,
390            "version mismatch in encoded blob: got {version_field}, expected {expected_version}"
391        );
392
393        let decoded = T::from_bytes(&bytes);
394        assert!(
395            decoded.is_ok(),
396            "Fix: decode round trip; restore this invariant before continuing: {decoded:?}"
397        );
398        let Ok(back) = decoded else {
399            return;
400        };
401        assert!(
402            sample.structurally_eq(&back),
403            "round-tripped value diverges from original"
404        );
405
406        // Mutate magic.
407        let mut mutated = bytes.clone();
408        mutated[0] ^= 0xFF;
409        assert!(
410            T::from_bytes(&mutated).is_err(),
411            "mutated magic must surface as a typed error"
412        );
413
414        // Mutate version.
415        let mut mutated = bytes.clone();
416        let bumped = T::VERSION.wrapping_add(1);
417        mutated[4..8].copy_from_slice(&bumped.to_le_bytes());
418        assert!(
419            T::from_bytes(&mutated).is_err(),
420            "mutated version must surface as a typed error"
421        );
422
423        // Truncate one byte off the tail.
424        if bytes.len() > 8 {
425            let truncated = &bytes[..bytes.len() - 1];
426            assert!(
427                T::from_bytes(truncated).is_err(),
428                "truncated trailing byte must surface as a typed error"
429            );
430        }
431    }
432
433    /// Helper for tests that want to fabricate blobs with arbitrary
434    /// magic + version. Returns a header-only buffer (no sections).
435    /// Useful for asserting that consumers reject empty-section blobs
436    /// when their schema requires N sections.
437    #[must_use]
438    pub fn header_only(magic: &[u8; 4], version: u32) -> Vec<u8> {
439        WireWriter::new(magic, version).into_bytes()
440    }
441
442    /// Confirm that the `EnvelopeError` matches an expected variant
443    /// (without requiring the consumer's wrapper enum to expose
444    /// `PartialEq`).
445    ///
446    /// # Panics
447    ///
448    /// Panics when `err` does not match the expected envelope-error category.
449    pub fn assert_envelope_error_kind(err: &EnvelopeError, kind: ExpectedEnvelopeError) {
450        let matches = matches!(
451            (err, kind),
452            (
453                EnvelopeError::Truncated { .. },
454                ExpectedEnvelopeError::Truncated
455            ) | (
456                EnvelopeError::BadMagic { .. },
457                ExpectedEnvelopeError::BadMagic
458            ) | (
459                EnvelopeError::VersionMismatch { .. },
460                ExpectedEnvelopeError::VersionMismatch
461            ) | (
462                EnvelopeError::SectionTooLarge { .. },
463                ExpectedEnvelopeError::SectionTooLarge
464            )
465        );
466        assert!(
467            matches,
468            "expected envelope error kind {kind:?}, got {err:?}"
469        );
470    }
471
472    /// Variant tags for [`assert_envelope_error_kind`]. Mirrors
473    /// [`EnvelopeError`] but is decoupled from the consumer's wrapper
474    /// enum so they can match on it without re-exporting the
475    /// variants.
476    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
477    pub enum ExpectedEnvelopeError {
478        /// `EnvelopeError::Truncated`
479        Truncated,
480        /// `EnvelopeError::BadMagic`
481        BadMagic,
482        /// `EnvelopeError::VersionMismatch`
483        VersionMismatch,
484        /// `EnvelopeError::SectionTooLarge`
485        SectionTooLarge,
486    }
487}