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

nordisk/holger

server/repository/znippy-build-thing/src/lib.rs

//! **The build-history repository backend** — holger serving a forge's
//! `znippy-build-thing` package.
//!
//! A build thing is a published recipe crate. A built artifact carries a
//! *receipt* naming that recipe, its version and its content hash, in its own
//! `.nordisk.buildthing` linker section. After the build and **before** the
//! release, the forge writes the other direction into a `.znippy`: one
//! **attestation** per artifact, saying *recipe R with parameters P produced
//! artifact hash H*.
//!
//! That package is what this backend serves. It is the sixteenth-and-something
//! znippy-backed repository here, and deliberately the least clever one: every
//! serving route is a path mapping onto a file already in the archive.
//!
//! ```text
//! GET /{repo}/                          index.json      the manifest
//! GET /{repo}/artifact/{sha256}         one attestation, by the artifact's hash
//! GET /{repo}/recipe/{krate}            what this recipe built, any version
//! GET /{repo}/recipe/{krate}/{version}  what this exact recipe version built
//! GET /{repo}/atom/{atom}               what was built for this atom
//! GET /{repo}/{path}                    passthrough of any stored file
//! ```
//!
//! # Why there is no shared type with the writer
//!
//! The writer is `nornir_build_thing::history` in edda. This crate does not
//! depend on it and does not model an attestation at all — the archive already
//! holds the derived `by-recipe/…` and `by-atom/…` lookups, so answering "what
//! did this recipe build" is a file read, not a query. Nothing to keep in sync,
//! nothing to drift, and a schema change in the attestation body cannot break
//! serving.
//!
//! The one exception is [`RepositoryBackendTrait::list`], which reads
//! `index.json` for human-facing coordinates. It falls back to enumerating
//! `attestations/` when the manifest is missing or is a shape this holger does
//! not understand — a repo that cannot pretty-print its listing still serves.
//!
//! # Read-only, and it must stay that way
//!
//! The package is **derived**: lose it and the forge regenerates it from the
//! artifacts. A writable build history would be a second writer of provenance,
//! and a provenance store that anyone can append to attests nothing. `put`
//! refuses.

use std::path::PathBuf;
use std::sync::Arc;

use anyhow::{anyhow, Result};

use traits::znippy_base::ok;
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;

/// The manifest's path inside the archive.
pub const INDEX_PATH: &str = "index.json";

/// The directory holding one attestation per artifact hash.
pub const ATTESTATION_DIR: &str = "attestations";

/// The directory holding the derived recipe → artifacts lookup.
pub const BY_RECIPE_DIR: &str = "by-recipe";

/// The directory holding the derived atom → artifacts lookup.
pub const BY_ATOM_DIR: &str = "by-atom";

/// The `kind` the manifest must name for this backend to trust its rows.
pub const PACKAGE_KIND: &str = "znippy-build-thing";

/// One manifest row, as far as this backend cares.
///
/// A deliberately partial view: `serde` ignores every field not named here, so
/// the writer can add to the manifest without a holger release. The fields that
/// ARE here are the ones a listing needs, and each is `#[serde(default)]` so a
/// row missing one still lists instead of taking the whole listing down.
#[derive(Debug, Clone, serde::Deserialize)]
struct IndexRow {
    #[serde(default)]
    artifact_sha256: String,
    #[serde(default)]
    artifact_bytes: u64,
    #[serde(default)]
    build_thing_version: String,
}

/// The manifest, as far as this backend cares.
#[derive(Debug, Clone, serde::Deserialize)]
struct Index {
    #[serde(default)]
    kind: String,
    #[serde(default)]
    attestations: Vec<IndexRow>,
}

/// A forge's build history, served read-only out of a `.znippy` archive.
pub struct BuildThingRepoZnippy {
    base: ZnippyBase,
    /// The manifest (`index.json`), read once (lane HOL-PERF-2): the same bytes after.
    manifest: std::sync::OnceLock<Option<traits::Bytes>>,
}

impl BuildThingRepoZnippy {
    /// An unconfigured repo: no archive, so every route 404s and every listing
    /// is empty. Serving nothing is the honest state for a history that has not
    /// been written yet.
    #[must_use]
    pub fn new(name: String) -> Self {
        Self {
            base: ZnippyBase::new(name),
            manifest: std::sync::OnceLock::new(),
        }
    }

