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

nordisk/holger

traits/src/lib.rs

//! Foundational contracts and shared types for holger — the seam every backend,
//! upstream, and connector implements, plus the coordinate/format vocabulary they
//! all speak. This crate deliberately sits at the bottom of the dep graph and
//! stays thin: it depends on `znippy-common` (for the source-of-truth type ids)
//! but NOT on the sibling `znippy-plugin-*` crates.
//!
//! The three server-side seams:
//!   * [`RepositoryBackendTrait`] — a repo that serves an ecosystem's native index
//!     (see [`backend`]): the core six, ONE [`Caps`] answer, and the methods the
//!     caps unlock. A wrapper implements [`Decorator`] and forwards by construction.
//!   * [`Upstream`] — a read-oriented `RepositoryBackendTrait` that a proxy
//!     reads through on a miss and asks what the origin published.
//!   * [`HolgerObject`] — the transport-agnostic facade (in-process engine vs gRPC
//!     client, one `Arc<dyn>` handle), the Rust mirror of `holger.proto`.
//!
//! Agent-side, [`ConnectorTrait`] moves assets between a Source and Target; its
//! [`UpstreamAuth`] cousin here is the *server-side* auth enum, distinct from the
//! agent's `ConnectorAuth`.
//!
//! GOTCHA: znippy is the single source of truth for [`ArtifactFormat::znippy_type_id`]
//! discriminants — most are derived by calling the znippy handler's `type_id()`
//! directly so they can't drift, but `Pip` (2) and `Maven3` (3) stay hardcoded
//! literals because their native handlers live in the plugin crates this crate
//! won't depend on. The `znippy_type_ids_match_*` tests guard those two.

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

/// The artifact byte buffer every backend hands back. Re-exported so a
/// backend spells `traits::Bytes` and no repository manifest has to name the
/// crate. See `.nornir/vec-to-bytes-migration.md`.
// znippy-common, re-exported so a consumer binds the SAME copy this crate's
// `ZnippyReader` signatures mean. holger's workspace has resolved TWO of them
// before now (a registry copy for `traits` and a path copy for a feature
// elsewhere), and the failure mode is a type that implements `ZnippyReader` and
// still will not go where one is wanted.
pub use znippy_common;

pub use bytes::Bytes;

/// anyhow, re-exported for [`znippy_read_only!`]'s expansion.
pub use anyhow;

/// The repository backend seam: [`RepositoryBackendTrait`], [`Caps`],
/// [`Decorator`] and the [`backend::conformance`] check that holds caps and
/// methods against each other.
pub mod backend;
pub use backend::{
    chunks_of, collect_body, is_unsupported, unsupported, Access, Archive, ArchiveInfo, Cap, Caps,
    ChunkIter, Decorator, HttpBytesReply, HttpReply, RepositoryBackendTrait, StreamedGet,
    Unsupported, READER_PREALLOC_CAP,
};

/// What every read-only `znippy-*` backend shares.
pub mod znippy_base;
pub use znippy_base::{sha256_hex, DocMemo, ZnippyBase};

/// holger's own pin on the znippy on-disk archive format it will parse — the
/// reader-side gate that refuses a *newer*-than-known archive with a typed,
/// request-facing error instead of letting an unknown layout reach the parser.
/// Lives here so every `znippy-*` repository backend (all of which already depend
/// on this crate) consumes the one implementation.
pub mod znippy_format;

/// **MIGRATE** — the vocabulary for the ONE-TIME move of an existing Nexus or
/// Artifactory registry into holger. The format map, the repository creation plan
/// with its collision refusal, and the [`migrate::MigrationSink`] seam the byte
/// mover writes through. It lives here because the agent CLI and the server's
/// `MigrationService` must classify a repository the SAME way, and a classifier
/// that exists twice will disagree once.
pub mod migrate;

/// The shared test kit. Dev-dependency only (`features = ["test-support"]`).
#[cfg(feature = "test-support")]
pub mod test_support;

pub use znippy_format::{
    ensure_supported_format, recorded_format_version, ArchiveFormatError, FormatGate,
    MAX_SUPPORTED_ZNIPPY_FORMAT, ZNIPPY_FORMAT_VERSION_KEY,
};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct ArtifactId {
    pub namespace: Option<String>,
    pub name: String,
    pub version: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "lowercase")]
pub enum ArtifactFormat {
    Maven3,
    Pip,
    Rust,
    Go,
    Nuget,
    Npm,
    Gem,
    Deb,
    Rpm,
    Helm,
    Docker,
    Conda,
    Composer,
    /// znippy's own native package format (a merged/signed `.znippy` package
    /// served as the artifact itself, rather than a backing store for some
    /// other ecosystem). The one format that is znippy all the way down.
    Znippy,
    Raw,
}

impl ArtifactFormat {
    /// Canonical znippy `pkg_type` / DenseUnion discriminant for this format.
    ///
    /// znippy is the **single source of truth** for these numbers (see
    /// `znippy-common`'s native + skeleton handlers). Every variant whose handler
    /// is reachable from `znippy-common` calls that handler's
    /// [`ArchiveTypePlugin::type_id`] directly — including `Rust`, whose native
    /// `cargo_native::CargoPlugin` lives in `znippy-common` — so the two can never
    /// drift (the test below asserts the equality as data). Only `Pip` (2) and
    /// `Maven3` (3) remain literals: their native handlers live in the sibling
    /// `znippy-plugin-python` / `znippy-plugin-maven` crates, which this
    /// foundational `traits` crate deliberately does NOT depend on (keeping the
    /// base dep graph thin). The `znippy_type_ids_match_*` tests assert those two
    /// literals still equal znippy's published discriminants.
    pub fn znippy_type_id(&self) -> i8 {
        use znippy_common::plugin::ArchiveTypePlugin;
        use znippy_common::plugins::{cargo_native, skeletons};
        match self {
            // Native cargo handler lives in znippy-common — derive, don't hardcode.
            ArtifactFormat::Rust => cargo_native::CargoPlugin::new().type_id(),
            ArtifactFormat::Pip => 2,
            ArtifactFormat::Maven3 => 3,
            ArtifactFormat::Go => skeletons::GoPlugin.type_id(),
            ArtifactFormat::Nuget => skeletons::NugetPlugin.type_id(),
            // npm is a real native handler now (`plugins::npm_native`), not a skeleton.
            ArtifactFormat::Npm => znippy_common::plugins::npm_native::NpmPlugin.type_id(),
            // rpm is a real native handler now (`plugins::rpm_native`), not a skeleton.
            ArtifactFormat::Rpm => znippy_common::plugins::rpm_native::RpmPlugin.type_id(),
            // deb is a real native handler now (`plugins::deb_native`), not a skeleton.
            ArtifactFormat::Deb => znippy_common::plugins::deb_native::DebPlugin.type_id(),
            // gem is a real native handler now (`plugins::gem_native`), not a skeleton.
            ArtifactFormat::Gem => znippy_common::plugins::gem_native::GemPlugin.type_id(),
            ArtifactFormat::Docker => skeletons::DockerPlugin.type_id(),
            ArtifactFormat::Helm => skeletons::HelmPlugin.type_id(),
            // conda is a real native handler now (`plugins::conda_native`), not a skeleton.
            ArtifactFormat::Conda => znippy_common::plugins::conda_native::CondaPlugin.type_id(),
            ArtifactFormat::Composer => skeletons::ComposerPlugin.type_id(),
            // znippy's own container format — not one of the ecosystem handler
            // discriminants, so it gets a dedicated reserved id.
            ArtifactFormat::Znippy => 100,
            ArtifactFormat::Raw => 0,
        }
    }

