//! C0 — the state-snapshot serialization spine for the native-execution VM
//! (Stage 1; see `docs/design/native-execution-vm-2026-06-15.md`).
//!
//! This is the single non-parallelizable foundation every other Stage-1
//! component imports: the versioned, little-endian, bounds-checked on-disk
//! container [`StateSnapshot`] that holds **all owned guest state**, plus the
//! shared identity / fd types ([`Vpid`], [`SocketHandle`], [`FdTarget`]) the
//! other components depend on. It is *pure framing*: new types + hand-written
//! codecs + tests. It touches NO hot path and does NOT re-key the sentry's live
//! `fdt()` / `cwds()` tables (that host→virtual re-keying is a later component).
//!
//! ## Design lineage (do not invent new framing)
//!
//! The codecs are modelled directly on this crate's two established idioms:
//!
//!   * [`crate::snapshot_frame`] — the LE field readers
//!     ([`le_u16`]/[`le_u32`]/[`le_u64`]), the [`cap_count`] anti-OOM count
//!     guard, and the version-skew HARD-refusal idiom (the "#122" pattern:
//!     [`StateSnapshot::read_from`] returns `Err` on a version mismatch — never a
//!     warning, never a silent accept). Every blob length is capped before
//!     allocation so a corrupt/hostile header can't OOM-abort the host before the
//!     read loop's per-record `read_exact` bounds checks run.
//!   * `TsiListenerSnapshot` (in `devices::virtio::vsock::muxer`) — hand-written
//!     fixed-width LE records, length-prefixed sections, every count guarded by
//!     [`cap_count`], **NO serde**.
//!
//! ## Identity discipline (a binding correction from adversarial review)
//!
//! A serialized identity is a **virtual pid** ([`Vpid`]), never a host pid. A
//! host pid is an index into a live process's table — meaningless once the
//! process is gone, and forgeable across a restore onto a different host. The
//! whole point of the state-owning kernel is that identity lives in *our* state,
//! so it is what we serialize. Likewise a serialized fd is **never** a bare
//! host-fd integer (also a live-process table index); it is a virtualized
//! [`FdTarget`] that names *what the fd refers to*, so restore can re-open / re-
//! bind the real resource.

use std::io::{self, Read, Write};

use crate::snapshot_frame::cap_count;

// ─── Shared identity & fd types (imported by other Stage-1 components) ───────

/// The virtual pid: the sentry's *owned* process identity, stable across
/// snapshot/restore and independent of any host pid. Host pids are a private
/// implementation detail of the live process tree and are **never** serialized
/// as identity — only `Vpid` crosses the wire.
pub type Vpid = u32;

/// A transparent newtype placeholder for a loopback (guest↔guest) socket
/// handle. In a later Stage-1 component this becomes the real `smoltcp`
/// `SocketHandle`; today it is a `u32` token so the [`FdTarget::LoopSocket`]
/// codec compiles and round-trips. The wire encoding is a bare LE `u32`, so
/// swapping the inner type for `smoltcp`'s handle (also a small integer index)
/// is a non-breaking field-type change as long as it stays `u32`-representable.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SocketHandle(pub u32);

/// The kind of host-egress endpoint a [`FdTarget::HostEgressSocket`] refers to.
/// The native-exec model owns the *loopback* world; a host-egress socket is the
/// one fd class whose far end lives outside the owned kernel, so on restore it
/// must be re-dialed (it cannot be byte-restored). We serialize the minimal
/// descriptor needed to re-create the socket and, for a connected socket, to
/// re-dial the same peer.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum EgressProto {
    /// `AF_INET`/`AF_INET6` + `SOCK_STREAM` (TCP).
    Tcp = 0,
    /// `AF_INET`/`AF_INET6` + `SOCK_DGRAM` (UDP).
    Udp = 1,
}

impl EgressProto {
    fn from_u8(v: u8) -> Option<Self> {
        match v {
            0 => Some(Self::Tcp),
            1 => Some(Self::Udp),
            _ => None,
        }
    }
}

/// The kind of a device node fd ([`FdTarget::DevNode`]). The sentry synthesizes
/// a small set of character devices in-kernel rather than passing through host
/// device nodes, so a fixed enum (not a raw major/minor) is the right
/// serializable identity: it names the *semantics* to re-create, host-
/// independent. (`major/minor` would be a host detail; the kind is owned state.)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum DevKind {
    /// `/dev/null` — discards writes, EOF on read.
    Null = 0,
    /// `/dev/zero` — infinite zeros on read.
    Zero = 1,
    /// `/dev/full` — ENOSPC on write.
    Full = 2,
    /// `/dev/random` or `/dev/urandom` — CSPRNG bytes (both map here; the
    /// guest-visible distinction is behavioural, not stateful).
    Random = 3,
    /// A pty/tty character device.
    Tty = 4,
}

impl DevKind {
    fn from_u8(v: u8) -> Option<Self> {
        match v {
            0 => Some(Self::Null),
            1 => Some(Self::Zero),
            2 => Some(Self::Full),
            3 => Some(Self::Random),
            4 => Some(Self::Tty),
            _ => None,
        }
    }
}

/// Which end of a pipe a [`FdTarget::Pipe`] fd holds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum PipeEnd {
    /// The read end.
    Read = 0,
    /// The write end.
    Write = 1,
}

impl PipeEnd {
    fn from_u8(v: u8) -> Option<Self> {
        match v {
            0 => Some(Self::Read),
            1 => Some(Self::Write),
            _ => None,
        }
    }
}

/// One owned-loopback socket watch installed in a guest epoll object.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EpollLoopWatch {
    pub socket: SocketHandle,
    pub events: u32,
    pub data: u64,
}

/// The virtualized value an open guest fd refers to. **Critical:** a serialized
/// fd is NEVER a bare host-fd integer — a host fd is just an index into a live
/// process's descriptor table, un-serializable and meaningless after the process
/// exits. Each variant instead names *what the fd points at* in owned state, so
/// restore can re-create or re-bind the resource.
///
/// Variant serializable fields (each documented as a minimal sensible set):
///
///   * [`LoopSocket`](Self::LoopSocket) — a handle into the owned loopback
///     netstack. The socket's buffers/peer state live in
///     [`super::netstack::LoopSnapshot`]; this fd
///     only references it by handle, so the field is just the [`SocketHandle`].
///   * [`RootRelFile`](Self::RootRelFile) — a regular file open under the
///     guest rootfs. `path` is the **rootfs-relative** guest path (bytes, not
///     `String`: Linux paths are arbitrary bytes), `offset` the current seek
///     position to restore, `flags` the open flags (`O_RDONLY`/`O_APPEND`/…).
///     No host fd is stored — restore re-opens `path` under the rootfs and
///     `lseek`s to `offset`.
///   * [`HostEgressSocket`](Self::HostEgressSocket) — the one fd class whose far
///     end is outside the owned world. Minimal descriptor to re-dial: `proto`
///     (TCP/UDP), `v6` (address family), the local + remote IP/port. A
///     `connected` flag distinguishes a bound-but-unconnected socket (remote
///     is unused) from a connected one. (Per the design's "external host
///     resources" caveat, a host-egress socket may not byte-restore — but its
///     identity is still serializable so policy can re-dial or fail cleanly.)
///   * [`DevNode`](Self::DevNode) — a synthesized character device, identified
///     by its semantic [`DevKind`] (host-independent), not a host major/minor,
///     plus the original status flags.
///   * [`Pipe`](Self::Pipe) — an anonymous pipe end. `id` is a capture-local pipe
///     identity shared by every fd that points at the same pipe object, `end`
///     records read/write direction, `buffered` carries the bytes currently
///     buffered in the pipe, and `flags` records the fd status flags. Restore
///     recreates one host pipe per `id` and duplicates the requested ends.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FdTarget {
    LoopSocket(SocketHandle),
    RootRelFile {
        path: Vec<u8>,
        offset: u64,
        flags: u32,
    },
    HostEgressSocket {
        proto: EgressProto,
        /// `true` ⇒ `AF_INET6` (addresses are 16-byte); `false` ⇒ `AF_INET`
        /// (4-byte, left-justified in the 16-byte field, rest zero).
        v6: bool,
        local_ip: [u8; 16],
        local_port: u16,
        /// `true` ⇒ the socket is connected and `remote_*` are meaningful;
        /// `false` ⇒ bound/listening only (remote fields are zero/ignored).
        connected: bool,
        remote_ip: [u8; 16],
        remote_port: u16,
    },
    DevNode {
        kind: DevKind,
        flags: u32,
    },
    Pipe {
        id: u64,
        end: PipeEnd,
        buffered: Vec<u8>,
        flags: u32,
    },
    Epoll {
        loop_watches: Vec<EpollLoopWatch>,
    },
}

