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}