Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

crates/heimdall-broker/tests/policy.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! The broker's pure decisions (spec §4). Heimdall is a defensive sandbox:
//! these prove what the policy lets a job open or connect to, without a
//! kernel.

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

use heimdall_broker::policy::{
    join_relative, normalise, parse_sockaddr, proc_rewrite, sockaddr_bytes,
};
use heimdall_broker::{Access, BrokerPolicy, Resolve};
use heimdall_core::JobId;
use proptest::prelude::*;

const R: u64 = libc::O_RDONLY as u64;
const W: u64 = libc::O_WRONLY as u64;
const RW: u64 = libc::O_RDWR as u64;
const CREAT: u64 = libc::O_CREAT as u64;
const TRUNC: u64 = libc::O_TRUNC as u64;
const DIR: u64 = libc::O_DIRECTORY as u64;
const PATH: u64 = libc::O_PATH as u64;
const TMPFILE: u64 = libc::O_TMPFILE as u64;

/// The container job's shape: the image read-only, /in read, /out
/// write-only, /tmp read-write, /proc read, two devices.
fn job() -> BrokerPolicy {
    BrokerPolicy::empty(JobId::random())
        .with_prefix("/", Access::Read, Resolve::InRoot)
        .with_prefix("/in", Access::Read, Resolve::Beneath)
        .with_prefix("/out", Access::WriteOnly, Resolve::NoSymlinks)
        .with_prefix("/tmp", Access::ReadWrite, Resolve::Beneath)
        .with_prefix("/proc", Access::Read, Resolve::Proc)
        .with_prefix("/dev/null", Access::ReadWrite, Resolve::Device)
        .with_prefix("/dev/urandom", Access::Read, Resolve::Device)
}

fn prefix_of(p: &BrokerPolicy, path: &str, flags: u64) -> Option<PathBuf> {
    p.check_open(path.as_bytes(), flags, 0o644)
        .ok()
        .map(|o| p.prefixes[o.prefix].path.clone())
}

fn refused(p: &BrokerPolicy, path: &str, flags: u64) -> &'static str {
    match p.check_open(path.as_bytes(), flags, 0) {
        Ok(o) => panic!("{path} {flags:#x} was allowed: {o:?}"),
        Err(r) => r.0,
    }
}

#[test]
fn a_read_under_in_is_planned_below_in_with_the_rest_of_the_path() {
    let p = job();
    let o = p.check_open(b"/in/a/b.txt", R, 0).unwrap();
    assert_eq!(p.prefixes[o.prefix].path, Path::new("/in"));
    assert_eq!(o.rel, Path::new("a/b.txt"));
    assert_eq!(o.mode, 0, "no mode without O_CREAT");
    let o = p.check_open(b"/in", R | DIR, 0).unwrap();
    assert_eq!(o.rel, Path::new("."), "listing /in itself is a read");
}

#[test]
fn every_kind_of_write_under_in_or_the_image_is_refused() {
    let p = job();
    for path in ["/in/a", "/etc/passwd", "/bin/sh", "/proc/self/status"] {
        for f in [W, RW, R | CREAT, R | TRUNC, W | CREAT | TRUNC, TMPFILE | RW] {
            assert_eq!(
                refused(&p, path, f),
                "write under a read-only prefix",
                "{path} {f:#x}"
            );
        }
    }
}

#[test]
fn out_is_write_only_no_read_back_no_listing_no_o_path_no_tmpfile() {
    let p = job();
    assert_eq!(
        prefix_of(&p, "/out/r", W | CREAT | TRUNC),
        Some("/out".into())
    );
    assert_eq!(prefix_of(&p, "/out/sub/r", W | CREAT), Some("/out".into()));
    assert_eq!(refused(&p, "/out/r", R), "read under a write-only prefix");
    assert_eq!(refused(&p, "/out/r", RW), "read under a write-only prefix");
    assert_eq!(
        refused(&p, "/out/r", PATH),
        "O_PATH under a write-only prefix"
    );
    assert_eq!(
        refused(&p, "/out", R | DIR),
        "read under a write-only prefix"
    );
    assert_eq!(
        refused(&p, "/out/x", W | DIR),
        "a directory under a write-only prefix"
    );
    assert_eq!(
        refused(&p, "/out", TMPFILE | W),
        "a directory under a write-only prefix"
    );
    assert_eq!(refused(&p, "/out", W), "the write-only directory itself");
}

