server/repository/znippy-npm/src/lib.rs
//! npm repository backend: serves the npm HTTP registry protocol (packument JSON +
//! `/-/`-tarballs) synthesized from packages inside a read-only znippy archive — the
//! npm sibling of `znippy-{maven,python,rust,nuget,...}`, all implementing
//! `RepositoryBackendTrait`.
//!
//! The authoritative path resolves through the archive's typed **npm view**
//! ([`ZnippyArchive::as_npm`]): `(name, version)` come from index columns the plugin
//! parsed out of each `package.json`, so a scoped package (`@scope/pkg`) resolves by
//! its real name rather than a filename guess — the `@scope/` prefix that the tarball
//! *filename* drops. `with_reader` keeps a generic exact-path fallback for non-archive
//! readers (no typed view).
//!
//! Two invariants worth knowing. Packument `dist.integrity` is the SSRI SHA-512 of the
//! *served* bytes, recomputed here because the archive stores blake3, not sha512 — npm
//! aborts install on a mismatch. And tarball URLs are built from a configurable
//! `base_url` (`ron_base_url`, empty ⇒ host-relative) so pulls work behind any reverse
//! proxy — the R2 fix that replaced a hardcoded `127.0.0.1`.
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use traits::znippy_base::{not_found, ok};
use traits::{ArtifactFormat, ArtifactId, RepositoryBackendTrait, ZnippyBase};
use znippy_common::ZnippyReader;
/// znippy-backed npm registry.
///
/// Implements the subset of the npm HTTP registry API needed to install:
/// * `GET /{repo}/{pkg}` → packument JSON (versions + tarball URLs)
/// * `GET /{repo}/{pkg}/-/{file}.tgz` → the package tarball
///
/// When backed by a concrete [`ZnippyArchive`] the repo resolves through the typed
/// **npm view** ([`ZnippyArchive::as_npm`]): the package `name` (including the
/// `@scope/` prefix that the tarball *filename* drops) and `version` are read from
/// the index columns the plugin parsed out of `package.json`. So a **scoped**
/// package resolves by its authoritative name instead of a filename guess. The
/// view is built once (cached behind the archive's `OnceLock`) and reused. A
/// `with_reader` fallback keeps a generic path-only mode for non-archive readers.
///
/// Tarball URLs in the packument are built from a configurable **base URL**
/// (`ron_base_url`) instead of a hardcoded host, so npm pulls work behind any
/// public host / reverse proxy (an empty base yields host-relative URLs).
pub struct NpmRepoZnippy {
base: ZnippyBase,
/// Base URL prefixed onto packument `dist.tarball` links. Empty ⇒ host-relative.
base_url: String,
/// Each packument, built once (lane HOL-RSS): building one hashes every version's
/// tarball, and the archive is immutable, so the same bytes after.
packuments: traits::DocMemo,
}
impl NpmRepoZnippy {
pub fn new(name: String) -> Self {
Self {
base: ZnippyBase::new(name),
base_url: String::new(),
packuments: traits::DocMemo::default(),
}
}
/// Backed by a specific znippy archive file. Host-relative tarball URLs.
pub fn with_archive(name: String, archive_path: PathBuf) -> Result<Self> {
Self::with_archive_and_url(name, archive_path, String::new())
}
/// Backed by an archive, with a configurable base URL for packument tarball
/// links (`ron_base_url`, never a hardcoded host).
pub fn with_archive_and_url(
name: String,
archive_path: PathBuf,
base_url: String,
) -> Result<Self> {
Ok(Self {
base: ZnippyBase::with_archive(name, archive_path)?,
base_url: normalize_base(base_url),
packuments: traits::DocMemo::default(),
})
}
/// Backed by any ZnippyReader. No typed npm view; serves by exact path only.
pub fn with_reader(name: String, reader: Arc<dyn ZnippyReader>) -> Self {
Self {
base: ZnippyBase::with_reader(name, reader),
base_url: String::new(),
packuments: traits::DocMemo::default(),
}
}
/// Override the packument base URL.
pub fn with_base_url(mut self, base_url: String) -> Self {
self.base_url = normalize_base(base_url);
self
}
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)
}
/// The unscoped basename for a tarball URL: npm names tarballs after the
/// unscoped package (`@scope/widget` ⇒ `widget-2.3.4.tgz`).
fn tarball_basename(name: &str, version: &str) -> String {
let unscoped = name.rsplit('/').next().unwrap_or(name);
format!("{unscoped}-{version}.tgz")
}
/// Build the packument for `pkg` (a possibly-scoped name) from the typed view.
/// Built with `serde_json` so the (attacker-controlled) package name/version
/// from `package.json` are JSON-escaped, never interpolated raw — a name
/// containing `"` can't inject or rewrite the document.
fn packument(&self, pkg: &str) -> Option<Vec<u8>> {
let npm = self.base.typed()?.as_npm()?;
let mut versions = serde_json::Map::new();
for (name, version) in npm.list() {
if name != pkg {
continue;
}
let basename = Self::tarball_basename(&name, &version);
// The tarball URL routes back to this repo's `/{pkg}/-/{file}` handler.
let tarball = format!(
"{base}/{repo}/{pkg}/-/{file}",
base = self.base_url,
repo = self.base.name(),
pkg = name,
file = basename,
);
// dist.integrity is the SSRI SHA-512 of the SERVED tarball bytes
// (the archive stores blake3, so it must be computed here). npm/yarn
// verify it on install and abort on a mismatch. shasum (sha1 hex) is
// the legacy companion field.
let mut dist = serde_json::Map::new();
dist.insert("tarball".into(), serde_json::Value::String(tarball));
// Both digests over the tarball a piece at a time (lane HOL-RSS): the
// tarball is never whole in memory for its packument.
let digests = npm.get(&name, &version).and_then(|p| {
use sha1::Sha1;
use sha2::{Digest, Sha512};
let (mut s512, mut s1) = (Sha512::new(), Sha1::new());
self.base
.for_each_piece(p.relative_path(), |piece| {
s512.update(piece);
s1.update(piece);
})
.ok()?;
Some((s512.finalize(), s1.finalize()))
});
if let Some((sha512, sha1_out)) = digests {
use base64::Engine;
let integrity = format!(
"sha512-{}",
base64::engine::general_purpose::STANDARD.encode(sha512)
);
// sha1 0.11 output (hybrid-array) has no LowerHex; same lowercase hex (2026-10-02, W3).
let shasum = sha1_out
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>();
dist.insert("integrity".into(), serde_json::Value::String(integrity));
dist.insert("shasum".into(), serde_json::Value::String(shasum));
}
versions.insert(
version.clone(),
serde_json::json!({
"name": pkg,
"version": version,
"dist": dist,
}),
);
}
if versions.is_empty() {
return None;
}
let doc = serde_json::json!({ "name": pkg, "versions": versions });
serde_json::to_vec(&doc).ok()
}
/// Resolve a tarball request `(pkg, file)` through the view. `version` is the
/// trailing `-{version}` of the filename (before `.tgz`); `pkg` is the
/// authoritative (possibly scoped) name.
fn tarball_via_view(&self, pkg: &str, file: &str) -> Option<Vec<u8>> {
let npm = self.base.typed()?.as_npm()?;
let version = parse_tgz_version(file)?;
npm.get(pkg, &version).and_then(|p| p.into_bytes().ok())
}
}
/// Normalize a base URL: strip a single trailing slash so joins don't double up.
fn normalize_base(base: String) -> String {
base.trim_end_matches('/').to_string()
}
/// `widget-2.3.4.tgz` → `2.3.4`: strip `.tgz`, take the suffix after the last `-`
/// that starts with a digit (the conventional version start).
fn parse_tgz_version(file: &str) -> Option<String> {
let stem = file.strip_suffix(".tgz")?;
let mut split_pos = None;
for (i, c) in stem.char_indices() {
if c == '-' {
if let Some(next) = stem[i + 1..].chars().next() {
if next.is_ascii_digit() {
split_pos = Some(i);
}
}
}
}
split_pos.map(|pos| stem[pos + 1..].to_string())
}
/// Split an npm wire remainder into either a packument request (no `-` segment) or
/// a tarball request `(pkg, file)`. Handles scoped names whether the scope slash is
/// a literal `/` (multi-segment) or `%2f`-encoded (single segment).
fn parse_npm_wire(remainder: &str) -> NpmWire {
let decoded = remainder.replace("%2f", "/").replace("%2F", "/");
let segs: Vec<&str> = decoded.split('/').filter(|s| !s.is_empty()).collect();
if let Some(i) = segs.iter().position(|s| *s == "-") {
if i >= 1 && i + 1 < segs.len() {
let pkg = segs[..i].join("/");
let file = segs[i + 1..].join("/");
return NpmWire::Tarball { pkg, file };
}
}
NpmWire::Packument {
pkg: segs.join("/"),
}
}
enum NpmWire {
Packument { pkg: String },
Tarball { pkg: String, file: String },
}
impl RepositoryBackendTrait for NpmRepoZnippy {
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 packument, built once and handed out as the same bytes (lane HOL-RSS); every
/// other request as [`handle_http2_request`](Self::handle_http2_request) answers it.
fn handle_http2_request_bytes(
&self,
method: &str,
suburl: &str,
body: &[u8],
) -> anyhow::Result<traits::HttpBytesReply> {
if method == "GET" {
let remainder = self.base.strip_repo(suburl);
if !remainder.is_empty() {
if let NpmWire::Packument { pkg } = parse_npm_wire(remainder) {
let doc = self
.packuments
.get_or(&pkg, || self.packument(&pkg).map(traits::Bytes::from));
return Ok(match doc {
Some(b) => traits::znippy_base::ok_bytes("application/json", b),
None => traits::znippy_base::not_found_bytes("Package not found"),
});
}
}
}
self.handle_http2_request(method, suburl, body)
.map(|(s, h, b)| (s, h, traits::Bytes::from(b)))
}
/// A tarball download in pieces (lane HOL-RSS): the archive file the whole-body door
/// reads (the view's, else the exact path), so the door holds a piece, not the tarball.
fn stream_get(
&self,
suburl: &str,
piece_bytes: usize,
) -> anyhow::Result<Option<traits::StreamedGet>> {
let remainder = self.base.strip_repo(suburl);
if remainder.is_empty() {
return Ok(None);
}
let NpmWire::Tarball { pkg, file } = parse_npm_wire(remainder) else {
return Ok(None);
};
let via_view = self
.base
.typed()
.and_then(|a| a.as_npm())
.and_then(|npm| npm.get(&pkg, &parse_tgz_version(&file)?))
.map(|p| p.relative_path().to_string());
let rel = via_view.unwrap_or_else(|| format!("{pkg}/-/{file}"));
self.base
.stream_get(&rel, "application/octet-stream", piece_bytes)
}
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!("npm repo znippy handle_http2_request.suburl={}", suburl);
let remainder = self.base.strip_repo(suburl);
if remainder.is_empty() {
return Ok(not_found("Not found"));
}
match parse_npm_wire(remainder) {
NpmWire::Tarball { pkg, file } => {
// View-driven (authoritative scoped name), then exact-path fallback.
if let Some(data) = self.tarball_via_view(&pkg, &file) {
return Ok(ok("application/octet-stream", data));
}
match self.get_file(&format!("{pkg}/-/{file}")) {
Ok(data) => Ok(ok("application/octet-stream", data)),
Err(_) => Ok(not_found("Tarball not found in archive")),
}
}
NpmWire::Packument { pkg } => match self.packument(&pkg) {
Some(json) => Ok(ok("application/json", json)),
None => Ok(not_found("Package not found")),
},
}
}
fn format(&self) -> ArtifactFormat {
ArtifactFormat::Npm
}
fn fetch(&self, id: &ArtifactId) -> anyhow::Result<Option<traits::Bytes>> {
// Resolve through the typed npm view: the name (incl. @scope, carried in
// id.namespace when scoped, else id.name) and version come from the index
// columns, so scoped packages resolve by their authoritative name.
let name = match &id.namespace {
Some(scope) if !scope.is_empty() => format!("{scope}/{}", id.name),
_ => id.name.clone(),
};
if let Some(archive) = self.base.typed() {
if let Some(npm) = archive.as_npm() {
if let Some(pkg) = npm.get(&name, &id.version) {
return Ok(Some(traits::Bytes::from(pkg.into_bytes()?)));
}
return Ok(None);
}
}
// Fallback for non-archive readers: legacy filename path build.
let path = format!("{n}/-/{n}-{v}.tgz", n = id.name, v = id.version);
match self.get_file(&path) {
Ok(data) => Ok(Some(traits::Bytes::from(data))),
Err(_) => Ok(None),
}
}
/// List packages in the archive via the view — authoritative `(name, version)`
/// coords (scoped names intact), no filename guessing.
fn list(
&self,
name_filter: Option<&str>,
limit: usize,
) -> anyhow::Result<Vec<traits::ArtifactEntry>> {
if let Some(archive) = self.base.typed() {
if let Some(npm) = archive.as_npm() {
// ONE index read for the whole listing; no artifact byte is touched.
let tokens = self.base.tokens().open();
let mut out = Vec::new();
for (name, version) in npm.list() {
if let Some(f) = name_filter {
if !name.contains(f) {
continue;
}
}
// The package handle names its own archive row
// (`relative_path`), which is the key the lookup sub-index
// answers a change token for. Size and token come off the
// same handle; neither reads an artifact byte.
let (size_bytes, change_token) = npm
.get(&name, &version)
.map(|p| {
let token = tokens.as_ref().and_then(|t| t.token(p.relative_path()));
(p.size() as i64, token)
})
.unwrap_or((0, None));
// Scoped names → namespace = "@scope", name = "pkg".
let (namespace, short) = match name.rsplit_once('/') {
Some((scope, n)) => (Some(scope.to_string()), n.to_string()),
None => (None, name.clone()),
};
out.push(
traits::ArtifactEntry {
id: ArtifactId {
namespace,
name: short,
version,
},
size_bytes,
content_type: "application/octet-stream".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(change_token),
);
if limit != 0 && out.len() >= limit {
break;
}
}
return Ok(out);
}
}
Ok(Vec::new())
}
}
#[cfg(test)]
mod tests {
use super::*;
use traits::test_support::MemReader;
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
#[test]
fn test_new() {
let repo = NpmRepoZnippy::new("npm-test".to_string());
assert_eq!(repo.name(), "npm-test");
assert!(!repo.is_writable());
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-npm",
"new_name_and_readonly",
repo.name() == "npm-test" && !repo.is_writable(),
&format!("name={} writable={}", repo.name(), repo.is_writable()),
);
}
#[test]
fn caps_and_methods_agree() {
traits::backend::conformance::check(&NpmRepoZnippy::new("e".into())).unwrap();
let loaded = NpmRepoZnippy::with_reader(
"l".into(),
MemReader::new([("a/-/a-1.tgz", b"x".to_vec())]),
);
assert!(loaded.caps().archive);
traits::backend::conformance::check(&loaded).unwrap();
}
#[test]
fn test_format() {
let f = NpmRepoZnippy::new("x".into()).format();
assert_eq!(f, ArtifactFormat::Npm);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-npm",
"format_is_npm",
f == ArtifactFormat::Npm,
&format!("format = {f:?}"),
);
}
#[test]
fn parse_tgz_version_handles_hyphenated_name() {
assert_eq!(
parse_tgz_version("widget-2.3.4.tgz").as_deref(),
Some("2.3.4")
);
assert_eq!(
parse_tgz_version("left-pad-1.0.0.tgz").as_deref(),
Some("1.0.0")
);
assert_eq!(parse_tgz_version("noversion.tgz"), None);
}
#[test]
fn parse_npm_wire_splits_scoped_tarball_and_packument() {
match parse_npm_wire("@acme/widget/-/widget-2.3.4.tgz") {
NpmWire::Tarball { pkg, file } => {
assert_eq!(pkg, "@acme/widget");
assert_eq!(file, "widget-2.3.4.tgz");
}
_ => panic!("expected a tarball request"),
}
match parse_npm_wire("@acme%2fwidget") {
NpmWire::Packument { pkg } => assert_eq!(pkg, "@acme/widget"),
_ => panic!("expected a packument request"),
}
}
/// End-to-end serve/fetch over the generic `with_reader` fallback: inject a
/// known tarball at the exact archive key the backend resolves to, then assert
/// both `fetch()` and `handle_http2_request()` return it (and 404/None for a
/// missing artifact). No typed npm view here, so resolution is exact-path.
#[test]
fn serves_injected_archive() {
// The generic fallback builds the archive key as `{name}/-/{name}-{ver}.tgz`
// (fetch) and the HTTP tarball handler falls back to `{pkg}/-/{file}` —
// both land on this same key.
const KEY: &str = "widget/-/widget-2.3.4.tgz";
let payload = b"\x1f\x8b\x08injected-tarball-bytes".to_vec();
let reader = MemReader::new([(KEY, payload.clone())]);
let repo = NpmRepoZnippy::with_reader("npm-test".to_string(), reader);
let id = ArtifactId {
namespace: None,
name: "widget".to_string(),
version: "2.3.4".to_string(),
};
// fetch() — present artifact resolves to the injected bytes.
let fetched = repo.fetch(&id).expect("fetch must not error");
let fetch_ok = fetched.as_deref() == Some(payload.as_slice());
assert!(fetch_ok, "fetch should return the injected tarball bytes");
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-npm",
"fetch_returns_injected_tarball",
fetch_ok,
&format!(
"key={KEY} fetched_len={:?} expected_len={}",
fetched.as_ref().map(|d| d.len()),
payload.len()
),
);
// handle_http2_request() — GET the tarball URL, expect 200 + octet-stream.
let url = "/npm-test/widget/-/widget-2.3.4.tgz";
let (status, headers, body) = repo
.handle_http2_request("GET", url, b"")
.expect("request must not error");
let ctype = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("Content-Type"))
.map(|(_, v)| v.clone());
let serve_ok = status == 200
&& ctype.as_deref() == Some("application/octet-stream")
&& body == payload;
assert!(
serve_ok,
"GET {url} should serve the injected tarball as octet-stream"
);
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-npm",
"http2_serves_injected_tarball",
serve_ok,
&format!(
"url={url} status={status} content_type={:?} body_len={} expected_len={}",
ctype,
body.len(),
payload.len()
),
);
// Missing artifact — fetch() yields None.
let missing_id = ArtifactId {
namespace: None,
name: "ghost".to_string(),
version: "9.9.9".to_string(),
};
let missing_fetch = repo.fetch(&missing_id).expect("fetch must not error");
let fetch_404 = missing_fetch.is_none();
assert!(fetch_404, "fetch of an absent artifact should be None");
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-npm",
"fetch_missing_is_none",
fetch_404,
&format!(
"name=ghost version=9.9.9 result={:?}",
missing_fetch.map(|d| d.len())
),
);
// Missing artifact — handle_http2_request() yields 404.
let missing_url = "/npm-test/ghost/-/ghost-9.9.9.tgz";
let (missing_status, _h, _b) = repo
.handle_http2_request("GET", missing_url, b"")
.expect("request must not error");
let serve_404 = missing_status == 404;
assert!(serve_404, "GET {missing_url} should 404");
#[cfg(feature = "testmatrix")]
fstatus(
"znippy-npm",
"http2_missing_is_404",
serve_404,
&format!("url={missing_url} status={missing_status}"),
);
}
}