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