Gunnar

nordisk/heimdall

crates/heimdall-run/src/runner.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! [`WasmJobRunner`]: the milestone 1 wasm path behind heimdall-core's
//! [`JobRunner`] trait.
//!
//! **Heimdall is a defensive sandbox.** For every job the runner verifies and
//! loads the signed guest (signature chain to a trust root plus the job's
//! blake3 pin), runs it with no WASI under the job's limits through
//! [`run_job`] into a private staging dir, then moves the emitted files into
//! the job's `output_dir` and returns the signed result. The per-boot key
//! stays in the runner; the guest never sees it.

use std::path::{Path, PathBuf};

use heimdall_core::{
    BootKey, Features, Job, JobKind, JobRunner, Limits, Params, RunError, SignedResult,
    VerifyingKey,
};

use crate::job::{run_job, wasm_features, JobError, JobSpec};
use crate::outdir::FILES_DIR;
use crate::policy_file::{PolicyFile, RunLimits, DEFAULT_MAX_INPUT_BYTES, LIMIT_KEYS};
use crate::wasm::load_guest;

/// Runs wasm jobs; refuses native jobs with [`RunError::Unsupported`].
pub struct WasmJobRunner {
    key: BootKey,
    trust_roots: Vec<PathBuf>,
    work_dir: PathBuf,
}

impl WasmJobRunner {
    /// A runner with a fresh per-boot key. `work_dir` holds per-job staging
    /// dirs (best on the same file system as the jobs' output dirs).
    pub fn new(trust_roots: Vec<PathBuf>, work_dir: PathBuf) -> Self {
        Self::with_key(BootKey::generate(), trust_roots, work_dir)
    }

    /// A runner with a given key (tests and fixtures).
    pub fn with_key(key: BootKey, trust_roots: Vec<PathBuf>, work_dir: PathBuf) -> Self {
        Self {
            key,
            trust_roots,
            work_dir,
        }
    }

    /// The pinned-key record of this runner's per-boot key.
    pub fn pinned(&self) -> heimdall_core::PinnedKey {
        self.key.pinned()
    }
}

/// heimdall-core limits as the wasm run's limits: every shared limit must be
/// between 1 and Heimdall's ceiling, and fuel is always metered.
pub fn run_limits_from_core(l: &Limits) -> Result<RunLimits, RunError> {
    let fuel = l.fuel.ok_or_else(|| RunError::InvalidJob {
        reason: "limits.fuel is None: a Heimdall wasm run always meters fuel".into(),
    })?;
    let r = RunLimits {
        fuel,
        wall_time_ms: l.wall_ms(),
        memory_bytes: l.memory_bytes,
        table_elements: l.table_elements,
        instances: l.instances,
        tables: l.tables,
        memories: l.memories,
        stack_bytes: l.stack_bytes,
        max_input_bytes: DEFAULT_MAX_INPUT_BYTES,
        max_output_files: l.max_output_files,
        max_output_file_bytes: l.max_output_file_bytes,
        max_output_total_bytes: l.max_output_total_bytes,
    };
    let c = RunLimits::ceilings();
    let pairs = [
        (r.fuel, c.fuel),
        (r.wall_time_ms, c.wall_time_ms),
        (r.memory_bytes, c.memory_bytes),
        (r.table_elements.into(), c.table_elements.into()),
        (r.instances.into(), c.instances.into()),
        (r.tables.into(), c.tables.into()),
        (r.memories.into(), c.memories.into()),
        (r.stack_bytes, c.stack_bytes),
        (r.max_input_bytes, c.max_input_bytes),
        (r.max_output_files.into(), c.max_output_files.into()),
        (r.max_output_file_bytes, c.max_output_file_bytes),
        (r.max_output_total_bytes, c.max_output_total_bytes),
    ];
    for ((v, ceiling), (key, _)) in pairs.into_iter().zip(LIMIT_KEYS) {
        if v == 0 || v > ceiling {
            return Err(RunError::InvalidJob {
                reason: format!("limit {key} = {v} is outside 1..={ceiling}"),
            });
        }
    }
    if r.max_output_file_bytes > r.max_output_total_bytes {
        return Err(RunError::InvalidJob {
            reason: "max_output_file_bytes is above max_output_total_bytes".into(),
        });
    }
    Ok(r)
}

