Skip to main content

vyre_foundation/serial/
wire.rs

1// Stable binary IR wire format for serialized IR programs.
2
3use crate::ir::{BufferDecl, DataType, Expr, Node, Program};
4
5/// The `decode` module.
6pub mod decode;
7/// The `encode` module.
8pub mod encode;
9/// The `framing` module.
10pub mod framing;
11/// The `tags` module.
12pub mod tags;
13
14/// Maximum buffers accepted from one IR wire-format program.
15///
16/// I10 requires bounded allocation before validating semantics. This limit
17/// rejects hostile wire blobs before allocating the buffer table.
18pub const MAX_BUFFERS: usize = 16_384;
19
20/// Maximum statement nodes accepted from any single wire-format node list.
21///
22/// I10 requires node vectors to be bounded before allocation; nested lists are
23/// each checked against this budget as they are decoded.
24pub const MAX_NODES: usize = 1_000_000;
25
26/// Maximum call arguments accepted from one wire-format call expression.
27///
28/// I10 requires expression argument vectors to be bounded before allocation.
29pub const MAX_ARGS: usize = 4_096;
30
31/// Maximum tensor rank (dimension count) accepted from a wire-format
32/// `DataType::TensorShaped` shape.
33///
34/// I10 requires the shape vector to be bounded before the decoder reads each
35/// dimension. Real tensors are single-digit rank (the inline `SmallVec`
36/// capacity is 4); this ceiling is generous enough never to reject a real
37/// program yet bounds the shape allocation to a small fixed size instead of the
38/// transitive `MAX_PROGRAM_BYTES / 4` worst case. Makes the "rank-limited shape"
39/// contract on `DataType::TensorShaped` actually enforced.
40pub const MAX_TENSOR_RANK: usize = 4_096;
41
42/// Maximum device-mesh axis count accepted from a wire-format
43/// `DataType::DeviceMesh`.
44///
45/// I10 requires the axis vector to be bounded before the decoder reads each
46/// axis. Real meshes have a handful of axes (data/model/pipeline parallelism);
47/// this ceiling never rejects a real program yet bounds the allocation.
48pub const MAX_MESH_AXES: usize = 4_096;
49
50/// Maximum UTF-8 string length accepted from the IR wire format.
51///
52/// I10 bounds allocation for names and operation identifiers carried by
53/// attacker-controlled wire bytes.
54pub const MAX_STRING_LEN: usize = 1 << 20;
55
56/// Maximum opaque payload length accepted from the IR wire format.
57///
58/// I10 bounds allocation for extension-defined `Expr::Opaque` and
59/// `Node::Opaque` payloads carried by attacker-controlled wire bytes.
60/// Must match the encoder limit in `put_node.rs` and `put_expr.rs`.
61pub const MAX_OPAQUE_PAYLOAD_LEN: usize = MAX_ARGS * 1024;
62
63/// Maximum recursive decode depth for the IR wire format.
64///
65/// The limit is applied to the **shared** recursion counter in `Reader`
66/// that `Reader::node` and `Reader::expr` both increment on entry and
67/// decrement on exit. A hostile blob cannot evade the cap by alternating
68/// statement and expression nesting  -  every nested decode call, whether it
69/// descends into a `Node::If`/`Loop`/`Block` body or into a nested
70/// [`Expr`] argument tree, counts against the same budget. Depth ≥
71/// `MAX_DECODE_DEPTH` is rejected with a `Fix:`-prefixed error before any
72/// stack frame is pushed, preventing stack-overflow `DoS` from a blob that
73/// nests `Block(Block(... Block(...) ...))` a million times deep.
74///
75/// Covers audit L.1.35 (HIGH).
76pub const MAX_DECODE_DEPTH: u32 = 64;
77
78/// Hard ceiling on the size of a single wire-encoded Program in bytes.
79///
80/// The framing layer rejects larger blobs before any decode allocation so
81/// attacker-controlled input cannot force unbounded memory growth.
82pub const MAX_PROGRAM_BYTES: usize = 64 * 1024 * 1024;
83
84pub(crate) struct Reader<'a> {
85    pub bytes: &'a [u8],
86    pub pos: usize,
87    /// Current recursion depth on the decode call stack. Incremented by
88    /// every `node()` and `expr()` call and compared against
89    /// [`MAX_DECODE_DEPTH`] before any nested decode proceeds.
90    pub depth: u32,
91}
92
93impl Program {
94    /// Serialize this IR program into the stable `VIR0` IR wire format.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`crate::error::Error::WireFormatValidation`] when a count
99    /// cannot be represented in the versioned wire format or when a public
100    /// enum variant has no registered stable wire tag. The `message` field
101    /// carries the actionable diagnostic prose including a `Fix:` hint.
102    #[inline]
103    #[must_use]
104    pub fn to_wire(&self) -> Result<Vec<u8>, crate::error::Error> {
105        encode::to_wire(self).map_err(wire_err)
106    }
107
108    /// Serialize this IR program into the stable `VIR0` IR wire format,
109    /// appending to an existing buffer.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`crate::error::Error::WireFormatValidation`] when a count
114    /// cannot be represented in the versioned wire format or when a public
115    /// enum variant has no registered stable wire tag. The `message` field
116    /// carries the actionable diagnostic prose including a `Fix:` hint.
117    #[inline]
118    pub fn to_wire_into(&self, dst: &mut Vec<u8>) -> Result<(), crate::error::Error> {
119        encode::to_wire_into(self, dst).map_err(wire_err)
120    }
121
122    /// Serialize this IR program into bytes.
123    ///
124    /// This compatibility wrapper preserves the pre-`to_wire` API name.
125    ///
126    /// On an encoding error, an empty vector is returned after logging the
127    /// failure. Use [`Program::to_wire`] when the caller needs to handle the
128    /// error explicitly.
129    #[must_use]
130    #[inline]
131    pub fn to_bytes(&self) -> Vec<u8> {
132        match self.to_wire() {
133            Ok(bytes) => bytes,
134            Err(error) => {
135                tracing::error!(
136                    error = %error,
137                    "Program::to_bytes: wire encoding failed; returning empty bytes. \
138                     Fix: call Program::to_wire and handle the validation error explicitly."
139                );
140                Vec::new()
141            }
142        }
143    }
144
145    /// Deserialize an IR program from the stable `VYRE` IR wire format.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`crate::error::Error::VersionMismatch`] when the
150    /// payload advertises a schema version this runtime does not
151    /// understand. Returns [`crate::error::Error::WireFormatValidation`]
152    /// for any other decode failure  -  truncated bytes, unknown enum
153    /// tag, integrity digest mismatch, or malformed structural
154    /// section.
155    #[inline]
156    #[must_use]
157    pub fn from_wire(bytes: &[u8]) -> Result<Self, crate::error::Error> {
158        if bytes.len() > MAX_PROGRAM_BYTES {
159            return Err(wire_err(format!(
160                "Fix: wire blob is {} bytes, exceeding the {}-byte IR framing cap. Reject this input or split the Program before serialization.",
161                bytes.len(),
162                MAX_PROGRAM_BYTES
163            )));
164        }
165        // The version field is validated before the string-based
166        // decoder so that an out-of-range version surfaces as the
167        // typed `VersionMismatch` variant instead of being absorbed
168        // into the generic `WireFormatValidation` bucket. Tooling
169        // that hangs off the diagnostic code `E-WIRE-VERSION` relies
170        // on this distinction.
171        if bytes.len() >= framing::MAGIC.len() + 2
172            && &bytes[..framing::MAGIC.len()] == framing::MAGIC
173        {
174            let version = u16::from_le_bytes([bytes[4], bytes[5]]);
175            if !framing::wire_format_version_is_supported(version) {
176                return Err(crate::error::Error::VersionMismatch {
177                    expected: u32::from(framing::WIRE_FORMAT_VERSION),
178                    found: u32::from(version),
179                });
180            }
181        }
182        decode::from_wire(bytes).map_err(wire_err)
183    }
184
185    /// Deserialize an IR program from bytes.
186    ///
187    /// This compatibility wrapper preserves the pre-`from_wire` API name.
188    ///
189    /// # Errors
190    ///
191    /// Returns the same actionable decode errors as [`Program::from_wire`].
192    #[inline]
193    #[must_use]
194    pub fn from_bytes(bytes: &[u8]) -> Result<Self, crate::error::Error> {
195        Self::from_wire(bytes)
196    }
197
198    /// Stable content hash of this Program, used as a cache identity.
199    ///
200    /// Computed as BLAKE3 of the canonical wire-format encoding. This is the
201    /// exact-match identity for persistent-cache consumers that need a
202    /// deterministic key per Program without re-implementing canonicalization.
203    /// On canonical wire-encoding failure, the value is a domain-separated
204    /// error digest rather than an all-zero sentinel, so malformed programs do
205    /// not collapse into the same cache identity.
206    #[must_use]
207    pub fn content_hash(&self) -> [u8; 32] {
208        self.fingerprint()
209    }
210}
211
212/// Wrap an internal wire-format error string in the typed [`crate::error::Error`]
213/// so every public boundary of this module returns a structured variant
214/// callers can match on.
215fn wire_err(message: String) -> crate::error::Error {
216    crate::error::Error::WireFormatValidation { message }
217}
218
219/// Append stable VIR0 wire bytes for a [`DataType`] (tag + any payload) into
220/// `buf`. Used by disk-cache fingerprinting where `Debug` output would be
221/// the wrong contract.
222///
223/// # Errors
224///
225/// Returns a wire-format diagnostic when `value` contains a datatype variant
226/// without a stable tag or a payload that cannot fit the VIR0 encoding.
227pub fn append_data_type_fingerprint(buf: &mut Vec<u8>, value: &DataType) -> Result<(), String> {
228    tags::data_type_tag::put_data_type(buf, value).map_err(String::from)
229}
230
231/// Append stable VIR0 wire bytes for a `Node` statement list (count + each
232/// node). Matches the statement encoding used in full program wire (`to_wire`)
233/// (without the file envelope, metadata, or buffer table).
234///
235/// # Errors
236///
237/// Returns a wire-format diagnostic when the node list or any nested payload
238/// cannot be represented in VIR0.
239pub fn append_node_list_fingerprint(buf: &mut Vec<u8>, nodes: &[Node]) -> Result<(), String> {
240    encode::put_nodes(buf, nodes).map_err(String::from)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::ir::{BufferAccess, BufferDecl, DataType, Node, Program};
247
248    #[test]
249    #[inline]
250    pub(crate) fn to_bytes_returns_empty_on_wire_error() {
251        let long_name = "x".repeat(MAX_STRING_LEN + 1);
252        let program = Program::wrapped(
253            vec![BufferDecl::storage(
254                &long_name,
255                0,
256                BufferAccess::ReadOnly,
257                DataType::U32,
258            )],
259            [1, 1, 1],
260            vec![],
261        );
262        assert!(program.to_wire().is_err());
263        assert!(program.to_bytes().is_empty());
264    }
265
266    /// EDGE-001 regression: `MAX_DECODE_DEPTH` covers **both** Node and Expr
267    /// recursion through the same counter. A blob that nests statement
268    /// bodies past the depth limit must be rejected at decode time,
269    /// preventing stack-overflow DoS on untrusted input.
270    ///
271    /// The test runs on a dedicated thread with an 8 MiB stack because
272    /// the encode/decode walk down a `MAX_DECODE_DEPTH + 1`-deep Block
273    /// tree uses ~3–4× the native frames the default 2 MiB test stack
274    /// allocates. Without the explicit stack, the test itself
275    /// stack-overflows before the decode guard ever fires  -  masking
276    /// the real assertion.
277    #[test]
278    pub(crate) fn decode_depth_cap_rejects_deeply_nested_blocks() {
279        std::thread::Builder::new()
280            .stack_size(8 * 1024 * 1024)
281            .spawn(run_decode_depth_cap)
282            .expect("Fix: spawn test worker")
283            .join()
284            .expect("Fix: decode-depth-cap worker panicked");
285    }
286
287    fn run_decode_depth_cap() {
288        // Build the nested program iteratively so the test thread's
289        // stack only owns the tree, not a recursion chain the depth
290        // of the tree.
291        let mut inner = Node::Block(vec![]);
292        for _ in 0..MAX_DECODE_DEPTH {
293            inner = Node::Block(vec![inner]);
294        }
295        let program = Program::wrapped(
296            vec![BufferDecl::read_write("out", 0, DataType::U32)],
297            [1, 1, 1],
298            vec![inner],
299        );
300        let bytes = program
301            .to_wire()
302            .expect("Fix: building a (MAX_DEPTH+1)-nested program must still encode");
303        let decoded = Program::from_wire(&bytes);
304        assert!(
305            decoded.is_err(),
306            "decoding a program deeper than MAX_DECODE_DEPTH must fail; got Ok"
307        );
308        let err = decoded.unwrap_err().to_string();
309        assert!(
310            err.contains("Fix:"),
311            "depth-exceed error must carry a `Fix:` hint, got: {err}"
312        );
313    }
314}
315
316/// OPAQUE-001 regression: encoder and decoder must agree on the
317/// maximum opaque payload length. A payload at MAX_OPAQUE_PAYLOAD_LEN
318/// must encode; a payload one byte larger must fail at encode time.
319#[test]
320pub(crate) fn opaque_payload_limit_is_symmetric() {
321    use crate::ir::{Expr, ExprNode};
322    use std::any::Any;
323
324    #[derive(Debug)]
325    struct BigOpaque(Vec<u8>);
326    impl ExprNode for BigOpaque {
327        fn extension_kind(&self) -> &'static str {
328            "test.big"
329        }
330        fn debug_identity(&self) -> &str {
331            "test.big"
332        }
333        fn result_type(&self) -> Option<DataType> {
334            Some(DataType::U32)
335        }
336        fn cse_safe(&self) -> bool {
337            false
338        }
339        fn stable_fingerprint(&self) -> [u8; 32] {
340            [0; 32]
341        }
342        fn validate_extension(&self) -> Result<(), String> {
343            Ok(())
344        }
345        fn as_any(&self) -> &dyn Any {
346            self
347        }
348        fn wire_payload(&self) -> Vec<u8> {
349            self.0.clone()
350        }
351    }
352
353    // At the limit: must encode successfully.
354    let expr_ok = Expr::opaque(BigOpaque(vec![0u8; MAX_OPAQUE_PAYLOAD_LEN]));
355    let program_ok = Program::wrapped(
356        vec![BufferDecl::read_write("out", 0, DataType::U32)],
357        [1, 1, 1],
358        vec![Node::let_bind("_", expr_ok)],
359    );
360    assert!(
361        program_ok.to_wire().is_ok(),
362        "at-limit opaque payload ({MAX_OPAQUE_PAYLOAD_LEN} bytes) must encode"
363    );
364
365    // One byte over: must fail at encode time.
366    let expr_over = Expr::opaque(BigOpaque(vec![0u8; MAX_OPAQUE_PAYLOAD_LEN + 1]));
367    let program_over = Program::wrapped(
368        vec![BufferDecl::read_write("out", 0, DataType::U32)],
369        [1, 1, 1],
370        vec![Node::let_bind("_", expr_over)],
371    );
372    let err = program_over
373        .to_wire()
374        .expect_err("opaque payload exceeding MAX_OPAQUE_PAYLOAD_LEN must fail at encode");
375    let msg = err.to_string();
376    assert!(
377        msg.contains("MAX_OPAQUE_PAYLOAD_LEN") || msg.contains(&MAX_OPAQUE_PAYLOAD_LEN.to_string()),
378        "error should mention the limit, got: {msg}"
379    );
380}