Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/promote/npm.rs

//! **The npm door of the intake chain** (lane P5, 2026-10-21).
//!
//! `npm publish` and `npm install` speak to ONE registry URL. The chain's npm door is the
//! repository `promotion.intake.npm` names (the estate's `npm`, `ron_repo_type: "npm"`, no
//! archive of its own). It holds nothing: what it serves is a view of the intake chain, exactly
//! as the PEP 503 index is a view of production.
//!
//! ```text
//!   npm publish ─PUT /npm/<package>─▶ the document's one tarball ─▶ INGESTED in `incoming`
//!                                     (the coordinate `<package>/-/<name>-<version>.tgz`,
//!                                      the path `npm` itself gives the tarball)
//!   … the chain's walk: SCANNED, POLICY_CHECKED, APPROVED (N people), SEALED, PROMOTED …
//!   npm install ─GET /npm/<package>─▶ the packument over the AVAILABLE tarballs
//!               ─GET /npm/<package>/-/<file>.tgz─▶ the sealed bytes
//! ```
//!
//! What it holds to, and why:
//!
//! * **The tarball is the truth.** A version's manifest in the packument is the tarball's own
//!   `package/package.json`, read from the SEALED bytes (cached by their sha256), never the
//!   document the publisher sent; `dist.integrity` is the SHA-512 of those sealed bytes. The
//!   publish is refused (`holger-npm-2`) when the attached tarball is not what its document
//!   says: its integrity, its length, or its `package.json` naming another package or version.
//! * **One publish is one version and one tarball** (`holger-npm-1`): what `npm publish` sends.
//! * **Nothing is ever taken back** (`holger-npm-3`): unpublish (`DELETE …/-rev/…`, the
//!   `PUT …/-rev/…` rewrite), deprecate and every other rewrite of the package document are
//!   refused by policy. A sealed version is never published again (`holger-intake-10`); a
//!   version still on its walk is replaced by a new publish, as the chain replaces any unsealed
//!   upload.
//! * **dist-tags** name AVAILABLE versions only (`holger-npm-4`). A publish records its tags
//!   (`latest` unless `--tag`); they take effect when that version is AVAILABLE, so `latest`
//!   never points at something nobody may install. `npm dist-tag add/rm` change them, audited;
//!   `latest` is never removed. With no `latest` recorded, it is the highest AVAILABLE version.
//!
//! The registry semantics are npm's (the public registry's documents and paths); the code is
//! Holger's own.

use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::Mutex;

use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use traits::ArtifactId;

use crate::lock::LockOrRecover as _;

// The tarball read and its caps live in `holger-index-rows` (lane HOL-PERF-2): the scan
// worker runs them at intake; the publish door still checks a publish's own package.json.
pub use holger_index_rows::npm::{
    integrity_of, read_manifest, MAX_MANIFEST_BYTES, MAX_MEMBERS, MAX_UNPACKED_BYTES,
};
/// npm's limit on a package name.
pub const MAX_NAME: usize = 214;
/// The tag every package has.
pub const LATEST: &str = "latest";

// ── the refusals ────────────────────────────────────────────────────────────

/// Why the npm door refused. Every variant leaves the chain as it was.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NpmError {
    /// The publish document is not one npm publish of one version with its tarball.
    Publish(String),
    /// The attached tarball is not what the document says it is.
    Tarball(String),
    /// Unpublish, deprecate or another rewrite of a published package.
    Rewrite(String),
    /// A dist-tag change the door does not make.
    Tag { status: u16, why: String },
}

impl NpmError {
    /// The frozen `holger-npm-<n>` code.
    pub fn code(&self) -> &'static holger_errcode::ErrCode {
        match self {
            NpmError::Publish(_) => &holger_errcode::NPM_PUBLISH_INVALID,
            NpmError::Tarball(_) => &holger_errcode::NPM_TARBALL_MISMATCH,
            NpmError::Rewrite(_) => &holger_errcode::NPM_REWRITE_REFUSED,
            NpmError::Tag { .. } => &holger_errcode::NPM_TAG_REFUSED,
        }
    }

    pub fn tagged(&self) -> String {
        self.code().tag(self)
    }

    pub fn status(&self) -> u16 {
        match self {
            NpmError::Publish(_) | NpmError::Tarball(_) => 400,
            NpmError::Rewrite(_) => 405,
            NpmError::Tag { status, .. } => *status,
        }
    }
}

impl std::fmt::Display for NpmError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            NpmError::Publish(why) => write!(f, "not an npm publish of one version: {why}"),
            NpmError::Tarball(why) => write!(
                f,
                "the published tarball is not what its document says: {why}"
            ),
            NpmError::Rewrite(why) => write!(
                f,
                "{why}: published versions are never unpublished, deprecated or rewritten (policy)"
            ),
            NpmError::Tag { why, .. } => write!(f, "dist-tag refused: {why}"),
        }
    }
}

