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-package/src/lib.rs

//! The one repository backend that is znippy all the way down: it serves znippy's
//! OWN `.znippy` packages as the artifact, not a foreign ecosystem synthesized out
//! of a backing archive (contrast the sibling `znippy-{maven,npm,rpm,…}` crates,
//! which mimic an upstream registry protocol over the same store). With no registry
//! to imitate, [`ZnippyRepoZnippy`] speaks a tiny Holger-native HTTP surface —
//! `/` lists `.znippy` files, `/{name}/{version}` maps to `{name}-{version}.znippy`,
//! anything else is raw path-passthrough — and the read-only [`RepositoryBackendTrait`]
//! serve/list/archive-info methods parse coordinates from those `{name}-{version}.znippy`
//! filenames.
//!
//! Provenance is OPT-IN and feature-gated. `with_archive` (the default) opens without
//! verifying and records no signer; `with_archive_verified` (behind `sign`) checks the
//! detached-CMS signatures against trusted `roots` from the index digests alone and
//! REJECTS the archive on any failure — including a perfectly good but *unsigned* one.
//! The gotcha: the two constructors are byte-for-byte identical on the serve path; the
//! only observable difference is [`verified_signer`](ZnippyRepoZnippy::verified_signer),
//! so never assume served bytes were provenance-checked unless a signer is recorded.

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

#[cfg(feature = "sign")]
use anyhow::anyhow;
use anyhow::Result;

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

/// znippy-backed repository that serves **znippy packages themselves** — the
/// one format that is znippy all the way down.
///
/// Every other Holger repo uses a znippy archive as a *backing store* for some
/// foreign ecosystem (npm tarballs, jars, …). This one serves znippy's own
/// native `.znippy` packages (merged / signed packages produced by the znippy
/// CLI) as the artifact. There is no third-party registry protocol to mimic, so
/// it speaks a small Holger-native HTTP surface:
///   * `GET /{repo}/`                      → newline list of `.znippy` packages
///   * `GET /{repo}/{name}/{version}`      → `{name}-{version}.znippy`
///   * `GET /{repo}/{path}`                → path-passthrough of any stored file
pub struct ZnippyRepoZnippy {
    base: ZnippyBase,
    /// `Some(common_name)` once the backing archive's provenance (detached CMS
    /// signatures) has been verified against trusted roots. `None` means the
    /// archive was opened WITHOUT provenance verification (the default, off-by-
    /// default path that matches znippy's opt-in `sign` philosophy).
    verified_signer: Option<String>,
    /// The package listing, built once (lane HOL-PERF-2).
    listing: std::sync::OnceLock<traits::Bytes>,
}

impl ZnippyRepoZnippy {
    pub fn new(name: String) -> Self {
        Self {
            base: ZnippyBase::new(name),
            verified_signer: None,
            listing: std::sync::OnceLock::new(),
        }
    }

    pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
        Ok(Self {
            base: ZnippyBase::with_archive(name, archive_path)?,
            verified_signer: None,
            listing: std::sync::OnceLock::new(),
        })
    }

    /// Like [`with_archive`](Self::with_archive), but FIRST verifies the archive's
    /// provenance: every per-artifact detached CMS signature plus the per-archive
    /// signature are checked, with the signer cert chained to `roots`. Digests are
    /// recomputed from the index (the sunk-cost chunk BLAKE3 hashes) — no file
    /// bytes are read. On success the verified signer's common name is recorded
    /// (see [`verified_signer`](Self::verified_signer)); on ANY failure — including
    /// an unsigned archive or a signer that does not chain to a trusted root — the
    /// error is propagated and the archive is rejected (no reader is constructed).
    ///
    /// This is the OPT-IN path: callers only reach it when trusted roots are
    /// configured. With no roots configured, callers keep using `with_archive`
    /// and behaviour is byte-for-byte unchanged.
    ///
    /// Gated behind the `sign` feature (off by default) so the default dep graph
    /// does not pull `znippy-common/sign` — see the Cargo.toml note.
    #[cfg(feature = "sign")]
    pub fn with_archive_verified(
        name: String,
        archive_path: PathBuf,
        roots: &znippy_common::sign::CertStore,
    ) -> Result<Self> {
        let report = znippy_common::sign::verify_archive(&archive_path, roots)?;
        Ok(Self {
            base: ZnippyBase::with_archive(name, archive_path)?,
            verified_signer: Some(report.signer.common_name().to_string()),
            listing: std::sync::OnceLock::new(),
        })
    }

    /// Config-facing form of [`with_archive_verified`](Self::with_archive_verified):
    /// loads one or more DER-encoded trusted root certificates from `root_der_paths`,
    /// builds the [`CertStore`](znippy_common::sign::CertStore), and verifies the
    /// archive's provenance against it. Keeps the PKI/filesystem loading inside this
    /// crate so the wiring layer (`server/lib`) does not need a direct
    /// `znippy-common` dependency. Fails closed on any read/parse/verify error.
    #[cfg(feature = "sign")]
    pub fn with_archive_verified_der_roots(
        name: String,
        archive_path: PathBuf,
        root_der_paths: &[PathBuf],
    ) -> Result<Self> {
        let mut ders = Vec::with_capacity(root_der_paths.len());
        for p in root_der_paths {
            ders.push(
                std::fs::read(p)
                    .map_err(|e| anyhow!("read trusted root '{}': {e}", p.display()))?,
            );
        }
        let roots = znippy_common::sign::CertStore::from_der_certs(&ders)?;
        Self::with_archive_verified(name, archive_path, &roots)
    }

    pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
        Self {
            base: ZnippyBase::with_reader(name, reader),
            verified_signer: None,
            listing: std::sync::OnceLock::new(),
        }
    }

    /// The common name of the provenance signer, if this repo was opened via
    /// [`with_archive_verified`](Self::with_archive_verified) and verification
    /// passed. `None` for unverified (default) repos.
    pub fn verified_signer(&self) -> Option<&str> {
        self.verified_signer.as_deref()
    }

    pub fn list_files(&self) -> Vec<String> {
        self.base.list_files()
    }

    pub fn get_file(&self, relative_path: &str) -> Result<Vec<u8>> {
        self.base.get_file(relative_path)
    }

    fn package_list(&self) -> Vec<u8> {
        let mut pkgs: Vec<String> = self
            .list_files()
            .into_iter()
            .filter(|f| f.ends_with(".znippy"))
            .collect();
        pkgs.sort();
        pkgs.join("\n").into_bytes()
    }
}

