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

nordisk/edda

crates/nornir-build-thing/src/container.rs

//! **Container / appliance IMAGE build** — a second build *target kind* alongside
//! the cargo [`BuildVariant`](crate::variant::BuildVariant).
//!
//! Where a `BuildVariant` describes one `cargo build` point, a [`ContainerBuild`]
//! describes building an **OCI / appliance image** declaratively: a `base`, the
//! `layers` (payloads copied in — a host path, or the artifact a cargo variant
//! produced), an `entrypoint`/`cmd`, `env`, exposed ports, labels, and image
//! `tags`. It is generic enough that a **tunnr appliance image** and a **desktop
//! project app image** both express cleanly.
//!
//! It is *data* (pure serde) and dependency-light like the rest of the crate: it
//! materializes a **Containerfile** ([`ContainerBuild::containerfile`]) and the
//! image-builder **argv** ([`ContainerBuild::build_args`]) but runs nothing
//! itself — the host (nornir / an xtask) drives the chosen engine
//! (`docker`/`podman`/`buildah`). The descriptor is content-addressable
//! ([`ContainerBuild::content_id`]) so an image build can carry provenance the
//! same way a cargo build does.
//!
//! ```
//! use nornir_build_thing::{ContainerBuild, ImageLayer, ImageFormat};
//!
//! let img = ContainerBuild {
//!     name: "tunnr-appliance".into(),
//!     base: "debian:bookworm-slim".into(),
//!     layers: vec![ImageLayer::from_variant("full", "/usr/local/bin/tunnr")],
//!     entrypoint: vec!["/usr/local/bin/tunnr".into()],
//!     tags: vec!["tunnr-appliance:0.1".into()],
//!     ..Default::default()
//! };
//! assert!(img.containerfile().contains("FROM debian:bookworm-slim"));
//! assert!(img.build_args("docker").contains(&"tunnr-appliance:0.1".to_string()));
//! ```
//!
//! # Two modes, never blended
//!
//! Synthesis (above) covers the images this schema can *describe*. It cannot
//! describe the ones that need `RUN`, `ARG`, a multi-stage `FROM … AS build` or
//! `COPY --from=build` — and a cell that quietly dropped those would build a
//! plausible-looking WRONG image (a Spark with no Iceberg jar, a Valkey with no
//! FalkorDB module) that STARTS and fails much later. So the second mode names a
//! **real Containerfile on disk** and synthesizes nothing:
//!
//! ```
//! use nornir_build_thing::ContainerBuild;
//!
//! let img = ContainerBuild {
//!     name: "nordisk-spark-iceberg".into(),
//!     containerfile: "containers/spark-iceberg/Containerfile".into(),
//!     tags: vec!["localhost/nordisk-spark-iceberg:4.1.2".into()],
//!     ..Default::default()
//! };
//! img.validate().unwrap();
//! // `-f` names the real file; the context defaults to its directory.
//! let args = img.build_args("podman");
//! assert_eq!(args[args.len() - 2], "containers/spark-iceberg/Containerfile");
//! assert_eq!(args[args.len() - 1], "containers/spark-iceberg");
//! ```
//!
//! The modes are **exclusive**: exactly one of `base` and `containerfile` may be
//! set, and mixing `containerfile` with any synthesis field
//! (`layers`/`env`/`entrypoint`/…) is refused by
//! [`ContainerBuild::validate`] rather than resolved by a precedence rule nobody
//! would remember. Only `name`, `tags` and `format` are shared — they are engine
//! FLAGS (`-t`, `--format`), not Containerfile text.
//!
//! ```
//! use nornir_build_thing::ContainerBuild;
//! let bad = ContainerBuild {
//!     name: "blended".into(),
//!     base: "scratch".into(),
//!     containerfile: "containers/x/Containerfile".into(),
//!     ..Default::default()
//! };
//! assert!(bad.validate().is_err(), "the two modes never blend");
//! ```

use std::collections::BTreeMap;

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

/// The image artifact format a [`ContainerBuild`] produces. Deserializes from a
/// bare TOML string (`format = "oci"`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum ImageFormat {
    /// An OCI image (the portable default).
    Oci,
    /// A Docker-format image.
    Docker,
    /// A single-file appliance / disk image (a VM or bootable appliance).
    Appliance,
}

impl Default for ImageFormat {
    fn default() -> Self {
        ImageFormat::Oci
    }
}

impl From<String> for ImageFormat {
    fn from(s: String) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "" | "oci" => ImageFormat::Oci,
            "docker" => ImageFormat::Docker,
            "appliance" | "disk" | "vm" => ImageFormat::Appliance,
            _ => ImageFormat::Oci,
        }
    }
}

impl From<ImageFormat> for String {
    fn from(f: ImageFormat) -> Self {
        match f {
            ImageFormat::Oci => "oci",
            ImageFormat::Docker => "docker",
            ImageFormat::Appliance => "appliance",
        }
        .to_string()
    }
}

impl ImageFormat {
    /// Stable label (matches the string form).
    pub fn as_str(&self) -> &'static str {
        match self {
            ImageFormat::Oci => "oci",
            ImageFormat::Docker => "docker",
            ImageFormat::Appliance => "appliance",
        }
    }
}

/// One payload copied into the image: a `source` placed at `dest` inside the
/// image. `source` is either a host path/URL, or — when [`from_variant`] is set —
/// the artifact a cargo [`BuildVariant`](crate::variant::BuildVariant) produced,
/// letting an image chain a cargo build's output as a layer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ImageLayer {
    /// Source path (host path). Omitted when [`from_variant`] supplies the
    /// payload — the artifact a cargo build produced.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub source: String,
    /// Destination path inside the image.
    pub dest: String,
    /// When set, `source` refers to the artifact produced by this cargo build
    /// variant name (the image layer *is* that variant's binary).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub from_variant: Option<String>,
    /// Mark the copied file executable (`chmod +x`).
    #[serde(default, skip_serializing_if = "is_false")]
    pub executable: bool,
}

fn is_false(b: &bool) -> bool {
    !*b
}

impl ImageLayer {
    /// A layer copying a host `source` to `dest`.
    pub fn copy(source: impl Into<String>, dest: impl Into<String>) -> Self {
        ImageLayer { source: source.into(), dest: dest.into(), from_variant: None, executable: false }
    }

    /// A layer whose payload is the artifact a cargo build `variant` produced,
    /// placed (executable) at `dest`.
    pub fn from_variant(variant: impl Into<String>, dest: impl Into<String>) -> Self {
        ImageLayer {
            source: String::new(),
            dest: dest.into(),
            from_variant: Some(variant.into()),
            executable: true,
        }
    }

    /// The `COPY` source rendered in the Containerfile — the artifact path when
    /// chained from a variant (a build-context relative name), else `source`.
    fn copy_source(&self) -> String {
        match &self.from_variant {
            // The host stages the variant's artifact under this build-context name.
            Some(v) => format!("./{v}"),
            None => self.source.clone(),
        }
    }
}