// ─── Codec helpers (private; mirror snapshot_frame's discipline) ─────────────

/// Per-blob byte-length ceiling shared by every length-prefixed `Vec<u8>` in
/// this container (paths, pipe buffers, send/recv queues, xsave areas). 64 MiB
/// is a generous, allocation-bounding cap: real values are KiB-to-low-MiB, so a
/// `u32` length claiming more than this is corrupt/hostile and is refused
/// *before* the `vec![0; n]` allocation — the read loop would fail at
/// `read_exact` anyway, this just stops a pre-emptive OOM.
const BLOB_MAX: usize = 64 << 20;

/// Read exactly `N` bytes or fail (never panic on a short stream).
#[inline]
fn rb<const N: usize, R: Read>(r: &mut R) -> io::Result<[u8; N]> {
    let mut x = [0u8; N];
    r.read_exact(&mut x)?;
    Ok(x)
}

#[inline]
fn ru8<R: Read>(r: &mut R) -> io::Result<u8> {
    Ok(rb::<1, _>(r)?[0])
}
#[inline]
fn ru16<R: Read>(r: &mut R) -> io::Result<u16> {
    Ok(u16::from_le_bytes(rb::<2, _>(r)?))
}
#[inline]
fn ru32<R: Read>(r: &mut R) -> io::Result<u32> {
    Ok(u32::from_le_bytes(rb::<4, _>(r)?))
}
#[inline]
fn ru64<R: Read>(r: &mut R) -> io::Result<u64> {
    Ok(u64::from_le_bytes(rb::<8, _>(r)?))
}
#[inline]
fn ri64<R: Read>(r: &mut R) -> io::Result<i64> {
    Ok(i64::from_le_bytes(rb::<8, _>(r)?))
}

#[inline]
fn err(m: impl Into<String>) -> io::Error {
    io::Error::new(io::ErrorKind::InvalidData, m.into())
}

/// Write a length-prefixed byte blob: `len u32 · bytes`.
#[inline]
fn wblob<W: Write>(w: &mut W, b: &[u8]) -> io::Result<()> {
    w.write_all(&(b.len() as u32).to_le_bytes())?;
    w.write_all(b)
}

/// Read a length-prefixed byte blob, refusing a length over [`BLOB_MAX`] before
/// allocating (anti-OOM); the trailing `read_exact` still bounds the read.
#[inline]
fn rblob<R: Read>(r: &mut R, what: &str) -> io::Result<Vec<u8>> {
    let n = ru32(r)? as usize;
    if n > BLOB_MAX {
        return Err(err(format!("{what} blob length {n} exceeds {BLOB_MAX}")));
    }
    let mut buf = vec![0u8; n];
    r.read_exact(&mut buf)?;
    Ok(buf)
}

/// Read a section element count `u32`, then right-size the up-front
/// `Vec::with_capacity` to what the remaining stream could justify via
/// [`cap_count`]. We use a fixed, generous `region` budget rather than the true
/// remaining length (a `Read` is not seekable) — the per-record `read_exact`
/// inside the loop is the real bound; this only stops a `0xFFFF_FFFF` count
/// driving a multi-GiB pre-allocation. Returns `(count, with_capacity_hint)`.
#[inline]
fn read_count<R: Read>(r: &mut R, min_elem: usize) -> io::Result<(usize, usize)> {
    let count = ru32(r)? as usize;
    // BLOB_MAX is a comfortable per-section element-bytes budget for the hint;
    // it bounds only the allocation, not the (read_exact-guarded) loop.
    let hint = cap_count(count, min_elem, BLOB_MAX);
    Ok((count, hint))
}

// ─── ProcState ───────────────────────────────────────────────────────────────

/// The lifecycle state of a guest process. A zombie carries its exit code (to be
/// reaped by its parent); a reaped process is fully gone but may still be
/// referenced transiently. **No host pid is stored** — identity is the [`Vpid`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProcState {
    Running,
    /// Exited, awaiting `wait()` by the parent; carries the wait-status exit code.
    Zombie(i32),
    /// Already reaped.
    Reaped,
}

impl ProcState {
    /// Wire: `tag u8 · (exit_code i32 if Zombie)`. Reading is bounds-checked.
    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        match self {
            ProcState::Running => w.write_all(&[0u8]),
            ProcState::Zombie(code) => {
                w.write_all(&[1u8])?;
                w.write_all(&code.to_le_bytes())
            }
            ProcState::Reaped => w.write_all(&[2u8]),
        }
    }
    fn read_from<R: Read>(r: &mut R) -> io::Result<ProcState> {
        Ok(match ru8(r)? {
            0 => ProcState::Running,
            1 => ProcState::Zombie(i32::from_le_bytes(rb::<4, _>(r)?)),
            2 => ProcState::Reaped,
            t => return Err(err(format!("bad ProcState tag {t}"))),
        })
    }
}

// ─── ThreadRegs ────────────────────────────────────────────────────────────

/// One thread's register file: the 18-greg x86_64 GPR set (matching the sentry's
/// captured `[i64; 18]` register layout — see `CAPTURED` in `sentry::mod`), the
/// `%fs` base (TLS pointer, captured alongside as `GUEST_FS`), and the FPU/SIMD
/// `xsave` area (variable length: depends on the host's enabled XSAVE
/// components, hence length-prefixed and capped).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThreadRegs {
    pub vtid: u32,
    pub gregs: [i64; 18],
    pub fs: u64,
    pub xsave: Vec<u8>,
}

impl ThreadRegs {
    /// Wire: `vtid u32 · gregs[18] (i64 LE each) · fs u64 · xsave(blob)`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&self.vtid.to_le_bytes())?;
        for g in &self.gregs {
            w.write_all(&g.to_le_bytes())?;
        }
        w.write_all(&self.fs.to_le_bytes())?;
        wblob(w, &self.xsave)
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<ThreadRegs> {
        let vtid = ru32(r)?;
        let mut gregs = [0i64; 18];
        for g in &mut gregs {
            *g = ri64(r)?;
        }
        let fs = ru64(r)?;
        let xsave = rblob(r, "xsave")?;
        Ok(ThreadRegs {
            vtid,
            gregs,
            fs,
            xsave,
        })
    }
}

// ─── GuestProcRecord ─────────────────────────────────────────────────────────

/// One guest process: its owned [`Vpid`] identity, its parent / process-group /
/// session ids, its threads' register files, and its lifecycle state. **No host
/// pid is stored as identity** — the host pid of the live process is a private
/// runtime detail, re-assigned by the restore logic.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GuestProcRecord {
    pub vpid: Vpid,
    pub parent_vpid: Vpid,
    pub pgid: u32,
    pub sid: u32,
    pub threads: Vec<ThreadRegs>,
    pub state: ProcState,
}

