Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

src/pack.rs

//! AIRGAP1 — pack (build side).
//!
//! Design (`.nornir/airgap-install.md` §2 Phase 0): collect each product's
//! artifacts + *all* target-identical deps into a versioned **staging tree**,
//! then [`crate::archive`] turns that tree into the bundle. The `VERSION` file
//! lives **outside** the bundle for easy bumping (the `dist` pattern). When a
//! component's deps must be 100% target-alike, it is built in a per-target
//! container — that capture step is a seam ([`ContainerBuild`](crate::pack::ContainerBuild)) since it needs
//! Docker/podman, which is not available (and not wanted) at airgap-install time.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

/// One bundled product's contents + provenance (what was packed, from where, how).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PackManifest {
    pub product: String,
    /// Airgap generation, date-stamped (e.g. "20250505").
    pub version: String,
    /// Whether deps were captured via a per-target container (100% target-alike).
    pub built_in_container: bool,
    /// Relative artifact paths inside the staging tree.
    pub artifacts: Vec<String>,
}

/// A staging tree being assembled on the build host — the input to
/// [`crate::archive::Archive::pack`].
#[derive(Debug, Clone)]
pub struct Staging {
    pub root: PathBuf,
    pub version: String,
}

impl Staging {
    /// Create a fresh, empty staging tree at `root` with the given version stamp.
    pub fn new(root: impl Into<PathBuf>, version: impl Into<String>) -> Result<Self> {
        let root = root.into();
        std::fs::create_dir_all(&root)
            .with_context(|| format!("create staging root {}", root.display()))?;
        Ok(Staging {
            root,
            version: version.into(),
        })
    }

    /// Add a product artifact (a real file) under `rel` inside the staging tree
    /// (e.g. `linux-x86_64/holger`). Copies the bytes; returns the relative path.
    pub fn add_artifact(&self, src: &Path, rel: &str) -> Result<String> {
        let dest = self.root.join(rel);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("create {}", parent.display()))?;
        }
        std::fs::copy(src, &dest)
            .with_context(|| format!("copy {} → {}", src.display(), dest.display()))?;
        Ok(rel.to_string())
    }

    /// Write a product's [`ComponentSpec`](crate::product::ComponentSpec) manifest into the staging tree under
    /// `manifests/<name>.json` (so the installer reads it back from the bundle).
    pub fn add_component_spec(&self, spec: &crate::product::ComponentSpec) -> Result<String> {
        let rel = format!("manifests/{}.json", spec.name);
        let dest = self.root.join(&rel);
        std::fs::create_dir_all(dest.parent().unwrap()).ok();
        std::fs::write(&dest, serde_json::to_vec_pretty(spec)?)
            .with_context(|| format!("write {}", dest.display()))?;
        Ok(rel)
    }

    /// Enumerate every file in the staging tree (relative paths).
    pub fn artifacts(&self) -> Result<Vec<String>> {
        let mut out = Vec::new();
        walk(&self.root, &self.root, &mut out)?;
        out.sort();
        Ok(out)
    }

    /// **Export a container image INTO the staging tree**, so the bundle carries it
    /// to a target that has no registry.
    ///
    /// The image lands at [`IMAGES_REL`]`/<sanitised ref>.tar` — an ordinary file in
    /// the staging tree, and that placement is the entire integrity story. Because
    /// `Archive::pack` digests the tree it is handed
    /// (`archive::collect_digests` → one `EntryDigest { path, sha256, len }` per
    /// file), a packed image is covered by `BundleManifest` and re-hashed by
    /// `verify_entries` on receipt **exactly like every other artifact**. No second
    /// manifest, no parallel digest, and — deliberately — no way to add an image
    /// that skips verification: there is no code path here that writes outside the
    /// staged tree.
    ///
    /// The export itself streams through draupnir's ONE OCI engine (bollard over the
    /// podman/Docker REST socket), never `podman save`. These images are large —
    /// `localhost/nordisk-spark-iceberg:4.1.2` measures 1.39 GB on oden — so nothing
    /// on this path buffers the archive in memory.
    ///
    /// Returns the staging-relative path. Build-host only: exporting needs a live
    /// container engine, which an airgap TARGET by definition does not have.
    #[cfg(feature = "pack-oci")]
    pub fn add_image_archive(&self, image: &str) -> Result<String> {
        let rel = format!("{IMAGES_REL}/{}.tar", sanitise_image_ref(image));
        let dest = self.root.join(&rel);
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("create {}", parent.display()))?;
        }
        let archive = draupnir::container::ContainerBoot::new()
            .export_image(image, &dest)
            .map_err(|e| anyhow::anyhow!("export image `{image}` into the bundle: {e}"))?;

        // SURFACE: the packed image, named by what the ARCHIVE says it is (tags +
        // config digest read back out of the tar), not by what we asked for. An
        // image built on this host is not byte-reproducible from its Containerfile,
        // so this digest is the only durable link between the recipe and the bytes
        // the target will run — recording it here is what makes the pairing
        // checkable later.
        crate::surface_status!(
            "skidbladnir/pack",
            "pack_export_container_image",
            true,
            "image={image} rel={rel} bytes={} tags={:?} config={}",
            archive.bytes,
            archive.tags,
            archive.config_digest
        );
        Ok(rel)
    }

    /// Without the OCI engine there is no way to export an image, and pretending
    /// otherwise would put an empty file in the bundle that verifies clean and fails
    /// on the target. Honest bail, matching [`PodmanBuild::build_into`].
    #[cfg(not(feature = "pack-oci"))]
    pub fn add_image_archive(&self, image: &str) -> Result<String> {
        anyhow::bail!(
            "packing container image `{image}` needs the zero-shell OCI engine \
             (`--features pack-oci` / `oci`) — a build-host concern; no container \
             engine is compiled in"
        )
    }
}

