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