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

nordisk/Skidbladnir

src/pack_toolchain.rs

//! AIRGAP-RUST — `pack_toolchain`, the bundle-assembly orchestrator (pack side).
//!
//! The follow-up to the merged airgap-Rust core (`vendor.rs` + the `PrefixPayload`
//! product kind + `install_prefix_payload` + the znippy `rust-toolchain` classifier,
//! `type_id = 41`). Those pieces UNFOLD a `rust-dev-rhel8-<ver>.znippy`; this module
//! is the missing PACK half — it ASSEMBLES the staging tree and SEALS it into that
//! signed `.znippy` (design `.nornir/airgap-rust-znippy-design-2026-07-30.md` §2, §4).
//!
//! What it does (connected build-host only, §5):
//! 1. Run `cargo vendor` for a target project ([`crate::vendor::run_cargo_vendor`]) →
//!    `vendor/` + a `<PREFIX>`-tokenised `.cargo/config` ([`crate::vendor::cargo_config_toml`]).
//! 2. STAGE the tree the classifier recognises — `toolchain/` (bin + rust-std),
//!    `vendor/`, `cargo-config/config.toml`, `manifest/vendor.lock`, `manifest/bundle.json`,
//!    and a **`manifest/toolchain.pins.json`** (sha256 + blake3 per member — pinned like
//!    the ISO kernel, `edda/crates/korp-installer/src/kernel.rs`).
//! 3. SEAL the staging tree into the bundle via the [`crate::archive::Archive`] seam —
//!    [`crate::archive::ZnippyArchive`](crate::archive::ZnippyArchive) in prod (the real znippy seal), [`crate::archive::TarZstd`]
//!    in the offline unit tests. Signing rides the existing pki step ([`pack_toolchain_signed`](crate::pack_toolchain::pack_toolchain_signed)).
//!
//! Then [`crate::install::install_prefix_payload`] unfolds it — closing the loop.
//!
//! Split by testability (the vendor.rs doctrine): the staging-tree assembly
//! ([`assemble_staging`](crate::pack_toolchain::assemble_staging)) + the pins writer ([`write_pins`](crate::pack_toolchain::write_pins)) are PURE fs — no
//! network, no multi-GB download, no `cargo vendor` — so they are unit-tested with a
//! fake toolchain dir. Only the full [`pack_toolchain`] runs `cargo vendor`; its real
//! rustup-tarball round-trip is gated `#[ignore]` (not in tree).

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

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

/// The canonical payload name + `.cargo/config` filename inside the bundle.
pub const CARGO_CONFIG_REL: &str = "cargo-config/config.toml";
/// The bundle-provenance manifest the classifier (`type_id = 41`) reads `version` +
/// `host` out of.
pub const BUNDLE_MANIFEST_REL: &str = "manifest/bundle.json";
/// The per-member integrity table — sha256 + blake3, "pinned like the kernel".
pub const PINS_REL: &str = "manifest/toolchain.pins.json";
/// A copy of the source `Cargo.lock` — the exact, auditable crate set.
pub const VENDOR_LOCK_REL: &str = "manifest/vendor.lock";

// ───────────────────────────── bundle.json ─────────────────────────────

/// The `manifest/bundle.json` provenance manifest. Its `version` + `host` fields are
/// exactly the keys the znippy `rust-toolchain` classifier parses to populate the
/// `toolchain_version` / `host_triple` Arrow columns (`znippy-plugin-rust-toolchain`),
/// so a sealed bundle is self-describing to `znippy meta`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolchainBundleManifest {
    /// The payload name — `rust-dev-rhel8` for the canonical bundle.
    pub product: String,
    /// The rustc/toolchain version (e.g. `1.97.1`). READ BY THE CLASSIFIER.
    pub version: String,
    /// The host (== target) triple (e.g. `x86_64-unknown-linux-gnu`). READ BY THE CLASSIFIER.
    pub host: String,
    /// The cargo version if known (informational).
    pub cargo_version: Option<String>,
    /// The upstream source URL the toolchain dist came from (provenance; the pin's
    /// partner — `toolchain.pins.json` carries the hashes, this carries the origin).
    pub source: Option<String>,
    /// The bundled toolchain components (e.g. `rustc`, `cargo`, `rust-std-<triple>`).
    pub components: Vec<String>,
}