/// One concrete **image build point** — the container-target sibling of a cargo
/// [`BuildVariant`](crate::variant::BuildVariant).
///
/// Serializes as a `[[build.container]]` table:
/// ```toml
/// [[build.container]]
/// name = "tunnr-appliance"
/// base = "debian:bookworm-slim"
/// entrypoint = ["/usr/local/bin/tunnr"]
/// tags = ["tunnr-appliance:0.1"]
/// format = "oci"
///
/// [[build.container.layers]]
/// from_variant = "full"          # chain the cargo `full` variant's binary
/// dest = "/usr/local/bin/tunnr"
/// ```
/// **The recipe of a container's base image** — a Containerfile in this repo or in a
/// sibling checkout, built under the base's own tag.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BaseRecipe {
    /// The sibling checkout the recipe lives in (`"korp"`); empty = this repo.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub repo: String,
    /// The Containerfile, relative to that checkout.
    pub containerfile: String,
    /// The build context, relative to that checkout; empty = the Containerfile's
    /// own directory.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub context: String,
}

impl BaseRecipe {
    /// `(checkout, context dir, containerfile path relative to the context)` for a
    /// recipe of a container cell in `repo_root`. A sibling resolves beside it.
    /// Refused BY NAME when the repo is not a NAME or a path escapes its checkout.
    pub fn locate(&self, repo_root: &std::path::Path) -> anyhow::Result<(std::path::PathBuf, std::path::PathBuf, String)> {
        let bad = |p: &str| p.is_empty() || p.starts_with('/') || p.split('/').any(|c| c == "..");
        if !self.repo.is_empty() && (self.repo.contains('/') || self.repo.contains("..")) {
            anyhow::bail!("base_recipe.repo `{}` is not a sibling checkout NAME", self.repo);
        }
        if bad(&self.containerfile) || (!self.context.is_empty() && bad(&self.context)) {
            anyhow::bail!("base_recipe paths must stay inside the checkout: `{}` / `{}`", self.containerfile, self.context);
        }
        let checkout = if self.repo.is_empty() {
            repo_root.to_path_buf()
        } else {
            repo_root.parent().unwrap_or(repo_root).join(&self.repo)
        };
        let file = std::path::Path::new(&self.containerfile);
        let context_rel = if self.context.is_empty() {
            file.parent().map(std::path::Path::to_path_buf).unwrap_or_default()
        } else {
            std::path::PathBuf::from(&self.context)
        };
        let in_context = file
            .strip_prefix(&context_rel)
            .map(|p| p.display().to_string())
            .map_err(|_| anyhow::anyhow!("base_recipe.containerfile `{}` is not inside its context `{}`", self.containerfile, context_rel.display()))?;
        Ok((checkout.clone(), checkout.join(context_rel), in_context))
    }
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContainerBuild {
    /// Stable handle (`nornir build --image <name>`).
    pub name: String,
    /// Base image (`FROM`), e.g. `debian:bookworm-slim` or `scratch`. Required in
    /// synthesis mode; MUST be empty in file mode, where the `FROM` (or several,
    /// for a multi-stage build) lives in the Containerfile on disk.
    ///
    /// `#[serde(default)]` since file mode landed — without it serde REFUSES a
    /// `containerfile = "…"` cell at parse time with "missing field `base`", which
    /// is both the wrong error and the wrong layer to raise it at. Emptiness is
    /// still rejected in synthesis mode, by [`validate`](Self::validate), which is
    /// where the mode is known. Serialization is unchanged: `base` carries no
    /// `skip_serializing_if`, so every existing descriptor's JSON is byte-identical.
    #[serde(default)]
    pub base: String,
    /// Payloads copied into the image, in order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub layers: Vec<ImageLayer>,
    /// The image `ENTRYPOINT` (exec form).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub entrypoint: Vec<String>,
    /// The image `CMD` (default args, exec form).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub cmd: Vec<String>,
    /// The image `WORKDIR`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workdir: Option<String>,
    /// Environment (`ENV`), key-sorted so materialization is deterministic.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
    /// Ports to `EXPOSE`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub expose: Vec<u16>,
    /// Image `LABEL`s (key-sorted).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub labels: BTreeMap<String, String>,
    /// Image tags to apply (`-t`), e.g. `tunnr-appliance:0.1`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tags: Vec<String>,
    /// **The digest this recipe must produce, when a consumer pins it** (2026-09-22). Set at
    /// build time from the consumer's pin (a `[[build.kernel]]` row's `toolchain_image_sha256`
    /// for the image it names, [`crate::kernel::toolchain_pin_for`]); never declared and never
    /// serialized, so no descriptor's JSON or `content_id` moves.
    ///
    /// When it is set, [`crate::image_build::build_container`] builds under a STAGING tag,
    /// checks the built image against it, and only then applies [`tags`](Self::tags). MEASURED
    /// by T16 on t14s: every `--build kernel` compose POSTed `/build?t=nordisk-kernel-toolchain:latest`,
    /// the layer cache answered with the 2026-09-15 rebuild `311bc06f`, and `:latest` moved to
    /// an image no row pins (`78eaa55a`) while the kernel step itself ran by digest — so
    /// everything that resolves the image by TAG got the wrong one, and nothing said so.
    #[serde(skip)]
    pub expected_sha256: String,
    /// Output image format.
    #[serde(default, skip_serializing_if = "is_oci")]
    pub format: ImageFormat,
    /// **FILE MODE**: a real Containerfile on disk, repo-root-relative
    /// (`containers/spark-iceberg/Containerfile`). When set, NOTHING is
    /// synthesized — the file's own bytes are the build input, so `RUN`, `ARG`,
    /// multi-stage `FROM … AS build` and `COPY --from=build` all work.
    ///
    /// Mutually exclusive with the synthesis fields (`base`/`layers`/… — see
    /// [`validate`](Self::validate)): exactly one of `base` and `containerfile`
    /// may be set, and a cell that sets both is REFUSED rather than resolved by a
    /// precedence nobody remembers.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub containerfile: String,
    /// **FILE MODE**: the build context directory, repo-root-relative — what the
    /// engine tars up and what the Containerfile's `COPY` resolves against.
    /// Defaults to `containerfile`'s parent directory (the usual
    /// `containers/<image>/` layout), so it is normally omitted.
    ///
    /// Only meaningful with [`containerfile`](Self::containerfile): in synthesis
    /// mode the context is fixed by the layer-staging contract (a `from_variant`
    /// layer's artifact is staged under `./<variant>`), so honouring a different
    /// context there would silently invalidate that staging. Setting `context`
    /// without `containerfile` is therefore an error, not a no-op.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub context: String,
    /// **DISTRIBUTABLE ARCHIVE**: when set, the built image is also EXPORTED to
    /// this repo-root-relative tar path (e.g. `.container-out/gunnar-image.tar`)
    /// — the engine's image-export stream (docker-archive format, what
    /// `docker save` writes; `podman load` and `docker load` both read it), so a
    /// release can carry the container as a FILE instead of "it exists in podman
    /// storage on the build box". Empty (the default) exports nothing, and every
    /// existing descriptor's JSON is byte-identical (`skip_serializing_if`).
    /// Valid in both synthesis and file mode: it is an OUTPUT, not a build input.
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub archive: String,
    /// **Consumption edges** (`[build.container.edges.<feature>]`) — a root
    /// feature bound to another build thing's artifact, `repo/variant : typ`,
    /// type-checked at load against the donor's declaration — the SAME edge
    /// every other kind carries (design f3: "any feature bound to
    /// `repo/variant : typ`"). Empty for every existing descriptor.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub edges: BTreeMap<String, crate::tree::EdgeDecl>,
    /// **Where [`base`](Self::base) is built from, when the base is OURS**
    /// (`[build.container.base_recipe]`). A forge whose engine does not have the
    /// base builds it from this recipe first — a jera job before the cell —
    /// instead of failing the cell on a `FROM` it cannot pull (loki 2026-10-16:
    /// tunnr's `localhost/tunnr-rdp-weston-base:latest` and
    /// `localhost/nordisk-falkordb-valkey:9`, both recipes of ours). Absent on every
    /// existing descriptor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_recipe: Option<BaseRecipe>,
    /// **What this image's consumers require of it** — the toolchain surface's
    /// closure, landed by [`apply_tool_requirements`](Self::apply_tool_requirements)
    /// before the build. The twin of
    /// [`KernelBuild::requirements`](crate::kernel::KernelBuild::requirements):
    /// derived, never hand-declared, and carried on the row so the build can
    /// emit `toolchain.why.toml` beside the image. Absent on every image nobody
    /// requires anything of, so every existing descriptor's JSON is unchanged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub requires: Option<crate::toolchain_requires::ToolRequirements>,
}

fn is_oci(f: &ImageFormat) -> bool {
    *f == ImageFormat::Oci
}

impl ContainerBuild {
    /// This descriptor's artifact kind — always
    /// [`ArtifactKind::Container`](crate::variant::ArtifactKind::Container). The
    /// unifying label that lets one dispatcher / enumeration / provenance `kind`
    /// span containers alongside the cargo variants.
    pub fn artifact_kind(&self) -> crate::variant::ArtifactKind {
        crate::variant::ArtifactKind::Container
    }

