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

nordisk/holger

server/lib/tests/listing_provenance.rs

//! **What each repository backend's LISTING can prove without reading bytes**
//! — declared once here and checked against every backend's own source.
//! Moved out of `holger-traits` (2026-10-17): nothing but this guard used it.

use traits::{ArtifactFormat, ALL_ARTIFACT_FORMATS};

/// Every backend that serves `fmt`, as `(crate_dir, package, listing provenance)`,
/// derived from [`ArtifactFormat::home`] (kept honest by
/// `every_format_names_a_crate_that_exists`), so a backend cannot be added or
/// renamed and quietly left out of the answer.
fn backends(fmt: &ArtifactFormat) -> Vec<(&'static str, &'static str, ListingProvenance)> {
    let home = fmt.home();
    std::iter::once((home.crate_dir, home.package))
        .chain(home.also)
        .map(|(dir, package)| (dir, package, ListingProvenance::of(dir, package)))
        .collect()
}

/// **What a repository backend's LISTING can answer about an entry's
/// provenance, WITHOUT reading artifact bytes.**
///
/// [`ArtifactEntry::published_unix_ms`] and [`ArtifactEntry::sha256`] are
/// `Option` because the backends genuinely differ here, and the difference is a
/// cost, not a preference: a listing that reads and hashes the store to fill in
/// a column is a worse defect than the missing column. This enum is that
/// difference written down once, next to the format→crate map, so "which
/// backends can answer" is a thing the code states rather than a thing somebody
/// has to go and read seventeen `list()` implementations to find out.
///
/// It is a DECLARATION, and `every_backend_declares_what_its_listing_can_prove`
/// checks it against the backends' own sources.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListingProvenance {
    /// **Neither.** Nothing declares this today.
    ///
    /// Until 2026-09-20 the znippy-backed backends that list through a TYPED
    /// VIEW or a manifest — `znippy-maven`, `znippy-npm`, `znippy-gem`,
    /// `znippy-conda`, `znippy-python`, `znippy-build-thing` — were here, and
    /// for a measured reason: the lookup sub-index that holds the per-chunk
    /// checksums is keyed by archive-relative path, and a typed view handed
    /// out a coordinate and a size but never the path of the row it came
    /// from. `znippy-common 0.9.16` gave every typed package handle a
    /// `relative_path()`, and all six moved to
    /// [`ChangeTokenOnly`](Self::ChangeTokenOnly).
    ///
    /// The variant is kept because it is the honest answer for any backend
    /// whose store carries no time, no digest and no per-row identity, and
    /// `every_backend_declares_what_its_listing_can_prove` makes such a
    /// backend say `UNKNOWN_PROVENANCE` out loud rather than leave three
    /// fields to a `Default` that would hand out a zero.
    Neither,
    /// **No time, no digest, and an opaque CHANGE TOKEN.** The znippy-backed
    /// backends whose listing has each row's archive-relative path in hand.
    ///
    /// The two fields a znippy file table genuinely cannot answer stay `None`
    /// — there is no mtime and no content SHA-256 in it, and getting either
    /// would mean extracting and hashing the whole repository. What the index
    /// DOES carry, already loaded, is a BLAKE3 per chunk, and a fold of those
    /// is [`ArtifactEntry::change_token`]: equal iff the artifact is unchanged
    /// **at this source**. See [`ArchiveTokenIndex::token`] for the fold and
    /// [`RemoteAsset::change_token`] for the comparison rule.
    ChangeTokenOnly,
    /// **The time, and no digest.** Nothing declares this today.
    ///
    /// `raw` was the one backend here until 2026-09-20, when it learned to
    /// memoize the digest of bytes it had ALREADY had in hand and moved to
    /// [`ListingProvenance::TimeAndDigestWhenOnHand`]. The variant is kept
    /// because it is the honest answer for any future backend that can stat a
    /// row but can never get its bytes cheaply, and deleting it would invite
    /// the next such backend to over-claim.
    TimeOnly,
    /// **The time, and a digest only when one is already on hand.** `file-rust`
    /// and `raw`.
    ///
    /// Both memoize `(len, mtime) → sha256`; the listing peeks that memo and
    /// never falls back to hashing. So a warm server answers and a cold one
    /// says unknown, and both answers are true.
    ///
    /// The two differ in ONE way and it is load-bearing: `file-rust`'s crate
    /// files are immutable by the registry's own rules, while a `raw` blob can
    /// be overwritten. That is why the stamp is not decoration — a raw memo
    /// whose `(len, mtime)` no longer matches must fall back to `None`, because
    /// a stale digest would tell a mirror `unchanged` about a blob that DID
    /// change.
    TimeAndDigestWhenOnHand,
    /// **The time, and a digest from a BOUNDED read of the listed artifact.**
    /// `file-oci`.
    ///
    /// The listed artifact is the manifest itself — kilobytes, and capped — and
    /// OCI is content-addressed, so its SHA-256 is the digest a registry client
    /// already sees. The cap is what keeps this from becoming the defect the
    /// others avoid.
    TimeAndBoundedDigest,
}