impl std::error::Error for NpmError {}

fn rewrite(what: &str) -> NpmError {
    NpmError::Rewrite(what.to_string())
}

// ── names, versions, coordinates ────────────────────────────────────────────

/// npm's rule for a NEW package name: lowercase, URL-safe, at most 214 characters, an optional
/// `@scope/`, never starting with `.` or `_`.
pub fn valid_package_name(name: &str) -> bool {
    fn part(p: &str) -> bool {
        !p.is_empty()
            && !p.starts_with(['.', '_'])
            && p.chars().all(|c| {
                c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '.' | '_' | '~')
            })
    }
    if name.is_empty() || name.len() > MAX_NAME {
        return false;
    }
    match name.strip_prefix('@') {
        Some(rest) => {
            matches!(rest.split_once('/'), Some((scope, pkg)) if part(scope) && part(pkg))
        }
        None => part(name),
    }
}

/// The name without its scope (what the tarball's file name carries).
pub fn unscoped(name: &str) -> &str {
    name.rsplit('/').next().unwrap_or(name)
}

/// A semantic version (`1.2.3`, `1.2.3-beta.1`, `1.2.3+build`), as npm requires one.
pub fn valid_version(v: &str) -> bool {
    parse_version(v).is_some()
}

type Parsed<'a> = ([u64; 3], Vec<&'a str>);

fn parse_version(v: &str) -> Option<Parsed<'_>> {
    if v.is_empty() || v.len() > 256 {
        return None;
    }
    let core_pre = v
        .split_once('+')
        .map(|(a, b)| (a, Some(b)))
        .unwrap_or((v, None));
    if let Some(build) = core_pre.1 {
        if build.is_empty()
            || !build
                .split('.')
                .all(|i| !i.is_empty() && i.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
        {
            return None;
        }
    }
    let (core, pre) = match core_pre.0.split_once('-') {
        Some((c, p)) => (c, Some(p)),
        None => (core_pre.0, None),
    };
    let mut nums = [0u64; 3];
    let mut it = core.split('.');
    for n in &mut nums {
        let s = it.next()?;
        if s.is_empty()
            || !s.chars().all(|c| c.is_ascii_digit())
            || (s.len() > 1 && s.starts_with('0'))
        {
            return None;
        }
        *n = s.parse().ok()?;
    }
    if it.next().is_some() {
        return None;
    }
    let pre: Vec<&str> = match pre {
        None => Vec::new(),
        Some(p) => {
            let ids: Vec<&str> = p.split('.').collect();
            if ids
                .iter()
                .any(|i| i.is_empty() || !i.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
            {
                return None;
            }
            ids
        }
    };
    Some((nums, pre))
}

/// SemVer precedence (build metadata ignored). An unparseable version sorts first.
pub fn compare_versions(a: &str, b: &str) -> Ordering {
    match (parse_version(a), parse_version(b)) {
        (Some((ca, pa)), Some((cb, pb))) => {
            ca.cmp(&cb)
                .then_with(|| match (pa.is_empty(), pb.is_empty()) {
                    (true, true) => Ordering::Equal,
                    (true, false) => Ordering::Greater,
                    (false, true) => Ordering::Less,
                    (false, false) => {
                        for (x, y) in pa.iter().zip(pb.iter()) {
                            let o = match (x.parse::<u64>(), y.parse::<u64>()) {
                                (Ok(m), Ok(n)) => m.cmp(&n),
                                (Ok(_), Err(_)) => Ordering::Less,
                                (Err(_), Ok(_)) => Ordering::Greater,
                                (Err(_), Err(_)) => x.cmp(y),
                            };
                            if o != Ordering::Equal {
                                return o;
                            }
                        }
                        pa.len().cmp(&pb.len())
                    }
                })
        }
        (None, Some(_)) => Ordering::Less,
        (Some(_), None) => Ordering::Greater,
        (None, None) => a.cmp(b),
    }
}

/// A dist-tag name: not a version (npm reads `pkg@1.2.3` as a version), URL-safe, short.
pub fn valid_tag(tag: &str) -> bool {
    !tag.is_empty()
        && tag.len() <= 64
        && !valid_version(tag)
        && !tag.starts_with(|c: char| {
            c.is_ascii_digit() || c == 'v' && tag[1..].starts_with(|d: char| d.is_ascii_digit())
        })
        && tag
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
}

/// The tarball's file name: `<name without scope>-<version>.tgz`.
pub fn tarball_file(package: &str, version: &str) -> String {
    format!("{}-{version}.tgz", unscoped(package))
}

/// **The intake coordinate of a version**: the tarball's registry path below the repository,
/// `<package>/-/<file>`, as a raw coordinate (no namespace, no version), the way the chain's raw
/// `incoming` and `production` name a file.
pub fn coordinate(package: &str, version: &str) -> ArtifactId {
    ArtifactId {
        namespace: None,
        name: format!("{package}/-/{}", tarball_file(package, version)),
        version: String::new(),
    }
}

/// The (package, version) an intake coordinate is the npm tarball of, if it is one.
pub fn coords_of(id: &ArtifactId) -> Option<(String, String)> {
    if id.namespace.as_deref().is_some_and(|n| !n.is_empty()) || !id.version.is_empty() {
        return None;
    }
    let (package, file) = id.name.split_once("/-/")?;
    if !valid_package_name(package) || file.contains('/') {
        return None;
    }
    let version = file
        .strip_suffix(".tgz")?
        .strip_prefix(unscoped(package))?
        .strip_prefix('-')?;
    valid_version(version).then(|| (package.to_string(), version.to_string()))
}

// ── the wire ────────────────────────────────────────────────────────────────

/// What a path below the npm door names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Route {
    /// `/<package>`: the packument (GET), a publish (PUT).
    Packument(String),
    /// `/<package>/-/<file>.tgz`: a tarball.
    Tarball { package: String, file: String },
    /// `/-/package/<package>/dist-tags`.
    DistTags(String),
    /// `/-/package/<package>/dist-tags/<tag>`.
    DistTag { package: String, tag: String },
    /// Any `-rev` path: how npm unpublishes and rewrites.
    Rewrite(String),
    /// Anything else (search, audit, a version document): not served here.
    Other,
}

