nordisk/heimdall
crates/heimdall-plain/tests/image.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! Pinned image verification and unpacking. Heimdall is a defensive sandbox;
//! these tests feed it hostile layers and corrupted blobs in temp dirs only.
mod common;
use std::path::Path;
use common::*;
use heimdall_core::ImageRef;
use heimdall_plain::image::{ImageError, OciLayout};
fn unpack(
l: &Layout,
img: &ImageRef,
) -> (
std::path::PathBuf,
Result<heimdall_plain::image::Unpacked, ImageError>,
) {
let root = l
.dir
.parent()
.unwrap()
.join(format!("rootfs-{}", heimdall_core::JobId::random()));
std::fs::create_dir_all(&root).unwrap();
let r = OciLayout::new(&l.dir).unpack(img, &root);
(root, r)
}
fn file(name: &str, data: &[u8]) -> Entry {
Entry::File(name.into(), data.to_vec(), 0o644)
}
fn layout(name: &str) -> Layout {
Layout::new(&scratch(name).join("layout"))
}
#[test]
fn a_gzip_layer_unpacks_with_modes_and_env() {
let l = layout("gz");
let img = l.image(
&[(
Layout::tar(&[
Entry::Dir("bin"),
Entry::File("bin/t".into(), b"x".to_vec(), 0o755),
]),
true,
)],
&["PATH=/bin", "A=b"],
);
let (root, r) = unpack(&l, &img);
let u = r.unwrap();
assert_eq!(u.env, vec!["PATH=/bin".to_string(), "A=b".to_string()]);
assert_eq!(u.layers, 1);
use std::os::unix::fs::PermissionsExt;
let m = std::fs::metadata(root.join("bin/t")).unwrap();
assert_eq!(m.permissions().mode() & 0o777, 0o755);
}
#[test]
fn a_plain_tar_layer_unpacks() {
let l = layout("tar");
let img = l.image(&[(Layout::tar(&[file("a", b"1")]), false)], &[]);
let (root, r) = unpack(&l, &img);
r.unwrap();
assert_eq!(std::fs::read(root.join("a")).unwrap(), b"1");
}
#[test]
fn later_layers_override_earlier_ones() {
let l = layout("over");
let img = l.image(
&[
(Layout::tar(&[file("a", b"old"), file("b", b"keep")]), true),
(Layout::tar(&[file("a", b"new")]), true),
],
&[],
);
let (root, r) = unpack(&l, &img);
r.unwrap();
assert_eq!(std::fs::read(root.join("a")).unwrap(), b"new");
assert_eq!(std::fs::read(root.join("b")).unwrap(), b"keep");
}
#[test]
fn a_whiteout_removes_a_lower_file() {
let l = layout("wh");
let img = l.image(
&[
(
Layout::tar(&[Entry::Dir("d"), file("d/x", b"1"), file("d/y", b"2")]),
true,
),
(Layout::tar(&[file("d/.wh.x", b"")]), true),
],
&[],
);
let (root, r) = unpack(&l, &img);
r.unwrap();
assert!(!root.join("d/x").exists());
assert!(root.join("d/y").exists());
assert!(!root.join("d/.wh.x").exists());
}
#[test]
fn an_opaque_whiteout_empties_a_lower_directory() {
let l = layout("opq");
let img = l.image(
&[
(Layout::tar(&[Entry::Dir("d"), file("d/x", b"1")]), true),
(
Layout::tar(&[file("d/.wh..wh..opq", b""), file("d/z", b"3")]),
true,
),
],
&[],
);
let (root, r) = unpack(&l, &img);
r.unwrap();
assert!(!root.join("d/x").exists());
assert!(root.join("d/z").exists());
}
#[test]
fn a_dotdot_entry_is_refused() {
let l = layout("dotdot");
let img = l.image(
&[(
Layout::tar(&[Entry::RawFile(b"../evil".to_vec(), b"x".to_vec())]),
true,
)],
&[],
);
let (root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::Escape(_))), "{r:?}");
assert!(!root.parent().unwrap().join("evil").exists());
}
#[test]
fn an_absolute_entry_is_refused() {
let l = layout("abs");
let img = l.image(
&[(
Layout::tar(&[Entry::RawFile(b"/etc/evil".to_vec(), b"x".to_vec())]),
true,
)],
&[],
);
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::Escape(_))), "{r:?}");
}
#[test]
fn writing_through_a_symlinked_parent_is_refused() {
let l = layout("symparent");
let outside = l.dir.parent().unwrap().join("outside");
std::fs::create_dir_all(&outside).unwrap();
let img = l.image(
&[(
Layout::tar(&[
Entry::Symlink("link".into(), outside.display().to_string()),
file("link/pwned", b"x"),
]),
true,
)],
&[],
);
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::Escape(_))), "{r:?}");
assert!(!outside.join("pwned").exists());
}
#[test]
fn a_hard_link_outside_the_root_is_refused() {
let l = layout("hardlink");
let img = l.image(
&[(
Layout::tar(&[Entry::HardLink("h".into(), "../../etc/passwd".into())]),
true,
)],
&[],
);
let (_root, r) = unpack(&l, &img);
assert!(r.is_err());
}
#[test]
fn a_symlink_inside_the_image_is_kept_as_a_link() {
let l = layout("symok");
let img = l.image(
&[(
Layout::tar(&[
file("target", b"t"),
Entry::Symlink("ln".into(), "target".into()),
]),
true,
)],
&[],
);
let (root, r) = unpack(&l, &img);
r.unwrap();
assert!(std::fs::symlink_metadata(root.join("ln"))
.unwrap()
.file_type()
.is_symlink());
}
#[test]
fn a_corrupted_layer_is_a_digest_mismatch() {
let l = layout("corrupt");
let img = l.image(&[(Layout::tar(&[file("a", b"1")]), false)], &[]);
let d = l.manifest(&img)["layers"][0]["digest"]
.as_str()
.unwrap()
.to_string();
let p = l.blob_path(&d);
let mut b = std::fs::read(&p).unwrap();
let n = b.len();
b[n - 1] ^= 1; // tar padding: unpacking still succeeds, the digest does not
std::fs::write(&p, b).unwrap();
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::DigestMismatch { .. })), "{r:?}");
}
#[test]
fn a_corrupted_manifest_is_a_digest_mismatch() {
let l = layout("manifest");
let img = l.image(&[(Layout::tar(&[file("a", b"1")]), true)], &[]);
let p = l.blob_path(&img.digest);
let mut b = std::fs::read(&p).unwrap();
b.push(b' ');
std::fs::write(&p, b).unwrap();
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::DigestMismatch { .. })), "{r:?}");
}
#[test]
fn a_corrupted_config_is_a_digest_mismatch() {
let l = layout("config");
let img = l.image(&[(Layout::tar(&[file("a", b"1")]), true)], &[]);
let d = l.manifest(&img)["config"]["digest"]
.as_str()
.unwrap()
.to_string();
let p = l.blob_path(&d);
let mut b = std::fs::read(&p).unwrap();
b[0] = b' ';
std::fs::write(&p, b).unwrap();
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::DigestMismatch { .. })), "{r:?}");
}
#[test]
fn a_layer_size_that_lies_is_refused() {
let l = layout("size");
let raw = Layout::tar(&[file("a", b"1")]);
let (ld, ln) = l.blob(&raw);
let (cd, cn) = l.blob(br#"{"config":{}}"#);
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": [{"mediaType": "application/vnd.oci.image.layer.v1.tar", "digest": ld, "size": ln + 1}]
});
let (md, _) = l.blob(&serde_json::to_vec(&m).unwrap());
let img = ImageRef {
name: "x".into(),
digest: md,
};
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::SizeMismatch { .. })), "{r:?}");
}
#[test]
fn a_missing_blob_is_named() {
let l = layout("missing");
let img = ImageRef {
name: "x".into(),
digest: format!("sha256:{}", "cd".repeat(32)),
};
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::MissingBlob(_))), "{r:?}");
}
#[test]
fn an_unpinned_digest_is_refused() {
let l = layout("unpinned");
for d in ["latest", "sha256:abc", "sha512:00"] {
let img = ImageRef {
name: "x".into(),
digest: d.into(),
};
let (_root, r) = unpack(&l, &img);
assert!(matches!(r, Err(ImageError::BadDigest(_))), "{d}");
}
}
#[test]
fn a_zstd_layer_is_an_unsupported_media_type() {
let l = layout("zstd");
let (ld, ln) = l.blob(b"not really zstd");
let (cd, cn) = l.blob(br#"{"config":{}}"#);
let m = serde_json::json!({
"schemaVersion": 2,
"config": {"mediaType": "application/vnd.oci.image.config.v1+json", "digest": cd, "size": cn},
"layers": [{"mediaType": "application/vnd.oci.image.layer.v1.tar+zstd", "digest": ld, "size": ln}]
});
let (md, _) = l.blob(&serde_json::to_vec(&m).unwrap());
let (_root, r) = unpack(
&l,
&ImageRef {
name: "x".into(),
digest: md,
},
);
assert!(matches!(r, Err(ImageError::LayerType(_))), "{r:?}");
}
#[test]
fn an_image_index_picks_the_linux_manifest_for_this_arch() {
let l = layout("index");
let img = l.image(&[(Layout::tar(&[file("a", b"right")]), true)], &[]);
let other = l.image(&[(Layout::tar(&[file("a", b"wrong")]), true)], &[]);
let arch = if cfg!(target_arch = "x86_64") {
"amd64"
} else {
"arm64"
};
let idx = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{"mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": other.digest,
"size": std::fs::metadata(l.blob_path(&other.digest)).unwrap().len(),
"platform": {"architecture": "s390x", "os": "linux"}},
{"mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": img.digest,
"size": std::fs::metadata(l.blob_path(&img.digest)).unwrap().len(),
"platform": {"architecture": arch, "os": "linux"}}
]
});
let (id, _) = l.blob(&serde_json::to_vec(&idx).unwrap());
let (root, r) = unpack(
&l,
&ImageRef {
name: "x".into(),
digest: id,
},
);
r.unwrap();
assert_eq!(std::fs::read(root.join("a")).unwrap(), b"right");
}
#[test]
fn an_index_without_this_arch_is_refused() {
let l = layout("noarch");
let img = l.image(&[(Layout::tar(&[file("a", b"1")]), true)], &[]);
let idx = serde_json::json!({
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [{"digest": img.digest, "size": std::fs::metadata(l.blob_path(&img.digest)).unwrap().len(),
"platform": {"architecture": "s390x", "os": "linux"}}]
});
let (id, _) = l.blob(&serde_json::to_vec(&idx).unwrap());
let (_root, r) = unpack(
&l,
&ImageRef {
name: "x".into(),
digest: id,
},
);
assert!(matches!(r, Err(ImageError::Manifest(_))), "{r:?}");
}
#[test]
fn device_nodes_in_a_layer_are_skipped() {
let l = layout("dev");
let mut b = tar::Builder::new(Vec::new());
let mut h = tar::Header::new_gnu();
h.set_entry_type(tar::EntryType::Char);
h.set_size(0);
h.set_mode(0o666);
b.append_data(&mut h, "dev/null", &[][..]).unwrap();
let img = l.image(&[(b.into_inner().unwrap(), true)], &[]);
let (root, r) = unpack(&l, &img);
r.unwrap();
assert!(!Path::new(&root.join("dev/null")).exists());
}
#[test]
fn a_busybox_image_unpacks_when_busybox_is_present() {
if busybox().is_none() {
eprintln!("no static busybox here; skipped");
return;
}
let l = layout("bb");
let img = busybox_image(&l);
let (root, r) = unpack(&l, &img);
r.unwrap();
assert!(root.join("bin/busybox").exists());
assert_eq!(
std::fs::read_link(root.join("bin/sh")).unwrap(),
Path::new("busybox")
);
}