impl ListingProvenance {
    /// The provenance of one backend, by the directory and package
    /// [`ArtifactFormat::home`] names it with.
    ///
    /// Keyed on the backend, not the format, because two backends for the SAME
    /// format differ: `znippy-rust` proves a change token and `file-rust`
    /// proves a time.
    fn of(crate_dir: &str, package: &str) -> Self {
        match crate_dir {
            "file-rust" => ListingProvenance::TimeAndDigestWhenOnHand,
            "file-oci" => ListingProvenance::TimeAndBoundedDigest,
            // The one backend that is not a separate crate: `raw`, in
            // `server/lib/src/raw.rs`.
            "" if package == "holger-server-lib" => ListingProvenance::TimeAndDigestWhenOnHand,
            // Every znippy-backed backend. Nine of them always had the
            // archive-relative PATH of each row in hand — the key the lookup
            // sub-index is keyed by. The other six (`znippy-maven`,
            // `znippy-npm`, `znippy-gem`, `znippy-conda`, `znippy-python`)
            // list through a TYPED VIEW and `znippy-build-thing` through a
            // manifest; since `znippy-common 0.9.16` a typed package handle
            // names its own row (`relative_path()`), and build-thing's row is
            // the writer's layout (`attestations/{sha}.json`). All fifteen
            // stamp a change token.
            "znippy-composer" | "znippy-docker" | "znippy-go" | "znippy-helm" | "znippy-nuget"
            | "znippy-package" | "znippy-deb" | "znippy-rpm" | "znippy-rust" | "znippy-maven"
            | "znippy-npm" | "znippy-gem" | "znippy-conda" | "znippy-python"
            | "znippy-build-thing" => ListingProvenance::ChangeTokenOnly,
            // A backend this table has not met declares nothing, and the
            // guard then requires its source to say UNKNOWN_PROVENANCE.
            _ => ListingProvenance::Neither,
        }
    }
    /// Whether this backend's listing can carry a publication time.
    fn answers_time(&self) -> bool {
        matches!(
            self,
            ListingProvenance::TimeOnly
                | ListingProvenance::TimeAndDigestWhenOnHand
                | ListingProvenance::TimeAndBoundedDigest
        )
    }

    /// Whether this backend's listing can EVER carry a digest. "Ever", not
    /// "always": [`TimeAndDigestWhenOnHand`](Self::TimeAndDigestWhenOnHand)
    /// answers only when the checksum is already memoized.
    fn answers_digest(&self) -> bool {
        matches!(
            self,
            ListingProvenance::TimeAndDigestWhenOnHand | ListingProvenance::TimeAndBoundedDigest
        )
    }

    /// Whether this backend's listing can EVER carry an
    /// [`ArtifactEntry::change_token`].
    ///
    /// A third axis, not a third digest: a token is comparable only against
    /// another token from the SAME source, so it is neither a time nor a hash
    /// and gets its own question. "Ever" for the same reason as
    /// [`answers_digest`](Self::answers_digest): a znippy archive sealed before
    /// the lookup sub-index existed cannot answer, and that listing says `None`
    /// rather than inventing one.
    fn answers_change_token(&self) -> bool {
        matches!(self, ListingProvenance::ChangeTokenOnly)
    }
}