fn percent_decode(s: &str) -> Option<String> {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' {
            let hex = s.get(i + 1..i + 3)?;
            out.push(u8::from_str_radix(hex, 16).ok()?);
            i += 3;
        } else {
            out.push(b[i]);
            i += 1;
        }
    }
    String::from_utf8(out).ok()
}

/// Split a decoded path into the package (one segment, or `@scope/name`) and the rest.
fn package_and_rest(segs: &[&str]) -> Option<(String, usize)> {
    let first = *segs.first()?;
    if first.starts_with('@') {
        let second = *segs.get(1)?;
        Some((format!("{first}/{second}"), 2))
    } else {
        Some((first.to_string(), 1))
    }
}

/// **Route a path below the door** (`sub` is the path after `/<repo>`, its query ignored).
/// npm escapes a scoped name's slash (`@scope%2fname`); both spellings route the same.
pub fn route(sub: &str) -> Route {
    let sub = sub.split('?').next().unwrap_or(sub);
    let Some(decoded) = percent_decode(sub) else {
        return Route::Other;
    };
    let segs: Vec<&str> = decoded
        .trim_matches('/')
        .split('/')
        .filter(|s| !s.is_empty())
        .collect();
    if segs.contains(&"-rev") {
        return Route::Rewrite(decoded.clone());
    }
    if segs.first() == Some(&"-") {
        if segs.get(1) != Some(&"package") {
            return Route::Other;
        }
        let Some((package, used)) = package_and_rest(&segs[2..]) else {
            return Route::Other;
        };
        return match &segs[2 + used..] {
            ["dist-tags"] => Route::DistTags(package),
            ["dist-tags", tag] => Route::DistTag {
                package,
                tag: tag.to_string(),
            },
            _ => Route::Other,
        };
    }
    let Some((package, used)) = package_and_rest(&segs) else {
        return Route::Other;
    };
    match &segs[used..] {
        [] => Route::Packument(package),
        ["-", file] => Route::Tarball {
            package,
            file: file.to_string(),
        },
        _ => Route::Other,
    }
}

// ── the publish ─────────────────────────────────────────────────────────────

/// One `npm publish`, read and checked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Publish {
    pub name: String,
    pub version: String,
    /// `<name without scope>-<version>.tgz`.
    pub file: String,
    pub tarball: Vec<u8>,
    /// The tags the publish asks for (`latest` unless `npm publish --tag`).
    pub tags: Vec<String>,
}

