Gunnar

nordisk/heimdall

crates/heimdall-cli/src/main.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! heimdall-cli (spec §7): the `heimdall` command line.
//!
//! **Heimdall is a defensive sandbox.** Milestone 1 runs wasm jobs only:
//!
//! - `heimdall run --job wasm --module <guest> --trust-root <der> --in <dir> --out <dir> [--policy <file>]`
//! - `heimdall verify-result <file> --key <pinned key>`
//!
//! Native jobs and the `profile` verbs print "not in milestone 1" and exit 2.
//! Exit codes are documented in `docs/CLI.md`.

use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use clap::{Parser, Subcommand, ValueEnum};
use heimdall_core::{BootKey, JobId, JobTicket, Nonce, Params, Verdict};
use heimdall_run::verify::{default_files_dir, load_pinned_key, read_result_file, verify_result};
use heimdall_run::{run_job, GuestRunner, JobSpec, PolicyFile};

/// Exit code: the job ran and was Accepted, or the result verified and says Accepted.
pub const EXIT_OK: u8 = 0;
/// Exit code: the result did not verify (signature, key, ticket or outputs).
pub const EXIT_REJECTED: u8 = 1;
/// Exit code: a usage error, or a verb that is not in milestone 1.
pub const EXIT_USAGE: u8 = 2;
/// Exit code: the job could not start, or a file could not be read.
pub const EXIT_HOST: u8 = 3;
/// Exit code: the job ran and was Refused (the signed result was written), or
/// the result verified and says Refused.
pub const EXIT_REFUSED: u8 = 4;

#[derive(Parser, Debug)]
#[command(
    name = "heimdall",
    version,
    about = "Heimdall is a defensive sandbox: run untrusted work, trust only its signed verdict."
)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand, Debug)]
enum Command {
    /// Run one job on an input dir; outputs and the signed result go into a fresh out dir.
    Run(RunArgs),
    /// Verify a signed result file against a pinned public key.
    VerifyResult(VerifyArgs),
    /// Syscall profiles: probe, gen, check (not in milestone 1).
    #[command(disable_help_flag = true)]
    Profile {
        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
        rest: Vec<String>,
    },
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum JobKindArg {
    Wasm,
    Native,
}

#[derive(clap::Args, Debug)]
struct RunArgs {
    /// The job kind: wasm (milestone 1) or native (later).
    #[arg(long = "job", value_enum)]
    job: JobKindArg,
    /// The signed wasm guest (`<guest>.wasm` beside `.wasm.p7s` and `.wasm.blake3`).
    #[arg(long = "module")]
    module: Option<PathBuf>,
    /// A DER root certificate the guest's signature must chain to; may repeat.
    #[arg(long = "trust-root")]
    trust_roots: Vec<PathBuf>,
    /// Also demand that the guest's BLAKE3-256 equals this hex digest.
    #[arg(long = "pin-blake3")]
    pin_blake3: Option<String>,
    /// The input dir (exactly one regular file in milestone 1).
    #[arg(long = "in")]
    input: PathBuf,
    /// The out dir; it must not exist yet.
    #[arg(long = "out")]
    out: PathBuf,
    /// The job policy file (TOML, docs/POLICY.md).
    #[arg(long = "policy")]
    policy: Option<PathBuf>,
    /// Write this run's pinned public key record here (a new file).
    #[arg(long = "pin-out")]
    pin_out: Option<PathBuf>,
    /// The caller's job id (32 hex digits); random when absent.
    #[arg(long = "job-id", value_parser = parse_job_id)]
    job_id: Option<JobId>,
    /// The caller's nonce (64 hex digits); random when absent.
    #[arg(long = "nonce", value_parser = parse_nonce)]
    nonce: Option<Nonce>,
    /// A job parameter, `key=value`; may repeat (a key at most once).
    #[arg(long = "param", value_parser = parse_param)]
    params: Vec<(String, String)>,
}

#[derive(clap::Args, Debug)]
struct VerifyArgs {
    /// The signed result file (`out/result.signed`).
    file: PathBuf,
    /// The pinned public key: a file holding the record, or the record itself.
    #[arg(long = "key")]
    key: String,
    /// The dir whose files must match the result's outputs (default: `files/`
    /// beside the result file, when it exists).
    #[arg(long = "files")]
    files: Option<PathBuf>,
    /// The job id the result must be for (needs --nonce too).
    #[arg(long = "job-id", value_parser = parse_job_id, requires = "nonce")]
    job_id: Option<JobId>,
    /// The nonce the result must carry (needs --job-id too).
    #[arg(long = "nonce", value_parser = parse_nonce, requires = "job_id")]
    nonce: Option<Nonce>,
}

fn parse_param(s: &str) -> Result<(String, String), String> {
    let (k, v) = s
        .split_once('=')
        .ok_or_else(|| format!("param {s:?} is not key=value"))?;
    if k.is_empty() {
        return Err(format!("param {s:?} has an empty key"));
    }
    Ok((k.to_string(), v.to_string()))
}

fn parse_job_id(s: &str) -> Result<JobId, String> {
    JobId::from_hex(s).ok_or_else(|| format!("job id {s:?} is not {} hex digits", 2 * JobId::LEN))
}

fn parse_nonce(s: &str) -> Result<Nonce, String> {
    Nonce::from_hex(s).ok_or_else(|| format!("nonce {s:?} is not {} hex digits", 2 * Nonce::LEN))
}

const NOT_IN_M1: &str = "not in milestone 1";

fn main() -> ExitCode {
    let cli = match Cli::try_parse() {
        Ok(c) => c,
        Err(e) => {
            let _ = e.print();
            return ExitCode::from(match e.kind() {
                clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
                    EXIT_OK
                }
                _ => EXIT_USAGE,
            });
        }
    };
    ExitCode::from(dispatch(cli))
}

