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

nordisk/holger

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