nordisk/heimdall
crates/heimdall-plain/tests/common/mod.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Test kit for heimdall-plain: OCI image layouts built on the fly (a static
//! busybox, or synthetic layers), job dirs, fake shims. Heimdall is a
//! defensive sandbox; these tests exercise only its own containment.
#![allow(dead_code)]
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use heimdall_core::*;
use sha2::{Digest, Sha256};
pub fn scratch(name: &str) -> PathBuf {
let base = std::env::var_os("HEIMDALL_PLAIN_SCRATCH")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
let d = base.join(format!("heimdall-plain-{name}-{}", JobId::random()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// One layer entry.
pub enum Entry {
Dir(&'static str),
File(String, Vec<u8>, u32),
Symlink(String, String),
HardLink(String, String),
/// A raw path written into the header (for traversal tests).
RawFile(Vec<u8>, Vec<u8>),
}
fn sha(b: &[u8]) -> String {
format!("sha256:{}", heimdall_core::hex::encode(&Sha256::digest(b)))
}
/// An OCI image layout under construction.
pub struct Layout {
pub dir: PathBuf,
}
impl Layout {
pub fn new(dir: &Path) -> Layout {
std::fs::create_dir_all(dir.join("blobs/sha256")).unwrap();
std::fs::write(dir.join("oci-layout"), br#"{"imageLayoutVersion":"1.0.0"}"#).unwrap();
std::fs::write(
dir.join("index.json"),
br#"{"schemaVersion":2,"manifests":[]}"#,
)
.unwrap();
Layout {
dir: dir.to_path_buf(),
}
}
pub fn blob(&self, bytes: &[u8]) -> (String, u64) {
let d = sha(bytes);
std::fs::write(self.blob_path(&d), bytes).unwrap();
(d, bytes.len() as u64)
}
pub fn blob_path(&self, digest: &str) -> PathBuf {
self.dir
.join("blobs/sha256")
.join(digest.strip_prefix("sha256:").unwrap())
}
pub fn tar(entries: &[Entry]) -> Vec<u8> {
let mut b = tar::Builder::new(Vec::new());
for e in entries {
let mut h = tar::Header::new_gnu();
match e {
Entry::Dir(p) => {
h.set_entry_type(tar::EntryType::Directory);
h.set_mode(0o755);
h.set_size(0);
b.append_data(&mut h, p, &[][..]).unwrap();
}
Entry::File(p, data, mode) => {
h.set_entry_type(tar::EntryType::Regular);
h.set_mode(*mode);
h.set_size(data.len() as u64);
b.append_data(&mut h, p, &data[..]).unwrap();
}
Entry::Symlink(p, target) => {
h.set_entry_type(tar::EntryType::Symlink);
h.set_size(0);
h.set_mode(0o777);
b.append_link(&mut h, p, target).unwrap();
}
Entry::HardLink(p, target) => {
h.set_entry_type(tar::EntryType::Link);
h.set_size(0);
h.set_mode(0o644);
b.append_link(&mut h, p, target).unwrap();
}
Entry::RawFile(p, data) => {
h.set_entry_type(tar::EntryType::Regular);
h.set_mode(0o644);
h.set_size(data.len() as u64);
let name = &mut h.as_old_mut().name;
name[..p.len()].copy_from_slice(p);
h.set_cksum();
b.append(&h, &data[..]).unwrap();
}
}
}
b.into_inner().unwrap()
}
pub fn gzip(raw: &[u8]) -> Vec<u8> {
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
e.write_all(raw).unwrap();
e.finish().unwrap()
}
/// Store layers (gzip or plain) + config + manifest; return the image ref.
pub fn image(&self, layers: &[(Vec<u8>, bool)], env: &[&str]) -> ImageRef {
let mut descs = Vec::new();
for (raw, gz) in layers {
let bytes = if *gz { Self::gzip(raw) } else { raw.clone() };
let (d, n) = self.blob(&bytes);
descs.push(serde_json::json!({
"mediaType": if *gz { "application/vnd.oci.image.layer.v1.tar+gzip" } else { "application/vnd.oci.image.layer.v1.tar" },
"digest": d, "size": n
}));
}
let cfg = serde_json::json!({
"architecture": "amd64", "os": "linux",
"config": {"Env": env},
"rootfs": {"type": "layers", "diff_ids": []}
});
let (cd, cn) = self.blob(&serde_json::to_vec(&cfg).unwrap());
let m = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {"mediaType": "application/vnd.oci.image.config.v1+json", "digest": cd, "size": cn},
"layers": descs
});
let (md, _) = self.blob(&serde_json::to_vec(&m).unwrap());
ImageRef {
name: "test/image".into(),
digest: md,
}
}
/// The manifest JSON of `img`.
pub fn manifest(&self, img: &ImageRef) -> serde_json::Value {
serde_json::from_slice(&std::fs::read(self.blob_path(&img.digest)).unwrap()).unwrap()
}
}
/// The busybox applets the container tests use.
pub const APPLETS: &[&str] = &[
"sh",
"cp",
"cat",
"echo",
"nc",
"wget",
"dd",
"mkdir",
"ln",
"mkfifo",
"touch",
"sleep",
"seq",
"grep",
"id",
"hostname",
"unshare",
"ls",
"true",
"false",
"head",
"kill",
"mount",
"rm",
"sha256sum",
"yes",
"printf",
"test",
"chmod",
"ps",
"readlink",
];
/// Where a static busybox is found (HEIMDALL_PLAIN_BUSYBOX or /bin/busybox).
pub fn busybox() -> Option<PathBuf> {
let p = std::env::var_os("HEIMDALL_PLAIN_BUSYBOX")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/bin/busybox"));
p.exists().then_some(p)
}
/// A one-layer busybox image (static busybox at /bin/busybox + applet links).
pub fn busybox_image(layout: &Layout) -> ImageRef {
let bb = std::fs::read(busybox().expect("a static busybox")).unwrap();
let mut entries = vec![
Entry::Dir("bin"),
Entry::Dir("tmp"),
Entry::Dir("in"),
Entry::Dir("out"),
Entry::Dir("proc"),
Entry::Dir("dev"),
Entry::Dir("etc"),
Entry::File("bin/busybox".into(), bb, 0o755),
Entry::File("etc/marker".into(), b"from-the-image".to_vec(), 0o644),
];
for a in APPLETS {
entries.push(Entry::Symlink(format!("bin/{a}"), "busybox".into()));
}
layout.image(
&[(Layout::tar(&entries), true)],
&["PATH=/bin", "HEIMDALL_TEST_ENV=from-image"],
)
}
/// A job directory: in/, out/.
pub struct JobDirs {
pub base: PathBuf,
pub input: PathBuf,
pub output: PathBuf,
}
pub fn job_dirs(base: &Path) -> JobDirs {
let input = base.join(format!("in-{}", JobId::random()));
let output = base.join(format!("out-{}", JobId::random()));
std::fs::create_dir_all(&input).unwrap();
std::fs::create_dir_all(&output).unwrap();
JobDirs {
base: base.to_path_buf(),
input,
output,
}
}
pub fn native_job(image: &ImageRef, argv: &[&str], d: &JobDirs) -> Job {
let mut policy = Policy::default();
policy.limits.wall = Duration::from_secs(20);
policy.limits.memory_bytes = 128 << 20;
policy.limits.pids = 64;
Job::new(
JobKind::Native {
image: image.clone(),
argv: argv.iter().map(|s| s.to_string()).collect(),
},
d.input.clone(),
d.output.clone(),
serde_json::Value::Null,
policy,
)
}
pub fn sh(script: &str) -> Vec<&str> {
vec!["/bin/sh", "-c", script]
}
/// The verdict code, or "accepted".
pub fn code(r: &JobResult) -> String {
r.verdict.code().unwrap_or("accepted").to_string()
}