    /// The cargo build variants this image chains as layers (via
    /// [`ImageLayer::from_variant`]) — the builds that must run first so their
    /// artifacts can be staged into the build context.
    pub fn variant_deps(&self) -> Vec<&str> {
        self.layers.iter().filter_map(|l| l.from_variant.as_deref()).collect()
    }

    /// The first image tag, if any (the "primary" reference).
    pub fn primary_tag(&self) -> Option<&str> {
        self.tags.first().map(|s| s.as_str())
    }

    /// Whether this cell builds a **real Containerfile on disk** rather than a
    /// synthesized one — i.e. [`containerfile`](Self::containerfile) is set.
    pub fn is_file_mode(&self) -> bool {
        !self.containerfile.trim().is_empty()
    }

    /// The build-context directory the engine tars up and `COPY` resolves
    /// against, repo-root-relative.
    ///
    /// File mode: the declared [`context`](Self::context), else the declared
    /// Containerfile's PARENT directory (`containers/spark-iceberg/Containerfile`
    /// → `containers/spark-iceberg`), else `.`. Synthesis mode: always `.` — the
    /// repo root, which is what the layer-staging contract assumes.
    pub fn context_dir(&self) -> String {
        if !self.is_file_mode() {
            return ".".to_string();
        }
        let ctx = self.context.trim();
        if !ctx.is_empty() {
            return normalize_rel(ctx);
        }
        let cf = normalize_rel(self.containerfile.trim());
        match cf.rsplit_once('/') {
            Some((parent, _)) if !parent.is_empty() => parent.to_string(),
            _ => ".".to_string(),
        }
    }

    /// The Containerfile's path **relative to the build context** — the name an
    /// engine that receives a context TAR (bollard's `dockerfile` option) must use
    /// to find it inside the stream. `Dockerfile` in synthesis mode, since that is
    /// the name this crate tars its synthesized text under.
    ///
    /// Always inside the context: [`validate`](Self::validate) refuses a
    /// Containerfile that is not.
    pub fn containerfile_in_context(&self) -> String {
        if !self.is_file_mode() {
            return "Dockerfile".to_string();
        }
        let cf = normalize_rel(self.containerfile.trim());
        let ctx = self.context_dir();
        if ctx == "." {
            return cf;
        }
        cf.strip_prefix(&format!("{ctx}/")).map(str::to_string).unwrap_or(cf)
    }

    /// Materialize the **Containerfile** (Dockerfile syntax) for this image.
    /// Deterministic (env/labels are key-sorted), so the same descriptor always
    /// renders the same file — the input a content-addressed image build hashes.
    ///
    /// In **file mode** this synthesizes nothing (there is nothing to synthesize):
    /// it returns a comment-only REFERENCE to the on-disk file. That text carries
    /// no `FROM`, so it can never be handed to an engine as a buildable
    /// Containerfile by mistake — the engine would refuse it immediately instead
    /// of producing a plausible-looking WRONG image. Use
    /// [`containerfile_text`](Self::containerfile_text) to get the bytes an engine
    /// should actually receive.
    pub fn containerfile(&self) -> String {
        if self.is_file_mode() {
            return format!(
                "# containerfile: {}\n# context: {}\n# on-disk Containerfile — nothing is synthesized for this cell\n",
                normalize_rel(self.containerfile.trim()),
                self.context_dir()
            );
        }
        let mut f = String::new();
        f.push_str(&format!("FROM {}\n", self.base));
        if let Some(w) = &self.workdir {
            f.push_str(&format!("WORKDIR {w}\n"));
        }
        // No `RUN chmod +x` here, deliberately. The build context tar already carries
        // the mode (0755 when `executable`), and `COPY` preserves it — so the chmod
        // was always redundant, and on a shell-free base (`scratch`, distroless) it is
        // fatal: there is no `/bin/sh` to run it, so the image fails to build. Emitting
        // it cost every no-libc appliance either a build failure (`executable = true`)
        // or a binary landing 0644 and refusing to start (`executable = false`).
        for l in &self.layers {
            f.push_str(&format!("COPY {} {}\n", l.copy_source(), l.dest));
        }
        for (k, v) in &self.env {
            f.push_str(&format!("ENV {k}={v}\n"));
        }
        for p in &self.expose {
            f.push_str(&format!("EXPOSE {p}\n"));
        }
        for (k, v) in &self.labels {
            f.push_str(&format!("LABEL {k}={}\n", quote(v)));
        }
        if !self.entrypoint.is_empty() {
            f.push_str(&format!("ENTRYPOINT {}\n", json_exec(&self.entrypoint)));
        }
        if !self.cmd.is_empty() {
            f.push_str(&format!("CMD {}\n", json_exec(&self.cmd)));
        }
        f
    }

    /// Materialize the image-builder **argv** for `engine` (`docker` / `podman` /
    /// `buildah`): `build [-t <tag>]… -f <file> <context>`.
    ///
    /// Synthesis mode emits `-f - .`: the Containerfile is fed on stdin, so the
    /// host pipes [`containerfile`](Self::containerfile) and the context is the
    /// repo root. File mode names the REAL file and its context instead
    /// (`-f containers/spark-iceberg/Containerfile containers/spark-iceberg`), so
    /// nothing is piped and the engine reads a Containerfile this crate never
    /// rewrote. Both paths are relative to the repo root, which is the engine's
    /// working directory.
    pub fn build_args(&self, engine: &str) -> Vec<String> {
        let _ = engine; // argv is engine-neutral for docker/podman/buildah
        let mut a = vec!["build".to_string()];
        for t in &self.tags {
            a.push("-t".to_string());
            a.push(t.clone());
        }
        // Appliance/OCI output hint for engines that support it.
        if self.format != ImageFormat::Docker {
            a.push("--format".to_string());
            a.push(self.format.as_str().to_string());
        }
        a.push("-f".to_string());
        if self.is_file_mode() {
            a.push(normalize_rel(self.containerfile.trim()));
            a.push(self.context_dir());
        } else {
            a.push("-".to_string()); // Containerfile on stdin
            a.push(".".to_string()); // build context
        }
        a
    }

