Gunnar

nordisk/heimdall

crates/heimdall-run/src/policy_file.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The job policy file (TOML), documented in `docs/POLICY.md`.
//!
//! Heimdall is a defensive sandbox. The policy file only ever *narrows* what a
//! job may do: every limit has a hard ceiling compiled into Heimdall, unknown
//! keys are refused, and the keys that would widen a job beyond its own
//! `in/` and `out/` (`read`, `write`, `net`) are refused in milestone 1.

use std::io::Read;
use std::path::Path;

use serde::Deserialize;

/// The only policy file format version this build understands.
pub const POLICY_FORMAT_VERSION: u32 = 1;

/// A policy file larger than this is refused before it is parsed.
pub const MAX_POLICY_FILE_BYTES: usize = 64 * 1024;

const KIB: u64 = 1024;
const MIB: u64 = 1024 * KIB;
const GIB: u64 = 1024 * MIB;

/// The default largest input file.
pub const DEFAULT_MAX_INPUT_BYTES: u64 = 64 * MIB;

/// The limits of one wasm job, after defaults and ceilings were applied.
/// Everything except `max_input_bytes` maps one to one onto
/// [`heimdall_core::Limits`]; the defaults are heimdall-core's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RunLimits {
    /// Wasm fuel (instructions, roughly) the guest may burn. Always metered.
    pub fuel: u64,
    /// Wall-clock timeout in milliseconds (epoch interruption).
    pub wall_time_ms: u64,
    /// Linear memory, in bytes, over all of the guest's memories.
    pub memory_bytes: u64,
    /// Table elements, over all of the guest's tables.
    pub table_elements: u32,
    /// Core instances the guest component may create.
    pub instances: u32,
    /// Tables the guest may create.
    pub tables: u32,
    /// Memories the guest may create.
    pub memories: u32,
    /// The wasm stack, in bytes.
    pub stack_bytes: u64,
    /// The largest input file Heimdall reads into the guest.
    pub max_input_bytes: u64,
    /// How many files the guest may emit.
    pub max_output_files: u32,
    /// The largest single file the guest may emit.
    pub max_output_file_bytes: u64,
    /// All emitted files together.
    pub max_output_total_bytes: u64,
}

/// Every limit key of the `[limits]` table, in file order, with its hard
/// ceiling.
pub const LIMIT_KEYS: [(&str, u64); 12] = [
    ("fuel", 1_000_000_000_000),
    ("wall_time_ms", 3_600_000),
    ("memory_bytes", 4 * GIB),
    ("table_elements", 1_000_000),
    ("instances", 100),
    ("tables", 100),
    ("memories", 16),
    ("stack_bytes", 16 * MIB),
    ("max_input_bytes", 4 * GIB),
    ("max_output_files", 10_000),
    ("max_output_file_bytes", 4 * GIB),
    ("max_output_total_bytes", 16 * GIB),
];

impl RunLimits {
    /// The hard ceiling of every limit: no policy file can go above these.
    pub fn ceilings() -> RunLimits {
        Self::from_values(LIMIT_KEYS.map(|(_, c)| c))
    }

    fn from_values(v: [u64; 12]) -> RunLimits {
        // Every u32 field has a ceiling below u32::MAX, so these never truncate
        // for checked values.
        let u = |x: u64| u32::try_from(x).unwrap_or(u32::MAX);
        RunLimits {
            fuel: v[0],
            wall_time_ms: v[1],
            memory_bytes: v[2],
            table_elements: u(v[3]),
            instances: u(v[4]),
            tables: u(v[5]),
            memories: u(v[6]),
            stack_bytes: v[7],
            max_input_bytes: v[8],
            max_output_files: u(v[9]),
            max_output_file_bytes: v[10],
            max_output_total_bytes: v[11],
        }
    }

    fn values(&self) -> [u64; 12] {
        [
            self.fuel,
            self.wall_time_ms,
            self.memory_bytes,
            u64::from(self.table_elements),
            u64::from(self.instances),
            u64::from(self.tables),
            u64::from(self.memories),
            self.stack_bytes,
            self.max_input_bytes,
            u64::from(self.max_output_files),
            self.max_output_file_bytes,
            self.max_output_total_bytes,
        ]
    }

    /// The heimdall-core limits the wasm host enforces.
    pub fn to_core(&self) -> heimdall_core::Limits {
        heimdall_core::Limits {
            fuel: Some(self.fuel),
            wall: std::time::Duration::from_millis(self.wall_time_ms),
            memory_bytes: self.memory_bytes,
            table_elements: self.table_elements,
            instances: self.instances,
            tables: self.tables,
            memories: self.memories,
            stack_bytes: self.stack_bytes,
            max_output_files: self.max_output_files,
            max_output_file_bytes: self.max_output_file_bytes,
            max_output_total_bytes: self.max_output_total_bytes,
            ..heimdall_core::Limits::default()
        }
    }
}

