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