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