/// Where packed container images live inside a bundle's staging tree (and so,
/// after unfold, inside the installed `software` root).
pub const IMAGES_REL: &str = "images";

/// A filesystem-safe filename for an image reference — `localhost/foo:1.2` becomes
/// `localhost-foo-1.2`. Purely cosmetic/uniqueness: nothing MATCHES on this name.
/// Both the load side here and korp's runtime lookup identify an archive by the
/// `RepoTags` inside it, because a filename is a claim while the manifest is what
/// the archive actually is.
pub fn sanitise_image_ref(image: &str) -> String {
    image
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect()
}

/// **Load every image the bundle carried into the local store** — the unfold-side
/// counterpart to [`Staging::add_image_archive`], to be run after `extract_all` and
/// BEFORE the service that needs those images is started.
///
/// `software_root` is the unfolded tree (the same dir `Archive::extract_all` wrote
/// into). A missing [`IMAGES_REL`] directory is `Ok(vec![])`, not an error — most
/// bundles carry no images, and that is not a fault.
///
/// Each archive is verified structurally by draupnir before the engine is handed a
/// byte (truncated / not-an-image are named, distinct failures), on top of the
/// sha256 the bundle's own `verify_entries` already checked. Returns what was
/// loaded, so a caller can report the tags and digests actually installed.
#[cfg(feature = "pack-oci")]
pub fn load_bundled_images(software_root: &Path) -> Result<Vec<draupnir::container::ImageArchive>> {
    let dir = software_root.join(IMAGES_REL);
    if !dir.is_dir() {
        return Ok(Vec::new());
    }
    let boot = draupnir::container::ContainerBoot::new();
    let mut loaded = Vec::new();
    let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
        .with_context(|| format!("read image dir {}", dir.display()))?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| p.is_file())
        .collect();
    // Deterministic order so a failure is reproducible and the log reads the same
    // way twice.
    entries.sort();
    for path in entries {
        let archive = boot
            .import_image(&path)
            .map_err(|e| anyhow::anyhow!("load bundled image {}: {e}", path.display()))?;
        crate::surface_status!(
            "skidbladnir/unfold",
            "unfold_load_container_image",
            true,
            "rel={IMAGES_REL}/{} tags={:?} config={} bytes={}",
            path.file_name().unwrap_or_default().to_string_lossy(),
            archive.tags,
            archive.config_digest,
            archive.bytes
        );
        loaded.push(archive);
    }
    Ok(loaded)
}