    /// Parse a Nexus/Artifactory/CLI format string into an `ArtifactFormat`.
    pub fn from_format_str(s: &str) -> Option<Self> {
        Some(match s.to_lowercase().as_str() {
            "maven2" | "maven" | "maven3" | "java" => ArtifactFormat::Maven3,
            "pip" | "pypi" | "python" => ArtifactFormat::Pip,
            "rust" | "cargo" | "crates.io" | "cratesio" => ArtifactFormat::Rust,
            "go" | "golang" => ArtifactFormat::Go,
            "nuget" | "dotnet" => ArtifactFormat::Nuget,
            "npm" | "node" => ArtifactFormat::Npm,
            "gem" | "ruby" | "rubygems" => ArtifactFormat::Gem,
            "deb" | "debian" | "apt" => ArtifactFormat::Deb,
            "rpm" | "yum" | "dnf" => ArtifactFormat::Rpm,
            "helm" | "chart" => ArtifactFormat::Helm,
            "docker" | "oci" => ArtifactFormat::Docker,
            "conda" | "anaconda" => ArtifactFormat::Conda,
            "composer" | "php" => ArtifactFormat::Composer,
            "znippy" | "snippy" => ArtifactFormat::Znippy,
            // Artifactory calls this repo class "Generic"; holger calls it Raw.
            "raw" | "generic" => ArtifactFormat::Raw,
            _ => return None,
        })
    }

    /// **Where this format's backend actually LIVES.** The answer to "I know the
    /// capability by one name — which crate implements it?"
    ///
    /// # Why this exists (a real, repeated cost)
    ///
    /// A capability in holger routinely carries FOUR different names, and the one
    /// in the docs is usually not the one in the source tree. PyPI is the worst
    /// case, and it has already caused two wrong answers and a nearly-started
    /// 677-LOC re-implementation of code that was sitting right there:
    ///
    /// | where | the name it uses |
    /// |---|---|
    /// | README / design docs / the UI | **PyPI** |
    /// | `ron_repo_type` an operator writes | `pip` |
    /// | this enum + the backend struct | `Pip` / `PipRepoZnippy` |
    /// | the crate + its directory | **`znippy-python`** / `holger-znippy-python-repository` |
    ///
    /// So `grep -ri pypi server/` returns **nothing from the implementation**, and
    /// `grep -ri python` finds only a directory. [`from_format_str`] already maps
    /// the spoken aliases onto the variant, but it stops there — it never says
    /// where the code is. That last hop is the one that was missing, so this is it.
    ///
    /// `crate_dir` is relative to `server/repository/`, or empty for a format whose
    /// backend is not a separate repository crate (`Raw` lives in
    /// `server/lib/src/raw.rs`). `also` names a SECOND backend for the same format
    /// where one exists (a file-backed peer of the znippy-backed one).
    ///
    /// The mapping is asserted against the real directory layout by
    /// `every_format_names_a_crate_that_exists`, so a renamed crate breaks the
    /// build instead of quietly re-opening the trap.
    ///
    /// [`from_format_str`]: ArtifactFormat::from_format_str
    pub fn home(&self) -> FormatHome {
        // (config_key, aliases, crate_dir, package, also)
        let (config_key, aliases, crate_dir, package, also) = match self {
            ArtifactFormat::Maven3 => (
                "maven3",
                &["maven", "maven2", "java"][..],
                "znippy-maven",
                "holger-znippy-maven-repository",
                None,
            ),
            // The four-name case documented above: docs say PyPI, config says pip,
            // code says Pip, the crate says python.
            ArtifactFormat::Pip => (
                "pip",
                &["pypi", "python"][..],
                "znippy-python",
                "holger-znippy-python-repository",
                None,
            ),
            // Note the word order flips between dir and package here too:
            // `znippy-rust/` is `holger-rust-znippy-repository`.
            ArtifactFormat::Rust => (
                "rust",
                &["cargo", "crates.io", "cratesio"][..],
                "znippy-rust",
                "holger-rust-znippy-repository",
                Some(("file-rust", "holger-rust-file-repository")),
            ),
            ArtifactFormat::Go => (
                "go",
                &["golang"][..],
                "znippy-go",
                "holger-znippy-go-repository",
                None,
            ),
            ArtifactFormat::Nuget => (
                "nuget",
                &["dotnet"][..],
                "znippy-nuget",
                "holger-znippy-nuget-repository",
                None,
            ),
            ArtifactFormat::Npm => (
                "npm",
                &["node"][..],
                "znippy-npm",
                "holger-znippy-npm-repository",
                None,
            ),
            ArtifactFormat::Gem => (
                "gem",
                &["ruby", "rubygems"][..],
                "znippy-gem",
                "holger-znippy-gem-repository",
                None,
            ),
            ArtifactFormat::Deb => (
                "deb",
                &["debian", "apt"][..],
                "znippy-deb",
                "holger-znippy-deb-repository",
                None,
            ),
            ArtifactFormat::Rpm => (
                "rpm",
                &["yum", "dnf"][..],
                "znippy-rpm",
                "holger-znippy-rpm-repository",
                None,
            ),
            ArtifactFormat::Helm => (
                "helm",
                &["chart"][..],
                "znippy-helm",
                "holger-znippy-helm-repository",
                None,
            ),
            // `oci` is the config alias AND a second, file-backed implementation.
            ArtifactFormat::Docker => (
                "docker",
                &["oci"][..],
                "znippy-docker",
                "holger-znippy-docker-repository",
                Some(("file-oci", "holger-file-oci-repository")),
            ),
            ArtifactFormat::Conda => (
                "conda",
                &["anaconda"][..],
                "znippy-conda",
                "holger-znippy-conda-repository",
                None,
            ),
            ArtifactFormat::Composer => (
                "composer",
                &["php"][..],
                "znippy-composer",
                "holger-znippy-composer-repository",
                None,
            ),
            // znippy-as-the-artifact. The crate is `znippy-package`, not `znippy`.
            ArtifactFormat::Znippy => (
                "znippy",
                &["snippy"][..],
                "znippy-package",
                "holger-znippy-package-repository",
                None,
            ),
            // Not a repository crate: the backend is `server/lib/src/raw.rs`.
            ArtifactFormat::Raw => ("raw", &["generic"][..], "", "holger-server-lib", None),
        };
        FormatHome {
            format: self.clone(),
            config_key,
            aliases,
            crate_dir,
            package,
            also,
        }
    }
}

/// Where one [`ArtifactFormat`]'s backend lives, plus every other name the same
/// capability is called by. See [`ArtifactFormat::home`] for why this is needed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormatHome {
    /// The format itself.
    pub format: ArtifactFormat,
    /// The canonical `ron_repo_type` string an operator writes in a config.
    pub config_key: &'static str,
    /// The OTHER names this capability is known by — docs, UI labels, ecosystem
    /// spellings. Searching any of them should lead here.
    pub aliases: &'static [&'static str],
    /// Directory under `server/repository/`, or `""` when the backend is not a
    /// separate repository crate.
    pub crate_dir: &'static str,
    /// The cargo package name of that crate.
    pub package: &'static str,
    /// A second `(crate_dir, package)` backend for the same format, when one
    /// exists (e.g. the file-backed peer of a znippy-backed repo).
    pub also: Option<(&'static str, &'static str)>,
}

impl FormatHome {
    /// Every name this capability answers to — the config key first, then the
    /// aliases. Handy for a "did you mean?" or a docs table.
    pub fn all_names(&self) -> Vec<&'static str> {
        std::iter::once(self.config_key)
            .chain(self.aliases.iter().copied())
            .collect()
    }
}

/// Every format holger knows, in declaration order — so a caller can enumerate
/// the capability set instead of hand-maintaining a second list.
pub const ALL_ARTIFACT_FORMATS: &[ArtifactFormat] = &[
    ArtifactFormat::Maven3,
    ArtifactFormat::Pip,
    ArtifactFormat::Rust,
    ArtifactFormat::Go,
    ArtifactFormat::Nuget,
    ArtifactFormat::Npm,
    ArtifactFormat::Gem,
    ArtifactFormat::Deb,
    ArtifactFormat::Rpm,
    ArtifactFormat::Helm,
    ArtifactFormat::Docker,
    ArtifactFormat::Conda,
    ArtifactFormat::Composer,
    ArtifactFormat::Znippy,
    ArtifactFormat::Raw,
];

