Skip to main content

ruso_runtime/runtime/
binary.rs

1//! Binary serialization of `BytecodeProgram` (magic `RUSO`, version 1).
2//!
3//! **Versioning:** the header carries a one-byte `VERSION`; the decoder accepts
4//! only that exact value, rejecting anything else up front with `BadVersion`
5//! (never a cryptic mid-decode `Corrupt`). Any change to the wire format must
6//! bump `VERSION` — a coordinated step, since the registry has to deploy the
7//! new runtime and serve re-compiled bytecode. See the Bytecode chapter at
8//! <https://docs.ruso.hopeless-labs.com/internals/bytecode.html>.
9//!
10//! The current v1 layout encodes `CmpValue::Number` as `u64` (earlier revisions
11//! silently truncated to `u32`), assigns HTTP method tags 5 and 6 to `Head` and
12//! `Options`, and bounds every untrusted list/count against the remaining
13//! buffer so a malicious or corrupt `.rbc` file cannot trigger OOM allocations.
14//! After decoding, [`validate_program`] bounds-checks every instruction operand
15//! against its pool so out-of-range indices surface as `Corrupt` rather than
16//! panicking the executor that indexes those pools directly.
17
18use std::collections::HashMap;
19
20use thiserror::Error;
21
22use crate::contract::{
23    BodyValue, CmpOp, CmpValue, EvidenceKind, ExtractSource, FieldKind, HttpMethod, InlinePart,
24    InlinePartBody, MatchPredicate, ObjectBody, QualifiedField, QualifiedMatch, Severity,
25};
26use crate::runtime::bytecode::{BytecodeProgram, Instr};
27use crate::runtime::spec::{CheckMetadata, HttpRequestSpec, ProbeKind, ProgramSpec};
28
29pub const MAGIC: &[u8; 4] = b"RUSO";
30pub const VERSION: u8 = 1;
31
32#[derive(Debug, Error)]
33pub enum BytecodeError {
34    #[error("bytecode too short")]
35    TooShort,
36    #[error("invalid magic")]
37    BadMagic,
38    #[error(
39        "unsupported bytecode version {found} (this build reads version {supported}); \
40         recompile the script or update ruso"
41    )]
42    BadVersion { found: u8, supported: u8 },
43    #[error("corrupt bytecode: {0}")]
44    Corrupt(&'static str),
45    #[error("invalid hex: {0}")]
46    InvalidHex(String),
47}
48
49pub fn encode(program: &BytecodeProgram) -> Vec<u8> {
50    let mut w = Writer::default();
51    w.bytes(MAGIC);
52    w.u8(VERSION);
53    write_metadata(&mut w, &program.spec.metadata);
54    write_probes(&mut w, &program.spec.probes);
55    write_strings(&mut w, &program.strings);
56    write_payloads(&mut w, &program.payloads);
57    write_matchers(&mut w, &program.matchers);
58    write_extracts(&mut w, &program.extracts);
59    write_evidence(&mut w, &program.evidence);
60    write_code(&mut w, &program.code);
61    w.0
62}
63
64pub fn decode(bytes: &[u8]) -> Result<BytecodeProgram, BytecodeError> {
65    let mut r = Reader::new(bytes);
66    r.consume_magic()?;
67    let version = r.u8()?;
68    if version != VERSION {
69        return Err(BytecodeError::BadVersion {
70            found: version,
71            supported: VERSION,
72        });
73    }
74    let metadata = read_metadata(&mut r)?;
75    let probes = read_probes(&mut r)?;
76    let strings = read_strings(&mut r)?;
77    let payloads = read_payloads(&mut r)?;
78    let matchers = read_matchers(&mut r)?;
79    let extracts = read_extracts(&mut r)?;
80    let evidence = read_evidence(&mut r)?;
81    let code = read_code(&mut r)?;
82    if r.remaining() != 0 {
83        return Err(BytecodeError::Corrupt("trailing bytes"));
84    }
85    let program = BytecodeProgram {
86        spec: ProgramSpec { probes, metadata },
87        code,
88        strings,
89        payloads,
90        matchers,
91        extracts,
92        evidence,
93    };
94    validate_program(&program)?;
95    Ok(program)
96}
97
98/// Bounds-check every instruction operand against the pool it indexes.
99///
100/// The decode helpers above guarantee no out-of-buffer reads and no OOM
101/// allocations, but they do **not** check that an instruction's operand
102/// indices (`strings[name]`, `payloads[id]`, `matchers[start..start+len]`,
103/// …) actually fall within the decoded pools — those indices are plain
104/// `u32`s in the code stream. The executor indexes the pools directly, so
105/// an unchecked out-of-range index would panic the worker thread. A
106/// malicious or corrupt `.rbc` (e.g. `ruso exec evil.rbc`) must surface as a
107/// clean `Corrupt` error, not a panic. This pass closes that gap so the
108/// "untrusted bytecode is safe to decode" guarantee holds end to end.
109///
110/// Jump targets (`else_pc`, `end_pc`) are deliberately *not* rejected when
111/// they point past `code`: the executor's main loop halts once `pc >=
112/// code.len()`, so an out-of-range jump simply ends execution rather than
113/// reading out of bounds.
114fn validate_program(p: &BytecodeProgram) -> Result<(), BytecodeError> {
115    let strings = p.strings.len();
116    let payloads = p.payloads.len();
117    let matchers = p.matchers.len();
118    let extracts = p.extracts.len();
119    let evidence = p.evidence.len();
120
121    // `idx < bound` with the index widened to usize so a u32 operand can
122    // never wrap; `range` additionally rejects start+len overflow.
123    let one = |idx: u32, bound: usize| -> Result<(), BytecodeError> {
124        if (idx as usize) < bound {
125            Ok(())
126        } else {
127            Err(BytecodeError::Corrupt("operand index out of range"))
128        }
129    };
130    let range = |start: u32, len: u16, bound: usize| -> Result<(), BytecodeError> {
131        let end = (start as usize)
132            .checked_add(len as usize)
133            .ok_or(BytecodeError::Corrupt("operand range overflow"))?;
134        if end <= bound {
135            Ok(())
136        } else {
137            Err(BytecodeError::Corrupt("operand range out of bounds"))
138        }
139    };
140
141    for instr in &p.code {
142        match instr {
143            Instr::Set { name, value } => {
144                one(*name, strings)?;
145                one(*value, strings)?;
146            }
147            Instr::SetList { name, start, len } => {
148                one(*name, strings)?;
149                range(*start, *len, strings)?;
150            }
151            Instr::Send { probe, payload } => {
152                one(*probe, strings)?;
153                if let Some(id) = payload {
154                    one(*id, payloads)?;
155                }
156            }
157            Instr::Match(m) | Instr::Assert(m) => one(*m, matchers)?,
158            Instr::MatchAll { start, len } | Instr::MatchAny { start, len } => {
159                range(*start, *len, matchers)?;
160            }
161            Instr::Extract { name, source } => {
162                one(*name, strings)?;
163                one(*source, extracts)?;
164            }
165            Instr::IfMatch { matcher, .. } => one(*matcher, matchers)?,
166            Instr::ForList {
167                item, start, len, ..
168            } => {
169                one(*item, strings)?;
170                range(*start, *len, strings)?;
171            }
172            Instr::ForVar { item, list, .. } => {
173                one(*item, strings)?;
174                one(*list, strings)?;
175            }
176            Instr::Save { from, to } => {
177                one(*from, strings)?;
178                one(*to, strings)?;
179            }
180            Instr::Evidence(k) => one(*k, evidence)?,
181            Instr::Retry { probe, .. } => one(*probe, strings)?,
182            Instr::RetryDelay(v) | Instr::Sleep(v) => one(*v, strings)?,
183            // Operand-free / jump-only instructions: nothing to bound here.
184            Instr::LoopBack
185            | Instr::Break
186            | Instr::Stop
187            | Instr::Fail
188            | Instr::Continue
189            | Instr::Exit => {}
190        }
191    }
192    Ok(())
193}
194
195pub fn bytes_to_hex(bytes: &[u8]) -> String {
196    use std::fmt::Write as _;
197    let mut out = String::with_capacity(bytes.len() * 2);
198    for b in bytes {
199        let _ = write!(out, "{b:02x}");
200    }
201    out
202}
203
204pub fn bytes_to_hex_dump(bytes: &[u8]) -> String {
205    use std::fmt::Write as _;
206    let mut out = String::new();
207    for (offset, chunk) in bytes.chunks(16).enumerate() {
208        let off = offset * 16;
209        let _ = write!(out, "{off:08x}: ");
210        for b in chunk {
211            let _ = write!(out, "{b:02x} ");
212        }
213        for _ in chunk.len()..16 {
214            out.push_str("   ");
215        }
216        out.push_str(" |");
217        for &b in chunk {
218            if b.is_ascii_graphic() || b == b' ' {
219                out.push(b as char);
220            } else {
221                out.push('.');
222            }
223        }
224        out.push_str("|\n");
225    }
226    out
227}
228
229pub fn hex_to_bytes(input: &str) -> Result<Vec<u8>, BytecodeError> {
230    let mut compact = String::with_capacity(input.len());
231    for c in input.chars() {
232        if c.is_ascii_whitespace() {
233            continue;
234        }
235        if !c.is_ascii_hexdigit() {
236            return Err(BytecodeError::InvalidHex(format!("non-hex char: {c:?}")));
237        }
238        compact.push(c);
239    }
240    if !compact.len().is_multiple_of(2) {
241        return Err(BytecodeError::InvalidHex("odd length".into()));
242    }
243    let bytes = compact.as_bytes();
244    let mut out = Vec::with_capacity(bytes.len() / 2);
245    for chunk in bytes.chunks_exact(2) {
246        // SAFETY: validated above as ascii hex.
247        let pair = std::str::from_utf8(chunk).expect("ascii hex");
248        let byte = u8::from_str_radix(pair, 16)
249            .map_err(|err| BytecodeError::InvalidHex(err.to_string()))?;
250        out.push(byte);
251    }
252    Ok(out)
253}
254
255/// Decode a hex string into raw bytecode bytes.
256///
257/// Earlier revisions accepted an `@path` prefix that would read the file as
258/// raw bytecode. That alternate entry point conflated "hex-decoded input"
259/// with "file IO" and provided a path-traversal sink for any caller passing
260/// less-trusted input (env vars, CI parameters, scripted wrappers). File IO
261/// is now the CLI's responsibility — runtime callers pass bytes directly via
262/// [`decode`] or hex via this function.
263pub fn load_bytecode_input(input: &str) -> Result<Vec<u8>, BytecodeError> {
264    hex_to_bytes(input.trim())
265}
266
267#[derive(Default)]
268struct Writer(Vec<u8>);
269
270impl Writer {
271    fn u8(&mut self, v: u8) {
272        self.0.push(v);
273    }
274
275    fn u16(&mut self, v: u16) {
276        self.0.extend_from_slice(&v.to_le_bytes());
277    }
278
279    fn u32(&mut self, v: u32) {
280        self.0.extend_from_slice(&v.to_le_bytes());
281    }
282
283    fn u64(&mut self, v: u64) {
284        self.0.extend_from_slice(&v.to_le_bytes());
285    }
286
287    fn bytes(&mut self, data: &[u8]) {
288        self.0.extend_from_slice(data);
289    }
290
291    fn str(&mut self, s: &str) {
292        let b = s.as_bytes();
293        self.u32(b.len() as u32);
294        self.bytes(b);
295    }
296
297    fn opt_str(&mut self, value: &Option<String>) {
298        match value {
299            Some(s) => {
300                self.u8(1);
301                self.str(s);
302            }
303            None => self.u8(0),
304        }
305    }
306
307    fn opt_bytes(&mut self, value: &Option<Vec<u8>>) {
308        match value {
309            Some(data) => {
310                self.u8(1);
311                self.u32(data.len() as u32);
312                self.bytes(data);
313            }
314            None => self.u8(0),
315        }
316    }
317
318    fn opt_u16(&mut self, value: Option<u16>) {
319        match value {
320            Some(v) => {
321                self.u8(1);
322                self.u16(v);
323            }
324            None => self.u8(0),
325        }
326    }
327}
328
329struct Reader<'a> {
330    data: &'a [u8],
331    pos: usize,
332}
333
334impl<'a> Reader<'a> {
335    fn new(data: &'a [u8]) -> Self {
336        Self { data, pos: 0 }
337    }
338
339    fn remaining(&self) -> usize {
340        self.data.len().saturating_sub(self.pos)
341    }
342
343    fn consume_magic(&mut self) -> Result<(), BytecodeError> {
344        let magic = self.need(4)?;
345        if magic != MAGIC {
346            return Err(BytecodeError::BadMagic);
347        }
348        Ok(())
349    }
350
351    fn need(&mut self, n: usize) -> Result<&'a [u8], BytecodeError> {
352        if self.pos + n > self.data.len() {
353            return Err(BytecodeError::Corrupt("unexpected end"));
354        }
355        let slice = &self.data[self.pos..self.pos + n];
356        self.pos += n;
357        Ok(slice)
358    }
359
360    fn u8(&mut self) -> Result<u8, BytecodeError> {
361        Ok(self.need(1)?[0])
362    }
363
364    fn u16(&mut self) -> Result<u16, BytecodeError> {
365        Ok(u16::from_le_bytes(self.need(2)?.try_into().unwrap()))
366    }
367
368    fn u32(&mut self) -> Result<u32, BytecodeError> {
369        Ok(u32::from_le_bytes(self.need(4)?.try_into().unwrap()))
370    }
371
372    fn u64(&mut self) -> Result<u64, BytecodeError> {
373        Ok(u64::from_le_bytes(self.need(8)?.try_into().unwrap()))
374    }
375
376    fn str(&mut self) -> Result<String, BytecodeError> {
377        let len = self.u32()? as usize;
378        // Reject lengths that overrun the buffer before allocating.
379        if len > self.remaining() {
380            return Err(BytecodeError::Corrupt("string length exceeds buffer"));
381        }
382        let bytes = self.need(len)?;
383        String::from_utf8(bytes.to_vec()).map_err(|_| BytecodeError::Corrupt("utf8"))
384    }
385
386    fn opt_str(&mut self) -> Result<Option<String>, BytecodeError> {
387        if self.u8()? == 0 {
388            Ok(None)
389        } else {
390            Ok(Some(self.str()?))
391        }
392    }
393
394    fn opt_bytes(&mut self) -> Result<Option<Vec<u8>>, BytecodeError> {
395        if self.u8()? == 0 {
396            Ok(None)
397        } else {
398            let len = self.u32()? as usize;
399            if len > self.remaining() {
400                return Err(BytecodeError::Corrupt("bytes length exceeds buffer"));
401            }
402            Ok(Some(self.need(len)?.to_vec()))
403        }
404    }
405
406    fn opt_u16(&mut self) -> Result<Option<u16>, BytecodeError> {
407        if self.u8()? == 0 {
408            Ok(None)
409        } else {
410            Ok(Some(self.u16()?))
411        }
412    }
413
414    /// Convert an untrusted `u32` count into a `usize`, bounded against the
415    /// remaining buffer.
416    ///
417    /// Every list/pool length in the bytecode is followed by at least one
418    /// byte per element (an opcode tag, a u8 discriminant, or a 4-byte
419    /// length prefix). The strict lower bound is `1` byte per item, so any
420    /// `count > remaining()` is unambiguously corrupt — and this check runs
421    /// **before** `Vec::with_capacity(count)`, so an attacker-controlled
422    /// `count = u32::MAX` cannot trigger a multi-GB allocation.
423    fn bounded_count(&self, raw: u32) -> Result<usize, BytecodeError> {
424        let count = raw as usize;
425        if count > self.remaining() {
426            return Err(BytecodeError::Corrupt(
427                "list length exceeds remaining bytes",
428            ));
429        }
430        Ok(count)
431    }
432}
433
434fn write_metadata(w: &mut Writer, metadata: &CheckMetadata) {
435    w.opt_str(&metadata.name);
436    w.opt_str(&metadata.description);
437    w.opt_str(&metadata.impact);
438    match &metadata.severity {
439        Some(s) => {
440            w.u8(1);
441            w.u8(severity_tag(s));
442        }
443        None => w.u8(0),
444    }
445    w.opt_str(&metadata.author);
446    write_strings(w, &metadata.cve);
447    write_strings(w, &metadata.cwe);
448    write_strings(w, &metadata.references);
449    write_strings(w, &metadata.cvss);
450    write_strings(w, &metadata.cvss_score);
451    w.opt_str(&metadata.mitigation);
452    write_strings(w, &metadata.tags);
453    w.opt_str(&metadata.version);
454    // `family` is the last field of the metadata block. Any change to this
455    // layout must bump VERSION (the registry has to redeploy + recompile).
456    w.opt_str(&metadata.family);
457}
458
459fn read_metadata(r: &mut Reader<'_>) -> Result<CheckMetadata, BytecodeError> {
460    Ok(CheckMetadata {
461        name: r.opt_str()?,
462        description: r.opt_str()?,
463        impact: r.opt_str()?,
464        severity: if r.u8()? == 0 {
465            None
466        } else {
467            Some(read_severity(r)?)
468        },
469        author: r.opt_str()?,
470        cve: read_strings(r)?,
471        cwe: read_strings(r)?,
472        references: read_strings(r)?,
473        cvss: read_strings(r)?,
474        cvss_score: read_strings(r)?,
475        mitigation: r.opt_str()?,
476        tags: read_strings(r)?,
477        version: r.opt_str()?,
478        family: r.opt_str()?,
479    })
480}
481
482fn severity_tag(s: &Severity) -> u8 {
483    match s {
484        Severity::Low => 0,
485        Severity::Medium => 1,
486        Severity::High => 2,
487        Severity::Critical => 3,
488        Severity::Info => 4,
489    }
490}
491
492fn read_severity(r: &mut Reader<'_>) -> Result<Severity, BytecodeError> {
493    Ok(match r.u8()? {
494        0 => Severity::Low,
495        1 => Severity::Medium,
496        2 => Severity::High,
497        3 => Severity::Critical,
498        4 => Severity::Info,
499        _ => return Err(BytecodeError::Corrupt("severity")),
500    })
501}
502
503fn write_probes(w: &mut Writer, probes: &HashMap<String, ProbeKind>) {
504    let mut names: Vec<_> = probes.keys().cloned().collect();
505    names.sort();
506    w.u32(names.len() as u32);
507    for name in names {
508        w.str(&name);
509        write_probe_kind(w, probes.get(&name).expect("sorted key"));
510    }
511}
512
513fn read_probes(r: &mut Reader<'_>) -> Result<HashMap<String, ProbeKind>, BytecodeError> {
514    let raw = r.u32()?;
515    let count = r.bounded_count(raw)?;
516    let mut probes = HashMap::with_capacity(count);
517    for _ in 0..count {
518        let name = r.str()?;
519        let kind = read_probe_kind(r)?;
520        probes.insert(name, kind);
521    }
522    Ok(probes)
523}
524
525fn write_probe_kind(w: &mut Writer, kind: &ProbeKind) {
526    match kind {
527        ProbeKind::Http(spec) => {
528            w.u8(0);
529            write_http_spec(w, spec);
530        }
531        ProbeKind::Dns(spec) => {
532            w.u8(1);
533            write_socket_probe(w, spec);
534        }
535        ProbeKind::Tcp(spec) => {
536            w.u8(2);
537            write_socket_probe(w, spec);
538        }
539        ProbeKind::Udp(spec) => {
540            w.u8(3);
541            write_socket_probe(w, spec);
542        }
543    }
544}
545
546fn write_socket_probe(w: &mut Writer, spec: &crate::runtime::spec::SocketProbeSpec) {
547    w.str(&spec.host);
548    w.opt_u16(spec.port);
549    w.opt_bytes(&spec.payload);
550    w.u8(u8::from(spec.tls));
551    w.u8(u8::from(spec.session));
552    w.u32(spec.read_max);
553    w.u32(spec.read_idle_ms);
554}
555
556fn read_socket_probe(
557    r: &mut Reader<'_>,
558) -> Result<crate::runtime::spec::SocketProbeSpec, BytecodeError> {
559    Ok(crate::runtime::spec::SocketProbeSpec {
560        host: r.str()?,
561        port: r.opt_u16()?,
562        payload: r.opt_bytes()?,
563        tls: r.u8()? != 0,
564        session: r.u8()? != 0,
565        read_max: r.u32()?,
566        read_idle_ms: r.u32()?,
567    })
568}
569
570fn read_probe_kind(r: &mut Reader<'_>) -> Result<ProbeKind, BytecodeError> {
571    Ok(match r.u8()? {
572        0 => ProbeKind::Http(read_http_spec(r)?),
573        1 => ProbeKind::Dns(read_socket_probe(r)?),
574        2 => ProbeKind::Tcp(read_socket_probe(r)?),
575        3 => ProbeKind::Udp(read_socket_probe(r)?),
576        _ => return Err(BytecodeError::Corrupt("probe kind")),
577    })
578}
579
580fn write_http_spec(w: &mut Writer, spec: &HttpRequestSpec) {
581    w.u8(http_method_tag(&spec.method));
582    w.str(&spec.path);
583    w.opt_str(&spec.timeout);
584    write_opt_bool(w, &spec.follow_redirect);
585    write_opt_bool(w, &spec.verify_ssl);
586    w.opt_str(&spec.proxy);
587    w.opt_str(&spec.user_agent);
588    write_header_list(w, &spec.headers);
589    write_header_list(w, &spec.cookies);
590    write_header_list(w, &spec.queries);
591    write_opt_object(w, &spec.data_body);
592    write_opt_object(w, &spec.json_body);
593    w.opt_str(&spec.raw_body);
594    w.opt_str(&spec.body_bytes);
595    write_opt_object(w, &spec.multipart_body);
596}
597
598fn read_http_spec(r: &mut Reader<'_>) -> Result<HttpRequestSpec, BytecodeError> {
599    Ok(HttpRequestSpec {
600        method: read_http_method(r)?,
601        path: r.str()?,
602        timeout: r.opt_str()?,
603        follow_redirect: read_opt_bool(r)?,
604        verify_ssl: read_opt_bool(r)?,
605        proxy: r.opt_str()?,
606        user_agent: r.opt_str()?,
607        headers: read_header_list(r)?,
608        cookies: read_header_list(r)?,
609        queries: read_header_list(r)?,
610        data_body: read_opt_object(r)?,
611        json_body: read_opt_object(r)?,
612        raw_body: r.opt_str()?,
613        body_bytes: r.opt_str()?,
614        multipart_body: read_opt_object(r)?,
615    })
616}
617
618fn http_method_tag(m: &HttpMethod) -> u8 {
619    match m {
620        HttpMethod::Get => 0,
621        HttpMethod::Post => 1,
622        HttpMethod::Put => 2,
623        HttpMethod::Patch => 3,
624        HttpMethod::Delete => 4,
625        HttpMethod::Head => 5,
626        HttpMethod::Options => 6,
627    }
628}
629
630fn read_http_method(r: &mut Reader<'_>) -> Result<HttpMethod, BytecodeError> {
631    Ok(match r.u8()? {
632        0 => HttpMethod::Get,
633        1 => HttpMethod::Post,
634        2 => HttpMethod::Put,
635        3 => HttpMethod::Patch,
636        4 => HttpMethod::Delete,
637        5 => HttpMethod::Head,
638        6 => HttpMethod::Options,
639        _ => return Err(BytecodeError::Corrupt("http method")),
640    })
641}
642
643fn write_opt_bool(w: &mut Writer, value: &Option<bool>) {
644    match value {
645        Some(v) => {
646            w.u8(1);
647            w.u8(u8::from(*v));
648        }
649        None => w.u8(0),
650    }
651}
652
653fn read_opt_bool(r: &mut Reader<'_>) -> Result<Option<bool>, BytecodeError> {
654    if r.u8()? == 0 {
655        Ok(None)
656    } else {
657        Ok(Some(r.u8()? != 0))
658    }
659}
660
661fn write_header_list(w: &mut Writer, pairs: &[(String, String)]) {
662    w.u32(pairs.len() as u32);
663    for (k, v) in pairs {
664        w.str(k);
665        w.str(v);
666    }
667}
668
669fn read_header_list(r: &mut Reader<'_>) -> Result<Vec<(String, String)>, BytecodeError> {
670    let raw = r.u32()?;
671    let count = r.bounded_count(raw)?;
672    let mut pairs = Vec::with_capacity(count);
673    for _ in 0..count {
674        pairs.push((r.str()?, r.str()?));
675    }
676    Ok(pairs)
677}
678
679fn write_opt_object(w: &mut Writer, body: &Option<ObjectBody>) {
680    match body {
681        Some(obj) => {
682            w.u8(1);
683            write_object(w, obj);
684        }
685        None => w.u8(0),
686    }
687}
688
689fn read_opt_object(r: &mut Reader<'_>) -> Result<Option<ObjectBody>, BytecodeError> {
690    if r.u8()? == 0 {
691        Ok(None)
692    } else {
693        Ok(Some(read_object(r)?))
694    }
695}
696
697fn write_object(w: &mut Writer, obj: &ObjectBody) {
698    w.u32(obj.pairs.len() as u32);
699    for (key, value) in &obj.pairs {
700        w.str(key);
701        write_body_value(w, value);
702    }
703}
704
705fn read_object(r: &mut Reader<'_>) -> Result<ObjectBody, BytecodeError> {
706    let raw = r.u32()?;
707    let count = r.bounded_count(raw)?;
708    let mut pairs = Vec::with_capacity(count);
709    for _ in 0..count {
710        pairs.push((r.str()?, read_body_value(r)?));
711    }
712    Ok(ObjectBody { pairs })
713}
714
715fn write_body_value(w: &mut Writer, value: &BodyValue) {
716    match value {
717        BodyValue::String(s) => {
718            w.u8(0);
719            w.str(s);
720        }
721        BodyValue::Interpolation(s) => {
722            w.u8(1);
723            w.str(s);
724        }
725        BodyValue::Object(obj) => {
726            w.u8(2);
727            write_object(w, obj);
728        }
729        BodyValue::Bytes(hex) => {
730            w.u8(3);
731            w.str(hex);
732        }
733        BodyValue::Part(part) => {
734            w.u8(4);
735            w.opt_str(&part.filename);
736            match &part.body {
737                InlinePartBody::Text(t) => {
738                    w.u8(0);
739                    w.str(t);
740                }
741                InlinePartBody::Bytes(b) => {
742                    w.u8(1);
743                    w.str(b);
744                }
745            }
746        }
747    }
748}
749
750fn read_body_value(r: &mut Reader<'_>) -> Result<BodyValue, BytecodeError> {
751    Ok(match r.u8()? {
752        0 => BodyValue::String(r.str()?),
753        1 => BodyValue::Interpolation(r.str()?),
754        2 => BodyValue::Object(read_object(r)?),
755        3 => BodyValue::Bytes(r.str()?),
756        4 => BodyValue::Part(InlinePart {
757            filename: r.opt_str()?,
758            body: match r.u8()? {
759                1 => InlinePartBody::Bytes(r.str()?),
760                _ => InlinePartBody::Text(r.str()?),
761            },
762        }),
763        _ => return Err(BytecodeError::Corrupt("body value")),
764    })
765}
766
767fn write_strings(w: &mut Writer, strings: &[String]) {
768    w.u32(strings.len() as u32);
769    for s in strings {
770        w.str(s);
771    }
772}
773
774fn read_strings(r: &mut Reader<'_>) -> Result<Vec<String>, BytecodeError> {
775    let raw = r.u32()?;
776    let count = r.bounded_count(raw)?;
777    let mut strings = Vec::with_capacity(count);
778    for _ in 0..count {
779        strings.push(r.str()?);
780    }
781    Ok(strings)
782}
783
784fn write_payloads(w: &mut Writer, payloads: &[Vec<u8>]) {
785    w.u32(payloads.len() as u32);
786    for data in payloads {
787        w.u32(data.len() as u32);
788        w.bytes(data);
789    }
790}
791
792fn read_payloads(r: &mut Reader<'_>) -> Result<Vec<Vec<u8>>, BytecodeError> {
793    let raw = r.u32()?;
794    let count = r.bounded_count(raw)?;
795    let mut payloads = Vec::with_capacity(count);
796    for _ in 0..count {
797        let len = r.u32()? as usize;
798        if len > r.remaining() {
799            return Err(BytecodeError::Corrupt("payload length exceeds buffer"));
800        }
801        payloads.push(r.need(len)?.to_vec());
802    }
803    Ok(payloads)
804}
805
806fn write_matchers(w: &mut Writer, matchers: &[QualifiedMatch]) {
807    w.u32(matchers.len() as u32);
808    for m in matchers {
809        write_matcher(w, m);
810    }
811}
812
813fn read_matchers(r: &mut Reader<'_>) -> Result<Vec<QualifiedMatch>, BytecodeError> {
814    let raw = r.u32()?;
815    let count = r.bounded_count(raw)?;
816    let mut matchers = Vec::with_capacity(count);
817    for _ in 0..count {
818        matchers.push(read_matcher(r)?);
819    }
820    Ok(matchers)
821}
822
823fn write_matcher(w: &mut Writer, m: &QualifiedMatch) {
824    w.str(&m.field.target);
825    write_field_kind(w, &m.field.kind);
826    write_predicate(w, &m.predicate);
827}
828
829fn read_matcher(r: &mut Reader<'_>) -> Result<QualifiedMatch, BytecodeError> {
830    Ok(QualifiedMatch {
831        field: QualifiedField {
832            target: r.str()?,
833            kind: read_field_kind(r)?,
834        },
835        predicate: read_predicate(r)?,
836    })
837}
838
839fn write_field_kind(w: &mut Writer, kind: &FieldKind) {
840    match kind {
841        FieldKind::Status => w.u8(0),
842        FieldKind::Body => w.u8(1),
843        FieldKind::Header(name) => {
844            w.u8(2);
845            w.str(name);
846        }
847        FieldKind::ResponseTime => w.u8(3),
848        FieldKind::ResponseSize => w.u8(4),
849        FieldKind::Answer => w.u8(5),
850        FieldKind::Banner => w.u8(6),
851        FieldKind::Response => w.u8(7),
852    }
853}
854
855fn read_field_kind(r: &mut Reader<'_>) -> Result<FieldKind, BytecodeError> {
856    Ok(match r.u8()? {
857        0 => FieldKind::Status,
858        1 => FieldKind::Body,
859        2 => FieldKind::Header(r.str()?),
860        3 => FieldKind::ResponseTime,
861        4 => FieldKind::ResponseSize,
862        5 => FieldKind::Answer,
863        6 => FieldKind::Banner,
864        7 => FieldKind::Response,
865        _ => return Err(BytecodeError::Corrupt("field kind")),
866    })
867}
868
869fn write_predicate(w: &mut Writer, p: &MatchPredicate) {
870    match p {
871        MatchPredicate::Compare { op, value } => {
872            w.u8(0);
873            w.u8(cmp_op_tag(*op));
874            write_cmp_value(w, value);
875        }
876        MatchPredicate::Contains(s) => {
877            w.u8(1);
878            w.str(s);
879        }
880        MatchPredicate::NotContains(s) => {
881            w.u8(2);
882            w.str(s);
883        }
884        MatchPredicate::Regex(s) => {
885            w.u8(3);
886            w.str(s);
887        }
888    }
889}
890
891fn read_predicate(r: &mut Reader<'_>) -> Result<MatchPredicate, BytecodeError> {
892    Ok(match r.u8()? {
893        0 => MatchPredicate::Compare {
894            op: read_cmp_op(r)?,
895            value: read_cmp_value(r)?,
896        },
897        1 => MatchPredicate::Contains(r.str()?),
898        2 => MatchPredicate::NotContains(r.str()?),
899        3 => MatchPredicate::Regex(r.str()?),
900        _ => return Err(BytecodeError::Corrupt("predicate")),
901    })
902}
903
904fn cmp_op_tag(op: CmpOp) -> u8 {
905    match op {
906        CmpOp::Eq => 0,
907        CmpOp::Ne => 1,
908        CmpOp::Lt => 2,
909        CmpOp::Gt => 3,
910        CmpOp::Le => 4,
911        CmpOp::Ge => 5,
912    }
913}
914
915fn read_cmp_op(r: &mut Reader<'_>) -> Result<CmpOp, BytecodeError> {
916    Ok(match r.u8()? {
917        0 => CmpOp::Eq,
918        1 => CmpOp::Ne,
919        2 => CmpOp::Lt,
920        3 => CmpOp::Gt,
921        4 => CmpOp::Le,
922        5 => CmpOp::Ge,
923        _ => return Err(BytecodeError::Corrupt("cmp op")),
924    })
925}
926
927fn write_cmp_value(w: &mut Writer, value: &CmpValue) {
928    match value {
929        CmpValue::Number(n) => {
930            w.u8(0);
931            // Full u64 — earlier revisions truncated to u32, silently
932            // mangling comparisons against values above ~4.3 billion (e.g.
933            // `response_size > 5_000_000_000`).
934            w.u64(*n);
935        }
936        CmpValue::String(s) => {
937            w.u8(1);
938            w.str(s);
939        }
940        CmpValue::Duration(d) => {
941            w.u8(2);
942            w.str(d);
943        }
944    }
945}
946
947fn read_cmp_value(r: &mut Reader<'_>) -> Result<CmpValue, BytecodeError> {
948    Ok(match r.u8()? {
949        0 => CmpValue::Number(r.u64()?),
950        1 => CmpValue::String(r.str()?),
951        2 => CmpValue::Duration(r.str()?),
952        _ => return Err(BytecodeError::Corrupt("cmp value")),
953    })
954}
955
956fn write_extracts(w: &mut Writer, extracts: &[ExtractSource]) {
957    w.u32(extracts.len() as u32);
958    for e in extracts {
959        write_extract(w, e);
960    }
961}
962
963fn read_extracts(r: &mut Reader<'_>) -> Result<Vec<ExtractSource>, BytecodeError> {
964    let raw = r.u32()?;
965    let count = r.bounded_count(raw)?;
966    let mut extracts = Vec::with_capacity(count);
967    for _ in 0..count {
968        extracts.push(read_extract(r)?);
969    }
970    Ok(extracts)
971}
972
973fn write_extract(w: &mut Writer, e: &ExtractSource) {
974    match e {
975        ExtractSource::Body { target, regex } => {
976            w.u8(0);
977            w.str(target);
978            w.opt_str(regex);
979        }
980        ExtractSource::Header { target, name } => {
981            w.u8(1);
982            w.str(target);
983            w.str(name);
984        }
985    }
986}
987
988fn read_extract(r: &mut Reader<'_>) -> Result<ExtractSource, BytecodeError> {
989    Ok(match r.u8()? {
990        0 => ExtractSource::Body {
991            target: r.str()?,
992            regex: r.opt_str()?,
993        },
994        1 => ExtractSource::Header {
995            target: r.str()?,
996            name: r.str()?,
997        },
998        _ => return Err(BytecodeError::Corrupt("extract")),
999    })
1000}
1001
1002fn write_evidence(w: &mut Writer, kinds: &[EvidenceKind]) {
1003    w.u32(kinds.len() as u32);
1004    for k in kinds {
1005        write_evidence_kind(w, k);
1006    }
1007}
1008
1009fn read_evidence(r: &mut Reader<'_>) -> Result<Vec<EvidenceKind>, BytecodeError> {
1010    let raw = r.u32()?;
1011    let count = r.bounded_count(raw)?;
1012    let mut kinds = Vec::with_capacity(count);
1013    for _ in 0..count {
1014        kinds.push(read_evidence_kind(r)?);
1015    }
1016    Ok(kinds)
1017}
1018
1019fn write_evidence_kind(w: &mut Writer, k: &EvidenceKind) {
1020    match k {
1021        EvidenceKind::Body { target, pattern } => {
1022            w.u8(0);
1023            w.str(target);
1024            w.opt_str(pattern);
1025        }
1026        EvidenceKind::Response { target, pattern } => {
1027            w.u8(1);
1028            w.str(target);
1029            w.opt_str(pattern);
1030        }
1031        EvidenceKind::Header {
1032            target,
1033            name,
1034            pattern,
1035        } => {
1036            w.u8(2);
1037            w.str(target);
1038            w.str(name);
1039            w.opt_str(pattern);
1040        }
1041    }
1042}
1043
1044fn read_evidence_kind(r: &mut Reader<'_>) -> Result<EvidenceKind, BytecodeError> {
1045    Ok(match r.u8()? {
1046        0 => EvidenceKind::Body {
1047            target: r.str()?,
1048            pattern: r.opt_str()?,
1049        },
1050        1 => EvidenceKind::Response {
1051            target: r.str()?,
1052            pattern: r.opt_str()?,
1053        },
1054        2 => EvidenceKind::Header {
1055            target: r.str()?,
1056            name: r.str()?,
1057            pattern: r.opt_str()?,
1058        },
1059        _ => return Err(BytecodeError::Corrupt("evidence")),
1060    })
1061}
1062
1063const OP_SET: u8 = 1;
1064const OP_SEND: u8 = 2;
1065const OP_MATCH: u8 = 3;
1066const OP_MATCH_ALL: u8 = 4;
1067const OP_MATCH_ANY: u8 = 5;
1068const OP_ASSERT: u8 = 6;
1069const OP_EXTRACT: u8 = 7;
1070const OP_IF_MATCH: u8 = 8;
1071const OP_SAVE: u8 = 9;
1072const OP_EVIDENCE: u8 = 10;
1073const OP_RETRY: u8 = 11;
1074const OP_RETRY_DELAY: u8 = 12;
1075const OP_SLEEP: u8 = 13;
1076const OP_STOP: u8 = 14;
1077const OP_FAIL: u8 = 15;
1078const OP_CONTINUE: u8 = 16;
1079const OP_EXIT: u8 = 17;
1080// 18 reserved: was `Repeat`, removed. Decoding it now yields an unknown-opcode
1081// error (no published bytecode uses it).
1082const OP_LOOP_BACK: u8 = 19;
1083const OP_BREAK: u8 = 20;
1084const OP_SET_LIST: u8 = 21;
1085const OP_FOR_LIST: u8 = 22;
1086const OP_FOR_VAR: u8 = 23;
1087
1088fn write_code(w: &mut Writer, code: &[Instr]) {
1089    w.u32(code.len() as u32);
1090    for instr in code {
1091        write_instr(w, instr);
1092    }
1093}
1094
1095fn read_code(r: &mut Reader<'_>) -> Result<Vec<Instr>, BytecodeError> {
1096    let raw = r.u32()?;
1097    let count = r.bounded_count(raw)?;
1098    let mut code = Vec::with_capacity(count);
1099    for _ in 0..count {
1100        code.push(read_instr(r)?);
1101    }
1102    Ok(code)
1103}
1104
1105fn write_instr(w: &mut Writer, instr: &Instr) {
1106    match instr {
1107        Instr::Set { name, value } => {
1108            w.u8(OP_SET);
1109            w.u32(*name);
1110            w.u32(*value);
1111        }
1112        Instr::SetList { name, start, len } => {
1113            w.u8(OP_SET_LIST);
1114            w.u32(*name);
1115            w.u32(*start);
1116            w.u16(*len);
1117        }
1118        Instr::Send { probe, payload } => {
1119            w.u8(OP_SEND);
1120            w.u32(*probe);
1121            match payload {
1122                Some(id) => {
1123                    w.u8(1);
1124                    w.u32(*id);
1125                }
1126                None => w.u8(0),
1127            }
1128        }
1129        Instr::Match(matcher) => {
1130            w.u8(OP_MATCH);
1131            w.u32(*matcher);
1132        }
1133        Instr::MatchAll { start, len } => {
1134            w.u8(OP_MATCH_ALL);
1135            w.u32(*start);
1136            w.u16(*len);
1137        }
1138        Instr::MatchAny { start, len } => {
1139            w.u8(OP_MATCH_ANY);
1140            w.u32(*start);
1141            w.u16(*len);
1142        }
1143        Instr::Assert(matcher) => {
1144            w.u8(OP_ASSERT);
1145            w.u32(*matcher);
1146        }
1147        Instr::Extract { name, source } => {
1148            w.u8(OP_EXTRACT);
1149            w.u32(*name);
1150            w.u32(*source);
1151        }
1152        Instr::IfMatch { matcher, else_pc } => {
1153            w.u8(OP_IF_MATCH);
1154            w.u32(*matcher);
1155            w.u32(*else_pc);
1156        }
1157        Instr::ForList {
1158            item,
1159            start,
1160            len,
1161            end_pc,
1162        } => {
1163            w.u8(OP_FOR_LIST);
1164            w.u32(*item);
1165            w.u32(*start);
1166            w.u16(*len);
1167            w.u32(*end_pc);
1168        }
1169        Instr::ForVar { item, list, end_pc } => {
1170            w.u8(OP_FOR_VAR);
1171            w.u32(*item);
1172            w.u32(*list);
1173            w.u32(*end_pc);
1174        }
1175        Instr::LoopBack => w.u8(OP_LOOP_BACK),
1176        Instr::Break => w.u8(OP_BREAK),
1177        Instr::Save { from, to } => {
1178            w.u8(OP_SAVE);
1179            w.u32(*from);
1180            w.u32(*to);
1181        }
1182        Instr::Evidence(kind) => {
1183            w.u8(OP_EVIDENCE);
1184            w.u32(*kind);
1185        }
1186        Instr::Retry { probe, count } => {
1187            w.u8(OP_RETRY);
1188            w.u32(*probe);
1189            w.u32(*count);
1190        }
1191        Instr::RetryDelay(value) => {
1192            w.u8(OP_RETRY_DELAY);
1193            w.u32(*value);
1194        }
1195        Instr::Sleep(value) => {
1196            w.u8(OP_SLEEP);
1197            w.u32(*value);
1198        }
1199        Instr::Stop => w.u8(OP_STOP),
1200        Instr::Fail => w.u8(OP_FAIL),
1201        Instr::Continue => w.u8(OP_CONTINUE),
1202        Instr::Exit => w.u8(OP_EXIT),
1203    }
1204}
1205
1206fn read_instr(r: &mut Reader<'_>) -> Result<Instr, BytecodeError> {
1207    Ok(match r.u8()? {
1208        OP_SET => Instr::Set {
1209            name: r.u32()?,
1210            value: r.u32()?,
1211        },
1212        OP_SET_LIST => Instr::SetList {
1213            name: r.u32()?,
1214            start: r.u32()?,
1215            len: r.u16()?,
1216        },
1217        OP_SEND => {
1218            let probe = r.u32()?;
1219            let payload = if r.u8()? == 0 { None } else { Some(r.u32()?) };
1220            Instr::Send { probe, payload }
1221        }
1222        OP_MATCH => Instr::Match(r.u32()?),
1223        OP_MATCH_ALL => Instr::MatchAll {
1224            start: r.u32()?,
1225            len: r.u16()?,
1226        },
1227        OP_MATCH_ANY => Instr::MatchAny {
1228            start: r.u32()?,
1229            len: r.u16()?,
1230        },
1231        OP_ASSERT => Instr::Assert(r.u32()?),
1232        OP_EXTRACT => Instr::Extract {
1233            name: r.u32()?,
1234            source: r.u32()?,
1235        },
1236        OP_IF_MATCH => Instr::IfMatch {
1237            matcher: r.u32()?,
1238            else_pc: r.u32()?,
1239        },
1240        OP_FOR_LIST => Instr::ForList {
1241            item: r.u32()?,
1242            start: r.u32()?,
1243            len: r.u16()?,
1244            end_pc: r.u32()?,
1245        },
1246        OP_FOR_VAR => Instr::ForVar {
1247            item: r.u32()?,
1248            list: r.u32()?,
1249            end_pc: r.u32()?,
1250        },
1251        OP_LOOP_BACK => Instr::LoopBack,
1252        OP_BREAK => Instr::Break,
1253        OP_SAVE => Instr::Save {
1254            from: r.u32()?,
1255            to: r.u32()?,
1256        },
1257        OP_EVIDENCE => Instr::Evidence(r.u32()?),
1258        OP_RETRY => Instr::Retry {
1259            probe: r.u32()?,
1260            count: r.u32()?,
1261        },
1262        OP_RETRY_DELAY => Instr::RetryDelay(r.u32()?),
1263        OP_SLEEP => Instr::Sleep(r.u32()?),
1264        OP_STOP => Instr::Stop,
1265        OP_FAIL => Instr::Fail,
1266        OP_CONTINUE => Instr::Continue,
1267        OP_EXIT => Instr::Exit,
1268        _ => return Err(BytecodeError::Corrupt("opcode")),
1269    })
1270}
1271
1272#[cfg(test)]
1273mod tests {
1274    use super::*;
1275
1276    #[test]
1277    fn hex_roundtrip() {
1278        let bytes = vec![0x52, 0x55, 0x53, 0x4f, 0x01, 0xff];
1279        let hex = bytes_to_hex(&bytes);
1280        assert_eq!(hex_to_bytes(&hex).unwrap(), bytes);
1281    }
1282
1283    #[test]
1284    fn hex_rejects_non_hex_chars() {
1285        match hex_to_bytes("zz") {
1286            Err(BytecodeError::InvalidHex(_)) => {}
1287            other => panic!("expected InvalidHex, got {other:?}"),
1288        }
1289    }
1290
1291    #[test]
1292    fn read_severity_rejects_unknown_byte() {
1293        let mut r = Reader::new(&[0x99]);
1294        match read_severity(&mut r) {
1295            Err(BytecodeError::Corrupt("severity")) => {}
1296            other => panic!("expected Corrupt(severity), got {other:?}"),
1297        }
1298    }
1299
1300    #[test]
1301    fn read_severity_accepts_known_bytes() {
1302        for (byte, expected) in [
1303            (0u8, Severity::Low),
1304            (1, Severity::Medium),
1305            (2, Severity::High),
1306            (3, Severity::Critical),
1307            (4, Severity::Info),
1308        ] {
1309            let data = [byte];
1310            let mut r = Reader::new(&data);
1311            assert_eq!(read_severity(&mut r).unwrap(), expected);
1312        }
1313    }
1314
1315    #[test]
1316    fn read_http_method_rejects_unknown_byte() {
1317        let mut r = Reader::new(&[0xff]);
1318        assert!(matches!(
1319            read_http_method(&mut r),
1320            Err(BytecodeError::Corrupt("http method"))
1321        ));
1322    }
1323
1324    #[test]
1325    fn read_http_method_accepts_head_and_options() {
1326        for (byte, expected) in [(5u8, HttpMethod::Head), (6, HttpMethod::Options)] {
1327            let data = [byte];
1328            let mut r = Reader::new(&data);
1329            assert_eq!(read_http_method(&mut r).unwrap(), expected);
1330        }
1331    }
1332
1333    #[test]
1334    fn read_cmp_op_rejects_unknown_byte() {
1335        let mut r = Reader::new(&[0xfe]);
1336        assert!(matches!(
1337            read_cmp_op(&mut r),
1338            Err(BytecodeError::Corrupt("cmp op"))
1339        ));
1340    }
1341
1342    #[test]
1343    fn read_cmp_value_rejects_unknown_byte() {
1344        let mut r = Reader::new(&[0x77]);
1345        assert!(matches!(
1346            read_cmp_value(&mut r),
1347            Err(BytecodeError::Corrupt("cmp value"))
1348        ));
1349    }
1350
1351    #[test]
1352    fn cmp_number_roundtrips_full_u64() {
1353        // Regression for the u32 → u64 widening: writing wrapped to u32 and
1354        // silently truncated large numbers. The wire format preserves the
1355        // full u64.
1356        let mut w = Writer::default();
1357        let value = CmpValue::Number(u64::MAX - 5);
1358        write_cmp_value(&mut w, &value);
1359        let mut r = Reader::new(&w.0);
1360        assert_eq!(read_cmp_value(&mut r).unwrap(), value);
1361    }
1362
1363    #[test]
1364    fn bounded_count_rejects_oversized_count() {
1365        // Attacker writes count = u32::MAX with only a few bytes following.
1366        // Without bounding this triggers a multi-GB `Vec::with_capacity`.
1367        let mut payload = Vec::new();
1368        payload.extend_from_slice(&u32::MAX.to_le_bytes());
1369        let mut r = Reader::new(&payload);
1370        let raw = r.u32().unwrap();
1371        assert!(matches!(
1372            r.bounded_count(raw),
1373            Err(BytecodeError::Corrupt(
1374                "list length exceeds remaining bytes"
1375            ))
1376        ));
1377    }
1378
1379    #[test]
1380    fn read_strings_rejects_oversized_count() {
1381        // Bytecode: "RUSO" + version + huge string count + nothing else.
1382        let mut bad = Vec::new();
1383        bad.extend_from_slice(MAGIC);
1384        bad.push(VERSION);
1385        // skip past metadata/probes by hand-crafting minimal valid prefix
1386        // — instead exercise read_strings directly.
1387        let mut buf = Vec::new();
1388        buf.extend_from_slice(&u32::MAX.to_le_bytes());
1389        let mut r = Reader::new(&buf);
1390        assert!(matches!(
1391            read_strings(&mut r),
1392            Err(BytecodeError::Corrupt(_))
1393        ));
1394    }
1395
1396    #[test]
1397    fn read_payloads_rejects_oversized_count() {
1398        let mut buf = Vec::new();
1399        buf.extend_from_slice(&u32::MAX.to_le_bytes());
1400        let mut r = Reader::new(&buf);
1401        assert!(matches!(
1402            read_payloads(&mut r),
1403            Err(BytecodeError::Corrupt(_))
1404        ));
1405    }
1406
1407    #[test]
1408    fn read_payloads_rejects_oversized_payload_length() {
1409        // count = 1 (valid), but the single payload claims a huge length.
1410        let mut buf = Vec::new();
1411        buf.extend_from_slice(&1u32.to_le_bytes()); // count
1412        buf.extend_from_slice(&u32::MAX.to_le_bytes()); // payload length
1413        let mut r = Reader::new(&buf);
1414        assert!(matches!(
1415            read_payloads(&mut r),
1416            Err(BytecodeError::Corrupt(_))
1417        ));
1418    }
1419
1420    #[test]
1421    fn read_str_rejects_oversized_length() {
1422        let mut buf = Vec::new();
1423        buf.extend_from_slice(&u32::MAX.to_le_bytes());
1424        let mut r = Reader::new(&buf);
1425        assert!(matches!(r.str(), Err(BytecodeError::Corrupt(_))));
1426    }
1427
1428    #[test]
1429    fn decode_rejects_bad_version() {
1430        let mut buf = Vec::new();
1431        buf.extend_from_slice(MAGIC);
1432        buf.push(99); // unsupported version
1433        assert!(matches!(
1434            decode(&buf),
1435            Err(BytecodeError::BadVersion { found: 99, .. })
1436        ));
1437    }
1438
1439    #[test]
1440    fn decode_rejects_bad_magic() {
1441        let buf = [0u8, 0u8, 0u8, 0u8, VERSION];
1442        assert!(matches!(decode(&buf), Err(BytecodeError::BadMagic)));
1443    }
1444
1445    #[test]
1446    fn metadata_roundtrip_preserves_tags_and_lists() {
1447        let metadata = CheckMetadata {
1448            name: Some("Check".into()),
1449            description: None,
1450            impact: None,
1451            severity: Some(Severity::High),
1452            author: None,
1453            cve: vec!["CVE-2024-1".into()],
1454            cwe: vec!["CWE-79".into()],
1455            references: vec!["https://example.com".into()],
1456            cvss: vec![],
1457            cvss_score: vec![],
1458            mitigation: Some("patch".into()),
1459            tags: vec!["auth".into(), "rce".into()],
1460            version: Some("1.2.3".into()),
1461            family: Some("web".into()),
1462        };
1463        let mut w = Writer::default();
1464        write_metadata(&mut w, &metadata);
1465        let mut r = Reader::new(&w.0);
1466        let decoded = read_metadata(&mut r).unwrap();
1467        assert_eq!(decoded.tags, vec!["auth", "rce"]);
1468        assert_eq!(decoded.cve, vec!["CVE-2024-1"]);
1469        assert_eq!(decoded.mitigation.as_deref(), Some("patch"));
1470        assert_eq!(decoded.severity, Some(Severity::High));
1471        assert_eq!(decoded.version.as_deref(), Some("1.2.3"));
1472        assert_eq!(decoded.family.as_deref(), Some("web"));
1473    }
1474
1475    #[test]
1476    fn decode_rejects_out_of_range_string_index() {
1477        // A `Set { name: 7, value: 0 }` over an empty string pool would
1478        // panic the executor with an out-of-bounds index. decode() must
1479        // reject it as Corrupt instead.
1480        let program = BytecodeProgram {
1481            spec: ProgramSpec {
1482                probes: Default::default(),
1483                metadata: CheckMetadata::default(),
1484            },
1485            code: vec![Instr::Set { name: 7, value: 0 }],
1486            strings: vec![],
1487            payloads: vec![],
1488            matchers: vec![],
1489            extracts: vec![],
1490            evidence: vec![],
1491        };
1492        let bytes = encode(&program);
1493        match decode(&bytes) {
1494            Err(BytecodeError::Corrupt("operand index out of range")) => {}
1495            other => panic!("expected operand-index Corrupt, got {other:?}"),
1496        }
1497    }
1498
1499    #[test]
1500    fn decode_rejects_out_of_range_match_slice() {
1501        let program = BytecodeProgram {
1502            spec: ProgramSpec {
1503                probes: Default::default(),
1504                metadata: CheckMetadata::default(),
1505            },
1506            // MatchAll over [0,3) but the matcher pool is empty.
1507            code: vec![Instr::MatchAll { start: 0, len: 3 }],
1508            strings: vec![],
1509            payloads: vec![],
1510            matchers: vec![],
1511            extracts: vec![],
1512            evidence: vec![],
1513        };
1514        let bytes = encode(&program);
1515        assert!(matches!(decode(&bytes), Err(BytecodeError::Corrupt(_))));
1516    }
1517
1518    #[test]
1519    fn decode_accepts_in_range_operands() {
1520        let program = BytecodeProgram {
1521            spec: ProgramSpec {
1522                probes: Default::default(),
1523                metadata: CheckMetadata::default(),
1524            },
1525            code: vec![Instr::Set { name: 0, value: 1 }],
1526            strings: vec!["host".into(), "value".into()],
1527            payloads: vec![],
1528            matchers: vec![],
1529            extracts: vec![],
1530            evidence: vec![],
1531        };
1532        let bytes = encode(&program);
1533        let decoded = decode(&bytes).expect("valid operands round-trip");
1534        assert_eq!(decoded.code.len(), 1);
1535    }
1536
1537    #[test]
1538    fn load_bytecode_input_no_longer_reads_files() {
1539        // Earlier revisions accepted `@/path/to/file` to read raw bytecode.
1540        // That entry point is gone; `@…` should now be treated as hex input
1541        // and fail because `@` is not a hex digit.
1542        match load_bytecode_input("@/etc/passwd") {
1543            Err(BytecodeError::InvalidHex(_)) => {}
1544            other => panic!("expected InvalidHex, got {other:?}"),
1545        }
1546    }
1547}