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