// === Upstreams (server side, sync request path) ===
//
// An upstream is a registry holger reads from on a local miss (another holger,
// crates.io, a Skidbladnir source). It is a read-oriented
// [`RepositoryBackendTrait`], so it drops straight into `ProxyBackend`: `fetch()`
// is the read and a writable primary write-through-caches the hit. Its HTTP is
// impl-side and SYNC (`ureq`): the server request path runs inside the async
// hyper handler, where a blocked-on `reqwest` panics.

/// How an upstream authenticates with its registry — the server-side enum,
/// distinct from the agent-side `ConnectorAuth`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum UpstreamAuth {
    /// No authentication.
    #[default]
    None,
    /// HTTP Basic auth.
    Basic { username: String, password: String },
    /// Bearer token.
    Bearer { token: String },
    /// Custom API-key header.
    ApiKey { header: String, key: String },
    /// mTLS client cert (built into the HTTP client, no per-request header).
    Mtls { cert_pem: String, key_pem: String },
}

/// Size + checksum + content-type for an upstream artifact: lets the proxy
/// verify a fetched body before it caches it.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactMeta {
    pub size: Option<u64>,
    pub sha256: Option<String>,
    pub content_type: Option<String>,
}

/// **An upstream registry** — a read-oriented [`RepositoryBackendTrait`] that
/// knows its base URL and auth and can state what the ORIGIN published for a
/// coordinate, the one fact a pull-through cache cannot compute for itself.
pub trait Upstream: RepositoryBackendTrait {
    /// The upstream base, e.g. `https://index.crates.io`.
    fn base_url(&self) -> &str;

    /// Credentials attached to upstream requests.
    fn auth(&self) -> &UpstreamAuth;

    /// Size + checksum + content-type; `Ok(None)` when the upstream has no such artifact.
    fn metadata(&self, id: &ArtifactId) -> anyhow::Result<Option<ArtifactMeta>>;

    /// The lowercase-hex sha256 the origin publishes for `id`, or `Ok(None)`
    /// when it publishes none (an honest "no claim", never a pass mark). `Err`
    /// is "I could not ask", which is not a pass mark either.
    fn advertised_sha256(&self, id: &ArtifactId) -> anyhow::Result<Option<String>> {
        Ok(self
            .metadata(id)?
            .and_then(|m| m.sha256)
            .and_then(|s| normalize_sha256(s.trim().trim_start_matches("sha256:"))))
    }
}

// === The change token a znippy-backed listing CAN answer ===

/// **A handle on the archive path whose lookup sub-index can answer change
/// tokens.**
///
/// Cheap and `Clone`: it holds a path and nothing else. The index itself is
/// opened per listing by [`ArchiveTokens::open`], never cached, because a
/// writable / hot-append archive is re-opened per read on the backends that
/// have one and a cached index would answer about bytes that have since moved.
/// An index read is bounded — footer, manifest, lookup section, trie — and
/// touches **no chunk data at all**, which is the property the whole feature
/// rests on and which `a_znippy_listing_stamps_a_token_and_never_extracts_a_byte`
/// counts.
#[derive(Debug, Clone, Default)]
pub struct ArchiveTokens {
    path: Option<std::path::PathBuf>,
}

impl ArchiveTokens {
    /// No archive, so no tokens. The honest state for a backend built from a
    /// bare [`znippy_common::ZnippyReader`] (no path to read an index from) or
    /// from nothing at all.
    pub const fn none() -> Self {
        Self { path: None }
    }

    /// Tokens from the archive at `path`.
    pub fn at(path: impl Into<std::path::PathBuf>) -> Self {
        Self {
            path: Some(path.into()),
        }
    }

    /// The archive this reads, when there is one.
    pub fn path(&self) -> Option<&std::path::Path> {
        self.path.as_deref()
    }

    /// Open the lookup sub-index for ONE listing.
    ///
    /// `None` when there is no archive, or when the archive carries no lookup
    /// sub-index (a pre-v0.7 seal), or when it cannot be read. All three are
    /// "this listing cannot answer a token", which is a state the wire already
    /// has a spelling for — never an error and never a fabricated value.
    pub fn open(&self) -> Option<ArchiveTokenIndex> {
        let path = self.path.as_deref()?;
        znippy_common::ArchiveReader::open(path)
            .ok()
            .map(|reader| ArchiveTokenIndex { reader })
    }
}

/// One archive's lookup sub-index, held open for the duration of one listing.
///
/// Every [`token`](Self::token) after the open is pure memory: an fst lookup or
/// a binary search over the already-decoded columns, then a fold of checksums
/// that were loaded with the index.
pub struct ArchiveTokenIndex {
    reader: znippy_common::ArchiveReader,
}

/// Domain separator for holger's change-token fold. It is holger's own and
/// deliberately NOT znippy's `sign::file_digest` tag: `sign` is an optional
/// feature carrying the whole CMS stack, and a change token is **source-scoped**
/// — nothing outside this server ever compares one against a value computed by
/// other code, so agreeing with another implementation buys nothing. The INPUTS
/// are the same index columns either way.
const CHANGE_TOKEN_TAG: &[u8] = b"holger.change-token.v1";

impl ArchiveTokenIndex {
    /// Chunk rows the index carries, across all files. The counter the
    /// "reads the index, not the store" measurement reports.
    pub fn rows(&self) -> usize {
        self.reader.row_count()
    }

    /// The change token for one archive-relative path: a BLAKE3 fold of the
    /// path and of the per-chunk checksums the index already holds, in
    /// `chunk_seq` order, as 64 lowercase hex characters.
    ///
    /// **No artifact byte is read.** The chunk checksums come off the lookup
    /// sub-index that was loaded at [`ArchiveTokens::open`]; the fold hashes 32
    /// bytes per chunk. This is why a znippy-backed listing can answer this and
    /// cannot answer [`ArtifactEntry::sha256`] — the latter is a hash of the
    /// bytes and would mean extracting the whole repository.
    ///
    /// `None` when the path is not in the archive.
    ///
    /// The path is part of the fold on purpose, exactly as znippy's own
    /// provenance digest does it: two entries with identical bytes are two
    /// different artifacts at this source, and a token that collapsed them
    /// would say `unchanged` about a coordinate that had been repointed. It is
    /// also, precisely, why this value is NOT a content digest and must never
    /// be put in `sha256`.
    pub fn token(&self, relative_path: &str) -> Option<String> {
        let chunks = self.reader.locate(relative_path);
        if chunks.is_empty() {
            return None;
        }
        let mut h = znippy_common::blake3::Hasher::new();
        h.update(CHANGE_TOKEN_TAG);
        h.update(&(relative_path.len() as u64).to_le_bytes());
        h.update(relative_path.as_bytes());
        h.update(&(chunks.len() as u64).to_le_bytes());
        // `ArchiveReader::locate` returns the file's chunks sorted by
        // `chunk_seq`, so the fold is order-stable without a second sort.
        for c in &chunks {
            h.update(&c.chunk_seq.to_le_bytes());
            h.update(&c.checksum);
        }
        Some(hex32(h.finalize().as_bytes()))
    }
}

/// 32 bytes as 64 lowercase hex characters, with one allocation.
fn hex32(d: &[u8; 32]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(64);
    for b in d {
        out.push(HEX[(b >> 4) as usize] as char);
        out.push(HEX[(b & 0x0f) as usize] as char);
    }
    out
}

// === The list() shape every znippy-backed backend was writing out by hand ===