#[test]
fn dotdot_anywhere_is_refused_even_when_it_would_land_inside_a_prefix() {
    let p = job();
    for path in [
        "/in/../out/x",
        "/in/a/../a",
        "/tmp/..",
        "/../in/a",
        "/out/./../in/a",
    ] {
        assert_eq!(refused(&p, path, R), "`..` in path", "{path}");
    }
}

#[test]
fn dots_and_repeated_slashes_are_normalised_before_the_prefix_match() {
    let p = job();
    let o = p.check_open(b"//in/./a//b", R, 0).unwrap();
    assert_eq!(p.prefixes[o.prefix].path, Path::new("/in"));
    assert_eq!(o.rel, Path::new("a/b"));
    assert_eq!(
        refused(&p, "/./out//./r", R),
        "read under a write-only prefix"
    );
}

#[test]
fn a_sibling_whose_name_starts_like_a_prefix_is_not_inside_it() {
    let p = BrokerPolicy::job(JobId::random(), "/in", "/out");
    assert_eq!(refused(&p, "/input/a", R), "outside policy");
    assert_eq!(refused(&p, "/outside", W), "outside policy");
    assert_eq!(refused(&p, "/etc/passwd", R), "outside policy");
}

#[test]
fn the_longest_prefix_wins_so_tmp_is_writable_inside_a_read_only_image() {
    let p = job();
    assert_eq!(prefix_of(&p, "/tmp/x", RW | CREAT), Some("/tmp".into()));
    assert_eq!(prefix_of(&p, "/etc/marker", R), Some("/".into()));
    assert_eq!(
        refused(&p, "/etc/new", W | CREAT),
        "write under a read-only prefix"
    );
}

#[test]
fn a_device_prefix_matches_only_its_exact_path_and_opens_for_read_or_write_only() {
    let p = job();
    assert_eq!(prefix_of(&p, "/dev/null", W), Some("/dev/null".into()));
    assert_eq!(
        prefix_of(&p, "/dev/urandom", R),
        Some("/dev/urandom".into())
    );
    assert_eq!(
        refused(&p, "/dev/urandom", W),
        "write under a read-only prefix"
    );
    // `2>/dev/null`: O_CREAT|O_TRUNC on the existing device is fine; O_EXCL is not.
    assert_eq!(
        prefix_of(&p, "/dev/null", W | CREAT | TRUNC),
        Some("/dev/null".into())
    );
    assert_eq!(
        refused(&p, "/dev/null", W | CREAT | libc::O_EXCL as u64),
        "an exact path opens for read or write only"
    );
    assert_eq!(
        refused(&p, "/dev/null", R | DIR),
        "an exact path opens for read or write only"
    );
    // /dev/null/x and /dev/nullx are not /dev/null; they fall to the image
    // prefix "/" (where /dev is another mount and the open then fails).
    assert_eq!(prefix_of(&p, "/dev/nullx", R), Some("/".into()));
    let only = BrokerPolicy::empty(JobId::random()).with_prefix(
        "/dev/null",
        Access::ReadWrite,
        Resolve::Device,
    );
    assert_eq!(refused(&only, "/dev/null/x", R), "outside policy");
    assert_eq!(refused(&only, "/dev", R | DIR), "outside policy");
}

#[test]
fn the_kernels_o_largefile_bit_from_a_musl_worker_is_understood() {
    let p = job();
    let f = R | heimdall_broker::policy::O_LARGEFILE_KERNEL as u64;
    assert_eq!(p.check_open(b"/in/a", f, 0).unwrap().rel, Path::new("a"));
}

#[test]
fn o_direct_and_o_noatime_are_ordinary_flags_and_pass() {
    let p = job();
    let f = W | CREAT | TRUNC | libc::O_DIRECT as u64;
    assert_eq!(prefix_of(&p, "/tmp/f", f), Some("/tmp".into()));
    assert_eq!(
        prefix_of(&p, "/in/a", R | libc::O_NOATIME as u64),
        Some("/in".into())
    );
}

#[test]
fn unknown_open_flags_and_a_bad_access_mode_are_refused() {
    let p = job();
    assert_eq!(refused(&p, "/in/a", 1 << 33), "unknown open flags");
    assert_eq!(refused(&p, "/in/a", 0x4000_0000), "unknown open flags");
    assert_eq!(refused(&p, "/in/a", 3), "bad access mode");
}

#[test]
fn relative_and_empty_paths_never_reach_the_prefix_match() {
    let p = job();
    assert_eq!(refused(&p, "in/a", R), "relative path");
    assert_eq!(refused(&p, "", R), "empty path");
    assert_eq!(join_relative(b"/in/sub", b"a").unwrap(), b"/in/sub/a");
    assert!(join_relative(b"pipe:[1234]", b"a").is_err());
    assert!(join_relative(b"", b"a").is_err());
}

