Skip to main content

znippy_plugin_rust_toolchain/
lib.rs

1//! znippy handler for **airgapped Rust-developer bundles** — the
2//! `rust-dev-rhel8-<ver>.znippy` payload (a standalone rustup-dist toolchain +
3//! a `cargo vendor` crate set + a `.cargo/config` + pinned integrity manifest)
4//! that lets a disconnected RHEL 8 box `cargo build`/`cargo run` offline.
5//!
6//! It teaches znippy's Arrow-IPC index what each entry of a sealed rust-dev
7//! bundle *is*, so the archive is self-describing and DuckDB/Polars-queryable
8//! (`SELECT logical_path FROM bundle WHERE member_kind = 'vendor-crate'`). It adds
9//! **only metadata columns** — the format, the pack/move/unfold mechanics and the
10//! sealing stay owned by znippy + Skidbladnir.
11//!
12//! Native builtin: registered in `znippy-cli/src/handlers.rs::builtin_handlers`,
13//! discovered via [`ArchiveTypePlugin::meta`]. NOT a WASM plugin. Sibling of
14//! `znippy-plugin-skidbladnir` (type_id 40) — a fresh `type_id` (41) so a
15//! `znippy meta` query over a toolchain bundle is unambiguous (design decision §6.2).
16//!
17//! Laws it honours: **P-1** one archive = one ecosystem (`--format rust-toolchain`);
18//! **P-2** writes only the columns it declared; **P-4** never panics — a member it
19//! cannot parse still writes with its path-derived `member_kind`, only the optional
20//! `toolchain_version`/`host_triple` degrade to null.
21
22#[cfg(feature = "znippy-handler")]
23use std::collections::HashMap;
24
25#[cfg(feature = "znippy-handler")]
26use znippy_common::arrow::datatypes::{DataType, Field};
27#[cfg(feature = "znippy-handler")]
28use znippy_common::plugin::{
29    ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta,
30};
31
32/// The bundle-member classifier. **Unconditional**, and deliberately free of
33/// every dependency this crate has: it is pure `&str` -> enum, so it compiles for
34/// `wasm32-unknown-unknown` as-is.
35///
36/// That is what `--no-default-features` buys a consumer. holger's package-handler
37/// plugins (`holger-handler-rust-toolchain`, and its wasm shim) need this
38/// ownership rule and nothing else; taking it with the default features would
39/// drag `znippy-common` -- arrow, io_uring and the gatling engine -- into a
40/// `wasm32` build that cannot link any of them. Same shape as
41/// `znippy-plugin-maven`'s `host-decompressors`: the target-independent core
42/// stays, the host-only machinery is compiled out.
43pub mod member;
44pub use member::{classify, component_of, is_rust_dev_bundle, MemberKind};
45
46/// DenseUnion / pkg_type discriminant. Clear of the built-ins (1–18), media (25)
47/// and the skidbladnir bundle classifier (40).
48pub const RUST_TOOLCHAIN_TYPE_ID: i8 = 41;
49
50/// The canonical bundle layout, printed by the `layout` subcommand and mirrored
51/// by the classifier in [`member`].
52pub const BUNDLE_LAYOUT: &str = "\
53rust-dev-rhel8-<rustc-ver>.znippy
54├── manifest/
55│   ├── bundle.json            # rustc/cargo version, host triple, source-URL provenance
56│   ├── toolchain.pins.json    # per-payload sha256 + blake3 (the \"pinned like the kernel\" table)
57│   └── vendor.lock            # exact crate set = a copy of the source Cargo.lock
58├── toolchain/                 # the standalone (rustup-dist) toolchain, UNPACKED into the tree
59│   ├── bin/                   # rustc, cargo, rustdoc, rust-lld, …
60│   ├── lib/                   # librustc driver .so's, std .rlibs
61│   └── lib/rustlib/<triple>/  # the rust-std for the target
62├── vendor/                    # `cargo vendor` output — the \"basic crates\" set
63│   └── <crate>-<ver>/…        # already-compressed .crate contents expanded to source
64└── cargo-config/
65    └── config.toml            # source-replacement → vendor/, offline hard-on";
66
67/// Native rust-dev-bundle handler.
68#[cfg(feature = "znippy-handler")]
69pub struct NativeRustToolchainPlugin;
70
71#[cfg(feature = "znippy-handler")]
72impl NativeRustToolchainPlugin {
73    pub fn new() -> Self {
74        NativeRustToolchainPlugin
75    }
76}
77
78#[cfg(feature = "znippy-handler")]
79impl Default for NativeRustToolchainPlugin {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85/// Best-effort pull of a top-level string field from a JSON member. Never
86/// panics — bad/garbage JSON just yields `None` (P-4).
87#[cfg(feature = "znippy-handler")]
88fn json_str_field(data: &[u8], key: &str) -> Option<String> {
89    let v: serde_json::Value = serde_json::from_slice(data).ok()?;
90    v.get(key)?.as_str().map(str::to_string)
91}
92
93#[cfg(feature = "znippy-handler")]
94impl ArchiveTypePlugin for NativeRustToolchainPlugin {
95    fn name(&self) -> &str {
96        "rust-toolchain"
97    }
98
99    fn type_id(&self) -> i8 {
100        RUST_TOOLCHAIN_TYPE_ID
101    }
102
103    fn meta(&self) -> HandlerMeta {
104        HandlerMeta {
105            name: "rust-toolchain".into(),
106            aliases: vec!["rust-dev".into(), "rustdev".into(), "toolchain".into()],
107            type_id: RUST_TOOLCHAIN_TYPE_ID,
108            ecosystem: "Airgapped Rust developer bundle (rustup-dist toolchain + cargo-vendor set)"
109                .into(),
110            extensions: vec![
111                ".pins.json".into(),
112                ".lock".into(),
113                ".rlib".into(),
114                ".rmeta".into(),
115                ".toml".into(),
116            ],
117            description: "Tags each rust-dev bundle entry (toolchain bin / rust-std / \
118                          vendored crate / cargo-config / pins manifest) into the index so \
119                          a sealed offline-Rust bundle is self-describing and queryable"
120                .into(),
121            commands: vec![
122                HandlerCommand::new(
123                    "classify",
124                    "Print the member_kind / component for a bundle path",
125                ),
126                HandlerCommand::new("layout", "Print the canonical rust-dev bundle layout"),
127            ],
128        }
129    }
130
131    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
132        match cmd {
133            "layout" => {
134                println!("{BUNDLE_LAYOUT}");
135                Ok(())
136            }
137            "classify" => {
138                let path = args
139                    .first()
140                    .ok_or_else(|| anyhow::anyhow!("usage: rust-toolchain classify <bundle-path>"))?;
141                match classify(path) {
142                    Some(kind) => {
143                        println!("path:        {path}");
144                        println!("member_kind: {}", kind.as_str());
145                        println!("component:   {}", component_of(kind, path).as_deref().unwrap_or("-"));
146                        Ok(())
147                    }
148                    None => {
149                        anyhow::bail!("'{path}' is not a recognised rust-dev bundle member")
150                    }
151                }
152            }
153            other => anyhow::bail!("rust-toolchain: unknown subcommand '{other}'"),
154        }
155    }
156
157    fn matches_path(&self, path: &str) -> bool {
158        classify(path).is_some()
159    }
160
161    fn schema_fields(&self) -> Vec<Field> {
162        vec![
163            Field::new("member_kind", DataType::Utf8, true),
164            Field::new("component", DataType::Utf8, true),
165            Field::new("logical_path", DataType::Utf8, true),
166            Field::new("toolchain_version", DataType::Utf8, true),
167            Field::new("host_triple", DataType::Utf8, true),
168        ]
169    }
170
171    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
172        let kind = classify(path)?;
173        let mut f: HashMap<String, ExtensionValue> = HashMap::new();
174
175        f.insert("member_kind".into(), ExtensionValue::Str(kind.as_str().into()));
176        f.insert("logical_path".into(), ExtensionValue::Str(path.into()));
177        if let Some(c) = component_of(kind, path) {
178            f.insert("component".into(), ExtensionValue::Str(c));
179        }
180
181        // toolchain_version / host_triple only live in the bundle.json manifest;
182        // parse best-effort. A garbage or oversized file leaves them null (P-4).
183        if kind == MemberKind::Manifest {
184            if let Some(v) = json_str_field(data, "version")
185                .or_else(|| json_str_field(data, "rustc_version"))
186            {
187                f.insert("toolchain_version".into(), ExtensionValue::Str(v));
188            }
189            if let Some(h) = json_str_field(data, "host")
190                .or_else(|| json_str_field(data, "host_triple"))
191            {
192                f.insert("host_triple".into(), ExtensionValue::Str(h));
193            }
194        }
195
196        Some(ExtensionRow { fields: f })
197    }
198}
199
200#[cfg(all(test, feature = "znippy-handler"))]
201mod tests {
202    use super::*;
203
204    fn get<'a>(row: &'a ExtensionRow, k: &str) -> Option<&'a ExtensionValue> {
205        row.fields.get(k)
206    }
207
208    #[test]
209    fn tags_a_vendored_crate() {
210        let p = NativeRustToolchainPlugin::new();
211        let row = p.extract_metadata("vendor/serde-1.0.203/src/lib.rs", b"pub fn x() {}").unwrap();
212        assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("vendor-crate".into())));
213        assert_eq!(get(&row, "component"), Some(&ExtensionValue::Str("serde-1.0.203".into())));
214        // a source file carries no toolchain version
215        assert!(get(&row, "toolchain_version").is_none());
216    }
217
218    #[test]
219    fn tags_the_rust_std_with_its_triple() {
220        let p = NativeRustToolchainPlugin::new();
221        let row = p
222            .extract_metadata(
223                "toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib",
224                b"\x00rlib",
225            )
226            .unwrap();
227        assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("rust-std".into())));
228        assert_eq!(
229            get(&row, "component"),
230            Some(&ExtensionValue::Str("x86_64-unknown-linux-gnu".into()))
231        );
232    }
233
234    #[test]
235    fn parses_bundle_manifest_version_and_host() {
236        let p = NativeRustToolchainPlugin::new();
237        let json = br#"{"version":"1.97.1","host":"x86_64-unknown-linux-gnu","source":"https://static.rust-lang.org"}"#;
238        let row = p.extract_metadata("manifest/bundle.json", json).unwrap();
239        assert_eq!(get(&row, "toolchain_version"), Some(&ExtensionValue::Str("1.97.1".into())));
240        assert_eq!(
241            get(&row, "host_triple"),
242            Some(&ExtensionValue::Str("x86_64-unknown-linux-gnu".into()))
243        );
244    }
245
246    #[test]
247    fn manifest_with_garbage_json_falls_back_never_panics() {
248        let p = NativeRustToolchainPlugin::new();
249        let row = p.extract_metadata("manifest/bundle.json", b"not json at all }{").unwrap();
250        assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("manifest".into())));
251        assert!(get(&row, "toolchain_version").is_none());
252        assert!(get(&row, "host_triple").is_none());
253    }
254
255    #[test]
256    fn does_not_claim_foreign_paths() {
257        let p = NativeRustToolchainPlugin::new();
258        assert!(!p.matches_path("README.md"));
259        assert!(!p.matches_path("s3/doc.pdf"));
260        assert!(p.matches_path("toolchain/bin/rustc"));
261        assert!(p.extract_metadata("README.md", b"x").is_none());
262    }
263
264    #[test]
265    fn schema_and_meta_are_consistent() {
266        let p = NativeRustToolchainPlugin::new();
267        assert_eq!(p.type_id(), RUST_TOOLCHAIN_TYPE_ID);
268        assert_eq!(p.meta().type_id, RUST_TOOLCHAIN_TYPE_ID);
269        // distinct from the skidbladnir bundle classifier's type_id (40)
270        assert_ne!(p.type_id(), 40);
271        assert_eq!(p.schema_fields().len(), 5);
272    }
273}