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