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}