impl ToolchainBundleManifest {
    /// A minimal manifest for `product`/`version`/`host`; builders add the rest.
    pub fn new(
        product: impl Into<String>,
        version: impl Into<String>,
        host: impl Into<String>,
    ) -> Self {
        ToolchainBundleManifest {
            product: product.into(),
            version: version.into(),
            host: host.into(),
            cargo_version: None,
            source: None,
            components: Vec::new(),
        }
    }
    pub fn with_source(mut self, s: impl Into<String>) -> Self {
        self.source = Some(s.into());
        self
    }
    pub fn with_cargo_version(mut self, v: impl Into<String>) -> Self {
        self.cargo_version = Some(v.into());
        self
    }
    pub fn with_component(mut self, c: impl Into<String>) -> Self {
        self.components.push(c.into());
        self
    }
}

// ───────────────────────────── toolchain.pins.json ─────────────────────────────

/// One pinned bundle member: its relative path + the DUAL digest (sha256 that
/// matches rustup's published `.sha256`, and blake3 which is znippy's native
/// integrity) + the byte length. Mirrors how `korp-installer` pins the `bzImage`
/// (`edda/crates/korp-installer/src/kernel.rs`) — every carried external is pinned,
/// never trusted anonymously.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PinEntry {
    /// Path relative to the bundle root, `/`-separated (e.g. `toolchain/bin/rustc`).
    pub path: String,
    /// Lowercase-hex SHA-256 of the file bytes.
    pub sha256: String,
    /// Lowercase-hex BLAKE3 of the file bytes.
    pub blake3: String,
    /// File length in bytes.
    pub len: u64,
}

/// The `manifest/toolchain.pins.json` table — a per-member sha256 + blake3 pin for
/// every file in the bundle, written like the ISO kernel pin. On the airgapped box a
/// verifier can re-hash any unfolded member and confirm it against this table without
/// the network (the sha256 also cross-checks rustup's own published `.sha256`).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolchainPins {
    /// The payload the pins belong to (`rust-dev-rhel8`).
    pub product: String,
    /// The toolchain version the pins were taken at.
    pub version: String,
    /// One pin per bundle member, SORTED by `path` (deterministic — the same tree
    /// pins identically on any box, the kernel-pin discipline).
    pub entries: Vec<PinEntry>,
}

impl ToolchainPins {
    /// Look a member's pin up by its relative path.
    pub fn get(&self, rel: &str) -> Option<&PinEntry> {
        self.entries.iter().find(|e| e.path == rel)
    }
}

/// The dual digest (sha256 + blake3) + length of one file, read in a SINGLE pass over
/// its bytes (both hashers fed the same buffer). The pins-table primitive.
pub fn pin_file(path: &Path) -> Result<(String, String, u64)> {
    use std::io::Read as _;
    let mut f =
        std::fs::File::open(path).with_context(|| format!("pin: open {}", path.display()))?;
    let mut sha = nornir_hash::Sha256Hasher::new();
    let mut b3 = blake3::Hasher::new();
    let mut buf = vec![0u8; 64 * 1024];
    let mut len: u64 = 0;
    loop {
        let n = f
            .read(&mut buf)
            .with_context(|| format!("pin: read {}", path.display()))?;
        if n == 0 {
            break;
        }
        sha.update(&buf[..n]);
        b3.update(&buf[..n]);
        len += n as u64;
    }
    Ok((sha.finalize_hex(), b3.finalize().to_hex().to_string(), len))
}

/// Walk `root` (a fully-assembled staging tree) and write `manifest/toolchain.pins.json`
/// — a sha256 + blake3 pin for every file EXCEPT the pins file itself (it cannot pin
/// its own bytes). Returns the table it wrote. Pure fs; no network. This is the
/// "pinned like the kernel" writer the design calls for (§2.1).
pub fn write_pins(root: &Path, product: &str, version: &str) -> Result<ToolchainPins> {
    let mut rels = Vec::new();
    walk_rel(root, root, &mut rels)?;
    rels.sort();

    let mut entries = Vec::with_capacity(rels.len());
    for rel in rels {
        // The pins file cannot appear in its own table (it does not exist yet, but
        // guard anyway so a re-run over an already-pinned tree stays deterministic).
        if rel == PINS_REL {
            continue;
        }
        let (sha256, blake3, len) = pin_file(&root.join(&rel))?;
        entries.push(PinEntry {
            path: rel,
            sha256,
            blake3,
            len,
        });
    }

    let pins = ToolchainPins {
        product: product.to_string(),
        version: version.to_string(),
        entries,
    };
    let dest = root.join(PINS_REL);
    if let Some(parent) = dest.parent() {
        std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
    }
    std::fs::write(&dest, serde_json::to_vec_pretty(&pins)?)
        .with_context(|| format!("write pins {}", dest.display()))?;
    Ok(pins)
}