/// Walk a znippy archive's file table and turn the entries a `coordinate`
/// function recognises into [`ArtifactEntry`] rows.
///
/// Only the path→coordinate parse differs per ecosystem, so that is the only
/// part left to the caller ([`ZnippyBase::list_entries`] passes the tokens).
/// `limit == 0` means no cap. Paths `coordinate` returns `None` for are
/// skipped, which is how a backend ignores its own index/metadata entries.
/// `tokens` is the archive's lookup sub-index, when the backend has a path to
/// read one from ([`ArchiveTokens::open`]). Every row it can answer for gets an
/// [`ArtifactEntry::change_token`]; the rest stay absent. Passing `None` is the
/// honest answer for a backend built from a bare reader with no archive path.
pub fn list_archive_entries<F>(
    reader: &dyn znippy_common::ZnippyReader,
    tokens: Option<&ArchiveTokenIndex>,
    name_filter: Option<&str>,
    limit: usize,
    content_type: &str,
    coordinate: F,
) -> anyhow::Result<Vec<ArtifactEntry>>
where
    F: Fn(&str) -> Option<ArtifactId>,
{
    let mut out = Vec::new();
    for path in reader.list_files()? {
        let Some(id) = coordinate(&path) else {
            continue;
        };
        if let Some(f) = name_filter {
            if !id.name.contains(f) {
                continue;
            }
        }
        // Publication time and digest are UNKNOWN here, and that is measured,
        // not lazy. `ZnippyReader` is `list_files` / `file_size` / `contains` /
        // `extract_file`: there is no modification time in the file table and
        // no per-entry SHA-256. Answering either would mean `extract_file` +
        // hash for every row — the whole repository read and hashed on a
        // listing. Every znippy-backed backend therefore says `None` here, and
        // a caller that needs a digest asks for the artifact.
        //
        // The CHANGE TOKEN is the one provenance question this backend can
        // answer, and it is answered from the index alone — no chunk of any
        // artifact is read to produce it. See [`ArchiveTokenIndex::token`].
        out.push(
            ArtifactEntry::unknown_provenance(
                id,
                reader.file_size(&path).unwrap_or(0) as i64,
                content_type,
            )
            .with_change_token(tokens.and_then(|t| t.token(&path))),
        );
        if limit != 0 && out.len() >= limit {
            break;
        }
    }
    Ok(out)
}

/// The Cargo download path `/{repo}/crates/{name}/{version}/download` (query
/// string ignored) as a coordinate; `None` for every other path a Rust
/// registry serves (`config.json`, the sparse index), which name no artifact.
/// The one mapping both Rust backends answer `coordinate_for_path` with.
pub fn crate_download_coordinate(repo: &str, suburl: &str) -> Option<ArtifactId> {
    let path = suburl.split('?').next().unwrap_or("");
    match path
        .trim_start_matches('/')
        .split('/')
        .collect::<Vec<_>>()
        .as_slice()
    {
        [r, "crates", name, version, "download"]
            if *r == repo && !name.is_empty() && !version.is_empty() =>
        {
            Some(ArtifactId {
                namespace: None,
                name: name.to_string(),
                version: version.to_string(),
            })
        }
        _ => None,
    }
}

/// The commonest path→coordinate parse: take the last path segment, strip
/// `suffix`, split the stem on its LAST `-` into name and version. Serves
/// helm (`.tgz`) and znippy-package (`.znippy`) verbatim.
pub fn coordinate_from_name_dash_version(path: &str, suffix: &str) -> Option<ArtifactId> {
    let file = path.rsplit('/').next().unwrap_or(path);
    let stem = file.strip_suffix(suffix)?;
    let (name, version) = stem.rsplit_once('-')?;
    if name.is_empty() || version.is_empty() {
        return None;
    }
    Some(ArtifactId {
        namespace: None,
        name: name.to_string(),
        version: version.to_string(),
    })
}

// === Connector trait for external systems (Nexus, Artifactory, crates.io) ===

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteRepository {
    pub name: String,
    pub format: String,
    pub repo_type: String,
    pub url: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteAsset {
    pub path: String,
    pub download_url: String,
    pub content_type: Option<String>,
    pub size: Option<u64>,
    /// **The digest the coordinate was PUBLISHED with**, when the source knew
    /// one: `Cargo.lock`'s `checksum`, PyPI's `sha256` digest. Lowercase hex,
    /// no algorithm prefix.
    ///
    /// It exists so the bytes that cross the airgap are the bytes the lockfile
    /// pinned. Before this field the agent parsed both digests and threw them
    /// away: a compromised registry or CDN serving substituted bytes for one
    /// pinned coordinate had them packed into the znippy archive and certified
    /// by a receipt whose sha256 was computed over the substitution. The
    /// allowlist and https stopped a network-position attacker; nothing stopped
    /// the origin itself.
    ///
    /// `None` means the source published no digest — it does NOT mean the bytes
    /// are trusted, and a connector that has one must fill it in.
    #[serde(default)]
    pub sha256: Option<String>,
    /// **An opaque CHANGE TOKEN for this coordinate, as published by THIS
    /// source.**
    ///
    /// Equal ⇒ unchanged at this source. Different ⇒ changed at this source.
    /// It is **never** compared across sources, and it is **never** a content
    /// digest: it may bind the path, the storage layout, or anything else the
    /// source finds cheap, so two sources holding byte-identical artifacts will
    /// legitimately publish different tokens. Anything that treats it as a hash
    /// of the bytes is wrong.
    ///
    /// # Why it exists
    ///
    /// 15 of holger's 17 repository backends are `znippy-*`, and a znippy file
    /// table carries neither a modification time nor a per-entry SHA-256 — so
    /// [`ArtifactEntry::published_unix_ms`] and [`ArtifactEntry::sha256`] are
    /// both `None` and every such repository landed in `unproven` on every
    /// mirror tick, re-fetching itself whole every cycle under the default
    /// [`UnprovenPolicy::Refetch`](crate::RemoteAsset). What znippy DOES have,
    /// for free, is a merkle fold of the per-chunk BLAKE3 hashes its index
    /// already stores — a value that is stable while the bytes are stable and
    /// differs when they differ, and which is emphatically not a content
    /// digest. That is a change token, and this is the field for it.
    ///
    /// # The scope is part of the value
    ///
    /// A token carried here is SCOPE-STAMPED: `"<scope>|<opaque>"`, where the
    /// scope names the source it came from and the part after the first `|` is
    /// never interpreted. [`scope_change_token`] builds one, and
    /// [`split_change_token`] takes it apart. A comparison between two tokens
    /// with different scopes is refused rather than answered — which is what
    /// makes "never compared across sources" a property of the code instead of
    /// a rule someone has to remember.
    ///
    /// `None` means this source published no token. It does NOT mean the bytes
    /// are unchanged and it does not mean they changed.
    #[serde(default)]
    pub change_token: Option<String>,
}

#[async_trait]
pub trait ConnectorTrait: Send + Sync {
    fn name(&self) -> &str;

    async fn list_repositories(&self) -> anyhow::Result<Vec<RemoteRepository>>;

    async fn list_assets(&self, repository: &str) -> anyhow::Result<Vec<RemoteAsset>>;

    async fn download_asset(&self, asset: &RemoteAsset) -> anyhow::Result<Vec<u8>>;

    async fn upload_asset(&self, repository: &str, path: &str, data: &[u8]) -> anyhow::Result<()>;
}

// === HolgerObject: the transport-agnostic facade ===
//
// `HolgerObject` is the single Rust contract an external partner programs
// against. Hold an `Arc<dyn HolgerObject>` and you don't care what's behind the
// vtable — exactly like a Java interface reference. Two transports implement it
// today:
//   * an in-process adapter that calls the local Holger engine directly, and
//   * a remote adapter that forwards each call over gRPC (tonic client).
// The language-neutral contract for non-Rust / remote partners is `holger.proto`
// (the generated client/server stubs); this trait mirrors it for Rust callers.

/// Summary of one configured repository (mirror of the proto `RepositoryInfo`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositoryInfo {
    pub name: String,
    pub repo_type: String,
    pub writable: bool,
    pub has_archive: bool,
    /// Mirror of `RepositoryInfo.supports_listing`: [`Caps::listing`].
    pub supports_listing: bool,
    /// Mirror of `RepositoryInfo.is_group`: a read-only view whose archive
    /// figures are the SUM of `members` — skip it when summing a fleet.
    pub is_group: bool,
    /// Mirror of `RepositoryInfo.members`: the group's member names, in order;
    /// empty for a repository that owns its storage.
    pub members: Vec<String>,
}

