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