/// ★ **Every backend declares what its listing can prove, and the
/// declaration is checked against the backend's own source.**
///
/// The enumeration comes from [`ArtifactFormat::home`] — the same map
/// `every_format_names_a_crate_that_exists` keeps honest — so a backend
/// cannot be added, renamed or moved and quietly left out of the answer.
///
/// RED when a backend's code and its [`ListingProvenance`] disagree: a
/// znippy backend that started stamping times, or a file backend that
/// stopped. That disagreement is exactly the kind that would otherwise be
/// found by an operator wondering why a mirror never settles.
#[test]
fn every_backend_declares_what_its_listing_can_prove() {
    let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .to_path_buf();

    let mut seen: std::collections::BTreeMap<&'static str, ListingProvenance> =
        std::collections::BTreeMap::new();

    for fmt in ALL_ARTIFACT_FORMATS {
        let backends = backends(fmt);
        assert!(!backends.is_empty(), "{fmt:?}: no backend at all");

        for (dir, package, prov) in backends {
            // A format served by two backends must not have them collapsed
            // into one answer — that is the whole reason this is keyed on
            // the backend.
            if let Some(before) = seen.insert(package, prov) {
                assert_eq!(before, prov, "{package}: two different declarations");
            }

            let src = if dir.is_empty() {
                // The one backend that is not a separate crate.
                repo_root.join("server/lib/src/raw.rs")
            } else {
                repo_root
                    .join("server/repository")
                    .join(dir)
                    .join("src/lib.rs")
            };
            let text = std::fs::read_to_string(&src)
                .unwrap_or_else(|e| panic!("{fmt:?}: cannot read {}: {e}", src.display()));

            // A backend that claims a time must set one; a backend that
            // claims none must not.
            let stamps_time = text.contains("with_published_system_time")
                || text.contains("with_published_unix_ms");
            assert_eq!(
                stamps_time,
                prov.answers_time(),
                "{package} ({}) declares {prov:?} but its source {} a publication time",
                src.display(),
                if stamps_time { "sets" } else { "never sets" }
            );

            let stamps_digest = text.contains("with_sha256");
            assert_eq!(
                stamps_digest,
                prov.answers_digest(),
                "{package} ({}) declares {prov:?} but its source {} a digest",
                src.display(),
                if stamps_digest { "sets" } else { "never sets" }
            );

            // A backend that claims a change token must stamp one, and a
            // backend that claims none must not. `list_archive_entries`
            // counts as stamping it: that helper fills the field for every
            // row its caller passes a token index for, which is the whole
            // point of the shared loop.
            let stamps_token = text.contains("with_change_token")
                || text.contains("list_archive_entries")
                || text.contains("list_entries");
            assert_eq!(
                stamps_token,
                prov.answers_change_token(),
                "{package} ({}) declares {prov:?} but its source {} a change token",
                src.display(),
                if stamps_token {
                    "stamps"
                } else {
                    "never stamps"
                }
            );

            // And a backend that can prove nothing must SAY so out loud,
            // rather than leaving three fields to a `Default` that would
            // hand out a zero.
            if prov == ListingProvenance::Neither {
                assert!(
                    text.contains("UNKNOWN_PROVENANCE") || text.contains("unknown_provenance"),
                    "{package}: cannot prove a time, a digest or a change token, and does \
                     not say so — use ArtifactEntry::UNKNOWN_PROVENANCE / ::unknown_provenance"
                );
            }
        }
    }

    // The split is real and must stay visible: at least one backend on each
    // side, or this whole enum has quietly become a constant. The `Neither`
    // side emptied on 2026-09-20 — every typed view learned its path with
    // `znippy-common 0.9.16` — so the split now runs between the file
    // backends (a time, a digest when on hand) and the znippy backends (a
    // change token). A `znippy-*` backend that has fallen back to
    // `Neither` is a typed view that lost its path again, and that is the
    // regression this line is for.
    for (dir, _package, prov) in ALL_ARTIFACT_FORMATS.iter().flat_map(|f| backends(f)) {
        if dir.starts_with("znippy-") {
            assert!(
                prov.answers_change_token(),
                "{dir}: a znippy-backed listing that stamps no change token — its mirror \
                 re-fetches the whole repository every tick"
            );
        }
    }
    assert!(
        seen.values().any(|p| p.answers_digest()),
        "no backend can publish a digest"
    );
    assert!(
        seen.values().any(|p| p.answers_change_token()),
        "no backend publishes a change token any more — every znippy-backed repository is \
         then back to re-fetching itself whole on every mirror tick"
    );
}

/// The two backends for `rust` are NOT the same answer, and that is the
/// case the per-backend keying exists for.
#[test]
fn one_format_can_have_two_backends_with_two_different_answers() {
    let b = backends(&ArtifactFormat::Rust);
    assert_eq!(b.len(), 2, "rust has a znippy backend and a file backend");
    let znippy = b
        .iter()
        .find(|(d, _, _)| *d == "znippy-rust")
        .expect("znippy-rust");
    let file = b
        .iter()
        .find(|(d, _, _)| *d == "file-rust")
        .expect("file-rust");
    assert_eq!(znippy.2, ListingProvenance::ChangeTokenOnly);
    assert_eq!(file.2, ListingProvenance::TimeAndDigestWhenOnHand);
}