Skip to main content

cellos_telemetry/
lib.rs

1//! In-guest telemetry agent for the CellOS runner-evidence wedge (Phase F).
2//!
3//! Phase F3a — implementation. ADR-0006 is the doctrine reference.
4//!
5//! Operating model (ADR-0006 §5):
6//! - This agent runs as PID 2, forked by `cellos-init` BEFORE the workload
7//!   process (PID 3+) starts.
8//! - Workload seccomp profile blocks `kill(2)`, `tgkill(2)`, and `ptrace(2)`
9//!   against PIDs ≤ 2; the agent is structurally unreachable from the
10//!   workload. (TODO seccomp gate — config lives in `cellos-host-firecracker`.)
11//! - The agent holds NO private key. Authenticity comes from WHICH vsock
12//!   CID:port its frames arrive on — the host bound the channel before the
13//!   workload existed.
14//! - The agent fills only `probe_source`, `guest_pid`, `guest_comm`,
15//!   `guest_monotonic_ns`, and the leading `content_version`. The supervisor
16//!   host-stamps `cell_id`, `run_id`, `host_received_at`, `spec_signature_hash`,
17//!   and the ADG `output` block on receipt; anything the agent puts in those
18//!   fields is overwritten.
19//!
20//! Wire shape (ADR-0006 §12):
21//!
22//! ```text
23//! u32 LE length || CBOR map(5) {
24//!     "content_version"     => u16  (FIRST — host short-circuits unknown major)
25//!     "probe_source"        => text
26//!     "guest_pid"           => u32
27//!     "guest_comm"          => text
28//!     "guest_monotonic_ns"  => u64
29//! }
30//! ```
31//!
32//! The CBOR encoder/decoder is hand-rolled and *minimal*: definite-length
33//! map(5), uint major (0), text major (3), no floats, no tags, no indefinite
34//! lengths. `content_version` is always emitted first so the host can reject
35//! unknown majors before walking unknown probe-source strings.
36//!
37//! Probes the agent declares (Phase F3):
38//! - `process.spawned`, `process.exited` (`/proc` delta walker)
39//! - `capability.denied` (stub — kernel surface not yet wired)
40//! - declared `inotify` watch (one)
41//! - `net.connect_attempted` (stub)
42//!
43//! Back-pressure (ADR-0006 §5.3): drop-with-counter. The agent surfaces
44//! drops via `cell.observability.guest.telemetry.dropped`, never by
45//! blocking the workload.
46//!
47//! See [docs/adr/0006-in-vm-observability-runner-evidence.md] for the
48//! complete decision record.
49
50// Crate-wide policy: CBOR + framing core is pure-safe Rust. Syscall surfaces
51// (probes/* and the binary entrypoint) opt out per-module with
52// `#[allow(unsafe_code)]` because libc::inotify_init1/socket/connect/fork
53// have no safe wrapper at this layer.
54#![deny(unsafe_code)]
55#![warn(missing_docs)]
56
57/// CBOR wire-format major version. Must match
58/// `cellos_host_telemetry::WIRE_CONTENT_VERSION_MAJOR` or the host rejects
59/// the frame (ADR-0006 §12 wire-schema versioning).
60pub const WIRE_CONTENT_VERSION_MAJOR: u16 = 1;
61
62/// Vsock port the guest agent connects to on the host.
63/// Must match `cellos_host_telemetry::VSOCK_TELEMETRY_PORT`.
64pub const VSOCK_TELEMETRY_PORT: u32 = 9001;
65
66/// Vsock CID for the host (`VMADDR_CID_HOST`). Firecracker maps guest-initiated
67/// connect(CID=2) to the host listener.
68pub const VMADDR_CID_HOST: u32 = 2;
69
70/// Maximum frame body size (CBOR map payload) the agent ever emits.
71/// 4 KiB is generous for our 5-field map; the host enforces the same bound.
72pub const MAX_FRAME_BODY_BYTES: usize = 4096;
73
74/// Probe source identifiers the agent emits.
75///
76/// String constants rather than an enum so the host's CBOR decoder can match
77/// without coupling to this crate's Rust types — Path A is intentionally a
78/// data wire, not a Rust API.
79// TODO cfg-gate when F1a lands: future probes belong in #[cfg(target_os = "linux")] pub mod probes;
80pub mod probe_source {
81    /// Process spawn observed via guest-side `/proc` delta walk.
82    pub const PROCESS_SPAWNED: &str = "process.spawned";
83    /// Process exit observed via guest-side `/proc` delta walk.
84    pub const PROCESS_EXITED: &str = "process.exited";
85    /// `capability.denied` event from the kernel via guest probe.
86    pub const CAPABILITY_DENIED: &str = "capability.denied";
87    /// `inotify` watch fired on a declared FS path.
88    pub const FS_INOTIFY_FIRED: &str = "fs.inotify_fired";
89    /// `connect(2)` attempt observed pre-syscall.
90    pub const NET_CONNECT_ATTEMPTED: &str = "net.connect_attempted";
91
92    /// All known probe sources. Used by the decoder to reject unknown sources
93    /// without bringing the channel down (drop-with-counter semantics).
94    pub const ALL: &[&str] = &[
95        PROCESS_SPAWNED,
96        PROCESS_EXITED,
97        CAPABILITY_DENIED,
98        FS_INOTIFY_FIRED,
99        NET_CONNECT_ATTEMPTED,
100    ];
101
102    /// True iff `s` matches one of the declared probe sources.
103    pub fn is_known(s: &str) -> bool {
104        ALL.contains(&s)
105    }
106}
107
108pub mod probes;
109
110/// Guest-side telemetry declaration — what the in-VM agent claims it will
111/// exercise. The host validates `declared ⊆ authorized` before accepting
112/// these events (ADR-0006 §11 admission validation, Doctrine #11).
113///
114/// Added 2026-05-16 as F3 prep alongside ADR-0006 acceptance. This is the
115/// type the F3-next-tranche admission path will project from the spec's
116/// `telemetry` block.
117#[derive(Debug, Clone)]
118pub struct GuestTelemetryDeclaration {
119    /// The set of capabilities the workload claims it will exercise.
120    /// Subset-checked against the cell's authorized surface at admission.
121    pub declared_surface: cellos_core::authority::DeclaredAuthoritySurface,
122    /// Version string of the in-guest telemetry agent emitting this
123    /// declaration. Used for compatibility gating at the host.
124    pub agent_version: String,
125}
126
127/// One probe-firing the agent emits.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ProbeEvent {
130    /// One of the [`probe_source`] constants.
131    pub probe_source: &'static str,
132    /// Guest-side process id (`getpid()` or as observed).
133    pub guest_pid: u32,
134    /// Guest-side process command name (truncated to 16 chars to match Linux `comm`).
135    pub guest_comm: String,
136    /// Guest-side monotonic timestamp at probe-fire.
137    pub guest_monotonic_ns: u64,
138}
139
140// ---------------------------------------------------------------------------
141// Hand-rolled minimal CBOR encoder/decoder.
142//
143// Subset only:
144//   - major 0 (uint, 1/2/4/8-byte argument)
145//   - major 3 (text string, 1/2/4-byte length argument)
146//   - major 5 (map, definite-length only — we only ever emit map(5))
147//
148// No floats, no tags, no indefinite lengths, no negative ints, no arrays, no
149// byte strings. If we ever add fields, the encoder grows; the decoder rejects
150// anything outside this subset with `WireError::Unsupported*`.
151// ---------------------------------------------------------------------------
152
153/// Errors surfaced by the wire encoder/decoder.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum WireError {
156    /// Frame body exceeded [`MAX_FRAME_BODY_BYTES`].
157    FrameTooLarge {
158        /// Length of the offending frame body, in bytes.
159        len: usize,
160    },
161    /// Frame body shorter than the declared length prefix.
162    FrameTruncated {
163        /// Length declared by the 4-byte big-endian length prefix.
164        declared: u32,
165        /// Actual number of body bytes available after the header.
166        actual: usize,
167    },
168    /// Length-prefix header itself is shorter than 4 bytes.
169    ShortHeader {
170        /// Number of header bytes actually received.
171        got: usize,
172    },
173    /// CBOR ran past end of buffer.
174    UnexpectedEof,
175    /// CBOR major type is not in the supported subset (0, 3, 5).
176    UnsupportedMajor {
177        /// Offending CBOR major type encountered in the buffer.
178        major: u8,
179    },
180    /// CBOR additional info encodes indefinite-length or > 8-byte length.
181    UnsupportedAdditional {
182        /// Offending CBOR additional-info nibble (0..=31).
183        additional: u8,
184    },
185    /// Outer item is not a 5-entry definite map.
186    NotMap5,
187    /// First map key is not the literal text `"content_version"` —
188    /// host short-circuits unknown majors before walking the rest.
189    ContentVersionMustBeFirst,
190    /// `content_version` major is not [`WIRE_CONTENT_VERSION_MAJOR`].
191    UnsupportedContentVersion(u16),
192    /// Required field missing or duplicated.
193    MissingField(&'static str),
194    /// Field present but wrong CBOR major.
195    FieldType {
196        /// Name of the field whose CBOR major did not match the schema.
197        field: &'static str,
198        /// CBOR major type actually observed for that field.
199        got_major: u8,
200    },
201    /// Integer field overflowed its declared width.
202    IntegerOverflow {
203        /// Name of the integer field that exceeded its declared width.
204        field: &'static str,
205    },
206    /// Probe-source string not in [`probe_source::ALL`].
207    UnknownProbeSource(String),
208    /// Text field is not valid UTF-8.
209    InvalidUtf8 {
210        /// Name of the text field that failed UTF-8 validation.
211        field: &'static str,
212    },
213}
214
215impl core::fmt::Display for WireError {
216    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
217        match self {
218            Self::FrameTooLarge { len } => write!(f, "frame too large: {len} bytes"),
219            Self::FrameTruncated { declared, actual } => {
220                write!(f, "frame truncated: declared {declared}, got {actual}")
221            }
222            Self::ShortHeader { got } => write!(f, "short header: {got} bytes"),
223            Self::UnexpectedEof => write!(f, "unexpected end of CBOR buffer"),
224            Self::UnsupportedMajor { major } => write!(f, "unsupported CBOR major {major}"),
225            Self::UnsupportedAdditional { additional } => {
226                write!(f, "unsupported CBOR additional {additional}")
227            }
228            Self::NotMap5 => write!(f, "outer CBOR item is not map(5)"),
229            Self::ContentVersionMustBeFirst => {
230                write!(f, "content_version must be the first map key")
231            }
232            Self::UnsupportedContentVersion(v) => {
233                write!(f, "unsupported content_version major {v}")
234            }
235            Self::MissingField(n) => write!(f, "missing or duplicate field {n}"),
236            Self::FieldType { field, got_major } => {
237                write!(f, "field {field}: wrong major {got_major}")
238            }
239            Self::IntegerOverflow { field } => write!(f, "integer overflow in field {field}"),
240            Self::UnknownProbeSource(s) => write!(f, "unknown probe_source {s:?}"),
241            Self::InvalidUtf8 { field } => write!(f, "invalid utf8 in field {field}"),
242        }
243    }
244}
245
246impl std::error::Error for WireError {}
247
248// ---- Encoder ---------------------------------------------------------------
249
250/// Push the type byte (`major << 5 | additional`) and big-endian argument
251/// for a CBOR uint. We always pick the smallest legal encoding; that is the
252/// only encoding the decoder accepts as canonical-equivalent.
253fn push_uint(out: &mut Vec<u8>, major: u8, value: u64) {
254    debug_assert!(major <= 7);
255    let m = major << 5;
256    if value < 24 {
257        out.push(m | (value as u8));
258    } else if value <= u8::MAX as u64 {
259        out.push(m | 24);
260        out.push(value as u8);
261    } else if value <= u16::MAX as u64 {
262        out.push(m | 25);
263        out.extend_from_slice(&(value as u16).to_be_bytes());
264    } else if value <= u32::MAX as u64 {
265        out.push(m | 26);
266        out.extend_from_slice(&(value as u32).to_be_bytes());
267    } else {
268        out.push(m | 27);
269        out.extend_from_slice(&value.to_be_bytes());
270    }
271}
272
273fn push_text(out: &mut Vec<u8>, s: &str) {
274    push_uint(out, 3, s.len() as u64);
275    out.extend_from_slice(s.as_bytes());
276}
277
278/// CBOR-encode a [`ProbeEvent`] body (no length prefix).
279///
280/// `content_version` is emitted FIRST so the host can short-circuit unknown
281/// majors before walking unknown probe-source strings (ADR-0006 §12).
282pub fn encode_event_body(ev: &ProbeEvent) -> Vec<u8> {
283    let mut out = Vec::with_capacity(128);
284
285    // map(5) — definite-length, 5 entries.
286    out.push((5u8 << 5) | 5);
287
288    // 1. "content_version" => uint
289    push_text(&mut out, "content_version");
290    push_uint(&mut out, 0, WIRE_CONTENT_VERSION_MAJOR as u64);
291
292    // 2. "probe_source" => text
293    push_text(&mut out, "probe_source");
294    push_text(&mut out, ev.probe_source);
295
296    // 3. "guest_pid" => uint
297    push_text(&mut out, "guest_pid");
298    push_uint(&mut out, 0, ev.guest_pid as u64);
299
300    // 4. "guest_comm" => text
301    push_text(&mut out, "guest_comm");
302    push_text(&mut out, &ev.guest_comm);
303
304    // 5. "guest_monotonic_ns" => uint
305    push_text(&mut out, "guest_monotonic_ns");
306    push_uint(&mut out, 0, ev.guest_monotonic_ns);
307
308    out
309}
310
311/// CBOR-encode a [`ProbeEvent`] and prepend the 4-byte LE frame-length
312/// header.
313///
314/// Returns [`WireError::FrameTooLarge`] if the body exceeds
315/// [`MAX_FRAME_BODY_BYTES`] — that is a budget the host enforces too, so
316/// failing locally avoids putting an unreceivable frame on the wire.
317pub fn encode_frame(ev: &ProbeEvent) -> Result<Vec<u8>, WireError> {
318    let body = encode_event_body(ev);
319    if body.len() > MAX_FRAME_BODY_BYTES {
320        return Err(WireError::FrameTooLarge { len: body.len() });
321    }
322    let mut frame = Vec::with_capacity(4 + body.len());
323    frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
324    frame.extend_from_slice(&body);
325    Ok(frame)
326}
327
328// ---- Decoder ---------------------------------------------------------------
329
330/// Cursor over a CBOR byte slice. Pure-safe; bounds-checked on every read.
331struct Cursor<'a> {
332    buf: &'a [u8],
333    pos: usize,
334}
335
336impl<'a> Cursor<'a> {
337    fn new(buf: &'a [u8]) -> Self {
338        Self { buf, pos: 0 }
339    }
340
341    fn take(&mut self, n: usize) -> Result<&'a [u8], WireError> {
342        if self.pos + n > self.buf.len() {
343            return Err(WireError::UnexpectedEof);
344        }
345        let slice = &self.buf[self.pos..self.pos + n];
346        self.pos += n;
347        Ok(slice)
348    }
349
350    fn read_u8(&mut self) -> Result<u8, WireError> {
351        Ok(self.take(1)?[0])
352    }
353
354    /// Decode the (major, argument) pair for the next CBOR item.
355    /// Refuses indefinite-length (additional=31) and unsupported additional bits.
356    fn read_header(&mut self) -> Result<(u8, u64), WireError> {
357        let b = self.read_u8()?;
358        let major = b >> 5;
359        let additional = b & 0x1f;
360        let arg = match additional {
361            0..=23 => additional as u64,
362            24 => self.read_u8()? as u64,
363            25 => {
364                let bs = self.take(2)?;
365                u16::from_be_bytes([bs[0], bs[1]]) as u64
366            }
367            26 => {
368                let bs = self.take(4)?;
369                u32::from_be_bytes([bs[0], bs[1], bs[2], bs[3]]) as u64
370            }
371            27 => {
372                let bs = self.take(8)?;
373                u64::from_be_bytes([bs[0], bs[1], bs[2], bs[3], bs[4], bs[5], bs[6], bs[7]])
374            }
375            other => return Err(WireError::UnsupportedAdditional { additional: other }),
376        };
377        Ok((major, arg))
378    }
379
380    fn read_text(&mut self, field: &'static str) -> Result<String, WireError> {
381        let (major, len) = self.read_header()?;
382        if major != 3 {
383            return Err(WireError::FieldType {
384                field,
385                got_major: major,
386            });
387        }
388        let bytes = self.take(len as usize)?;
389        std::str::from_utf8(bytes)
390            .map(|s| s.to_owned())
391            .map_err(|_| WireError::InvalidUtf8 { field })
392    }
393
394    fn read_uint(&mut self, field: &'static str) -> Result<u64, WireError> {
395        let (major, val) = self.read_header()?;
396        if major != 0 {
397            return Err(WireError::FieldType {
398                field,
399                got_major: major,
400            });
401        }
402        Ok(val)
403    }
404}
405
406/// Decode a frame body (no length prefix) into a [`ProbeEvent`].
407///
408/// Validation order:
409/// 1. Outer must be `map(5)`.
410/// 2. First key must be the literal text `"content_version"` and its value
411///    must equal [`WIRE_CONTENT_VERSION_MAJOR`]. This short-circuits unknown
412///    majors before walking unknown probe-source strings.
413/// 3. Remaining four keys may appear in any order; each must appear exactly
414///    once.
415/// 4. `probe_source` must be in [`probe_source::ALL`].
416pub fn decode_event_body(body: &[u8]) -> Result<ProbeEvent, WireError> {
417    let mut cur = Cursor::new(body);
418
419    // Outer map(5).
420    let (major, len) = cur.read_header()?;
421    if major != 5 || len != 5 {
422        return Err(WireError::NotMap5);
423    }
424
425    // First key MUST be content_version. Reject before doing any other work
426    // so an unknown major can't trigger probe-source validation, etc.
427    let first_key = cur.read_text("<map key 0>")?;
428    if first_key != "content_version" {
429        return Err(WireError::ContentVersionMustBeFirst);
430    }
431    let cv = cur.read_uint("content_version")?;
432    if cv > u16::MAX as u64 {
433        return Err(WireError::IntegerOverflow {
434            field: "content_version",
435        });
436    }
437    if cv as u16 != WIRE_CONTENT_VERSION_MAJOR {
438        return Err(WireError::UnsupportedContentVersion(cv as u16));
439    }
440
441    // Remaining 4 keys, any order.
442    let mut probe_source: Option<String> = None;
443    let mut guest_pid: Option<u32> = None;
444    let mut guest_comm: Option<String> = None;
445    let mut guest_monotonic_ns: Option<u64> = None;
446
447    for _ in 0..4 {
448        let key = cur.read_text("<map key>")?;
449        match key.as_str() {
450            "probe_source" => {
451                if probe_source.is_some() {
452                    return Err(WireError::MissingField("probe_source"));
453                }
454                probe_source = Some(cur.read_text("probe_source")?);
455            }
456            "guest_pid" => {
457                if guest_pid.is_some() {
458                    return Err(WireError::MissingField("guest_pid"));
459                }
460                let v = cur.read_uint("guest_pid")?;
461                if v > u32::MAX as u64 {
462                    return Err(WireError::IntegerOverflow { field: "guest_pid" });
463                }
464                guest_pid = Some(v as u32);
465            }
466            "guest_comm" => {
467                if guest_comm.is_some() {
468                    return Err(WireError::MissingField("guest_comm"));
469                }
470                guest_comm = Some(cur.read_text("guest_comm")?);
471            }
472            "guest_monotonic_ns" => {
473                if guest_monotonic_ns.is_some() {
474                    return Err(WireError::MissingField("guest_monotonic_ns"));
475                }
476                guest_monotonic_ns = Some(cur.read_uint("guest_monotonic_ns")?);
477            }
478            _ => {
479                // Unknown key — reject. Forwarding it would leak attribution
480                // shape into the host stamp. We map all unknown keys to a
481                // single static label so the decoder allocates nothing for
482                // adversarial input.
483                return Err(WireError::MissingField("<unknown key>"));
484            }
485        }
486    }
487
488    let ps_owned = probe_source.ok_or(WireError::MissingField("probe_source"))?;
489    if !probe_source::is_known(&ps_owned) {
490        return Err(WireError::UnknownProbeSource(ps_owned));
491    }
492    // Map back to the &'static str so downstream code keeps the no-alloc invariant.
493    let ps_static: &'static str = probe_source::ALL
494        .iter()
495        .copied()
496        .find(|k| *k == ps_owned)
497        .expect("is_known just verified");
498
499    Ok(ProbeEvent {
500        probe_source: ps_static,
501        guest_pid: guest_pid.ok_or(WireError::MissingField("guest_pid"))?,
502        guest_comm: guest_comm.ok_or(WireError::MissingField("guest_comm"))?,
503        guest_monotonic_ns: guest_monotonic_ns
504            .ok_or(WireError::MissingField("guest_monotonic_ns"))?,
505    })
506}
507
508/// Decode a length-prefixed frame: `u32 LE length || CBOR body`.
509///
510/// Returns [`WireError::ShortHeader`] if `frame.len() < 4` and
511/// [`WireError::FrameTruncated`] if the declared length exceeds the bytes
512/// actually present.
513pub fn decode_frame(frame: &[u8]) -> Result<ProbeEvent, WireError> {
514    if frame.len() < 4 {
515        return Err(WireError::ShortHeader { got: frame.len() });
516    }
517    let declared = u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]);
518    if declared as usize > MAX_FRAME_BODY_BYTES {
519        return Err(WireError::FrameTooLarge {
520            len: declared as usize,
521        });
522    }
523    let body_present = frame.len() - 4;
524    if (declared as usize) > body_present {
525        return Err(WireError::FrameTruncated {
526            declared,
527            actual: body_present,
528        });
529    }
530    decode_event_body(&frame[4..4 + declared as usize])
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    fn sample() -> ProbeEvent {
538        ProbeEvent {
539            probe_source: probe_source::PROCESS_SPAWNED,
540            guest_pid: 4242,
541            guest_comm: "workload".to_owned(),
542            guest_monotonic_ns: 123_456_789_012,
543        }
544    }
545
546    #[test]
547    fn wire_versions_match() {
548        assert_eq!(WIRE_CONTENT_VERSION_MAJOR, 1);
549        assert_eq!(VSOCK_TELEMETRY_PORT, 9001);
550        assert_eq!(VMADDR_CID_HOST, 2);
551    }
552
553    #[test]
554    fn probe_source_constants_are_stable_strings() {
555        assert_eq!(probe_source::PROCESS_SPAWNED, "process.spawned");
556        assert_eq!(probe_source::CAPABILITY_DENIED, "capability.denied");
557        assert!(probe_source::is_known("process.spawned"));
558        assert!(!probe_source::is_known("rogue.event"));
559    }
560
561    #[test]
562    fn round_trip_encode_decode() {
563        let ev = sample();
564        let frame = encode_frame(&ev).expect("encode");
565        let back = decode_frame(&frame).expect("decode");
566        assert_eq!(back, ev);
567    }
568
569    #[test]
570    fn round_trip_each_probe_source() {
571        for &ps in probe_source::ALL {
572            let ev = ProbeEvent {
573                probe_source: ps,
574                guest_pid: 7,
575                guest_comm: "p".to_owned(),
576                guest_monotonic_ns: 1,
577            };
578            let frame = encode_frame(&ev).unwrap();
579            let back = decode_frame(&frame).unwrap();
580            assert_eq!(back, ev, "round-trip failed for {ps}");
581        }
582    }
583
584    #[test]
585    fn content_version_first_short_circuits_unknown_major() {
586        // Build a CBOR map(5) where the FIRST key is content_version but
587        // the value is an unsupported major, and a LATER key is an
588        // unknown probe source. The decoder MUST return
589        // UnsupportedContentVersion before reaching the probe-source check.
590        let mut body = Vec::new();
591        body.push((5u8 << 5) | 5); // map(5)
592        push_text(&mut body, "content_version");
593        push_uint(&mut body, 0, 999); // bogus major
594        push_text(&mut body, "probe_source");
595        push_text(&mut body, "rogue.event"); // would also fail later
596        push_text(&mut body, "guest_pid");
597        push_uint(&mut body, 0, 1);
598        push_text(&mut body, "guest_comm");
599        push_text(&mut body, "x");
600        push_text(&mut body, "guest_monotonic_ns");
601        push_uint(&mut body, 0, 1);
602
603        match decode_event_body(&body) {
604            Err(WireError::UnsupportedContentVersion(999)) => {}
605            other => panic!(
606                "expected UnsupportedContentVersion(999), got {other:?} \
607                 (host MUST short-circuit before probe-source validation)"
608            ),
609        }
610    }
611
612    #[test]
613    fn content_version_must_be_first_key() {
614        // First key is something else — even though all other fields are
615        // present and valid. This is the structural guarantee that lets the
616        // host reject without indexing the rest of the map.
617        let mut body = Vec::new();
618        body.push((5u8 << 5) | 5);
619        push_text(&mut body, "guest_pid");
620        push_uint(&mut body, 0, 1);
621        push_text(&mut body, "content_version");
622        push_uint(&mut body, 0, WIRE_CONTENT_VERSION_MAJOR as u64);
623        push_text(&mut body, "probe_source");
624        push_text(&mut body, probe_source::PROCESS_SPAWNED);
625        push_text(&mut body, "guest_comm");
626        push_text(&mut body, "x");
627        push_text(&mut body, "guest_monotonic_ns");
628        push_uint(&mut body, 0, 1);
629
630        assert_eq!(
631            decode_event_body(&body),
632            Err(WireError::ContentVersionMustBeFirst)
633        );
634    }
635
636    #[test]
637    fn unknown_probe_source_rejected() {
638        let mut body = Vec::new();
639        body.push((5u8 << 5) | 5);
640        push_text(&mut body, "content_version");
641        push_uint(&mut body, 0, WIRE_CONTENT_VERSION_MAJOR as u64);
642        push_text(&mut body, "probe_source");
643        push_text(&mut body, "rogue.event");
644        push_text(&mut body, "guest_pid");
645        push_uint(&mut body, 0, 1);
646        push_text(&mut body, "guest_comm");
647        push_text(&mut body, "x");
648        push_text(&mut body, "guest_monotonic_ns");
649        push_uint(&mut body, 0, 1);
650
651        match decode_event_body(&body) {
652            Err(WireError::UnknownProbeSource(s)) => assert_eq!(s, "rogue.event"),
653            other => panic!("expected UnknownProbeSource, got {other:?}"),
654        }
655    }
656
657    #[test]
658    fn frame_truncation_detected() {
659        let ev = sample();
660        let mut frame = encode_frame(&ev).unwrap();
661        // Drop the last byte — body is now shorter than the declared length.
662        let original_len = frame.len();
663        frame.pop();
664        match decode_frame(&frame) {
665            Err(WireError::FrameTruncated { declared, actual }) => {
666                assert_eq!(declared as usize, original_len - 4);
667                assert_eq!(actual, original_len - 4 - 1);
668            }
669            other => panic!("expected FrameTruncated, got {other:?}"),
670        }
671    }
672
673    #[test]
674    fn short_header_detected() {
675        // Only 3 bytes — the 4-byte length header itself didn't arrive in full.
676        assert_eq!(
677            decode_frame(&[0x00, 0x00, 0x00]),
678            Err(WireError::ShortHeader { got: 3 })
679        );
680        assert_eq!(decode_frame(&[]), Err(WireError::ShortHeader { got: 0 }));
681    }
682
683    #[test]
684    fn frame_too_large_rejected() {
685        // Forge a frame whose declared length is larger than the cap.
686        let mut frame = Vec::new();
687        frame.extend_from_slice(&((MAX_FRAME_BODY_BYTES as u32) + 1).to_le_bytes());
688        // Body bytes don't matter — header alone trips the check.
689        match decode_frame(&frame) {
690            Err(WireError::FrameTooLarge { .. }) => {}
691            other => panic!("expected FrameTooLarge, got {other:?}"),
692        }
693    }
694
695    #[test]
696    fn unsupported_major_rejected() {
697        // Outer item is an array, not a map.
698        let body = vec![(4u8 << 5) | 5];
699        assert_eq!(decode_event_body(&body), Err(WireError::NotMap5));
700    }
701
702    #[test]
703    fn indefinite_length_rejected() {
704        // additional=31 = indefinite length — not in our subset.
705        let body = vec![(5u8 << 5) | 31];
706        match decode_event_body(&body) {
707            Err(WireError::UnsupportedAdditional { additional: 31 }) => {}
708            other => panic!("expected UnsupportedAdditional(31), got {other:?}"),
709        }
710    }
711}