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