// ───────────────────────────── staging assembly ─────────────────────────────

/// The inputs to [`assemble_staging`] — everything the staging tree needs, already
/// produced on the connected host (the toolchain expanded, the crates vendored). Kept
/// data-only so the assembly is a pure file placement, unit-testable with fakes.
#[derive(Debug, Clone)]
pub struct StagingInputs {
    /// The expanded rustup-standalone toolchain root (holds `bin/`, `lib/`, …). Its
    /// CONTENTS are copied under `toolchain/` in the bundle.
    pub toolchain_dir: PathBuf,
    /// The `cargo vendor` output dir. Its CONTENTS are copied under `vendor/`.
    pub vendor_dir: PathBuf,
    /// The `.cargo/config.toml` body (the `<PREFIX>`-tokenised generator output). Written
    /// verbatim to `cargo-config/config.toml`.
    pub cargo_config_toml: String,
    /// The source `Cargo.lock` to copy to `manifest/vendor.lock` (the auditable crate
    /// set). `None` ⇒ no lock is carried.
    pub vendor_lock: Option<PathBuf>,
    /// The provenance manifest written to `manifest/bundle.json`.
    pub manifest: ToolchainBundleManifest,
}

/// The result of a staging assembly — the root, the sorted relative member paths, the
/// written provenance manifest, and the pins table. Plain data (serde) so a test
/// asserts EXACTLY the staged layout without a real toolchain (the `InstallOutcome`
/// doctrine).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssembledStaging {
    /// The staging tree root (what [`crate::archive::Archive::pack`] seals).
    pub root: PathBuf,
    /// Every staged member, relative + sorted (== the sealed bundle's entry set).
    pub files: Vec<String>,
    /// The provenance manifest written to `manifest/bundle.json`.
    pub manifest: ToolchainBundleManifest,
    /// The `manifest/toolchain.pins.json` table.
    pub pins: ToolchainPins,
}

/// Assemble the bundle staging tree at `root` from `inputs` — PURE fs (no network, no
/// `cargo vendor`, no seal). Lays the exact layout the `rust-toolchain` classifier
/// recognises:
///
/// ```text
/// <root>/
/// ├── toolchain/        # inputs.toolchain_dir CONTENTS (bin/, lib/, lib/rustlib/<triple>/…)
/// ├── vendor/           # inputs.vendor_dir CONTENTS (the cargo-vendor crate set)
/// ├── cargo-config/config.toml   # inputs.cargo_config_toml
/// └── manifest/
///     ├── bundle.json            # inputs.manifest (version + host the classifier reads)
///     ├── vendor.lock            # inputs.vendor_lock (if any) — a copy of Cargo.lock
///     └── toolchain.pins.json    # sha256 + blake3 per member (written LAST)
/// ```
///
/// The pins table is written LAST so it covers every other member (and never itself).
pub fn assemble_staging(inputs: &StagingInputs, root: &Path) -> Result<AssembledStaging> {
    if !inputs.toolchain_dir.is_dir() {
        anyhow::bail!(
            "assemble_staging: toolchain dir {} is not a directory",
            inputs.toolchain_dir.display()
        );
    }
    if !inputs.vendor_dir.is_dir() {
        anyhow::bail!(
            "assemble_staging: vendor dir {} is not a directory (run `cargo vendor` first)",
            inputs.vendor_dir.display()
        );
    }
    std::fs::create_dir_all(root)
        .with_context(|| format!("create staging root {}", root.display()))?;

    // toolchain/ ← the expanded rustup-standalone toolchain (bin + lib + rust-std).
    copy_tree_contents(&inputs.toolchain_dir, &root.join("toolchain"))
        .with_context(|| "stage toolchain/")?;
    // vendor/ ← the cargo-vendor crate set.
    copy_tree_contents(&inputs.vendor_dir, &root.join("vendor"))
        .with_context(|| "stage vendor/")?;

    // cargo-config/config.toml ← the `<PREFIX>`-tokenised generator output.
    let cfg_dest = root.join(CARGO_CONFIG_REL);
    std::fs::create_dir_all(cfg_dest.parent().unwrap())
        .with_context(|| format!("create {}", cfg_dest.parent().unwrap().display()))?;
    std::fs::write(&cfg_dest, &inputs.cargo_config_toml)
        .with_context(|| format!("write {}", cfg_dest.display()))?;

    // manifest/vendor.lock ← a copy of the source Cargo.lock (auditable crate set).
    if let Some(lock) = &inputs.vendor_lock {
        let lock_dest = root.join(VENDOR_LOCK_REL);
        std::fs::create_dir_all(lock_dest.parent().unwrap())
            .with_context(|| format!("create {}", lock_dest.parent().unwrap().display()))?;
        std::fs::copy(lock, &lock_dest).with_context(|| {
            format!(
                "copy Cargo.lock {} → {}",
                lock.display(),
                lock_dest.display()
            )
        })?;
    }

    // manifest/bundle.json ← the provenance the classifier reads (version + host).
    let man_dest = root.join(BUNDLE_MANIFEST_REL);
    std::fs::create_dir_all(man_dest.parent().unwrap())
        .with_context(|| format!("create {}", man_dest.parent().unwrap().display()))?;
    std::fs::write(&man_dest, serde_json::to_vec_pretty(&inputs.manifest)?)
        .with_context(|| format!("write {}", man_dest.display()))?;

    // manifest/toolchain.pins.json ← sha256 + blake3 per member (LAST, covers all).
    let pins = write_pins(root, &inputs.manifest.product, &inputs.manifest.version)?;

    // Enumerate the sealed entry set.
    let mut files = Vec::new();
    walk_rel(root, root, &mut files)?;
    files.sort();

    Ok(AssembledStaging {
        root: root.to_path_buf(),
        files,
        manifest: inputs.manifest.clone(),
        pins,
    })
}