    /// The Containerfile **bytes an engine must receive**, resolved against
    /// `repo_root`: the real file's contents in file mode, the synthesized text in
    /// synthesis mode.
    ///
    /// This is the one accessor that touches disk, and it is what a builder should
    /// call — [`containerfile`](Self::containerfile) alone cannot tell the truth
    /// for a file-mode cell.
    pub fn containerfile_text(&self, repo_root: &std::path::Path) -> Result<String> {
        if !self.is_file_mode() {
            return Ok(self.containerfile());
        }
        let p = repo_root.join(normalize_rel(self.containerfile.trim()));
        std::fs::read_to_string(&p).map_err(|e| {
            anyhow!("container '{}': read Containerfile {}: {e}", self.name, p.display())
        })
    }

    /// A human-readable one-liner, e.g. `docker build -t tunnr-appliance:0.1 -f - .`.
    pub fn command_line(&self, engine: &str) -> String {
        format!("{engine} {}", self.build_args(engine).join(" "))
    }

    /// Validate the descriptor: a name, EXACTLY ONE of the two build modes, and —
    /// in synthesis mode — every layer fully specified.
    ///
    /// **The two modes never blend.** A cell either names a real Containerfile on
    /// disk (`containerfile`) or describes one to synthesize (`base` + `layers` +
    /// `env` + …). Setting both is not resolved by precedence — there is no
    /// answer to "is the synthesized text prepended, appended, or ignored?" that a
    /// reader could predict six months later — so it is REFUSED here, at
    /// declaration time, where `--list` can report the cell as unbuildable. Only
    /// `name`, `tags` and `format` are shared: those are engine FLAGS
    /// (`-t`/`--format`), not Containerfile text, so they carry no ambiguity.
    ///
    /// Path checks are lexical (no I/O) so this stays callable from a wasm
    /// build-thing plugin; [`validate_in`](Self::validate_in) adds the on-disk
    /// existence check.
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            return Err(anyhow!("container build: empty name"));
        }
        crate::tree::validate_edge_rows(&format!("container '{}'", self.name), None, &self.edges, &[])?;
        // `archive` is an output path and must stay inside the repo: an absolute
        // path (or a `..` escape) would let a descriptor write anywhere on the
        // build host, and the artifact record would then name a file outside the
        // checkout it claims to describe.
        let arch = self.archive.trim();
        if !arch.is_empty()
            && (std::path::Path::new(arch).is_absolute()
                || arch.split(['/', '\\']).any(|c| c == ".."))
        {
            return Err(anyhow!(
                "container '{}': `archive` must be a repo-root-relative path without `..` \
                 (got `{arch}`)",
                self.name
            ));
        }
        if self.is_file_mode() {
            return self.validate_file_mode();
        }
        if !self.context.trim().is_empty() {
            return Err(anyhow!(
                "container '{}': `context` is set without `containerfile`. A synthesized cell's \
                 build context is fixed at the repo root by the layer-staging contract (a \
                 `from_variant` layer's artifact is staged as `./<variant>`), so a different \
                 context would break every `COPY` this crate emits — declare a `containerfile` \
                 or drop the `context`",
                self.name
            ));
        }
        if self.base.trim().is_empty() {
            return Err(anyhow!("container '{}': empty base image", self.name));
        }
        for (i, l) in self.layers.iter().enumerate() {
            if l.dest.trim().is_empty() {
                return Err(anyhow!("container '{}': layer #{i} has no dest", self.name));
            }
            if l.from_variant.is_none() && l.source.trim().is_empty() {
                return Err(anyhow!(
                    "container '{}': layer #{i} needs a `source` or a `from_variant`",
                    self.name
                ));
            }
        }
        Ok(())
    }

    /// The file-mode half of [`validate`](Self::validate): refuse any synthesis
    /// field, and refuse a path that cannot work.
    fn validate_file_mode(&self) -> Result<()> {
        // ── the exclusivity gate ──────────────────────────────────────────────
        let mut blended: Vec<&str> = Vec::new();
        if !self.base.trim().is_empty() {
            blended.push("base");
        }
        if !self.layers.is_empty() {
            blended.push("layers");
        }
        if !self.entrypoint.is_empty() {
            blended.push("entrypoint");
        }
        if !self.cmd.is_empty() {
            blended.push("cmd");
        }
        if self.workdir.is_some() {
            blended.push("workdir");
        }
        if !self.env.is_empty() {
            blended.push("env");
        }
        if !self.expose.is_empty() {
            blended.push("expose");
        }
        if !self.labels.is_empty() {
            blended.push("labels");
        }
        if !blended.is_empty() {
            return Err(anyhow!(
                "container '{}': `containerfile` builds the file on disk VERBATIM, so it cannot \
                 be combined with the synthesized fields {} — there is no defined order in which \
                 synthesized text would join a real Containerfile (prepended? appended? \
                 ignored?), so the combination is refused instead of guessed. Move those \
                 directives into {} , or drop `containerfile` and let this cell synthesize. \
                 (`tags` and `format` are engine flags and stay legal in both modes.)",
                self.name,
                blended.join(" + "),
                self.containerfile.trim()
            ));
        }

        // ── the path gate ─────────────────────────────────────────────────────
        let raw_cf = self.containerfile.trim();
        check_rel_path(&self.name, "containerfile", raw_cf)?;
        let cf = normalize_rel(raw_cf);
        let raw_ctx = self.context.trim();
        if !raw_ctx.is_empty() {
            check_rel_path(&self.name, "context", raw_ctx)?;
        }
        let ctx = self.context_dir();
        // podman/Docker forbid `COPY` reaching above the build context, and the
        // engine only ever tars the context — so a Containerfile living OUTSIDE it
        // is not merely unusual, it is unbuildable: the file is not in the stream
        // the engine receives. Refuse it here rather than at build time.
        if ctx != "." && !(cf == ctx || cf.starts_with(&format!("{ctx}/"))) {
            return Err(anyhow!(
                "container '{}': containerfile '{cf}' lives OUTSIDE its build context '{ctx}'. \
                 The engine tars up the context and nothing above it, and `COPY` cannot reach \
                 above it either, so this cell could never build — put the Containerfile inside \
                 '{ctx}', or widen `context` to a directory that holds it",
                self.name
            ));
        }
        Ok(())
    }

    /// [`validate`](Self::validate) plus the **on-disk** check a file-mode cell
    /// needs: the named Containerfile really exists under `repo_root`, and its
    /// build context really is a directory.
    ///
    /// This is what makes `--list` tell the truth. A cell naming a Containerfile
    /// that is not there is not "buildable, we'll find out later" — it is a broken
    /// declaration, and finding that out at build time (after the queue, after the
    /// deps built) is exactly the failure the declaration exists to prevent.
    /// Synthesis-mode cells touch no disk here and behave exactly as
    /// [`validate`](Self::validate).
    /// **What this image PROVIDES, as the toolchain surface's vocabulary** —
    /// the tool names its own recipe mentions.
    ///
    /// `text` is the image's recipe: the Containerfile's bytes in file mode, or
    /// the synthesized layers' in synthesis mode. A tool is provided iff the
    /// recipe NAMES it, which is exactly the question the hand-written
    /// `the_toolchain_image_carries_every_step_s_tool` asked before this surface
    /// existed — promoted from a test's assertion to the donor's exposure, so
    /// the closure can ask it instead of a human remembering to.
    ///
    /// **What this is not**, said plainly rather than discovered later: it does
    /// not run the image and it does not read a package database. A recipe that
    /// names `clang` in a comment counts as providing it, and a base image that
    /// carries `gcc` without naming it does not. That asymmetry is the honest
    /// one for a DECLARATION-time check — the alternative is running a container
    /// to answer a question about a build plan — and the live half is the build
    /// itself failing, loudly, on a missing binary.
    pub fn provides_tools(&self, recipe: &str, asked: &[String]) -> Vec<String> {
        let mut out: Vec<String> = asked.iter().filter(|t| recipe.contains(t.as_str())).cloned().collect();
        out.sort();
        out.dedup();
        out
    }

    /// **Land a consumer's tool requirements on this image, or refuse BY NAME.**
    ///
    /// The twin of [`KernelBuild::apply_requirements`](crate::kernel::KernelBuild::apply_requirements),
    /// and the same three legs in the same order: the requirers must agree with
    /// each other, the IMAGE must honour what they agree on, and what survives
    /// is kept ON the row so the why-index can be emitted beside the artifact.
    ///
    /// A refusal names tool · consumer · feature · reason, which is the whole
    /// difference between this and a build that dies on `qemu-system-x86: not
    /// found` twenty minutes in with nothing saying who wanted it.
    pub fn apply_tool_requirements(
        &mut self,
        reqs: &crate::toolchain_requires::ToolRequirements,
        recipe: &str,
    ) -> Result<()> {
        let conflicts = reqs.conflicts();
        if !conflicts.is_empty() {
            return Err(anyhow!(
                "container '{}': its consumers disagree about what it must carry — {}",
                self.name,
                conflicts.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
            ));
        }
        let asked: Vec<String> = reqs.rows.keys().cloned().collect();
        let provides = self.provides_tools(recipe, &asked);
        let against = reqs.contradictions(&provides, &format!("the image '{}' recipe", self.name));
        if !against.is_empty() {
            return Err(anyhow!(
                "container '{}' does not satisfy its consumers' requirements — {} — add it to the image's recipe, or drop the axis that needs it",
                self.name,
                against.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")
            ));
        }
        self.requires = Some(reqs.clone());
        Ok(())
    }

    /// The inverted index for this image against a closure — the file
    /// [`TOOLCHAIN_WHY_FILE`](crate::toolchain_requires::TOOLCHAIN_WHY_FILE)
    /// carries, emitted beside the built image.
    ///
    /// `everything` is every tool name worth classifying: the requirements' plus
    /// whatever roster the caller knows the image installs. What it answers that
    /// nothing answered before: **which tools are in this image that nobody asked
    /// for** (`image-only`), the exact twin of `preset-only`, and the same rule
    /// applies — it is a question, not a verdict.
    pub fn tool_why_index(
        &self,
        recipe: &str,
        everything: &[String],
    ) -> crate::toolchain_requires::ToolWhyIndex {
        let empty = crate::toolchain_requires::ToolRequirements::default();
        let reqs = self.requires.as_ref().unwrap_or(&empty);
        let mut asked: Vec<String> = reqs.rows.keys().cloned().collect();
        asked.extend(everything.iter().cloned());
        let provides = self.provides_tools(recipe, &asked);
        crate::toolchain_requires::ToolWhyIndex::of(&provides, reqs)
    }

    pub fn validate_in(&self, repo_root: &std::path::Path) -> Result<()> {
        self.validate()?;
        if !self.is_file_mode() {
            return Ok(());
        }
        let cf = normalize_rel(self.containerfile.trim());
        let abs = repo_root.join(&cf);
        if !abs.is_file() {
            return Err(anyhow!(
                "container '{}': containerfile '{cf}' does not exist under {} (looked at {})",
                self.name,
                repo_root.display(),
                abs.display()
            ));
        }
        let ctx = self.context_dir();
        let ctx_abs = repo_root.join(&ctx);
        if !ctx_abs.is_dir() {
            return Err(anyhow!(
                "container '{}': build context '{ctx}' is not a directory under {} (looked at {})",
                self.name,
                repo_root.display(),
                ctx_abs.display()
            ));
        }
        Ok(())
    }

    /// The canonical bytes an image build's content id is taken over: the
    /// descriptor's Containerfile plus its sorted tags/format — a stable image
    /// spec fingerprint that survives serde round-trips.
    pub fn canonical_bytes(&self) -> Vec<u8> {
        let mut s = self.containerfile();
        s.push_str("#tags:");
        let mut tags = self.tags.clone();
        tags.sort();
        s.push_str(&tags.join(","));
        s.push_str(&format!("#format:{}", self.format.as_str()));
        s.into_bytes()
    }

    /// A content-addressed id (lowercase-hex SHA-256 over [`canonical_bytes`]) —
    /// the image-build analogue of a cargo build's provenance digest.
    pub fn content_id(&self) -> String {
        nornir_hash::sha256_hex(&self.canonical_bytes())
    }
}