    /// Open the backing archive.
    ///
    /// # Errors
    ///
    /// When the archive cannot be opened — corrupt, truncated, or written by an
    /// incompatible znippy version.
    pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
        Ok(Self {
            base: ZnippyBase::with_archive(name, &archive_path)?,
            manifest: std::sync::OnceLock::new(),
        })
    }

    /// Serve from an already-open reader.
    #[must_use]
    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self {
            base: ZnippyBase::with_reader(name, reader),
            manifest: std::sync::OnceLock::new(),
        }
    }

    /// Every path in the backing archive, or empty when there is no archive.
    #[must_use]
    pub fn list_files(&self) -> Vec<String> {
        self.base.list_files()
    }

    /// Extract one stored file.
    ///
    /// # Errors
    ///
    /// When no archive is configured, or the path is not in it.
    pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
        self.base.get_file(relative_path)
    }

    /// The attestation for one artifact hash.
    ///
    /// # Errors
    ///
    /// When the history does not cover that artifact.
    pub fn attestation(&self, artifact_sha256: &str) -> Result<Vec<u8>> {
        self.get_file(&format!("{ATTESTATION_DIR}/{artifact_sha256}.json"))
    }

    /// **What did this recipe build?** — the artifact hashes, one per line.
    ///
    /// `version` of `None` spans every version of the recipe crate, which is the
    /// shape a CVE advisory has: it names a crate and a range, not one version.
    ///
    /// # Errors
    ///
    /// When the history holds nothing for that recipe.
    pub fn built_by_recipe(&self, krate: &str, version: Option<&str>) -> Result<Vec<u8>> {
        let path = match version {
            Some(v) => format!("{BY_RECIPE_DIR}/{krate}/{v}"),
            None => format!("{BY_RECIPE_DIR}/{krate}"),
        };
        self.get_file(&path)
    }

    /// Parse the manifest, if there is one this holger understands.
    fn index(&self) -> Option<Index> {
        let bytes = self.get_file(INDEX_PATH).ok()?;
        let index: Index = serde_json::from_slice(&bytes)
            .map_err(|e| log::warn!("{}: {INDEX_PATH} is not a manifest this holger understands, falling back to archive paths: {e}", self.base.name()))
            .ok()?;
        if index.kind != PACKAGE_KIND {
            log::warn!(
                "{}: {INDEX_PATH} says kind {:?}, not {PACKAGE_KIND:?} — falling back to archive paths",
                self.base.name(),
                index.kind
            );
            return None;
        }
        Some(index)
    }
}

/// Is `s` safe to place into an archive lookup path?
///
/// The archive is keyed by exact path rather than by a filesystem walk, so a
/// `..` cannot escape anything here — it simply misses. It is refused anyway,
/// with a 400 rather than a 404, because a request that could only have been
/// written by someone trying to escape should not be answered as if it were a
/// typo.
fn safe_component(s: &str) -> bool {
    !s.is_empty() && s != "." && s != ".." && !s.contains('\\') && !s.contains('\0')
}

fn json(body: Vec<u8>) -> (u16, Vec<(String, String)>, Vec<u8>) {
    (
        200,
        vec![("Content-Type".into(), "application/json".into())],
        body,
    )
}

fn text(body: Vec<u8>) -> (u16, Vec<(String, String)>, Vec<u8>) {
    (
        200,
        vec![("Content-Type".into(), "text/plain".into())],
        body,
    )
}

fn not_found(what: &str) -> (u16, Vec<(String, String)>, Vec<u8>) {
    (
        404,
        Vec::new(),
        format!("not in this build history: {what}").into_bytes(),
    )
}

impl RepositoryBackendTrait for BuildThingRepoZnippy {
    // Not `znippy_read_only!`: a build history refuses a put with its own reason.
    fn name(&self) -> &str {
        self.base.name()
    }
    /// Always false. See the crate docs: a writable provenance store attests
    /// nothing.
    fn is_writable(&self) -> bool {
        false
    }
    fn caps(&self) -> traits::Caps {
        traits::Caps::znippy(self.base.has_archive())
    }
    fn archive(&self) -> anyhow::Result<traits::Archive> {
        self.base.archive()
    }

