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