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)
}