fn dispatch(cli: Cli) -> u8 {
    match cli.command {
        Command::Profile { .. } => {
            eprintln!("heimdall profile: {NOT_IN_M1}");
            EXIT_USAGE
        }
        Command::Run(args) => run(args),
        Command::VerifyResult(args) => verify(args),
    }
}

/// Load the wasm guest. heimdall-wasm is wired in `heimdall_run::wasm`.
fn load_guest(args: &RunArgs, policy: &PolicyFile) -> Result<Box<dyn GuestRunner>, String> {
    let module = args.module.as_deref().ok_or("--module is required")?;
    heimdall_run::wasm::load_guest(
        module,
        &args.trust_roots,
        args.pin_blake3.as_deref(),
        &policy.limits,
    )
    .map(|g| Box::new(g) as Box<dyn GuestRunner>)
    .map_err(|e| e.to_string())
}

fn write_new_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
    let mut f = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o644)
        .custom_flags(libc::O_NOFOLLOW)
        .open(path)?;
    f.write_all(bytes)?;
    f.sync_all()
}

fn run(args: RunArgs) -> u8 {
    if args.job == JobKindArg::Native {
        eprintln!("heimdall run --job native: {NOT_IN_M1}");
        return EXIT_USAGE;
    }
    if args.module.is_none() {
        eprintln!("heimdall run --job wasm needs --module <guest.wasm>");
        return EXIT_USAGE;
    }
    let mut params = Params::new();
    for (k, v) in &args.params {
        if params.insert(k.clone(), v.clone()).is_some() {
            eprintln!("heimdall run: --param {k} given twice");
            return EXIT_USAGE;
        }
    }
    let policy = match &args.policy {
        None => PolicyFile::default(),
        Some(p) => match PolicyFile::load(p) {
            Ok(p) => p,
            Err(e) => {
                eprintln!("heimdall run: policy {}: {e}", p.display());
                return EXIT_HOST;
            }
        },
    };
    let guest = match load_guest(&args, &policy) {
        Ok(g) => g,
        Err(e) => {
            eprintln!("heimdall run: {e}");
            return EXIT_HOST;
        }
    };
    // The per-boot key: one heimdall process is one boot. The secret half is
    // never written anywhere; the caller pins the public record.
    let key = BootKey::generate();
    let pin = key.pinned().to_text();
    if let Some(p) = &args.pin_out {
        if let Err(e) = write_new_file(p, pin.as_bytes()) {
            eprintln!("heimdall run: --pin-out {}: {e}", p.display());
            return EXIT_HOST;
        }
    }
    let mut spec = JobSpec::new(&args.input, &args.out, policy, params);
    if let Some(id) = args.job_id {
        spec.job_id = id;
    }
    if let Some(n) = args.nonce {
        spec.nonce = n;
    }
    let outcome = match run_job(&spec, guest.as_ref(), &key) {
        Ok(o) => o,
        Err(e) => {
            eprintln!("heimdall run: {e}");
            return EXIT_HOST;
        }
    };
    let r = &outcome.signed.result;
    print!("pinned-key: {pin}");
    println!("job-id: {}", r.job_id);
    println!("nonce: {}", r.nonce);
    println!("result: {}", outcome.result_path.display());
    match &r.verdict {
        Verdict::Accepted => {
            println!("verdict: accepted ({} outputs)", r.outputs.len());
            EXIT_OK
        }
        Verdict::Refused { code, reason } => {
            println!(
                "verdict: refused: {} [{}]",
                reason.escape_debug(),
                code.escape_debug()
            );
            EXIT_REFUSED
        }
    }
}