impl RepositoryInfo {
    /// The ONE mapping from a backend to its catalog row: every field is read
    /// off the backend's own [`Caps`], so the catalog cannot drift from it.
    pub fn of(name: &str, repo: &dyn RepositoryBackendTrait) -> RepositoryInfo {
        let caps = repo.caps();
        RepositoryInfo {
            name: name.to_string(),
            repo_type: format!("{:?}", repo.format()),
            writable: repo.is_writable(),
            has_archive: caps.archive,
            supports_listing: caps.listing,
            is_group: caps.group,
            members: repo.members(),
        }
    }
}

/// One artifact in a repository listing (mirror of the proto `ArtifactEntry`).
///
/// # The two fields that are allowed to say "I don't know"
///
/// [`published_unix_ms`](Self::published_unix_ms) and [`sha256`](Self::sha256)
/// are `Option`, and the `None` is the whole point. A listing is assembled by
/// whichever backend owns the bytes, and the backends genuinely differ in what
/// they can answer WITHOUT reading the artifact:
///
/// * a loose-file backend stats the file it is already statting, so a
///   modification time costs nothing extra;
/// * a znippy-backed backend reads its file table through
///   [`znippy_common::ZnippyReader`], which publishes `list_files`,
///   `file_size`, `contains` and `extract_file` and **no** modification time
///   and **no** per-entry digest — so the only way it could answer either is to
///   extract and hash every artifact in the repository, on the listing path.
///
/// A listing that takes minutes is a worse defect than a listing that is
/// missing a column, so a backend that would have to read bytes answers `None`.
///
/// **`None` means "not known here", never "zero" and never "empty".** The same
/// discipline [`RemoteAsset::sha256`] already states: an absent digest is not a
/// statement about the bytes. Do not paper over it with `unwrap_or(0)` or
/// `unwrap_or_default()` — a fabricated `0` timestamp sorts as 1970 and a
/// fabricated empty digest compares equal to another empty digest, and both of
/// those read as real answers.
///
/// Build one with [`ArtifactEntry::unknown_provenance`] and then say what you
/// do know with [`with_published_unix_ms`](Self::with_published_unix_ms) /
/// [`with_sha256`](Self::with_sha256), so that "I cannot answer this" is spelled
/// out loud at the call site instead of being a field somebody forgot.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactEntry {
    pub id: ArtifactId,
    pub size_bytes: i64,
    pub content_type: String,
    /// When these bytes were published, in **Unix milliseconds UTC**.
    ///
    /// `None` = this backend does not know. Milliseconds rather than seconds
    /// because two artifacts published in the same second are ordinary, and a
    /// "most recently changed" list that ties on them shuffles between loads.
    ///
    /// Non-positive values are not carried: see [`normalize_published_unix_ms`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub published_unix_ms: Option<i64>,
    /// Lowercase-hex SHA-256 of the artifact's bytes, 64 characters, no
    /// `sha256:` prefix — the same spelling [`RemoteAsset::sha256`] carries, so
    /// a mirror can compare the two without reformatting either.
    ///
    /// `None` = this backend does not know. A malformed value is dropped to
    /// `None` on the way in rather than carried: see [`normalize_sha256`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sha256: Option<String>,
    /// **An opaque CHANGE TOKEN for these bytes, as this backend computes it.**
    ///
    /// Equal ⇒ unchanged **in this repository on this server**. Different ⇒
    /// changed here. It is not a content digest and must never be compared
    /// against one, nor against a token from any other source: see
    /// [`RemoteAsset::change_token`], which is where a consumer stamps its own
    /// source scope onto the value before storing or comparing it.
    ///
    /// This is the field a `znippy-*` backend CAN answer, which is why it
    /// exists: `ZnippyReader`'s index already carries a BLAKE3 per chunk, so a
    /// per-entry merkle fold of them costs one hash of already-loaded
    /// checksums and reads no artifact bytes at all. The same backends still
    /// answer `None` for [`published_unix_ms`](Self::published_unix_ms) and
    /// [`sha256`](Self::sha256), because those two genuinely are not in the
    /// file table.
    ///
    /// `None` = this backend does not know. Never fabricated, never empty: an
    /// empty token would compare equal to another empty token, which is the
    /// exact lie the whole `Option` exists to prevent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub change_token: Option<String>,
}

impl ArtifactEntry {
    /// **"The rest of this row is unknown", for the functional-record-update
    /// tail of a struct literal.**
    ///
    /// `..ArtifactEntry::UNKNOWN_PROVENANCE` at the end of a literal reads as a
    /// sentence, and it is deliberately not `..Default::default()`: a reader
    /// who sees `Default` has to go and look up whether the default timestamp
    /// is `None` or `0`. This one says it in the name, and the day a third
    /// "might not be knowable" field is added, every backend that cannot answer
    /// it keeps compiling and keeps saying `None` — which is the correct
    /// answer — instead of being handed a zero by a `#[derive(Default)]`.
    ///
    /// The `id`, `size_bytes` and `content_type` in it are never used: a
    /// literal always overrides all three, and `String::new()` does not
    /// allocate, so the discarded value costs nothing.
    pub const UNKNOWN_PROVENANCE: ArtifactEntry = ArtifactEntry {
        id: ArtifactId {
            namespace: None,
            name: String::new(),
            version: String::new(),
        },
        size_bytes: 0,
        content_type: String::new(),
        published_unix_ms: None,
        sha256: None,
        change_token: None,
    };

    /// The three fields every backend has, and an explicit "I know nothing
    /// about when this was published or what it hashes to".
    ///
    /// This is the constructor a backend that cannot answer cheaply should
    /// call, because the name says what it is doing.
    pub fn unknown_provenance(
        id: ArtifactId,
        size_bytes: i64,
        content_type: impl Into<String>,
    ) -> Self {
        ArtifactEntry {
            id,
            size_bytes,
            content_type: content_type.into(),
            published_unix_ms: None,
            sha256: None,
            change_token: None,
        }
    }

    /// Attach a publication time. A non-positive or absent value stays `None`
    /// — see [`normalize_published_unix_ms`].
    #[must_use]
    pub fn with_published_unix_ms(mut self, ms: Option<i64>) -> Self {
        self.published_unix_ms = normalize_published_unix_ms(ms);
        self
    }

    /// Attach a digest. Anything that is not 64 hex characters stays `None` —
    /// see [`normalize_sha256`].
    #[must_use]
    pub fn with_sha256(mut self, digest: Option<impl AsRef<str>>) -> Self {
        self.sha256 = digest.and_then(|d| normalize_sha256(d.as_ref()));
        self
    }

    /// Attach an opaque change token. Anything that is not a usable token —
    /// empty, whitespace-only, over-long, or carrying a control character —
    /// stays `None`; see [`normalize_change_token`].
    ///
    /// The value passed here is the backend's own RAW token. Scoping it to a
    /// source is the consumer's job and happens at the edge that knows which
    /// source it asked ([`scope_change_token`]).
    #[must_use]
    pub fn with_change_token(mut self, token: Option<impl AsRef<str>>) -> Self {
        self.change_token = token.and_then(|t| normalize_change_token(t.as_ref()));
        self
    }

    /// The system-time flavour of [`with_published_unix_ms`], for the loose-file
    /// backends that get a [`std::time::SystemTime`] out of `fs::metadata`.
    ///
    /// A time at or before the Unix epoch, or one that does not fit an `i64` of
    /// milliseconds, is `None` — a clock that answers nonsense is a backend
    /// that does not know.
    #[must_use]
    pub fn with_published_system_time(self, t: Option<std::time::SystemTime>) -> Self {
        self.with_published_unix_ms(t.and_then(system_time_to_unix_ms))
    }
}