/// Lexically normalize a repo-root-relative path: `\` → `/`, drop `.` and empty
/// segments, resolve `..` against the segments already collected. Purely textual —
/// no symlink resolution, no I/O — so it is identical on every host and inside a
/// wasm plugin. `""` normalizes to `.`.
///
/// Leading `..` that cannot be resolved is PRESERVED, so
/// [`check_rel_path`] can still see (and refuse) an escape.
fn normalize_rel(p: &str) -> String {
    let mut out: Vec<&str> = Vec::new();
    for seg in p.split(['/', '\\']) {
        match seg {
            "" | "." => {}
            ".." => match out.last() {
                Some(&last) if last != ".." => {
                    out.pop();
                }
                _ => out.push(".."),
            },
            s => out.push(s),
        }
    }
    if out.is_empty() { ".".to_string() } else { out.join("/") }
}

/// Refuse a path a container cell can never portably use: absolute (it pins the
/// declaration to one host's filesystem, and breaks the moment the repo is cloned,
/// worktree'd or built in CI) or escaping the repo root via `..` (the engine is
/// handed the repo root as its working directory; nothing above it is in scope).
fn check_rel_path(name: &str, field: &str, raw: &str) -> Result<()> {
    let mut c = raw.chars();
    let windows_drive = matches!(
        (c.next(), c.next(), c.next()),
        (Some(d), Some(':'), Some('/' | '\\')) if d.is_ascii_alphabetic()
    );
    if raw.starts_with('/') || raw.starts_with('\\') || windows_drive {
        return Err(anyhow!(
            "container '{name}': `{field}` = '{raw}' is absolute; container paths are relative to \
             the REPO ROOT so the same declaration builds from a clone, a git worktree and CI"
        ));
    }
    let norm = normalize_rel(raw);
    if norm == ".." || norm.starts_with("../") {
        return Err(anyhow!(
            "container '{name}': `{field}` = '{raw}' escapes the repo root (normalizes to \
             '{norm}'); a build context is rooted at the repo, and nothing above it is in scope"
        ));
    }
    Ok(())
}

/// Render an exec-form array as JSON (`["a","b"]`) for `ENTRYPOINT` / `CMD`.
fn json_exec(parts: &[String]) -> String {
    let inner: Vec<String> = parts.iter().map(|p| quote(p)).collect();
    format!("[{}]", inner.join(", "))
}

