crates/heimdall-container/src/workspace.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The job workspace (owner decision 2026-11-01): a writable scratch area at
//! [`heimdall_core::WORKSPACE_PATH`] INSIDE the job's container.
//!
//! Heimdall is a defensive sandbox. The size picks the kind
//! ([`heimdall_core::Workspace::kind`]):
//!
//! - at most 1 GiB: a tmpfs in the container's own mount namespace, capped by
//! `size=` (the kernel refuses a write past it with ENOSPC; its pages count
//! against the job's memory limit);
//! - above: a per-job folder on the host disk, `<work_dir>/workspaces/<job>`
//! (mode 0700, owned by the runner), bind-mounted into that one container
//! only. The shim measures it while the job runs and kills the job past its
//! cap (`heimdall-limit-workspace`). Heimdall never executes anything in
//! it on the host.
//!
//! Both kinds are `nosuid,nodev` and `noexec` unless the job asks for `exec`
//! AND the runner allows it ([`crate::HeimdallConfig::allow_workspace_exec`]);
//! Landlock gives the workspace read and write, plus execute only then
//! ([`crate::confine::plan_with_workspace`]). Both are gone when the job
//! ends: the tmpfs with the container, the host folder removed by the runner.
//! Only what the job itself writes to `/out` leaves it, through the usual
//! output scan.
use std::path::{Path, PathBuf};
use heimdall_core::{JobId, Workspace, WorkspaceKind, WORKSPACE_PATH};
use serde_json::{json, Value};
/// The workspace's OCI mount: a tmpfs (`host` = `None`) or a bind of its
/// host folder (`host` = `Some`), as the kind says.
pub fn mount(ws: &Workspace, host: Option<&Path>) -> Result<Value, String> {
let exec = |mut o: Vec<String>| {
if !ws.exec {
o.push("noexec".into());
}
o
};
match (ws.kind(), host) {
(WorkspaceKind::Tmpfs, None) => {
let mut o = exec(vec!["nosuid".into(), "nodev".into()]);
o.push(format!("size={}", ws.bytes));
o.push("mode=0755".into());
Ok(json!({
"destination": WORKSPACE_PATH,
"type": "tmpfs",
"source": "tmpfs",
"options": o,
}))
}
(WorkspaceKind::HostDir, Some(dir)) => Ok(json!({
"destination": WORKSPACE_PATH,
"type": "bind",
"source": dir,
"options": exec(vec!["rbind".into(), "rw".into(), "nosuid".into(), "nodev".into()]),
})),
(k, h) => Err(format!(
"a {k:?} workspace of {} bytes with host folder {h:?}",
ws.bytes
)),
}
}
/// The per-job host folder of a large workspace.
pub fn host_dir(work_dir: &Path, id: &JobId) -> PathBuf {
work_dir.join("workspaces").join(id.to_hex())
}
/// Create the host folder (mode 0700; it must not exist yet).
pub fn create_host_dir(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::DirBuilderExt;
if let Some(p) = dir.parent() {
std::fs::create_dir_all(p)?;
}
std::fs::DirBuilder::new().mode(0o700).create(dir)
}
/// Bytes the files under `dir` take on disk (allocated blocks; links not
/// followed). 0 when `dir` is gone.
pub fn usage(dir: &Path) -> u64 {
use std::os::unix::fs::MetadataExt;
let mut total = 0u64;
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(rd) = std::fs::read_dir(&d) else {
continue;
};
for e in rd.flatten() {
let Ok(m) = e.metadata() else { continue };
total = total.saturating_add(m.blocks().saturating_mul(512));
if m.is_dir() {
stack.push(e.path());
}
}
}
total
}