znippy_common/plugins/native/generic_native.rs
1//! The **no-package** handler: an arbitrary directory tree, archived as opaque
2//! blobs, with no package semantics claimed at all.
3//!
4//! ## Why a handler for "no handler"
5//!
6//! `--format` selects exactly one handler and defaults to `rust`, so before this
7//! module the only way to archive a tree that is not a package registry was to
8//! tell znippy it was a Rust one. That worked — `CargoPlugin::matches_path` is
9//! `path.ends_with(".crate")`, so nothing matched and no metadata was extracted —
10//! but it worked by accident, and it lied twice over: the archive recorded
11//! `pkg_type = 1`, so `znippy decompress --type rust` and every `RustView`
12//! consumer would claim a container-registry mirror as crates.io content.
13//!
14//! A `pkg_type` is a promise about what the rows mean. The honest promise for a
15//! tree of image layers, a build output, or a home directory is *none*, and
16//! [`GenericPlugin`] is the handler that makes it: its own `type_id`, no
17//! extensions claimed, no columns contributed, [`matches_path`] permanently
18//! `false` so [`extract_metadata`] is never even called.
19//!
20//! ## The use case it was added for
21//!
22//! A container-registry mirror tree — what `talosctl image cache-create` writes,
23//! and the same shape a Nexus/registry mirror has:
24//!
25//! ```text
26//! <root>/blob/<sha256-hex> 1 KB … 500 MB, ALREADY gzip/zstd
27//! <root>/manifests/<host>/<repo>/reference/<tag> small JSON
28//! <root>/manifests/<host>/<repo>/digest/<sha256> small JSON
29//! ```
30//!
31//! Every property of that tree argues for a no-op handler. The blob filenames are
32//! bare hex with **no extension**, so no extension-keyed handler could match one
33//! anyway; the blobs are content-addressed, so a metadata extractor has nothing
34//! to add that the name does not already say; and the payload is already
35//! compressed, which is a question for `precompressed`'s magic-byte probe, not
36//! for a package parser.
37//!
38//! Tier A by rule P-5 (`.nornir/plugins-design.md` §f3): it needs `std` and
39//! `arrow` and nothing else — in fact it needs less than any other handler here.
40
41use crate::plugin::{ArchiveTypePlugin, ExtensionRow, HandlerMeta};
42
43/// Archive any tree as opaque blobs. Extracts no metadata, claims no extension,
44/// contributes no Arrow column.
45///
46/// The `type_id` matters even though nothing is extracted: it is what stops a
47/// generic archive from being indistinguishable from a `cargo` one, and what
48/// makes `znippy decompress --type generic` mean something.
49pub struct GenericPlugin;
50
51impl GenericPlugin {
52 pub fn new() -> Self {
53 Self
54 }
55}
56
57impl Default for GenericPlugin {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl ArchiveTypePlugin for GenericPlugin {
64 fn name(&self) -> &str {
65 "generic"
66 }
67
68 /// 26 — the first free discriminant above the skeletons (4-24) and media (25),
69 /// and below the out-of-crate handlers (40-42). Asserted against the whole
70 /// register by `znippy-cli`'s `handlers` tests rather than restated here.
71 fn type_id(&self) -> i8 {
72 26
73 }
74
75 fn meta(&self) -> HandlerMeta {
76 HandlerMeta {
77 name: "generic".into(),
78 // `none` is what a user types when they mean "no package semantics";
79 // `raw`/`plain`/`blob` are what they type when they mean the same
80 // thing about the payload. All four resolve here so nobody has to
81 // guess which word this tool chose.
82 aliases: vec!["none".into(), "raw".into(), "plain".into(), "blob".into()],
83 type_id: 26,
84 ecosystem: "none — an arbitrary file tree".into(),
85 // Deliberately empty: claiming an extension is exactly the promise
86 // this handler exists NOT to make.
87 extensions: Vec::new(),
88 description: "Any directory tree as opaque blobs — no metadata extracted, \
89 no package type claimed. The honest choice for a \
90 container-registry mirror, a build output or a data dump."
91 .into(),
92 commands: Vec::new(),
93 }
94 }
95
96 /// Permanently `false`, which is the whole point: `PluginRegistry::extract`
97 /// gates on this, so [`extract_metadata`](Self::extract_metadata) is never
98 /// reached and the compress path pays nothing per file.
99 fn matches_path(&self, _path: &str) -> bool {
100 false
101 }
102
103 /// Unreachable through the registry (see [`matches_path`](Self::matches_path)),
104 /// and `None` if a caller invokes it directly: there is no metadata to claim.
105 fn extract_metadata(&self, _path: &str, _data: &[u8]) -> Option<ExtensionRow> {
106 None
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 /// The handler's contract is what it *refuses* to do, so that is what is
115 /// asserted. Every path shape from the registry-mirror tree — bare hex with no
116 /// extension, a `host:port` segment, an `@` in a repo name, a `+` in a tag, a
117 /// non-ASCII tag — must be claimed by nothing and extract nothing.
118 #[test]
119 fn nothing_is_claimed_and_nothing_is_extracted() {
120 let p = GenericPlugin::new();
121 for path in [
122 "blob/9f2c1e0b7a4d3f8c6e5a2b9d0c7f4e1a8b3d6c9e2f0a1b2c3d4e5f6a7b8c9d0e",
123 "manifests/registry.k8s.io:5000/kube-apiserver/reference/v1.30.0+build1",
124 "manifests/ghcr.io/siderolabs@sha256/digest/sha256:abcdef",
125 "manifests/ünïcödé-täg.json",
126 // And the things a package handler WOULD claim: generic must not.
127 "serde-1.0.200.crate",
128 "guava-33.0.jar",
129 ] {
130 assert!(!p.matches_path(path), "generic must claim nothing: {path}");
131 assert!(p.extract_metadata(path, b"whatever").is_none());
132 }
133 assert!(p.schema_fields().is_empty(), "no columns means no schema change");
134 assert!(p.meta().extensions.is_empty(), "claiming an extension is the promise we avoid");
135 }
136
137 /// The aliases are the user-facing surface — a `--format none` that resolves
138 /// to nothing is the bug this guards. Resolution itself lives in
139 /// `znippy-cli::handlers::find_handler`; what is assertable here is that the
140 /// names are declared and lower-case (that lookup is `eq_ignore_ascii_case`,
141 /// but a mixed-case alias in the register would still read wrong in
142 /// `znippy handlers`).
143 #[test]
144 fn the_no_package_aliases_are_declared() {
145 let m = GenericPlugin::new().meta();
146 for want in ["none", "raw", "plain", "blob"] {
147 assert!(m.aliases.iter().any(|a| a == want), "missing alias {want}");
148 }
149 assert!(m.aliases.iter().all(|a| a.chars().all(|c| c.is_ascii_lowercase())));
150 assert_eq!(m.name, "generic");
151 assert_eq!(m.type_id, 26);
152 }
153}