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