// SPDX-License-Identifier: AGPL-3.0-only
//! One wasm job, end to end (docs/RUN.md).
//!
//! **Heimdall is a defensive sandbox.** The steps: read the one input file,
//! create the fresh out dir, run the guest under the policy's limits, write
//! the guest's files into `out/files/`, hash and audit them, then sign the
//! JobResult with the per-boot key and write `out/result.signed`.
//!
//! The guest is reached through [`GuestRunner`], so the run logic is the same
//! for the real wasm host (heimdall-wasm) and for the scripted guests the
//! tests use.
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use heimdall_core::{
codes, BootKey, BrokerEvent, Decision, ExitInfo, Features, JobId, JobResult, JobTicket, Nonce,
Output, Params, Protection, ResultError, Sha256Digest, SignedResult, Verdict,
};
use crate::indir::{read_single_input, InputError};
use crate::outdir::{OutDir, OutDirError};
use crate::policy_file::{PolicyFile, RunLimits};
/// What protects a milestone 1 wasm run, stated truthfully: a wasmtime
/// guest with no WASI under fuel, epoch and memory limits; no container, no
/// seccomp, no landlock, no broker. The guest's signature and blake3 pin are
/// checked when it is loaded ([`crate::wasm::load_guest`]), which
/// `heimdall run` and [`crate::WasmJobRunner`] do for every job.
pub fn wasm_features() -> Features {
Features {
runner: "heimdall-wasm".into(),
container: false,
seccomp: Protection::None,
landlock: false,
broker: false,
wasm: true,
image_verified_each_start: true,
}
}
/// What the guest answered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestVerdict {
/// The guest accepted the input.
Accepted { note: String },
/// The guest refused, trapped or hit a limit. `code` is the host's
/// stable refusal code (`heimdall-wasm-<n>`), `reason` the plain words.
Refused { code: String, reason: String },
}
/// One guest run: the verdict, the files it emitted, the fuel it used.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestRun {
pub verdict: GuestVerdict,
/// `(name, bytes)` in emit order.
pub files: Vec<(String, Vec<u8>)>,
pub fuel_used: u64,
}
/// Something that runs a guest once. heimdall-wasm's `Guest` implements it
/// through [`crate::wasm`]; tests use scripted guests.
pub trait GuestRunner {
fn run_guest(&self, input: &[u8], params: &Params, limits: &RunLimits) -> GuestRun;
}
/// Everything one job needs besides the guest and the key.
#[derive(Debug, Clone)]
pub struct JobSpec {
pub input_dir: PathBuf,
pub out_dir: PathBuf,
pub policy: PolicyFile,
pub params: Params,
pub job_id: JobId,
pub nonce: Nonce,
}
impl JobSpec {
/// A spec with a fresh random job id and nonce.
pub fn new(input_dir: &Path, out_dir: &Path, policy: PolicyFile, params: Params) -> JobSpec {
JobSpec {
input_dir: input_dir.to_path_buf(),
out_dir: out_dir.to_path_buf(),
policy,
params,
job_id: JobId::random(),
nonce: Nonce::random(),
}
}
/// What the caller keeps to check the result.
pub fn ticket(&self) -> JobTicket {
JobTicket {
job_id: self.job_id,
nonce: self.nonce,
}
}
}
/// A finished job: the signed result, its wire bytes and where they were written.
#[derive(Debug, Clone)]
pub struct JobOutcome {
pub signed: SignedResult,
pub wire: Vec<u8>,
pub result_path: PathBuf,
}
impl JobOutcome {
pub fn is_accepted(&self) -> bool {
self.signed.result.verdict.is_accepted()
}
}
/// Why a job could not start or could not be signed. Nothing signed exists.
#[derive(Debug, thiserror::Error)]
pub enum JobError {
#[error("input: {0}")]
Input(#[from] InputError),
#[error("out dir: {0}")]
OutDir(#[from] OutDirError),
#[error("the result could not be signed: {0}")]
Sign(#[from] ResultError),
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Run one job end to end and sign its result.
///
/// Input and out-dir problems stop the job before the guest runs (no result).
/// Everything after the guest started ends in a signed result: a guest
/// refusal, a trap, a limit hit, a refused output name or an output cap all
/// give a signed Refused with the reason.
pub fn run_job(
spec: &JobSpec,
guest: &dyn GuestRunner,
key: &BootKey,
) -> Result<JobOutcome, JobError> {
let limits = spec.policy.limits;
let input = read_single_input(&spec.input_dir, limits.max_input_bytes)?;
let mut out = OutDir::create(&spec.out_dir, limits.output_caps())?;
let started_ms = now_ms();
let run = guest.run_guest(&input.bytes, &spec.params, &limits);
let mut events = Vec::new();
let elapsed = |t: u64| t.saturating_sub(started_ms);
let mut verdict = match &run.verdict {
GuestVerdict::Accepted { .. } => Verdict::Accepted,
GuestVerdict::Refused { code, reason } => Verdict::Refused {
code: code.clone(),
reason: reason.clone(),
},
};
events.push(BrokerEvent {
at_ms: elapsed(now_ms()),
call: "fuel".into(),
args: vec![run.fuel_used.to_string(), limits.fuel.to_string()],
decision: if run.verdict_is_trap() {
Decision::Killed
} else {
Decision::Allowed
},
});
for (name, bytes) in &run.files {
match out.write_output(name, bytes) {
Ok(_) => events.push(BrokerEvent {
at_ms: elapsed(now_ms()),
call: "emit-file".into(),
args: vec![name.clone(), bytes.len().to_string()],
decision: Decision::Allowed,
}),
Err(e) => {
events.push(BrokerEvent {
at_ms: elapsed(now_ms()),
call: "emit-file".into(),
args: vec![name.escape_debug().to_string(), bytes.len().to_string()],
decision: Decision::Refused,
});
verdict = Verdict::Refused {
code: codes::OUT_REFUSED.into(),
reason: format!("output refused ({e})"),
};
break;
}
}
}
if verdict.is_accepted() || !out.outputs().is_empty() {
if let Err(e) = out.audit() {
verdict = Verdict::Refused {
code: codes::OUT_AUDIT.into(),
reason: format!("output audit failed ({e})"),
};
}
}
// A refusal Heimdall itself decided (bad output, failed audit) hands on no
// partial output. A refusal the guest returned keeps its files: a refusal
// report is a legitimate output.
let heimdall_refused = matches!(&verdict, Verdict::Refused { .. })
&& !matches!(&run.verdict, GuestVerdict::Refused { .. });
let guest_trapped = run.verdict_is_trap();
if heimdall_refused || guest_trapped {
out.discard_outputs()?;
}
let outputs = out
.outputs()
.into_iter()
.map(|w| Output {
name: w.name,
sha256: Sha256Digest(w.sha256),
bytes: w.len,
})
.collect();
let finished_ms = now_ms().max(started_ms);
let exit = if run.verdict_is_trap() {
ExitInfo {
killed: run.kill_reason(),
..ExitInfo::default()
}
} else {
ExitInfo::exited(0)
};
let result = JobResult {
job_id: spec.job_id,
nonce: spec.nonce,
verdict,
outputs,
exit,
started_unix: (started_ms / 1000) as i64,
finished_unix: (finished_ms / 1000) as i64,
runner: wasm_features(),
events,
};
let signed = key.sign(result)?;
let wire = signed.to_bytes();
let result_path = out.write_result(&wire)?;
Ok(JobOutcome {
signed,
wire,
result_path,
})
}
impl GuestRun {
/// The limit behind a trap, when the reason names one.
pub fn kill_reason(&self) -> Option<heimdall_core::KillReason> {
use heimdall_core::KillReason;
let GuestVerdict::Refused { reason, .. } = &self.verdict else {
return None;
};
let r = reason.to_ascii_lowercase();
if r.contains("fuel") {
Some(KillReason::Fuel)
} else if r.contains("wall") || r.contains("timeout") || r.contains("time limit") {
Some(KillReason::Wall)
} else if r.contains("memory") {
Some(KillReason::Memory)
} else {
None
}
}
/// The host's trap/limit verdict reads `guest trapped (<why>)`.
pub fn verdict_is_trap(&self) -> bool {
matches!(&self.verdict, GuestVerdict::Refused { reason, .. } if reason.starts_with("guest trapped"))
}
}