server/lib/src/chain_view.rs
//! **Chain views** (lane HOL-MVP-FORMATS, owner 2026-10-30: deb and rpm "must work too").
//!
//! The deb, rpm, helm, maven3 and go repository types are znippy-archive backends: each one
//! synthesizes its native index (apt `Release` + `Packages`, rpm-md `repomd.xml` +
//! `primary.xml`, Helm `index.yaml`, `maven-metadata.xml`, the GOPROXY `@v/list`) over the
//! files of a [`ZnippyReader`]. A box with no shell has no way to lay an archive, so an
//! ARCHIVE-LESS repository of one of these types, on a Holger with an intake chain, is a
//! VIEW of the chain's production repository, like the npm door:
//!
//! * it holds nothing of its own;
//! * its files are the AVAILABLE artifacts whose coordinate starts with `<repository>/`,
//! the prefix stripped (`apt/pool/main/h/hello/hello_1.0_amd64.deb` is the view `apt`'s
//! `pool/main/h/hello/hello_1.0_amd64.deb`), read from the SEALED store;
//! * a package reaches it only through the chain (uploaded into `incoming` at
//! `<repository>/<path>`, scanned, approved, sealed, AVAILABLE), and a write into it is
//! refused: the view is read-only;
//! * the backend is the format's own, built over a snapshot reader of that set, and built
//! again when the set changes (the backends cache their indexes as if the archive were
//! immutable, which a snapshot is).
//!
//! Without an intake chain an archive-less archive type has nothing to be a view of, and the
//! boot refuses it by name ([`refuse_without_chain`]).
//!
//! **Signed metadata** (lane HOL-SIGNED-APT-DNF): a view made [`ChainView::signed_by`] the
//! box's repository key ([`crate::repo_sign`]) also serves deb `dists/<suite>/InRelease`
//! (the `Release` clear-signed) and `dists/<suite>/Release.gpg`, and rpm
//! `repodata/repomd.xml.asc`, each over the exact index bytes the view serves at that
//! catalog generation: the signature is made once per document ([`crate::signed_index`]),
//! then handed out again. Without a key those paths are the backend's (404), as before. The
//! index tree (deb `dists/`, rpm `repodata/`) is never a file of the set (lane HOL-SIGN-2).
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex, OnceLock};
use traits::znippy_common::ZnippyReader;
use traits::{ArtifactFormat, ArtifactId, Bytes, Caps, HttpReply, RepositoryBackendTrait};
use crate::lock::LockOrRecover as _;
use crate::promote::intake::IntakeChain;
/// The shared slot the Holger fills with its intake chain once it is built.
pub type ChainSlot = Arc<OnceLock<Arc<IntakeChain>>>;
/// The repository types an archive-less repository may be a view as, with the format each
/// serves. Only the ones this build compiled.
pub fn view_format(repo_type: &str) -> Option<ArtifactFormat> {
match repo_type {
#[cfg(feature = "fmt-deb")]
"deb" => Some(ArtifactFormat::Deb),
#[cfg(feature = "fmt-rpm")]
"rpm" => Some(ArtifactFormat::Rpm),
#[cfg(feature = "fmt-helm")]
"helm" => Some(ArtifactFormat::Helm),
#[cfg(feature = "fmt-maven")]
"maven3" => Some(ArtifactFormat::Maven3),
#[cfg(feature = "fmt-go")]
"go" => Some(ArtifactFormat::Go),
_ => None,
}
}
/// The boot's refusal of an archive-less view type on a Holger without an intake chain.
pub fn refuse_without_chain(repo: &str, repo_type: &str) -> anyhow::Error {
anyhow::anyhow!(
"repository '{repo}' is `ron_repo_type: \"{repo_type}\"` with no `ron_archive_path`: an archive-less \
{repo_type} repository is a view of the intake chain's AVAILABLE set, and this config declares no \
chain (`promotion.intake`); declare the chain, or give the repository a znippy archive"
)
}
/// One AVAILABLE file of a view: its coordinate in production and its size.
#[derive(Clone)]
struct Entry {
id: ArtifactId,
size: Option<u64>,
}
/// A snapshot of a view's files over the chain: what the format's backend reads.
struct ChainReader {
chain: Option<Arc<IntakeChain>>,
files: BTreeMap<String, Entry>,
}
impl ZnippyReader for ChainReader {
fn list_files(&self) -> anyhow::Result<Vec<String>> {
Ok(self.files.keys().cloned().collect())
}
fn extract_file(&self, relative_path: &str) -> anyhow::Result<Vec<u8>> {
let rel = relative_path.trim_start_matches('/');
let (Some(chain), Some(e)) = (&self.chain, self.files.get(rel)) else {
anyhow::bail!("'{rel}' is not AVAILABLE in this view");
};
match chain.serve(&e.id) {
Ok(Some(b)) => Ok(b),
Ok(None) => anyhow::bail!("'{rel}' is no longer AVAILABLE"),
Err(err) => anyhow::bail!("'{rel}': {err}"),
}
}
fn contains(&self, relative_path: &str) -> bool {
self.files
.contains_key(relative_path.trim_start_matches('/'))
}
fn file_size(&self, relative_path: &str) -> Option<u64> {
self.files
.get(relative_path.trim_start_matches('/'))
.and_then(|e| e.size)
}
}
/// **The rows of a view's set** (lane HOL-PERF-2): each file's index row, as the intake
/// stored it. The format backend renders its index from them and opens no package.
struct ChainRows {
chain: Option<Arc<IntakeChain>>,
files: Arc<BTreeMap<String, Entry>>,
}
impl holger_index_rows::RowSource for ChainRows {
fn row(&self, rel: &str) -> Option<Arc<holger_index_rows::StoredRow>> {
let e = self.files.get(rel.trim_start_matches('/'))?;
self.chain.as_ref()?.index_row(&e.id)
}
}
/// A view's backend, the set it serves and the catalog generation that set is at.
type Current = (
u64,
Arc<dyn RepositoryBackendTrait>,
Arc<BTreeMap<String, Entry>>,
);
/// **An archive-less archive-type repository as a view of the chain's AVAILABLE set.**
pub struct ChainView {
name: String,
repo_type: String,
format: ArtifactFormat,
slot: ChainSlot,
/// The backend over the view's set, the set itself and the catalog generation it is
/// at (HOL-PERF-1): kept until the chain's catalog says the set under `<name>/` changed.
current: Mutex<Option<Current>>,
/// What the view can do: the format backend's caps, masked, fixed when the view is
/// made. The door asks them on its async worker for every request; they never take a
/// snapshot of the chain.
caps: Caps,
/// The signer of its indexes under the box's repository key (HOL-SIGNED-APT-DNF,
/// [`crate::signed_index`]): `None` serves no signature.
signer: Option<crate::signed_index::IndexSigner>,
}
impl ChainView {
/// `None` when `repo_type` is no view type of this build.
pub fn new(name: &str, repo_type: &str, slot: ChainSlot) -> Option<Self> {
let format = view_format(repo_type)?;
let mut view = Self {
name: name.to_string(),
repo_type: repo_type.to_string(),
format,
slot,
current: Mutex::new(None),
caps: Caps::NONE,
signer: None,
};
let none = Arc::new(BTreeMap::new());
let mut caps = view
.build(
Arc::new(ChainReader {
chain: None,
files: BTreeMap::new(),
}),
Arc::new(ChainRows {
chain: None,
files: none,
}),
)
.caps();
caps.archive = false;
caps.streaming_uploads = false;
caps.delete = false;
// A file of the set streams from the sealed store (HOL-PERF-3): the view maps a
// path to the file's coordinate; an index document maps to nothing and is the
// format backend's.
caps.streaming_reads = true;
caps.path_mapping = true;
view.caps = caps;
Some(view)
}
/// **Sign this view's metadata with `key`** (HOL-SIGNED-APT-DNF): deb `InRelease` and
/// `Release.gpg`, rpm `repomd.xml.asc`. `None` leaves the view unsigned.
pub fn signed_by(mut self, key: Option<Arc<crate::repo_sign::RepoKey>>) -> Self {
self.signer =
key.map(|k| crate::signed_index::IndexSigner::new(&self.name, &self.repo_type, k));
self
}
/// The format's backend over the view's set (HOL-PERF-1): the catalog's generation
/// for `<name>/` is asked first, and the backend is built again only when it moved.
fn backend(&self) -> Arc<dyn RepositoryBackendTrait> {
self.current().1
}
/// **A signed path's answer** (HOL-SIGNED-APT-DNF), or `None` when `suburl` is no signed
/// path of this view or the view holds no key: the signature over the document the
/// backend serves now ([`crate::signed_index::IndexSigner::answer`]).
fn signed_answer(&self, suburl: &str) -> Option<anyhow::Result<traits::HttpBytesReply>> {
let signer = self.signer.as_ref()?;
if !signer.signs(suburl) {
return None;
}
signer.answer(suburl, self.backend().as_ref())
}
/// The catalog generation, the backend and the set it serves, current.
fn current(
&self,
) -> (
u64,
Arc<dyn RepositoryBackendTrait>,
Arc<BTreeMap<String, Entry>>,
) {
let chain = self.slot.get().cloned();
let prefix = format!("{}/", self.name);
let now = chain
.as_ref()
.map(|c| c.prefix_generation(&prefix))
.unwrap_or(0);
if let Some((have, b, files)) = self.current.lock_or_recover().as_ref() {
if *have == now {
return (*have, Arc::clone(b), Arc::clone(files));
}
}
let (generation, files) = chain
.as_ref()
.map(|c| c.available_under(&prefix))
.unwrap_or_default();
// HOL-SIGN-2: the format's index tree is never a file of the set. The view's
// indexes are the ones the backend synthesizes over the AVAILABLE packages (and
// the ones the box key signs), never a `Release`, `InRelease` or `repomd.xml`
// that came through the chain at an index path.
let files: BTreeMap<String, Entry> = files
.into_iter()
.filter(|(rel, _)| !crate::signed_index::is_index_path(&self.repo_type, rel))
.map(|(rel, id)| (rel, Entry { id, size: None }))
.collect();
let shared = Arc::new(files.clone());
let rows = Arc::new(ChainRows {
chain: chain.clone(),
files: Arc::clone(&shared),
});
let b = self.build(Arc::new(ChainReader { chain, files }), rows);
let files = shared;
*self.current.lock_or_recover() = Some((generation, Arc::clone(&b), Arc::clone(&files)));
(generation, b, files)
}
/// The format's backend over `reader`; deb, rpm and Helm render their indexes from
/// `rows` (lane HOL-PERF-2), Maven and Go index file paths only.
fn build(
&self,
reader: Arc<dyn ZnippyReader>,
rows: Arc<ChainRows>,
) -> Arc<dyn RepositoryBackendTrait> {
let name = self.name.clone();
// Every arm is a format feature; a build with none of them uses none of these.
let _ = (&name, &reader, &rows);
match self.repo_type.as_str() {
#[cfg(feature = "fmt-deb")]
"deb" => Arc::new(repository_znippy_deb::DebRepoZnippy::with_reader_and_rows(
name, reader, rows,
)),
#[cfg(feature = "fmt-rpm")]
"rpm" => Arc::new(repository_znippy_rpm::RpmRepoZnippy::with_reader_and_rows(
name, reader, rows,
)),
#[cfg(feature = "fmt-helm")]
"helm" => Arc::new(
repository_znippy_helm::HelmRepoZnippy::with_reader_and_rows(name, reader, rows),
),
#[cfg(feature = "fmt-maven")]
"maven3" => Arc::new(repository_znippy_maven::MavenRepoZnippy::with_reader(
name, reader,
)),
#[cfg(feature = "fmt-go")]
"go" => Arc::new(repository_znippy_go::GoRepoZnippy::with_reader(
name, reader,
)),
other => unreachable!("`{other}` passed view_format"),
}
}
fn read_only(&self) -> anyhow::Error {
anyhow::anyhow!(
"repository '{}' is a view of the intake chain's AVAILABLE set: it is read-only; upload into the \
chain's incoming repository at '{}/<path>'",
self.name, self.name
)
}
}
impl RepositoryBackendTrait for ChainView {
fn name(&self) -> &str {
&self.name
}
fn format(&self) -> ArtifactFormat {
self.format.clone()
}
fn is_writable(&self) -> bool {
false
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<Bytes>> {
self.backend().fetch(id)
}
fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
Err(self.read_only())
}
fn handle_http2_request(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<HttpReply> {
if !matches!(method, "GET" | "HEAD") {
return Ok((
405,
vec![("Content-Type".into(), "text/plain".into())],
self.read_only().to_string().into_bytes(),
));
}
if self.signer.as_ref().is_some_and(|k| k.signs(suburl)) {
return self
.handle_http2_request_bytes(method, suburl, body)
.map(|(s, h, b)| (s, h, b.to_vec()));
}
self.backend().handle_http2_request(method, suburl, body)
}
/// The format backend's index, as the bytes it rendered once for this set (lane
/// HOL-PERF-2): no copy per request.
fn handle_http2_request_bytes(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<traits::HttpBytesReply> {
if !matches!(method, "GET" | "HEAD") {
return Ok((
405,
vec![("Content-Type".into(), "text/plain".into())],
Bytes::from(self.read_only().to_string()),
));
}
if let Some(answer) = self.signed_answer(suburl) {
return if method == "HEAD" {
answer.map(traits::backend::head_of_bytes)
} else {
answer
};
}
self.backend()
.handle_http2_request_bytes(method, suburl, body)
}
fn caps(&self) -> Caps {
self.caps
}
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
self.backend().list(name_filter, limit)
}
/// A path that is one of the set's files names that file's coordinate in production;
/// anything else (an index document, a checksum the format computes) names nothing.
fn coordinate_for_path(&self, suburl: &str) -> anyhow::Result<Option<ArtifactId>> {
let path = suburl
.split('?')
.next()
.unwrap_or(suburl)
.trim_start_matches('/');
let Some(rel) = path
.strip_prefix(&self.name)
.and_then(|r| r.strip_prefix('/'))
else {
return Ok(None);
};
Ok(self.current().2.get(rel).map(|e| e.id.clone()))
}
/// A file of the set, streamed from the sealed store and verified once.
fn fetch_chunks(
&self,
id: &ArtifactId,
chunk_bytes: usize,
) -> anyhow::Result<Option<traits::ChunkIter>> {
let Some(chain) = self.slot.get() else {
return Ok(None);
};
let prefix = format!("{}/", self.name);
if !id.name.starts_with(&prefix)
|| id.namespace.as_deref().is_some_and(|n| !n.is_empty())
|| !id.version.is_empty()
{
return Ok(None);
}
chain
.serve_stream(id, chunk_bytes)
.map_err(|e| anyhow::anyhow!(e.tagged()))
}
}