impl RepositoryBackendTrait for ZnippyRepoZnippy {
    traits::znippy_read_only!(base);

    /// The read verb: what [`handle_http2_request_bytes`](Self::handle_http2_request_bytes)
    /// answers a `GET` (or `HEAD`) with, so a document rendered once is served as the same bytes.
    fn read_http2_bytes(&self, head: bool, suburl: &str) -> anyhow::Result<traits::HttpBytesReply> {
        self.handle_http2_request_bytes(if head { "HEAD" } else { "GET" }, suburl, &[])
    }

    /// The package listing, built once (lane HOL-PERF-2): the same bytes after.
    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())
        {
            let list = self
                .listing
                .get_or_init(|| traits::Bytes::from(self.package_list()))
                .clone();
            return Ok(traits::znippy_base::ok_bytes("text/plain", list));
        }
        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!("znippy package repo handle_http2_request.suburl={}", suburl);

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

        match parts.as_slice() {
            // Package listing.
            [] => Ok(ok("text/plain", self.package_list())),
            // Coordinates: /{name}/{version}
            [name, version] => match self.get_file(&format!("{name}-{version}.znippy")) {
                Ok(data) => Ok(ok("application/x-znippy", data)),
                Err(_) => Ok(not_found("Package not found in archive")),
            },
            // Path-passthrough for anything else.
            _ => match self.get_file(remainder) {
                Ok(data) => Ok(ok("application/x-znippy", data)),
                Err(_) => Ok(not_found("Not found in archive")),
            },
        }
    }

    fn format(&self) -> ArtifactFormat {
        ArtifactFormat::Znippy
    }

    fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
        let path = format!("{}-{}.znippy", id.name, id.version);
        match self.get_file(&path) {
            Ok(data) => Ok(Some(traits::Bytes::from(data))),
            Err(_) => Ok(None),
        }
    }

    /// Package coordinates from the archive's `.znippy` entries — the shared
    /// `name-version` parse, so this backend carries only its suffix.
    fn list(
        &self,
        name_filter: Option<&str>,
        limit: usize,
    ) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
        self.base
            .list_entries(name_filter, limit, "application/octet-stream", |p| {
                traits::coordinate_from_name_dash_version(p, ".znippy")
            })
    }
}

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

    #[cfg(feature = "testmatrix")]
    use traits::test_support::fstatus;

    #[test]
    fn caps_and_methods_agree() {
        traits::backend::conformance::check(&ZnippyRepoZnippy::new("e".into())).unwrap();
        let loaded = ZnippyRepoZnippy::with_reader(
            "l".into(),
            MemReader::new([("a-1.znippy", b"x".to_vec())]),
        );
        assert!(loaded.caps().archive);
        assert_eq!(loaded.archive().unwrap().info().file_count, 1);
        traits::backend::conformance::check(&loaded).unwrap();
    }

    #[test]
    fn test_new() {
        let repo = ZnippyRepoZnippy::new("znippy-test".to_string());
        assert_eq!(repo.name(), "znippy-test");
        assert!(!repo.is_writable());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "new_name_and_readonly",
            repo.name() == "znippy-test" && !repo.is_writable(),
            &format!("name={} writable={}", repo.name(), repo.is_writable()),
        );
    }

    #[test]
    fn test_format() {
        let f = ZnippyRepoZnippy::new("x".into()).format();
        assert_eq!(f, ArtifactFormat::Znippy);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "format_is_package",
            f == ArtifactFormat::Znippy,
            &format!("format = {f:?}"),
        );
    }

    /// Inject a known `.znippy` artifact via the in-memory reader and assert the
    /// real serve/fetch paths return it. The stored key matches the one both
    /// `fetch()` and the `/{name}/{version}` HTTP arm build:
    /// `format!("{name}-{version}.znippy")`. Covers a hit and a miss on each path.
    #[test]
    fn serves_injected_archive() {
        let bytes = b"znippy package payload bytes".to_vec();
        let key = "demo-1.0.0.znippy";
        let reader = MemReader::new([(key, bytes.clone())]);
        let repo = ZnippyRepoZnippy::with_reader("pkgs".into(), reader);

        // fetch() HIT: id {name=demo, version=1.0.0} → "demo-1.0.0.znippy".
        let id = ArtifactId {
            namespace: None,
            name: "demo".into(),
            version: "1.0.0".into(),
        };
        let fetched = repo.fetch(&id).unwrap();
        assert_eq!(fetched.as_deref(), Some(bytes.as_slice()));

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "fetch_hit_returns_bytes",
            fetched.as_deref() == Some(bytes.as_slice()),
            &format!("key={key} len={}", bytes.len()),
        );

        // fetch() MISS: absent coordinates → Ok(None).
        let miss_id = ArtifactId {
            namespace: None,
            name: "nope".into(),
            version: "9.9.9".into(),
        };
        let missed = repo.fetch(&miss_id).unwrap();
        assert!(missed.is_none());

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "fetch_miss_returns_none",
            missed.is_none(),
            "nope-9.9.9.znippy absent → None",
        );

        // handle_http2_request() HIT: GET /{repo}/{name}/{version} → 200 + bytes.
        let (status, headers, served) = repo
            .handle_http2_request("GET", "/pkgs/demo/1.0.0", b"")
            .unwrap();
        let ctype = headers
            .iter()
            .find(|(k, _)| k == "Content-Type")
            .map(|(_, v)| v.as_str());
        assert_eq!(status, 200);
        assert_eq!(ctype, Some("application/x-znippy"));
        assert_eq!(served, bytes);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "http2_hit_200_ctype_body",
            status == 200 && ctype == Some("application/x-znippy") && served == bytes,
            &format!("status={status} ctype={ctype:?} len={}", served.len()),
        );

        // handle_http2_request() MISS: absent coordinates → 404.
        let (miss_status, _miss_headers, _miss_body) = repo
            .handle_http2_request("GET", "/pkgs/nope/9.9.9", b"")
            .unwrap();
        assert_eq!(miss_status, 404);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "http2_miss_404",
            miss_status == 404,
            &format!(
                "status={miss_status} body={}",
                String::from_utf8_lossy(&_miss_body)
            ),
        );
    }

    /// **The listing is built once** (lane HOL-PERF-2, step 6).
    #[test]
    fn the_package_listing_is_built_once() {
        let c = traits::test_support::CountingReader::new(MemReader::new([
            ("a-1.0.znippy", b"z".to_vec()),
            ("b-2.0.znippy", b"z".to_vec()),
        ]));
        let repo = ZnippyRepoZnippy::with_reader("z".into(), c.clone());
        let a = repo.handle_http2_request_bytes("GET", "/z/", &[]).unwrap();
        let t = c.table_reads();
        let b = repo.handle_http2_request_bytes("GET", "/z/", &[]).unwrap();
        assert_eq!(&a.2[..], b"a-1.0.znippy\nb-2.0.znippy");
        assert_eq!(
            (a.2.as_ptr(), c.table_reads()),
            (b.2.as_ptr(), t),
            "the same bytes, no second listing"
        );
    }
}