impl GuestProcRecord {
    /// Wire: `vpid u32 · parent_vpid u32 · pgid u32 · sid u32 ·
    /// n_threads u32 · threads[] · ProcState`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&self.vpid.to_le_bytes())?;
        w.write_all(&self.parent_vpid.to_le_bytes())?;
        w.write_all(&self.pgid.to_le_bytes())?;
        w.write_all(&self.sid.to_le_bytes())?;
        w.write_all(&(self.threads.len() as u32).to_le_bytes())?;
        for t in &self.threads {
            t.write_to(w)?;
        }
        self.state.write_to(w)
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<GuestProcRecord> {
        let vpid = ru32(r)?;
        let parent_vpid = ru32(r)?;
        let pgid = ru32(r)?;
        let sid = ru32(r)?;
        // A thread record is at least: vtid(4)+gregs(144)+fs(8)+xsave_len(4) = 160B.
        let (n, hint) = read_count(r, 160)?;
        let mut threads = Vec::with_capacity(hint);
        for _ in 0..n {
            threads.push(ThreadRegs::read_from(r)?);
        }
        let state = ProcState::read_from(r)?;
        Ok(GuestProcRecord {
            vpid,
            parent_vpid,
            pgid,
            sid,
            threads,
            state,
        })
    }
}

// ─── VmaRecord ─────────────────────────────────────────────────────────────

/// One virtual-memory-area record (a line of the guest's `/proc/self/maps`, as
/// the sentry already filters them — see the `b"maps"` arm in `sentry::mod`).
/// Fixed-width: 28 bytes. `file_off` is the file offset for a file-backed
/// mapping (0 for anonymous, which is what the sentry's copied-in guest segments
/// are today). The mapping *contents* live in the RAM blob captured separately
/// (the whole-address-space dump of the design's §5); this record carries only
/// the metadata to re-establish the mapping at the same VA.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VmaRecord {
    pub va_start: u64,
    pub len: u64,
    pub prot: u32,
    pub file_off: u64,
}

impl VmaRecord {
    /// Fixed wire size of one record.
    pub const WIRE_LEN: usize = 28;

    /// Wire: `va_start u64 · len u64 · prot u32 · file_off u64`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&self.va_start.to_le_bytes())?;
        w.write_all(&self.len.to_le_bytes())?;
        w.write_all(&self.prot.to_le_bytes())?;
        w.write_all(&self.file_off.to_le_bytes())?;
        Ok(())
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<VmaRecord> {
        Ok(VmaRecord {
            va_start: ru64(r)?,
            len: ru64(r)?,
            prot: ru32(r)?,
            file_off: ru64(r)?,
        })
    }
}

// ─── FdTarget codec ──────────────────────────────────────────────────────────

impl FdTarget {
    /// Wire: a `tag u8` selecting the variant, then the variant's fields.
    /// `0=LoopSocket 1=RootRelFile 2=HostEgressSocket 3=DevNode 4=Pipe 5=Epoll`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        match self {
            FdTarget::LoopSocket(h) => {
                w.write_all(&[0u8])?;
                w.write_all(&h.0.to_le_bytes())?;
            }
            FdTarget::RootRelFile {
                path,
                offset,
                flags,
            } => {
                w.write_all(&[1u8])?;
                wblob(w, path)?;
                w.write_all(&offset.to_le_bytes())?;
                w.write_all(&flags.to_le_bytes())?;
            }
            FdTarget::HostEgressSocket {
                proto,
                v6,
                local_ip,
                local_port,
                connected,
                remote_ip,
                remote_port,
            } => {
                w.write_all(&[2u8])?;
                w.write_all(&[*proto as u8, *v6 as u8, *connected as u8])?;
                w.write_all(local_ip)?;
                w.write_all(&local_port.to_le_bytes())?;
                w.write_all(remote_ip)?;
                w.write_all(&remote_port.to_le_bytes())?;
            }
            FdTarget::DevNode { kind, flags } => {
                w.write_all(&[3u8, *kind as u8])?;
                w.write_all(&flags.to_le_bytes())?;
            }
            FdTarget::Pipe {
                id,
                end,
                buffered,
                flags,
            } => {
                w.write_all(&[4u8, *end as u8])?;
                w.write_all(&id.to_le_bytes())?;
                wblob(w, buffered)?;
                w.write_all(&flags.to_le_bytes())?;
            }
            FdTarget::Epoll { loop_watches } => {
                w.write_all(&[5u8])?;
                w.write_all(&(loop_watches.len() as u32).to_le_bytes())?;
                for watch in loop_watches {
                    w.write_all(&watch.socket.0.to_le_bytes())?;
                    w.write_all(&watch.events.to_le_bytes())?;
                    w.write_all(&watch.data.to_le_bytes())?;
                }
            }
        }
        Ok(())
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<FdTarget> {
        Ok(match ru8(r)? {
            0 => FdTarget::LoopSocket(SocketHandle(ru32(r)?)),
            1 => FdTarget::RootRelFile {
                path: rblob(r, "fd.path")?,
                offset: ru64(r)?,
                flags: ru32(r)?,
            },
            2 => {
                let proto = EgressProto::from_u8(ru8(r)?).ok_or_else(|| err("bad EgressProto"))?;
                let v6 = ru8(r)? != 0;
                let connected = ru8(r)? != 0;
                let local_ip = rb::<16, _>(r)?;
                let local_port = ru16(r)?;
                let remote_ip = rb::<16, _>(r)?;
                let remote_port = ru16(r)?;
                FdTarget::HostEgressSocket {
                    proto,
                    v6,
                    local_ip,
                    local_port,
                    connected,
                    remote_ip,
                    remote_port,
                }
            }
            3 => FdTarget::DevNode {
                kind: DevKind::from_u8(ru8(r)?).ok_or_else(|| err("bad DevKind"))?,
                flags: ru32(r)?,
            },
            4 => {
                let end = PipeEnd::from_u8(ru8(r)?).ok_or_else(|| err("bad PipeEnd"))?;
                FdTarget::Pipe {
                    id: ru64(r)?,
                    end,
                    buffered: rblob(r, "fd.pipe")?,
                    flags: ru32(r)?,
                }
            }
            5 => {
                let (count, hint) = read_count(r, 16)?;
                let mut loop_watches = Vec::with_capacity(hint);
                for _ in 0..count {
                    loop_watches.push(EpollLoopWatch {
                        socket: SocketHandle(ru32(r)?),
                        events: ru32(r)?,
                        data: ru64(r)?,
                    });
                }
                FdTarget::Epoll { loop_watches }
            }
            t => return Err(err(format!("bad FdTarget tag {t}"))),
        })
    }
}

// ─── Sections ────────────────────────────────────────────────────────────────

/// One guest process's memory image inside the shared `mem.blob`.
///
/// `blob_off` is the page-aligned base offset of this process's dump within
/// `mem.blob`; each [`VmaRecord::file_off`] is relative to that base. This lets
/// one snapshot carry separate address spaces for a whole guest process tree
/// without inventing per-process sidecar files.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ProcMemImage {
    pub vpid: Vpid,
    pub blob_off: u64,
    pub regions: Vec<VmaRecord>,
    pub threads: Vec<ThreadRegs>,
}