/// **Read the document `npm publish` PUTs** to `/<registry>/<url_package>`: exactly one
/// version and its tarball (base64 in `_attachments`), the tarball checked against the
/// document's `dist.integrity` (required, SHA-512), its `length`, and its own `package.json`.
/// A document with no attachment is how npm deprecates, unpublishes and rewrites: refused.
pub fn parse_publish(url_package: &str, body: &[u8]) -> Result<Publish, NpmError> {
    let doc: Value = serde_json::from_slice(body)
        .map_err(|e| NpmError::Publish(format!("the body is not JSON: {e}")))?;
    let doc = doc
        .as_object()
        .ok_or_else(|| NpmError::Publish("the body is not a JSON object".into()))?;
    let name = doc
        .get("name")
        .and_then(Value::as_str)
        .ok_or_else(|| NpmError::Publish("the document has no `name`".into()))?;
    if name != url_package {
        return Err(NpmError::Publish(format!(
            "the document names '{name}', the URL names '{url_package}'"
        )));
    }
    if !valid_package_name(name) {
        return Err(NpmError::Publish(format!(
            "'{name}' is not an npm package name"
        )));
    }
    if let Some(id) = doc.get("_id").and_then(Value::as_str) {
        if id != name {
            return Err(NpmError::Publish(format!(
                "`_id` '{id}' is not the name '{name}'"
            )));
        }
    }
    let attachments = doc
        .get("_attachments")
        .and_then(Value::as_object)
        .filter(|a| !a.is_empty());
    let Some(attachments) = attachments else {
        return Err(rewrite("a package document without a tarball (deprecate, unpublish, an owner or metadata change)"));
    };
    let versions = doc
        .get("versions")
        .and_then(Value::as_object)
        .ok_or_else(|| NpmError::Publish("the document has no `versions`".into()))?;
    if versions.len() != 1 || attachments.len() != 1 {
        return Err(NpmError::Publish(format!(
            "{} version(s) and {} tarball(s); one publish is one of each",
            versions.len(),
            attachments.len()
        )));
    }
    let (version, manifest) = versions.iter().next().expect("one version");
    if !valid_version(version) {
        return Err(NpmError::Publish(format!(
            "'{version}' is not a semantic version"
        )));
    }
    if manifest.get("name").and_then(Value::as_str) != Some(name)
        || manifest.get("version").and_then(Value::as_str) != Some(version.as_str())
    {
        return Err(NpmError::Publish(format!(
            "the version document of '{version}' names another package or version"
        )));
    }
    let (file, attachment) = attachments.iter().next().expect("one tarball");
    // npm names a scoped package's attachment with its scope (`@scope/name-1.0.0.tgz`, MEASURED with
    // npm 10.9 on 2026-10-21); the registry path drops it. Either spelling, nothing else.
    let want_file = tarball_file(name, version);
    if *file != want_file && *file != format!("{name}-{version}.tgz") {
        return Err(NpmError::Publish(format!(
            "the tarball is '{file}', a publish of {name}@{version} attaches '{want_file}'"
        )));
    }
    let data = attachment
        .get("data")
        .and_then(Value::as_str)
        .ok_or_else(|| NpmError::Publish("the tarball attachment has no `data`".into()))?;
    let tarball = base64::engine::general_purpose::STANDARD
        .decode(data.as_bytes())
        .map_err(|e| NpmError::Publish(format!("the tarball attachment is not base64: {e}")))?;
    if let Some(len) = attachment.get("length").and_then(Value::as_u64) {
        if len != tarball.len() as u64 {
            return Err(NpmError::Tarball(format!(
                "the attachment says {len} bytes, it carries {}",
                tarball.len()
            )));
        }
    }
    let want = manifest
        .pointer("/dist/integrity")
        .and_then(Value::as_str)
        .ok_or_else(|| NpmError::Tarball("the version names no `dist.integrity`; Holger verifies every tarball by its SHA-512".into()))?;
    let got = integrity_of(&tarball);
    if !want.split_whitespace().any(|w| w == got) {
        return Err(NpmError::Tarball(format!(
            "`dist.integrity` is {want}, the attached tarball is {got}"
        )));
    }
    let own = read_manifest(&tarball).map_err(NpmError::Tarball)?;
    if own.get("name").and_then(Value::as_str) != Some(name)
        || own.get("version").and_then(Value::as_str) != Some(version.as_str())
    {
        return Err(NpmError::Tarball(format!(
            "the tarball's package.json is {}@{}, the publish is {name}@{version}",
            own.get("name").and_then(Value::as_str).unwrap_or("?"),
            own.get("version").and_then(Value::as_str).unwrap_or("?"),
        )));
    }
    let mut tags: Vec<String> = doc
        .get("dist-tags")
        .and_then(Value::as_object)
        .map(|t| {
            t.iter()
                .filter(|(_, v)| v.as_str() == Some(version.as_str()))
                .map(|(k, _)| k.clone())
                .collect()
        })
        .unwrap_or_default();
    if let Some(bad) = tags.iter().find(|t| !valid_tag(t)) {
        return Err(NpmError::Publish(format!("'{bad}' is not a dist-tag name")));
    }
    if tags.is_empty() {
        tags.push(LATEST.to_string());
    }
    Ok(Publish {
        name: name.to_string(),
        version: version.clone(),
        file: want_file,
        tarball,
        tags,
    })
}