/// Provenance-verification tests. Gated behind the `sign` feature because they
/// seal a real SIGNED `.znippy` archive (via `znippy-compress` + the
/// `znippy_common::sign` dev PKI helpers) and assert that `with_archive_verified`
/// accepts a trusted root, rejects an untrusted/empty root, and that the
/// unverified `with_archive` path keeps working unchanged.
#[cfg(all(test, feature = "sign"))]
mod sign_tests {
    use super::*;
    use znippy_common::sign::{self, CertStore};
    use znippy_common::{ArchiveMetaSink, ArrowIpcSink, MetaSinkFactory};

    #[cfg(feature = "testmatrix")]
    use traits::test_support::fstatus;

    /// Build the same signing sink factory the znippy CLI's `compress --sign`
    /// arm builds: wrap the default Arrow-IPC meta sink with a detached-CMS signer.
    fn sign_factory(signer: Box<dyn sign::ArchiveSigner + Send>) -> MetaSinkFactory {
        Box::new(move |file, off| {
            Box::new(ArrowIpcSink::new(file, off).with_signer(signer)) as Box<dyn ArchiveMetaSink>
        })
    }

    /// Seal a SIGNED archive containing one `.znippy` package file, returning the
    /// archive path and the package's stored relative path + bytes.
    fn seal_signed_archive(
        dir: &std::path::Path,
        signer: Box<dyn sign::ArchiveSigner + Send>,
    ) -> (PathBuf, String, Vec<u8>) {
        let input = dir.join("src");
        std::fs::create_dir_all(&input).unwrap();
        let rel = "demo-1.0.0.znippy".to_string();
        let bytes = b"holger provenance test package payload".to_vec();
        std::fs::write(input.join(&rel), &bytes).unwrap();

        let out = dir.join("out");
        znippy_compress::compress_dir(&input, &out, false, None, None, Some(sign_factory(signer)))
            .expect("compress_dir (signed) should seal an archive");
        let archive = out.with_extension("znippy");
        assert!(archive.exists(), "sealed archive should exist");
        (archive, rel, bytes)
    }