/// Without the OCI engine a bundle that CARRIES images cannot honour them. Loudly
/// `Err` rather than silently skipping: a target that starts a service whose image
/// was never loaded fails later and further from the cause.
#[cfg(not(feature = "pack-oci"))]
pub fn load_bundled_images(software_root: &Path) -> Result<Vec<()>> {
    let dir = software_root.join(IMAGES_REL);
    if !dir.is_dir() {
        return Ok(Vec::new());
    }
    anyhow::bail!(
        "the bundle carries container images at {} but this build has no OCI engine \
         (`--features pack-oci` / `oci`) to load them with",
        dir.display()
    )
}

/// The VERSION file outside the bundle — the easy-bump pattern from `dist`. We
/// write it NEXT TO the bundle, not inside it, so bumping the generation is a
/// one-line edit that doesn't require repacking.
pub fn write_version_file(beside_bundle: &Path, version: &str) -> Result<PathBuf> {
    let dir = beside_bundle.parent().unwrap_or_else(|| Path::new("."));
    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
    let p = dir.join("VERSION");
    std::fs::write(&p, format!("{version}\n")).with_context(|| format!("write {}", p.display()))?;
    Ok(p)
}

/// Pack `product` into a [`PackManifest`] by enumerating the assembled staging
/// tree. (The actual bundle bytes are produced by [`crate::archive`]; this
/// records provenance.)
pub fn pack(staging: &Staging, product: &str, built_in_container: bool) -> Result<PackManifest> {
    // SURFACE: pack — enumerate the assembled staging tree into the provenance
    // manifest. RED (with the failing path) if the tree can't be walked; green with
    // the real artifact count on success — the implanted verdict the matrix reads.
    let artifacts = match staging.artifacts() {
        Ok(a) => a,
        Err(e) => {
            crate::surface_status!(
                "skidbladnir/pack",
                "pack_collect_staging_tree",
                false,
                "enumerate staging {} for `{product}` failed: {e:#}",
                staging.root.display()
            );
            return Err(e);
        }
    };
    crate::surface_status!(
        "skidbladnir/pack",
        "pack_collect_staging_tree",
        true,
        "product={product} version={} artifacts={} built_in_container={built_in_container}",
        staging.version,
        artifacts.len()
    );
    Ok(PackManifest {
        product: product.to_string(),
        version: staging.version.clone(),
        built_in_container,
        artifacts,
    })
}

/// Per-target container build for 100%-target-alike dep capture (the `dist`
/// reason a build sometimes runs in a container). It needs a container runtime,
/// which is a build-host concern, never an airgap-target one.
pub trait ContainerBuild {
    /// Build `product` for `target_triple` inside a container and copy the
    /// resulting artifacts into `into` (a staging subdir).
    fn build_into(&self, product: &str, target_triple: &str, into: &Path) -> Result<Vec<String>>;
}

/// A real, target-identical dep-capture build. The container work runs over
/// draupnir's ONE OCI engine (`build_image` + `extract_path`, pure-Rust bollard
/// over the podman/Docker REST API) behind the **`pack-oci`** feature — zero shell
/// (the LAW: containers controlled in Rust, one engine constellation-wide). The
/// old `podman build`/`create`/`cp` subprocess trio was REMOVED; with the feature
/// off, the build renders the Containerfile for real then bails HONESTLY, pointing
/// at `--features pack-oci` (it never shells out and never fakes a capture).
///
/// `dist`'s reason for building in a container is that a component's runtime deps
/// must be **100% target-alike**: the base image *is* the target's userland, so
/// whatever the build links/copies inside the container is exactly what the
/// target has. This impl renders a Containerfile, has the OCI engine `build`
/// it to capture the product's artifacts + deps into `/out`, then extracts `/out`
/// into the staging subdir — all a build-host concern, never run at
/// airgap-install time.
#[derive(Debug, Clone)]
pub struct PodmanBuild {
    /// Base image whose userland equals the target's (e.g.
    /// `registry.access.redhat.com/ubi9/ubi-minimal` for a RHEL9 target). This is
    /// what makes the captured deps target-identical.
    pub base_image: String,
    /// Build steps that fetch/produce the product + its deps into `/out` inside
    /// the container (the `dist` "wget-in-container" dep capture). Each line is a
    /// `RUN <step>` in the Containerfile. Rendered, never shelled out by us.
    pub capture_steps: Vec<String>,
}