// ───────────────────────────── the orchestrator ─────────────────────────────

/// The result of [`pack_toolchain`] — the sealed bundle, its content-addressed
/// [`crate::archive::BundleManifest`] (the verify-on-receipt anchor the ed25519
/// signature attests), and the assembled staging (layout + pins + provenance).
#[derive(Debug, Clone)]
pub struct PackToolchainOutcome {
    /// The sealed `.znippy` (or `.tar.zst` under the offline engine) bundle path.
    pub bundle: PathBuf,
    /// The archive engine's content-addressed sidecar manifest (sha256 root).
    pub archive_manifest: crate::archive::BundleManifest,
    /// The staging tree that was sealed (layout, pins, bundle.json).
    pub staging: AssembledStaging,
}

/// The connected-host inputs for a full pack: an already-expanded toolchain dir + a
/// target project to vendor. Everything else (the config, the pins, the manifest) is
/// derived.
#[derive(Debug, Clone)]
pub struct PackToolchainRequest {
    /// The expanded rustup-standalone toolchain root (holds `bin/`, `lib/`, …).
    pub toolchain_dir: PathBuf,
    /// The target project whose deps get `cargo vendor`ed (holds `Cargo.toml` +
    /// resolved `Cargo.lock`).
    pub project_dir: PathBuf,
    /// The bundle-provenance manifest (product/version/host + source URL). `version`
    /// + `host` are what the classifier reads.
    pub manifest: ToolchainBundleManifest,
}

/// AIRGAP-RUST — assemble + seal the whole bundle (design §2/§4). Connected-host only
/// (runs `cargo vendor`). The flow:
///
/// 1. `cargo vendor` the project ([`crate::vendor::run_cargo_vendor`]) into a scratch
///    `vendor/` — the "basic crates" source set.
/// 2. Generate the portable `<PREFIX>`-tokenised `.cargo/config`
///    ([`crate::vendor::cargo_config_toml`] + [`crate::vendor::packed_vendor_directory`]).
/// 3. [`assemble_staging`] — copy the toolchain + vendor set, drop the config + the
///    `Cargo.lock` copy + `bundle.json`, and write `toolchain.pins.json`.
/// 4. SEAL the staging tree into `out` via the injected [`crate::archive::Archive`]
///    engine (the real znippy seal in prod; `TarZstd` offline). Its sidecar
///    [`crate::archive::BundleManifest`] is the verify-on-receipt anchor.
///
/// `scratch` is a working dir for the vendored crates + the staging tree; pass a
/// tempdir. `out` is the bundle path (e.g. `rust-dev-rhel8-1.97.1.znippy`). Sign the
/// result with [`pack_toolchain_signed`] (feature `pki`).
pub fn pack_toolchain(
    req: &PackToolchainRequest,
    scratch: &Path,
    out: &Path,
    archive: &dyn crate::archive::Archive,
) -> Result<PackToolchainOutcome> {
    let r = pack_toolchain_impl(req, scratch, out, archive);
    match &r {
        Ok(o) => crate::surface_status!(
            "skidbladnir/pack_toolchain",
            "pack_toolchain",
            true,
            "product={} version={} host={} members={} bundle={}",
            o.staging.manifest.product,
            o.staging.manifest.version,
            o.staging.manifest.host,
            o.staging.files.len(),
            o.bundle.display()
        ),
        Err(e) => crate::surface_status!(
            "skidbladnir/pack_toolchain",
            "pack_toolchain",
            false,
            "pack toolchain `{}` failed: {:#}",
            req.manifest.product,
            e
        ),
    }
    r
}

