src/cut.rs
//! **The cut — pool → carriable bundle.**
//!
//! The pool ([`crate::pool`]) is plumbing; a cut is the deliverable: a named, dated
//! archive an operator can carry to the gap. It selects artefacts out of the pool by
//! their bindings, stages them under their human names next to the caller's metadata
//! files (a works-together statement travels WITH the bytes), and seals the staging
//! through [`Archive::pack`] (TarZstd) — sidecar `manifest.json` with per-entry
//! SHA-256 and a `root_sha256`. No second bundle format.
//!
//! **Partial packs are cut, loudly.** Refusing to pack until every artefact exists
//! would mean no archive at all while an arm is unbuilt; packing silently short would
//! be the bundle that fails hours later inside the gap. So: a pack is cut when at
//! least one wanted artefact is present, and everything missing is named in
//! [`Cut::missing`] — the caller must surface that, never green.
//!
//! What rides (the `wanted` names, the metadata files, the label) is the consumer's
//! knowledge; this module owns only the mechanics.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use crate::archive::{Archive, TarZstd};
use crate::pool::Pool;
/// What one cut should contain — all of it the caller's knowledge.
#[derive(Clone, Debug)]
pub struct CutSpec<'a> {
/// Pool binding names to stage under `payload/<name>`.
pub wanted: &'a [String],
/// The pack's base name AND the bundle version stamped into the manifest,
/// e.g. `infra-pack-2026.08.10`.
pub label: &'a str,
/// Appended to the filename after the label, e.g. an arch: `amd64`.
pub suffix: &'a str,
/// Files written at the staging root beside `payload/` — e.g.
/// `("pins.json", …)`: the statement of what works together.
pub metadata: &'a [(&'a str, Vec<u8>)],
}
/// What one cut produced — and what it could NOT include.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cut {
/// The archive, e.g. `<out_dir>/<label>-<suffix>.tar.zst`.
/// Its sidecar `<bundle>.manifest.json` sits next to it.
pub bundle: PathBuf,
/// The manifest's `root_sha256` — the value an out-of-band channel quotes.
pub root_sha256: String,
/// Entry count (the staged artefacts + the metadata files).
pub entries: usize,
/// Wanted names that were NOT in the pool and therefore did not travel.
/// Non-empty = a partial pack; the caller must say so, loudly.
pub missing: Vec<String>,
}
/// Cut a pack: `spec.wanted` names are resolved against the pool's bindings and
/// staged under `payload/<name>`, `spec.metadata` files are written beside them,
/// and the staging is sealed as `<out_dir>/<label>-<suffix>.tar.zst`.
///
/// Refuses when NOTHING wanted is present — an archive of only metadata is a
/// works-together statement about bytes that are not there.
pub fn cut_pack(pool: &Pool, spec: &CutSpec<'_>, out_dir: &Path) -> Result<Cut> {
let mut missing = Vec::new();
let mut present: Vec<(String, PathBuf)> = Vec::new();
for name in spec.wanted {
match pool.resolve(name)? {
Some(b) => present.push((name.clone(), pool.blob_path(&b.digest))),
None => missing.push(name.clone()),
}
}
if present.is_empty() {
bail!(
"nothing to pack: none of the {} wanted artefact(s) is in the pool yet \
({} missing) — a pack of only metadata states versions of absent bytes",
spec.wanted.len(),
missing.len()
);
}
fs::create_dir_all(out_dir)
.with_context(|| format!("create pack dir {}", out_dir.display()))?;
// Stage under human names. Hardlink from the pool (same filesystem by
// construction — the pool's own staging lives beside it); copy as the
// fallback so an out_dir on another mount still packs.
let staging = out_dir.join(format!(".staging-{}", spec.label));
if staging.exists() {
fs::remove_dir_all(&staging).context("clear stale pack staging")?;
}
let payload = staging.join("payload");
fs::create_dir_all(&payload).context("create pack staging")?;
for (name, blob) in &present {
let dst = payload.join(name);
if fs::hard_link(blob, &dst).is_err() {
fs::copy(blob, &dst)
.with_context(|| format!("stage {} from {}", name, blob.display()))?;
}
}
for (name, bytes) in spec.metadata {
crate::pool::safe_component(name, "metadata filename")?;
fs::write(staging.join(name), bytes).with_context(|| format!("write metadata {name}"))?;
}
let bundle = out_dir.join(format!("{}-{}.tar.zst", spec.label, spec.suffix));
let manifest = TarZstd::new()
.pack(&staging, spec.label, &bundle)
.context("seal the pack")?;
fs::remove_dir_all(&staging).ok();
Ok(Cut {
bundle,
root_sha256: manifest.root_sha256,
entries: manifest.entries.len(),
missing,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fetch::verify_on_receipt;
fn pool_with(names: &[(&str, &[u8])]) -> (tempfile::TempDir, Pool) {
let d = tempfile::tempdir().unwrap();
let pool = Pool::open(d.path().join("pool")).unwrap();
for (name, bytes) in names {
let i = pool.ingest_bytes(bytes).unwrap();
pool.bind(name, &i.digest, "test://fixture").unwrap();
}
(d, pool)
}
/// The cut is a REAL bundle: it verifies on receipt through the same gate the
/// airgapped side runs, and the caller's metadata travels inside.
#[test]
fn a_cut_pack_verifies_through_the_receipt_gate() {
let (d, pool) = pool_with(&[("talos.iso", b"iso-bytes"), ("talosctl", b"ctl-bytes")]);
let pins = br#"{"name":"infra-pack-2026.08.10"}"#.to_vec();
let cut = cut_pack(
&pool,
&CutSpec {
wanted: &["talos.iso".into(), "talosctl".into()],
label: "infra-pack-2026.08.10",
suffix: "amd64",
metadata: &[("pins.json", pins.clone())],
},
&d.path().join("packs"),
)
.unwrap();
assert!(cut.missing.is_empty());
assert_eq!(cut.entries, 3, "two artefacts + pins.json");
assert!(
cut.bundle
.file_name()
.unwrap()
.to_str()
.unwrap()
.starts_with("infra-pack-2026.08.10")
);
let report = verify_on_receipt(&cut.bundle, &TarZstd::new(), None, None).unwrap();
assert!(report.is_ok(), "{report:?}");
// The works-together statement travels with the bytes.
let back = TarZstd::new()
.read_entry(&cut.bundle, "pins.json")
.expect("pins.json rides in the pack");
assert_eq!(back, pins);
}
/// A partial pool cuts a partial pack that NAMES what is absent.
#[test]
fn a_partial_pack_names_every_missing_artefact() {
let (d, pool) = pool_with(&[("talos.iso", b"iso-bytes")]);
let cut = cut_pack(
&pool,
&CutSpec {
wanted: &["talos.iso".into(), "talosctl".into(), "tofu.zip".into()],
label: "p",
suffix: "amd64",
metadata: &[("pins.json", b"{}".to_vec())],
},
&d.path().join("packs"),
)
.unwrap();
assert_eq!(
cut.missing,
vec!["talosctl".to_string(), "tofu.zip".to_string()]
);
assert_eq!(cut.entries, 2, "the present artefact + pins.json");
}
/// ★ RED — an empty pool refuses: a pack of only metadata is a statement
/// about bytes that are not there. And a metadata name cannot be a path.
#[test]
fn an_empty_pool_refuses_to_pack() {
let (d, pool) = pool_with(&[]);
let e = cut_pack(
&pool,
&CutSpec {
wanted: &["talos.iso".into()],
label: "p",
suffix: "amd64",
metadata: &[],
},
&d.path().join("packs"),
)
.unwrap_err();
assert!(e.to_string().contains("nothing to pack"), "{e}");
let (d, pool) = pool_with(&[("x", b"y")]);
let e = cut_pack(
&pool,
&CutSpec {
wanted: &["x".into()],
label: "p",
suffix: "amd64",
metadata: &[("../escape.json", b"{}".to_vec())],
},
&d.path().join("packs"),
)
.unwrap_err();
assert!(e.to_string().contains("single path component"), "{e}");
}
}