znippy-common 0.10.1

Core logic and data structures for Znippy, a parallel chunked compression system.
Documentation
//! The **no-package** handler: an arbitrary directory tree, archived as opaque
//! blobs, with no package semantics claimed at all.
//!
//! ## Why a handler for "no handler"
//!
//! `--format` selects exactly one handler and defaults to `rust`, so before this
//! module the only way to archive a tree that is not a package registry was to
//! tell znippy it was a Rust one. That worked — `CargoPlugin::matches_path` is
//! `path.ends_with(".crate")`, so nothing matched and no metadata was extracted —
//! but it worked by accident, and it lied twice over: the archive recorded
//! `pkg_type = 1`, so `znippy decompress --type rust` and every `RustView`
//! consumer would claim a container-registry mirror as crates.io content.
//!
//! A `pkg_type` is a promise about what the rows mean. The honest promise for a
//! tree of image layers, a build output, or a home directory is *none*, and
//! [`GenericPlugin`] is the handler that makes it: its own `type_id`, no
//! extensions claimed, no columns contributed, [`matches_path`] permanently
//! `false` so [`extract_metadata`] is never even called.
//!
//! ## The use case it was added for
//!
//! A container-registry mirror tree — what `talosctl image cache-create` writes,
//! and the same shape a Nexus/registry mirror has:
//!
//! ```text
//! <root>/blob/<sha256-hex>                             1 KB … 500 MB, ALREADY gzip/zstd
//! <root>/manifests/<host>/<repo>/reference/<tag>       small JSON
//! <root>/manifests/<host>/<repo>/digest/<sha256>       small JSON
//! ```
//!
//! Every property of that tree argues for a no-op handler. The blob filenames are
//! bare hex with **no extension**, so no extension-keyed handler could match one
//! anyway; the blobs are content-addressed, so a metadata extractor has nothing
//! to add that the name does not already say; and the payload is already
//! compressed, which is a question for `precompressed`'s magic-byte probe, not
//! for a package parser.
//!
//! Tier A by rule P-5 (`.nornir/plugins-design.md` §f3): it needs `std` and
//! `arrow` and nothing else — in fact it needs less than any other handler here.

use crate::plugin::{ArchiveTypePlugin, ExtensionRow, Claim, HandlerSpec};

/// Archive any tree as opaque blobs. Extracts no metadata, claims no extension,
/// contributes no Arrow column.
///
/// The `type_id` matters even though nothing is extracted: it is what stops a
/// generic archive from being indistinguishable from a `cargo` one, and what
/// makes `znippy decompress --type generic` mean something.
pub struct GenericPlugin;

impl GenericPlugin {
    pub fn new() -> Self {
        Self
    }
}

impl Default for GenericPlugin {
    fn default() -> Self {
        Self::new()
    }
}

/// The generic handler. `type_id` 26 is the first free discriminant above the
/// skeletons (4-24) and media (25), below the out-of-crate handlers (40-42).
pub static GENERIC_SPEC: HandlerSpec = HandlerSpec {
    name: "generic",
    // `none` is what a user types when they mean "no package semantics";
    // `raw`/`plain`/`blob` are what they type when they mean the same thing
    // about the payload.
    aliases: &["none", "raw", "plain", "blob"],
    type_id: 26,
    ecosystem: "none — an arbitrary file tree",
    // Deliberately empty: claiming an extension is exactly the promise this
    // handler exists NOT to make — and with none, the derived `matches_path` is
    // permanently `false`, so `extract_metadata` is never reached.
    extensions: &[],
    claim: Claim::Extensions,
    description: "Any directory tree as opaque blobs — no metadata extracted, \
                  no package type claimed. The honest choice for a \
                  container-registry mirror, a build output or a data dump.",
    fields: &[],
    commands: &[],
};

impl ArchiveTypePlugin for GenericPlugin {
    fn spec(&self) -> &HandlerSpec {
        &GENERIC_SPEC
    }

    /// Unreachable through the registry (nothing matches),
    /// and `None` if a caller invokes it directly: there is no metadata to claim.
    fn extract_metadata(&self, _path: &str, _data: &[u8]) -> Option<ExtensionRow> {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The handler's contract is what it *refuses* to do, so that is what is
    /// asserted. Every path shape from the registry-mirror tree — bare hex with no
    /// extension, a `host:port` segment, an `@` in a repo name, a `+` in a tag, a
    /// non-ASCII tag — must be claimed by nothing and extract nothing.
    #[test]
    fn nothing_is_claimed_and_nothing_is_extracted() {
        let p = GenericPlugin::new();
        for path in [
            "blob/9f2c1e0b7a4d3f8c6e5a2b9d0c7f4e1a8b3d6c9e2f0a1b2c3d4e5f6a7b8c9d0e",
            "manifests/registry.k8s.io:5000/kube-apiserver/reference/v1.30.0+build1",
            "manifests/ghcr.io/siderolabs@sha256/digest/sha256:abcdef",
            "manifests/ünïcödé-täg.json",
            // And the things a package handler WOULD claim: generic must not.
            "serde-1.0.200.crate",
            "guava-33.0.jar",
        ] {
            assert!(!p.matches_path(path), "generic must claim nothing: {path}");
            assert!(p.extract_metadata(path, b"whatever").is_none());
        }
        assert!(p.schema_fields().is_empty(), "no columns means no schema change");
        assert!(p.meta().extensions.is_empty(), "claiming an extension is the promise we avoid");
    }

    /// The aliases are the user-facing surface — a `--format none` that resolves
    /// to nothing is the bug this guards. Resolution itself lives in
    /// `znippy-cli::handlers::find_handler`; what is assertable here is that the
    /// names are declared and lower-case (that lookup is `eq_ignore_ascii_case`,
    /// but a mixed-case alias in the register would still read wrong in
    /// `znippy handlers`).
    #[test]
    fn the_no_package_aliases_are_declared() {
        let m = GenericPlugin::new().meta();
        for want in ["none", "raw", "plain", "blob"] {
            assert!(m.aliases.iter().any(|a| a == want), "missing alias {want}");
        }
        assert!(m.aliases.iter().all(|a| a.chars().all(|c| c.is_ascii_lowercase())));
        assert_eq!(m.name, "generic");
        assert_eq!(m.type_id, 26);
    }
}