fn pack_toolchain_impl(
    req: &PackToolchainRequest,
    scratch: &Path,
    out: &Path,
    archive: &dyn crate::archive::Archive,
) -> Result<PackToolchainOutcome> {
    std::fs::create_dir_all(scratch)
        .with_context(|| format!("create scratch {}", scratch.display()))?;

    // 1. Vendor the project's crates (CONNECTED host — the ONE network step).
    let vendor_dir = scratch.join("vendor");
    crate::vendor::run_cargo_vendor(&req.project_dir, &vendor_dir)
        .with_context(|| format!("cargo vendor {}", req.project_dir.display()))?;

    // 2. The portable `<PREFIX>`-tokenised config (install-time rewrite → real prefix).
    let cargo_config_toml =
        crate::vendor::cargo_config_toml(&crate::vendor::packed_vendor_directory());

    // 3. Assemble the staging tree (toolchain + vendor + config + lock + manifests + pins).
    let staging_root = scratch.join("staging");
    let lock = req.project_dir.join("Cargo.lock");
    let inputs = StagingInputs {
        toolchain_dir: req.toolchain_dir.clone(),
        vendor_dir,
        cargo_config_toml,
        vendor_lock: lock.is_file().then_some(lock),
        manifest: req.manifest.clone(),
    };
    let staging = assemble_staging(&inputs, &staging_root)?;

    // 4. Seal the staging tree into the bundle via the archive engine.
    let archive_manifest = archive
        .pack(&staging_root, &req.manifest.version, out)
        .with_context(|| {
            format!(
                "seal staging {} → {}",
                staging_root.display(),
                out.display()
            )
        })?;

    Ok(PackToolchainOutcome {
        bundle: out.to_path_buf(),
        archive_manifest,
        staging,
    })
}

/// [`pack_toolchain`] + the pack-time authenticity step: ed25519-sign the sealed
/// bundle's `root_sha256` with `ca` and write the detached signature sidecar
/// ([`crate::pki::sign_bundle`]). This is the "signed `.znippy`" the design names
/// (§2.4) — the target pins the packer's CA out-of-band via a
/// [`crate::archive::TrustRoot`]. Feature `pki`.
#[cfg(feature = "pki")]
pub fn pack_toolchain_signed(
    req: &PackToolchainRequest,
    scratch: &Path,
    out: &Path,
    archive: &dyn crate::archive::Archive,
    ca: &crate::pki::MachineCa,
) -> Result<(PackToolchainOutcome, crate::archive::BundleSignature)> {
    let outcome = pack_toolchain(req, scratch, out, archive)?;
    let sig = crate::pki::sign_bundle(out, ca)
        .with_context(|| format!("sign toolchain bundle {}", out.display()))?;
    Ok((outcome, sig))
}

// ───────────────────────────── fs helpers ─────────────────────────────

/// Recursively copy the CONTENTS of `src` into `dst` (the `cp <src>/. <dst>` shape),
/// preserving the relative tree + Unix mode bits (so `toolchain/bin/rustc` stays
/// executable). Symlinks are recreated as symlinks (rustup ships some).
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 e = e?;
        let p = e.path();
        let name = match p.file_name() {
            Some(n) => n.to_owned(),
            None => continue,
        };
        let target = dst.join(&name);
        let ft = e.file_type()?;
        if ft.is_symlink() {
            copy_symlink(&p, &target)?;
        } else if ft.is_dir() {
            copy_tree_contents(&p, &target)?;
        } else if ft.is_file() {
            std::fs::copy(&p, &target)
                .with_context(|| format!("copy {} → {}", p.display(), target.display()))?;
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt as _;
                if let Ok(md) = std::fs::metadata(&p) {
                    let _ = std::fs::set_permissions(
                        &target,
                        std::fs::Permissions::from_mode(md.permissions().mode()),
                    );
                }
            }
        }
    }
    Ok(())
}

