Skip to main content

znippy_common/plugins/native/
skeletons.rs

1//! Skeleton package handlers — dummy implementations for additional ecosystems.
2//!
3//! Each handler here is a *stub*: a static [`HandlerSpec`] (so it shows up in
4//! `znippy handlers` and is selectable with `--format <name>`) plus a filename
5//! `extract_metadata` (package name + best-effort version, zero decompression
6//! cost) that writes the `name` / `version` columns. Real per-ecosystem parsing (manifest
7//! files inside the archive, signatures, dep graphs) is left as a TODO for
8//! whoever promotes the stub to a full handler.
9//!
10//! To promote a skeleton: give it its own spec + `extract_metadata` with real
11//! parsing, and any ecosystem-specific subcommands in `spec().commands` +
12//! `run_command`.
13//!
14//! `type_id` registry continues from the native handlers (1=cargo, 2=python,
15//! 3=maven, 6=npm, 11=gem, 14=conda — npm is `plugins::npm_native`, gem is
16//! `plugins::gem_native`, conda is `plugins::conda_native`, NONE a skeleton). See
17//! `.nornir/plugins-design.md` §f3 for the authoritative table, including the
18//! tier-B ids that live in their own crates (25=media, 40=skidbladnir,
19//! 41=rust-toolchain, 42=git).
20//!
21//! A promoted handler stays **here**, in `znippy-common/src/plugins/native/`, for as
22//! long as it needs nothing but `std`, `arrow` and this crate. Needing a
23//! third-party parser, a decompressor, a network fetch, its own crate-type or
24//! its own release cadence is what makes it a `znippy-plugin-*` crate instead —
25//! rule **P-5**.
26
27use crate::plugin::{file_name, ArchiveTypePlugin, Col, ExtensionRow, ExtensionValue, Claim, HandlerSpec};
28use std::collections::HashMap;
29
30/// Best-effort `(name, version?)` split from a package filename.
31///
32/// Strips the first matching extension, then splits at the last `-`/`_`/`@`
33/// that is immediately followed by a digit (the conventional version start).
34/// Falls back to `(stem, None)` for formats without an embedded version
35/// (e.g. raw ELF binaries).
36fn parse_coords(path: &str, extensions: &[&str]) -> (String, Option<String>) {
37    let filename = file_name(path);
38    let stem = extensions
39        .iter()
40        .find_map(|ext| filename.strip_suffix(ext))
41        .unwrap_or(filename);
42
43    let mut split_pos = None;
44    for (i, c) in stem.char_indices() {
45        if matches!(c, '-' | '_' | '@') {
46            if let Some(next) = stem[i + 1..].chars().next() {
47                if next.is_ascii_digit() {
48                    split_pos = Some(i);
49                }
50            }
51        }
52    }
53
54    match split_pos {
55        Some(pos) => (stem[..pos].to_string(), Some(stem[pos + 1..].to_string())),
56        None => (stem.to_string(), None),
57    }
58}
59
60/// Stamp out a skeleton handler that parses `name` + best-effort `version`
61/// from the filename (the shared `coords` command prints them).
62macro_rules! skeleton_handler {
63    (
64        struct: $struct:ident,
65        name: $name:literal,
66        type_id: $tid:literal,
67        aliases: [$($alias:literal),* $(,)?],
68        ecosystem: $eco:literal,
69        extensions: [$($ext:literal),* $(,)?],
70        description: $desc:literal $(,)?
71    ) => {
72        #[doc = concat!("Skeleton handler for the ", $eco, " ecosystem (stub).")]
73        pub struct $struct;
74
75        impl ArchiveTypePlugin for $struct {
76            fn spec(&self) -> &HandlerSpec {
77                static SPEC: HandlerSpec = HandlerSpec {
78                    name: $name,
79                    aliases: &[$($alias),*],
80                    type_id: $tid,
81                    ecosystem: $eco,
82                    extensions: &[$($ext),*],
83                    claim: Claim::Extensions,
84                    description: concat!($desc, " (skeleton — filename-only)"),
85                    fields: SKELETON_FIELDS,
86                    commands: &[],
87                };
88                &SPEC
89            }
90
91            fn extract_metadata(&self, path: &str, _data: &[u8]) -> Option<ExtensionRow> {
92                skeleton_row(path, self.spec().extensions)
93            }
94        }
95    };
96}
97
98/// The two columns every skeleton writes.
99const SKELETON_FIELDS: &[(&str, Col)] = &[("name", Col::Utf8), ("version", Col::Utf8)];
100
101fn skeleton_row(path: &str, extensions: &[&str]) -> Option<ExtensionRow> {
102    let (name, version) = parse_coords(path, extensions);
103    let mut fields = HashMap::new();
104    fields.insert("name".into(), ExtensionValue::Str(name));
105    fields.insert("version".into(), ExtensionValue::OptStr(version));
106    Some(ExtensionRow { fields })
107}
108
109skeleton_handler! {
110    struct: GoPlugin,
111    name: "go",
112    type_id: 4,
113    aliases: ["golang"],
114    ecosystem: "Go / Go modules (proxy.golang.org)",
115    extensions: [".zip", ".mod", ".info"],
116    description: "Go module zips published to the module proxy",
117}
118
119skeleton_handler! {
120    struct: NugetPlugin,
121    name: "nuget",
122    type_id: 5,
123    aliases: ["dotnet", ".net"],
124    ecosystem: ".NET / NuGet (nuget.org)",
125    extensions: [".nupkg", ".snupkg"],
126    description: ".NET NuGet packages (zip with a .nuspec manifest)",
127}
128
129// npm (type_id 6) is a REAL handler now — see `plugins::npm_native::NpmPlugin`
130// (authoritative name+version from package.json). It is intentionally absent from
131// the skeleton list below; `register` folds in `NpmPlugin` from `npm_native`.
132
133// elf (type_id 7) is a REAL handler now — see the `znippy-plugin-elf` crate
134// (`NativeElfPlugin`). It reads every fact from the BINARY: header, PT_INTERP,
135// DT_NEEDED with symbol versions, build-id, ISA level, the .nordisk.buildthing
136// receipt, .note.package and .dep-v0 — which is exactly what the skeleton here
137// could not do, because it parsed the FILE NAME.
138//
139// It had to be DELETED rather than left beside the real handler: `find_handler`
140// takes the first match in `builtin_handlers()` order and that vector is sorted
141// by type_id, so a skeleton sharing the name `elf` would win over any handler
142// with a higher id and `--format elf` would silently seal an archive with a
143// handler that extracts nothing. That is the docker/media `image` collision
144// recorded below, and it is why the real handler took id 7 instead of a fresh
145// one. See `.nornir/elf-design.md` §3. It also leaves tier A: it needs an ELF
146// parser and an inflate, so rule P-5 puts it in its own crate.
147
148// rpm (type_id 8) is a REAL handler now — see `plugins::rpm_native::RpmPlugin`
149// (authoritative NEVRA incl. epoch from the header tag table). It is intentionally
150// absent from the skeleton list below; `builtin_handlers` folds in `RpmPlugin`.
151
152// deb (type_id 9) is a REAL handler now — see `plugins::deb_native::DebPlugin`
153// (authoritative control fields from the control tarball: ar → control.tar.* →
154// control). Intentionally absent from the skeleton list; `builtin_handlers` folds
155// in `DebPlugin`.
156
157skeleton_handler! {
158    struct: FlatpakPlugin,
159    name: "flatpak",
160    type_id: 10,
161    aliases: ["flatpakref"],
162    ecosystem: "Flatpak bundles (Flathub)",
163    extensions: [".flatpak", ".flatpakref"],
164    description: "Flatpak single-file application bundles (OSTree)",
165}
166
167// gem (type_id 11) is a REAL handler now — see `plugins::gem_native::GemPlugin`
168// (authoritative name/version/platform from metadata.gz). It is intentionally
169// absent from the skeleton list; `builtin_handlers` folds in `GemPlugin`.
170
171// `image` was an alias here and is NOT any more. It collided with the REAL media
172// handler (`znippy-plugin-media`, type_id 25), which advertises `image` for a
173// picture. `find_handler` takes the first match in `builtin_handlers()` order —
174// `sort_by_key(type_id)` — so docker (12) won over media (25) and `--format
175// image` over a photo tree silently sealed the archive with `pkg_type = 12`, a
176// skeleton that extracts nothing, while `znippy handlers` listed the alias under
177// media. media's `image` was unreachable and docker's was a lie about the
178// content; the collision is now asserted against in
179// `znippy-cli::handlers::tests::every_name_and_alias_in_the_register_is_unique`.
180// `oci` and `container` say "container image" with no ambiguity at all, so
181// nothing is lost here.
182skeleton_handler! {
183    struct: DockerPlugin,
184    name: "docker",
185    type_id: 12,
186    aliases: ["oci", "container"],
187    ecosystem: "OCI / Docker container images",
188    extensions: [".oci", ".docker"],
189    description: "OCI image layouts / docker save tarballs (manifest + layers)",
190}
191
192skeleton_handler! {
193    struct: HelmPlugin,
194    name: "helm",
195    type_id: 13,
196    aliases: ["chart", "k8s"],
197    ecosystem: "Helm charts (Kubernetes / Artifact Hub)",
198    extensions: [".tgz"],
199    description: "Helm chart archives (Chart.yaml + templates)",
200}
201
202// conda (type_id 14) is a REAL handler now — see
203// `plugins::conda_native::CondaPlugin` (authoritative name/version/build/subdir
204// from info/index.json for .tar.bz2). It is intentionally absent from the
205// skeleton list; `builtin_handlers` folds in `CondaPlugin`.
206
207skeleton_handler! {
208    struct: SnapPlugin,
209    name: "snap",
210    type_id: 15,
211    aliases: ["snapcraft", "snapd"],
212    ecosystem: "Snap packages (Snapcraft / Snap Store)",
213    extensions: [".snap"],
214    description: "Snap application packages (squashfs image)",
215}
216
217skeleton_handler! {
218    struct: AppImagePlugin,
219    name: "appimage",
220    type_id: 16,
221    aliases: ["appdir"],
222    ecosystem: "AppImage portable Linux applications",
223    extensions: [".AppImage", ".appimage"],
224    description: "AppImage self-mounting application bundles (ELF + squashfs)",
225}
226
227skeleton_handler! {
228    struct: ComposerPlugin,
229    name: "composer",
230    type_id: 17,
231    aliases: ["php", "packagist"],
232    ecosystem: "PHP / Composer (Packagist)",
233    extensions: [".zip", ".phar"],
234    description: "Composer/PHP packages (zip with composer.json, or PHAR)",
235}
236
237skeleton_handler! {
238    struct: HexPlugin,
239    name: "hex",
240    type_id: 18,
241    aliases: ["elixir", "erlang", "mix"],
242    ecosystem: "Erlang / Elixir (hex.pm)",
243    extensions: [".tar"],
244    description: "Hex packages (tar of metadata + contents.tar.gz)",
245}
246
247skeleton_handler! {
248    struct: CabalPlugin,
249    name: "cabal",
250    type_id: 19,
251    aliases: ["haskell", "hackage"],
252    ecosystem: "Haskell / Cabal (Hackage)",
253    extensions: [".tar.gz"],
254    description: "Cabal source packages (sdist tarball with a .cabal file)",
255}
256
257skeleton_handler! {
258    struct: SwiftPlugin,
259    name: "swift",
260    type_id: 20,
261    aliases: ["swiftpm", "spm"],
262    ecosystem: "Swift / Swift Package Manager",
263    extensions: [".zip"],
264    description: "Swift package archives (Package.swift + sources)",
265}
266
267skeleton_handler! {
268    struct: ParquetPlugin,
269    name: "parquet",
270    type_id: 21,
271    aliases: ["geoparquet", "pq"],
272    ecosystem: "Apache Parquet / GeoParquet columnar data",
273    extensions: [".parquet", ".geoparquet"],
274    description: "Parquet column files (GeoParquet adds a geo metadata key)",
275}
276
277skeleton_handler! {
278    struct: DatasetPlugin,
279    name: "dataset",
280    type_id: 22,
281    aliases: ["hf", "huggingface", "hub"],
282    ecosystem: "ML datasets (Hugging Face Hub / Croissant)",
283    extensions: [".dataset", ".hf"],
284    description: "Versioned ML dataset bundles (data shards + dataset card)",
285}
286
287skeleton_handler! {
288    struct: ArrowPlugin,
289    name: "arrow",
290    type_id: 23,
291    aliases: ["ipc", "feather"],
292    ecosystem: "Apache Arrow IPC / Feather",
293    extensions: [".arrow", ".feather", ".ipc"],
294    description: "Arrow IPC record-batch files (zero-copy columnar)",
295}
296
297skeleton_handler! {
298    struct: GeoJsonPlugin,
299    name: "geojson",
300    type_id: 24,
301    aliases: ["geo", "gis", "shapefile"],
302    ecosystem: "GIS vector data (GeoJSON / Shapefile / GeoPackage)",
303    extensions: [".geojson", ".gpkg", ".shp", ".fgb"],
304    description: "Geospatial vector datasets (features + CRS)",
305}
306
307/// Every skeleton handler, in `type_id` order. The register in `znippy-cli`
308/// folds these in alongside the native handlers.
309pub fn skeleton_handlers() -> Vec<Box<dyn ArchiveTypePlugin>> {
310    vec![
311        Box::new(GoPlugin),
312        Box::new(NugetPlugin),
313        Box::new(FlatpakPlugin),
314        Box::new(DockerPlugin),
315        Box::new(HelmPlugin),
316        Box::new(SnapPlugin),
317        Box::new(AppImagePlugin),
318        Box::new(ComposerPlugin),
319        Box::new(HexPlugin),
320        Box::new(CabalPlugin),
321        Box::new(SwiftPlugin),
322        Box::new(ParquetPlugin),
323        Box::new(DatasetPlugin),
324        Box::new(ArrowPlugin),
325        Box::new(GeoJsonPlugin),
326    ]
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn parse_coords_splits_name_and_version() {
335        assert_eq!(
336            parse_coords("foo/bar-1.2.3.nupkg", &[".nupkg"]),
337            ("bar".to_string(), Some("1.2.3".to_string()))
338        );
339        assert_eq!(
340            parse_coords("lodash-4.17.21.tgz", &[".tgz"]),
341            ("lodash".to_string(), Some("4.17.21".to_string()))
342        );
343    }
344
345    #[test]
346    fn parse_coords_handles_no_version() {
347        assert_eq!(
348            parse_coords("/usr/bin/ls.elf", &[".elf"]),
349            ("ls".to_string(), None)
350        );
351    }
352
353    #[test]
354    fn skeletons_have_unique_sequential_type_ids() {
355        let mut ids: Vec<i8> = skeleton_handlers().iter().map(|h| h.type_id()).collect();
356        let mut sorted = ids.clone();
357        sorted.sort();
358        sorted.dedup();
359        assert_eq!(ids.len(), sorted.len(), "type_ids must be unique");
360        ids.sort();
361        assert_eq!(*ids.first().unwrap(), 4, "skeletons start at type_id 4");
362        assert_eq!(*ids.last().unwrap(), 24);
363    }
364
365    #[test]
366    fn every_skeleton_matches_and_extracts() {
367        for h in skeleton_handlers() {
368            let m = h.meta();
369            assert!(!m.extensions.is_empty(), "{} has no extensions", m.name);
370            let sample = format!("pkg-1.0{}", m.extensions[0]);
371            assert!(h.matches_path(&sample), "{} should match {}", m.name, sample);
372            let row = h.extract_metadata(&sample, &[]).expect("row");
373            assert!(row.fields.contains_key("name"));
374        }
375    }
376}