#[test]
fn the_creation_mode_is_kept_only_with_o_creat_and_cut_to_permission_bits() {
    let p = job();
    let o = p.check_open(b"/out/x", W | CREAT, 0o100_644).unwrap();
    assert_eq!(o.mode, 0o644);
    let o = p.check_open(b"/tmp/x", RW, 0o777).unwrap();
    assert_eq!(o.mode, 0);
}

#[test]
fn proc_self_thread_self_net_and_mounts_are_rewritten_to_the_callers_ids() {
    let r = |s: &str| proc_rewrite(Path::new(s), 7, 9);
    assert_eq!(r("self/status"), Path::new("7/status"));
    assert_eq!(r("self"), Path::new("7"));
    assert_eq!(r("thread-self/stat"), Path::new("7/task/9/stat"));
    assert_eq!(r("net/dev"), Path::new("7/net/dev"));
    assert_eq!(r("mounts"), Path::new("7/mounts"));
    assert_eq!(r("1/status"), Path::new("1/status"));
    assert_eq!(r("cpuinfo"), Path::new("cpuinfo"));
    assert_eq!(r("."), Path::new("."));
}

#[test]
fn without_a_fetch_peer_every_socket_and_connect_is_refused() {
    let p = job();
    for (d, t) in [
        (libc::AF_INET, libc::SOCK_STREAM),
        (libc::AF_UNIX, libc::SOCK_STREAM),
        (libc::AF_INET6, libc::SOCK_DGRAM),
    ] {
        assert_eq!(
            p.check_socket(d as u64, t as u64, 0).unwrap_err().0,
            "no network for this job"
        );
    }
    let sa = sockaddr_bytes("127.0.0.1:80".parse().unwrap());
    assert_eq!(
        p.check_connect(&sa).unwrap_err().0,
        "no network for this job"
    );
}

#[test]
fn a_fetch_job_with_several_peers_connects_to_each_and_to_nothing_else() {
    let a: SocketAddr = "10.1.2.3:443".parse().unwrap();
    let b: SocketAddr = "10.1.2.4:8443".parse().unwrap();
    let c: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
    let p = job().with_fetch(a).with_fetch(b).with_fetch(c);
    assert_eq!(p.fetch, vec![a, b, c]);
    for x in [a, b, c] {
        assert_eq!(p.check_connect(&sockaddr_bytes(x)).unwrap(), x);
    }
    let tcp = libc::SOCK_STREAM as u64;
    assert!(p.check_socket(libc::AF_INET as u64, tcp, 0).is_ok());
    assert!(p.check_socket(libc::AF_INET6 as u64, tcp, 0).is_ok());
    for other in [
        "10.1.2.3:8443",
        "10.1.2.4:443",
        "[2001:db8::1]:8443",
        "10.1.2.5:443",
    ] {
        let sa = sockaddr_bytes(other.parse().unwrap());
        assert_eq!(
            p.check_connect(&sa).unwrap_err().0,
            "address not allowed",
            "{other}"
        );
    }
    let v4only = job().with_fetch(a).with_fetch(b);
    assert_eq!(
        v4only
            .check_socket(libc::AF_INET6 as u64, tcp, 0)
            .unwrap_err()
            .0,
        "socket family not allowed"
    );
}

