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