    /// The transport is a `.znippy` archive, so the format is
    /// [`ArtifactFormat::Znippy`]. The *contents* are attestations rather than
    /// packages, and that distinction lives in the repo type
    /// (`znippy-build-thing`) rather than in a new format discriminant — znippy
    /// owns the format numbers, and a build history is not a new archive format.
    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Znippy
    }

    /// The manifest, read once and handed out as the same bytes (lane HOL-PERF-2).
    fn handle_http2_request_bytes(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<traits::HttpBytesReply> {
        if method == "GET"
            && self
                .base
                .strip_repo(suburl)
                .split('/')
                .all(|s| s.is_empty())
        {
            if let Some(m) = self
                .manifest
                .get_or_init(|| self.get_file(INDEX_PATH).ok().map(traits::Bytes::from))
            {
                let (status, headers, _) = json(Vec::new());
                return Ok((status, headers, m.clone()));
            }
        }
        if method == "HEAD" {
            return self
                .handle_http2_request_bytes("GET", suburl, &[])
                .map(traits::backend::head_of_bytes);
        }
        self.handle_http2_request(method, suburl, body)
            .map(|(s, h, b)| (s, h, traits::Bytes::from(b)))
    }

    fn handle_http2_request(
        &self,
        method: &str,
        suburl: &str,
        body: &[u8],
    ) -> anyhow::Result<(u16, Vec<(String, String)>, Vec<u8>)> {
        if method == "HEAD" {
            // HOL-HEAD-1: the GET answer's status and headers, its length, no body.
            return self
                .handle_http2_request("GET", suburl, &[])
                .map(traits::backend::head_of);
        }
        let _ = body;
        log::debug!("build-history repo {}: {method} {suburl}", self.base.name());

        let remainder = self.base.strip_repo(suburl);
        let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();

        if parts.iter().any(|p| !safe_component(p)) {
            return Ok((
                400,
                Vec::new(),
                b"a build-history path holds no traversal components".to_vec(),
            ));
        }

        match parts.as_slice() {
            // The manifest. A history with no archive answers 404 rather than an
            // empty manifest: "nothing configured" and "nothing built" are
            // different answers and must not look alike.
            [] => match self.get_file(INDEX_PATH) {
                Ok(b) => Ok(json(b)),
                Err(_) => Ok(not_found(INDEX_PATH)),
            },
            // One attestation, by the hash of what it attests.
            ["artifact", sha] => match self.attestation(sha) {
                Ok(b) => Ok(json(b)),
                Err(_) => Ok(not_found(sha)),
            },
            // What did this recipe build?
            ["recipe", krate] => match self.built_by_recipe(krate, None) {
                Ok(b) => Ok(text(b)),
                Err(_) => Ok(not_found(krate)),
            },
            ["recipe", krate, version] => match self.built_by_recipe(krate, Some(version)) {
                Ok(b) => Ok(text(b)),
                Err(_) => Ok(not_found(&format!("{krate}:{version}"))),
            },
            ["atom", atom] => match self.get_file(&format!("{BY_ATOM_DIR}/{atom}")) {
                Ok(b) => Ok(text(b)),
                Err(_) => Ok(not_found(atom)),
            },
            _ => match self.get_file(remainder) {
                Ok(b) => Ok(ok("application/octet-stream", b)),
                Err(_) => Ok(not_found(remainder)),
            },
        }
    }

    /// The artifact hash is the primary key: `id.name` is the sha256 of the
    /// built artifact and `id.version` is descriptive (the recipe version the
    /// listing reported). Looking up by hash and ignoring the version is
    /// deliberate — the hash identifies the bytes exactly, and a caller that got
    /// the version wrong should still get the right attestation rather than a
    /// 404 it cannot diagnose.
    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
        if !safe_component(&id.name) {
            return Ok(None);
        }
        Ok(self.attestation(&id.name).ok().map(traits::Bytes::from))
    }

    fn put(&self, _id: &ArtifactId, _data: &[u8]) -> anyhow::Result<()> {
        Err(anyhow!(
            "a build history is derived and read-only: it is written by the forge after a build \
             and regenerated from the artifacts if lost, so there is nothing here to publish into"
        ))
    }

    /// Every attested artifact.
    ///
    /// Reads the manifest for the recipe version, and falls back to enumerating
    /// `attestations/` when there is no manifest this holger understands — the
    /// hashes are in the paths, so the listing degrades rather than empties. An
    /// empty listing must mean "this history covers nothing", never "the
    /// manifest changed shape".
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        let reader = match self.base.reader() {
            Some(r) => r,
            None => return Ok(Vec::new()),
        };
        // ONE index read for the whole listing; every token after it is memory,
        // and no chunk of any attestation is touched. See [`traits::ArchiveTokens`].
        let tokens = self.base.tokens().open();
        let mut out = Vec::new();
        // `path` is the attestation's archive row — the key the lookup sub-index
        // answers a change token for. A manifest row names it by the writer's
        // layout (`attestations/{sha}.json`), the fallback has it in hand.
        let mut push = |sha: String, version: String, size: i64, path: &str| {
            if let Some(f) = name_filter {
                if !sha.contains(f) {
                    return;
                }
            }
            out.push(
                traits::ArtifactEntry {
                    id: ArtifactId {
                        namespace: None,
                        name: sha,
                        version,
                    },
                    size_bytes: size,
                    content_type: "application/json".into(),
                    // A znippy file table publishes no mtime and no per-entry digest;
                    // answering either would mean extracting and hashing every
                    // artifact on the listing path. Unknown, never a fabricated zero.
                    // The CHANGE TOKEN below is what the index CAN answer.
                    ..traits::ArtifactEntry::UNKNOWN_PROVENANCE
                }
                .with_change_token(tokens.as_ref().and_then(|t| t.token(path))),
            );
        };

        let mut rows_in_manifest = 0usize;
        if let Some(index) = self.index() {
            rows_in_manifest = index.attestations.len();
            for row in index.attestations {
                let path = format!("{ATTESTATION_DIR}/{}.json", row.artifact_sha256);
                push(
                    row.artifact_sha256,
                    row.build_thing_version,
                    row.artifact_bytes as i64,
                    &path,
                );
            }
        }

        // The fallback runs when there was no manifest AND when a manifest that
        // parsed carried no rows. The second case is the one that bites: a
        // manifest renaming the `attestations` array still deserialises, into an
        // empty one, and would report a history full of builds as covering
        // nothing.
        //
        // The condition is the manifest's ROW COUNT, not `out.is_empty()` — a
        // `name_filter` that matches none of a perfectly good manifest also
        // leaves `out` empty, and re-listing from the archive paths there would
        // answer a filtered question with an unfiltered fallback's silence.
        if rows_in_manifest == 0 {
            let prefix = format!("{ATTESTATION_DIR}/");
            let mut paths = reader.list_files()?;
            paths.sort();
            for p in paths {
                let Some(rest) = p.strip_prefix(&prefix) else {
                    continue;
                };
                let Some(sha) = rest.strip_suffix(".json") else {
                    continue;
                };
                let size = reader.file_size(&p).unwrap_or(0) as i64;
                push(sha.to_string(), String::new(), size, &p);
            }
            if !out.is_empty() {
                log::warn!(
                    "{}: {INDEX_PATH} reported no attestations but the archive holds {} — \
                     listing from the archive paths instead",
                    self.base.name(),
                    out.len()
                );
            }
        }
        if limit != 0 {
            out.truncate(limit);
        }
        Ok(out)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Caps and methods agree, with and without an archive loaded.
    #[test]
    fn caps_match_methods() {
        struct One;
        impl znippy_common::ZnippyReader for One {
            fn list_files(&self) -> anyhow::Result<Vec<String>> {
                Ok(vec!["x".into()])
            }
            fn extract_file(&self, _: &str) -> anyhow::Result<Vec<u8>> {
                Ok(Vec::new())
            }
            fn contains(&self, p: &str) -> bool {
                p == "x"
            }
            fn file_size(&self, _: &str) -> Option<u64> {
                Some(0)
            }
        }
        traits::backend::conformance::check(&BuildThingRepoZnippy::new("r".into())).unwrap();
        traits::backend::conformance::check(&BuildThingRepoZnippy::with_reader(
            "r".into(),
            std::sync::Arc::new(One),
        ))
        .unwrap();
    }

    /// **The manifest is read once** (lane HOL-PERF-2, step 6): a second `index.json`
    /// request extracts nothing and answers the same bytes.
    #[test]
    fn the_manifest_is_read_once() {
        use traits::test_support::{CountingReader, MemReader};
        let c = CountingReader::new(MemReader::new([(INDEX_PATH, br#"{"builds":[]}"#.to_vec())]));
        let repo = BuildThingRepoZnippy::with_reader("bt".into(), c.clone());
        let a = repo.handle_http2_request_bytes("GET", "/bt", &[]).unwrap();
        assert_eq!(a.0, 200);
        let n = c.member_reads();
        let b = repo.handle_http2_request_bytes("GET", "/bt", &[]).unwrap();
        assert_eq!(
            (a.2.as_ptr(), c.member_reads()),
            (b.2.as_ptr(), n),
            "the same bytes, nothing extracted again"
        );
    }
}