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

nordisk/Skidbladnir

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