Gunnar

nordisk/heimdall

crates/heimdall-core/src/codec.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! The canonical byte encoding of a [`JobResult`], version 1.
//!
//! This is the exact byte string that is signed, so it must be deterministic:
//! one value has exactly one encoding, and the decoder accepts nothing else
//! (no trailing bytes, no unknown tags, no non-UTF-8 text, no lengths beyond
//! the input). Integers are big-endian; a string or byte string is a `u32`
//! length followed by its bytes; a list is a `u32` count followed by its items.
//!
//! ```text
//! JobResult v1 =
//!   job_id[16] nonce[32]
//!   verdict: u8 tag (0 = Accepted, 1 = Refused) [code: str, reason: str if 1]
//!   outputs: u32 n, n × (name: str, sha256[32], bytes: u64)
//!   exit:    code: opt i32, signal: opt i32,
//!            killed: u8 (0 none, 1 Wall, 2 Memory, 3 Pids, 4 Fuel)
//!   started_unix: i64  finished_unix: i64
//!   runner:  runner: str, container: bool,
//!            seccomp: u8 (0 None, 1 Default, 2 Generated [profile_digest: str]),
//!            landlock: bool, broker: bool, wasm: bool,
//!            image_verified_each_start: bool
//!   events:  u32 n, n × (at_ms: u64, call: str, args: u32 m × str,
//!                        decision: u8 (0 Allowed, 1 Refused, 2 Killed))
//!
//! opt T = u8 (0 = absent | 1 = present, then T); bool = u8 0 or 1 (nothing
//! else decodes); i32 and i64 are two's complement, big-endian.
//! ```
//!
//! No float is encoded: `Limits::cpus` (an `f32`) is never part of a result.

use crate::types::*;

/// Largest encoded result accepted by the decoder (64 MiB).
pub const MAX_ENCODED_RESULT: usize = 64 * 1024 * 1024;

