plugins/native/znippy-plugin-rust-toolchain/src/member.rs
//! Bundle-member classification for an airgapped Rust-developer bundle.
//!
//! A `rust-dev-rhel8-<ver>.znippy` is a fixed directory layout — the canonical
//! copy is [`crate::BUNDLE_LAYOUT`], which the `layout` subcommand prints and
//! which this classifier mirrors. Every entry belongs to
//! exactly one [`MemberKind`], decided **purely from its relative path** — no file
//! read, no parsing. This mirrors the sibling `znippy-plugin-skidbladnir`
//! classifier: one pure, exhaustively-tested fn is the whole ownership rule the
//! plugin's `matches_path` + `extract_metadata` lean on.
//!
//! Canonical layout:
//! ```text
//! rust-dev-rhel8-1.97.1.znippy
//! ├── manifest/
//! │ ├── bundle.json # rustc/cargo version, host triple, provenance
//! │ ├── toolchain.pins.json # per-payload sha256 + blake3 (pinned like the kernel)
//! │ └── vendor.lock # a copy of the source Cargo.lock (the crate set)
//! ├── toolchain/ # the standalone (rustup-dist) toolchain, UNPACKED
//! │ ├── bin/ # rustc, cargo, rustdoc, rust-lld, …
//! │ ├── lib/ # librustc driver .so's, std .rlibs
//! │ └── lib/rustlib/<triple>/ # the rust-std for the target
//! ├── vendor/<crate>-<ver>/… # `cargo vendor` output — the "basic crates" set
//! └── cargo-config/config.toml # source-replacement → vendor/, offline hard-on
//! ```
/// One payload member's role inside the bundle. The discriminant strings are the
/// values written into the `member_kind` Arrow column (queryable via DuckDB).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberKind {
/// `manifest/bundle.json` — rustc/cargo version, host triple, source-URL provenance.
Manifest,
/// `manifest/toolchain.pins.json` — per-payload sha256 + blake3 (the "pinned like
/// the kernel" table). Kept distinct from a plain manifest so a `znippy meta` query
/// can find the integrity pins directly.
ToolchainPins,
/// `manifest/vendor.lock` — the exact crate set (a copy of the source `Cargo.lock`).
VendorLock,
/// `toolchain/bin/*` — the executables (`rustc`, `cargo`, `rustdoc`, `rust-lld`, …).
ToolchainBin,
/// `toolchain/**/rustlib/<triple>/*` — the `rust-std` for the build/run target.
RustStd,
/// `toolchain/lib/*` and any other toolchain support file (driver `.so`s, `.rlib`s).
ToolchainLib,
/// `vendor/<crate>-<ver>/*` — a vendored crate's expanded source.
VendorCrate,
/// `cargo-config/*` — the `config.toml` dropped at install (`[source]` replacement).
CargoConfig,
}
impl MemberKind {
/// The stable string written to the `member_kind` column.
pub fn as_str(self) -> &'static str {
match self {
MemberKind::Manifest => "manifest",
MemberKind::ToolchainPins => "toolchain-pins",
MemberKind::VendorLock => "vendor-lock",
MemberKind::ToolchainBin => "toolchain-bin",
MemberKind::RustStd => "rust-std",
MemberKind::ToolchainLib => "toolchain-lib",
MemberKind::VendorCrate => "vendor-crate",
MemberKind::CargoConfig => "cargo-config",
}
}
}
/// Normalise a relative path to `/`-separated components, dropping a leading
/// `./` and any empty segments. Windows `\` is tolerated.
fn segments(path: &str) -> Vec<&str> {
path.split(['/', '\\']).filter(|s| !s.is_empty() && *s != ".").collect()
}
/// Classify a bundle entry by its relative path. `None` = not a recognised
/// rust-dev bundle member (the plugin then does not claim it). Pure and total.
pub fn classify(path: &str) -> Option<MemberKind> {
let segs = segments(path);
let top = *segs.first()?;
match top {
"manifest" => {
let name = *segs.last().unwrap_or(&top);
if name == "toolchain.pins.json" {
Some(MemberKind::ToolchainPins)
} else if name == "vendor.lock" {
Some(MemberKind::VendorLock)
} else {
Some(MemberKind::Manifest)
}
}
"toolchain" => {
if segs.get(1).copied() == Some("bin") {
Some(MemberKind::ToolchainBin)
} else if let Some(i) = segs.iter().position(|s| *s == "rustlib") {
// `.../rustlib/<triple>/<content>` is the per-target std — there must
// be a segment AFTER the triple dir. The bare `rustlib/` root
// (`components`, version manifests) stays a generic toolchain lib.
if segs.len() > i + 2 {
Some(MemberKind::RustStd)
} else {
Some(MemberKind::ToolchainLib)
}
} else {
Some(MemberKind::ToolchainLib)
}
}
"vendor" => Some(MemberKind::VendorCrate),
"cargo-config" => Some(MemberKind::CargoConfig),
_ => None,
}
}
/// The logical component a member belongs to (the `component` column). Dynamic —
/// the crate name for a vendored crate, the target triple for a rust-std member —
/// so this returns an owned `String` (unlike the skidbladnir plugin's fixed set).
/// `None` for bundle-level members (manifest/config) that span the whole toolchain.
pub fn component_of(kind: MemberKind, path: &str) -> Option<String> {
let segs = segments(path);
match kind {
// The `<crate>-<ver>` directory directly under `vendor/`.
MemberKind::VendorCrate => segs.get(1).map(|s| s.to_string()),
// The `<triple>` directory directly after `rustlib/`.
MemberKind::RustStd => {
let idx = segs.iter().position(|s| *s == "rustlib")?;
segs.get(idx + 1).map(|s| s.to_string())
}
// The executable name under `toolchain/bin/`.
MemberKind::ToolchainBin => segs.last().map(|s| s.to_string()),
_ => None,
}
}
/// Does `name` look like a rust-dev bundle archive — `rust-dev-<...>.znippy`?
/// (e.g. `rust-dev-rhel8-1.97.1.znippy`). Used by the `bundle-name` subcommand and
/// by a caller that wants to route a `.znippy` to this classifier.
pub fn is_rust_dev_bundle(name: &str) -> bool {
let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
base.starts_with("rust-dev-") && base.ends_with(".znippy")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_every_member_role() {
let cases = [
("manifest/bundle.json", MemberKind::Manifest),
("manifest/toolchain.pins.json", MemberKind::ToolchainPins),
("manifest/vendor.lock", MemberKind::VendorLock),
("toolchain/bin/rustc", MemberKind::ToolchainBin),
("toolchain/bin/cargo", MemberKind::ToolchainBin),
("toolchain/lib/librustc_driver.so", MemberKind::ToolchainLib),
(
"toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib",
MemberKind::RustStd,
),
("vendor/serde-1.0.203/src/lib.rs", MemberKind::VendorCrate),
("vendor/anyhow-1.0.86/Cargo.toml", MemberKind::VendorCrate),
("cargo-config/config.toml", MemberKind::CargoConfig),
];
for (p, want) in cases {
assert_eq!(classify(p), Some(want), "classify({p})");
}
}
#[test]
fn rustlib_root_is_generic_lib_not_std() {
// The bare rustlib root (components list, not a per-target std) is generic.
assert_eq!(
classify("toolchain/lib/rustlib/components"),
Some(MemberKind::ToolchainLib)
);
}
#[test]
fn unknown_paths_are_not_claimed() {
assert_eq!(classify("README.md"), None);
assert_eq!(classify("s3/doc.pdf"), None);
assert_eq!(classify(""), None);
// Leading ./ is tolerated.
assert_eq!(classify("./vendor/serde-1.0/src/lib.rs"), Some(MemberKind::VendorCrate));
}
#[test]
fn component_derivation() {
assert_eq!(
component_of(MemberKind::VendorCrate, "vendor/serde-1.0.203/src/lib.rs").as_deref(),
Some("serde-1.0.203")
);
assert_eq!(
component_of(
MemberKind::RustStd,
"toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib"
)
.as_deref(),
Some("x86_64-unknown-linux-gnu")
);
assert_eq!(
component_of(MemberKind::ToolchainBin, "toolchain/bin/cargo").as_deref(),
Some("cargo")
);
assert_eq!(component_of(MemberKind::Manifest, "manifest/bundle.json"), None);
}
#[test]
fn recognises_bundle_archive_name() {
assert!(is_rust_dev_bundle("rust-dev-rhel8-1.97.1.znippy"));
assert!(is_rust_dev_bundle("/tmp/rust-dev-rhel8-1.97.1.znippy"));
assert!(!is_rust_dev_bundle("tillsynia-20260706.znippy"));
assert!(!is_rust_dev_bundle("rust-dev-rhel8-1.97.1.tar.zst"));
}
}