server/lib/src/repository.rs
//! The `Repository` config node and its backend factory — the seam between RON
//! config and a live `RepositoryBackendTrait`.
//!
//! A `Repository` is parsed from RON as inert `ron_*` fields; the wiring engine
//! (`lib::wire_holger`) then calls [`Repository::backend_from_config`] in a
//! second pass to materialize `backend_repository`, dispatching on
//! `ron_repo_type` to the right per-ecosystem backend. The three rust storage
//! flavors are the ones to keep straight: `ron_writable_archive` = live
//! seal-able znippy registry (the /sparring + /cache primary), `ron_archive_path`
//! = read-only drift snapshot, `ron_data_dir` = loose-file dev backend; the
//! `*-remote` types instead build read-only crates.io/Nexus/Artifactory
//! pull-through upstreams. Most ecosystems go through the `znippy_backed!` macro
//! (archive required); npm/nuget/oci are hand-rolled so `ron_base_url` can be
//! threaded into client-facing URLs (packument tarballs, V3 service index).
//!
//! Gotcha: `wired_upstreams`/`wired_members` are raw pointers
//! filled in *after* the graph is built and pinned — they are `null`/dangling
//! until wiring completes and must never be followed before then.
use anyhow::Context;
#[cfg(feature = "fmt-docker")]
use repository_file_oci::OciRepoFile;
#[cfg(feature = "fmt-rust")]
use repository_file_rust::RustRepoFile as RustRepo;
#[cfg(feature = "fmt-build-thing")]
use repository_znippy_build_thing::BuildThingRepoZnippy;
#[cfg(feature = "fmt-composer")]
use repository_znippy_composer::ComposerRepoZnippy;
#[cfg(feature = "fmt-conda")]
use repository_znippy_conda::CondaRepoZnippy;
#[cfg(feature = "fmt-deb")]
use repository_znippy_deb::DebRepoZnippy;
#[cfg(feature = "fmt-docker")]
use repository_znippy_docker::DockerRepoZnippy;
#[cfg(feature = "fmt-gem")]
use repository_znippy_gem::GemRepoZnippy;
#[cfg(feature = "fmt-go")]
use repository_znippy_go::GoRepoZnippy;
#[cfg(feature = "fmt-helm")]
use repository_znippy_helm::HelmRepoZnippy;
#[cfg(feature = "fmt-maven")]
use repository_znippy_maven::MavenRepoZnippy;
#[cfg(feature = "fmt-npm")]
use repository_znippy_npm::NpmRepoZnippy;
#[cfg(feature = "fmt-nuget")]
use repository_znippy_nuget::NugetRepoZnippy;
#[cfg(feature = "fmt-znippy")]
use repository_znippy_package::ZnippyRepoZnippy;
#[cfg(feature = "fmt-python")]
use repository_znippy_python::PipRepoZnippy;
#[cfg(feature = "fmt-rpm")]
use repository_znippy_rpm::RpmRepoZnippy;
#[cfg(feature = "fmt-rust")]
use repository_znippy_rust::RustRepoZnippy;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use traits::{ArtifactFormat, RepositoryBackendTrait, UpstreamAuth};
// The `rust-remote` pull-through upstream is a live `ureq` HTTP client, so it is
// gated behind `online` (like reqwest/ureq themselves). The zero-egress airgap
// tiers (`embedded`/`mini`) omit `online`, do not compile this module, and the
// `rust-remote` repo arm below fails closed instead of reaching for the network.
#[cfg(feature = "fmt-raw")]
use crate::raw::RawBackend;
#[cfg(feature = "online")]
use crate::upstream_rust::RustRegistryUpstream;
// ── Which formats THIS binary compiled (stinky 1.4, 2026-09-21) ───────────────
//
// `ron_repo_type` → the `fmt-*` feature that links its backend. One row per
// dispatch arm below; the storage-shape spellings (`rust` = file + znippy,
// `docker` + `oci` = znippy + file) collapse onto the ONE feature that names
// the format, exactly as `traits::FormatHome::also` pairs them. `group` is not
// here: a virtual group owns no backend and is served in every build. The
// remote kinds (`rust-remote`, `*-remote`) are not here either — they are
// gated by `online` / `federation`, which already refuse by name.
/// Every `(ron_repo_type, fmt feature)` pair the dispatch knows, compiled or not.
pub const FORMAT_FEATURES: &[(&str, &str)] = &[
("raw", "fmt-raw"),
("rust", "fmt-rust"),
("maven3", "fmt-maven"),
("pip", "fmt-python"),
("npm", "fmt-npm"),
("nuget", "fmt-nuget"),
("go", "fmt-go"),
("gem", "fmt-gem"),
("deb", "fmt-deb"),
("rpm", "fmt-rpm"),
("helm", "fmt-helm"),
("docker", "fmt-docker"),
("oci", "fmt-docker"),
("conda", "fmt-conda"),
("composer", "fmt-composer"),
("znippy", "fmt-znippy"),
("znippy-build-thing", "fmt-build-thing"),
];
/// The `fmt-*` feature that serves `repo_type`, or `None` for a type that is
/// not a format (a group, a remote kind, a typo).
pub fn format_feature(repo_type: &str) -> Option<&'static str> {
FORMAT_FEATURES
.iter()
.find(|(t, _)| *t == repo_type)
.map(|(_, f)| *f)
}
/// Whether the backend for `feature` is linked into THIS binary. `cfg!`, so
/// the answer is the build's, not the config's.
pub fn format_feature_compiled(feature: &str) -> bool {
#[allow(clippy::match_like_matches_macro)]
match feature {
"fmt-raw" => cfg!(feature = "fmt-raw"),
"fmt-rust" => cfg!(feature = "fmt-rust"),
"fmt-maven" => cfg!(feature = "fmt-maven"),
"fmt-python" => cfg!(feature = "fmt-python"),
"fmt-npm" => cfg!(feature = "fmt-npm"),
"fmt-nuget" => cfg!(feature = "fmt-nuget"),
"fmt-go" => cfg!(feature = "fmt-go"),
"fmt-gem" => cfg!(feature = "fmt-gem"),
"fmt-deb" => cfg!(feature = "fmt-deb"),
"fmt-rpm" => cfg!(feature = "fmt-rpm"),
"fmt-helm" => cfg!(feature = "fmt-helm"),
"fmt-docker" => cfg!(feature = "fmt-docker"),
"fmt-conda" => cfg!(feature = "fmt-conda"),
"fmt-composer" => cfg!(feature = "fmt-composer"),
"fmt-znippy" => cfg!(feature = "fmt-znippy"),
"fmt-build-thing" => cfg!(feature = "fmt-build-thing"),
_ => false,
}
}
/// Whether `repo_type` can be instantiated by this binary. A type that is not
/// a format at all (`group`, the remote kinds) answers `true` here: their own
/// arms decide, and those refusals already have names.
pub fn repo_type_compiled(repo_type: &str) -> bool {
format_feature(repo_type).is_none_or(format_feature_compiled)
}
/// The `ron_repo_type` spellings this binary serves — what the by-name refusal
/// lists so an operator sees the whole accepted set, not one missing word.
pub fn compiled_repo_types() -> Vec<&'static str> {
FORMAT_FEATURES
.iter()
.filter(|(_, f)| format_feature_compiled(f))
.map(|(t, _)| *t)
.collect()
}
/// `holger-mvp-1`: the repository names a format this build did not compile.
pub fn uncompiled_format(repo_name: &str, repo_type: &str, feature: &str) -> anyhow::Error {
anyhow::anyhow!(crate::errcode::MVP_FORMAT_NOT_COMPILED.tag(format!(
"repository '{repo_name}' is `ron_repo_type: \"{repo_type}\"` but this server was \
built without the `{feature}` feature; the formats this binary serves are \
{:?} — rebuild with `--features {feature}` (or `fmt-all`), or remove the repository",
compiled_repo_types()
)))
}
/// Build a remote-registry-federation upstream backend (Artifactory · Nexus ·
/// Azure Artifacts · AWS CodeArtifact) by hiring Skidbladnir's harbour clients.
/// Only compiled in the `federation` (`full`) tier.
#[cfg(feature = "federation")]
fn make_remote_upstream(
kind: &str,
name: String,
base: String,
repo: String,
auth: UpstreamAuth,
format: ArtifactFormat,
) -> anyhow::Result<Arc<dyn RepositoryBackendTrait>> {
use crate::upstream::SkidbladnirUpstream;
let up = match kind {
"nexus-remote" => SkidbladnirUpstream::nexus(name, base, repo, auth, format),
"artifactory-remote" => SkidbladnirUpstream::artifactory(name, base, repo, auth, format),
"azure-remote" => SkidbladnirUpstream::azure(name, base, repo, auth, format),
"aws-remote" => SkidbladnirUpstream::aws(name, base, auth, format),
other => anyhow::bail!("unknown remote upstream kind: {other}"),
};
Ok(Arc::new(up))
}
/// `federation`-off fallback: the harbour clients are compiled OUT of the
/// `mini`/`embedded` tiers (they carry no Skidbladnir / registry-client
/// dependency), so a config that asks for a remote upstream fails closed with a
/// clear rebuild hint rather than silently doing nothing.
#[cfg(not(feature = "federation"))]
fn make_remote_upstream(
kind: &str,
_name: String,
_base: String,
_repo: String,
_auth: UpstreamAuth,
_format: ArtifactFormat,
) -> anyhow::Result<Arc<dyn RepositoryBackendTrait>> {
anyhow::bail!(
"repository type `{kind}` needs upstream-registry federation, which is compiled OUT of this holger build (the `mini`/`embedded` tiers link no registry clients). Rebuild the `full` tier, e.g. `cargo build -p holger-server-lib --no-default-features --features full`."
)
}
#[derive(Serialize, Deserialize)]
pub struct Repository {
// Parsed from RON
pub ron_name: String,
pub ron_repo_type: String, // rust/java/python/raw
pub ron_upstreams: Vec<String>, // empty means no upstreams
pub ron_in: Option<RepositoryIO>,
pub ron_out: Option<RepositoryIO>,
/// Member repositories of a **virtual `group`** repo (`ron_repo_type: "group"`).
/// The group owns no storage; it resolves each request across these members in
/// declaration order (first hit wins) — the Nexus/Artifactory "group repo".
/// Only meaningful for the `group` type; empty for every other type.
#[serde(default)]
pub ron_members: Vec<String>,
/// Optional path to a znippy archive (.znippy file)
#[serde(default)]
pub ron_archive_path: Option<String>,
/// Optional storage directory for file-backed (writable) repos.
/// Defaults to /var/lib/holger/{ron_name} when omitted.
#[serde(default)]
pub ron_data_dir: Option<String>,
/// Public base URL used in config.json `dl` for the disk-backed Rust repo.
/// E.g. `https://holger.example.com`. Defaults to `https://127.0.0.1:8443`
/// when omitted.
#[serde(default)]
pub ron_base_url: Option<String>,
/// Optional path to a WRITABLE znippy archive (rust repos). When set, the repo
/// is a live `/sparring`-style registry: crates are appended into this single
/// `.znippy` (created on first push, reboot-safe re-seal per write) and can
/// later be `seal`ed into a static "drift" snapshot. Distinct from
/// `ron_archive_path` (read-only drift) and `ron_data_dir` (loose-file repo).
#[serde(default)]
pub ron_writable_archive: Option<String>,
/// Remote-cache upstream: the Nexus/Artifactory repository name to proxy
/// (e.g. `maven-central`, `cargo-proxy`). Used by the `nexus-remote` /
/// `artifactory-remote` backends together with `ron_base_url`.
#[serde(default)]
pub ron_upstream_repo: Option<String>,
/// Remote-cache upstream ecosystem/layout (`maven2`, `cargo`, `npm`, …).
/// Selects the per-format coordinate→URL mapping. **Omitted** defaults to
/// `rust` (cargo); a value present but not in
/// [`ArtifactFormat::from_format_str`]'s alias table is a **boot error**, not
/// a silent fallback — a typo like `mvn` would otherwise build a Maven remote
/// that speaks crates.io paths and 404s everything.
#[serde(default)]
pub ron_upstream_format: Option<String>,
/// **Which HANDLER reads this repository's container.**
///
/// `ron_repo_type` / `ron_upstream_format` name the container; this names
/// the reader. znippy is a container — compressed files in a directory,
/// sometimes with an extra index — so a Skidbladnir bundle, a Rust-toolchain
/// bundle and a plain sealed archive are all `ArtifactFormat::Znippy` and all
/// correct. The format string cannot tell them apart; this can.
///
/// ```text
/// ron_upstream_format: "znippy", the container
/// ron_handler: "skidbladnir", the reader
/// ```
///
/// **Omitted or empty ⇒ nothing changes** — `ron_repo_type` dispatches
/// exactly as before, and a `znippy` repo is served by the generic backend.
/// A name that is present but not in the [handler
/// catalog](crate::handlers::builtin_catalog) is a **boot error**, never a
/// fallback to the generic reader — same rule as `ron_upstream_format` above
/// and for the same reason: a typo that fell back would serve real bytes
/// through the wrong reader with no warning at boot.
///
/// Accepted values are the linked handlers plus every `.wasm` in
/// [`Holger::ron_plugin_dir`](crate::Holger) — so a new handler is this
/// string plus a module in that directory, with no holger rebuild.
#[serde(default)]
pub ron_handler: Option<String>,
/// Remote-cache upstream auth (RON enum: `None`, `Basic`, `Bearer`,
/// `ApiKey`, `Mtls`). Defaults to `None`.
#[serde(default)]
pub ron_upstream_auth: Option<UpstreamAuth>,
/// **This repository demands an authenticated identity for READS**, even
/// when the server-wide read gate (`auth.require_auth_for_reads`) is off.
///
/// Omitted or `false` ⇒ nothing changes, byte for byte: the repository is
/// served under whatever the global posture is. That is every config written
/// before this key existed.
///
/// # Why a per-REPOSITORY key, and not another global one
///
/// holger already had both halves of "authenticated reads" except the scope.
/// `auth.require_auth_for_reads` is one bool for the whole server: on, and
/// EVERY read anywhere needs a credential; off, and none does. `repo_roles`
/// is per repository, but it only refines what an ALREADY-AUTHENTICATED
/// identity may do — with the gate off it never runs, and with the gate on
/// an anonymous caller is already refused everywhere.
///
/// Neither expresses the shape an on-prem mirror against a real cloud
/// actually needs, and it is the shape the owner named: `crates` demands a
/// client certificate while the rest of the server stays open. This key is
/// that sentence, written once, on the repository it is about.
///
/// # What it does and does not do
///
/// It requires an IDENTITY, not a particular mechanism. Whatever the
/// server's `auth.methods` can authenticate satisfies it — an mTLS client
/// certificate, an OIDC bearer, a static token. The mechanism is the
/// operator's choice; what this key says is that *anonymous is not one of
/// them, here*. Authorization on top of that is `repo_roles`' job, unchanged:
/// name a subject `Denied` and it is refused even with a valid certificate.
///
/// For mTLS specifically it pairs with
/// [`TlsSettings::client_auth_optional`](crate::exposed::tls::TlsSettings::client_auth_optional) —
/// without that, the transport refuses a certless client before any
/// repository name exists, which is the across-the-board posture and makes
/// this key unreachable.
///
/// # It refuses rather than degrades
///
/// A repository that sets this on a server with no `auth.methods` at all
/// **refuses the boot, naming the repository**. Nothing could ever
/// authenticate, so the repository would be permanently unreachable while
/// the config read as merely "protected" — and the alternative reading, that
/// it should quietly serve open, is the one thing a credential declaration
/// must never mean.
#[serde(default)]
pub ron_require_auth: bool,
/// **The least role that may read or write this repository** — a floor under
/// the policy's own answer, never a raise above it. `Some(Admin)` makes a
/// repository an administrator's alone: every read and every write needs an
/// identity whose role HERE is at least this, and an identity below it is
/// refused (403) even when its role would read or write anywhere else.
/// Implies [`ron_require_auth`](Self::ron_require_auth) (a floor is checked on
/// an identity, so the read gate applies).
///
/// Why it exists (holger-ui's thin-layer move, 2026-10-04): the estate's
/// ledger backup (`monetize-backup`) was gated with `ron_require_auth`, which
/// meant "the console's bearer only" while that bearer was the only identity
/// there was. Once the people hold holger passkeys, every one of them
/// authenticates — and a reader would read the backup, a writer write it.
/// The floor says what was meant: the repository is an admin's.
#[serde(default)]
pub ron_require_role: Option<crate::auth::Role>,
/// **Who reads this repository** (HCON-5): `visibility: Public` is read with no sign-in
/// even when the box's `access.reads` is `Gated`; no word is `Private` (read under the box's
/// posture).
#[serde(default, alias = "visibility")]
pub ron_visibility: crate::posture::Visibility,
/// Optional path to a DER-encoded trusted root certificate. When set on a
/// `znippy` repo, the backing archive's provenance (per-artifact + per-archive
/// detached CMS signatures) is verified against this root BEFORE the repo is
/// served; verification failure — including an unsigned archive or a signer
/// that does not chain to this root — fails the wiring closed. Requires the
/// server to be built with the `sign` feature. Omitted ⇒ served unverified
/// (the default, byte-for-byte unchanged behaviour).
#[serde(default)]
pub ron_trusted_roots: Option<String>,
/// Negative-cache TTL in **seconds** for a proxy repo (one with
/// `ron_upstreams`). When set and non-zero, a coordinate that misses the
/// primary AND every upstream is remembered as a miss for this long, so a
/// repeated fetch of a not-yet-published artifact short-circuits without
/// re-walking the (potentially slow / network) upstream chain — the
/// Nexus/Artifactory "negative cache". Omitted or `0` ⇒ disabled (every miss
/// re-queries the upstreams, byte-for-byte the prior behaviour). Only
/// meaningful on a repo with upstreams; ignored otherwise.
#[serde(default)]
pub ron_negative_cache_secs: Option<u64>,
// Wired in second pass
#[serde(skip_serializing, skip_deserializing, default)]
pub backend_repository: Option<Arc<dyn RepositoryBackendTrait>>,
/// The digest this upstream's ORIGIN publishes for a coordinate, when this
/// repository is an upstream that can be asked.
///
/// `backend_repository` above is type-erased to `RepositoryBackendTrait`, which
/// has no way to ask "what does the registry itself say this artifact hashes
/// to?". That question is what separates caching bytes from LAUNDERING them:
/// without it a pull-through repo writes whatever the origin handed over and
/// then re-attests it under holger's own name. So the concrete backend's
/// attestation seam is kept here, alongside the erased one, and
/// `Holger::effective_backend` hands it to the proxy.
///
/// `None` ⇒ this upstream cannot be asked (it has no such seam, or it is not an
/// upstream at all), and the proxy caches exactly as it always did — silently
/// turning every existing pull-through repo into a pass-through would be a
/// product change nobody asked for.
#[serde(skip_serializing, skip_deserializing, default)]
pub upstream_attestation: Option<Arc<dyn traits::Upstream>>,
#[serde(skip_serializing, skip_deserializing, default)]
pub wired_upstreams: Vec<*const Repository>, // or &Repository pinned after build
/// Wired members of a `group` repo (resolved from `ron_members` in the second
/// pass). Same pinned-pointer discipline as `wired_upstreams`: `null`/dangling
/// until wiring completes, never followed before then.
#[serde(skip_serializing, skip_deserializing, default)]
pub wired_members: Vec<*const Repository>,
}
/// **Where one repository's bytes are on disk** — the one answer the flow picture,
/// the `disk_bytes` meter and the customer read (owner 2026-09-27: "the customer
/// can see where their bytes are").
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoragePlace {
/// `archive (writable)` | `archive` | `files`.
pub kind: &'static str,
pub path: PathBuf,
}
impl Repository {
/// **Where this repository keeps its bytes.** A group owns none (it is a view
/// over its members). A writable archive, a read-only archive, and a loose-file
/// data directory — defaulting to `/var/lib/holger/<name>` exactly as the
/// backends do when none is written.
pub fn storage_places(&self) -> Vec<StoragePlace> {
if self.ron_repo_type == "group" {
return Vec::new();
}
if let Some(a) = &self.ron_writable_archive {
return vec![StoragePlace {
kind: "archive (writable)",
path: PathBuf::from(a),
}];
}
if let Some(a) = &self.ron_archive_path {
return vec![StoragePlace {
kind: "archive",
path: PathBuf::from(a),
}];
}
let dir = self
.ron_data_dir
.clone()
.unwrap_or_else(|| format!("/var/lib/holger/{}", self.ron_name));
vec![StoragePlace {
kind: "files",
path: PathBuf::from(dir),
}]
}
/// Materialize this repository's backend using only the handlers **linked**
/// into this holger. The plugin directory is not read, because a single
/// `Repository` does not own it — [`Holger::instantiate_backends`] builds
/// one catalog for the whole config and passes it to
/// [`Repository::backend_from_config_with`].
pub fn backend_from_config(&mut self) -> anyhow::Result<()> {
self.backend_from_config_with(&crate::handlers::builtin_catalog()?)
}
pub fn backend_from_config_with(
&mut self,
handlers: &holger_plugin_host::HandlerCatalog,
) -> anyhow::Result<()> {
// A named handler IS the backend, whatever container it reads: the
// reader is one level below the format, so it takes precedence over the
// `ron_repo_type` dispatch below rather than living inside one arm of it.
//
// Absent, empty or whitespace ⇒ fall straight through to the match, so a
// config that says nothing about handlers behaves byte-for-byte as it did
// before this field existed.
let named_handler = self
.ron_handler
.as_deref()
.map(str::trim)
.filter(|h| !h.is_empty())
.map(str::to_string);
if let Some(handler) = named_handler {
return self.wire_handler(&handler, handlers);
}
// Build a znippy-backed repo that requires an archive path. Unused when a
// build names only `fmt-raw` + `fmt-rust` (every user is a `#[cfg]`ed arm).
#[allow(unused_macros)]
macro_rules! znippy_backed {
($ty:ty, $label:expr) => {{
if let Some(archive_path) = &self.ron_archive_path {
let repo = <$ty>::with_archive(
self.ron_name.clone(),
PathBuf::from(archive_path),
).with_context(|| format!(
"repo '{}' ({}): failed to open znippy archive '{}' — corrupt, \
truncated, or written by an incompatible znippy version",
self.ron_name, $label, archive_path
))?;
self.backend_repository = Some(Arc::new(repo));
} else {
anyhow::bail!(concat!($label, " repository requires ron_archive_path to a znippy archive"));
}
Ok(())
}};
}
match self.ron_repo_type.as_str() {
#[cfg(feature = "fmt-rust")]
"rust" => {
if let Some(writable) = &self.ron_writable_archive {
// Writable znippy-archive registry (the /sparring + /cache
// primary): seal-able into a drift snapshot, reboot-safe.
let mut repo = RustRepoZnippy::with_writable_archive(
self.ron_name.clone(),
PathBuf::from(writable),
)
.with_auth_required(self.ron_require_auth);
if let Some(url) = &self.ron_base_url {
repo = repo.with_base_url(url.clone());
}
self.backend_repository = Some(Arc::new(repo));
} else if let Some(archive_path) = &self.ron_archive_path {
let base_url = self
.ron_base_url
.clone()
.unwrap_or_else(|| "https://127.0.0.1:8443".to_string());
let repo = RustRepoZnippy::with_archive_and_url(
self.ron_name.clone(),
PathBuf::from(archive_path),
base_url,
)
.with_context(|| {
format!(
"repo '{}' (rust): failed to open znippy archive '{}' — corrupt, \
truncated, or written by an incompatible znippy version",
self.ron_name, archive_path
)
})?
.with_auth_required(self.ron_require_auth);
self.backend_repository = Some(Arc::new(repo));
} else {
// Writable, disk-backed dev backend. Requires a storage dir.
let dir = self.ron_data_dir.clone().ok_or_else(|| {
anyhow::anyhow!(
"Rust file-backed repository requires ron_data_dir (storage directory)"
)
})?;
let base_url = self
.ron_base_url
.clone()
.unwrap_or_else(|| "https://127.0.0.1:8443".to_string());
let repo = RustRepo::new(self.ron_name.clone(), PathBuf::from(dir), base_url);
self.backend_repository = Some(Arc::new(repo));
}
Ok(())
}
// Read-only REMOTE rust registry (the /cache upstream). `ron_base_url` names
// the sparse index base; none is refused (`holger-offline-3`), never crates.io.
// Pair with a writable "rust" + `ron_upstreams` to get a caching mirror.
#[cfg(feature = "online")]
"rust-remote" => {
let repo = match &self.ron_base_url {
Some(idx) => RustRegistryUpstream::new(
self.ron_name.clone(),
idx.clone(),
format!("{}/crates", idx.trim_end_matches('/')),
),
// No upstream fallback (Holger v2.0 B2): an air-gapped default never
// reaches for a public registry it was not told to.
None => anyhow::bail!(holger_errcode::OFFLINE_NO_UPSTREAM.tag(format!(
"repository '{}' is a 'rust-remote' upstream and names no `ron_base_url`, so it has no upstream: holger falls back to none. Name one, e.g. ron_base_url: Some(\"https://index.crates.io\")",
self.ron_name
))),
};
// ONE Arc, registered twice: erased for serving, concrete for the
// digest question. The crates.io sparse index publishes a `cksum`
// per version, so this upstream CAN be asked — and once it is
// registered here the proxy refuses bytes that contradict it.
let repo = Arc::new(repo);
self.upstream_attestation = Some(repo.clone());
self.backend_repository = Some(repo);
Ok(())
}
// Zero-egress airgap tiers (`online` off) link no outbound HTTP client,
// so the pull-through `rust-remote` upstream is not compiled. Fail
// CLOSED with an actionable message rather than silently degrading —
// an airgap operator must use a local 'rust' repo, not a network mirror.
#[cfg(not(feature = "online"))]
"rust-remote" => Err(anyhow::anyhow!(
"repo '{}' is 'rust-remote' (a pull-through network upstream) but this \
binary was built WITHOUT the `online` feature (zero-egress airgap tier) \
— no HTTP client is linked. Rebuild with `--features online`, or serve \
crates from a local 'rust' repository backed by a znippy archive.",
self.ron_name
)),
// Read-only REMOTE upstream-registry-federation harbours (holger's
// `full`-tier feature). holger owns the orchestration (which repos
// proxy which upstreams, pull-through caching via `ProxyBackend`,
// compression via znippy); the registry-client MECHANISM lives in
// Skidbladnir's `federation` "ship" (see `crate::upstream`). Shared
// config fields: `ron_base_url` = harbour base, `ron_upstream_repo` =
// repo/feed, `ron_upstream_format` = ecosystem (default cargo),
// `ron_upstream_auth` = creds (default None). Pair with a writable
// primary + `ron_upstreams` for transparent pull-through caching.
// - `nexus-remote` → `ron_base_url` = Nexus base, `ron_upstream_repo` = hosted/proxy repo.
// - `artifactory-remote` → `ron_base_url` = Artifactory base, `ron_upstream_repo` = repo; auth may be Bearer or JFrog API-key.
// - `azure-remote` → `ron_base_url` = Azure DevOps org, `ron_upstream_repo` = "{project}/{feed}" (or "{feed}" org-scoped); auth = PAT-as-Basic or AAD Bearer.
// - `aws-remote` → `ron_base_url` = CodeArtifact repository endpoint; auth = Bearer authorization token.
"nexus-remote" | "artifactory-remote" | "azure-remote" | "aws-remote" => {
let base = self.ron_base_url.clone().ok_or_else(|| {
anyhow::anyhow!(
"{}: requires ron_base_url (the harbour base URL / org / repository endpoint)",
self.ron_repo_type
)
})?;
// aws-remote addresses artifacts off the single repository endpoint
// and needs no separate repo name; the others require one.
let repo = if self.ron_repo_type == "aws-remote" {
self.ron_upstream_repo.clone().unwrap_or_default()
} else {
self.ron_upstream_repo.clone().ok_or_else(|| {
anyhow::anyhow!(
"{}: requires ron_upstream_repo (the hosted/proxy repo or project/feed)",
self.ron_repo_type
)
})?
};
// Omitted ⇒ default to Rust. PRESENT-but-unparseable ⇒ hard fail,
// like the missing ron_base_url / ron_upstream_repo above.
// Collapsing the two cases with `.and_then(…).unwrap_or(Rust)`
// meant a typo (`"mvn"`, `"jar"`, `"gradle"` — none of them
// aliases) silently built a Maven remote with a crates.io-shaped
// upstream layout, so every coordinate 404'd with no warning at
// boot and the operator saw an upstream that simply never had
// anything.
let format = match self.ron_upstream_format.as_deref() {
None => ArtifactFormat::Rust,
Some(s) => ArtifactFormat::from_format_str(s).ok_or_else(|| {
anyhow::anyhow!(
"{}: unrecognised ron_upstream_format '{}'. Accepted: \
maven/maven2/maven3/java, pip/pypi/python, rust/cargo, \
go/golang, nuget/dotnet, npm/node, gem/ruby/rubygems, \
deb/debian/apt, rpm/yum/dnf, helm/chart, docker/oci, \
conda/anaconda, composer/php, znippy, raw",
self.ron_repo_type,
s
)
})?,
};
let auth = self.ron_upstream_auth.clone().unwrap_or_default();
let backend = make_remote_upstream(
&self.ron_repo_type,
self.ron_name.clone(),
base,
repo,
auth,
format,
)?;
self.backend_repository = Some(backend);
Ok(())
}
#[cfg(feature = "fmt-maven")]
"maven3" => znippy_backed!(MavenRepoZnippy, "Maven3"),
#[cfg(feature = "fmt-python")]
"pip" => znippy_backed!(PipRepoZnippy, "Pip"),
// npm threads `ron_base_url` into the packument tarball links (R2): no
// hardcoded host, so pulls work behind any public host / reverse proxy.
// Unset ⇒ host-relative URLs.
#[cfg(feature = "fmt-npm")]
"npm" => {
if let Some(archive_path) = &self.ron_archive_path {
let repo = NpmRepoZnippy::with_archive_and_url(
self.ron_name.clone(),
PathBuf::from(archive_path),
self.ron_base_url.clone().unwrap_or_default(),
)?;
self.backend_repository = Some(Arc::new(repo));
Ok(())
} else {
// No archive: the intake chain's npm door (`promotion.intake.npm`, lane P5),
// which holds nothing of its own; `Holger::validate_npm_door` refused any
// other archive-less npm repository before this ran.
self.backend_repository =
Some(Arc::new(NpmRepoZnippy::new(self.ron_name.clone())));
Ok(())
}
}
// NuGet threads `ron_base_url` into the V3 service-index
// PackageBaseAddress so remote clients are pointed at the public host,
// not a hardcoded loopback. Unset ⇒ the dev-loopback default.
#[cfg(feature = "fmt-nuget")]
"nuget" => {
if let Some(archive_path) = &self.ron_archive_path {
let mut repo = NugetRepoZnippy::with_archive(
self.ron_name.clone(),
PathBuf::from(archive_path),
)?;
if let Some(url) = &self.ron_base_url {
repo = repo.with_base_url(url.clone());
}
self.backend_repository = Some(Arc::new(repo));
Ok(())
} else {
anyhow::bail!("Nuget repository requires ron_archive_path to a znippy archive");
}
}
#[cfg(feature = "fmt-go")]
"go" => znippy_backed!(GoRepoZnippy, "Go"),
#[cfg(feature = "fmt-gem")]
"gem" => znippy_backed!(GemRepoZnippy, "Gem"),
#[cfg(feature = "fmt-deb")]
"deb" => znippy_backed!(DebRepoZnippy, "Deb"),
#[cfg(feature = "fmt-rpm")]
"rpm" => znippy_backed!(RpmRepoZnippy, "Rpm"),
#[cfg(feature = "fmt-helm")]
"helm" => znippy_backed!(HelmRepoZnippy, "Helm"),
#[cfg(feature = "fmt-docker")]
"docker" => znippy_backed!(DockerRepoZnippy, "Docker"),
// Writable OCI registry (Distribution Spec v2). Push target for the
// modern Helm flow (`helm push oci://…`) and any OCI artifact; needs
// no archive — it owns a disk-backed, content-addressed store.
#[cfg(feature = "fmt-docker")]
"oci" => {
let dir = self
.ron_data_dir
.clone()
.unwrap_or_else(|| format!("/var/lib/holger/{}", self.ron_name));
let repo = OciRepoFile::new(self.ron_name.clone(), PathBuf::from(dir))?;
self.backend_repository = Some(Arc::new(repo));
Ok(())
}
// Generic / **raw** writable bucket for arbitrary blobs, addressed by
// path (Nexus/Artifactory "raw hosted repo"). Owns a disk-backed,
// path-addressed loose-file store — no archive, no index synthesis; a
// `ron_data_dir` is its only requirement (defaults under /var/lib).
#[cfg(feature = "fmt-raw")]
"raw" => {
let dir = self
.ron_data_dir
.clone()
.unwrap_or_else(|| format!("/var/lib/holger/{}", self.ron_name));
let repo = RawBackend::new(self.ron_name.clone(), PathBuf::from(dir));
self.backend_repository = Some(Arc::new(repo));
Ok(())
}
#[cfg(feature = "fmt-conda")]
"conda" => znippy_backed!(CondaRepoZnippy, "Conda"),
// Hand-rolled rather than `znippy_backed!` for the same reason
// nuget is: the synthesized p2 metadata embeds an ABSOLUTE
// `dist.url`, and Composer follows it verbatim — leak the loopback
// default and every remote `composer install` downloads from the
// wrong host. `ron_base_url` must reach the backend.
#[cfg(feature = "fmt-composer")]
"composer" => {
if let Some(archive_path) = &self.ron_archive_path {
let mut repo = ComposerRepoZnippy::with_archive(
self.ron_name.clone(),
PathBuf::from(archive_path),
)
.with_context(|| {
format!(
"repo '{}' (Composer): failed to open znippy archive '{}' — corrupt, \
truncated, or written by an incompatible znippy version",
self.ron_name, archive_path
)
})?;
if let Some(url) = &self.ron_base_url {
repo = repo.with_base_url(url.clone());
}
self.backend_repository = Some(Arc::new(repo));
Ok(())
} else {
anyhow::bail!(
"Composer repository requires ron_archive_path to a znippy archive"
);
}
}
// A virtual `group` repo owns no storage of its own — it aggregates
// its `ron_members`' backends. The concrete `GroupBackend` is built in
// the wiring pass (`wire_holger`) once every member backend exists, so
// there is nothing to materialize here.
"group" => {
if self.ron_members.is_empty() {
anyhow::bail!(
"group repository '{}' requires ron_members (member repo names)",
self.ron_name
);
}
Ok(())
}
// The forge's BUILD HISTORY: attestations, not packages. Read-only —
// the package is derived from the artifacts and regenerated if lost,
// so a writable one would be a second writer of provenance.
#[cfg(feature = "fmt-build-thing")]
"znippy-build-thing" => znippy_backed!(BuildThingRepoZnippy, "BuildThing"),
#[cfg(feature = "fmt-znippy")]
"znippy" => {
if self.ron_trusted_roots.is_some() {
// Provenance-enforced path (opt-in via `ron_trusted_roots`).
self.wire_verified_znippy()
} else {
znippy_backed!(ZnippyRepoZnippy, "Znippy")
}
}
// A format this build did NOT compile lands here because its arm above
// is `#[cfg]`ed away. Refuse BY NAME (stinky 1.4): the repository, the
// format and the feature that would serve it — never a disabled repo
// that looks like an empty one.
other => match format_feature(other) {
Some(feature) => Err(uncompiled_format(&self.ron_name, other, feature)),
None => anyhow::bail!("Unsupported repository type: {}", other),
},
}
}
/// Mount the package handler named by `ron_handler` over this repository's
/// data directory.
///
/// The repository name and the storage are the operator's — a handler never
/// picks where it is served from or where it writes — so both are supplied
/// here rather than read out of the handler's manifest.
///
/// An unrecognised name is an error carrying the accepted set (see
/// [`holger_plugin_host::HandlerError`]); nothing here falls back to the
/// generic backend.
fn wire_handler(
&mut self,
handler: &str,
handlers: &holger_plugin_host::HandlerCatalog,
) -> anyhow::Result<()> {
let dir = self
.ron_data_dir
.clone()
.unwrap_or_else(|| format!("/var/lib/holger/{}", self.ron_name));
let backend = handlers
.mount_dir(handler, &self.ron_name, PathBuf::from(&dir))
.with_context(|| {
format!(
"repo '{}': ron_handler could not be honoured",
self.ron_name
)
})?;
self.backend_repository = Some(backend.into_backend());
Ok(())
}
/// Wire a `znippy` repo with provenance verification enabled (built with the
/// `sign` feature). Loads the DER trusted root from `ron_trusted_roots` and
/// verifies the backing archive before it is served; any read/parse/verify
/// failure fails the wiring closed.
#[cfg(all(feature = "sign", feature = "fmt-znippy"))]
fn wire_verified_znippy(&mut self) -> anyhow::Result<()> {
let roots_path = self
.ron_trusted_roots
.as_ref()
.expect("caller checks ron_trusted_roots is Some");
let archive_path = self.ron_archive_path.as_ref().ok_or_else(|| {
anyhow::anyhow!("Znippy repository requires ron_archive_path to a znippy archive")
})?;
let repo = ZnippyRepoZnippy::with_archive_verified_der_roots(
self.ron_name.clone(),
PathBuf::from(archive_path),
&[PathBuf::from(roots_path)],
)
.with_context(|| {
format!(
"repo '{}' (Znippy): provenance verification of archive '{}' against trusted \
root '{}' failed — archive is unsigned, tampered, or not chained to the root",
self.ron_name, archive_path, roots_path
)
})?;
self.backend_repository = Some(Arc::new(repo));
Ok(())
}
/// Fail-closed stub for builds WITHOUT the `sign` feature: a configured
/// `ron_trusted_roots` cannot be honoured, so refuse to wire rather than
/// silently serving the archive unverified.
#[cfg(all(feature = "fmt-znippy", not(feature = "sign")))]
fn wire_verified_znippy(&mut self) -> anyhow::Result<()> {
anyhow::bail!(
"repo '{}': ron_trusted_roots is configured but this server was built without both \
`sign` and `fmt-znippy`; provenance cannot be enforced — rebuild with \
`--features sign,fmt-znippy` or remove ron_trusted_roots",
self.ron_name
)
}
}
/// A repository's side of the wiring graph: the storage and exposed endpoints
/// it names. `wire_holger` checks both names resolve; nothing is pointed at.
#[derive(Serialize, Deserialize)]
pub struct RepositoryIO {
pub ron_storage_endpoint: String,
pub ron_exposed_endpoint: String,
}
/// A minimal, fully-specified `Repository` for tests.
///
/// `pub(crate)` and test-only so `lib.rs`'s wiring tests build the same shape
/// rather than keeping a second copy that drifts every time a field is added —
/// which is exactly how `upstream_attestation` would have been missed.
#[cfg(test)]
pub(crate) fn test_repo(name: &str) -> Repository {
Repository {
ron_name: name.to_string(),
ron_require_auth: false,
ron_require_role: None,
ron_visibility: Default::default(),
ron_repo_type: "rust".to_string(),
ron_upstreams: vec![],
ron_in: None,
ron_out: None,
ron_members: vec![],
ron_archive_path: None,
ron_data_dir: None,
ron_base_url: None,
ron_writable_archive: None,
ron_upstream_repo: None,
ron_upstream_format: None,
ron_handler: None,
ron_upstream_auth: None,
ron_trusted_roots: None,
ron_negative_cache_secs: None,
backend_repository: None,
upstream_attestation: None,
wired_upstreams: vec![],
wired_members: vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
use traits::ArtifactId;
use super::test_repo as rust_repo;
/// `ron_writable_archive` (the `/sparring` + `/cache`-primary config) builds a
/// WRITABLE znippy-backed rust backend that accepts a crate and serves it back.
#[test]
fn writable_archive_config_builds_writable_backend() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("sparring.znippy");
let mut repo = rust_repo("sparring");
repo.ron_writable_archive = Some(archive.to_string_lossy().into_owned());
repo.backend_from_config().unwrap();
let backend = repo.backend_repository.as_ref().expect("backend built");
assert!(
backend.is_writable(),
"ron_writable_archive must yield a writable backend"
);
let id = ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
};
backend.put(&id, b"crate-bytes").unwrap();
assert_eq!(
backend.fetch(&id).unwrap().as_deref(),
Some(b"crate-bytes".as_slice()),
"writable backend must serve the crate it was given"
);
}
/// `ron_archive_path` stays the read-only "drift" backend: seal a sparring
/// archive (via the writable path), reopen it read-only, confirm not writable
/// but still serves the sealed crate.
#[test]
fn archive_path_config_is_read_only_drift() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("drift.znippy");
let id = ArtifactId {
namespace: None,
name: "x".into(),
version: "1.0.0".into(),
};
let mut seed = rust_repo("seed");
seed.ron_writable_archive = Some(archive.to_string_lossy().into_owned());
seed.backend_from_config().unwrap();
seed.backend_repository
.unwrap()
.put(&id, b"sealed-bytes")
.unwrap();
let mut ro = rust_repo("drift");
ro.ron_archive_path = Some(archive.to_string_lossy().into_owned());
ro.backend_from_config().unwrap();
let backend = ro.backend_repository.unwrap();
assert!(
!backend.is_writable(),
"ron_archive_path must yield a read-only backend"
);
assert_eq!(
backend.fetch(&id).unwrap().as_deref(),
Some(b"sealed-bytes".as_slice()),
"read-only drift backend must serve the sealed crate"
);
}
/// `rust-remote` builds a read-only remote-registry upstream (the /cache
/// upstream) over the index it names, no archive/dir required.
#[test]
fn rust_remote_config_builds_read_only_upstream() {
let mut repo = rust_repo("crates-io");
repo.ron_repo_type = "rust-remote".to_string();
repo.ron_base_url = Some("https://index.crates.io".into());
repo.backend_from_config().unwrap();
let backend = repo.backend_repository.as_ref().expect("backend built");
assert!(!backend.is_writable(), "remote upstream must be read-only");
assert_eq!(backend.name(), "crates-io");
}
/// `nexus-remote` builds a read-only Nexus upstream from `ron_base_url` +
/// `ron_upstream_repo`; missing required config fails cleanly. Federation
/// (`full`) tier only — the mechanism is Skidbladnir's ship.
#[cfg(feature = "federation")]
#[test]
fn nexus_remote_config_builds_read_only_upstream() {
let mut repo = rust_repo("nexus-cache");
repo.ron_repo_type = "nexus-remote".to_string();
// missing ron_base_url + ron_upstream_repo -> error
assert!(
repo.backend_from_config().is_err(),
"nexus-remote needs base_url + repo"
);
repo.ron_base_url = Some("https://nexus.corp".to_string());
repo.ron_upstream_repo = Some("crates-proxy".to_string());
repo.ron_upstream_auth = Some(UpstreamAuth::Basic {
username: "u".into(),
password: "p".into(),
});
repo.backend_from_config().unwrap();
let backend = repo.backend_repository.as_ref().expect("backend built");
assert!(!backend.is_writable(), "nexus upstream must be read-only");
assert_eq!(backend.name(), "nexus-cache");
}
/// `artifactory-remote` builds a read-only Artifactory upstream (`full` tier).
#[cfg(feature = "federation")]
#[test]
fn artifactory_remote_config_builds_read_only_upstream() {
let mut repo = rust_repo("jfrog-cache");
repo.ron_repo_type = "artifactory-remote".to_string();
repo.ron_base_url = Some("https://jfrog.corp".to_string());
repo.ron_upstream_repo = Some("maven-remote".to_string());
repo.ron_upstream_format = Some("maven2".to_string());
repo.backend_from_config().unwrap();
let backend = repo.backend_repository.as_ref().expect("backend built");
assert!(
!backend.is_writable(),
"artifactory upstream must be read-only"
);
assert_eq!(backend.format(), ArtifactFormat::Maven3);
}
/// A typo'd `ron_upstream_format` must FAIL THE BOOT, not silently fall back
/// to the Rust layout.
///
/// RED-when-broken: `"mvn"`, `"jar"` and `"gradle"` are not aliases (the
/// Maven spellings are maven/maven2/maven3/java), so the old
/// `.and_then(from_format_str).unwrap_or(Rust)` built a Maven remote that
/// asked crates.io-shaped upstream paths and 404'd every coordinate, with no
/// warning anywhere.
#[cfg(feature = "federation")]
#[test]
fn unrecognised_upstream_format_fails_boot_instead_of_defaulting_to_rust() {
for typo in ["mvn", "jar", "gradle"] {
let mut repo = rust_repo("jfrog-cache");
repo.ron_repo_type = "artifactory-remote".to_string();
repo.ron_base_url = Some("https://jfrog.corp".to_string());
repo.ron_upstream_repo = Some("maven-remote".to_string());
repo.ron_upstream_format = Some(typo.to_string());
let err = repo
.backend_from_config()
.expect_err("an unrecognised format must not silently become Rust");
let msg = err.to_string();
assert!(
msg.contains(typo),
"error should name the bad value, got: {msg}"
);
assert!(
msg.contains("maven3"),
"error should list accepted spellings, got: {msg}"
);
}
// Omission still defaults to Rust — the fix must not turn the documented
// default into an error.
let mut omitted = rust_repo("jfrog-cache");
omitted.ron_repo_type = "artifactory-remote".to_string();
omitted.ron_base_url = Some("https://jfrog.corp".to_string());
omitted.ron_upstream_repo = Some("crates-remote".to_string());
omitted.ron_upstream_format = None;
omitted
.backend_from_config()
.expect("an omitted format defaults to Rust");
assert_eq!(
omitted.backend_repository.as_ref().unwrap().format(),
ArtifactFormat::Rust
);
}
/// The two new cloud harbours (`azure-remote`, `aws-remote`) build read-only
/// upstreams from RON config (`full` tier).
#[cfg(feature = "federation")]
#[test]
fn azure_and_aws_remote_configs_build_read_only_upstreams() {
let mut az = rust_repo("azure-cache");
az.ron_repo_type = "azure-remote".to_string();
az.ron_base_url = Some("contoso".to_string()); // Azure DevOps org
az.ron_upstream_repo = Some("proj/myfeed".to_string()); // project/feed
az.ron_upstream_format = Some("npm".to_string());
az.backend_from_config().unwrap();
assert!(!az.backend_repository.as_ref().unwrap().is_writable());
let mut aws = rust_repo("aws-cache");
aws.ron_repo_type = "aws-remote".to_string();
aws.ron_base_url = Some(
"https://d-111122223333.d.codeartifact.us-west-2.amazonaws.com/npm/my-repo".to_string(),
);
aws.ron_upstream_format = Some("npm".to_string());
aws.ron_upstream_auth = Some(UpstreamAuth::Bearer {
token: "AUTHTOK".into(),
});
aws.backend_from_config().unwrap(); // aws-remote needs no ron_upstream_repo
assert!(!aws.backend_repository.as_ref().unwrap().is_writable());
}
/// `mini`/`embedded` tier: the federation mechanism is compiled OUT, so a
/// config asking for a remote harbour FAILS CLOSED (never a silent no-op).
#[cfg(not(feature = "federation"))]
#[test]
fn remote_upstream_configs_fail_closed_without_federation() {
for kind in [
"nexus-remote",
"artifactory-remote",
"azure-remote",
"aws-remote",
] {
let mut repo = rust_repo("cache");
repo.ron_repo_type = kind.to_string();
repo.ron_base_url = Some("https://example.corp".to_string());
repo.ron_upstream_repo = Some("some-repo".to_string());
let err = repo.backend_from_config().unwrap_err().to_string();
assert!(
err.contains("federation") && err.contains("full"),
"{kind}: mini must fail closed with a rebuild-`full` hint, got: {err}"
);
}
}
// ── ron_handler: the config names the READER, not just the container ──────
/// A `znippy` repo holding a Skidbladnir bundle as a loose file in its data
/// directory. The generic znippy backend cannot serve this at all (it wants a
/// single `.znippy` ARCHIVE via `ron_archive_path`), which is what makes the
/// pair below a real red/green rather than two greens.
fn bundle_repo(name: &str, dir: &std::path::Path) -> Repository {
let mut r = rust_repo(name);
r.ron_repo_type = "znippy".to_string();
r.ron_data_dir = Some(dir.to_string_lossy().into_owned());
r
}
/// Bytes chosen to survive nothing but a byte-exact path: a NUL, a comma, a
/// colon and a 0xff, which is what znippy's JSON-ish row format could not
/// carry.
const BUNDLE_BYTES: &[u8] = b"\x00sealed,bundle:bytes\xff";
fn seed_bundle(dir: &std::path::Path) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("tillsynia-20260706.znippy"), BUNDLE_BYTES).unwrap();
}
/// **The claim.** One repository, one directory of bytes, two configs that
/// differ by a single string — and only the one naming the reader can serve.
#[test]
fn a_named_handler_serves_bytes_the_generic_znippy_backend_cannot() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("bundles");
seed_bundle(&dir);
// RED: the same repo with NO ron_handler. The generic dispatch is
// untouched, so it still demands ron_archive_path and refuses to wire.
let mut generic = bundle_repo("bundles", &dir);
let err = generic
.backend_from_config()
.expect_err("the generic znippy backend wired without an archive");
assert!(
err.to_string().contains("ron_archive_path"),
"the pre-existing generic-znippy error changed: {err}"
);
assert!(
generic.backend_repository.is_none(),
"a failed wiring left a backend behind"
);
// GREEN: name the reader.
let mut handled = bundle_repo("bundles", &dir);
handled.ron_handler = Some("skidbladnir".to_string());
handled
.backend_from_config()
.expect("ron_handler = skidbladnir must wire");
let backend = handled.backend_repository.as_ref().expect("backend built");
// Applied output, not a state round-trip: the bytes off the wire.
assert_eq!(backend.name(), "bundles");
assert_eq!(
backend.format(),
ArtifactFormat::Znippy,
"the container is still znippy"
);
let id = ArtifactId {
namespace: None,
name: "tillsynia".into(),
version: "20260706".into(),
};
assert_eq!(
backend.fetch(&id).expect("fetch"),
Some(traits::Bytes::from_static(BUNDLE_BYTES)),
"the handler resolved but served the wrong bytes"
);
// And a route NO generic znippy backend has: the shared member
// classifier on the wire. This is the assertion that cannot pass if the
// generic reader had been selected.
let (status, _, body) = backend
.handle_http2_request(
"GET",
"/bundles/tillsynia/20260706/classify/secrets/secrets.age",
b"",
)
.expect("classify");
assert_eq!(status, 200);
assert_eq!(
String::from_utf8_lossy(&body),
"kind\tsecret\ncomponent\tsecrets\nencrypted\ttrue"
);
}
/// The two handlers holger links are both reachable BY NAME, and they are
/// genuinely different readers — not one handler answering to two strings.
#[test]
fn each_linked_handler_answers_to_its_own_name() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("bundles");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("rust-dev-rhel8-1.97.1.znippy"), BUNDLE_BYTES).unwrap();
let mut r = bundle_repo("toolchains", &dir);
r.ron_handler = Some("rust-toolchain".to_string());
r.backend_from_config()
.expect("ron_handler = rust-toolchain must wire");
let backend = r.backend_repository.as_ref().expect("backend built");
// The rust-toolchain classifier, which Skidbladnir's would not produce.
let (status, _, body) = backend
.handle_http2_request(
"GET",
"/toolchains/rust-dev-rhel8/1.97.1/classify/vendor/serde-1.0.219/Cargo.toml",
b"",
)
.expect("classify");
assert_eq!(status, 200);
let line = String::from_utf8_lossy(&body);
assert!(
line.starts_with("kind\tvendor-crate"),
"rust-toolchain served a foreign classification: {line}"
);
// Same repo, same bytes, the OTHER handler: Skidbladnir does not claim a
// vendored crate path, so the two names really are two readers.
let mut s = bundle_repo("toolchains", &dir);
s.ron_handler = Some("skidbladnir".to_string());
s.backend_from_config().unwrap();
let (status, _, _) = s
.backend_repository
.as_ref()
.unwrap()
.handle_http2_request(
"GET",
"/toolchains/rust-dev-rhel8/1.97.1/classify/vendor/serde-1.0.219/Cargo.toml",
b"",
)
.expect("classify");
assert_eq!(status, 404, "both handler names produced the same answer");
}
/// A typo must FAIL THE BOOT and say what IS accepted — the same rule
/// `ron_upstream_format` above already follows, and for the same reason: a
/// fallback to the generic reader would serve real bytes through the wrong
/// reader with no warning anywhere.
#[test]
fn an_unknown_ron_handler_fails_boot_and_lists_what_is_accepted() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("bundles");
seed_bundle(&dir);
for typo in ["skidbladnr", "skidbladnir-bundle", "SKIDBLADNIR", "wasm"] {
let mut r = bundle_repo("bundles", &dir);
r.ron_handler = Some(typo.to_string());
let e = r
.backend_from_config()
.expect_err(&format!("'{typo}' resolved to a handler"));
let err = e.to_string();
// anyhow's Display shows only the outermost context; the accepted
// list is in the source, so render the whole chain the way a boot log
// does (`{:#}`).
let chain = format!("{e:#}");
assert!(
err.contains("bundles"),
"the error does not name the repo: {err}"
);
assert!(
chain.contains(typo),
"the error does not name the bad value: {chain}"
);
assert!(
chain.contains("skidbladnir") && chain.contains("rust-toolchain"),
"the error does not list what IS accepted: {chain}"
);
assert!(
r.backend_repository.is_none(),
"{typo}: a rejected handler still wired"
);
}
}
/// Absent, empty and whitespace are all "say nothing", and saying nothing
/// must leave the `ron_repo_type` dispatch exactly as it was.
#[test]
fn an_absent_or_empty_ron_handler_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
for (label, value) in [
("absent", None),
("empty", Some(String::new())),
("whitespace", Some(" ".to_string())),
] {
let mut repo = rust_repo(&format!("sparring-{label}"));
repo.ron_writable_archive = Some(
dir.path()
.join(format!("{label}.znippy"))
.to_string_lossy()
.into_owned(),
);
repo.ron_handler = value;
repo.backend_from_config()
.unwrap_or_else(|e| panic!("{label}: the untouched rust dispatch broke: {e:#}"));
let backend = repo.backend_repository.as_ref().expect("backend built");
assert!(
backend.is_writable(),
"{label}: not the writable rust backend"
);
assert_eq!(backend.format(), ArtifactFormat::Rust, "{label}");
// Applied output: the same put/fetch round trip the rust backend
// served before this field existed.
let id = ArtifactId {
namespace: None,
name: "demo".into(),
version: "0.1.0".into(),
};
backend
.put(&id, b"crate-bytes")
.unwrap_or_else(|e| panic!("{label}: put: {e:#}"));
assert_eq!(
backend.fetch(&id).unwrap(),
Some(traits::Bytes::from_static(b"crate-bytes")),
"{label}"
);
}
}
// ── the pull-through upstream must be ASKABLE, not just servable ─────────
//
// `backend_repository` is erased to `RepositoryBackendTrait`, which cannot be
// asked what digest the ORIGIN publishes for a coordinate. Without that
// question a pull-through repo writes whatever the upstream handed over and
// re-attests it in holger's own sparse index — laundering, not caching. The
// concrete backend answers it; this is the wiring that keeps hold of it, and
// it is invisible at runtime when it breaks (both gates just go quiet).
#[cfg(feature = "online")]
#[test]
fn a_rust_remote_upstream_registers_the_digest_its_origin_publishes() {
let mut repo = rust_repo("crates-io");
repo.ron_repo_type = "rust-remote".to_string();
repo.ron_base_url = Some("https://index.crates.io".into());
repo.backend_from_config()
.expect("rust-remote instantiates");
assert!(
repo.upstream_attestation.is_some(),
"a crates.io upstream publishes a `cksum` per version — it CAN be asked, \
so the proxy's digest gates must be able to ask it"
);
}
/// A plain local repository is not an origin and has nothing to publish, so it
/// registers nothing — and the proxy then caches from it exactly as before.
#[test]
fn a_local_repository_registers_no_attestation() {
let dir = tempfile::tempdir().unwrap();
let mut repo = rust_repo("rust-local");
repo.ron_data_dir = Some(dir.path().to_string_lossy().to_string());
repo.backend_from_config()
.expect("a local rust repo instantiates");
assert!(repo.upstream_attestation.is_none());
}
}