impl PodmanBuild {
    /// A per-target build that captures deps into `/out` on `base_image`.
    pub fn new(base_image: impl Into<String>) -> Self {
        PodmanBuild {
            base_image: base_image.into(),
            capture_steps: Vec::new(),
        }
    }

    /// Add a dep-capture step (rendered as one `RUN` line). E.g.
    /// `.step("dnf install -y openssl-libs && cp /usr/bin/holger /out/")`.
    pub fn step(mut self, run: impl Into<String>) -> Self {
        self.capture_steps.push(run.into());
        self
    }

    /// Render the Containerfile this build runs — a real artifact (testable
    /// without a container runtime), so the build invocation is honest and
    /// inspectable. `target_triple` is exported so capture steps can branch on it.
    pub fn containerfile(&self, product: &str, target_triple: &str) -> String {
        let mut s = String::new();
        s.push_str(&format!("FROM {}\n", self.base_image));
        s.push_str(&format!("ARG PRODUCT={product}\n"));
        s.push_str(&format!("ARG TARGET={target_triple}\n"));
        s.push_str("RUN mkdir -p /out\n");
        for step in &self.capture_steps {
            s.push_str(&format!("RUN {step}\n"));
        }
        s
    }
}

impl ContainerBuild for PodmanBuild {
    fn build_into(&self, product: &str, target_triple: &str, into: &Path) -> Result<Vec<String>> {
        std::fs::create_dir_all(into).with_context(|| format!("create {}", into.display()))?;

        // Render the Containerfile into a build context dir (shared by both engines).
        let ctx = tempfile::tempdir().context("create build context")?;
        let containerfile = ctx.path().join("Containerfile");
        std::fs::write(&containerfile, self.containerfile(product, target_triple))
            .with_context(|| format!("write {}", containerfile.display()))?;
        let tag = format!(
            "skidbladnir-capture/{product}:{}",
            target_triple.replace('/', "-")
        );

        #[cfg(feature = "pack-oci")]
        {
            // Route through draupnir's ONE OCI engine (zero-shell) — no `podman`
            // subprocess. `build_image` tars `ctx` and drives the daemon's `/build`;
            // `extract_path` is the `create`+`cp` twin. It roots the tar at the path
            // basename (`out/…`), so we flatten `out/`'s CONTENTS into `into` to keep
            // the exact layout the `podman cp {cid}:/out/.` path produced.
            use draupnir::container::ContainerBoot;
            let boot = ContainerBoot::new();
            boot.build_image(ctx.path(), "Containerfile", &tag)
                .map_err(|e| {
                    anyhow::anyhow!(
                        "OCI build_image `{tag}` for `{product}` ({target_triple}): {e}"
                    )
                })?;
            let ex = tempfile::tempdir().context("create OCI extract dir")?;
            boot.extract_path(&tag, "/out", ex.path())
                .map_err(|e| anyhow::anyhow!("OCI extract_path /out from `{tag}`: {e}"))?;
            let captured = ex.path().join("out");
            if captured.is_dir() {
                copy_tree_contents(&captured, into)?;
            }

            // Enumerate what was captured (relative paths under `into`).
            let mut out = Vec::new();
            walk(into, into, &mut out)?;
            out.sort();
            Ok(out)
        }
        #[cfg(not(feature = "pack-oci"))]
        {
            // No OCI engine compiled in (`--no-default-features` / feature off).
            // The constellation zero-shell law forbids the old `podman build`/
            // `create`/`cp` subprocess trio, so bail HONESTLY (never fake a
            // capture, never shell out) and point at the pure-Rust route. The
            // Containerfile render above (`ctx`/`tag`) was real.
            let _ = (&ctx, &tag); // rendered/tagged for real even on the honest-bail path
            anyhow::bail!(
                "container dep-capture for `{product}` ({target_triple}) needs the zero-shell \
                 OCI engine (`--features pack-oci` / `oci`) — a build-host concern, never run at \
                 airgap-install time; no container engine is compiled in (the podman subprocess \
                 path was removed under the zero-shell law)"
            )
        }
    }
}