#[test]
fn a_fetch_job_may_open_one_tcp_socket_family_and_connect_to_exactly_its_peer() {
    let peer: SocketAddr = "10.1.2.3:443".parse().unwrap();
    let p = job().with_fetch(peer);
    let tcp = libc::SOCK_STREAM as u64;
    assert!(p.check_socket(libc::AF_INET as u64, tcp, 0).is_ok());
    assert!(p
        .check_socket(
            libc::AF_INET as u64,
            tcp | libc::SOCK_CLOEXEC as u64,
            libc::IPPROTO_TCP as u64
        )
        .is_ok());
    assert_eq!(
        p.check_socket(libc::AF_INET6 as u64, tcp, 0).unwrap_err().0,
        "socket family not allowed"
    );
    assert_eq!(
        p.check_socket(libc::AF_UNIX as u64, tcp, 0).unwrap_err().0,
        "socket family not allowed"
    );
    assert_eq!(
        p.check_socket(libc::AF_INET as u64, libc::SOCK_DGRAM as u64, 0)
            .unwrap_err()
            .0,
        "only TCP stream sockets"
    );
    assert_eq!(
        p.check_socket(libc::AF_INET as u64, libc::SOCK_RAW as u64, 0)
            .unwrap_err()
            .0,
        "only TCP stream sockets"
    );
    assert_eq!(
        p.check_socket(libc::AF_INET as u64, tcp, libc::IPPROTO_UDP as u64)
            .unwrap_err()
            .0,
        "only TCP stream sockets"
    );
    assert_eq!(p.check_connect(&sockaddr_bytes(peer)).unwrap(), peer);
    for other in ["10.1.2.3:444", "10.1.2.4:443", "127.0.0.1:443"] {
        let sa = sockaddr_bytes(other.parse().unwrap());
        assert_eq!(
            p.check_connect(&sa).unwrap_err().0,
            "address not allowed",
            "{other}"
        );
    }
    let mut unix = vec![0u8; 110];
    unix[0..2].copy_from_slice(&(libc::AF_UNIX as u16).to_ne_bytes());
    assert_eq!(
        p.check_connect(&unix).unwrap_err().0,
        "unix sockets not allowed"
    );
    assert_eq!(p.check_connect(&[2]).unwrap_err().0, "short sockaddr");
    assert_eq!(
        p.check_connect(&sockaddr_bytes(peer)[..8]).unwrap_err().0,
        "short sockaddr"
    );
}

#[test]
fn ipv6_sockaddrs_round_trip() {
    let a: SocketAddr = "[2001:db8::1]:8443".parse().unwrap();
    assert_eq!(parse_sockaddr(&sockaddr_bytes(a)).unwrap(), a);
    let a: SocketAddr = "192.0.2.7:65535".parse().unwrap();
    assert_eq!(parse_sockaddr(&sockaddr_bytes(a)).unwrap(), a);
}

#[test]
fn a_policy_with_a_relative_or_unnormalised_prefix_or_a_write_only_device_is_invalid() {
    let id = JobId::random();
    assert!(BrokerPolicy::job(id, "in", "/out").validate().is_err());
    assert!(BrokerPolicy::job(id, "/in/../x", "/out")
        .validate()
        .is_err());
    assert!(BrokerPolicy::empty(id)
        .with_prefix("/dev/null", Access::WriteOnly, Resolve::Device)
        .validate()
        .is_err());
    assert!(job().validate().is_ok());
}

#[test]
fn a_policy_survives_a_json_round_trip_unchanged() {
    let p = job().with_fetch("10.0.0.1:443".parse().unwrap());
    let back: BrokerPolicy = serde_json::from_str(&serde_json::to_string(&p).unwrap()).unwrap();
    assert_eq!(back, p);
}

proptest! {
    /// Whatever path the worker names, an allowed open stays inside the
    /// prefix it was matched to: prefix + rel is the normalised path, and rel
    /// has no `..`.
    #[test]
    fn an_allowed_open_always_stays_inside_its_prefix(parts in proptest::collection::vec("(in|out|tmp|proc|\\.|\\.\\.|a|b|etc|dev|null|//)", 0..8), w in any::<bool>()) {
        let path = format!("/{}", parts.join("/"));
        let p = job();
        let flags = if w { W | CREAT } else { R };
        if let Ok(o) = p.check_open(path.as_bytes(), flags, 0o600) {
            {
                let norm = normalise(path.as_bytes()).unwrap();
                let pre = &p.prefixes[o.prefix].path;
                let joined = if o.rel == Path::new(".") { pre.clone() } else { pre.join(&o.rel) };
                prop_assert_eq!(joined, norm);
                prop_assert!(!o.rel.components().any(|c| c == std::path::Component::ParentDir));
                if w {
                    prop_assert!(matches!(p.prefixes[o.prefix].access, Access::WriteOnly | Access::ReadWrite));
                } else {
                    prop_assert!(p.prefixes[o.prefix].access != Access::WriteOnly);
                }
            }
        }
        if parts.iter().any(|s| s == "..") {
            prop_assert!(p.check_open(path.as_bytes(), flags, 0).is_err());
        }
    }

    /// Arbitrary bytes never panic the decision functions.
    #[test]
    fn arbitrary_bytes_never_panic_a_decision(b in proptest::collection::vec(any::<u8>(), 0..300), f in any::<u64>(), m in any::<u64>()) {
        let p = job().with_fetch("10.0.0.1:443".parse().unwrap());
        let _ = p.check_open(&b, f, m);
        let _ = p.check_connect(&b);
        let _ = parse_sockaddr(&b);
        let _ = p.check_socket(f, m, f ^ m);
    }
}