/// Recreate the symlink `src` at `dst` (same target path). Non-unix: dereference-copy.
fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
    #[cfg(unix)]
    {
        let target =
            std::fs::read_link(src).with_context(|| format!("read_link {}", src.display()))?;
        if dst.exists() {
            let _ = std::fs::remove_file(dst);
        }
        std::os::unix::fs::symlink(&target, dst)
            .with_context(|| format!("symlink {} → {}", dst.display(), target.display()))?;
        Ok(())
    }
    #[cfg(not(unix))]
    {
        std::fs::copy(src, dst)
            .with_context(|| format!("copy link {} → {}", src.display(), dst.display()))?;
        Ok(())
    }
}

/// Collect every regular file under `dir` as a `/`-separated path relative to `root`.
fn walk_rel(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();
        // Follow the classifier's view of the tree: regular files are members; a
        // symlink to a file is itself a member entry (its bytes are the link target
        // once the archive engine resolves it — TarZstd/znippy store the file).
        if p.is_dir() {
            walk_rel(root, &p, out)?;
        } else {
            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 _;

    /// Build a FAKE expanded rustup-standalone toolchain dir — a handful of tiny
    /// files in the real layout (`bin/`, `lib/`, `lib/rustlib/<triple>/…`) — so the
    /// staging assembly is exercised with ZERO multi-GB download (the design's
    /// no-download unit-test contract).
    fn fake_toolchain(root: &Path, triple: &str) {
        let bin = root.join("bin");
        std::fs::create_dir_all(&bin).unwrap();
        for exe in ["rustc", "cargo", "rustdoc"] {
            let mut f = std::fs::File::create(bin.join(exe)).unwrap();
            f.write_all(format!("#!/bin/sh\necho fake-{exe}\n").as_bytes())
                .unwrap();
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt as _;
                std::fs::set_permissions(bin.join(exe), std::fs::Permissions::from_mode(0o755))
                    .unwrap();
            }
        }
        std::fs::create_dir_all(root.join("lib")).unwrap();
        std::fs::write(root.join("lib/librustc_driver.so"), b"\x7fELF-fake-driver").unwrap();
        let std_dir = root.join(format!("lib/rustlib/{triple}/lib"));
        std::fs::create_dir_all(&std_dir).unwrap();
        std::fs::write(std_dir.join("libstd.rlib"), b"!<arch>\nfake-std").unwrap();
    }

    /// A FAKE `cargo vendor` output dir — one crate's expanded source.
    fn fake_vendor(root: &Path) {
        let c = root.join("anyhow-1.0.86/src");
        std::fs::create_dir_all(&c).unwrap();
        std::fs::write(
            root.join("anyhow-1.0.86/Cargo.toml"),
            b"[package]\nname=\"anyhow\"\n",
        )
        .unwrap();
        std::fs::write(c.join("lib.rs"), b"pub fn x() {}").unwrap();
    }

    fn inputs(scratch: &Path, triple: &str) -> StagingInputs {
        let tc = scratch.join("tc");
        let vend = scratch.join("vend");
        fake_toolchain(&tc, triple);
        fake_vendor(&vend);
        let lock = scratch.join("Cargo.lock");
        std::fs::write(
            &lock,
            b"# fake lock\n[[package]]\nname=\"anyhow\"\nversion=\"1.0.86\"\n",
        )
        .unwrap();
        StagingInputs {
            toolchain_dir: tc,
            vendor_dir: vend,
            cargo_config_toml: crate::vendor::cargo_config_toml(
                &crate::vendor::packed_vendor_directory(),
            ),
            vendor_lock: Some(lock),
            manifest: ToolchainBundleManifest::new("rust-dev-rhel8", "1.97.1", triple)
                .with_source("https://static.rust-lang.org")
                .with_cargo_version("1.97.1")
                .with_component("rustc")
                .with_component("cargo")
                .with_component(format!("rust-std-{triple}")),
        }
    }

    /// LAW 1 (inject-assert): assemble a real staging tree from a FAKE toolchain +
    /// vendor dir → assert the EXACT bundle layout the `rust-toolchain` classifier
    /// (type_id 41) recognises: `toolchain/bin`, the rust-std under its triple, the
    /// vendored crate, `cargo-config/config.toml`, and all three `manifest/*`.
    #[test]
    fn assemble_lays_the_classifier_recognised_layout() {
        let scratch = tempfile::tempdir().unwrap();
        let triple = "x86_64-unknown-linux-gnu";
        let ins = inputs(scratch.path(), triple);
        let staging_root = scratch.path().join("staging");
        let a = assemble_staging(&ins, &staging_root).unwrap();

        // Every canonical member is present.
        for want in [
            "toolchain/bin/rustc",
            "toolchain/bin/cargo",
            "toolchain/lib/librustc_driver.so",
            &format!("toolchain/lib/rustlib/{triple}/lib/libstd.rlib"),
            "vendor/anyhow-1.0.86/Cargo.toml",
            "vendor/anyhow-1.0.86/src/lib.rs",
            CARGO_CONFIG_REL,
            BUNDLE_MANIFEST_REL,
            VENDOR_LOCK_REL,
            PINS_REL,
        ] {
            assert!(
                a.files.iter().any(|f| f == want),
                "missing member `{want}` in {:?}",
                a.files
            );
        }

        // The dropped config is the portable `<PREFIX>`-tokenised generator output.
        let cfg = std::fs::read_to_string(staging_root.join(CARGO_CONFIG_REL)).unwrap();
        assert!(
            cfg.contains("directory = \"<PREFIX>/vendor\""),
            "config carries the PREFIX token"
        );
        assert!(cfg.contains("offline = true"));

        // bundle.json carries the classifier's `version` + `host` keys.
        let bj: serde_json::Value =
            serde_json::from_slice(&std::fs::read(staging_root.join(BUNDLE_MANIFEST_REL)).unwrap())
                .unwrap();
        assert_eq!(bj["version"], "1.97.1");
        assert_eq!(bj["host"], triple);
        assert_eq!(bj["product"], "rust-dev-rhel8");
    }

    /// LAW 1: the pins writer records a sha256 + blake3 + len for EVERY member except
    /// itself, sorted + deterministic, and the digests match a fresh independent hash
    /// of the staged bytes (the "pinned like the kernel" integrity table).
    #[test]
    fn pins_cover_every_member_with_dual_digest_and_are_verifiable() {
        let scratch = tempfile::tempdir().unwrap();
        let triple = "x86_64-unknown-linux-gnu";
        let ins = inputs(scratch.path(), triple);
        let staging_root = scratch.path().join("staging");
        let a = assemble_staging(&ins, &staging_root).unwrap();

        // The pins file never pins itself.
        assert!(
            a.pins.get(PINS_REL).is_none(),
            "pins file must not pin itself"
        );
        // Every OTHER staged member has a pin.
        for f in &a.files {
            if f == PINS_REL {
                continue;
            }
            assert!(a.pins.get(f).is_some(), "no pin for member `{f}`");
        }
        // Deterministic (sorted by path).
        let mut sorted = a.pins.entries.clone();
        sorted.sort_by(|x, y| x.path.cmp(&y.path));
        assert_eq!(a.pins.entries, sorted, "pins must be sorted by path");

        // A pin is a REAL dual digest — re-hash rustc independently and compare.
        let rustc = staging_root.join("toolchain/bin/rustc");
        let bytes = std::fs::read(&rustc).unwrap();
        let want_sha = nornir_hash::sha256_hex(&bytes);
        let want_b3 = blake3::hash(&bytes).to_hex().to_string();
        let pin = a.pins.get("toolchain/bin/rustc").unwrap();
        assert_eq!(pin.sha256, want_sha, "sha256 pin matches the file");
        assert_eq!(pin.blake3, want_b3, "blake3 pin matches the file");
        assert_eq!(pin.len, bytes.len() as u64, "len pin matches the file");
        assert_eq!(pin.sha256.len(), 64, "sha256 is 64 hex chars");
        assert_eq!(pin.blake3.len(), 64, "blake3 is 64 hex chars");

        // The table is round-trippable through the on-disk JSON (what the airgapped
        // box re-reads to verify).
        let on_disk: ToolchainPins =
            serde_json::from_slice(&std::fs::read(staging_root.join(PINS_REL)).unwrap()).unwrap();
        assert_eq!(on_disk, a.pins);
        assert_eq!(on_disk.product, "rust-dev-rhel8");
        assert_eq!(on_disk.version, "1.97.1");
    }

    /// The pins file is written LAST and covers `bundle.json` — proving order (a pin
    /// of a manifest that was itself written after the pins would be stale/missing).
    #[test]
    fn pins_cover_the_bundle_manifest() {
        let scratch = tempfile::tempdir().unwrap();
        let ins = inputs(scratch.path(), "x86_64-unknown-linux-gnu");
        let a = assemble_staging(&ins, &scratch.path().join("staging")).unwrap();
        let pin = a
            .pins
            .get(BUNDLE_MANIFEST_REL)
            .expect("bundle.json is pinned");
        let bytes = std::fs::read(a.root.join(BUNDLE_MANIFEST_REL)).unwrap();
        assert_eq!(pin.sha256, nornir_hash::sha256_hex(&bytes));
    }

    /// Assembly refuses a non-existent toolchain / vendor dir HONESTLY (never fakes a
    /// bundle out of nothing) — the pack-side twin of the install verify-first law.
    #[test]
    fn assemble_refuses_missing_inputs() {
        let scratch = tempfile::tempdir().unwrap();
        let mut ins = inputs(scratch.path(), "x86_64-unknown-linux-gnu");
        ins.toolchain_dir = scratch.path().join("does-not-exist");
        let err = assemble_staging(&ins, &scratch.path().join("s1"))
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("toolchain dir"),
            "honest missing-toolchain error: {err}"
        );
    }

    /// LAW 1 (round-trip): assemble → SEAL via the offline `TarZstd` engine → the
    /// bundle's content-addressed manifest covers every staged member and its
    /// verify-on-receipt is clean. This proves the seal step WITHOUT the znippy
    /// feature or a multi-GB toolchain (the real rustup+znippy round-trip is the
    /// separate `#[ignore]` test). It also closes the loop into
    /// `install_prefix_payload`, which unfolds exactly this shape.
    #[test]
    fn assemble_then_seal_roundtrips_through_tarzstd() {
        use crate::archive::{Archive as _, TarZstd};
        let scratch = tempfile::tempdir().unwrap();
        let ins = inputs(scratch.path(), "x86_64-unknown-linux-gnu");
        let staging_root = scratch.path().join("staging");
        let a = assemble_staging(&ins, &staging_root).unwrap();

        let engine = TarZstd::new();
        let bundle = scratch.path().join("rust-dev-rhel8-1.97.1.tar.zst");
        let manifest = engine.pack(&staging_root, "1.97.1", &bundle).unwrap();

        // Every staged member is a sealed entry.
        let sealed: std::collections::BTreeSet<&str> =
            manifest.entries.iter().map(|e| e.path.as_str()).collect();
        for f in &a.files {
            assert!(sealed.contains(f.as_str()), "sealed bundle missing `{f}`");
        }
        // Verify-on-receipt is clean (no tampered/missing/extra entries).
        let bad = engine.verify_entries(&bundle, &manifest).unwrap();
        assert!(bad.is_empty(), "verify found bad entries: {bad:?}");

        // Unfold it and confirm the classifier-shaped tree lands (loop closed).
        let dest = scratch.path().join("unfold");
        engine.extract_all(&bundle, &dest).unwrap();
        assert!(dest.join("toolchain/bin/rustc").is_file());
        assert!(dest.join(CARGO_CONFIG_REL).is_file());
        assert!(dest.join(PINS_REL).is_file());
    }

    /// The REAL pack round-trip — an expanded rustup-standalone tarball + `cargo
    /// vendor` against a live registry + a znippy seal — needs a connected host and
    /// GBs not in-tree. Gated `#[ignore]` so CI never silently "passes" a pack that
    /// never ran (the vendor.rs `#[ignore]` discipline). Run on a connected host:
    /// `cargo test --features znippy -- --ignored pack_toolchain_real`.
    #[test]
    #[ignore = "needs a connected host + an expanded rustup toolchain + a live registry (pack-side only)"]
    fn pack_toolchain_real() {
        // Point these at a real expanded `rust-<ver>-<triple>` dir and a seed project.
        let toolchain_dir = PathBuf::from(
            std::env::var("SKID_TOOLCHAIN_DIR")
                .expect("set SKID_TOOLCHAIN_DIR to an expanded rustup dist"),
        );
        let project_dir = PathBuf::from(
            std::env::var("SKID_SEED_PROJECT")
                .expect("set SKID_SEED_PROJECT to a Cargo project with a Cargo.lock"),
        );
        let scratch = tempfile::tempdir().unwrap();
        let out = scratch.path().join("rust-dev-rhel8.znippy");
        let req = PackToolchainRequest {
            toolchain_dir,
            project_dir,
            manifest: ToolchainBundleManifest::new(
                "rust-dev-rhel8",
                "1.97.1",
                "x86_64-unknown-linux-gnu",
            ),
        };
        #[cfg(feature = "znippy")]
        {
            let engine = crate::archive::ZnippyArchive::new();
            let o = pack_toolchain(&req, scratch.path(), &out, &engine).unwrap();
            assert!(o.bundle.is_file());
            assert!(!o.staging.pins.entries.is_empty());
        }
        #[cfg(not(feature = "znippy"))]
        let _ = (req, out);
    }
}