impl ProcMemImage {
    /// Wire: `vpid u32 · blob_off u64 · n_regions u32 · regions[] ·
    /// n_threads u32 · threads[]`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&self.vpid.to_le_bytes())?;
        w.write_all(&self.blob_off.to_le_bytes())?;
        w.write_all(&(self.regions.len() as u32).to_le_bytes())?;
        for v in &self.regions {
            v.write_to(w)?;
        }
        w.write_all(&(self.threads.len() as u32).to_le_bytes())?;
        for t in &self.threads {
            t.write_to(w)?;
        }
        Ok(())
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<ProcMemImage> {
        let vpid = ru32(r)?;
        let blob_off = ru64(r)?;
        let (n, hint) = read_count(r, VmaRecord::WIRE_LEN)?;
        let mut regions = Vec::with_capacity(hint);
        for _ in 0..n {
            regions.push(VmaRecord::read_from(r)?);
        }
        let (nt, hint) = read_count(r, 160)?;
        let mut threads = Vec::with_capacity(hint);
        for _ in 0..nt {
            threads.push(ThreadRegs::read_from(r)?);
        }
        Ok(ProcMemImage {
            vpid,
            blob_off,
            regions,
            threads,
        })
    }
}

/// The guest memory image section.
///
/// `procs` is the authoritative C4 shape: one memory/register image per guest
/// vpid. `regions`/`threads` are retained as the vpid-1 compatibility projection
/// used by older inspection tests and by code paths that still reason about a
/// single warm process while the multi-process restore architecture is being
/// completed.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct MemImage {
    pub regions: Vec<VmaRecord>,
    pub threads: Vec<ThreadRegs>,
    pub procs: Vec<ProcMemImage>,
}

impl MemImage {
    /// Wire: `n_regions u32 · regions[] · n_threads u32 · threads[] ·
    /// n_procs u32 · ProcMemImage[]`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&(self.regions.len() as u32).to_le_bytes())?;
        for v in &self.regions {
            v.write_to(w)?;
        }
        w.write_all(&(self.threads.len() as u32).to_le_bytes())?;
        for t in &self.threads {
            t.write_to(w)?;
        }
        w.write_all(&(self.procs.len() as u32).to_le_bytes())?;
        for p in &self.procs {
            p.write_to(w)?;
        }
        Ok(())
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<MemImage> {
        let (n, hint) = read_count(r, VmaRecord::WIRE_LEN)?;
        let mut regions = Vec::with_capacity(hint);
        for _ in 0..n {
            regions.push(VmaRecord::read_from(r)?);
        }
        let (nt, hint) = read_count(r, 160)?;
        let mut threads = Vec::with_capacity(hint);
        for _ in 0..nt {
            threads.push(ThreadRegs::read_from(r)?);
        }
        // A ProcMemImage with no regions/threads is at least vpid(4)+blob_off(8)+
        // n_regions(4)+n_threads(4) = 20 bytes.
        let (np, hint) = read_count(r, 20)?;
        let mut procs = Vec::with_capacity(hint);
        for _ in 0..np {
            procs.push(ProcMemImage::read_from(r)?);
        }
        Ok(MemImage {
            regions,
            threads,
            procs,
        })
    }

    /// Return the per-vpid image when available, falling back to the singleton
    /// projection only for vpid 1. Fresh snapshots should populate `procs`; the
    /// fallback keeps empty/builder snapshots and transitional single-process
    /// callers simple.
    pub fn proc_image(&self, vpid: Vpid) -> Option<ProcMemImageRef<'_>> {
        if let Some(p) = self.procs.iter().find(|p| p.vpid == vpid) {
            return Some(ProcMemImageRef {
                blob_off: p.blob_off,
                regions: &p.regions,
                threads: &p.threads,
            });
        }
        if vpid == 1 && (!self.regions.is_empty() || !self.threads.is_empty()) {
            return Some(ProcMemImageRef {
                blob_off: 0,
                regions: &self.regions,
                threads: &self.threads,
            });
        }
        None
    }
}

pub struct ProcMemImageRef<'a> {
    pub blob_off: u64,
    pub regions: &'a [VmaRecord],
    pub threads: &'a [ThreadRegs],
}

/// The process-tree section.
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct ProcTreeSnapshot {
    pub procs: Vec<GuestProcRecord>,
}

impl ProcTreeSnapshot {
    /// Wire: `n_procs u32 · procs[]`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&(self.procs.len() as u32).to_le_bytes())?;
        for p in &self.procs {
            p.write_to(w)?;
        }
        Ok(())
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<ProcTreeSnapshot> {
        // A proc record's fixed minimum is vpid+parent+pgid+sid (16) +
        // n_threads(4) + ProcState tag(1) = 21 bytes.
        let (n, hint) = read_count(r, 21)?;
        let mut procs = Vec::with_capacity(hint);
        for _ in 0..n {
            procs.push(GuestProcRecord::read_from(r)?);
        }
        Ok(ProcTreeSnapshot { procs })
    }
}

/// The fd-table section: per-vpid lists of `(guest_fd, FdTarget)`. The guest fd
/// is the `i32` the guest sees (the key into its virtualized table); the
/// [`FdTarget`] is the owned value it refers to (never a host fd). This is the
/// SERIALIZABLE shape of what the live sentry holds in `fdt()` keyed by host pid;
/// the host→vpid re-keying that produces these records is a *later* component
/// (this commit defines the wire shape only, it does not touch `fdt()`).
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct FdTableSnapshot {
    pub per_vpid: Vec<(Vpid, Vec<(i32, FdTarget)>)>,
}

impl FdTableSnapshot {
    /// Wire: `n_vpids u32 · per vpid: vpid u32 · n_fds u32 ·
    /// (gfd i32 · FdTarget)[]`.
    pub fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&(self.per_vpid.len() as u32).to_le_bytes())?;
        for (vpid, fds) in &self.per_vpid {
            w.write_all(&vpid.to_le_bytes())?;
            w.write_all(&(fds.len() as u32).to_le_bytes())?;
            for (gfd, target) in fds {
                w.write_all(&gfd.to_le_bytes())?;
                target.write_to(w)?;
            }
        }
        Ok(())
    }
    pub fn read_from<R: Read>(r: &mut R) -> io::Result<FdTableSnapshot> {
        // Each per-vpid entry is at least vpid(4)+n_fds(4) = 8 bytes.
        let (n, hint) = read_count(r, 8)?;
        let mut per_vpid = Vec::with_capacity(hint);
        for _ in 0..n {
            let vpid = ru32(r)?;
            // Each fd entry is at least gfd(4)+tag(1) = 5 bytes.
            let (nf, fhint) = read_count(r, 5)?;
            let mut fds = Vec::with_capacity(fhint);
            for _ in 0..nf {
                let gfd = i32::from_le_bytes(rb::<4, _>(r)?);
                let target = FdTarget::read_from(r)?;
                fds.push((gfd, target));
            }
            per_vpid.push((vpid, fds));
        }
        Ok(FdTableSnapshot { per_vpid })
    }
}

// ─── CpuFeatures ──────────────────────────────────────────────────────────────

/// A baseline of CPU feature bits the snapshot's *native* guest code relies on.
/// This is the **cross-host restore gate**: native code (the whole point of the
/// no-virt model) was JIT'd / compiled assuming these features; restoring it on
/// a host that lacks one would `#UD` (illegal instruction). [`satisfied_by`]
/// refuses such a restore.
///
/// Bit layout (LSB-first, documented per bit — stable on the wire):
///
/// | bit | feature  | meaning                                            |
/// |-----|----------|----------------------------------------------------|
/// | 0   | `sse2`   | baseline SSE2 (always present on x86_64, kept explicit) |
/// | 1   | `avx`    | 256-bit AVX                                        |
/// | 2   | `avx2`   | AVX2 integer                                       |
/// | 3   | `avx512f`| AVX-512 Foundation                                 |
/// | 4   | `fma`    | fused multiply-add                                 |
/// | 5   | `bmi2`   | bit-manipulation 2 (`mulx`/`pdep`/`pext`)          |
///
/// Bits 6..63 are reserved (must be 0) for future feature additions; adding a
/// bit is forward-compatible (an old reader simply never sets it, a new snapshot
/// that sets a reserved bit fails `satisfied_by` on an old host — the safe
/// direction). The numeric value is preserved verbatim across the codec.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CpuFeatures {
    bits: u64,
}

