Gunnar

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")
    );
}