/// `SystemTime` → Unix milliseconds, or `None` when it is not after the epoch
/// or does not fit.
///
/// Separate from the builder so the mapping is testable on its own, and so the
/// "a bad clock is an unknown, not a zero" rule lives in exactly one place.
pub fn system_time_to_unix_ms(t: std::time::SystemTime) -> Option<i64> {
    let d = t.duration_since(std::time::UNIX_EPOCH).ok()?;
    i64::try_from(d.as_millis()).ok().filter(|ms| *ms > 0)
}

/// The only accepted spelling of a digest in an [`ArtifactEntry`]: exactly 64
/// hex characters, lowercased. Everything else — an empty string, a `sha256:`
/// prefix, a truncated hash, a blake3 hash, whitespace — becomes `None`.
///
/// Dropping a malformed digest is the house rule (the connector path does the
/// same): a digest that is carried but wrong is worse than no digest, because
/// a mirror would compare against it and decide the bytes had changed when they
/// had not — or, worse, that they had not when they had.
pub fn normalize_sha256(s: &str) -> Option<String> {
    let t = s.trim();
    if t.len() != 64 || !t.bytes().all(|b| b.is_ascii_hexdigit()) {
        return None;
    }
    Some(t.to_ascii_lowercase())
}

/// The only accepted spelling of a publication time: strictly positive Unix
/// milliseconds.
///
/// `0` and negatives become `None` on purpose. `0` is what a proto3 scalar
/// carries when nobody set it, and an artifact published at exactly the Unix
/// epoch does not occur; treating the boundary as "unknown" costs a millisecond
/// of expressiveness and buys the guarantee that a peer which does not know how
/// to answer can never accidentally claim 1970.
pub fn normalize_published_unix_ms(ms: Option<i64>) -> Option<i64> {
    ms.filter(|v| *v > 0)
}

// ───────────────────────── the change token ─────────────────────────

/// The longest change token that will be carried. A token is opaque, so there
/// is nothing to validate about its contents — but it is stored once per
/// coordinate in a map that can hold half a million of them, so it is bounded.
/// 512 bytes is eight times the longest token holger itself produces (a 64-char
/// hex fold) and still keeps a full map inside a few hundred megabytes.
pub const MAX_CHANGE_TOKEN_LEN: usize = 512;

/// The only accepted spelling of a change token: non-empty after trimming, at
/// most [`MAX_CHANGE_TOKEN_LEN`] bytes, and free of control characters.
///
/// Everything else becomes `None`. The contents are NOT otherwise inspected —
/// that is what "opaque" means — but an empty string must never survive,
/// because an empty token compares equal to another empty token and would
/// report two different artifacts as `unchanged`. This is the same rule
/// [`normalize_sha256`] applies for the same reason.
pub fn normalize_change_token(s: &str) -> Option<String> {
    let t = s.trim();
    if t.is_empty() || t.len() > MAX_CHANGE_TOKEN_LEN || t.chars().any(char::is_control) {
        return None;
    }
    Some(t.to_string())
}

/// Server health (mirror of the proto `HealthResponse`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Health {
    pub status: String,
    pub version: String,
    pub uptime_seconds: i64,
}

/// Server operating profile (mirror of the proto `ServerProfileResponse`).
///
/// This is *server truth*: whether the server accepts writes at all, derived
/// from the live route table (or an explicit sealed-mode override). The UI
/// drives its static "silent running" chrome off this rather than a local
/// toggle.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServerProfile {
    /// "static" (sealed / read-only) or "dynamic" (writable / proxy).
    pub profile: String,
    /// True when the server accepts no writes on any repository.
    pub read_only: bool,
    /// Human label for the UI chrome / badge.
    pub label: String,
    /// Number of configured repositories that accept writes.
    pub writable_repo_count: i32,
}

impl Default for ServerProfile {
    /// A conservative dynamic (writable) default for transports that don't
    /// report a profile.
    fn default() -> Self {
        Self {
            profile: "dynamic".into(),
            read_only: false,
            label: "DYNAMIC / WRITABLE".into(),
            writable_repo_count: 0,
        }
    }
}

/// A cross-repo search query (mirror of the proto `SearchRequest`). Every axis
/// is optional (empty = unconstrained); axes AND-compose on the server.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SearchQuery {
    /// Case-insensitive substring match on the artifact name. Empty = any.
    pub name: String,
    /// Case-insensitive substring match on the namespace. Empty = any.
    pub namespace: String,
    /// Exact version match. Empty = any.
    pub version: String,
    /// Case-insensitive substring match on a raw archive file path.
    pub path: String,
    /// Restrict to these repositories. Empty = every configured repo.
    pub repositories: Vec<String>,
    /// Cap hits per kind. 0 ⇒ the server default.
    pub limit: i32,
}

/// One artifact matched by a search, tagged with the repo it was found in
/// (mirror of the proto `ArtifactHit`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SearchArtifactHit {
    pub repository: String,
    pub id: ArtifactId,
    pub size_bytes: i64,
    pub content_type: String,
}

/// One raw archive path matched by the `path` axis (mirror of the proto `PathHit`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SearchPathHit {
    pub repository: String,
    pub path: String,
}

/// A search result page (mirror of the proto `SearchResponse`).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SearchResults {
    pub artifacts: Vec<SearchArtifactHit>,
    pub paths: Vec<SearchPathHit>,
    /// True when the result hit the limit cap and more hits exist.
    pub truncated: bool,
}

/// A hosted SBOM document for one artifact coordinate (mirror of the proto
/// `FetchSbomResponse`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SbomDoc {
    /// The document bytes (byte-identical to what was attached).
    pub document: Vec<u8>,
    pub size_bytes: i64,
    /// blake3 hex of the document (its content address).
    pub content_address: String,
}

/// One custom property on an artifact coordinate: a key with its ordered values
/// (mirror of the proto `PropertyEntry`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PropertyKv {
    pub key: String,
    pub values: Vec<String>,
}

/// Transport-agnostic Holger facade — the Rust mirror of `holger.proto`.
/// Callable over either transport (in-process or gRPC) behind the same handle.
/// Every method is required: a transport that cannot answer one says so with an
/// error, never with an empty default a caller would believe.
#[async_trait]
pub trait HolgerObject: Send + Sync {
    /// Fetch an artifact by id from a named repository.
    async fn fetch(&self, repository: &str, id: &ArtifactId) -> anyhow::Result<Option<Bytes>>;

    /// Store an artifact (write-enabled repositories only).
    async fn put(&self, repository: &str, id: &ArtifactId, data: &[u8]) -> anyhow::Result<()>;

    /// List all configured repositories.
    async fn list_repositories(&self) -> anyhow::Result<Vec<RepositoryInfo>>;

    /// One page of a repository's artifacts, optionally filtered by a
    /// `name_filter` substring, `limit` per page, paged by an opaque
    /// `page_token`. Returns the entries and the next token (empty when exhausted).
    async fn list_artifacts(
        &self,
        repository: &str,
        name_filter: Option<String>,
        limit: u32,
        page_token: Option<String>,
    ) -> anyhow::Result<(Vec<ArtifactEntry>, String)>;

    /// The raw file paths inside the archive backing `repository`, optionally
    /// filtered to those starting with `prefix`.
    async fn list_archive_files(
        &self,
        repository: &str,
        prefix: Option<String>,
    ) -> anyhow::Result<Vec<String>>;

    /// Stats for the archive backing `repository`.
    async fn archive_info(&self, repository: &str) -> anyhow::Result<ArchiveInfo>;

    /// Server health / version / uptime.
    async fn health(&self) -> anyhow::Result<Health>;

    /// Server operating profile (static/read-only vs dynamic/writable).
    async fn server_profile(&self) -> anyhow::Result<ServerProfile>;