impl CpuFeatures {
    pub const SSE2: u64 = 1 << 0;
    pub const AVX: u64 = 1 << 1;
    pub const AVX2: u64 = 1 << 2;
    pub const AVX512F: u64 = 1 << 3;
    pub const FMA: u64 = 1 << 4;
    pub const BMI2: u64 = 1 << 5;

    /// Construct from a raw bit value (e.g. read back from a snapshot).
    pub fn from_bits(bits: u64) -> CpuFeatures {
        CpuFeatures { bits }
    }

    /// The raw bit value (for inspection / wire diffing).
    pub fn bits(&self) -> u64 {
        self.bits
    }

    /// Detect the baseline feature set of the *current* host using std's
    /// `is_x86_feature_detected!` (runtime CPUID). This is the value stamped
    /// into a snapshot at capture and the value a restoring host compares
    /// against.
    pub fn current() -> CpuFeatures {
        // `is_x86_feature_detected!` exists only on x86/x86_64. The sentry runs
        // x86 guests, so this CPU-feature baseline is meaningful ONLY on an x86
        // production host (where it is the real CPUID detection below). Off-x86
        // (e.g. the arm64 mac/CI box) this module compiles purely so the codec can
        // be unit-tested; report an empty baseline — a value never used to gate a
        // real restore off-x86. Guarding the macro behind a target_arch cfg is
        // exactly what the compiler recommends.
        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
        {
            let mut bits = 0u64;
            if std::is_x86_feature_detected!("sse2") {
                bits |= Self::SSE2;
            }
            if std::is_x86_feature_detected!("avx") {
                bits |= Self::AVX;
            }
            if std::is_x86_feature_detected!("avx2") {
                bits |= Self::AVX2;
            }
            if std::is_x86_feature_detected!("avx512f") {
                bits |= Self::AVX512F;
            }
            if std::is_x86_feature_detected!("fma") {
                bits |= Self::FMA;
            }
            if std::is_x86_feature_detected!("bmi2") {
                bits |= Self::BMI2;
            }
            CpuFeatures { bits }
        }
        #[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
        {
            CpuFeatures { bits: 0 }
        }
    }

    /// The cross-host restore gate: every feature bit the snapshot requires must
    /// also be present on `host`. (`self` is the snapshot's captured baseline;
    /// `host` is `CpuFeatures::current()` on the restoring machine.) A snapshot
    /// requiring AVX-512 cannot restore onto a host without it — its native code
    /// would `#UD`. Extra host features are fine (superset is allowed).
    pub fn satisfied_by(&self, host: &CpuFeatures) -> bool {
        (self.bits & !host.bits) == 0
    }

    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        w.write_all(&self.bits.to_le_bytes())
    }
    fn read_from<R: Read>(r: &mut R) -> io::Result<CpuFeatures> {
        Ok(CpuFeatures { bits: ru64(r)? })
    }
}

// ─── StateSnapshot (the container) ───────────────────────────────────────────

/// Magic bytes identifying a sentry state-snapshot container. (`SMSTSNAP` +
/// version-era byte; distinct from the `SMSNAP*` VM containers in
/// `snapshot_frame`.)
const MAGIC: [u8; 8] = *b"SMSTSNP1";

/// The current on-disk format version. [`StateSnapshot::read_from`] HARD-REFUSES
/// any other version (the "#122" idiom): a version mismatch is an `Err`, never a
/// warning and never a silent best-effort parse — a skewed snapshot must not be
/// half-interpreted into a live, owned kernel.
pub const CURRENT_VERSION: u32 = 7;

/// What the root vpid in this snapshot represents at restore time.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SnapshotEntryKind {
    /// A parked SENTINEL checkpoint that should become a warm zygote/template.
    WarmZygote,
    /// A checked-out, already-running VM/process tree that should be handed out
    /// directly on the next acquire, matching KVM/HVF live snapshot semantics.
    LiveTree,
}

impl SnapshotEntryKind {
    fn write_to<W: Write>(&self, w: &mut W) -> io::Result<()> {
        let tag = match self {
            SnapshotEntryKind::WarmZygote => 0u8,
            SnapshotEntryKind::LiveTree => 1u8,
        };
        w.write_all(&[tag])
    }

    fn read_from<R: Read>(r: &mut R) -> io::Result<SnapshotEntryKind> {
        let mut tag = [0u8; 1];
        r.read_exact(&mut tag)?;
        match tag[0] {
            0 => Ok(SnapshotEntryKind::WarmZygote),
            1 => Ok(SnapshotEntryKind::LiveTree),
            other => Err(err(format!("invalid snapshot entry-kind tag {other}"))),
        }
    }
}

/// The full owned-guest-state snapshot container: the versioned, little-endian,
/// bounds-checked spine. Holds every piece of owned guest state needed to
/// restore the world (memory metadata + CPU regs, process tree, owned netstack,
/// fd tables, and per-process cwds), gated on a CPU-feature baseline so native
/// guest code is only restored onto a host that can run it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StateSnapshot {
    pub version: u32,
    pub cpu_feature_baseline: CpuFeatures,
    pub entry_kind: SnapshotEntryKind,
    pub mem: MemImage,
    pub proctree: ProcTreeSnapshot,
    /// The owned-loopback netstack, serialized via its own codec
    /// ([`super::netstack::LoopSnapshot`]). This is the model that is actually
    /// wired live (`loop_state().net` is a byte-relay `LoopNet`, not a smoltcp
    /// stack); the earlier smoltcp-shaped `NetstackSnapshot`/`TcbRecord` was never
    /// populated from any live netstack and has been removed (C4.0).
    pub netstack: super::netstack::LoopSnapshot,
    pub fdtable: FdTableSnapshot,
    /// Per-process guest cwd (vpid → guest-absolute path bytes). Mirrors the
    /// sentry's `cwds()` map but keyed by [`Vpid`], not host pid.
    pub cwds: Vec<(Vpid, Vec<u8>)>,
    /// Per-process guest argv bytes in `/proc/<pid>/cmdline` wire format.
    pub cmdlines: Vec<(Vpid, Vec<u8>)>,
    /// Per-process guest `/proc/<pid>/exe` target path bytes.
    pub exes: Vec<(Vpid, Vec<u8>)>,
}

