src/vendor.rs
//! AIRGAP-RUST (pack side) — `cargo vendor` runner + `.cargo/config` generator.
//!
//! The greenfield piece of the airgapped-Rust bundle: there is **no**
//! source-replacement (`cargo vendor` / `[source]`) anywhere in the constellation
//! yet (design `.nornir/airgap-rust-znippy-design-2026-07-30.md` §1.1), so this
//! module owns no legacy. It is the **connected build-host** half (§5): it runs on
//! a box with network at *pack* time — never on the airgapped target — to turn a
//! seed project into (a) a `vendor/` directory holding every crate's source and
//! (b) a `config.toml` whose `[source]` stanza replaces crates.io with that vendor
//! dir and turns `net.offline` on. Dropped onto the target, `cargo build --offline`
//! then resolves every dependency from `vendor/` with zero egress.
//!
//! Split by testability (the same doctrine as the install matrix): the config
//! **generator** ([`cargo_config_toml`](crate::vendor::cargo_config_toml) / [`rewrite_prefix`](crate::vendor::rewrite_prefix)) is pure and unit-tested
//! offline; the [`run_cargo_vendor`](crate::vendor::run_cargo_vendor) runner shells the ONE accepted external
//! (`cargo` — the zero-shell law admits `qemu` + `cargo`, `product.rs`), so its real
//! round-trip is gated behind an `#[ignore]` test that needs a live registry.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// The placeholder the packed `config.toml` carries where the real install prefix
/// goes. `skidbladnir install` rewrites it to the activated generation's absolute
/// path ([`rewrite_prefix`]) so the vendor dir resolves on the target box.
pub const PREFIX_TOKEN: &str = "<PREFIX>";
/// Generate the airgap `.cargo/config.toml` body (design §2.3): crates.io is
/// source-**replaced** by the `vendored-sources` directory, and `net.offline` is
/// hard-on so a build against a crate NOT in the vendor set fails loudly instead of
/// reaching the network. `vendor_directory` is written verbatim into
/// `directory = "…"` — pass [`PREFIX_TOKEN`]-relative (`<PREFIX>/vendor`) at pack
/// time, or a real absolute path when generating for a fixed prefix.
///
/// Deliberately does NOT copy znippy's `target-cpu=native` (host-specific; design
/// §2.3) — the config stays portable across the boxes that receive the bundle.
pub fn cargo_config_toml(vendor_directory: &str) -> String {
format!(
"# Auto-generated by the skidbladnir airgap-rust packer.\n\
# Source-replacement: every crates.io dependency resolves from the vendored\n\
# crate set below, and `net.offline` refuses any network reach — a build that\n\
# needs a crate not in `vendor/` fails loudly rather than egressing.\n\
[source.crates-io]\n\
replace-with = \"vendored-sources\"\n\
\n\
[source.vendored-sources]\n\
directory = \"{vendor_directory}\"\n\
\n\
[net]\n\
offline = true\n"
)
}
/// The vendor directory value a packed config carries — `<PREFIX>/vendor` — so the
/// install-time [`rewrite_prefix`] has a single token to replace.
pub fn packed_vendor_directory() -> String {
format!("{PREFIX_TOKEN}/vendor")
}
/// Rewrite a packed config's [`PREFIX_TOKEN`] to the real activated prefix at
/// install time. `prefix` is the absolute path of the activated generation
/// (`<install-prefix>/current`); every `<PREFIX>` becomes that path, so
/// `directory = "<PREFIX>/vendor"` → `directory = "/…/current/vendor"`.
pub fn rewrite_prefix(config_template: &str, prefix: &Path) -> String {
config_template.replace(PREFIX_TOKEN, &prefix.to_string_lossy())
}
/// The result of a connected-host `cargo vendor` run.
#[derive(Debug, Clone)]
pub struct VendorOutcome {
/// The directory the crate sources were written to.
pub vendor_dir: PathBuf,
/// The `[source]` stanza `cargo vendor` printed to stdout (informational — the
/// packer writes its OWN portable [`cargo_config_toml`] instead of this
/// host-absolute one, but it is captured for provenance / diffing).
pub printed_stanza: String,
}
/// Locate the `cargo` binary: honour the `CARGO` env var cargo itself sets for a
/// build script / subcommand, else fall back to `cargo` on `PATH`.
fn cargo_bin() -> PathBuf {
std::env::var_os("CARGO")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("cargo"))
}
/// Run `cargo vendor` for the seed project at `manifest_dir` (holding its
/// `Cargo.toml` + resolved `Cargo.lock`), writing every crate's source into
/// `vendor_dir`. CONNECTED-host only (needs the registry) — shells the accepted
/// `cargo` external (zero-shell law: `qemu` + `cargo`). Returns the vendor dir +
/// the stanza cargo printed.
///
/// The packer then seals `vendor_dir` into the `.znippy` and writes its OWN
/// portable [`cargo_config_toml`] (`<PREFIX>/vendor`), NOT the host-absolute stanza
/// cargo prints — so the config is relocatable across every target box.
pub fn run_cargo_vendor(manifest_dir: &Path, vendor_dir: &Path) -> Result<VendorOutcome> {
let manifest = manifest_dir.join("Cargo.toml");
if !manifest.is_file() {
anyhow::bail!(
"cargo vendor: no Cargo.toml at {} (the seed project manifest)",
manifest.display()
);
}
if let Some(parent) = vendor_dir.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create vendor parent {}", parent.display()))?;
}
// `cargo vendor --manifest-path <dir>/Cargo.toml <vendor_dir>` writes the crate
// sources and prints the `[source]` stanza on stdout.
let out = std::process::Command::new(cargo_bin())
.arg("vendor")
.arg("--manifest-path")
.arg(&manifest)
.arg(vendor_dir)
.output()
.with_context(|| "spawn `cargo vendor` (is cargo on PATH?)")?;
if !out.status.success() {
anyhow::bail!(
"cargo vendor failed (exit {:?}): {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(VendorOutcome {
vendor_dir: vendor_dir.to_path_buf(),
printed_stanza: String::from_utf8_lossy(&out.stdout).into_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The generated config source-replaces crates.io with the vendor dir and turns
/// offline hard-on — the two invariants an airgapped build depends on.
#[test]
fn config_replaces_crates_io_and_forces_offline() {
let cfg = cargo_config_toml("/opt/rust-dev/current/vendor");
assert!(cfg.contains("[source.crates-io]"));
assert!(cfg.contains("replace-with = \"vendored-sources\""));
assert!(cfg.contains("[source.vendored-sources]"));
assert!(cfg.contains("directory = \"/opt/rust-dev/current/vendor\""));
assert!(cfg.contains("[net]"));
assert!(cfg.contains("offline = true"));
// Must NOT carry znippy's host-specific target-cpu=native (design §2.3).
assert!(!cfg.contains("target-cpu"));
}
/// The packed config carries the `<PREFIX>` token, and install-time rewrite turns
/// it into the real activated-generation path.
#[test]
fn prefix_token_round_trips_through_rewrite() {
let packed = cargo_config_toml(&packed_vendor_directory());
assert!(packed.contains("directory = \"<PREFIX>/vendor\""));
let real = rewrite_prefix(&packed, Path::new("/home/dev/.local/rust-dev/current"));
assert!(
real.contains("directory = \"/home/dev/.local/rust-dev/current/vendor\""),
"rewritten: {real}"
);
// No stray token survives the rewrite.
assert!(!real.contains(PREFIX_TOKEN));
}
/// The generated config has the three tables in canonical order with exactly one
/// `directory` line — the structural shape cargo reads (no dup tables, no stray
/// keys). Asserted by table order rather than adding a `toml` dep to the lean core.
#[test]
fn generated_config_has_the_three_tables_in_order() {
let cfg = cargo_config_toml("/x/vendor");
let crates_io = cfg.find("[source.crates-io]").expect("crates-io table");
let vendored = cfg
.find("[source.vendored-sources]")
.expect("vendored table");
let net = cfg.find("[net]").expect("net table");
assert!(
crates_io < vendored && vendored < net,
"tables in canonical order"
);
assert_eq!(
cfg.matches("directory =").count(),
1,
"exactly one directory line"
);
assert_eq!(
cfg.matches("[source.vendored-sources]").count(),
1,
"no duplicate table"
);
}
/// The REAL `cargo vendor` round-trip needs a live registry (network) + a seed
/// project's resolved Cargo.lock, neither of which is in-tree — gated honestly
/// so CI never silently "passes" a vendoring that never ran. Run with
/// `cargo test -- --ignored run_cargo_vendor_real` on a connected host.
#[test]
#[ignore = "needs a connected host + a live crates.io registry (pack-side only)"]
fn run_cargo_vendor_real() {
let tmp = tempfile::tempdir().unwrap();
let proj = tmp.path().join("seed");
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(
proj.join("Cargo.toml"),
"[package]\nname=\"seed\"\nversion=\"0.0.0\"\nedition=\"2021\"\n\n[dependencies]\nanyhow=\"1\"\n",
)
.unwrap();
std::fs::create_dir_all(proj.join("src")).unwrap();
std::fs::write(proj.join("src/lib.rs"), "").unwrap();
let vendor = tmp.path().join("vendor");
let out = run_cargo_vendor(&proj, &vendor).expect("cargo vendor");
assert!(
out.vendor_dir.join("anyhow").exists(),
"anyhow was vendored"
);
assert!(
out.printed_stanza.contains("[source"),
"cargo printed a source stanza"
);
}
}