impl Default for RunLimits {
    fn default() -> Self {
        let c = heimdall_core::Limits::default();
        RunLimits {
            // Fuel metering is never off in a Heimdall run.
            fuel: c.fuel.unwrap_or(1_000_000_000),
            wall_time_ms: c.wall_ms(),
            memory_bytes: c.memory_bytes,
            table_elements: c.table_elements,
            instances: c.instances,
            tables: c.tables,
            memories: c.memories,
            stack_bytes: c.stack_bytes,
            max_input_bytes: DEFAULT_MAX_INPUT_BYTES,
            max_output_files: c.max_output_files,
            max_output_file_bytes: c.max_output_file_bytes,
            max_output_total_bytes: c.max_output_total_bytes,
        }
    }
}

/// A parsed and checked policy file.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PolicyFile {
    /// The job kind the file is pinned to, if it names one (`"wasm"`).
    pub job: Option<String>,
    /// The limits, defaults filled in.
    pub limits: RunLimits,
}

/// Why a policy file was refused.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum PolicyError {
    #[error("policy file is larger than {MAX_POLICY_FILE_BYTES} bytes")]
    TooLarge,
    #[error("policy file is not valid UTF-8")]
    NotUtf8,
    #[error("policy file is not valid TOML for this format: {0}")]
    Syntax(String),
    #[error("policy file has no `version` key (expected version = {POLICY_FORMAT_VERSION})")]
    MissingVersion,
    #[error("policy file version {0} is not supported (expected {POLICY_FORMAT_VERSION})")]
    UnsupportedVersion(i64),
    #[error("policy key `{0}` is not allowed in milestone 1: a wasm job reads only its --in dir, writes only its --out dir and has no network")]
    WideningKey(&'static str),
    #[error("policy job kind `{0}` is not supported in milestone 1 (only \"wasm\")")]
    UnsupportedJob(String),
    #[error("policy limit `{key}` must be at least 1")]
    LimitZero { key: &'static str },
    #[error("policy limit `{key}` = {value} is above Heimdall's ceiling {ceiling}")]
    LimitAboveCeiling {
        key: &'static str,
        value: u64,
        ceiling: u64,
    },
    #[error("policy limit `{key}` must be a non-negative integer")]
    LimitNotInteger { key: &'static str },
    #[error(
        "policy limit max_output_file_bytes ({file}) is above max_output_total_bytes ({total})"
    )]
    FileAboveTotal { file: u64, total: u64 },
    #[error("cannot read policy file: {0}")]
    Io(String),
    #[error("policy file path is not a regular file")]
    NotRegularFile,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPolicy {
    version: Option<i64>,
    job: Option<RawJob>,
    limits: Option<toml::Table>,
    read: Option<toml::Value>,
    write: Option<toml::Value>,
    net: Option<toml::Value>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawJob {
    kind: String,
}

impl PolicyFile {
    /// Parse a policy file from raw bytes (size and UTF-8 checked first).
    /// This is the entry point the fuzz target drives.
    pub fn from_bytes(bytes: &[u8]) -> Result<PolicyFile, PolicyError> {
        if bytes.len() > MAX_POLICY_FILE_BYTES {
            return Err(PolicyError::TooLarge);
        }
        let text = std::str::from_utf8(bytes).map_err(|_| PolicyError::NotUtf8)?;
        Self::parse(text)
    }

    /// Parse a policy file from TOML text.
    pub fn parse(text: &str) -> Result<PolicyFile, PolicyError> {
        if text.len() > MAX_POLICY_FILE_BYTES {
            return Err(PolicyError::TooLarge);
        }
        let raw: RawPolicy =
            toml::from_str(text).map_err(|e| PolicyError::Syntax(e.message().to_string()))?;
        match raw.version {
            None => return Err(PolicyError::MissingVersion),
            Some(v) if v != i64::from(POLICY_FORMAT_VERSION) => {
                return Err(PolicyError::UnsupportedVersion(v))
            }
            Some(_) => {}
        }
        if raw.read.is_some() {
            return Err(PolicyError::WideningKey("read"));
        }
        if raw.write.is_some() {
            return Err(PolicyError::WideningKey("write"));
        }
        if raw.net.is_some() {
            return Err(PolicyError::WideningKey("net"));
        }
        let job = match raw.job {
            None => None,
            Some(j) if j.kind == "wasm" => Some(j.kind),
            Some(j) => return Err(PolicyError::UnsupportedJob(j.kind)),
        };
        let limits = parse_limits(raw.limits.unwrap_or_default())?;
        Ok(PolicyFile { job, limits })
    }

    /// Read and parse a policy file. The path must be a regular file (a
    /// symlink is refused) no larger than [`MAX_POLICY_FILE_BYTES`].
    pub fn load(path: &Path) -> Result<PolicyFile, PolicyError> {
        let meta = std::fs::symlink_metadata(path).map_err(|e| PolicyError::Io(e.to_string()))?;
        if !meta.file_type().is_file() {
            return Err(PolicyError::NotRegularFile);
        }
        let file = std::fs::File::open(path).map_err(|e| PolicyError::Io(e.to_string()))?;
        let mut buf = Vec::new();
        file.take(MAX_POLICY_FILE_BYTES as u64 + 1)
            .read_to_end(&mut buf)
            .map_err(|e| PolicyError::Io(e.to_string()))?;
        Self::from_bytes(&buf)
    }
}

fn parse_limits(table: toml::Table) -> Result<RunLimits, PolicyError> {
    // Unknown limit keys are refused: a typo must never silently fall back
    // to a default.
    for key in table.keys() {
        if !LIMIT_KEYS.iter().any(|(k, _)| k == key) {
            return Err(PolicyError::Syntax(format!("unknown limit `{key}`")));
        }
    }
    let mut values = RunLimits::default().values();
    for (i, (key, ceiling)) in LIMIT_KEYS.iter().enumerate() {
        let (key, ceiling) = (*key, *ceiling);
        match table.get(key) {
            None => {}
            Some(toml::Value::Integer(n)) => {
                let n = u64::try_from(*n).map_err(|_| PolicyError::LimitNotInteger { key })?;
                if n == 0 {
                    return Err(PolicyError::LimitZero { key });
                }
                if n > ceiling {
                    return Err(PolicyError::LimitAboveCeiling {
                        key,
                        value: n,
                        ceiling,
                    });
                }
                values[i] = n;
            }
            Some(_) => return Err(PolicyError::LimitNotInteger { key }),
        }
    }
    let limits = RunLimits::from_values(values);
    if limits.max_output_file_bytes > limits.max_output_total_bytes {
        return Err(PolicyError::FileAboveTotal {
            file: limits.max_output_file_bytes,
            total: limits.max_output_total_bytes,
        });
    }
    Ok(limits)
}

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

    #[test]
    fn minimal_file_with_only_version_gives_all_defaults() {
        let p = PolicyFile::parse("version = 1\n").unwrap();
        assert_eq!(p, PolicyFile::default());
    }

    #[test]
    fn defaults_are_at_least_one_and_never_above_ceilings() {
        let d = RunLimits::default().values();
        for (i, (key, ceiling)) in LIMIT_KEYS.iter().enumerate() {
            assert!(d[i] >= 1 && d[i] <= *ceiling, "{key}");
        }
        let d = RunLimits::default();
        assert!(d.max_output_file_bytes <= d.max_output_total_bytes);
    }

    #[test]
    fn defaults_are_heimdall_core_defaults_with_fuel_always_metered() {
        let core = heimdall_core::Limits::default();
        let mut want = core;
        want.fuel = Some(core.fuel.unwrap_or(1_000_000_000));
        assert_eq!(RunLimits::default().to_core(), want);
    }

    #[test]
    fn every_limit_key_is_read_into_its_own_field() {
        let mut text = String::from("version = 1\n[limits]\n");
        for (i, (key, _)) in LIMIT_KEYS.iter().enumerate() {
            text.push_str(&format!("{key} = {}\n", 1 + i));
        }
        let l = PolicyFile::parse(&text).unwrap().limits;
        let want: Vec<u64> = (0..12).map(|i| 1 + i as u64).collect();
        assert_eq!(l.values().to_vec(), want);
        assert_eq!(l.fuel, 1);
        assert_eq!(l.max_output_total_bytes, 12);
    }

    #[test]
    fn round_trip_through_values_is_identity() {
        let c = RunLimits::ceilings();
        assert_eq!(RunLimits::from_values(c.values()), c);
    }

    #[test]
    fn integer_ceilings_fit_their_field_types() {
        let c = RunLimits::ceilings();
        assert_eq!(c.values(), LIMIT_KEYS.map(|(_, c)| c));
    }

    #[test]
    fn to_core_carries_every_shared_limit() {
        let c = RunLimits::ceilings();
        let k = c.to_core();
        assert_eq!(k.fuel, Some(c.fuel));
        assert_eq!(k.wall_ms(), c.wall_time_ms);
        assert_eq!(k.memory_bytes, c.memory_bytes);
        assert_eq!(k.table_elements, c.table_elements);
        assert_eq!(k.instances, c.instances);
        assert_eq!(k.tables, c.tables);
        assert_eq!(k.memories, c.memories);
        assert_eq!(k.stack_bytes, c.stack_bytes);
        assert_eq!(k.max_output_files, c.max_output_files);
        assert_eq!(k.max_output_file_bytes, c.max_output_file_bytes);
        assert_eq!(k.max_output_total_bytes, c.max_output_total_bytes);
    }
}