impl StateSnapshot {
    /// Serialize the container. Layout (little-endian):
    ///
    /// ```text
    ///   MAGIC[8] · version u32 · cpu_feature_baseline u64 · entry_kind u8
    ///   mem      (MemImage codec)
    ///   proctree (ProcTreeSnapshot codec)
    ///   netstack (super::netstack::LoopSnapshot codec)
    ///   fdtable  (FdTableSnapshot codec)
    ///   cwds:     n u32 · per: vpid u32 · path(blob)
    ///   cmdlines: n u32 · per: vpid u32 · cmdline(blob)
    ///   exes:     n u32 · per: vpid u32 · exe(blob)
    /// ```
    ///
    /// The sections are ordered and self-delimiting; `version` is written as the
    /// caller's `self.version` (capture sets it to [`CURRENT_VERSION`]).
    pub fn write_to(&self, w: &mut impl Write) -> io::Result<()> {
        w.write_all(&MAGIC)?;
        w.write_all(&self.version.to_le_bytes())?;
        self.cpu_feature_baseline.write_to(w)?;
        self.entry_kind.write_to(w)?;
        self.mem.write_to(w)?;
        self.proctree.write_to(w)?;
        self.netstack.write_to(w)?;
        self.fdtable.write_to(w)?;
        w.write_all(&(self.cwds.len() as u32).to_le_bytes())?;
        for (vpid, path) in &self.cwds {
            w.write_all(&vpid.to_le_bytes())?;
            wblob(w, path)?;
        }
        w.write_all(&(self.cmdlines.len() as u32).to_le_bytes())?;
        for (vpid, cmdline) in &self.cmdlines {
            w.write_all(&vpid.to_le_bytes())?;
            wblob(w, cmdline)?;
        }
        w.write_all(&(self.exes.len() as u32).to_le_bytes())?;
        for (vpid, exe) in &self.exes {
            w.write_all(&vpid.to_le_bytes())?;
            wblob(w, exe)?;
        }
        Ok(())
    }