/// `Job::params` as string pairs: `null` or a JSON object of strings.
pub fn params_from_json(v: &serde_json::Value) -> Result<Params, RunError> {
    let bad = || RunError::InvalidJob {
        reason: "params must be null or a JSON object of string values".into(),
    };
    match v {
        serde_json::Value::Null => Ok(Params::new()),
        serde_json::Value::Object(m) => m
            .iter()
            .map(|(k, v)| Ok((k.clone(), v.as_str().ok_or_else(bad)?.to_string())))
            .collect(),
        _ => Err(bad()),
    }
}

fn io(e: impl std::fmt::Display) -> RunError {
    RunError::Io {
        reason: e.to_string(),
    }
}

fn check_empty_dir(dir: &Path) -> Result<(), RunError> {
    let meta = std::fs::symlink_metadata(dir).map_err(|e| RunError::InvalidJob {
        reason: format!("output_dir {}: {e}", dir.display()),
    })?;
    if !meta.is_dir() {
        return Err(RunError::InvalidJob {
            reason: format!("output_dir {} is not a directory", dir.display()),
        });
    }
    if std::fs::read_dir(dir).map_err(io)?.next().is_some() {
        return Err(RunError::InvalidJob {
            reason: format!("output_dir {} is not empty", dir.display()),
        });
    }
    Ok(())
}

/// Move every file of `from` into `to` (rename, or copy when on another file system).
fn move_files(from: &Path, to: &Path) -> Result<(), RunError> {
    if !from.exists() {
        return Ok(());
    }
    for e in std::fs::read_dir(from).map_err(io)? {
        let e = e.map_err(io)?;
        let dst = to.join(e.file_name());
        if std::fs::rename(e.path(), &dst).is_err() {
            std::fs::copy(e.path(), &dst).map_err(io)?;
        }
    }
    Ok(())
}

impl JobRunner for WasmJobRunner {
    fn run(&self, job: Job) -> Result<SignedResult, RunError> {
        let JobKind::Wasm { module } = &job.kind else {
            return Err(RunError::Unsupported {
                what: "native jobs: the heimdall-wasm runner runs only wasm guests".into(),
            });
        };
        if job.policy.net.is_some() {
            return Err(RunError::Unsupported {
                what: "network access: a wasm guest has no network".into(),
            });
        }
        if !job.policy.read.is_empty() || !job.policy.write.is_empty() {
            return Err(RunError::Unsupported {
                what: "extra read/write paths: a wasm guest reads only its input".into(),
            });
        }
        let limits = run_limits_from_core(&job.policy.limits)?;
        let params = params_from_json(&job.params)?;
        check_empty_dir(&job.output_dir)?;
        std::fs::create_dir_all(&self.work_dir).map_err(io)?;
        let guest = load_guest(
            &module.path,
            &self.trust_roots,
            Some(&module.blake3),
            &limits,
        )
        .map_err(|e| RunError::Image {
            reason: e.to_string(),
        })?;
        let staging = self.work_dir.join(format!("job-{}", job.id));
        let spec = JobSpec {
            input_dir: job.input_dir.clone(),
            out_dir: staging.clone(),
            policy: PolicyFile {
                job: Some("wasm".into()),
                limits,
            },
            params,
            job_id: job.id,
            nonce: job.nonce,
        };
        let outcome = run_job(&spec, &guest, &self.key);
        let moved = match &outcome {
            Ok(_) => move_files(&staging.join(FILES_DIR), &job.output_dir),
            Err(_) => Ok(()),
        };
        let _ = std::fs::remove_dir_all(&staging);
        moved?;
        match outcome {
            Ok(o) => Ok(o.signed),
            Err(JobError::Input(e)) => Err(RunError::InvalidJob {
                reason: format!("input: {e}"),
            }),
            Err(JobError::OutDir(e)) => Err(io(e)),
            Err(JobError::Sign(e)) => Err(RunError::Sign(e)),
        }
    }

    fn verifying_key(&self) -> VerifyingKey {
        self.key.verifying_key()
    }

    fn features(&self) -> Features {
        wasm_features()
    }
}