/// Copy the CONTENTS of `src` (recursively) into `dst`, preserving the relative
/// tree — the `cp <src>/. <dst>` layout. Used by the `pack-oci` engine path to
/// flatten draupnir's `extract_path` output (rooted at `out/…`) into the staging
/// subdir so both engines leave `into` byte-identical.
#[cfg_attr(not(any(feature = "pack-oci", test)), allow(dead_code))]
fn copy_tree_contents(src: &Path, dst: &Path) -> Result<()> {
    std::fs::create_dir_all(dst).with_context(|| format!("create {}", dst.display()))?;
    for e in std::fs::read_dir(src).with_context(|| format!("read {}", src.display()))? {
        let p = e?.path();
        let name = match p.file_name() {
            Some(n) => n.to_owned(),
            None => continue,
        };
        let target = dst.join(&name);
        if p.is_dir() {
            copy_tree_contents(&p, &target)?;
        } else if p.is_file() {
            std::fs::copy(&p, &target)
                .with_context(|| format!("copy {} -> {}", p.display(), target.display()))?;
        }
    }
    Ok(())
}

fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<()> {
    for e in std::fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
        let p = e?.path();
        if p.is_dir() {
            walk(root, &p, out)?;
        } else if p.is_file() {
            out.push(
                p.strip_prefix(root)
                    .unwrap_or(&p)
                    .to_string_lossy()
                    .replace('\\', "/"),
            );
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write as _;

    /// LAW 1 (inject-assert): assemble a real staging tree (artifact + spec),
    /// pack → assert the manifest enumerates them, the version stamp matches, and
    /// the VERSION file is written OUTSIDE the bundle.
    #[test]
    fn pack_collects_staging_tree_and_version_file_is_outside() {
        let scratch = tempfile::tempdir().unwrap();
        let staging = Staging::new(scratch.path().join("stage"), "20250505").unwrap();

        // A real artifact file.
        let bin = scratch.path().join("holger-bin");
        let mut f = std::fs::File::create(&bin).unwrap();
        f.write_all(b"ELF...holger").unwrap();
        staging.add_artifact(&bin, "linux-x86_64/holger").unwrap();
        // Its component spec.
        staging
            .add_component_spec(&crate::product::ComponentSpec::holger())
            .unwrap();

        let manifest = pack(&staging, "holger", true).unwrap();
        assert_eq!(manifest.product, "holger");
        assert_eq!(manifest.version, "20250505");
        assert!(manifest.built_in_container);
        assert!(
            manifest
                .artifacts
                .contains(&"linux-x86_64/holger".to_string())
        );
        assert!(
            manifest
                .artifacts
                .contains(&"manifests/holger.json".to_string())
        );

        // VERSION file lives BESIDE the bundle (easy bump), not in the tree.
        let bundle = scratch.path().join("out/lake.tar.zst");
        let vf = write_version_file(&bundle, "20250505").unwrap();
        assert_eq!(vf, scratch.path().join("out/VERSION"));
        assert_eq!(std::fs::read_to_string(&vf).unwrap(), "20250505\n");
        assert!(
            !staging.root.join("VERSION").exists(),
            "VERSION not in bundle tree"
        );
    }

    /// LAW 1 (inject-assert): the podman dep-capture build renders a real, exact
    /// Containerfile from the base image + capture steps (FROM, ARGs, /out, every
    /// RUN), and — when podman is absent — `build_into` fails with a clear,
    /// honest error rather than faking a capture.
    #[test]
    fn podman_build_renders_containerfile_and_is_honest_when_absent() {
        let build = PodmanBuild::new("registry.access.redhat.com/ubi9/ubi-minimal")
            .step("microdnf install -y openssl-libs")
            .step("cp /usr/bin/holger /out/holger && cp /usr/lib64/libssl.so.3 /out/");

        // The rendered Containerfile is exact and ordered.
        let cf = build.containerfile("holger", "x86_64-unknown-linux-gnu");
        let expected = "\
FROM registry.access.redhat.com/ubi9/ubi-minimal
ARG PRODUCT=holger
ARG TARGET=x86_64-unknown-linux-gnu
RUN mkdir -p /out
RUN microdnf install -y openssl-libs
RUN cp /usr/bin/holger /out/holger && cp /usr/lib64/libssl.so.3 /out/
";
        assert_eq!(cf, expected, "Containerfile must render byte-for-byte");

        // A build that cannot succeed must fail HONESTLY (a clear error) and never
        // fabricate a capture — asserted per engine, since the failure surface
        // differs (subprocess-absent vs. the OCI engine's daemon/build error).
        let scratch = tempfile::tempdir().unwrap();
        let into = scratch.path().join("stage/linux-x86_64");

        #[cfg(not(feature = "pack-oci"))]
        {
            // Feature-off: no OCI engine compiled in → honest zero-shell bail
            // pointing at the `pack-oci` feature (never a fake capture, no shell).
            let absent = PodmanBuild::new("scratch");
            let err = absent
                .build_into("holger", "x86_64-unknown-linux-gnu", &into)
                .unwrap_err()
                .to_string();
            assert!(
                err.contains("pack-oci"),
                "honest: needs the OCI engine feature: {err}"
            );
        }
        #[cfg(feature = "pack-oci")]
        {
            // OCI path: the `pack-oci` routing ignores the `podman` binary name and
            // drives draupnir's ONE OCI engine. Both an absent daemon (connect error)
            // and an unbuildable base (`scratch` has no shell for `RUN mkdir -p /out`)
            // surface an honest, `build_image`-prefixed error — never a fake capture.
            let broken = PodmanBuild::new("scratch");
            let err = broken
                .build_into("holger", "x86_64-unknown-linux-gnu", &into)
                .unwrap_err()
                .to_string();
            assert!(
                err.contains("OCI build_image"),
                "honest OCI engine error: {err}"
            );
        }

        // No artifacts were faked into the staging dir (the OCI path pre-creates the
        // empty dir before building; an empty dir is not a fabricated capture).
        assert!(
            !into.exists()
                || std::fs::read_dir(&into)
                    .map(|mut d| d.next().is_none())
                    .unwrap_or(true),
            "a failed build must not fabricate captured artifacts"
        );
    }

    /// LAW 1 (inject-assert): the `pack-oci` engine path flattens draupnir's
    /// `extract_path` output (rooted at `out/…`) into the staging subdir so BOTH
    /// engines leave `into` byte-identical (same relative paths the `walk` manifest
    /// enumerates). Proves the layout-normalisation helper directly (no daemon).
    #[test]
    fn copy_tree_contents_flattens_extracted_out_dir_into_staging() {
        let scratch = tempfile::tempdir().unwrap();
        // Simulate draupnir `extract_path(&tag, "/out", ex)` → ex/out/<tree>.
        let extracted_out = scratch.path().join("ex/out");
        std::fs::create_dir_all(extracted_out.join("linux-x86_64")).unwrap();
        std::fs::write(extracted_out.join("linux-x86_64/holger"), b"ELF...holger").unwrap();
        std::fs::write(extracted_out.join("libssl.so.3"), b"...lib").unwrap();

        // Flatten CONTENTS of out/ into the staging subdir (the `cp /out/.` shape).
        let into = scratch.path().join("stage/linux-x86_64");
        copy_tree_contents(&extracted_out, &into).unwrap();

        // The manifest walk sees the SAME relative paths a `podman cp {cid}:/out/.`
        // would have produced — no stray `out/` prefix.
        let mut got = Vec::new();
        walk(&into, &into, &mut got).unwrap();
        got.sort();
        assert_eq!(
            got,
            vec!["libssl.so.3".to_string(), "linux-x86_64/holger".to_string()]
        );
        assert_eq!(
            std::fs::read(into.join("linux-x86_64/holger")).unwrap(),
            b"ELF...holger"
        );
    }

    /// An image reference becomes a filesystem-safe name; the `/` and `:` that make
    /// an image ref unusable as a filename are the ones that must go.
    #[test]
    fn an_image_ref_sanitises_to_a_usable_filename() {
        assert_eq!(
            sanitise_image_ref("localhost/nordisk-spark-iceberg:4.1.2"),
            "localhost-nordisk-spark-iceberg-4.1.2"
        );
        assert_eq!(
            sanitise_image_ref("localhost/nordisk-falkordb-valkey:9-20260724"),
            "localhost-nordisk-falkordb-valkey-9-20260724"
        );
    }

    /// **THE integrity property, proven rather than asserted in prose**: an image
    /// staged under `images/` is digested into the `BundleManifest` and re-verified
    /// on receipt exactly like any other artifact — because it is placed in the
    /// staging tree and nothing about it is special.
    ///
    /// The archive bytes here are a stand-in (no daemon in unit tests); what is
    /// under test is the PACKING guarantee, not the export. RED-when-broken: write
    /// the image outside the staging root and it vanishes from both the manifest
    /// and the verify pass.
    #[test]
    fn a_staged_image_is_covered_by_the_bundle_digest_and_verify() {
        use crate::archive::{Archive, TarZstd};

        let scratch = tempfile::tempdir().unwrap();
        let staging = Staging::new(scratch.path().join("stage"), "20260811").unwrap();
        staging
            .add_artifact(
                &{
                    let p = scratch.path().join("holger");
                    std::fs::write(&p, b"ELF...holger").unwrap();
                    p
                },
                "linux-x86_64/holger",
            )
            .unwrap();

        // An image archive placed exactly where `add_image_archive` places one.
        let rel = format!("{IMAGES_REL}/localhost-nordisk-spark-iceberg-4.1.2.tar");
        let image_path = staging.root.join(&rel);
        std::fs::create_dir_all(image_path.parent().unwrap()).unwrap();
        std::fs::write(&image_path, b"fake-image-archive-bytes").unwrap();

        // It shows up as an ordinary artifact.
        assert!(
            staging.artifacts().unwrap().contains(&rel),
            "a staged image is enumerated like every other artifact"
        );

        let bundle = scratch.path().join("out/bundle.tar.zst");
        let engine = TarZstd::new();
        let manifest = engine.pack(&staging.root, "20260811", &bundle).unwrap();

        // Digested: path + sha256 + len, the same EntryDigest shape as the binary.
        let entry = manifest
            .entries
            .iter()
            .find(|e| e.path == rel)
            .expect("the packed image is in the bundle manifest");
        assert_eq!(entry.sha256, crate::archive::sha256_hex(b"fake-image-archive-bytes"));
        assert_eq!(entry.len, b"fake-image-archive-bytes".len() as u64);
        assert!(manifest.verify_root(), "the root covers the image entry too");

        // And verify-on-receipt re-hashes it out of the archive: clean now…
        assert!(
            engine.verify_entries(&bundle, &manifest).unwrap().is_empty(),
            "a faithfully packed image verifies clean"
        );
        // …and a tampered manifest digest for the IMAGE is caught, which is the
        // property that matters: the image is not exempt from verification.
        let mut tampered = manifest.clone();
        for e in tampered.entries.iter_mut() {
            if e.path == rel {
                e.sha256 = crate::archive::sha256_hex(b"something-else");
            }
        }
        let bad = engine.verify_entries(&bundle, &tampered).unwrap();
        assert!(
            bad.contains(&rel),
            "a wrong digest on the packed image is reported bad, not skipped: {bad:?}"
        );
    }

    /// A bundle that carries no images is not a fault — the overwhelmingly common
    /// case. RED-when-broken: return an error for a missing dir and every existing
    /// product's unfold breaks.
    #[cfg(feature = "pack-oci")]
    #[test]
    fn loading_images_from_a_bundle_that_has_none_is_not_an_error() {
        let scratch = tempfile::tempdir().unwrap();
        assert!(load_bundled_images(scratch.path()).unwrap().is_empty());
    }
}