    /// Inverse of [`write_to`](Self::write_to). HARD-REFUSES (returns `Err`,
    /// never a panic and never a silent accept) on:
    ///   * a magic-byte mismatch (not our container), and
    ///   * `version != `[`CURRENT_VERSION`] (the "#122" version-skew refusal).
    /// Every section count is bounded via [`cap_count`] before pre-allocation
    /// and every `Vec<u8>` length is capped to [`BLOB_MAX`], so a corrupt/hostile
    /// header cannot OOM-abort the host before the per-record `read_exact` bounds
    /// checks run.
    pub fn read_from(r: &mut impl Read) -> io::Result<StateSnapshot> {
        let magic = rb::<8, _>(r)?;
        if magic != MAGIC {
            return Err(err(format!(
                "state-snapshot magic mismatch: got {magic:02x?}, expected {MAGIC:02x?}"
            )));
        }
        let version = ru32(r)?;
        if version != CURRENT_VERSION {
            return Err(err(format!(
                "state-snapshot version {version} != supported {CURRENT_VERSION} \
                 (hard refusal — no best-effort parse across a version skew)"
            )));
        }
        let cpu_feature_baseline = CpuFeatures::read_from(r)?;
        let entry_kind = SnapshotEntryKind::read_from(r)?;
        let mem = MemImage::read_from(r)?;
        let proctree = ProcTreeSnapshot::read_from(r)?;
        let netstack = super::netstack::LoopSnapshot::read_from(r)?;
        let fdtable = FdTableSnapshot::read_from(r)?;
        // Each cwd entry is at least vpid(4)+path_len(4) = 8 bytes.
        let (n, hint) = read_count(r, 8)?;
        let mut cwds = Vec::with_capacity(hint);
        for _ in 0..n {
            let vpid = ru32(r)?;
            let path = rblob(r, "cwd")?;
            cwds.push((vpid, path));
        }
        let (n, hint) = read_count(r, 8)?;
        let mut cmdlines = Vec::with_capacity(hint);
        for _ in 0..n {
            let vpid = ru32(r)?;
            let cmdline = rblob(r, "cmdline")?;
            cmdlines.push((vpid, cmdline));
        }
        let (n, hint) = read_count(r, 8)?;
        let mut exes = Vec::with_capacity(hint);
        for _ in 0..n {
            let vpid = ru32(r)?;
            let exe = rblob(r, "exe")?;
            exes.push((vpid, exe));
        }
        Ok(StateSnapshot {
            version,
            cpu_feature_baseline,
            entry_kind,
            mem,
            proctree,
            netstack,
            fdtable,
            cwds,
            cmdlines,
            exes,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    // ── builders for non-trivial sample values ──────────────────────────────

    fn sample_thread(vtid: u32) -> ThreadRegs {
        let mut gregs = [0i64; 18];
        for (i, g) in gregs.iter_mut().enumerate() {
            *g = (i as i64) * 0x1111 - 3; // include a negative to exercise i64 sign
        }
        ThreadRegs {
            vtid,
            gregs,
            fs: 0x7f00_0000_1234,
            xsave: (0..512u32).map(|i| i as u8).collect(),
        }
    }

    fn sample_proc(vpid: Vpid, state: ProcState, nthreads: u32) -> GuestProcRecord {
        GuestProcRecord {
            vpid,
            parent_vpid: vpid.saturating_sub(1),
            pgid: vpid + 100,
            sid: vpid + 200,
            threads: (0..nthreads)
                .map(|t| sample_thread(vpid * 10 + t))
                .collect(),
            state,
        }
    }

    // A representative owned-loopback netstack: one established loopback
    // connection (peered sockets) + in-flight rx bytes + a distinct next_id /
    // ephemeral cursor, so the round-trip exercises the real `LoopSnapshot` codec
    // (the model actually wired live), not the vestigial smoltcp one.
    fn sample_loop_netstack() -> crate::sentry_portable::netstack::LoopSnapshot {
        use crate::sentry_portable::netstack::{Endpoint, LoopSnapshot, SockRecord, SockState};
        let lo = |port: u16| Endpoint {
            v6: false,
            ip: {
                let mut ip = [0u8; 16];
                ip[..4].copy_from_slice(&[127, 0, 0, 1]);
                ip
            },
            port,
        };
        LoopSnapshot {
            next_id: 3,
            next_ephemeral: 49160,
            socks: vec![
                SockRecord {
                    id: 1,
                    state: SockState::Established,
                    local: Some(lo(5432)),
                    remote: Some(lo(49152)),
                    peer: Some(2),
                    rx: vec![0xde, 0xad, 0xbe, 0xef],
                    rd_shut: false,
                    peer_wr_shut: false,
                    backlog: vec![],
                },
                SockRecord {
                    id: 2,
                    state: SockState::Established,
                    local: Some(lo(49152)),
                    remote: Some(lo(5432)),
                    peer: Some(1),
                    rx: vec![0x01, 0x02],
                    rd_shut: false,
                    peer_wr_shut: false,
                    backlog: vec![],
                },
            ],
        }
    }

    fn sample_snapshot() -> StateSnapshot {
        let regions = vec![
            VmaRecord {
                va_start: 0x40_0000,
                len: 0x2000,
                prot: 0b101, // r-x
                file_off: 0,
            },
            VmaRecord {
                va_start: 0x42_0000,
                len: 0x1000,
                prot: 0b011, // rw-
                file_off: 0x2000,
            },
        ];
        let thread = sample_thread(1);
        StateSnapshot {
            version: CURRENT_VERSION,
            cpu_feature_baseline: CpuFeatures::from_bits(
                CpuFeatures::SSE2 | CpuFeatures::AVX | CpuFeatures::AVX2,
            ),
            entry_kind: SnapshotEntryKind::LiveTree,
            mem: MemImage {
                regions: regions.clone(),
                threads: vec![thread.clone()],
                procs: vec![
                    ProcMemImage {
                        vpid: 1,
                        blob_off: 0,
                        regions: regions.clone(),
                        threads: vec![thread],
                    },
                    ProcMemImage {
                        vpid: 2,
                        blob_off: 0x8000,
                        regions: vec![VmaRecord {
                            va_start: 0x50_0000,
                            len: 0x1000,
                            prot: 0b011,
                            file_off: 0,
                        }],
                        threads: vec![sample_thread(20)],
                    },
                ],
            },
            proctree: ProcTreeSnapshot {
                procs: vec![
                    sample_proc(1, ProcState::Running, 2),
                    sample_proc(2, ProcState::Zombie(-9), 1),
                    sample_proc(3, ProcState::Reaped, 0),
                ],
            },
            netstack: sample_loop_netstack(),
            fdtable: FdTableSnapshot {
                per_vpid: vec![
                    (
                        1,
                        vec![
                            (
                                0,
                                FdTarget::DevNode {
                                    kind: DevKind::Null,
                                    flags: 0,
                                },
                            ),
                            (
                                1,
                                FdTarget::RootRelFile {
                                    path: b"/var/log/app.log".to_vec(),
                                    offset: 4096,
                                    flags: 0o2001, // O_WRONLY|O_APPEND
                                },
                            ),
                            (3, FdTarget::LoopSocket(SocketHandle(42))),
                            (
                                8,
                                FdTarget::Epoll {
                                    loop_watches: vec![EpollLoopWatch {
                                        socket: SocketHandle(42),
                                        events: 1,
                                        data: 99,
                                    }],
                                },
                            ),
                        ],
                    ),
                    (
                        2,
                        vec![
                            (
                                4,
                                FdTarget::Pipe {
                                    id: 0xfeed,
                                    end: PipeEnd::Read,
                                    buffered: vec![1, 2, 3, 4, 5],
                                    flags: 0,
                                },
                            ),
                            (
                                5,
                                FdTarget::HostEgressSocket {
                                    proto: EgressProto::Tcp,
                                    v6: false,
                                    local_ip: {
                                        let mut a = [0u8; 16];
                                        a[..4].copy_from_slice(&[10, 0, 0, 2]);
                                        a
                                    },
                                    local_port: 33000,
                                    connected: true,
                                    remote_ip: {
                                        let mut a = [0u8; 16];
                                        a[..4].copy_from_slice(&[93, 184, 216, 34]);
                                        a
                                    },
                                    remote_port: 443,
                                },
                            ),
                        ],
                    ),
                ],
            },
            cwds: vec![
                (1, b"/".to_vec()),
                (2, b"/srv/app".to_vec()),
                (3, b"/tmp".to_vec()),
            ],
            cmdlines: vec![
                (1, b"app\0serve\0".to_vec()),
                (2, b"worker\0".to_vec()),
                (3, b"helper\0".to_vec()),
            ],
            exes: vec![
                (1, b"/usr/bin/app".to_vec()),
                (2, b"/bin/busybox".to_vec()),
                (3, b"/bin/sh".to_vec()),
            ],
        }
    }

    // ── round-trip helper ────────────────────────────────────────────────────
    //
    // A `macro_rules!` rather than a generic fn: passing a codec's generic
    // `Type::read_from` as a higher-ranked closure bound
    // `for<'a> Fn(&'a mut Cursor<&'a Vec<u8>>)` is not expressible — a function
    // item is instantiated at ONE concrete reader lifetime, so it is "not general
    // enough" for the `for<'a>` bound (rustc E: "implementation of `Fn` is not
    // general enough"). Expanding inline calls `write_to`/`read_from` on concrete
    // types and sidesteps the HRTB entirely. Asserts the value round-trips and
    // that the reader consumed exactly what the writer wrote (no trailing slack
    // that would desync a following section).
    macro_rules! assert_round_trip {
        ($val:expr, $ty:ty) => {{
            let val = $val;
            let mut buf = Vec::new();
            val.write_to(&mut buf).expect("write_to");
            let mut cur = std::io::Cursor::new(&buf[..]);
            let back = <$ty>::read_from(&mut cur).expect("read_from");
            assert_eq!(
                cur.position() as usize,
                buf.len(),
                "trailing slack for {}",
                stringify!($ty)
            );
            assert_eq!(val, back, "round-trip mismatch for {}", stringify!($ty));
        }};
    }

    #[test]
    fn thread_regs_round_trip() {
        assert_round_trip!(sample_thread(7), ThreadRegs);
    }

    #[test]
    fn guest_proc_record_round_trip() {
        for st in [
            ProcState::Running,
            ProcState::Zombie(137),
            ProcState::Reaped,
        ] {
            assert_round_trip!(sample_proc(5, st, 3), GuestProcRecord);
        }
    }

    #[test]
    fn vma_record_round_trip_and_fixed_width() {
        let v = VmaRecord {
            va_start: 0xdead_beef,
            len: 0x1_0000,
            prot: 7,
            file_off: 0x4000,
        };
        let mut buf = Vec::new();
        v.write_to(&mut buf).unwrap();
        assert_eq!(buf.len(), VmaRecord::WIRE_LEN, "VmaRecord fixed wire width");
        assert_round_trip!(v, VmaRecord);
    }

    #[test]
    fn fd_target_round_trip_every_variant() {
        let variants = [
            FdTarget::LoopSocket(SocketHandle(0xabcd)),
            FdTarget::RootRelFile {
                path: b"/etc/hosts".to_vec(),
                offset: 1234,
                flags: 0,
            },
            FdTarget::HostEgressSocket {
                proto: EgressProto::Udp,
                v6: true,
                local_ip: [1; 16],
                local_port: 7,
                connected: false,
                remote_ip: [0; 16],
                remote_port: 0,
            },
            FdTarget::DevNode {
                kind: DevKind::Random,
                flags: 0o4000,
            },
            FdTarget::Pipe {
                id: 99,
                end: PipeEnd::Write,
                buffered: vec![9, 8, 7],
                flags: 0o4001,
            },
            FdTarget::Epoll {
                loop_watches: vec![EpollLoopWatch {
                    socket: SocketHandle(0xabcd),
                    events: 0x001,
                    data: 0xfeed_beef,
                }],
            },
        ];
        for v in variants {
            assert_round_trip!(v, FdTarget);
        }
    }

    #[test]
    fn proc_mem_image_round_trip() {
        let image = sample_snapshot().mem.procs[1].clone();
        assert_round_trip!(image, ProcMemImage);
    }

    #[test]
    fn mem_image_round_trip() {
        assert_round_trip!(sample_snapshot().mem, MemImage);
    }

    #[test]
    fn mem_image_looks_up_per_vpid_records() {
        let mem = sample_snapshot().mem;
        let root = mem.proc_image(1).expect("vpid 1 image");
        assert_eq!(root.blob_off, 0);
        assert_eq!(root.regions, mem.regions.as_slice());
        assert_eq!(root.threads, mem.threads.as_slice());

        let child = mem.proc_image(2).expect("vpid 2 image");
        assert_eq!(child.blob_off, 0x8000);
        assert_eq!(child.regions[0].va_start, 0x50_0000);

        assert!(mem.proc_image(99).is_none());

        let legacy = MemImage {
            regions: mem.regions.clone(),
            threads: mem.threads.clone(),
            procs: Vec::new(),
        };
        assert!(
            legacy.proc_image(1).is_some(),
            "vpid 1 falls back to the singleton projection"
        );
        assert!(legacy.proc_image(2).is_none());
    }

    #[test]
    fn proctree_round_trip() {
        assert_round_trip!(sample_snapshot().proctree, ProcTreeSnapshot);
    }

    #[test]
    fn netstack_round_trip() {
        assert_round_trip!(
            sample_snapshot().netstack,
            crate::sentry_portable::netstack::LoopSnapshot
        );
    }

    #[test]
    fn fdtable_round_trip() {
        assert_round_trip!(sample_snapshot().fdtable, FdTableSnapshot);
    }

    #[test]
    fn full_state_snapshot_round_trips() {
        let snap = sample_snapshot();
        let mut buf = Vec::new();
        snap.write_to(&mut buf).unwrap();
        let mut cur = Cursor::new(&buf);
        let back = StateSnapshot::read_from(&mut cur).unwrap();
        assert_eq!(cur.position() as usize, buf.len(), "trailing slack");
        assert_eq!(snap, back);
    }

    // ── CpuFeatures gate ─────────────────────────────────────────────────────

    #[test]
    fn cpu_features_satisfied_by_superset_only() {
        let want = CpuFeatures::from_bits(CpuFeatures::SSE2 | CpuFeatures::AVX2);
        let host_super =
            CpuFeatures::from_bits(CpuFeatures::SSE2 | CpuFeatures::AVX | CpuFeatures::AVX2);
        let host_missing = CpuFeatures::from_bits(CpuFeatures::SSE2); // no AVX2
        assert!(want.satisfied_by(&host_super), "superset host satisfies");
        assert!(want.satisfied_by(&want), "equal host satisfies");
        assert!(
            !want.satisfied_by(&host_missing),
            "host missing AVX2 must NOT satisfy an AVX2 snapshot"
        );
        // current() runs CPUID without panicking and always reports >= sse2 on x86.
        // Off-x86 (mac/CI) current() is an empty baseline by construction — the
        // sentry runs x86 guests, so the baseline only gates x86 restores — so this
        // live host-feature assertion is x86-only; the pure satisfied_by logic above
        // is exercised on every host.
        #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
        {
            let cur = CpuFeatures::current();
            assert!(
                cur.bits() & CpuFeatures::SSE2 != 0,
                "x86_64 baseline has sse2"
            );
        }
    }

    // ── golden-fixture byte-stability ────────────────────────────────────────

    /// A small, fully-deterministic snapshot whose serialization is asserted
    /// byte-for-byte stable. Any wire-format drift (field reorder, width change,
    /// added/removed byte) flips these bytes and fails — the contract-everything
    /// backstop. The expected vector below was produced by this exact builder;
    /// it is intentionally hand-checkable (no large blobs).
    fn golden_snapshot() -> StateSnapshot {
        let regions = vec![VmaRecord {
            va_start: 0x400000,
            len: 0x1000,
            prot: 5,
            file_off: 0,
        }];
        StateSnapshot {
            version: CURRENT_VERSION,
            cpu_feature_baseline: CpuFeatures::from_bits(CpuFeatures::SSE2),
            entry_kind: SnapshotEntryKind::WarmZygote,
            mem: MemImage {
                regions: regions.clone(),
                threads: vec![],
                procs: vec![ProcMemImage {
                    vpid: 1,
                    blob_off: 0,
                    regions,
                    threads: vec![],
                }],
            },
            proctree: ProcTreeSnapshot {
                procs: vec![GuestProcRecord {
                    vpid: 1,
                    parent_vpid: 0,
                    pgid: 1,
                    sid: 1,
                    threads: vec![],
                    state: ProcState::Running,
                }],
            },
            netstack: crate::sentry_portable::netstack::LoopSnapshot::default(),
            fdtable: FdTableSnapshot {
                per_vpid: vec![(
                    1,
                    vec![(
                        0,
                        FdTarget::DevNode {
                            kind: DevKind::Zero,
                            flags: 0,
                        },
                    )],
                )],
            },
            cwds: vec![(1, b"/".to_vec())],
            cmdlines: vec![(1, b"init\0".to_vec())],
            exes: vec![(1, b"/sbin/init".to_vec())],
        }
    }

    #[test]
    fn golden_fixture_is_byte_stable() {
        let mut buf = Vec::new();
        golden_snapshot().write_to(&mut buf).unwrap();

        let expected: Vec<u8> = vec![
            83, 77, 83, 84, 83, 78, 80, 49, 7, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0,
            0, 64, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
            0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 64, 0, 0, 0,
            0, 0, 0, 16, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0,
            0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 83, 77, 76, 79, 79,
            80, 48, 49, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0,
            0, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 47, 1, 0, 0, 0, 1,
            0, 0, 0, 5, 0, 0, 0, 105, 110, 105, 116, 0, 1, 0, 0, 0, 1, 0, 0, 0, 10, 0, 0, 0, 47,
            115, 98, 105, 110, 47, 105, 110, 105, 116,
        ];

        assert_eq!(
            buf, expected,
            "wire format drifted (or golden builder changed) — \
             update the fixture deliberately, never blindly"
        );
        // And the golden bytes still parse back to the original value.
        let back = StateSnapshot::read_from(&mut Cursor::new(&expected)).unwrap();
        assert_eq!(golden_snapshot(), back);
    }

    // ── version-skew HARD refusal (#122 idiom) ───────────────────────────────

    #[test]
    fn version_skew_is_hard_refused() {
        let mut buf = Vec::new();
        sample_snapshot().write_to(&mut buf).unwrap();
        // Corrupt the version field (bytes 8..12, right after the 8-byte magic).
        buf[8] = 0xff;
        buf[9] = 0xff;
        let e = StateSnapshot::read_from(&mut Cursor::new(&buf)).unwrap_err();
        assert_eq!(e.kind(), io::ErrorKind::InvalidData);
        assert!(
            e.to_string().contains("version"),
            "error should name the version skew: {e}"
        );
    }

    #[test]
    fn bad_magic_is_refused() {
        let mut buf = Vec::new();
        sample_snapshot().write_to(&mut buf).unwrap();
        buf[0] ^= 0xff; // mangle first magic byte
        let e = StateSnapshot::read_from(&mut Cursor::new(&buf)).unwrap_err();
        assert_eq!(e.kind(), io::ErrorKind::InvalidData);
        assert!(
            e.to_string().contains("magic"),
            "error should name magic: {e}"
        );
    }

    // ── anti-OOM count/length guards ─────────────────────────────────────────

    #[test]
    fn hostile_blob_length_is_refused_not_allocated() {
        // A ThreadRegs whose xsave length claims > BLOB_MAX must be rejected
        // before the vec![0; n] allocation (the read would fail anyway, but the
        // cap stops a pre-emptive multi-GiB allocation / OOM-abort).
        let mut buf = Vec::new();
        buf.extend_from_slice(&7u32.to_le_bytes()); // vtid
        buf.extend_from_slice(&[0u8; 18 * 8]); // gregs
        buf.extend_from_slice(&0u64.to_le_bytes()); // fs
        buf.extend_from_slice(&u32::MAX.to_le_bytes()); // xsave len = 4 GiB
        let e = ThreadRegs::read_from(&mut Cursor::new(&buf)).unwrap_err();
        assert_eq!(e.kind(), io::ErrorKind::InvalidData);
    }

    #[test]
    fn hostile_count_does_not_overallocate() {
        // A proctree claiming u32::MAX procs must not pre-allocate billions of
        // records: read_count caps the with_capacity hint, and the loop then
        // fails cleanly at the first read_exact past EOF (no OOM, an Err).
        let mut buf = Vec::new();
        buf.extend_from_slice(&u32::MAX.to_le_bytes()); // n_procs = ~4.29e9
                                                        // …no records follow.
        let e = ProcTreeSnapshot::read_from(&mut Cursor::new(&buf)).unwrap_err();
        // Truncation surfaces as UnexpectedEof from read_exact — an Err, not OOM,
        // not a panic.
        assert!(
            matches!(
                e.kind(),
                io::ErrorKind::UnexpectedEof | io::ErrorKind::InvalidData
            ),
            "unexpected kind {:?}",
            e.kind()
        );
    }

    #[test]
    fn truncated_full_snapshot_errors_at_every_prefix() {
        let mut buf = Vec::new();
        sample_snapshot().write_to(&mut buf).unwrap();
        for cut in 0..buf.len() {
            let r = StateSnapshot::read_from(&mut Cursor::new(&buf[..cut].to_vec()));
            assert!(r.is_err(), "prefix len {cut} should error, not parse");
        }
    }
}
