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",
);
}
}