Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy

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"
            );
        }
    }
}