znippy-cli/src/handlers.rs
//! Package-handler register + discovery.
//!
//! Rust has no runtime reflection / classpath scan, so the register is the
//! compiled-in catalog [`builtin_handlers`]. The tool autodiscovers it at
//! runtime via each handler's `meta()` — that is what `znippy handlers` lists
//! and what `--format <name>` selects from. Adding a native handler = one line
//! in [`builtin_handlers`].
//!
//! One znippy archive carries one package type, so `--format` selects exactly
//! one handler. Combining/merging or signing archives, and config-driven
//! orchestration of many handlers, are a higher layer — that layer is the
//! sibling `holger` repo, not znippy.
//!
//! Where a handler's *source* lives — in `znippy-common/src/plugins/native/` or in its
//! own `znippy-plugin-*` crate — is decided by rule **P-5** in
//! `.nornir/plugins-design.md` §f3. Both tiers register right here and are
//! indistinguishable to `znippy handlers`.
use anyhow::{Result, anyhow};
use znippy_common::plugin::ArchiveTypePlugin;
use znippy_common::plugins::cargo_native::CargoPlugin;
use znippy_common::plugins::conda_native::CondaPlugin;
use znippy_common::plugins::deb_native::DebPlugin;
use znippy_common::plugins::gem_native::GemPlugin;
use znippy_common::plugins::generic_native::GenericPlugin;
use znippy_common::plugins::npm_native::NpmPlugin;
use znippy_common::plugins::rpm_native::RpmPlugin;
use znippy_plugin_elf::NativeElfPlugin;
use znippy_plugin_git::NativeGitPlugin;
use znippy_plugin_maven::NativeMavenPlugin;
use znippy_plugin_media::NativeMediaPlugin;
use znippy_plugin_python::NativePythonPlugin;
use znippy_plugin_rust_toolchain::NativeRustToolchainPlugin;
use znippy_plugin_skidbladnir::NativeSkidbladnirPlugin;
/// The plugin register: every natively-compiled handler, sorted by `type_id`
/// (so cargo/rust = 1 lists first). To add a handler, add its constructor here.
pub fn builtin_handlers() -> Vec<Box<dyn ArchiveTypePlugin>> {
let mut handlers: Vec<Box<dyn ArchiveTypePlugin>> = vec![
Box::new(CargoPlugin::new()),
Box::new(NativePythonPlugin),
// ELF (7) — a REAL handler since the `elf` skeleton was promoted out of
// znippy-common. Every fact comes from the binary, never the file name.
Box::new(NativeElfPlugin::new()),
Box::new(NativeMavenPlugin),
Box::new(NpmPlugin),
Box::new(GemPlugin),
Box::new(CondaPlugin),
Box::new(RpmPlugin),
Box::new(DebPlugin),
Box::new(NativeMediaPlugin::new()),
Box::new(NativeSkidbladnirPlugin::new()),
Box::new(NativeRustToolchainPlugin::new()),
Box::new(NativeGitPlugin::new()),
// The no-package handler. Registered exactly like the others — the point
// of `--format generic` is that "no package semantics" is a first-class
// selection, not the absence of one.
Box::new(GenericPlugin::new()),
];
handlers.extend(znippy_common::plugins::skeletons::skeleton_handlers());
handlers.sort_by_key(|h| h.type_id());
handlers
}
/// Look up a handler by canonical name or alias (case-insensitive).
pub fn find_handler(query: &str) -> Result<Box<dyn ArchiveTypePlugin>> {
let q = query.to_ascii_lowercase();
builtin_handlers()
.into_iter()
.find(|h| {
let m = h.meta();
m.name.eq_ignore_ascii_case(&q) || m.aliases.iter().any(|a| a.eq_ignore_ascii_case(&q))
})
.ok_or_else(|| {
anyhow!("unknown handler '{}'; run `znippy handlers` to list available handlers", query)
})
}
/// Print the autodiscovered register (what `znippy handlers` shows).
pub fn print_catalog() {
let handlers = builtin_handlers();
println!("Package handlers ({}):\n", handlers.len());
for h in &handlers {
let m = h.meta();
let aliases = if m.aliases.is_empty() {
String::new()
} else {
format!(" (aliases: {})", m.aliases.join(", "))
};
println!(" • {}{} [type_id {}]", m.name, aliases, m.type_id);
if !m.ecosystem.is_empty() {
println!(" ecosystem: {}", m.ecosystem);
}
if !m.extensions.is_empty() {
println!(" extensions: {}", m.extensions.join(" "));
}
if !m.description.is_empty() {
println!(" {}", m.description);
}
for c in &m.commands {
println!(" cmd `{} {}` — {}", m.name, c.name, c.about);
}
}
}
/// The register's own integrity.
///
/// `generic_native.rs` documents its `type_id` as "asserted against the whole
/// register by `znippy-cli`'s `handlers` tests" — a claim this module could not
/// keep, because it carried no tests at all. A `type_id` is the on-disk
/// discriminant an archive is sealed with and `--format <name>` is the only way a
/// user reaches a handler, so a duplicate in either space is a silent
/// mis-selection: `find_handler` returns the FIRST match in `builtin_handlers()`
/// order, which is `sort_by_key(type_id)` order — stable, but arbitrary between
/// two handlers that collide.
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use znippy_common::plugin::Claim;
/// A handler's advertised extensions are a promise `matches_path` keeps:
/// every one of them is claimed, and the meta record is the spec's. A
/// handler whose spec says `Claim::Layout` claims by layout, so each of its
/// extensions must be claimed for at least one in-layout member.
#[test]
fn every_advertised_extension_is_claimed() {
for h in builtin_handlers() {
let m = h.meta();
assert_eq!((m.name.as_str(), m.type_id), (h.name(), h.type_id()));
if let Claim::Layout(_) = h.spec().claim {
for ext in &m.extensions {
assert!(
probe_paths().iter().any(|p| p.ends_with(ext.as_str()) && h.matches_path(p)),
"{} advertises {ext} but claims no bundle member with it",
m.name
);
}
continue;
}
for ext in &m.extensions {
let sample = format!("dir/pkg-1.0.0{ext}");
assert!(h.matches_path(&sample), "{} advertises {ext} but does not claim {sample}", m.name);
}
}
}
/// Paths every handler is asked about: each advertised extension at the root
/// and in upper case, the airgap-bundle layouts, git's loose and packed
/// object names, and plain files no package handler should want.
fn probe_paths() -> Vec<String> {
let mut probes: Vec<String> = Vec::new();
for h in builtin_handlers() {
for ext in &h.meta().extensions {
probes.push(format!("dir/pkg-1.0.0{ext}"));
probes.push(format!("dir/PKG-1.0.0{}", ext.to_ascii_uppercase()));
for root in ["dbdump/pg", "app", "njordconf", "infra/talos", "manifest", "toolchain/lib", "vendor/serde-1.0.0"] {
probes.push(format!("{root}/member{ext}"));
}
}
}
let oid = "0123456789abcdef0123456789abcdef01234567";
probes.extend([
oid.to_string(),
format!("objects/pack/pack-{oid}.pack"),
format!("objects/pack/pack-{oid}.idx"),
"lakespec.json".into(),
"secrets/db.age".into(),
"toolchain/bin/rustc".into(),
"README".into(),
"dir/blob.bin".into(),
]);
probes
}
/// `matches_path` is the spec's claim, never a second opinion: for every
/// handler and every probe the trait method and `spec().claims` agree. No
/// handler is exempt by name; a handler that claims by layout or by content
/// says so in its `HandlerSpec`.
#[test]
fn matches_path_is_the_spec_claim() {
for h in builtin_handlers() {
for p in probe_paths() {
assert_eq!(
h.matches_path(&p),
h.spec().claims(&p),
"{}: matches_path and its HandlerSpec disagree on {p}",
h.name()
);
}
}
}
/// No two handlers may share a `type_id`. It is the discriminant written into
/// every row of a sealed archive; two handlers claiming one means an archive
/// cannot say which of them wrote it.
#[test]
fn every_type_id_in_the_register_is_unique() {
let mut seen: HashMap<i8, String> = HashMap::new();
for h in builtin_handlers() {
let m = h.meta();
if let Some(prev) = seen.insert(m.type_id, m.name.clone()) {
panic!("type_id {} claimed by both '{}' and '{}'", m.type_id, prev, m.name);
}
}
}
/// No two handlers may share a selector. `find_handler` takes the first match,
/// so a duplicated name or alias silently routes `--format x` to whichever of
/// them sorts lower by `type_id`.
#[test]
fn every_name_and_alias_in_the_register_is_unique() {
let mut seen: HashMap<String, String> = HashMap::new();
for h in builtin_handlers() {
let m = h.meta();
for sel in std::iter::once(&m.name).chain(m.aliases.iter()) {
let key = sel.to_ascii_lowercase();
if let Some(prev) = seen.insert(key, m.name.clone()) {
panic!("selector '{}' claimed by both '{}' and '{}'", sel, prev, m.name);
}
}
}
}
/// Every declared selector must actually resolve, and resolve to the handler
/// that declared it. A handler can advertise an alias in `znippy handlers` that
/// `--format` then rejects — nothing but this connects the two.
#[test]
fn every_declared_selector_resolves_back_to_its_own_handler() {
for h in builtin_handlers() {
let m = h.meta();
for sel in std::iter::once(&m.name).chain(m.aliases.iter()) {
let got = find_handler(sel)
.unwrap_or_else(|e| panic!("'{sel}' is advertised by '{}' but: {e}", m.name));
assert_eq!(
got.type_id(),
m.type_id,
"--format {sel} is advertised by '{}' (type_id {}) but resolves to \
'{}' (type_id {})",
m.name,
m.type_id,
got.name(),
got.type_id(),
);
}
// Case is not part of the selector: `--format RUST` must work.
assert_eq!(find_handler(&m.name.to_ascii_uppercase()).unwrap().type_id(), m.type_id);
}
}
/// The no-package handler specifically: `--format generic` and each of its four
/// aliases must reach type_id 26. This is the claim `generic_native.rs` names
/// this module for.
#[test]
fn the_no_package_handler_is_reachable_by_every_word_for_it() {
for sel in ["generic", "none", "raw", "plain", "blob", "GENERIC", "None"] {
let h = find_handler(sel).unwrap_or_else(|e| panic!("--format {sel}: {e}"));
assert_eq!(h.name(), "generic", "--format {sel} reached '{}'", h.name());
assert_eq!(h.type_id(), 26, "--format {sel} reached type_id {}", h.type_id());
}
// And it claims nothing, which is the property the whole handler exists for.
let g = find_handler("generic").unwrap();
assert!(!g.matches_path("blob/9f2c1e0b7a4d3f8c"), "generic must claim no path");
assert!(!g.matches_path("serde-1.0.200.crate"), "generic must not steal cargo's");
}
/// An unknown format is refused with a message that says where to look, not a
/// silent fall back to the `rust` default — that default is exactly what makes
/// a mirror tree get sealed as crates.io content.
#[test]
fn an_unknown_format_is_refused_and_says_where_to_look() {
// `.err()` rather than `.unwrap_err()`: the Ok type is `Box<dyn
// ArchiveTypePlugin>` and `unwrap_err` would demand `Debug` on the trait
// object, which no handler has a reason to implement.
let e = find_handler("nexus")
.err()
.expect("an unknown --format must be refused, never silently defaulted")
.to_string();
assert!(e.contains("nexus"), "the message must name what was asked for: {e}");
assert!(e.contains("znippy handlers"), "the message must name the door: {e}");
}
}
#[cfg(test)]
mod elf_register_tests {
use super::*;
/// Regression for the collision this handler was born out of.
///
/// `elf` used to be a filename-only SKELETON at `type_id` 7. Because
/// [`find_handler`] takes the first match in [`builtin_handlers`] order, and
/// that vector is sorted by `type_id`, a real ELF handler given a FRESH id
/// would have sorted behind the stub: `--format elf` would have sealed the
/// archive with a handler that declares no columns and extracts nothing,
/// exactly as `--format image` once resolved to docker (12) instead of media
/// (25). The stub is deleted and the real handler took 7.
#[test]
fn format_elf_selects_the_real_handler_and_nothing_else_answers_to_it() {
let h = find_handler("elf").expect("elf must resolve");
assert_eq!(h.type_id(), znippy_plugin_elf::ELF_TYPE_ID);
assert!(
!h.schema_fields().is_empty(),
"the skeleton declared no columns; the real handler must"
);
assert_eq!(h.schema_fields().len(), 22);
for name in ["elf", "binary", "so"] {
let matches: Vec<i8> = builtin_handlers()
.iter()
.filter(|h| {
let m = h.meta();
m.name.eq_ignore_ascii_case(name)
|| m.aliases.iter().any(|a| a.eq_ignore_ascii_case(name))
})
.map(|h| h.type_id())
.collect();
assert_eq!(
matches,
vec![znippy_plugin_elf::ELF_TYPE_ID],
"'{name}' must resolve to exactly one handler"
);
}
}
}