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
22use std::collections::HashMap;
23
24use znippy_common::arrow::datatypes::{DataType, Field};
25use znippy_common::plugin::{
26    ArchiveTypePlugin, ExtensionRow, ExtensionValue, HandlerCommand, HandlerMeta,
27};
28
29pub mod member;
30pub use member::{classify, component_of, is_rust_dev_bundle, MemberKind};
31
32/// DenseUnion / pkg_type discriminant. Clear of the built-ins (1–18), media (25)
33/// and the skidbladnir bundle classifier (40).
34pub const RUST_TOOLCHAIN_TYPE_ID: i8 = 41;
35
36/// The canonical bundle layout, printed by the `layout` subcommand and mirrored
37/// by the classifier in [`member`].
38pub const BUNDLE_LAYOUT: &str = "\
39rust-dev-rhel8-<rustc-ver>.znippy
40├── manifest/
41│   ├── bundle.json            # rustc/cargo version, host triple, source-URL provenance
42│   ├── toolchain.pins.json    # per-payload sha256 + blake3 (the \"pinned like the kernel\" table)
43│   └── vendor.lock            # exact crate set = a copy of the source Cargo.lock
44├── toolchain/                 # the standalone (rustup-dist) toolchain, UNPACKED into the tree
45│   ├── bin/                   # rustc, cargo, rustdoc, rust-lld, …
46│   ├── lib/                   # librustc driver .so's, std .rlibs
47│   └── lib/rustlib/<triple>/  # the rust-std for the target
48├── vendor/                    # `cargo vendor` output — the \"basic crates\" set
49│   └── <crate>-<ver>/…        # already-compressed .crate contents expanded to source
50└── cargo-config/
51    └── config.toml            # source-replacement → vendor/, offline hard-on";
52
53/// Native rust-dev-bundle handler.
54pub struct NativeRustToolchainPlugin;
55
56impl NativeRustToolchainPlugin {
57    pub fn new() -> Self {
58        NativeRustToolchainPlugin
59    }
60}
61
62impl Default for NativeRustToolchainPlugin {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68/// Best-effort pull of a top-level string field from a JSON member. Never
69/// panics — bad/garbage JSON just yields `None` (P-4).
70fn json_str_field(data: &[u8], key: &str) -> Option<String> {
71    let v: serde_json::Value = serde_json::from_slice(data).ok()?;
72    v.get(key)?.as_str().map(str::to_string)
73}
74
75impl ArchiveTypePlugin for NativeRustToolchainPlugin {
76    fn name(&self) -> &str {
77        "rust-toolchain"
78    }
79
80    fn type_id(&self) -> i8 {
81        RUST_TOOLCHAIN_TYPE_ID
82    }
83
84    fn meta(&self) -> HandlerMeta {
85        HandlerMeta {
86            name: "rust-toolchain".into(),
87            aliases: vec!["rust-dev".into(), "rustdev".into(), "toolchain".into()],
88            type_id: RUST_TOOLCHAIN_TYPE_ID,
89            ecosystem: "Airgapped Rust developer bundle (rustup-dist toolchain + cargo-vendor set)"
90                .into(),
91            extensions: vec![
92                ".pins.json".into(),
93                ".lock".into(),
94                ".rlib".into(),
95                ".rmeta".into(),
96                ".toml".into(),
97            ],
98            description: "Tags each rust-dev bundle entry (toolchain bin / rust-std / \
99                          vendored crate / cargo-config / pins manifest) into the index so \
100                          a sealed offline-Rust bundle is self-describing and queryable"
101                .into(),
102            commands: vec![
103                HandlerCommand::new(
104                    "classify",
105                    "Print the member_kind / component for a bundle path",
106                ),
107                HandlerCommand::new("layout", "Print the canonical rust-dev bundle layout"),
108            ],
109        }
110    }
111
112    fn run_command(&self, cmd: &str, args: &[String]) -> anyhow::Result<()> {
113        match cmd {
114            "layout" => {
115                println!("{BUNDLE_LAYOUT}");
116                Ok(())
117            }
118            "classify" => {
119                let path = args
120                    .first()
121                    .ok_or_else(|| anyhow::anyhow!("usage: rust-toolchain classify <bundle-path>"))?;
122                match classify(path) {
123                    Some(kind) => {
124                        println!("path:        {path}");
125                        println!("member_kind: {}", kind.as_str());
126                        println!("component:   {}", component_of(kind, path).as_deref().unwrap_or("-"));
127                        Ok(())
128                    }
129                    None => {
130                        anyhow::bail!("'{path}' is not a recognised rust-dev bundle member")
131                    }
132                }
133            }
134            other => anyhow::bail!("rust-toolchain: unknown subcommand '{other}'"),
135        }
136    }
137
138    fn matches_path(&self, path: &str) -> bool {
139        classify(path).is_some()
140    }
141
142    fn schema_fields(&self) -> Vec<Field> {
143        vec![
144            Field::new("member_kind", DataType::Utf8, true),
145            Field::new("component", DataType::Utf8, true),
146            Field::new("logical_path", DataType::Utf8, true),
147            Field::new("toolchain_version", DataType::Utf8, true),
148            Field::new("host_triple", DataType::Utf8, true),
149        ]
150    }
151
152    fn extract_metadata(&self, path: &str, data: &[u8]) -> Option<ExtensionRow> {
153        let kind = classify(path)?;
154        let mut f: HashMap<String, ExtensionValue> = HashMap::new();
155
156        f.insert("member_kind".into(), ExtensionValue::Str(kind.as_str().into()));
157        f.insert("logical_path".into(), ExtensionValue::Str(path.into()));
158        if let Some(c) = component_of(kind, path) {
159            f.insert("component".into(), ExtensionValue::Str(c));
160        }
161
162        // toolchain_version / host_triple only live in the bundle.json manifest;
163        // parse best-effort. A garbage or oversized file leaves them null (P-4).
164        if kind == MemberKind::Manifest {
165            if let Some(v) = json_str_field(data, "version")
166                .or_else(|| json_str_field(data, "rustc_version"))
167            {
168                f.insert("toolchain_version".into(), ExtensionValue::Str(v));
169            }
170            if let Some(h) = json_str_field(data, "host")
171                .or_else(|| json_str_field(data, "host_triple"))
172            {
173                f.insert("host_triple".into(), ExtensionValue::Str(h));
174            }
175        }
176
177        Some(ExtensionRow { fields: f })
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn get<'a>(row: &'a ExtensionRow, k: &str) -> Option<&'a ExtensionValue> {
186        row.fields.get(k)
187    }
188
189    #[test]
190    fn tags_a_vendored_crate() {
191        let p = NativeRustToolchainPlugin::new();
192        let row = p.extract_metadata("vendor/serde-1.0.203/src/lib.rs", b"pub fn x() {}").unwrap();
193        assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("vendor-crate".into())));
194        assert_eq!(get(&row, "component"), Some(&ExtensionValue::Str("serde-1.0.203".into())));
195        // a source file carries no toolchain version
196        assert!(get(&row, "toolchain_version").is_none());
197    }
198
199    #[test]
200    fn tags_the_rust_std_with_its_triple() {
201        let p = NativeRustToolchainPlugin::new();
202        let row = p
203            .extract_metadata(
204                "toolchain/lib/rustlib/x86_64-unknown-linux-gnu/lib/libstd.rlib",
205                b"\x00rlib",
206            )
207            .unwrap();
208        assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("rust-std".into())));
209        assert_eq!(
210            get(&row, "component"),
211            Some(&ExtensionValue::Str("x86_64-unknown-linux-gnu".into()))
212        );
213    }
214
215    #[test]
216    fn parses_bundle_manifest_version_and_host() {
217        let p = NativeRustToolchainPlugin::new();
218        let json = br#"{"version":"1.97.1","host":"x86_64-unknown-linux-gnu","source":"https://static.rust-lang.org"}"#;
219        let row = p.extract_metadata("manifest/bundle.json", json).unwrap();
220        assert_eq!(get(&row, "toolchain_version"), Some(&ExtensionValue::Str("1.97.1".into())));
221        assert_eq!(
222            get(&row, "host_triple"),
223            Some(&ExtensionValue::Str("x86_64-unknown-linux-gnu".into()))
224        );
225    }
226
227    #[test]
228    fn manifest_with_garbage_json_falls_back_never_panics() {
229        let p = NativeRustToolchainPlugin::new();
230        let row = p.extract_metadata("manifest/bundle.json", b"not json at all }{").unwrap();
231        assert_eq!(get(&row, "member_kind"), Some(&ExtensionValue::Str("manifest".into())));
232        assert!(get(&row, "toolchain_version").is_none());
233        assert!(get(&row, "host_triple").is_none());
234    }
235
236    #[test]
237    fn does_not_claim_foreign_paths() {
238        let p = NativeRustToolchainPlugin::new();
239        assert!(!p.matches_path("README.md"));
240        assert!(!p.matches_path("s3/doc.pdf"));
241        assert!(p.matches_path("toolchain/bin/rustc"));
242        assert!(p.extract_metadata("README.md", b"x").is_none());
243    }
244
245    #[test]
246    fn schema_and_meta_are_consistent() {
247        let p = NativeRustToolchainPlugin::new();
248        assert_eq!(p.type_id(), RUST_TOOLCHAIN_TYPE_ID);
249        assert_eq!(p.meta().type_id, RUST_TOOLCHAIN_TYPE_ID);
250        // distinct from the skidbladnir bundle classifier's type_id (40)
251        assert_ne!(p.type_id(), 40);
252        assert_eq!(p.schema_fields().len(), 5);
253    }
254}