    #[test]
    fn verified_open_accepts_trusted_root_and_serves_bytes() {
        let dir = tempfile::tempdir().unwrap();
        let (ca_key, ca_der) = sign::dev::mint_ca("Holger Package Test CA").unwrap();
        let signer = sign::dev::new_p256_signer(&ca_key, &ca_der, "package-signer").unwrap();
        let (archive, rel, bytes) = seal_signed_archive(dir.path(), Box::new(signer));

        // (a) Correct trusted root → verification passes, signer recorded, bytes served.
        let roots = CertStore::from_der_certs(&[ca_der]).unwrap();
        let repo = ZnippyRepoZnippy::with_archive_verified("pkgs".into(), archive.clone(), &roots)
            .unwrap();
        assert_eq!(repo.verified_signer(), Some("package-signer"));
        assert_eq!(repo.get_file(&rel).unwrap(), bytes);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "verified_open_trusted_root",
            repo.verified_signer() == Some("package-signer"),
            &format!("signer={:?}", repo.verified_signer()),
        );
    }

    #[test]
    fn verified_open_rejects_wrong_and_empty_roots() {
        let dir = tempfile::tempdir().unwrap();
        let (ca_key, ca_der) = sign::dev::mint_ca("Holger Package Test CA").unwrap();
        let signer = sign::dev::new_p256_signer(&ca_key, &ca_der, "package-signer").unwrap();
        let (archive, _rel, _bytes) = seal_signed_archive(dir.path(), Box::new(signer));

        // (b1) Wrong (untrusted) root → rejected.
        let (_other_key, other_der) = sign::dev::mint_ca("Rogue CA").unwrap();
        let wrong_roots = CertStore::from_der_certs(&[other_der]).unwrap();
        let wrong =
            ZnippyRepoZnippy::with_archive_verified("pkgs".into(), archive.clone(), &wrong_roots);
        assert!(wrong.is_err(), "untrusted root must be rejected");

        // (b2) Empty root store → rejected (no trusted roots to chain to).
        let empty_roots = CertStore::new();
        let empty = ZnippyRepoZnippy::with_archive_verified("pkgs".into(), archive, &empty_roots);
        assert!(empty.is_err(), "empty root store must be rejected");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "verified_open_rejects_untrusted",
            wrong.is_err() && empty.is_err(),
            "wrong + empty roots both rejected",
        );
    }

    #[test]
    fn unverified_open_still_works_on_signed_archive() {
        let dir = tempfile::tempdir().unwrap();
        let (ca_key, ca_der) = sign::dev::mint_ca("Holger Package Test CA").unwrap();
        let signer = sign::dev::new_p256_signer(&ca_key, &ca_der, "package-signer").unwrap();
        let (archive, rel, bytes) = seal_signed_archive(dir.path(), Box::new(signer));

        // (c) The default, no-verification path is unchanged: opens + serves bytes,
        // and records no verified signer.
        let repo = ZnippyRepoZnippy::with_archive("pkgs".into(), archive).unwrap();
        assert_eq!(repo.verified_signer(), None);
        assert_eq!(repo.get_file(&rel).unwrap(), bytes);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "znippy-package",
            "unverified_open_unchanged",
            repo.verified_signer().is_none(),
            "with_archive opens signed archive without verifying",
        );
    }
}