Gunnar

nordisk/heimdall

crates/heimdall-run/src/job.rs

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