/// What the packument says about one sealed tarball, derived from its bytes.
#[derive(Debug, Clone, PartialEq)]
pub struct VersionMeta {
    pub manifest: Value,
    pub integrity: String,
}

pub fn version_meta(tgz: &[u8]) -> Result<VersionMeta, String> {
    Ok(VersionMeta {
        manifest: read_manifest(tgz)?,
        integrity: integrity_of(tgz),
    })
}

/// What the packument says about one sealed tarball, from its index row (lane HOL-PERF-2):
/// the manifest and integrity the scan worker read at intake. `None` for another row.
pub fn meta_of_row(row: &holger_index_rows::StoredRow) -> Option<VersionMeta> {
    match &row.row {
        holger_index_rows::IndexRow::Npm {
            manifest,
            integrity,
        } => Some(VersionMeta {
            manifest: manifest.clone(),
            integrity: integrity.clone(),
        }),
        _ => None,
    }
}

// ── the dist-tags ───────────────────────────────────────────────────────────

/// One line of a package's append-only tag record. `version: None` removes the tag.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TagRecord {
    pub tag: String,
    pub version: Option<String>,
    pub actor: String,
    pub at: i64,
}

/// The tags in force: replay the record in order, a set taking effect only for an AVAILABLE
/// version, a removal clearing it; then `latest`, if nothing names it, is the highest
/// AVAILABLE version (a release before any pre-release).
pub fn effective_tags(
    records: &[TagRecord],
    available: &BTreeSet<String>,
) -> BTreeMap<String, String> {
    let mut out = BTreeMap::new();
    for r in records {
        match &r.version {
            Some(v) if available.contains(v) => {
                out.insert(r.tag.clone(), v.clone());
            }
            Some(_) => {}
            None => {
                out.remove(&r.tag);
            }
        }
    }
    if !out.contains_key(LATEST) {
        let release = available
            .iter()
            .filter(|v| !v.contains('-'))
            .max_by(|a, b| compare_versions(a, b));
        if let Some(v) = release.or_else(|| available.iter().max_by(|a, b| compare_versions(a, b)))
        {
            out.insert(LATEST.to_string(), v.clone());
        }
    }
    out
}

/// **The dist-tag record**: one append-only JSON-lines file per package under the chain's
/// lineage root, beside the intake record.
pub struct TagLog {
    root: PathBuf,
    lock: Mutex<()>,
}

impl TagLog {
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self {
            root: root.into(),
            lock: Mutex::new(()),
        }
    }

    fn file(&self, package: &str) -> PathBuf {
        // A valid name holds no character a file name cannot; the scope's slash is spelled out.
        self.root
            .join(format!("{}.jsonl", package.replace('/', "%2f")))
    }

    pub fn append(&self, package: &str, rec: &TagRecord) -> std::io::Result<()> {
        let _held = self.lock.lock_or_recover();
        super::stages::append_jsonl(&self.file(package), rec)
    }

    pub fn records(&self, package: &str) -> Vec<TagRecord> {
        let _held = self.lock.lock_or_recover();
        std::fs::read_to_string(self.file(package))
            .map(|t| {
                t.lines()
                    .filter_map(|l| serde_json::from_str(l).ok())
                    .collect()
            })
            .unwrap_or_default()
    }
}

// ── the packument ───────────────────────────────────────────────────────────

/// **The packument** of `package` over its AVAILABLE versions. Each version is its tarball's own
/// manifest with `_id`, `dist.integrity` (the sealed bytes' SHA-512) and `dist.tarball` under
/// `tarball_base` (`<public base>/<repo>`).
pub fn packument(
    package: &str,
    versions: &[(String, VersionMeta)],
    tags: &BTreeMap<String, String>,
    tarball_base: &str,
) -> Value {
    let base = tarball_base.trim_end_matches('/');
    let mut docs = serde_json::Map::new();
    for (version, meta) in versions {
        let mut v = meta.manifest.as_object().cloned().unwrap_or_default();
        v.insert("name".into(), json!(package));
        v.insert("version".into(), json!(version));
        v.insert("_id".into(), json!(format!("{package}@{version}")));
        v.insert(
            "dist".into(),
            json!({ "integrity": meta.integrity, "tarball": format!("{base}/{package}/-/{}", tarball_file(package, version)) }),
        );
        docs.insert(version.clone(), Value::Object(v));
    }
    json!({ "_id": package, "name": package, "dist-tags": tags, "versions": docs })
}