/// Why bytes are not a canonical encoding.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum DecodeError {
    #[error("input is larger than {max} bytes")]
    TooLarge { max: usize },
    #[error("bad magic: not a Heimdall signed result")]
    BadMagic,
    #[error("unsupported format version {0}")]
    UnsupportedVersion(u16),
    #[error("truncated input while reading {what}")]
    Truncated { what: &'static str },
    #[error("{count} trailing bytes after the end")]
    TrailingBytes { count: usize },
    #[error("unknown tag {tag} for {what}")]
    BadTag { what: &'static str, tag: u8 },
    #[error("text field {what} is not UTF-8")]
    BadUtf8 { what: &'static str },
    #[error("declared result length does not match the input")]
    LengthMismatch,
}

pub(crate) struct Writer {
    buf: Vec<u8>,
}

impl Writer {
    pub(crate) fn new() -> Self {
        Self { buf: Vec::new() }
    }
    pub(crate) fn into_inner(self) -> Vec<u8> {
        self.buf
    }
    pub(crate) fn raw(&mut self, b: &[u8]) {
        self.buf.extend_from_slice(b);
    }
    pub(crate) fn u8(&mut self, v: u8) {
        self.buf.push(v);
    }
    pub(crate) fn u16(&mut self, v: u16) {
        self.raw(&v.to_be_bytes());
    }
    pub(crate) fn u32(&mut self, v: u32) {
        self.raw(&v.to_be_bytes());
    }
    pub(crate) fn u64(&mut self, v: u64) {
        self.raw(&v.to_be_bytes());
    }
    pub(crate) fn i32(&mut self, v: i32) {
        self.raw(&v.to_be_bytes());
    }
    pub(crate) fn i64(&mut self, v: i64) {
        self.raw(&v.to_be_bytes());
    }
    pub(crate) fn bool(&mut self, v: bool) {
        self.u8(u8::from(v));
    }
    pub(crate) fn opt_i32(&mut self, v: Option<i32>) {
        match v {
            None => self.u8(0),
            Some(x) => {
                self.u8(1);
                self.i32(x);
            }
        }
    }
    pub(crate) fn len(&mut self, n: usize) {
        self.u32(u32::try_from(n).expect("field longer than u32::MAX"));
    }
    pub(crate) fn str(&mut self, s: &str) {
        self.len(s.len());
        self.raw(s.as_bytes());
    }
}

pub(crate) struct Reader<'a> {
    buf: &'a [u8],
}

impl<'a> Reader<'a> {
    pub(crate) fn new(buf: &'a [u8]) -> Self {
        Self { buf }
    }
    pub(crate) fn remaining(&self) -> usize {
        self.buf.len()
    }
    pub(crate) fn raw(&mut self, n: usize, what: &'static str) -> Result<&'a [u8], DecodeError> {
        if self.buf.len() < n {
            return Err(DecodeError::Truncated { what });
        }
        let (h, t) = self.buf.split_at(n);
        self.buf = t;
        Ok(h)
    }
    pub(crate) fn array<const N: usize>(
        &mut self,
        what: &'static str,
    ) -> Result<[u8; N], DecodeError> {
        Ok(self.raw(N, what)?.try_into().expect("length checked"))
    }
    pub(crate) fn u8(&mut self, what: &'static str) -> Result<u8, DecodeError> {
        Ok(self.raw(1, what)?[0])
    }
    pub(crate) fn u16(&mut self, what: &'static str) -> Result<u16, DecodeError> {
        Ok(u16::from_be_bytes(self.array(what)?))
    }
    pub(crate) fn u32(&mut self, what: &'static str) -> Result<u32, DecodeError> {
        Ok(u32::from_be_bytes(self.array(what)?))
    }
    pub(crate) fn u64(&mut self, what: &'static str) -> Result<u64, DecodeError> {
        Ok(u64::from_be_bytes(self.array(what)?))
    }
    pub(crate) fn i32(&mut self, what: &'static str) -> Result<i32, DecodeError> {
        Ok(i32::from_be_bytes(self.array(what)?))
    }
    pub(crate) fn i64(&mut self, what: &'static str) -> Result<i64, DecodeError> {
        Ok(i64::from_be_bytes(self.array(what)?))
    }
    pub(crate) fn bool(&mut self, what: &'static str) -> Result<bool, DecodeError> {
        match self.u8(what)? {
            0 => Ok(false),
            1 => Ok(true),
            tag => Err(DecodeError::BadTag { what, tag }),
        }
    }
    pub(crate) fn opt_i32(&mut self, what: &'static str) -> Result<Option<i32>, DecodeError> {
        if self.bool(what)? {
            Ok(Some(self.i32(what)?))
        } else {
            Ok(None)
        }
    }
    /// A list count, refused when `count * min_item` cannot fit in what is left
    /// (so a forged huge count never drives a huge allocation).
    pub(crate) fn count(
        &mut self,
        min_item: usize,
        what: &'static str,
    ) -> Result<usize, DecodeError> {
        let n = self.u32(what)? as usize;
        if n.saturating_mul(min_item) > self.remaining() {
            return Err(DecodeError::Truncated { what });
        }
        Ok(n)
    }
    pub(crate) fn str(&mut self, what: &'static str) -> Result<String, DecodeError> {
        let n = self.u32(what)? as usize;
        let b = self.raw(n, what)?;
        String::from_utf8(b.to_vec()).map_err(|_| DecodeError::BadUtf8 { what })
    }
    pub(crate) fn finish(self) -> Result<(), DecodeError> {
        if self.buf.is_empty() {
            Ok(())
        } else {
            Err(DecodeError::TrailingBytes {
                count: self.buf.len(),
            })
        }
    }
}

/// Smallest encoded output entry: name length + sha256 + bytes.
const MIN_OUTPUT: usize = 4 + 32 + 8;
/// Smallest encoded event: at_ms + call length + args count + decision.
const MIN_EVENT: usize = 8 + 4 + 4 + 1;
/// Smallest encoded string: its length.
const MIN_STR: usize = 4;

pub(crate) fn write_result(w: &mut Writer, r: &JobResult) {
    w.raw(&r.job_id.0);
    w.raw(&r.nonce.0);
    match &r.verdict {
        Verdict::Accepted => w.u8(0),
        Verdict::Refused { code, reason } => {
            w.u8(1);
            w.str(code);
            w.str(reason);
        }
    }
    w.len(r.outputs.len());
    for o in &r.outputs {
        w.str(&o.name);
        w.raw(&o.sha256.0);
        w.u64(o.bytes);
    }
    w.opt_i32(r.exit.code);
    w.opt_i32(r.exit.signal);
    w.u8(match r.exit.killed {
        None => 0,
        Some(KillReason::Wall) => 1,
        Some(KillReason::Memory) => 2,
        Some(KillReason::Pids) => 3,
        Some(KillReason::Fuel) => 4,
    });
    w.i64(r.started_unix);
    w.i64(r.finished_unix);
    let f = &r.runner;
    w.str(&f.runner);
    w.bool(f.container);
    match &f.seccomp {
        Protection::None => w.u8(0),
        Protection::Default => w.u8(1),
        Protection::Generated { profile_digest } => {
            w.u8(2);
            w.str(profile_digest);
        }
    }
    w.bool(f.landlock);
    w.bool(f.broker);
    w.bool(f.wasm);
    w.bool(f.image_verified_each_start);
    w.len(r.events.len());
    for e in &r.events {
        w.u64(e.at_ms);
        w.str(&e.call);
        w.len(e.args.len());
        for a in &e.args {
            w.str(a);
        }
        w.u8(match e.decision {
            Decision::Allowed => 0,
            Decision::Refused => 1,
            Decision::Killed => 2,
        });
    }
}

pub(crate) fn read_result(rd: &mut Reader<'_>) -> Result<JobResult, DecodeError> {
    let job_id = JobId(rd.array("job_id")?);
    let nonce = Nonce(rd.array("nonce")?);
    let verdict = match rd.u8("verdict")? {
        0 => Verdict::Accepted,
        1 => Verdict::Refused {
            code: rd.str("code")?,
            reason: rd.str("reason")?,
        },
        tag => {
            return Err(DecodeError::BadTag {
                what: "verdict",
                tag,
            })
        }
    };
    let n = rd.count(MIN_OUTPUT, "outputs")?;
    let mut outputs = Vec::with_capacity(n);
    for _ in 0..n {
        outputs.push(Output {
            name: rd.str("output name")?,
            sha256: Sha256Digest(rd.array("output sha256")?),
            bytes: rd.u64("output bytes")?,
        });
    }
    let code = rd.opt_i32("exit code")?;
    let signal = rd.opt_i32("exit signal")?;
    let killed = match rd.u8("killed")? {
        0 => None,
        1 => Some(KillReason::Wall),
        2 => Some(KillReason::Memory),
        3 => Some(KillReason::Pids),
        4 => Some(KillReason::Fuel),
        tag => {
            return Err(DecodeError::BadTag {
                what: "killed",
                tag,
            })
        }
    };
    let exit = ExitInfo {
        code,
        signal,
        killed,
    };
    let started_unix = rd.i64("started_unix")?;
    let finished_unix = rd.i64("finished_unix")?;
    let runner_name = rd.str("runner")?;
    let container = rd.bool("container")?;
    let seccomp = match rd.u8("seccomp")? {
        0 => Protection::None,
        1 => Protection::Default,
        2 => Protection::Generated {
            profile_digest: rd.str("profile_digest")?,
        },
        tag => {
            return Err(DecodeError::BadTag {
                what: "seccomp",
                tag,
            })
        }
    };
    let runner = Features {
        runner: runner_name,
        container,
        seccomp,
        landlock: rd.bool("landlock")?,
        broker: rd.bool("broker")?,
        wasm: rd.bool("wasm")?,
        image_verified_each_start: rd.bool("image_verified_each_start")?,
    };
    let n = rd.count(MIN_EVENT, "events")?;
    let mut events = Vec::with_capacity(n);
    for _ in 0..n {
        let at_ms = rd.u64("event at_ms")?;
        let call = rd.str("event call")?;
        let m = rd.count(MIN_STR, "event args")?;
        let mut args = Vec::with_capacity(m);
        for _ in 0..m {
            args.push(rd.str("event arg")?);
        }
        let decision = match rd.u8("decision")? {
            0 => Decision::Allowed,
            1 => Decision::Refused,
            2 => Decision::Killed,
            tag => {
                return Err(DecodeError::BadTag {
                    what: "decision",
                    tag,
                })
            }
        };
        events.push(BrokerEvent {
            at_ms,
            call,
            args,
            decision,
        });
    }
    Ok(JobResult {
        job_id,
        nonce,
        verdict,
        outputs,
        exit,
        started_unix,
        finished_unix,
        runner,
        events,
    })
}

/// The canonical v1 encoding of `r`.
pub fn encode_result(r: &JobResult) -> Vec<u8> {
    let mut w = Writer::new();
    write_result(&mut w, r);
    w.into_inner()
}

/// Decode exactly one canonical v1 encoding (the whole input).
pub fn decode_result(bytes: &[u8]) -> Result<JobResult, DecodeError> {
    if bytes.len() > MAX_ENCODED_RESULT {
        return Err(DecodeError::TooLarge {
            max: MAX_ENCODED_RESULT,
        });
    }
    let mut rd = Reader::new(bytes);
    let r = read_result(&mut rd)?;
    rd.finish()?;
    Ok(r)
}