/// Minimal JSON string quoting for Containerfile exec-form + labels.
fn quote(s: &str) -> String {
    let mut out = String::with_capacity(s.len() + 2);
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            _ => out.push(c),
        }
    }
    out.push('"');
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn appliance() -> ContainerBuild {
        ContainerBuild {
            name: "tunnr-appliance".to_string(),
            base: "debian:bookworm-slim".to_string(),
            layers: vec![
                ImageLayer::from_variant("full", "/usr/local/bin/tunnr"),
                ImageLayer::copy("assets/", "/opt/tunnr/assets"),
            ],
            entrypoint: vec!["/usr/local/bin/tunnr".to_string()],
            cmd: vec!["--serve".to_string()],
            workdir: Some("/opt/tunnr".to_string()),
            env: BTreeMap::from([("TUNNR_ENV".to_string(), "prod".to_string())]),
            expose: vec![8080],
            labels: BTreeMap::from([("org.tunnr.kind".to_string(), "appliance".to_string())]),
            tags: vec!["tunnr-appliance:0.1".to_string()],
            // Unpinned: no consumer pins the appliance image.
            expected_sha256: String::new(),
            format: ImageFormat::Oci,
            // Synthesis mode: no on-disk Containerfile, no declared context. Spelled
            // out rather than `..Default::default()` so the fixture stays exhaustive
            // and the next field added to `ContainerBuild` has to be considered here.
            containerfile: String::new(),
            context: String::new(),
            // No distributable archive in the fixture: the export leg has its own
            // tests; here it must stay off so every other assertion sees the
            // one-artifact shape.
            archive: String::new(),
            // Nobody requires anything of the fixture image: the toolchain
            // surface's legs (the closure, the refusal, the why-index) have
            // their own tests, and `requires` is DERIVED — a hand-declared one
            // would be the second writer that field exists to not have.
            requires: None,
            // Built FROM a public image: no recipe of ours to build it from.
            base_recipe: None,
            // No consumption edges in the fixture: the edge legs (type check,
            // `--build`) have their own tests; here the container keeps its
            // one-artifact shape.
            edges: BTreeMap::new(),
        }
    }

    /// A shell-free base must get a Containerfile it can actually build.
    ///
    /// `scratch` and the distroless images carry no `/bin/sh`, so ANY `RUN` line is a
    /// build failure rather than a slow path. This asserts the property directly —
    /// no `RUN` is emitted for an executable layer — rather than asserting the absence
    /// of one particular string, so a future step that reintroduces a shell command by
    /// another name is caught too.
    ///
    /// Seen red against the previous renderer, which emitted
    /// `RUN chmod +x /gunnar` here.
    #[test]
    fn a_shell_free_base_gets_no_run_line_for_an_executable_layer() {
        let img = ContainerBuild {
            name: "appliance-scratch".into(),
            base: "scratch".into(),
            layers: vec![ImageLayer {
                source: String::new(),
                from_variant: Some("appliance-server".into()),
                dest: "/appliance".into(),
                executable: true,
            }],
            entrypoint: vec!["/appliance".into()],
            ..Default::default()
        };
        let cf = img.containerfile();
        assert!(
            cf.contains("COPY ./appliance-server /appliance\n"),
            "the layer must still be copied: {cf}"
        );
        assert!(
            !cf.lines().any(|l| l.starts_with("RUN ")),
            "a shell-free base cannot execute any RUN line, but got:\n{cf}"
        );
    }

    #[test]
    fn containerfile_materializes_deterministically() {
        let img = appliance();
        let cf = img.containerfile();
        assert!(cf.starts_with("FROM debian:bookworm-slim\n"));
        assert!(cf.contains("WORKDIR /opt/tunnr\n"));
        // the variant-chained layer stages under ./full; its mode rides in the context
        // tar, not in a `RUN`.
        assert!(cf.contains("COPY ./full /usr/local/bin/tunnr\n"));
        assert!(cf.contains("COPY assets/ /opt/tunnr/assets\n"));
        assert!(cf.contains("ENV TUNNR_ENV=prod\n"));
        assert!(cf.contains("EXPOSE 8080\n"));
        assert!(cf.contains(r#"LABEL org.tunnr.kind="appliance""#));
        assert!(cf.contains(r#"ENTRYPOINT ["/usr/local/bin/tunnr"]"#));
        assert!(cf.contains(r#"CMD ["--serve"]"#));
        // deterministic
        assert_eq!(cf, appliance().containerfile());
    }

    #[test]
    fn build_args_carry_tags_and_format() {
        let img = appliance();
        let args = img.build_args("docker");
        assert!(args.windows(2).any(|w| w == ["-t", "tunnr-appliance:0.1"]));
        assert!(args.windows(2).any(|w| w == ["--format", "oci"]));
        assert!(args.windows(2).any(|w| w == ["-f", "-"]), "Containerfile on stdin");
        assert_eq!(args.last().unwrap(), ".");
        assert_eq!(img.command_line("podman"), format!("podman {}", args.join(" ")));
    }

    #[test]
    fn variant_deps_lists_chained_cargo_builds() {
        assert_eq!(appliance().variant_deps(), vec!["full"]);
        assert_eq!(appliance().primary_tag(), Some("tunnr-appliance:0.1"));
    }

    #[test]
    fn validate_catches_missing_base_and_layer_source() {
        let mut img = appliance();
        img.base = "".to_string();
        assert!(img.validate().is_err(), "empty base rejected");

        let mut img = appliance();
        img.layers.push(ImageLayer { source: "".into(), dest: "/x".into(), from_variant: None, executable: false });
        assert!(img.validate().is_err(), "layer with neither source nor from_variant rejected");

        assert!(appliance().validate().is_ok());
    }

    /// Provenance round-trip: the descriptor survives JSON serde byte-for-byte and
    /// its content id is stable across the round-trip (a desktop-app image and a
    /// tunnr appliance both express + fingerprint cleanly).
    #[test]
    fn descriptor_and_content_id_round_trip() {
        let img = appliance();
        let id = img.content_id();
        assert_eq!(id.len(), 64);

        let json = serde_json::to_string_pretty(&img).unwrap();
        let back: ContainerBuild = serde_json::from_str(&json).unwrap();
        assert_eq!(back, img, "ContainerBuild survives a JSON round-trip");
        assert_eq!(back.content_id(), id, "content id is stable across round-trip");

        // A desktop-project app image expresses cleanly too.
        let desktop = ContainerBuild {
            name: "desktop-app".to_string(),
            base: "scratch".to_string(),
            layers: vec![ImageLayer::from_variant("minimal", "/app")],
            entrypoint: vec!["/app".to_string()],
            tags: vec!["desktop-app:latest".to_string()],
            format: ImageFormat::Docker,
            ..Default::default()
        };
        desktop.validate().unwrap();
        let back2: ContainerBuild =
            serde_json::from_str(&serde_json::to_string(&desktop).unwrap()).unwrap();
        assert_eq!(back2, desktop);
        assert_ne!(back2.content_id(), id, "distinct images have distinct ids");
    }

    // ══ FILE MODE: a real Containerfile on disk ═════════════════════════════════

    /// gunnar's checked-in `gunnar-scratch` cell, VERBATIM from
    /// `gunnar/.nornir/build-variants.toml` (lines 735-742). Copied in rather than
    /// read from the sibling checkout so the regression runs on any host; the
    /// sibling sweep below covers the live files when they are there.
    const GUNNAR_SCRATCH_CELL: &str = r#"
name = "gunnar-scratch"
base = "scratch"
entrypoint = ["/gunnar"]
cmd = ["serve"]
workdir = "/data"
expose = [2222, 50051]
tags = ["gunnar-scratch:0.1.0"]
"#;

    /// **The back-compat proof.** An EXISTING synthesis-mode cell — gunnar's real
    /// `gunnar-scratch`, parsed from its checked-in TOML — must render a
    /// byte-identical Containerfile and byte-identical build args after the
    /// `containerfile`/`context` fields landed.
    ///
    /// Frozen as exact strings, not `contains`, because "additive and
    /// back-compatible" is a claim about BYTES: a stray blank line or a reordered
    /// arg changes every existing image's `content_id` and silently invalidates
    /// every provenance record already sealed. `contains` would not have caught
    /// that; this does.
    #[test]
    fn an_existing_synthesis_cell_is_byte_identical_after_the_file_mode_fields() {
        let c: ContainerBuild = toml::from_str(GUNNAR_SCRATCH_CELL).expect("gunnar cell parses");
        assert!(!c.is_file_mode(), "an existing cell declares no containerfile");

        assert_eq!(
            c.containerfile(),
            "FROM scratch\n\
             WORKDIR /data\n\
             EXPOSE 2222\n\
             EXPOSE 50051\n\
             ENTRYPOINT [\"/gunnar\"]\n\
             CMD [\"serve\"]\n",
            "the synthesized Containerfile must not have moved by one byte"
        );
        assert_eq!(
            c.build_args("podman"),
            vec!["build", "-t", "gunnar-scratch:0.1.0", "--format", "oci", "-f", "-", "."],
            "synthesis mode still feeds the Containerfile on stdin against a repo-root context"
        );
        assert_eq!(c.context_dir(), ".");
        assert_eq!(c.containerfile_in_context(), "Dockerfile");
        // The content id is derived from the two above, so freezing it freezes the
        // provenance identity of every already-built synthesis image.
        assert_eq!(
            c.content_id(),
            nornir_hash::sha256_hex(&c.canonical_bytes()),
            "content id still hashes the canonical bytes"
        );
        c.validate().expect("an existing cell still validates");
    }

    /// The whole point: a cell naming a REAL Containerfile hands the engine that
    /// file, not a synthesized one. Modelled on korp's `containers/spark-iceberg`,
    /// which is `ARG`-parameterised with two `RUN` steps and therefore cannot be
    /// expressed by the synthesis fields at all.
    #[test]
    fn a_containerfile_cell_points_the_build_at_the_real_file() {
        let c: ContainerBuild = toml::from_str(
            r#"
name = "nordisk-spark-iceberg"
containerfile = "containers/spark-iceberg/Containerfile"
tags = ["localhost/nordisk-spark-iceberg:4.1.2", "localhost/nordisk-spark-iceberg:latest"]
"#,
        )
        .unwrap();
        c.validate().expect("a file-mode cell with no synthesis fields is valid");
        assert!(c.is_file_mode());

        // `context` defaults to the Containerfile's own directory — the layout korp
        // already uses (`podman build … containers/spark-iceberg`).
        assert_eq!(c.context_dir(), "containers/spark-iceberg");
        assert_eq!(c.containerfile_in_context(), "Containerfile");

        let args = c.build_args("podman");
        assert_eq!(
            args,
            vec![
                "build",
                "-t",
                "localhost/nordisk-spark-iceberg:4.1.2",
                "-t",
                "localhost/nordisk-spark-iceberg:latest",
                "--format",
                "oci",
                "-f",
                "containers/spark-iceberg/Containerfile",
                "containers/spark-iceberg",
            ],
            "-f names the REAL file and the context is its directory; nothing goes on stdin"
        );
        assert!(!args.iter().any(|a| a == "-"), "no stdin Containerfile in file mode: {args:?}");

        // And the synthesizer refuses to pretend: no FROM, so this text can never be
        // mistaken for a buildable Containerfile.
        let cf = c.containerfile();
        assert!(!cf.contains("FROM"), "file mode synthesizes no FROM: {cf}");
        assert!(cf.contains("containers/spark-iceberg/Containerfile"));
    }

    /// An explicit `context` wider than the Containerfile's directory is honoured,
    /// and the context-relative name follows it (what a tar-streaming engine needs).
    #[test]
    fn an_explicit_context_widens_the_tar_and_moves_the_relative_name() {
        let c: ContainerBuild = toml::from_str(
            r#"
name = "falkordb-valkey"
containerfile = "containers/falkordb-valkey/Containerfile"
context = "containers"
tags = ["localhost/nordisk-falkordb-valkey:9-20260724"]
"#,
        )
        .unwrap();
        c.validate().unwrap();
        assert_eq!(c.context_dir(), "containers");
        assert_eq!(c.containerfile_in_context(), "falkordb-valkey/Containerfile");
        let args = c.build_args("podman");
        assert_eq!(args[args.len() - 2], "containers/falkordb-valkey/Containerfile");
        assert_eq!(args[args.len() - 1], "containers");
    }

    /// **The exclusivity rule.** A cell that sets `containerfile` AND any
    /// synthesis field is REFUSED at validation — never silently resolved.
    ///
    /// Each synthesis field is checked on its own so the rule cannot rot into
    /// "only `base` is caught": `env`/`layers`/`labels` are exactly the ones a
    /// reader would expect to be merged into a real Containerfile, and merging is
    /// the behaviour being forbidden.
    #[test]
    fn a_cell_that_blends_a_real_containerfile_with_synthesis_is_refused() {
        let base = r#"name = "x"
containerfile = "containers/x/Containerfile"
"#;
        for (field, extra) in [
            ("base", "base = \"scratch\"\n"),
            ("entrypoint", "entrypoint = [\"/x\"]\n"),
            ("cmd", "cmd = [\"serve\"]\n"),
            ("workdir", "workdir = \"/data\"\n"),
            ("expose", "expose = [8080]\n"),
            ("env", "[env]\nA = \"b\"\n"),
            ("labels", "[labels]\n\"org.x\" = \"y\"\n"),
            ("layers", "[[layers]]\nsource = \"a\"\ndest = \"/a\"\n"),
        ] {
            let c: ContainerBuild = toml::from_str(&format!("{base}{extra}")).unwrap();
            let err = c
                .validate()
                .expect_err(&format!("`containerfile` + `{field}` must be refused"));
            let msg = err.to_string();
            assert!(msg.contains(field), "the error must name `{field}`: {msg}");
            assert!(
                msg.contains("containers/x/Containerfile"),
                "the error must point at the real file: {msg}"
            );
        }

        // `tags` + `format` are engine FLAGS, not Containerfile text — legal in both
        // modes, which is what makes the rule teachable.
        let ok: ContainerBuild = toml::from_str(
            "name = \"x\"\ncontainerfile = \"containers/x/Containerfile\"\n\
             tags = [\"x:1\"]\nformat = \"docker\"\n",
        )
        .unwrap();
        ok.validate().expect("tags + format stay legal alongside a real Containerfile");
    }

    /// `context` alone is meaningless (synthesis mode's context is fixed by the
    /// layer-staging contract), so it is an error rather than a silent no-op.
    #[test]
    fn a_context_without_a_containerfile_is_refused() {
        let c: ContainerBuild = toml::from_str(
            "name = \"x\"\nbase = \"scratch\"\ncontext = \"containers/x\"\n",
        )
        .unwrap();
        let msg = c.validate().expect_err("context without containerfile").to_string();
        assert!(msg.contains("`context` is set without `containerfile`"), "{msg}");
    }

    /// A Containerfile outside its context can NEVER build: the engine only ever
    /// receives the context, and `COPY` cannot reach above it. Refused, with the
    /// reason, rather than discovered as a "file not found" from the daemon.
    #[test]
    fn a_containerfile_outside_its_context_is_refused() {
        let c: ContainerBuild = toml::from_str(
            "name = \"x\"\ncontainerfile = \"build/Containerfile\"\ncontext = \"containers/x\"\n",
        )
        .unwrap();
        let msg = c.validate().expect_err("outside the context").to_string();
        assert!(msg.contains("OUTSIDE its build context"), "{msg}");

        // Absolute and escaping paths are refused for the same reason: the engine's
        // working directory is the repo root and nothing above it is in scope.
        for bad in ["/etc/Containerfile", "../other-repo/Containerfile"] {
            let c: ContainerBuild =
                toml::from_str(&format!("name = \"x\"\ncontainerfile = \"{bad}\"\n")).unwrap();
            assert!(c.validate().is_err(), "`{bad}` must be refused");
        }
    }

    /// **`--list` must tell the truth.** A cell naming a Containerfile that is not
    /// in the checkout is rejected at VALIDATION, not discovered at build time
    /// after the queue and the dependency builds.
    #[test]
    fn a_missing_containerfile_is_refused_at_validation() {
        let root = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(root.path().join("containers/spark-iceberg")).unwrap();

        let c: ContainerBuild = toml::from_str(
            "name = \"spark\"\ncontainerfile = \"containers/spark-iceberg/Containerfile\"\n",
        )
        .unwrap();
        // Shape-valid — the lexical rules cannot see the filesystem…
        c.validate().expect("shape is fine");
        // …but the cell is not buildable in THIS checkout.
        let msg = c.validate_in(root.path()).expect_err("missing file").to_string();
        assert!(msg.contains("does not exist"), "{msg}");
        assert!(msg.contains("containers/spark-iceberg/Containerfile"), "{msg}");

        // Write it and the same cell becomes buildable, and hands over the real bytes.
        std::fs::write(
            root.path().join("containers/spark-iceberg/Containerfile"),
            "ARG SPARK_TAG=4.1.2\nFROM docker.io/apache/spark:${SPARK_TAG}\nRUN curl -o x\n",
        )
        .unwrap();
        c.validate_in(root.path()).expect("present containerfile validates");
        let text = c.containerfile_text(root.path()).unwrap();
        assert!(text.starts_with("ARG SPARK_TAG="), "the REAL file's bytes: {text}");
        assert!(text.contains("RUN curl"), "RUN survives — it is not synthesized: {text}");

        // A synthesis cell needs no filesystem at all and is unaffected.
        appliance().validate_in(root.path()).expect("synthesis mode touches no disk");
        assert_eq!(
            appliance().containerfile_text(root.path()).unwrap(),
            appliance().containerfile()
        );
    }

    /// A file-mode cell chains no cargo variant (it has no `layers`), and its
    /// content id still moves with the declared path — two descriptors naming
    /// different Containerfiles are different builds.
    #[test]
    fn file_mode_descriptors_round_trip_and_stay_distinct() {
        let a: ContainerBuild =
            toml::from_str("name = \"a\"\ncontainerfile = \"containers/a/Containerfile\"\n")
                .unwrap();
        let b: ContainerBuild =
            toml::from_str("name = \"b\"\ncontainerfile = \"containers/b/Containerfile\"\n")
                .unwrap();
        assert!(a.variant_deps().is_empty());
        assert_ne!(a.content_id(), b.content_id(), "the declared path is part of the identity");

        let back: ContainerBuild =
            serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap();
        assert_eq!(back, a);
        assert_eq!(back.content_id(), a.content_id());

        // And a synthesis cell still serializes WITHOUT the two new keys, so an
        // already-persisted descriptor keeps its JSON shape.
        let json = serde_json::to_string(&appliance()).unwrap();
        assert!(!json.contains("containerfile"), "no empty field leaks into JSON: {json}");
        assert!(!json.contains("\"context\""), "no empty field leaks into JSON: {json}");
    }

    /// Path normalization is lexical and host-independent — the same declaration
    /// must produce the same argv from a clone, a git worktree and CI.
    #[test]
    fn relative_paths_normalize_lexically() {
        assert_eq!(normalize_rel("./containers//x/./Containerfile"), "containers/x/Containerfile");
        assert_eq!(normalize_rel("containers/x/../y/Containerfile"), "containers/y/Containerfile");
        assert_eq!(normalize_rel(""), ".");
        assert_eq!(normalize_rel("."), ".");
        assert_eq!(normalize_rel("../a"), "../a", "an escape is preserved so it can be refused");

        // A messy but legal declaration still lands on clean argv.
        let c: ContainerBuild =
            toml::from_str("name = \"x\"\ncontainerfile = \"./containers/x/../x/Containerfile\"\n")
                .unwrap();
        c.validate().unwrap();
        assert_eq!(c.context_dir(), "containers/x");
        assert_eq!(c.build_args("podman").iter().rev().nth(1).unwrap(), "containers/x/Containerfile");
    }

    #[test]
    fn image_format_parses_and_round_trips_all_spellings() {
        assert_eq!(ImageFormat::from("oci".to_string()), ImageFormat::Oci);
        assert_eq!(ImageFormat::from("".to_string()), ImageFormat::Oci);
        assert_eq!(ImageFormat::from("Docker".to_string()), ImageFormat::Docker);
        assert_eq!(ImageFormat::from("appliance".to_string()), ImageFormat::Appliance);
        assert_eq!(ImageFormat::from("disk".to_string()), ImageFormat::Appliance);
        assert_eq!(String::from(ImageFormat::Docker), "docker");
        assert_eq!(ImageFormat::default(), ImageFormat::Oci);
    }
}

#[cfg(test)]
mod base_recipe_tests {
    use super::*;

    /// A container cell may name where its base is built from; the recipe is
    /// located in this repo or a sibling, and a path that leaves the checkout is
    /// refused by name.
    #[test]
    fn a_base_recipe_is_declared_and_located() {
        let set = crate::variant::BuildVariantSet::from_toml_str(
            "[build]\nbase_suite = \"ORIGINAL\"\n\n[[build.container]]\nname = \"rdp\"\nbase = \"localhost/tunnr-rdp-weston-base:latest\"\ntags = [\"rdp:0.1\"]\n\n[build.container.base_recipe]\ncontainerfile = \"containers/rdp-weston-base/Containerfile\"\n",
        )
        .expect("parses");
        let c = set.containers.iter().find(|c| c.name == "rdp").expect("the cell");
        let r = c.base_recipe.as_ref().expect("the recipe");
        let root = std::path::Path::new("/w/tunnr");
        let (checkout, ctx, file) = r.locate(root).unwrap();
        assert_eq!((checkout.as_path(), ctx.as_path(), file.as_str()), (root, std::path::Path::new("/w/tunnr/containers/rdp-weston-base"), "Containerfile"));
        let sib = BaseRecipe { repo: "korp".into(), containerfile: "containers/falkordb-valkey/Containerfile".into(), context: "containers/falkordb-valkey".into() };
        assert_eq!(sib.locate(root).unwrap().1, std::path::PathBuf::from("/w/korp/containers/falkordb-valkey"));
        let escape = BaseRecipe { containerfile: "../x/Containerfile".into(), ..Default::default() };
        assert!(escape.locate(root).is_err());
        assert!(BaseRecipe { repo: "a/b".into(), containerfile: "C".into(), ..Default::default() }.locate(root).is_err());
    }
}