/// **May a caller move dist-tags?** Whoever brings packages in (Importer) or administers the
/// repository (RepositoryAdmin): a tag changes which AVAILABLE version a range installs, never
/// what is AVAILABLE. With no duties configured, the write gate alone decides.
pub fn may_tag(
    auth: &crate::auth::AuthConfig,
    identity: Option<&crate::auth::AuthIdentity>,
) -> Result<(), super::duties::DutyError> {
    use super::duties::{duties_of, principal_of, Duty, DutyError};
    if !auth.duties_enabled() {
        return Ok(());
    }
    let held = duties_of(auth, identity);
    if held.contains(&Duty::Importer) || held.contains(&Duty::RepositoryAdmin) {
        return Ok(());
    }
    Err(DutyError::NotPermitted {
        principal: principal_of(identity),
        held: held.into_iter().collect(),
        verb: "move an npm dist-tag".into(),
        needs: vec![Duty::Importer, Duty::RepositoryAdmin],
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write as _;

    fn tgz(name: &str, version: &str) -> Vec<u8> {
        let manifest = format!("{{\"name\":\"{name}\",\"version\":\"{version}\",\"license\":\"MIT\",\"dependencies\":{{\"left-pad\":\"^1.3.0\"}}}}");
        let mut tar = tar::Builder::new(Vec::new());
        for (path, body) in [
            ("package/package.json", manifest.into_bytes()),
            ("package/index.js", b"module.exports = 1;\n".to_vec()),
        ] {
            let mut h = tar::Header::new_gnu();
            h.set_size(body.len() as u64);
            h.set_mode(0o644);
            h.set_cksum();
            tar.append_data(&mut h, path, &body[..]).unwrap();
        }
        let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
        gz.write_all(&tar.into_inner().unwrap()).unwrap();
        gz.finish().unwrap()
    }

    fn doc(name: &str, version: &str, t: &[u8]) -> Value {
        let file = tarball_file(name, version);
        json!({
            "_id": name, "name": name, "dist-tags": { "latest": version },
            "versions": { version: { "name": name, "version": version, "dist": { "integrity": integrity_of(t), "tarball": "http://example.org/x.tgz" } } },
            "_attachments": { file: { "data": base64::engine::general_purpose::STANDARD.encode(t), "length": t.len() } },
        })
    }

    #[test]
    fn names_follow_npm() {
        for ok in ["left-pad", "@scope/name", "a.b_c~d", "x"] {
            assert!(valid_package_name(ok), "{ok}");
        }
        for bad in [
            "",
            "Left-Pad",
            ".hidden",
            "_x",
            "@scope",
            "@/x",
            "a/b",
            "@s/x/y",
            "a b",
            &"x".repeat(215),
        ] {
            assert!(!valid_package_name(bad), "{bad}");
        }
        assert_eq!(unscoped("@scope/name"), "name");
    }

    #[test]
    fn versions_order_by_semver() {
        assert!(
            valid_version("1.2.3") && valid_version("1.0.0-beta.1") && valid_version("1.0.0+b.7")
        );
        for bad in ["1.2", "01.2.3", "1.2.3-", "v1.2.3", "1.2.3.4", ""] {
            assert!(!valid_version(bad), "{bad}");
        }
        let mut vs = vec![
            "1.0.0",
            "1.0.0-alpha",
            "1.0.0-alpha.1",
            "1.0.0-beta",
            "0.9.12",
            "1.0.0-alpha.beta",
            "1.10.0",
            "1.2.0",
        ];
        vs.sort_by(|a, b| compare_versions(a, b));
        assert_eq!(
            vs,
            [
                "0.9.12",
                "1.0.0-alpha",
                "1.0.0-alpha.1",
                "1.0.0-alpha.beta",
                "1.0.0-beta",
                "1.0.0",
                "1.2.0",
                "1.10.0"
            ]
        );
        assert!(
            valid_tag("latest")
                && valid_tag("next")
                && !valid_tag("1.0.0")
                && !valid_tag("v1")
                && !valid_tag("a b")
        );
    }

    #[test]
    fn a_coordinate_is_the_tarballs_registry_path_and_reads_back() {
        let id = coordinate("@scope/name", "1.0.0-rc.1");
        assert_eq!(id.name, "@scope/name/-/name-1.0.0-rc.1.tgz");
        assert_eq!(
            coords_of(&id),
            Some(("@scope/name".into(), "1.0.0-rc.1".into()))
        );
        let plain = coordinate("left-pad", "1.3.0");
        assert_eq!(coords_of(&plain), Some(("left-pad".into(), "1.3.0".into())));
        for not in [
            "demo_pkg-1.0.0-py3-none-any.whl",
            "left-pad/-/other-1.3.0.tgz",
            "left-pad/-/left-pad-x.tgz",
            "x/-/a/x-1.0.0.tgz",
        ] {
            assert_eq!(
                coords_of(&ArtifactId {
                    namespace: None,
                    name: not.into(),
                    version: String::new()
                }),
                None,
                "{not}"
            );
        }
        assert_eq!(
            coords_of(&ArtifactId {
                namespace: None,
                name: plain.name.clone(),
                version: "1".into()
            }),
            None
        );
    }

    #[test]
    fn the_wire_routes_both_spellings_of_a_scope() {
        assert_eq!(route("/left-pad"), Route::Packument("left-pad".into()));
        assert_eq!(
            route("/@scope%2fname?write=true"),
            Route::Packument("@scope/name".into())
        );
        assert_eq!(
            route("/@scope%2Fname"),
            Route::Packument("@scope/name".into())
        );
        assert_eq!(
            route("/@scope/name"),
            Route::Packument("@scope/name".into())
        );
        assert_eq!(
            route("/@scope/name/-/name-1.0.0.tgz"),
            Route::Tarball {
                package: "@scope/name".into(),
                file: "name-1.0.0.tgz".into()
            }
        );
        assert_eq!(
            route("/-/package/@scope%2fname/dist-tags"),
            Route::DistTags("@scope/name".into())
        );
        assert_eq!(
            route("/-/package/left-pad/dist-tags/next"),
            Route::DistTag {
                package: "left-pad".into(),
                tag: "next".into()
            }
        );
        assert!(matches!(route("/left-pad/-rev/3-abc"), Route::Rewrite(_)));
        assert!(matches!(
            route("/left-pad/-/left-pad-1.0.0.tgz/-rev/3-abc"),
            Route::Rewrite(_)
        ));
        assert_eq!(route("/-/v1/search?text=x"), Route::Other);
        assert_eq!(route("/left-pad/1.0.0"), Route::Other);
        assert_eq!(route("/%zz"), Route::Other);
    }

    #[test]
    fn a_publish_is_one_version_with_its_checked_tarball() {
        let t = tgz("demo", "1.0.0");
        let p = parse_publish("demo", doc("demo", "1.0.0", &t).to_string().as_bytes()).unwrap();
        assert_eq!(
            (p.name.as_str(), p.version.as_str(), p.file.as_str()),
            ("demo", "1.0.0", "demo-1.0.0.tgz")
        );
        assert_eq!(p.tarball, t);
        assert_eq!(p.tags, vec!["latest".to_string()]);
        // The real npm client's spelling for a scoped package: the attachment keeps the scope.
        let s = tgz("@s/demo", "1.0.0");
        let mut d = doc("@s/demo", "1.0.0", &s);
        d["_attachments"] =
            json!({ "@s/demo-1.0.0.tgz": d["_attachments"]["demo-1.0.0.tgz"].clone() });
        assert_eq!(
            parse_publish("@s/demo", d.to_string().as_bytes())
                .unwrap()
                .file,
            "demo-1.0.0.tgz"
        );
        let mut beta = doc("demo", "1.0.0", &t);
        beta["dist-tags"] = json!({ "beta": "1.0.0" });
        assert_eq!(
            parse_publish("demo", beta.to_string().as_bytes())
                .unwrap()
                .tags,
            vec!["beta".to_string()]
        );
    }

    #[test]
    fn a_publish_that_lies_about_its_tarball_is_refused_by_name() {
        let t = tgz("demo", "1.0.0");
        let code = |d: Value, url: &str| {
            parse_publish(url, d.to_string().as_bytes())
                .unwrap_err()
                .code()
                .code
        };
        let mut d = doc("demo", "1.0.0", &t);
        d["versions"]["1.0.0"]["dist"]["integrity"] = json!(integrity_of(b"x"));
        assert_eq!(code(d, "demo"), "holger-npm-2");
        let mut d = doc("demo", "1.0.0", &t);
        d["versions"]["1.0.0"]["dist"]
            .as_object_mut()
            .unwrap()
            .remove("integrity");
        assert_eq!(code(d, "demo"), "holger-npm-2", "no integrity, no publish");
        let mut d = doc("demo", "1.0.0", &t);
        d["_attachments"]["demo-1.0.0.tgz"]["length"] = json!(3);
        assert_eq!(code(d, "demo"), "holger-npm-2");
        let other = tgz("other", "1.0.0");
        let mut d = doc("demo", "1.0.0", &other);
        d["versions"]["1.0.0"]["dist"]["integrity"] = json!(integrity_of(&other));
        assert_eq!(
            code(d, "demo"),
            "holger-npm-2",
            "the tarball's package.json names another package"
        );
        let older = tgz("demo", "0.9.0");
        assert_eq!(
            code(doc("demo", "1.0.0", &older), "demo"),
            "holger-npm-2",
            "the tarball's package.json names another version"
        );
        let mut d = doc("demo", "1.0.0", &t);
        d["_attachments"] =
            json!({ "demo-2.0.0.tgz": d["_attachments"]["demo-1.0.0.tgz"].clone() });
        assert_eq!(code(d, "demo"), "holger-npm-1");
        assert_eq!(
            code(doc("demo", "1.0.0", &t), "other"),
            "holger-npm-1",
            "the URL and the document name one package"
        );
        assert_eq!(
            parse_publish("demo", b"not json").unwrap_err().code().code,
            "holger-npm-1"
        );
        assert_eq!(
            code(doc("Demo", "1.0.0", &t), "Demo"),
            "holger-npm-1",
            "not a new package name"
        );
        let mut d = doc("demo", "1.0.0", &t);
        d.as_object_mut().unwrap().remove("_attachments");
        let e = parse_publish("demo", d.to_string().as_bytes()).unwrap_err();
        assert_eq!(
            (e.code().code, e.status()),
            ("holger-npm-3", 405),
            "a rewrite is refused by policy"
        );
        assert!(read_manifest(b"plain bytes").is_err());
    }

    #[test]
    fn tags_name_only_available_versions_and_latest_falls_back_to_the_highest() {
        let rec = |tag: &str, v: Option<&str>| TagRecord {
            tag: tag.into(),
            version: v.map(str::to_string),
            actor: "a".into(),
            at: 0,
        };
        let avail = |vs: &[&str]| vs.iter().map(|v| v.to_string()).collect::<BTreeSet<_>>();
        let records = [
            rec("latest", Some("1.0.0")),
            rec("beta", Some("2.0.0-beta.1")),
            rec("latest", Some("1.1.0")),
        ];
        let t = effective_tags(&records, &avail(&["1.0.0"]));
        assert_eq!(
            t,
            BTreeMap::from([("latest".to_string(), "1.0.0".to_string())]),
            "pending versions take no tag"
        );
        let t = effective_tags(&records, &avail(&["1.0.0", "1.1.0", "2.0.0-beta.1"]));
        assert_eq!(t["latest"], "1.1.0");
        assert_eq!(t["beta"], "2.0.0-beta.1");
        let t = effective_tags(
            &[rec("beta", Some("2.0.0-beta.1")), rec("beta", None)],
            &avail(&["2.0.0-beta.1", "1.9.0"]),
        );
        assert_eq!(
            t,
            BTreeMap::from([("latest".to_string(), "1.9.0".to_string())]),
            "a release before a pre-release"
        );
        assert!(effective_tags(&[], &BTreeSet::new()).is_empty());
    }

    #[test]
    fn the_packument_is_the_tarballs_manifest_with_its_integrity() {
        let t = tgz("@s/demo", "1.0.0");
        let meta = version_meta(&t).unwrap();
        assert_eq!(meta.manifest["dependencies"]["left-pad"], "^1.3.0");
        let tags = BTreeMap::from([("latest".to_string(), "1.0.0".to_string())]);
        let p = packument(
            "@s/demo",
            &[("1.0.0".into(), meta)],
            &tags,
            "https://repo.example.org/npm/",
        );
        assert_eq!(p["dist-tags"]["latest"], "1.0.0");
        assert_eq!(p["versions"]["1.0.0"]["_id"], "@s/demo@1.0.0");
        assert_eq!(
            p["versions"]["1.0.0"]["dist"]["tarball"],
            "https://repo.example.org/npm/@s/demo/-/demo-1.0.0.tgz"
        );
        assert_eq!(
            p["versions"]["1.0.0"]["dist"]["integrity"],
            integrity_of(&t)
        );
        assert_eq!(p["versions"]["1.0.0"]["dependencies"]["left-pad"], "^1.3.0");
    }

    #[test]
    fn the_tag_log_appends_and_reads_back() {
        let dir = tempfile::tempdir().unwrap();
        let log = TagLog::new(dir.path().join("npm-dist-tags"));
        assert!(log.records("@s/demo").is_empty());
        let r = TagRecord {
            tag: "latest".into(),
            version: Some("1.0.0".into()),
            actor: "console".into(),
            at: 1,
        };
        log.append("@s/demo", &r).unwrap();
        log.append(
            "@s/demo",
            &TagRecord {
                version: None,
                tag: "beta".into(),
                ..r.clone()
            },
        )
        .unwrap();
        assert_eq!(log.records("@s/demo").len(), 2);
        assert_eq!(log.records("@s/demo")[0], r);
        assert!(log.records("demo").is_empty());
    }
}