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

nordisk/heimdall

crates/heimdall-container/tests/workspace.rs

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

//! The job workspace as data (no kernel needed): its mount, its Landlock
//! rights, the job checks. Heimdall is a defensive sandbox; nothing runs.

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

use heimdall_container::{check_job, confine, workspace};
use heimdall_core::*;
use serde_json::json;

fn ws(bytes: u64, exec: bool) -> Workspace {
    Workspace { bytes, exec }
}

#[test]
fn a_small_workspace_is_a_capped_noexec_tmpfs_in_the_container() {
    let m = workspace::mount(&ws(64 << 20, false), None).unwrap();
    assert_eq!(
        m,
        json!({
            "destination": "/work",
            "type": "tmpfs",
            "source": "tmpfs",
            "options": ["nosuid", "nodev", "noexec", "size=67108864", "mode=0755"],
        })
    );
    let m = workspace::mount(&ws(64 << 20, true), None).unwrap();
    assert!(
        !m["options"].as_array().unwrap().contains(&json!("noexec")),
        "{m}"
    );
}

#[test]
fn a_large_workspace_is_a_noexec_bind_of_its_own_host_folder() {
    let dir = Path::new("/var/lib/heimdall/work/workspaces/abc");
    let m = workspace::mount(&ws(2 << 30, false), Some(dir)).unwrap();
    assert_eq!(
        m,
        json!({
            "destination": "/work",
            "type": "bind",
            "source": "/var/lib/heimdall/work/workspaces/abc",
            "options": ["rbind", "rw", "nosuid", "nodev", "noexec"],
        })
    );
    let m = workspace::mount(&ws(2 << 30, true), Some(dir)).unwrap();
    assert!(
        !m["options"].as_array().unwrap().contains(&json!("noexec")),
        "{m}"
    );
    // The kinds cannot be mixed up.
    assert!(workspace::mount(&ws(2 << 30, false), None).is_err());
    assert!(workspace::mount(&ws(1 << 20, false), Some(dir)).is_err());
}

#[test]
fn the_host_folder_is_per_job_under_the_runtime_dir() {
    let id = JobId::random();
    assert_eq!(
        workspace::host_dir(Path::new("/w"), &id),
        PathBuf::from(format!("/w/workspaces/{}", id.to_hex()))
    );
}

fn rootfs() -> PathBuf {
    let d = std::env::temp_dir().join(format!("hc-ws-root-{}", JobId::random().to_hex()));
    for e in ["bin", "work", "etc"] {
        std::fs::create_dir_all(d.join(e)).unwrap();
    }
    d
}

#[test]
fn landlock_gives_the_workspace_read_write_and_exec_only_when_asked() {
    let r = rootfs();
    let p = confine::plan_with_workspace(&r, &[], &[], Some(&ws(1 << 20, false))).unwrap();
    assert!(p.read_write.contains(&PathBuf::from("/work")), "{p:?}");
    assert!(p.read_write_exec.is_empty(), "{p:?}");
    // The image's own /work never gets the image's rights under the mount.
    assert!(!p.read_exec.contains(&PathBuf::from("/work")), "{p:?}");
    let p = confine::plan_with_workspace(&r, &[], &[], Some(&ws(1 << 20, true))).unwrap();
    assert_eq!(p.read_write_exec, vec![PathBuf::from("/work")]);
    assert!(!p.read_write.contains(&PathBuf::from("/work")), "{p:?}");
    // Without a workspace the plan is the old one.
    let none = confine::plan_with_workspace(&r, &[], &[], None).unwrap();
    assert_eq!(none, confine::plan(&r, &[], &[]).unwrap());
    assert!(none.read_write_exec.is_empty());
}

#[test]
fn the_exec_rights_are_read_write_and_execute() {
    use landlock::AccessFs;
    let x = confine::rights::read_write_exec();
    assert!(x.contains(AccessFs::Execute));
    assert!(x.contains(confine::rights::read_write()));
    assert!(!confine::rights::read_write().contains(AccessFs::Execute));
}

fn job(w: Option<Workspace>, write: Vec<PathBuf>) -> Job {
    let mut j = Job::new(
        JobKind::Native {
            image: ImageRef {
                name: "x".into(),
                digest: format!("sha256:{}", "ab".repeat(32)),
            },
            argv: vec!["/bin/true".into()],
        },
        "/nonexistent/in".into(),
        "/nonexistent/out".into(),
        serde_json::Value::Null,
        Policy::default(),
    );
    j.policy.workspace = w;
    j.policy.write = write;
    j
}

#[test]
fn a_zero_byte_workspace_and_policy_paths_under_work_are_invalid() {
    assert!(matches!(
        check_job(&job(Some(ws(0, false)), vec![])),
        Err(RunError::InvalidJob { .. })
    ));
    assert!(matches!(
        check_job(&job(None, vec!["/work/x".into()])),
        Err(RunError::InvalidJob { .. })
    ));
    check_job(&job(Some(ws(1 << 20, false)), vec![])).unwrap();
}

#[test]
fn usage_counts_what_the_files_take_on_disk() {
    let d = std::env::temp_dir().join(format!("hc-ws-use-{}", JobId::random().to_hex()));
    std::fs::create_dir_all(d.join("a/b")).unwrap();
    std::fs::write(d.join("a/b/f"), vec![1u8; 1 << 20]).unwrap();
    std::fs::write(d.join("g"), vec![1u8; 4096]).unwrap();
    let n = workspace::usage(&d);
    assert!(n >= (1 << 20) + 4096, "{n}");
    assert!(n < 2 << 20, "{n}");
    assert_eq!(workspace::usage(&d.join("missing")), 0);
}