fn verify(args: VerifyArgs) -> u8 {
    let pinned = match load_pinned_key(&args.key) {
        Ok(p) => p,
        Err(e) => {
            eprintln!("heimdall verify-result: {e}");
            return EXIT_HOST;
        }
    };
    let wire = match read_result_file(&args.file) {
        Ok(w) => w,
        Err(e) => {
            eprintln!("heimdall verify-result: {e}");
            return EXIT_HOST;
        }
    };
    let ticket = match (args.job_id, args.nonce) {
        (Some(job_id), Some(nonce)) => Some(JobTicket { job_id, nonce }),
        _ => None,
    };
    let files = args.files.clone().or_else(|| default_files_dir(&args.file));
    match verify_result(&wire, pinned, ticket.as_ref(), files.as_deref()) {
        Ok(v) => {
            let r = &v.signed.result;
            println!("verified: job {} key {}", r.job_id, v.signed.key_id);
            if let Some(d) = &v.files_dir {
                println!("outputs: {} checked in {}", v.files_checked, d.display());
            } else {
                println!("outputs: not checked (no files dir)");
            }
            match &r.verdict {
                Verdict::Accepted => {
                    println!("verdict: accepted");
                    EXIT_OK
                }
                Verdict::Refused { code, reason } => {
                    println!(
                        "verdict: refused: {} [{}]",
                        reason.escape_debug(),
                        code.escape_debug()
                    );
                    EXIT_REFUSED
                }
            }
        }
        Err(e) => {
            eprintln!("heimdall verify-result: {e}");
            if e.is_rejection() {
                EXIT_REJECTED
            } else {
                EXIT_HOST
            }
        }
    }
}

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

    fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
        Cli::try_parse_from(std::iter::once("heimdall").chain(args.iter().copied()))
    }

    #[test]
    fn run_wasm_with_all_flags_parses_into_run_args() {
        let cli = parse(&[
            "run", "--job", "wasm", "--module", "g.wasm", "--in", "i", "--out", "o", "--policy",
            "p.toml", "--param", "a=b", "--param", "c=",
        ])
        .unwrap();
        let Command::Run(r) = cli.command else {
            panic!("not run")
        };
        assert_eq!(r.job, JobKindArg::Wasm);
        assert_eq!(r.module, Some(PathBuf::from("g.wasm")));
        assert_eq!(r.input, PathBuf::from("i"));
        assert_eq!(r.out, PathBuf::from("o"));
        assert_eq!(r.policy, Some(PathBuf::from("p.toml")));
        assert_eq!(
            r.params,
            vec![("a".into(), "b".into()), ("c".into(), "".into())]
        );
    }

    #[test]
    fn run_without_in_is_a_usage_error() {
        assert!(parse(&["run", "--job", "wasm", "--module", "g", "--out", "o"]).is_err());
    }

    #[test]
    fn run_without_out_is_a_usage_error() {
        assert!(parse(&["run", "--job", "wasm", "--module", "g", "--in", "i"]).is_err());
    }

    #[test]
    fn run_with_unknown_job_kind_is_a_usage_error() {
        assert!(parse(&["run", "--job", "docker", "--in", "i", "--out", "o"]).is_err());
    }

    #[test]
    fn param_without_equals_sign_is_a_usage_error() {
        assert!(parse(&[
            "run", "--job", "wasm", "--module", "g", "--in", "i", "--out", "o", "--param", "noeq"
        ])
        .is_err());
    }

    #[test]
    fn param_with_empty_key_is_a_usage_error() {
        assert!(parse_param("=v").is_err());
    }

    #[test]
    fn param_value_may_contain_equals_signs() {
        assert_eq!(parse_param("k=a=b").unwrap(), ("k".into(), "a=b".into()));
    }

    #[test]
    fn verify_result_needs_a_pinned_key() {
        assert!(parse(&["verify-result", "r.signed"]).is_err());
        assert!(parse(&["verify-result", "r.signed", "--key", "k.pub"]).is_ok());
    }

    #[test]
    fn profile_accepts_any_trailing_words_so_it_can_say_not_in_milestone_1() {
        assert!(parse(&["profile", "probe", "holger-scan", "--anything"]).is_ok());
        assert!(parse(&["profile"]).is_ok());
    }

    #[test]
    fn unknown_subcommand_is_a_usage_error() {
        assert!(parse(&["exec", "x"]).is_err());
    }

    #[test]
    fn native_job_dispatch_exits_with_usage_code() {
        let cli = parse(&["run", "--job", "native", "--in", "i", "--out", "o"]).unwrap();
        assert_eq!(dispatch(cli), EXIT_USAGE);
    }

    #[test]
    fn profile_dispatch_exits_with_usage_code() {
        let cli = parse(&["profile", "gen", "w"]).unwrap();
        assert_eq!(dispatch(cli), EXIT_USAGE);
    }

    #[test]
    fn wasm_job_without_module_exits_with_usage_code() {
        let cli = parse(&["run", "--job", "wasm", "--in", "i", "--out", "o"]).unwrap();
        assert_eq!(dispatch(cli), EXIT_USAGE);
    }
}