server/sandbox/src/media.rs
//! **The sandbox on the medium** (lane HOLGER-MEDIA, 2026-10-23): what a main or
//! twin box's app archive carries beside the server, and how it is laid on the
//! box before the server serves.
//!
//! The medium carries, under the entry directory [`ENTRY_DIR`] of the app
//! `.znippy` (the same signed archive korp-start verifies at every boot, so the
//! installer, the metered installer, the keep medium and the live app all carry
//! it):
//!
//! | Entry | What |
//! |---|---|
//! | [`MANIFEST`] | this manifest: every entry's sha256 and size |
//! | [`CRUN`] | the static OCI runtime (`crun-<ver>-linux-amd64-disable-systemd`) |
//! | [`WORKER_ROOTFS`] | the scan worker's root filesystem, a tar: the static worker, `/etc/passwd`, `/etc/group`, the mount points |
//! | [`CLAMAV_ROOTFS`] | the pinned ClamAV image flattened to one tar, its signature database inside |
//!
//! At boot the server lays each entry under its sandbox directory
//! (`<data_root>/sandbox`), checks its sha256 against the manifest (a second leg
//! over the archive's own signed root), unpacks the rootfs tars with every
//! owner shifted into the containers' user namespace, and only then builds the
//! scan worker ([`crate::crun::Crun`]) and starts clamd. An entry that is
//! missing, short or wrong is a refusal by name, and the scans stay refused
//! (`holger-sandbox-1`): never an unverified rootfs.
use std::io::Read as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::Refusal;
/// The entry directory inside the app archive.
pub const ENTRY_DIR: &str = "sandbox";
/// The manifest entry.
pub const MANIFEST: &str = "manifest.toml";
/// The OCI runtime entry.
pub const CRUN: &str = "crun";
/// The scan worker's rootfs entry.
pub const WORKER_ROOTFS: &str = "holger-scan-worker.rootfs.tar";
/// The ClamAV rootfs entry.
pub const CLAMAV_ROOTFS: &str = "clamav.rootfs.tar";
/// The manifest's schema.
pub const SCHEMA: &str = "holger.sandbox-media/v1";
/// Heimdall's shim (lane HOL-HEIMDALL-03): `heimdall-container-shim`, static, the program
/// `engine: heimdall` starts per job. A medium that carries it also lays the worker's rootfs
/// tar whole ([`Layout::worker_rootfs_tar`]): Heimdall mints the pinned worker image from it.
pub const HEIMDALL_SHIM: &str = "heimdall-container-shim";
/// The pinned static crun release.
pub const CRUN_VERSION: &str = "1.21";
pub const CRUN_URL: &str = "https://github.com/containers/crun/releases/download/1.21/crun-1.21-linux-amd64-disable-systemd";
pub const CRUN_SHA256: &str = "53843205fe3fe38fb8f2ef08798d55d4746d1172696d580ef1e6d7ef7ce3afe6";
/// The pinned ClamAV image (its signature database inside; `scripts/clamd-sandbox.sh` pins the same).
pub const CLAMAV_IMAGE: &str = "docker.io/clamav/clamav:1.5.4@sha256:e19130cdebaeacc71fa46eab3ed6f69941f2f22aa5e08d55e9197f8edf43ce5e";
/// `sandbox/<name>`: the entry's path inside the archive.
pub fn entry_path(name: &str) -> String {
format!("{ENTRY_DIR}/{name}")
}
/// What one entry is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
/// A static program, laid executable.
Program,
/// A root filesystem tar, unpacked into a directory.
Rootfs,
/// One of Heimdall's released verify-bundle guests (a module, its `.wasm.blake3`, its
/// `.wasm.p7s`, the release's SLSA statement): laid read-only under
/// [`Layout::heimdall_guests`] (lane HOL-HEIMDALL-03).
Guest,
}
/// One entry of the manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
/// The entry's file name under [`ENTRY_DIR`].
pub name: String,
pub kind: Kind,
/// Its sha256, hex.
pub sha256: String,
pub bytes: u64,
/// The image it was flattened from (a rootfs), or the program's version.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
/// The manifest: every entry the medium carries.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema: String,
#[serde(rename = "entry")]
pub entries: Vec<Entry>,
}
impl Manifest {
/// The manifest of the three entries, from their bytes.
pub fn of(crun: &[u8], worker_rootfs: &[u8], clamav_rootfs: &[u8]) -> Manifest {
Manifest {
schema: SCHEMA.into(),
entries: vec![
Entry {
name: CRUN.into(),
kind: Kind::Program,
sha256: sha256_hex(crun),
bytes: crun.len() as u64,
source: Some(format!("crun {CRUN_VERSION}")),
},
Entry {
name: WORKER_ROOTFS.into(),
kind: Kind::Rootfs,
sha256: sha256_hex(worker_rootfs),
bytes: worker_rootfs.len() as u64,
source: Some(crate::DEFAULT_IMAGE.into()),
},
Entry {
name: CLAMAV_ROOTFS.into(),
kind: Kind::Rootfs,
sha256: sha256_hex(clamav_rootfs),
bytes: clamav_rootfs.len() as u64,
source: Some(CLAMAV_IMAGE.into()),
},
],
}
}
/// This manifest with one more entry (Heimdall's shim, a guest file).
#[must_use]
pub fn with(
mut self,
name: &str,
kind: Kind,
bytes: &[u8],
source: Option<String>,
) -> Manifest {
self.entries.push(Entry {
name: name.into(),
kind,
sha256: sha256_hex(bytes),
bytes: bytes.len() as u64,
source,
});
self
}
/// Does this medium carry Heimdall (its shim)?
pub fn carries_heimdall(&self) -> bool {
self.entry(HEIMDALL_SHIM).is_some()
}
/// Refuse a manifest that is not what this build reads, by name.
pub fn check(&self) -> Result<(), Refusal> {
if self.schema != SCHEMA {
return Err(Refusal::Config(format!(
"the sandbox manifest schema is {:?}, this build reads {SCHEMA:?}",
self.schema
)));
}
for want in [CRUN, WORKER_ROOTFS, CLAMAV_ROOTFS] {
if !self.entries.iter().any(|e| e.name == want) {
return Err(Refusal::Config(format!(
"the sandbox manifest names no {want:?} entry"
)));
}
}
for e in &self.entries {
if !valid_name(&e.name) {
return Err(Refusal::Config(format!(
"the sandbox manifest names an entry {:?} that is not one plain file name",
e.name
)));
}
if e.sha256.len() != 64 || !e.sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(Refusal::Config(format!(
"entry {:?}: sha256 {:?} is not 64 hex digits",
e.name, e.sha256
)));
}
if e.bytes == 0 {
return Err(Refusal::Config(format!("entry {:?} is empty", e.name)));
}
}
Ok(())
}
pub fn entry(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.name == name)
}
/// Is `bytes` the entry `name` the manifest describes? The refusal names
/// what differs.
pub fn verify(&self, name: &str, bytes: &[u8]) -> Result<&Entry, Refusal> {
let e = self.entry(name).ok_or_else(|| {
Refusal::Config(format!("the sandbox manifest names no {name:?} entry"))
})?;
if bytes.len() as u64 != e.bytes {
return Err(Refusal::ImageMissing(format!(
"sandbox entry {name:?}: {} bytes on the medium, the manifest says {}",
bytes.len(),
e.bytes
)));
}
let got = sha256_hex(bytes);
if got != e.sha256 {
return Err(Refusal::ImageMissing(format!(
"sandbox entry {name:?}: sha256 {got} on the medium, the manifest says {}",
e.sha256
)));
}
Ok(e)
}
}
fn valid_name(n: &str) -> bool {
!n.is_empty()
&& n != "."
&& n != ".."
&& n.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
}
pub fn sha256_hex(b: &[u8]) -> String {
use sha2::Digest as _;
sha2::Sha256::digest(b)
.iter()
.map(|x| format!("{x:02x}"))
.collect()
}
// ── the worker's rootfs ─────────────────────────────────────────────────────
/// **The scan worker's root filesystem as a tar**, from its static binary: what
/// `distribution/scan-worker/Containerfile` says, as files. Every owner is
/// root (uid 0 inside the container's user namespace); [`lay_rootfs`] shifts
/// it onto the host.
pub fn worker_rootfs_tar(worker: &[u8]) -> Vec<u8> {
let mut t = tar::Builder::new(Vec::new());
t.mode(tar::HeaderMode::Deterministic);
let dir = |t: &mut tar::Builder<Vec<u8>>, path: &str, mode: u32| {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Directory);
h.set_mode(mode);
h.set_size(0);
h.set_uid(0);
h.set_gid(0);
h.set_mtime(0);
t.append_data(&mut h, path, std::io::empty())
.expect("a tar in memory");
};
let file = |t: &mut tar::Builder<Vec<u8>>, path: &str, mode: u32, bytes: &[u8]| {
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Regular);
h.set_mode(mode);
h.set_size(bytes.len() as u64);
h.set_uid(0);
h.set_gid(0);
h.set_mtime(0);
t.append_data(&mut h, path, bytes).expect("a tar in memory");
};
file(&mut t, "holger-scan-worker", 0o555, worker);
dir(&mut t, "etc", 0o755);
file(
&mut t,
"etc/passwd",
0o444,
b"nonroot:x:65532:65532:nonroot:/scratch:/sbin/nologin\n",
);
file(&mut t, "etc/group", 0o444, b"nonroot:x:65532:\n");
// A job's mount points (lane HOL-SANDBOX-TRAIT): the input `in/i`, the read paths
// `in/r0`..`in/r3` and the one writable `out`; crun binds onto a read-only root, so a
// target that is not laid here fails the container's start.
for (d, m) in [
("scratch", 0o1777),
("in", 0o555),
("in/d-yara", 0o555),
("in/i", 0o555),
("in/r0", 0o555),
("in/r1", 0o555),
("in/r2", 0o555),
("in/r3", 0o555),
("out", 0o555),
("proc", 0o555),
("dev", 0o755),
("sys", 0o555),
("tmp", 0o1777),
] {
dir(&mut t, d, m);
}
file(&mut t, "in/artifact", 0o444, b"");
file(&mut t, "in/job.json", 0o444, b"");
t.into_inner().expect("a tar in memory")
}
// ── laying on the box ───────────────────────────────────────────────────────
/// Where the sandbox lives on a box: `<data_root>/sandbox`.
#[derive(Debug, Clone)]
pub struct Layout {
pub dir: PathBuf,
}
impl Layout {
pub fn under(data_root: &Path) -> Layout {
Layout {
dir: data_root.join("sandbox"),
}
}
/// The runtime program.
pub fn crun(&self) -> PathBuf {
self.dir.join(CRUN)
}
/// The worker's rootfs directory.
pub fn worker_rootfs(&self) -> PathBuf {
self.dir.join("worker").join("rootfs")
}
/// ClamAV's rootfs directory.
pub fn clamav_rootfs(&self) -> PathBuf {
self.dir.join("clamav").join("rootfs")
}
/// clamd's run directory (the socket appears in it) and its config.
pub fn clamd_run(&self) -> PathBuf {
self.dir.join("clamd")
}
/// The scanner signature database a signed `holger.scanner-sigs/v1` bundle installs
/// (lane HOL-NCSC): `clamav/` is clamd's DatabaseDirectory once `installed.json` exists.
/// Outside every rootfs, so the start's re-hash never sees it change.
pub fn signatures(&self) -> PathBuf {
self.dir.join("signatures")
}
pub fn clamd_socket(&self) -> PathBuf {
self.clamd_run().join("clamd.sock")
}
/// The job directories (`work_dir`) and the runtime's state.
pub fn jobs(&self) -> PathBuf {
self.dir.join("jobs")
}
pub fn state(&self) -> PathBuf {
self.dir.join("state")
}
/// Heimdall's shim (lane HOL-HEIMDALL-03), inside the exec view of the sandbox.
pub fn heimdall_shim(&self) -> PathBuf {
self.dir.join(HEIMDALL_SHIM)
}
/// The worker's rootfs tar, laid whole beside its unpacked tree when the medium carries
/// Heimdall: `engine: heimdall` mints the worker image from it.
pub fn worker_rootfs_tar(&self) -> PathBuf {
self.dir.join(WORKER_ROOTFS)
}
/// The OCI layout Heimdall's worker image is minted into.
pub fn heimdall_oci(&self) -> PathBuf {
self.dir.join("oci")
}
/// Heimdall's per-job bundles and state.
pub fn heimdall_work(&self) -> PathBuf {
self.dir.join("heimdall")
}
/// Heimdall's released verify-bundle guests, read-only.
pub fn heimdall_guests(&self) -> PathBuf {
self.dir.join("heimdall-guests")
}
/// The record of what was laid: each entry's sha256, so a boot that finds
/// the same medium lays nothing again.
pub fn laid_record(&self) -> PathBuf {
self.dir.join("laid.toml")
}
}
/// **Unpack a rootfs tar into `dir`**, hardened: no absolute entry path, no `..`
/// in one, no device node or fifo, no setuid or setgid bit on a file, no hard
/// link outside the root, no relative symlink that climbs out of it, and
/// nothing ever written THROUGH a symlink (a `lib -> /x` followed by `lib/evil`
/// is refused by name). An ABSOLUTE symlink target (`bin/arch -> /bin/busybox`,
/// 306 of them in the ClamAV image) is allowed: it resolves inside the
/// container's own root and is never followed here. MEASURED on the first laid
/// box (2026-10-24): the stricter rule refused the whole ClamAV rootfs and every
/// scan with it. Every owner is shifted by `uid_shift` (the containers' user
/// namespace base) so the files are theirs inside and nobody's outside; a shift
/// of 0 leaves the owners as the unpacking user (a test, a developer's box).
/// The directory is replaced whole.
pub fn lay_rootfs(tar_bytes: &[u8], dir: &Path, uid_shift: u32) -> Result<usize, Refusal> {
let io = |what: &str, e: std::io::Error| Refusal::Io(format!("{what}: {e}"));
let staging = dir.with_extension("laying");
let _ = remove_tree(&staging);
std::fs::create_dir_all(&staging).map_err(|e| io(&staging.display().to_string(), e))?;
let mut a = tar::Archive::new(tar_bytes);
a.set_preserve_permissions(true);
a.set_unpack_xattrs(false);
a.set_overwrite(true);
let mut n = 0usize;
// Each directory's own mode is set LAST: laid as it comes, a read-only directory
// (`in/` 0555 before `in/d-yara`) refuses the entries under it to an unpacking user
// without CAP_DAC_OVERRIDE (every non-root run, measured by OCI-R1 with keep-id).
let mut dirs: Vec<(PathBuf, u32)> = Vec::new();
for entry in a.entries().map_err(|e| io("the rootfs tar", e))? {
let mut entry = entry.map_err(|e| io("the rootfs tar", e))?;
let path = entry
.path()
.map_err(|e| io("an entry's path", e))?
.into_owned();
let rel =
checked_relative(&path).map_err(|w| Refusal::Config(format!("the rootfs tar: {w}")))?;
let kind = entry.header().entry_type();
let mode = entry.header().mode().unwrap_or(0o644);
if mode & 0o6000 != 0 && kind != tar::EntryType::Directory {
return Err(Refusal::Config(format!(
"the rootfs tar: {} carries a setuid/setgid bit",
rel.display()
)));
}
// Nothing is written through a symlink: every parent of the entry must be a
// real directory of this rootfs (a symlink parent, absolute or not, would be
// followed to wherever it points on the host).
through_no_symlink(&staging, &rel)?;
match kind {
tar::EntryType::Regular
| tar::EntryType::Continuous
| tar::EntryType::Directory
| tar::EntryType::GNULongName
| tar::EntryType::GNULongLink => {}
tar::EntryType::Symlink => {
let target = entry
.link_name()
.map_err(|e| io("a link", e))?
.map(|p| p.into_owned())
.unwrap_or_default();
// Absolute: the container's own root, never followed here. Relative: it
// must not climb out of the rootfs.
if !target.is_absolute()
&& target
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
&& leaves_root(&rel, &target)
{
return Err(Refusal::Config(format!(
"the rootfs tar: symlink {} -> {} leaves the root",
rel.display(),
target.display()
)));
}
}
tar::EntryType::Link => {
let target = entry
.link_name()
.map_err(|e| io("a link", e))?
.map(|p| p.into_owned())
.unwrap_or_default();
checked_relative(&target).map_err(|w| {
Refusal::Config(format!("the rootfs tar: hard link {}: {w}", rel.display()))
})?;
}
other => {
return Err(Refusal::Config(format!(
"the rootfs tar: {} is a {other:?} entry (no device nodes, fifos or sockets)",
rel.display()
)))
}
}
let dest = staging.join(&rel);
if rel.as_os_str().is_empty() {
continue;
}
entry
.unpack_in(&staging)
.map_err(|e| io(&format!("unpack {}", rel.display()), e))?;
if kind == tar::EntryType::Directory {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(
&dest,
std::fs::Permissions::from_mode((mode & 0o7777) | 0o700),
)
.map_err(|e| io(&format!("chmod {}", rel.display()), e))?;
dirs.retain(|(d, _)| d != &dest);
dirs.push((dest.clone(), mode & 0o7777));
}
if uid_shift != 0 {
let uid = entry.header().uid().unwrap_or(0) as u32;
let gid = entry.header().gid().unwrap_or(0) as u32;
lchown(
&dest,
uid.saturating_add(uid_shift),
gid.saturating_add(uid_shift),
)?;
}
n += 1;
}
// Directories last, the deepest first: a read-only directory refuses the files under it
// while unpacking.
dirs.sort_by_key(|(d, _)| std::cmp::Reverse(d.components().count()));
for (d, mode) in &dirs {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(d, std::fs::Permissions::from_mode(*mode))
.map_err(|e| io(&format!("chmod {}", d.display()), e))?;
}
let _ = remove_tree(dir);
std::fs::rename(&staging, dir).map_err(|e| {
io(
&format!("rename {} to {}", staging.display(), dir.display()),
e,
)
})?;
Ok(n)
}
/// **Remove a laid tree**, its read-only directories too: each directory is made writable
/// for its owner first (a non-root owner cannot unlink in a 0555 directory). Symlinks are
/// never followed.
pub fn remove_tree(dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt as _;
fn open_up(d: &Path) {
let Ok(m) = std::fs::symlink_metadata(d) else {
return;
};
if !m.is_dir() {
return;
}
let _ = std::fs::set_permissions(
d,
std::fs::Permissions::from_mode((m.permissions().mode() & 0o7777) | 0o700),
);
if let Ok(rd) = std::fs::read_dir(d) {
for e in rd.flatten() {
open_up(&e.path());
}
}
}
open_up(dir);
std::fs::remove_dir_all(dir)
}
/// Every ancestor of `rel` under `root` that exists must be a directory and not
/// a symlink (so nothing is written through one).
fn through_no_symlink(root: &Path, rel: &Path) -> Result<(), Refusal> {
let mut at = root.to_path_buf();
let parts: Vec<_> = rel.components().collect();
for c in parts.iter().take(parts.len().saturating_sub(1)) {
at.push(c);
match std::fs::symlink_metadata(&at) {
Ok(m) if m.file_type().is_symlink() => {
return Err(Refusal::Config(format!(
"the rootfs tar: {} would be written through the symlink {}",
rel.display(),
at.strip_prefix(root).unwrap_or(&at).display()
)));
}
_ => {}
}
}
Ok(())
}
fn leaves_root(at: &Path, target: &Path) -> bool {
let mut depth = at.components().count().saturating_sub(1) as i64;
for c in target.components() {
match c {
std::path::Component::ParentDir => {
depth -= 1;
if depth < 0 {
return true;
}
}
std::path::Component::Normal(_) => depth += 1,
_ => {}
}
}
false
}
/// A relative path with no `..`, no root, no empty or `.` components beyond the first.
fn checked_relative(p: &Path) -> Result<PathBuf, String> {
let mut out = PathBuf::new();
for c in p.components() {
match c {
std::path::Component::Normal(n) => out.push(n),
std::path::Component::CurDir => {}
std::path::Component::RootDir | std::path::Component::Prefix(_) => {
return Err(format!("{} is absolute", p.display()))
}
std::path::Component::ParentDir => {
return Err(format!("{} climbs out (..)", p.display()))
}
}
}
Ok(out)
}
// ── re-hashing what was laid (NCSC P1, lane HOL-NCSC) ──────────────────────
/// **A laid tree, as words**: relative path → `d <mode>`, `f <mode> <sha256>` or `l <target>`,
/// each with ` <uid>:<gid>` when the owners were shifted. `d ?` is a parent directory the tar
/// does not name (laid with whatever mode the unpacking made).
pub type Tree = std::collections::BTreeMap<String, String>;
const IMPLIED_DIR: &str = "d ?";
fn owners(uid: u64, gid: u64, uid_shift: u32) -> String {
if uid_shift == 0 {
String::new()
} else {
format!(
" {}:{}",
uid.saturating_add(uid_shift as u64),
gid.saturating_add(uid_shift as u64)
)
}
}
fn sha256_of(r: &mut dyn std::io::Read) -> std::io::Result<String> {
use sha2::Digest as _;
let mut h = sha2::Sha256::new();
let mut buf = vec![0u8; 64 << 10];
loop {
let n = r.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Ok(h.finalize().iter().map(|x| format!("{x:02x}")).collect())
}
/// **What [`lay_rootfs`] lays from `tar_bytes`**, as a [`Tree`] (the same `uid_shift`).
pub fn tar_tree(tar_bytes: &[u8], uid_shift: u32) -> Result<Tree, Refusal> {
let io = |what: &str, e: std::io::Error| Refusal::Io(format!("{what}: {e}"));
let mut t = Tree::new();
let mut a = tar::Archive::new(tar_bytes);
for entry in a.entries().map_err(|e| io("the rootfs tar", e))? {
let mut entry = entry.map_err(|e| io("the rootfs tar", e))?;
let path = entry
.path()
.map_err(|e| io("an entry's path", e))?
.into_owned();
let rel =
checked_relative(&path).map_err(|w| Refusal::Config(format!("the rootfs tar: {w}")))?;
if rel.as_os_str().is_empty() {
continue;
}
let key = rel.display().to_string();
let h = entry.header();
let mode = h.mode().unwrap_or(0o644) & 0o7777;
let own = owners(h.uid().unwrap_or(0), h.gid().unwrap_or(0), uid_shift);
let words = match h.entry_type() {
tar::EntryType::Directory => format!("d {mode:o}{own}"),
tar::EntryType::Regular | tar::EntryType::Continuous => {
format!(
"f {mode:o} {}{own}",
sha256_of(&mut entry).map_err(|e| io(&key, e))?
)
}
tar::EntryType::Symlink => {
let target = entry
.link_name()
.map_err(|e| io("a link", e))?
.map(|p| p.display().to_string())
.unwrap_or_default();
format!("l {target}{own}")
}
tar::EntryType::Link => {
// One inode with its target: the target's mode and bytes; the owner the last
// lay wrote (this entry's), on both names.
let target = entry
.link_name()
.map_err(|e| io("a link", e))?
.map(|p| p.into_owned())
.unwrap_or_default();
let tkey = checked_relative(&target)
.map_err(|w| Refusal::Config(format!("the rootfs tar: hard link {key}: {w}")))?
.display()
.to_string();
let Some(tw) = t.get(&tkey).cloned() else {
return Err(Refusal::Config(format!("the rootfs tar: hard link {key} names {tkey}, which comes later or not at all")));
};
let parts: Vec<&str> = tw.split(' ').take(3).collect();
let words = format!("{}{own}", parts.join(" "));
t.insert(tkey, words.clone());
words
}
_ => continue,
};
for anc in rel.ancestors().skip(1) {
if anc.as_os_str().is_empty() {
break;
}
t.entry(anc.display().to_string())
.or_insert_with(|| IMPLIED_DIR.to_string());
}
t.insert(key, words);
}
Ok(t)
}
/// **What is laid at `dir`**, as a [`Tree`]. Symlinks are read, never followed.
pub fn dir_tree(dir: &Path, uid_shift: u32) -> Result<Tree, Refusal> {
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
let io = |what: &Path, e: std::io::Error| Refusal::Io(format!("{}: {e}", what.display()));
let mut t = Tree::new();
let mut todo = vec![dir.to_path_buf()];
while let Some(d) = todo.pop() {
for e in std::fs::read_dir(&d).map_err(|e| io(&d, e))? {
let p = e.map_err(|e| io(&d, e))?.path();
let m = std::fs::symlink_metadata(&p).map_err(|e| io(&p, e))?;
let key = p.strip_prefix(dir).unwrap_or(&p).display().to_string();
let mode = m.permissions().mode() & 0o7777;
// The laid owners as they are: the lay already shifted them (`uid + shift`); the
// tar's side ([`tar_tree`]) adds the shift. Shifting here again made every laid
// rootfs differ under a non-zero shift (lane HOL-CDS-5).
let own = if uid_shift == 0 {
String::new()
} else {
format!(" {}:{}", m.uid(), m.gid())
};
let ft = m.file_type();
let words = if ft.is_symlink() {
format!(
"l {}{own}",
std::fs::read_link(&p).map_err(|e| io(&p, e))?.display()
)
} else if ft.is_dir() {
todo.push(p.clone());
format!("d {mode:o}{own}")
} else if ft.is_file() {
let mut f = std::fs::File::open(&p).map_err(|e| io(&p, e))?;
format!(
"f {mode:o} {}{own}",
sha256_of(&mut f).map_err(|e| io(&p, e))?
)
} else {
format!("x {:?}", ft)
};
t.insert(key, words);
}
}
Ok(t)
}
/// The sha256 of nothing: an empty file.
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
/// **The first way `got` is not `want`**, in words, or `None`. A parent directory the tar does
/// not name may have any mode. A directory or an EMPTY file that is not in the tar is a mount
/// point the runtime made (crun makes its bind targets inside the root) and is not a change
/// (what a directory holds is read entry by entry); anything else beside the tar is.
pub fn tree_difference(want: &Tree, got: &Tree) -> Option<String> {
for (path, w) in want {
match got.get(path) {
None => return Some(format!("{path} is missing")),
Some(g) if w == IMPLIED_DIR => {
if !g.starts_with("d ") {
return Some(format!(
"{path} is `{g}`, the medium lays a directory there"
));
}
}
Some(g) if g != w => return Some(format!("{path} is `{g}`, the medium says `{w}`")),
Some(_) => {}
}
}
for (path, g) in got.iter().filter(|(p, _)| !want.contains_key(*p)) {
let mut f = g.split(' ');
// A directory holds nothing by itself: what is in it is read entry by entry here.
let dir = g.starts_with("d ")
&& f.nth(1)
.and_then(|m| u32::from_str_radix(m, 8).ok())
.is_some_and(|m| m & 0o4000 == 0);
let mut f = g.split(' ');
let empty_file = f.next() == Some("f")
&& f.next()
.and_then(|m| u32::from_str_radix(m, 8).ok())
.is_some_and(|m| m & 0o6000 == 0)
&& f.next() == Some(EMPTY_SHA256);
if !(dir || empty_file) {
return Some(format!("{path} (`{g}`) is not on the medium"));
}
}
None
}
/// **Is the rootfs laid at `dir` still what `tar_bytes` lays?** `None` when it is, else the
/// first difference in words.
pub fn laid_rootfs_difference(
tar_bytes: &[u8],
dir: &Path,
uid_shift: u32,
) -> Result<Option<String>, Refusal> {
let want = tar_tree(tar_bytes, uid_shift)?;
let got = dir_tree(dir, uid_shift)?;
Ok(tree_difference(&want, &got))
}
/// **Is the program laid at `at` still the entry `sha256`** (executable, 0755)?
pub fn laid_program_difference(sha256: &str, at: &Path) -> Result<Option<String>, Refusal> {
use std::os::unix::fs::PermissionsExt as _;
let m =
std::fs::symlink_metadata(at).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
if !m.file_type().is_file() {
return Ok(Some(format!("{} is not a plain file", at.display())));
}
let mode = m.permissions().mode() & 0o7777;
if mode != 0o755 {
return Ok(Some(format!(
"{} has mode {mode:o}, laid 755",
at.display()
)));
}
let mut f =
std::fs::File::open(at).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
let got = sha256_of(&mut f).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
Ok((got != sha256).then(|| {
format!(
"{} has sha256 {got}, the medium says {sha256}",
at.display()
)
}))
}
/// **Is the read-only file laid at `at` still the entry `sha256`** (0444)?
pub fn laid_file_difference(sha256: &str, at: &Path) -> Result<Option<String>, Refusal> {
use std::os::unix::fs::PermissionsExt as _;
let m =
std::fs::symlink_metadata(at).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
if !m.file_type().is_file() {
return Ok(Some(format!("{} is not a plain file", at.display())));
}
let mode = m.permissions().mode() & 0o7777;
if mode != 0o444 {
return Ok(Some(format!(
"{} has mode {mode:o}, laid 444",
at.display()
)));
}
let mut f =
std::fs::File::open(at).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
let got = sha256_of(&mut f).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
Ok((got != sha256).then(|| {
format!(
"{} has sha256 {got}, the medium says {sha256}",
at.display()
)
}))
}
/// Lay a read-only file (a guest, the worker's rootfs tar): written whole, then 0444.
pub fn lay_file(bytes: &[u8], at: &Path) -> Result<(), Refusal> {
use std::os::unix::fs::PermissionsExt as _;
if let Some(d) = at.parent() {
std::fs::create_dir_all(d).map_err(|e| Refusal::Io(format!("{}: {e}", d.display())))?;
}
let tmp = at.with_extension("laying");
let _ = std::fs::remove_file(&tmp);
std::fs::write(&tmp, bytes).map_err(|e| Refusal::Io(format!("{}: {e}", tmp.display())))?;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o444))
.map_err(|e| Refusal::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, at).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
Ok(())
}
/// `chown` without following a symlink (the link itself gets the owner).
pub fn lchown(p: &Path, uid: u32, gid: u32) -> Result<(), Refusal> {
rustix::fs::chownat(
rustix::fs::CWD,
p,
Some(rustix::fs::Uid::from_raw(uid)),
Some(rustix::fs::Gid::from_raw(gid)),
rustix::fs::AtFlags::SYMLINK_NOFOLLOW,
)
.map_err(|e| {
Refusal::Io(format!(
"chown {} to {uid}:{gid}: {e} (the sandbox is laid by root)",
p.display()
))
})
}
/// Lay the runtime program: written whole, then made executable.
pub fn lay_program(bytes: &[u8], at: &Path) -> Result<(), Refusal> {
use std::os::unix::fs::PermissionsExt as _;
if let Some(d) = at.parent() {
std::fs::create_dir_all(d).map_err(|e| Refusal::Io(format!("{}: {e}", d.display())))?;
}
let tmp = at.with_extension("laying");
std::fs::write(&tmp, bytes).map_err(|e| Refusal::Io(format!("{}: {e}", tmp.display())))?;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.map_err(|e| Refusal::Io(format!("{}: {e}", tmp.display())))?;
std::fs::rename(&tmp, at).map_err(|e| Refusal::Io(format!("{}: {e}", at.display())))?;
Ok(())
}
/// Read a file whole, naming it on failure.
pub fn read(p: &Path) -> Result<Vec<u8>, Refusal> {
let mut f = std::fs::File::open(p).map_err(|e| Refusal::Io(format!("{}: {e}", p.display())))?;
let mut v = Vec::new();
f.read_to_end(&mut v)
.map_err(|e| Refusal::Io(format!("{}: {e}", p.display())))?;
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_worker_rootfs_tar_carries_the_image_as_files() {
let t = worker_rootfs_tar(b"\x7fELF-worker");
let mut a = tar::Archive::new(&t[..]);
let mut seen = std::collections::BTreeMap::new();
for e in a.entries().unwrap() {
let e = e.unwrap();
let p = e.path().unwrap().display().to_string();
seen.insert(
p,
(
e.header().mode().unwrap(),
e.header().uid().unwrap(),
e.header().entry_type(),
),
);
}
assert_eq!(
seen["holger-scan-worker"],
(0o555, 0, tar::EntryType::Regular)
);
assert_eq!(seen["etc/passwd"].0, 0o444);
assert_eq!(seen["scratch"], (0o1777, 0, tar::EntryType::Directory));
for p in [
"in",
"in/d-yara",
"proc",
"dev",
"sys",
"tmp",
"in/artifact",
"in/job.json",
] {
assert!(seen.contains_key(p), "{p} in {:?}", seen.keys());
}
// A job's mount points (lane HOL-SANDBOX-TRAIT): crun binds onto a READ-ONLY root, so
// every target is laid here: the job's input /in/i, its read paths /in/r0.. and /out.
for p in ["in/i", "in/r0", "in/r1", "in/r2", "in/r3", "out"] {
assert_eq!(
seen.get(p).map(|e| e.2),
Some(tar::EntryType::Directory),
"the mount point {p} in {:?}",
seen.keys()
);
}
assert_eq!(
worker_rootfs_tar(b"x"),
worker_rootfs_tar(b"x"),
"deterministic: the same bytes every time"
);
}
#[test]
fn the_manifest_round_trips_and_verifies_each_entry() {
let m = Manifest::of(b"crun", b"worker", b"clamav");
m.check().unwrap();
let text = toml::to_string(&m).unwrap();
assert!(
text.contains("[[entry]]")
&& text.contains("name = \"crun\"")
&& text.contains("kind = \"program\""),
"{text}"
);
let back: Manifest = toml::from_str(&text).unwrap();
assert_eq!(back, m);
back.verify(CRUN, b"crun").unwrap();
let e = back.verify(CRUN, b"crux").unwrap_err();
assert_eq!(e.code().code, "holger-sandbox-8", "{e}");
assert!(e.to_string().contains("sha256"), "{e}");
let e = back.verify(CRUN, b"crun-longer").unwrap_err();
assert!(e.to_string().contains("bytes"), "{e}");
assert!(back.verify("other", b"").is_err());
let bad = Manifest {
schema: "holger.sandbox-media/v9".into(),
..m.clone()
};
assert!(bad.check().unwrap_err().to_string().contains("schema"));
let short = Manifest {
entries: m.entries[..2].to_vec(),
..m.clone()
};
assert!(short
.check()
.unwrap_err()
.to_string()
.contains(CLAMAV_ROOTFS));
let traversal = Manifest {
entries: vec![Entry {
name: "../x".into(),
..m.entries[0].clone()
}],
..m.clone()
};
assert!(traversal.check().is_err());
}
#[test]
fn the_manifest_refuses_unknown_keys() {
assert!(
toml::from_str::<Manifest>("schema = \"holger.sandbox-media/v1\"\nextra = 1\n")
.is_err()
);
}
#[test]
fn a_rootfs_tar_is_laid_hardened() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("rootfs");
let n = lay_rootfs(&worker_rootfs_tar(b"\x7fELF"), &root, 0).unwrap();
assert!(n >= 10);
assert_eq!(
std::fs::read(root.join("holger-scan-worker")).unwrap(),
b"\x7fELF"
);
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(root.join("holger-scan-worker"))
.unwrap()
.permissions()
.mode()
& 0o7777,
0o555
);
assert_eq!(
std::fs::metadata(root.join("scratch"))
.unwrap()
.permissions()
.mode()
& 0o7777,
0o1777
);
// Laid again: replaced whole, nothing stale kept.
std::fs::write(root.join("stale"), b"x").unwrap();
lay_rootfs(&worker_rootfs_tar(b"\x7fELF2"), &root, 0).unwrap();
assert!(!root.join("stale").exists());
assert_eq!(
std::fs::read(root.join("holger-scan-worker")).unwrap(),
b"\x7fELF2"
);
}
/// The directories' own modes are laid last, the deepest first: a read-only directory
/// before its children (`in/` 0555, then `in/d-yara` 0555 and `in/job.json`) lays for a
/// user without CAP_DAC_OVERRIDE, keeps its mode, and is removed by [`remove_tree`].
#[test]
fn a_read_only_directory_is_laid_with_its_children_and_removed() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("rootfs");
lay_rootfs(&worker_rootfs_tar(b"\x7fELF"), &root, 0).unwrap();
for d in ["in", "in/d-yara", "proc", "sys"] {
assert_eq!(
std::fs::metadata(root.join(d))
.unwrap()
.permissions()
.mode()
& 0o7777,
0o555,
"{d}"
);
}
assert!(root.join("in/job.json").is_file() && root.join("in/d-yara").is_dir());
remove_tree(&root).expect("a laid tree is removed whole");
assert!(!root.exists());
}
/// A tar entry with its name and link fields written by hand: the `tar`
/// crate's builder refuses `..` and absolute names, and a hostile archive
/// does not use the builder.
fn tar_with(path: &str, kind: tar::EntryType, mode: u32, link: Option<&str>) -> Vec<u8> {
let mut h = tar::Header::new_gnu();
h.set_entry_type(kind);
h.set_mode(mode);
h.set_size(0);
h.set_uid(0);
h.set_gid(0);
{
let g = h.as_gnu_mut().unwrap();
g.name[..path.len()].copy_from_slice(path.as_bytes());
if let Some(l) = link {
g.linkname[..l.len()].copy_from_slice(l.as_bytes());
}
}
h.set_cksum();
let mut t = tar::Builder::new(Vec::new());
t.append(&h, std::io::empty()).unwrap();
t.into_inner().unwrap()
}
/// Two entries in one tar, by hand (the second may write through the first).
fn tar_with_two(
a: (&str, tar::EntryType, u32, Option<&str>),
b: (&str, tar::EntryType, u32, Option<&str>),
) -> Vec<u8> {
let mut out = Vec::new();
for (path, kind, mode, link) in [a, b] {
let one = tar_with(path, kind, mode, link);
out.extend_from_slice(&one[..one.len() - 1024]); // drop the end-of-archive blocks
}
out.extend_from_slice(&[0u8; 1024]);
out
}
#[test]
fn a_hostile_rootfs_tar_is_refused_by_name() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("r");
let outside = dir.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
let outside_s = outside.display().to_string();
let cases: Vec<(Vec<u8>, &str)> = vec![
(
tar_with("../escape", tar::EntryType::Regular, 0o644, None),
"climbs out",
),
(
tar_with("/abs", tar::EntryType::Regular, 0o644, None),
"absolute",
),
(
tar_with("bin/su", tar::EntryType::Regular, 0o4755, None),
"setuid",
),
(
tar_with("dev/sda", tar::EntryType::Block, 0o600, None),
"device",
),
(
tar_with("a/b", tar::EntryType::Symlink, 0o777, Some("../../../x")),
"leaves the root",
),
(
tar_with("a/l", tar::EntryType::Link, 0o644, Some("../../x")),
"climbs out",
),
// Written THROUGH a symlink that points outside: refused before the write.
(
tar_with_two(
("lib", tar::EntryType::Symlink, 0o777, Some(&outside_s)),
("lib/evil", tar::EntryType::Regular, 0o644, None),
),
"through the symlink",
),
(
tar_with_two(
("etc", tar::EntryType::Symlink, 0o777, Some("/etc")),
("etc/passwd", tar::EntryType::Regular, 0o644, None),
),
"through the symlink",
),
];
for (t, why) in cases {
let e = lay_rootfs(&t, &root, 0).unwrap_err();
assert!(e.to_string().contains(why), "{why}: {e}");
assert!(!root.exists(), "nothing laid after a refusal: {why}");
}
assert!(
!outside.join("evil").exists(),
"nothing reached the outside directory"
);
// What a real image carries is fine: a relative symlink that stays inside, an
// ABSOLUTE symlink (busybox), a setgid directory (ClamAV's database directory).
lay_rootfs(
&tar_with(
"usr/bin/clamd",
tar::EntryType::Symlink,
0o777,
Some("../sbin/clamd"),
),
&root,
0,
)
.unwrap();
lay_rootfs(
&tar_with(
"bin/arch",
tar::EntryType::Symlink,
0o777,
Some("/bin/busybox"),
),
&root,
0,
)
.unwrap();
let m = std::fs::symlink_metadata(root.join("bin/arch")).unwrap();
assert!(
m.file_type().is_symlink(),
"laid as a symlink, not followed"
);
assert_eq!(
std::fs::read_link(root.join("bin/arch")).unwrap(),
PathBuf::from("/bin/busybox")
);
lay_rootfs(
&tar_with("var/lib/clamav", tar::EntryType::Directory, 0o2755, None),
&root,
0,
)
.unwrap();
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(root.join("var/lib/clamav"))
.unwrap()
.permissions()
.mode()
& 0o7777,
0o2755
);
}
/// ★ **A rootfs laid under a user-namespace shift reads back as its tar** (lane HOL-CDS-5):
/// the lay owns each entry `uid + shift`, and the read-back takes the laid owners as they
/// are; it must not shift them again (on the appliance, PID 1 as root with the base 100000,
/// every start refused `holger-sandbox-14`: `dev` is `200000:200000`, the medium says
/// `100000:100000`). The shift is this user's own uid when it is not root (a chown to
/// yourself needs no privilege), so the test runs anywhere.
#[test]
fn a_rootfs_laid_under_a_shift_reads_back_as_its_tar() {
use std::os::unix::fs::MetadataExt as _;
let dir = tempfile::tempdir().unwrap();
let me = std::fs::metadata(dir.path()).unwrap().uid();
let shift = if me == 0 { 100_000 } else { me };
let root = dir.path().join("rootfs");
let tar = worker_rootfs_tar(b"\x7fELF");
lay_rootfs(&tar, &root, shift).unwrap();
assert_eq!(
laid_rootfs_difference(&tar, &root, shift).unwrap(),
None,
"laid at +{shift}, read back at +{shift}"
);
}
#[test]
fn a_laid_rootfs_reads_back_as_its_tar() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("rootfs");
let tar = worker_rootfs_tar(b"\x7fELF");
lay_rootfs(&tar, &root, 0).unwrap();
assert_eq!(laid_rootfs_difference(&tar, &root, 0).unwrap(), None);
// A parent the tar does not name, and an absolute symlink: laid and read back equal.
let t = tar_with(
"usr/bin/arch",
tar::EntryType::Symlink,
0o777,
Some("/bin/busybox"),
);
lay_rootfs(&t, &root, 0).unwrap();
assert_eq!(laid_rootfs_difference(&t, &root, 0).unwrap(), None);
std::fs::remove_file(root.join("usr/bin/arch")).unwrap();
std::os::unix::fs::symlink("/bin/evil", root.join("usr/bin/arch")).unwrap();
let why = laid_rootfs_difference(&t, &root, 0).unwrap().unwrap();
assert!(
why.contains("usr/bin/arch") && why.contains("/bin/evil"),
"{why}"
);
// An extra file with bytes is a change; an empty one (a runtime's mount point) is not.
lay_rootfs(&tar, &root, 0).unwrap();
std::fs::write(root.join("tmp/x"), b"").unwrap();
assert_eq!(laid_rootfs_difference(&tar, &root, 0).unwrap(), None);
std::fs::write(root.join("tmp/x"), b"#!").unwrap();
assert!(laid_rootfs_difference(&tar, &root, 0)
.unwrap()
.unwrap()
.contains("tmp/x"));
}
/// A real image's rootfs (`HOLGER_TEST_ROOTFS_TAR`, e.g. the pinned ClamAV image flattened by
/// `podman export`: 254 hard links, 368 symlinks): laid, it reads back as its tar. Skipped
/// without the variable.
#[test]
fn a_real_rootfs_reads_back_as_its_tar() {
let Some(p) = std::env::var_os("HOLGER_TEST_ROOTFS_TAR") else {
return;
};
let bytes = std::fs::read(&p).unwrap();
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("rootfs");
lay_rootfs(&bytes, &root, 0).unwrap();
assert_eq!(laid_rootfs_difference(&bytes, &root, 0).unwrap(), None);
}
#[test]
fn a_laid_program_is_checked_by_its_bytes_and_its_mode() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let at = dir.path().join("crun");
lay_program(b"crun", &at).unwrap();
assert_eq!(
laid_program_difference(&sha256_hex(b"crun"), &at).unwrap(),
None
);
std::fs::set_permissions(&at, std::fs::Permissions::from_mode(0o4755)).unwrap();
assert!(laid_program_difference(&sha256_hex(b"crun"), &at)
.unwrap()
.unwrap()
.contains("mode"));
lay_program(b"crux", &at).unwrap();
assert!(laid_program_difference(&sha256_hex(b"crun"), &at)
.unwrap()
.unwrap()
.contains("sha256"));
}
#[test]
fn the_layout_is_under_the_data_root() {
let l = Layout::under(Path::new("/data/holger"));
assert_eq!(l.crun(), PathBuf::from("/data/holger/sandbox/crun"));
assert_eq!(
l.worker_rootfs(),
PathBuf::from("/data/holger/sandbox/worker/rootfs")
);
assert_eq!(
l.clamd_socket(),
PathBuf::from("/data/holger/sandbox/clamd/clamd.sock")
);
assert_eq!(entry_path(MANIFEST), "sandbox/manifest.toml");
}
#[test]
fn the_program_is_laid_executable() {
let dir = tempfile::tempdir().unwrap();
let at = dir.path().join("bin").join("crun");
lay_program(b"#!/bin/sh\n", &at).unwrap();
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(&at).unwrap().permissions().mode() & 0o777,
0o755
);
}
}