Skip to main content

znippy_plugin_rust_toolchain/
member.rs

1//! Bundle-member classification for an airgapped Rust-developer bundle.
2//!
3//! A `rust-dev-rhel8-<ver>.znippy` is a fixed directory layout — the canonical
4//! copy is [`crate::BUNDLE_LAYOUT`], which the `layout` subcommand prints and
5//! which this classifier mirrors. Every entry belongs to
6//! exactly one [`MemberKind`], decided **purely from its relative path** — no file
7//! read, no parsing. This mirrors the sibling `znippy-plugin-skidbladnir`
8//! classifier: one pure, exhaustively-tested fn is the whole ownership rule the
9//! plugin's `matches_path` + `extract_metadata` lean on.
10//!
11//! Canonical layout:
12//! ```text
13//! rust-dev-rhel8-1.97.1.znippy
14//! ├── manifest/
15//! │   ├── bundle.json            # rustc/cargo version, host triple, provenance
16//! │   ├── toolchain.pins.json    # per-payload sha256 + blake3 (pinned like the kernel)
17//! │   └── vendor.lock            # a copy of the source Cargo.lock (the crate set)
18//! ├── toolchain/                 # the standalone (rustup-dist) toolchain, UNPACKED
19//! │   ├── bin/                   # rustc, cargo, rustdoc, rust-lld, …
20//! │   ├── lib/                   # librustc driver .so's, std .rlibs
21//! │   └── lib/rustlib/<triple>/  # the rust-std for the target
22//! ├── vendor/<crate>-<ver>/…     # `cargo vendor` output — the "basic crates" set
23//! └── cargo-config/config.toml   # source-replacement → vendor/, offline hard-on
24//! ```
25
26/// One payload member's role inside the bundle. The discriminant strings are the
27/// values written into the `member_kind` Arrow column (queryable via DuckDB).
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum MemberKind {
30    /// `manifest/bundle.json` — rustc/cargo version, host triple, source-URL provenance.
31    Manifest,
32    /// `manifest/toolchain.pins.json` — per-payload sha256 + blake3 (the "pinned like
33    /// the kernel" table). Kept distinct from a plain manifest so a `znippy meta` query
34    /// can find the integrity pins directly.
35    ToolchainPins,
36    /// `manifest/vendor.lock` — the exact crate set (a copy of the source `Cargo.lock`).
37    VendorLock,
38    /// `toolchain/bin/*` — the executables (`rustc`, `cargo`, `rustdoc`, `rust-lld`, …).
39    ToolchainBin,
40    /// `toolchain/**/rustlib/<triple>/*` — the `rust-std` for the build/run target.
41    RustStd,
42    /// `toolchain/lib/*` and any other toolchain support file (driver `.so`s, `.rlib`s).
43    ToolchainLib,
44    /// `vendor/<crate>-<ver>/*` — a vendored crate's expanded source.
45    VendorCrate,
46    /// `cargo-config/*` — the `config.toml` dropped at install (`[source]` replacement).
47    CargoConfig,
48}
49
50impl MemberKind {
51    /// The stable string written to the `member_kind` column.
52    pub fn as_str(self) -> &'static str {
53        match self {
54            MemberKind::Manifest => "manifest",
55            MemberKind::ToolchainPins => "toolchain-pins",
56            MemberKind::VendorLock => "vendor-lock",
57            MemberKind::ToolchainBin => "toolchain-bin",
58            MemberKind::RustStd => "rust-std",
59            MemberKind::ToolchainLib => "toolchain-lib",
60            MemberKind::VendorCrate => "vendor-crate",
61            MemberKind::CargoConfig => "cargo-config",
62        }
63    }
64}
65
66/// Normalise a relative path to `/`-separated components, dropping a leading
67/// `./` and any empty segments. Windows `\` is tolerated.
68fn segments(path: &str) -> Vec<&str> {
69    path.split(['/', '\\']).filter(|s| !s.is_empty() && *s != ".").collect()
70}
71
72/// Classify a bundle entry by its relative path. `None` = not a recognised
73/// rust-dev bundle member (the plugin then does not claim it). Pure and total.
74pub fn classify(path: &str) -> Option<MemberKind> {
75    let segs = segments(path);
76    let top = *segs.first()?;
77
78    match top {
79        "manifest" => {
80            let name = *segs.last().unwrap_or(&top);
81            if name == "toolchain.pins.json" {
82                Some(MemberKind::ToolchainPins)
83            } else if name == "vendor.lock" {
84                Some(MemberKind::VendorLock)
85            } else {
86                Some(MemberKind::Manifest)
87            }
88        }
89        "toolchain" => {
90            if segs.get(1).copied() == Some("bin") {
91                Some(MemberKind::ToolchainBin)
92            } else if let Some(i) = segs.iter().position(|s| *s == "rustlib") {
93                // `.../rustlib/<triple>/<content>` is the per-target std — there must
94                // be a segment AFTER the triple dir. The bare `rustlib/` root
95                // (`components`, version manifests) stays a generic toolchain lib.
96                if segs.len() > i + 2 {
97                    Some(MemberKind::RustStd)
98                } else {
99                    Some(MemberKind::ToolchainLib)
100                }
101            } else {
102                Some(MemberKind::ToolchainLib)
103            }
104        }
105        "vendor" => Some(MemberKind::VendorCrate),
106        "cargo-config" => Some(MemberKind::CargoConfig),
107        _ => None,
108    }
109}
110
111/// The logical component a member belongs to (the `component` column). Dynamic —
112/// the crate name for a vendored crate, the target triple for a rust-std member —
113/// so this returns an owned `String` (unlike the skidbladnir plugin's fixed set).
114/// `None` for bundle-level members (manifest/config) that span the whole toolchain.
115pub fn component_of(kind: MemberKind, path: &str) -> Option<String> {
116    let segs = segments(path);
117    match kind {
118        // The `<crate>-<ver>` directory directly under `vendor/`.
119        MemberKind::VendorCrate => segs.get(1).map(|s| s.to_string()),
120        // The `<triple>` directory directly after `rustlib/`.
121        MemberKind::RustStd => {
122            let idx = segs.iter().position(|s| *s == "rustlib")?;
123            segs.get(idx + 1).map(|s| s.to_string())
124        }
125        // The executable name under `toolchain/bin/`.
126        MemberKind::ToolchainBin => segs.last().map(|s| s.to_string()),
127        _ => None,
128    }
129}
130
131/// Does `name` look like a rust-dev bundle archive — `rust-dev-<...>.znippy`?
132/// (e.g. `rust-dev-rhel8-1.97.1.znippy`). Used by the `bundle-name` subcommand and
133/// by a caller that wants to route a `.znippy` to this classifier.
134pub fn is_rust_dev_bundle(name: &str) -> bool {
135    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
136    base.starts_with("rust-dev-") && base.ends_with(".znippy")
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn classifies_every_member_role() {
145        let cases = [
146            ("manifest/bundle.json", MemberKind::Manifest),
147            ("manifest/toolchain.pins.json", MemberKind::ToolchainPins),
148            ("manifest/vendor.lock", MemberKind::VendorLock),
149            ("toolchain/bin/rustc", MemberKind::ToolchainBin),
150            ("toolchain/bin/cargo", MemberKind::ToolchainBin),
151            ("toolchain/lib/librustc_driver.so", MemberKind::ToolchainLib),
152            (
153                "toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib",
154                MemberKind::RustStd,
155            ),
156            ("vendor/serde-1.0.203/src/lib.rs", MemberKind::VendorCrate),
157            ("vendor/anyhow-1.0.86/Cargo.toml", MemberKind::VendorCrate),
158            ("cargo-config/config.toml", MemberKind::CargoConfig),
159        ];
160        for (p, want) in cases {
161            assert_eq!(classify(p), Some(want), "classify({p})");
162        }
163    }
164
165    #[test]
166    fn rustlib_root_is_generic_lib_not_std() {
167        // The bare rustlib root (components list, not a per-target std) is generic.
168        assert_eq!(
169            classify("toolchain/lib/rustlib/components"),
170            Some(MemberKind::ToolchainLib)
171        );
172    }
173
174    #[test]
175    fn unknown_paths_are_not_claimed() {
176        assert_eq!(classify("README.md"), None);
177        assert_eq!(classify("s3/doc.pdf"), None);
178        assert_eq!(classify(""), None);
179        // Leading ./ is tolerated.
180        assert_eq!(classify("./vendor/serde-1.0/src/lib.rs"), Some(MemberKind::VendorCrate));
181    }
182
183    #[test]
184    fn component_derivation() {
185        assert_eq!(
186            component_of(MemberKind::VendorCrate, "vendor/serde-1.0.203/src/lib.rs").as_deref(),
187            Some("serde-1.0.203")
188        );
189        assert_eq!(
190            component_of(
191                MemberKind::RustStd,
192                "toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib"
193            )
194            .as_deref(),
195            Some("x86_64-unknown-linux-gnu")
196        );
197        assert_eq!(
198            component_of(MemberKind::ToolchainBin, "toolchain/bin/cargo").as_deref(),
199            Some("cargo")
200        );
201        assert_eq!(component_of(MemberKind::Manifest, "manifest/bundle.json"), None);
202    }
203
204    #[test]
205    fn recognises_bundle_archive_name() {
206        assert!(is_rust_dev_bundle("rust-dev-rhel8-1.97.1.znippy"));
207        assert!(is_rust_dev_bundle("/tmp/rust-dev-rhel8-1.97.1.znippy"));
208        assert!(!is_rust_dev_bundle("tillsynia-20260706.znippy"));
209        assert!(!is_rust_dev_bundle("rust-dev-rhel8-1.97.1.tar.zst"));
210    }
211}