    /// Cross-repo artifact search (`SearchService.Search`).
    async fn search(&self, query: &SearchQuery) -> anyhow::Result<SearchResults>;

    /// The hosted SBOM document for a coordinate (`SbomService.FetchSbom`);
    /// `Ok(None)` when none is attached.
    async fn fetch_sbom(
        &self,
        repository: &str,
        id: &ArtifactId,
    ) -> anyhow::Result<Option<SbomDoc>>;

    /// All custom properties on a coordinate (`PropertyService.GetProperties`);
    /// empty when none are set.
    async fn get_properties(
        &self,
        repository: &str,
        id: &ArtifactId,
    ) -> anyhow::Result<Vec<PropertyKv>>;
}

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

    #[cfg(feature = "testmatrix")]
    use nornir_testmatrix::functional_status as fstatus;

    #[test]
    fn znippy_type_ids_match_znippy_skeletons() {
        // znippy is the source of truth — these literals must equal the znippy
        // handler discriminants, or the mapping has drifted.
        assert_eq!(ArtifactFormat::Go.znippy_type_id(), 4);
        assert_eq!(ArtifactFormat::Nuget.znippy_type_id(), 5);
        assert_eq!(ArtifactFormat::Npm.znippy_type_id(), 6);
        assert_eq!(ArtifactFormat::Rpm.znippy_type_id(), 8);
        assert_eq!(ArtifactFormat::Deb.znippy_type_id(), 9);
        assert_eq!(ArtifactFormat::Gem.znippy_type_id(), 11);
        assert_eq!(ArtifactFormat::Docker.znippy_type_id(), 12);
        assert_eq!(ArtifactFormat::Helm.znippy_type_id(), 13);
        assert_eq!(ArtifactFormat::Conda.znippy_type_id(), 14);
        assert_eq!(ArtifactFormat::Composer.znippy_type_id(), 17);
        // natives
        assert_eq!(ArtifactFormat::Rust.znippy_type_id(), 1);
        assert_eq!(ArtifactFormat::Pip.znippy_type_id(), 2);
        assert_eq!(ArtifactFormat::Maven3.znippy_type_id(), 3);

        #[cfg(feature = "testmatrix")]
        {
            let ok = ArtifactFormat::Helm.znippy_type_id() == 13
                && ArtifactFormat::Rust.znippy_type_id() == 1
                && ArtifactFormat::Composer.znippy_type_id() == 17;
            fstatus(
                "traits",
                "znippy_type_ids_match",
                ok,
                &format!(
                    "Rust=1 Pip=2 Maven3=3 Helm=13 Composer=17 (helm={})",
                    ArtifactFormat::Helm.znippy_type_id()
                ),
            );
        }
    }

    /// Drift guard sourced from znippy itself: instead of comparing against
    /// hand-typed literals, assert each format's `znippy_type_id()` equals the
    /// `type_id()` of the corresponding znippy-common handler. If holger's table
    /// (or znippy's handler register) ever drifts, this fails — proving the two
    /// stay a single source of truth, as data rather than by eyeballing.
    ///
    /// Only the handlers reachable from `znippy-common` are covered (every native
    /// and skeleton handler except the maven/python natives, which live in the
    /// sibling plugin crates that `traits` does not depend on — those two stay
    /// guarded by the literal assertions in `znippy_type_ids_match_znippy_skeletons`).
    #[test]
    fn znippy_type_ids_derive_from_znippy_handlers_not_holger_copies() {
        use znippy_common::plugin::ArchiveTypePlugin;
        use znippy_common::plugins::{
            cargo_native::CargoPlugin, conda_native::CondaPlugin, deb_native::DebPlugin,
            gem_native::GemPlugin, npm_native::NpmPlugin, rpm_native::RpmPlugin, skeletons,
        };

        // (format, znippy handler's own type_id) — the right-hand side is znippy's
        // source of truth, never a literal copied into holger.
        let pairs: &[(ArtifactFormat, i8)] = &[
            // Rust is now derived from cargo_native (in znippy-common) rather than
            // a hardcoded `1` — this asserts the derivation, not a copy.
            (ArtifactFormat::Rust, CargoPlugin::new().type_id()),
            (ArtifactFormat::Npm, NpmPlugin.type_id()),
            (ArtifactFormat::Gem, GemPlugin.type_id()),
            (ArtifactFormat::Conda, CondaPlugin.type_id()),
            (ArtifactFormat::Rpm, RpmPlugin.type_id()),
            (ArtifactFormat::Deb, DebPlugin.type_id()),
            (ArtifactFormat::Go, skeletons::GoPlugin.type_id()),
            (ArtifactFormat::Nuget, skeletons::NugetPlugin.type_id()),
            (ArtifactFormat::Docker, skeletons::DockerPlugin.type_id()),
            (ArtifactFormat::Helm, skeletons::HelmPlugin.type_id()),
            (
                ArtifactFormat::Composer,
                skeletons::ComposerPlugin.type_id(),
            ),
        ];

        let mut all_ok = true;
        for (fmt, znippy_id) in pairs {
            let holger_id = fmt.znippy_type_id();
            if holger_id != *znippy_id {
                all_ok = false;
            }
            assert_eq!(
                holger_id, *znippy_id,
                "{fmt:?}: holger znippy_type_id()={holger_id} drifted from znippy handler type_id()={znippy_id}"
            );
        }

        #[cfg(feature = "testmatrix")]
        fstatus(
            "traits",
            "znippy_type_ids_derive_from_handlers",
            all_ok,
            &format!(
                "{} formats derive their discriminant from znippy handlers",
                pairs.len()
            ),
        );
        let _ = all_ok;
    }

    #[test]
    fn upstream_auth_defaults_to_none() {
        assert_eq!(UpstreamAuth::default(), UpstreamAuth::None);
        assert_ne!(
            UpstreamAuth::Bearer { token: "t".into() },
            UpstreamAuth::Basic {
                username: "u".into(),
                password: "p".into()
            }
        );
    }

    /// **The naming-mismatch guard.** Every format must name a crate that really
    /// exists on disk, and every name it is known by must resolve back to it.
    ///
    /// RED when a crate is renamed/moved without updating [`ArtifactFormat::home`]
    /// — which is the exact moment the trap re-opens: the docs keep saying "PyPI",
    /// the map keeps saying `znippy-python`, and the directory has become something
    /// else. A stale signpost is worse than none, so this fails the build instead.
    #[test]
    fn every_format_names_a_crate_that_exists() {
        // `traits/` sits beside `server/`, so the repo root is one level up.
        let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .expect("traits/ has a parent")
            .to_path_buf();

        for fmt in ALL_ARTIFACT_FORMATS {
            let home = fmt.home();

            // 1. The canonical config key and EVERY alias must parse back to this
            //    same format — so searching any spoken name lands on this map.
            for name in home.all_names() {
                assert_eq!(
                    ArtifactFormat::from_format_str(name),
                    Some(fmt.clone()),
                    "{fmt:?}: the name {name:?} must resolve back to it"
                );
            }

            // 2. The named crate directory must EXIST (and be a real crate).
            for (dir, package) in std::iter::once((home.crate_dir, home.package))
                .chain(home.also)
                .filter(|(d, _)| !d.is_empty())
            {
                let manifest = repo_root
                    .join("server/repository")
                    .join(dir)
                    .join("Cargo.toml");
                assert!(
                    manifest.is_file(),
                    "{fmt:?}: home() points at server/repository/{dir}, which does not exist \
                     (renamed? then update ArtifactFormat::home)"
                );
                let text = std::fs::read_to_string(&manifest).expect("read Cargo.toml");
                assert!(
                    text.lines()
                        .any(|l| l.trim() == format!("name = \"{package}\"")),
                    "{fmt:?}: server/repository/{dir} is not package {package:?} \
                     (renamed? then update ArtifactFormat::home)"
                );
            }
        }
    }

    // ── The two fields that are allowed to say "I don't know" ────────────────

    /// A digest is 64 hex characters or it is nothing. Everything else is
    /// DROPPED rather than carried, because a wrong digest is worse than no
    /// digest: it is compared against.
    #[test]
    fn a_digest_that_is_not_a_digest_becomes_unknown() {
        assert_eq!(normalize_sha256(&"a".repeat(64)), Some("a".repeat(64)));
        assert_eq!(
            normalize_sha256(&"A".repeat(64)),
            Some("a".repeat(64)),
            "hex is lowercased"
        );
        assert_eq!(
            normalize_sha256(&format!("  {}  ", "a".repeat(64))),
            Some("a".repeat(64))
        );
        for bad in [
            "",
            "sha256:".to_string().as_str(),
            &format!("sha256:{}", "a".repeat(64)),
            &"a".repeat(63),
            &"a".repeat(65),
            &"z".repeat(64),
        ] {
            assert_eq!(
                normalize_sha256(bad),
                None,
                "{bad:?} must not be accepted as a digest"
            );
        }
    }

    // ── The change token ─────────────────────────────────────────────────────

    /// An empty token is not a token. The reason is the same one that drops a
    /// malformed digest, and it is sharper here: an empty token compares EQUAL
    /// to another empty token, so a listing that published one would tell a
    /// mirror that two different artifacts are the same artifact.
    #[test]
    fn an_empty_or_unusable_change_token_becomes_unknown() {
        assert_eq!(normalize_change_token(""), None);
        assert_eq!(normalize_change_token("   "), None);
        assert_eq!(
            normalize_change_token("a\u{0}b"),
            None,
            "control characters are refused"
        );
        assert_eq!(
            normalize_change_token(&"x".repeat(MAX_CHANGE_TOKEN_LEN + 1)),
            None
        );
        assert_eq!(
            normalize_change_token("  abc  "),
            Some("abc".to_string()),
            "trimmed"
        );
        assert_eq!(
            ArtifactEntry::unknown_provenance(
                ArtifactId {
                    namespace: None,
                    name: "a".into(),
                    version: "1".into()
                },
                1,
                "t",
            )
            .with_change_token(Some(""))
            .change_token,
            None,
            "and the builder drops it rather than carrying it"
        );
    }

    /// ★ **The proto3 zero values cannot become answers.**
    ///
    /// The fields are `optional` on the wire, so absence is a state — but a
    /// peer that is not this codebase can still put `0` and `""` on the wire,
    /// and neither is allowed past this line. A zero timestamp would sort as
    /// the oldest thing in the repository; an empty digest would compare equal
    /// to another empty digest and turn `unproven` into a false `unchanged`.
    #[test]
    fn the_proto3_zero_values_are_not_answers() {
        assert_eq!(
            normalize_published_unix_ms(Some(0)),
            None,
            "0 is what nobody-set looks like"
        );
        assert_eq!(normalize_published_unix_ms(Some(-1)), None);
        assert_eq!(normalize_published_unix_ms(None), None);
        assert_eq!(normalize_published_unix_ms(Some(1)), Some(1));
        assert_eq!(
            normalize_sha256(""),
            None,
            "the empty string is not a digest"
        );
    }

    /// `unknown_provenance` says it in its name, and the builders refuse to
    /// turn a bad value into a good-looking one.
    #[test]
    fn an_entry_that_knows_nothing_says_so_and_cannot_be_talked_into_a_value() {
        let id = ArtifactId {
            namespace: None,
            name: "a".into(),
            version: "1".into(),
        };
        let e = ArtifactEntry::unknown_provenance(id.clone(), 7, "application/octet-stream");
        assert_eq!(e.published_unix_ms, None);
        assert_eq!(e.sha256, None);
        assert_eq!(e.size_bytes, 7);

        let e = e.with_published_unix_ms(Some(0)).with_sha256(Some(""));
        assert_eq!(e.published_unix_ms, None, "a zero must not become a time");
        assert_eq!(e.sha256, None, "an empty string must not become a digest");

        let e = e
            .with_published_unix_ms(Some(1_726_000_000_000))
            .with_sha256(Some("B".repeat(64)));
        assert_eq!(e.published_unix_ms, Some(1_726_000_000_000));
        assert_eq!(e.sha256, Some("b".repeat(64)));

        // The record-update tail is the same "I don't know", for a literal.
        let lit = ArtifactEntry {
            id,
            size_bytes: 7,
            content_type: "application/octet-stream".into(),
            ..ArtifactEntry::UNKNOWN_PROVENANCE
        };
        assert_eq!(lit.published_unix_ms, None);
        assert_eq!(lit.sha256, None);
    }

    /// A clock at or before the epoch is a backend that does not know, not a
    /// backend that knows 1970.
    #[test]
    fn a_system_time_at_the_epoch_is_unknown_rather_than_zero() {
        assert_eq!(system_time_to_unix_ms(std::time::UNIX_EPOCH), None);
        assert_eq!(
            system_time_to_unix_ms(std::time::UNIX_EPOCH - std::time::Duration::from_secs(1)),
            None
        );
        let ms = system_time_to_unix_ms(std::time::SystemTime::now()).expect("now is after 1970");
        assert!(ms > 1_700_000_000_000, "milliseconds, not seconds: {ms}");
    }

    /// A znippy listing says unknown for every row. This is the helper thirteen
    /// backends share, so it is the one place the rule has to hold.
    #[test]
    fn the_shared_znippy_listing_answers_unknown_for_every_row() {
        // No archive needed: the entry shape is what is asserted, and the
        // helper's contract is that it never sets either field.
        let e = ArtifactEntry::unknown_provenance(
            ArtifactId {
                namespace: None,
                name: "x".into(),
                version: "1".into(),
            },
            1,
            "application/octet-stream",
        );
        assert_eq!((e.published_unix_ms, e.sha256), (None, None));
    }

    /// The specific case that cost real time: PyPI is spelled four ways, and the
    /// implementation directory matches NONE of the two an operator or a reader
    /// would try first. Pin it by name so the map is not silently "simplified".
    #[test]
    fn the_pypi_four_name_split_is_pinned() {
        let home = ArtifactFormat::Pip.home();
        assert_eq!(
            home.config_key, "pip",
            "operators write ron_repo_type: \"pip\""
        );
        assert!(
            home.aliases.contains(&"pypi"),
            "the docs/UI name must be findable"
        );
        assert!(
            home.aliases.contains(&"python"),
            "the crate's own name must be findable"
        );
        assert_eq!(
            home.crate_dir, "znippy-python",
            "the implementation lives under a THIRD name — that is the whole point"
        );
        // The trap, stated as an assertion: neither name the reader reaches for
        // first is the directory name.
        assert_ne!(home.crate_dir, home.config_key);
        assert_ne!(home.crate_dir, "pypi");
    }

    #[test]
    fn format_str_roundtrips_aliases() {
        assert_eq!(
            ArtifactFormat::from_format_str("cargo"),
            Some(ArtifactFormat::Rust)
        );
        assert_eq!(
            ArtifactFormat::from_format_str("golang"),
            Some(ArtifactFormat::Go)
        );
        assert_eq!(
            ArtifactFormat::from_format_str("dotnet"),
            Some(ArtifactFormat::Nuget)
        );
        assert_eq!(
            ArtifactFormat::from_format_str("oci"),
            Some(ArtifactFormat::Docker)
        );
        assert_eq!(ArtifactFormat::from_format_str("nope"), None);

        #[cfg(feature = "testmatrix")]
        {
            let ok = ArtifactFormat::from_format_str("cargo") == Some(ArtifactFormat::Rust)
                && ArtifactFormat::from_format_str("oci") == Some(ArtifactFormat::Docker)
                && ArtifactFormat::from_format_str("nope").is_none();
            fstatus(
                "traits",
                "format_str_aliases_roundtrip",
                ok,
                "cargo->Rust golang->Go dotnet->Nuget oci->Docker nope->None",
            );
        }
    }
}