use crate::download;
use crate::package_json::spec::{Range, Spec};
use semver::Version;
use serde_json::Value;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PackumentDetail {
#[default]
Abbreviated,
Full,
}
impl PackumentDetail {
fn accept(self) -> Option<&'static str> {
match self {
PackumentDetail::Abbreviated => Some("application/vnd.npm.install-v1+json"),
PackumentDetail::Full => None,
}
}
}
pub struct Registry {
pub base_url: String,
pub detail: PackumentDetail,
}
impl Default for Registry {
fn default() -> Self {
Self {
base_url: "https://registry.npmjs.org".to_string(),
detail: PackumentDetail::Abbreviated,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Resolved {
pub name: String,
pub version: Version,
pub tarball_url: String,
pub integrity: Option<String>,
pub license: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Omission {
pub name: String,
pub spec: String,
pub reason: String,
}
impl Omission {
pub(crate) fn new(
name: impl Into<String>,
spec: impl Into<String>,
reason: impl Into<String>,
) -> Omission {
Omission {
name: name.into(),
spec: spec.into(),
reason: reason.into(),
}
}
}
impl std::fmt::Display for Omission {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.spec.is_empty() {
write!(f, "{} ({})", self.name, self.reason)
} else {
write!(f, "{} ({}: {})", self.name, self.spec, self.reason)
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SearchResult {
pub name: String,
pub version: String,
pub description: Option<String>,
pub keywords: Vec<String>,
pub date: Option<String>,
pub homepage: Option<String>,
}
impl Registry {
pub fn npm() -> Self {
Self::default()
}
pub fn with_base_url(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
..Self::default()
}
}
pub fn with_detail(mut self, detail: PackumentDetail) -> Self {
self.detail = detail;
self
}
pub fn tarball_url(&self, name: &str, version: &str) -> String {
let unscoped = name.rsplit('/').next().unwrap_or(name);
format!("{}/{}/-/{}-{}.tgz", self.base_url, name, unscoped, version)
}
pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
let encoded = match name.strip_prefix('@') {
Some(rest) => format!("@{}", rest.replacen('/', "%2f", 1)),
None => name.to_string(),
};
let url = format!("{}/{}", self.base_url, encoded);
let bytes = download::fetch_with_accept(&url, self.detail.accept())?;
Ok(serde_json::from_slice(&bytes)?)
}
pub fn resolve(
&self,
name: &str,
range: &Range,
) -> Result<Resolved, Box<dyn std::error::Error + Send + Sync>> {
let doc = self.packument(name)?;
let (version, tarball, integrity, license) = select_version(&doc, range)
.ok_or_else(|| format!("no published version of {name} matches {range}"))?;
let tarball_url = tarball.unwrap_or_else(|| self.tarball_url(name, &version.to_string()));
Ok(Resolved {
name: name.to_string(),
version,
tarball_url,
integrity,
license,
})
}
pub fn search(
&self,
query: &str,
limit: usize,
) -> Result<Vec<SearchResult>, Box<dyn std::error::Error + Send + Sync>> {
let size = limit.clamp(1, 250);
let url = format!(
"{}/-/v1/search?text={}&size={size}",
self.base_url,
encode_query(query)
);
let bytes = download::fetch(&url)?;
let doc: Value = serde_json::from_slice(&bytes)?;
Ok(parse_search(&doc))
}
pub fn resolve_tree(
&self,
roots: &[(String, Range)],
) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
self.resolve_tree_observed(roots, |_| {})
}
pub(crate) fn resolve_tree_observed<O>(
&self,
roots: &[(String, Range)],
on_event: O,
) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
where
O: Fn(ResolveEvent<'_>) + Sync,
{
self.resolve_walk(
&required_roots(roots),
|name| self.packument(name),
on_event,
FLAT_INSTALL,
)
.map(|(packages, _omissions)| packages)
}
pub fn resolve_tree_nested(
&self,
roots: &[(String, Range)],
) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
self.resolve_tree_nested_observed(&required_roots(roots), |_| {})
.map(|(packages, _omissions)| packages)
}
pub(crate) fn resolve_tree_nested_observed<O>(
&self,
roots: &[(String, Range, bool)],
on_event: O,
) -> Result<(Vec<Resolved>, Vec<Omission>), Box<dyn std::error::Error + Send + Sync>>
where
O: Fn(ResolveEvent<'_>) + Sync,
{
self.resolve_walk(roots, |name| self.packument(name), on_event, NESTED_AUDIT)
}
#[cfg(test)]
fn resolve_tree_from<F>(
&self,
roots: &[(String, Range)],
get_packument: F,
) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
where
F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
{
self.resolve_walk(&required_roots(roots), get_packument, |_| {}, FLAT_INSTALL)
.map(|(packages, _omissions)| packages)
}
#[cfg(test)]
fn resolve_tree_nested_from<F>(
&self,
roots: &[(String, Range)],
get_packument: F,
) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
where
F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
{
self.resolve_walk(&required_roots(roots), get_packument, |_| {}, NESTED_AUDIT)
.map(|(packages, _omissions)| packages)
}
fn resolve_walk<F, O>(
&self,
roots: &[(String, Range, bool)],
get_packument: F,
on_event: O,
policy: WalkPolicy,
) -> Result<(Vec<Resolved>, Vec<Omission>), Box<dyn std::error::Error + Send + Sync>>
where
F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
O: Fn(ResolveEvent<'_>) + Sync,
{
use std::collections::{HashMap, VecDeque};
let mut packuments: HashMap<String, Value> = HashMap::new();
let mut fetch_errors: HashMap<String, String> = HashMap::new();
let mut resolved: HashMap<String, Vec<Resolved>> = HashMap::new();
let mut omissions: Vec<Omission> = Vec::new();
let mut count = 0usize;
let mut queue: VecDeque<(String, Range, bool)> = roots.iter().cloned().collect();
while !queue.is_empty() {
let batch: Vec<(String, Range, bool)> = queue.drain(..).collect();
prefetch_packuments(
&batch,
&mut packuments,
&mut fetch_errors,
!policy.skip_unsupported,
&get_packument,
&on_event,
)?;
for (name, range, optional) in batch {
if let Some(existing) = resolved.get(&name) {
if existing.iter().any(|r| range.matches(&r.version)) {
continue; }
if !policy.nested {
return Err(format!(
"version conflict for `{name}`: resolved {} but also required \
`{range}` (flat node_modules install resolves one version per \
package)",
existing[0].version
)
.into());
}
}
if let Some(cause) = fetch_errors.get(&name) {
if policy.skip_unsupported && optional {
push_unique(
&mut omissions,
Omission::new(
&name,
range.to_string(),
format!("optional dependency failed to fetch: {cause}"),
),
);
continue;
}
return Err(cause.clone().into());
}
if !packuments.contains_key(&name) {
let doc = get_packument(&name)?;
packuments.insert(name.clone(), doc);
}
let doc = &packuments[&name];
let (version, tarball, integrity, license) = match select_version(doc, &range) {
Some(selected) => selected,
None if policy.skip_unsupported && optional => {
push_unique(
&mut omissions,
Omission::new(
&name,
range.to_string(),
"optional dependency: no published version matches",
),
);
continue;
}
None => {
return Err(format!("no published version of {name} matches {range}").into())
}
};
let deps = dependencies_of(doc, &version, policy.include_optional);
let tarball_url =
tarball.unwrap_or_else(|| self.tarball_url(&name, &version.to_string()));
for (dep_name, dep_spec, dep_optional) in deps {
if policy.skip_unsupported {
let action = classify_dep(&dep_name, &dep_spec).map_err(|e| {
format!(
"{name}@{version} dependency `{dep_name}`: unsupported version \
{dep_spec:?}: {e}"
)
})?;
match action {
EdgeAction::Resolve {
name: target,
range: dep_range,
} => queue.push_back((target, dep_range, optional || dep_optional)),
EdgeAction::Omit(omission) => push_unique(&mut omissions, omission),
}
} else {
let dep_range = Range::parse(&dep_spec).map_err(|e| {
format!(
"{name}@{version} dependency `{dep_name}`: unsupported version \
{dep_spec:?}: {e}"
)
})?;
queue.push_back((dep_name, dep_range, false));
}
}
let package = Resolved {
name,
version,
tarball_url,
integrity,
license,
};
count += 1;
on_event(ResolveEvent::Resolved {
count,
package: &package,
});
resolved
.entry(package.name.clone())
.or_default()
.push(package);
}
}
let mut out: Vec<Resolved> = resolved.into_values().flatten().collect();
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
omissions.sort_by(|a, b| (&a.name, &a.spec).cmp(&(&b.name, &b.spec)));
Ok((out, omissions))
}
}
#[derive(Clone, Copy)]
struct WalkPolicy {
nested: bool,
include_optional: bool,
skip_unsupported: bool,
}
const FLAT_INSTALL: WalkPolicy = WalkPolicy {
nested: false,
include_optional: false,
skip_unsupported: false,
};
const NESTED_AUDIT: WalkPolicy = WalkPolicy {
nested: true,
include_optional: true,
skip_unsupported: true,
};
fn required_roots(roots: &[(String, Range)]) -> Vec<(String, Range, bool)> {
roots
.iter()
.map(|(name, range)| (name.clone(), range.clone(), false))
.collect()
}
pub(crate) enum ResolveEvent<'a> {
FetchBegin {
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
name: &'a str,
},
FetchDone {
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
name: &'a str,
},
Resolved {
#[allow(dead_code)]
count: usize,
#[cfg_attr(not(feature = "cli"), allow(dead_code))]
package: &'a Resolved,
},
}
fn push_unique(omissions: &mut Vec<Omission>, omission: Omission) {
if !omissions.contains(&omission) {
omissions.push(omission);
}
}
pub(crate) enum EdgeAction {
Resolve { name: String, range: Range },
Omit(Omission),
}
pub(crate) fn classify_dep(
name: &str,
spec: &str,
) -> Result<EdgeAction, Box<dyn std::error::Error + Send + Sync>> {
let spec = spec.trim();
if spec.starts_with("workspace:") {
return Ok(EdgeAction::Omit(Omission::new(
name,
spec,
"workspace: protocol",
)));
}
if spec.starts_with("link:") {
return Ok(EdgeAction::Omit(Omission::new(
name,
spec,
"link: protocol",
)));
}
match Spec::parse(spec) {
Spec::Git { .. } => Ok(EdgeAction::Omit(Omission::new(
name,
spec,
"git dependency",
))),
Spec::Tarball(_) => Ok(EdgeAction::Omit(Omission::new(
name,
spec,
"remote tarball",
))),
Spec::Path(_) => Ok(EdgeAction::Omit(Omission::new(name, spec, "local path"))),
Spec::Alias {
name: target,
spec: inner,
} => match *inner {
Spec::Registry(range) => Ok(EdgeAction::Resolve {
name: target,
range: Range::parse(&range)?,
}),
_ => Ok(EdgeAction::Omit(Omission::new(
name,
spec,
"npm: alias to a non-registry target",
))),
},
Spec::Registry(range) => Ok(EdgeAction::Resolve {
name: name.to_string(),
range: Range::parse(&range)?,
}),
}
}
const PACKUMENT_CONCURRENCY: usize = 8;
type FetchedPackument = Result<Value, Box<dyn std::error::Error + Send + Sync>>;
fn prefetch_packuments<F, O>(
batch: &[(String, Range, bool)],
packuments: &mut std::collections::HashMap<String, Value>,
fetch_errors: &mut std::collections::HashMap<String, String>,
fail_fast: bool,
get_packument: &F,
on_event: &O,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
F: Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync,
O: Fn(ResolveEvent<'_>) + Sync,
{
use std::collections::HashSet;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Mutex;
let mut seen: HashSet<&str> = HashSet::new();
let missing: Vec<&str> = batch
.iter()
.map(|(name, _, _)| name.as_str())
.filter(|name| {
!packuments.contains_key(*name)
&& !fetch_errors.contains_key(*name)
&& seen.insert(*name)
})
.collect();
if missing.is_empty() {
return Ok(());
}
let results: Vec<Mutex<Option<FetchedPackument>>> =
missing.iter().map(|_| Mutex::new(None)).collect();
let next = AtomicUsize::new(0);
std::thread::scope(|scope| {
for _ in 0..missing.len().min(PACKUMENT_CONCURRENCY) {
scope.spawn(|| loop {
let i = next.fetch_add(1, Ordering::Relaxed);
let Some(name) = missing.get(i) else { break };
on_event(ResolveEvent::FetchBegin { name });
let result = get_packument(name); on_event(ResolveEvent::FetchDone { name });
*results[i]
.lock()
.expect("no panic while holding a slot lock") = Some(result);
});
}
});
for (name, slot) in missing.iter().zip(results) {
let result = slot
.into_inner()
.expect("a panicking worker re-panics out of the scope before this runs")
.expect("every index below missing.len() was claimed and filled");
match result {
Ok(doc) => {
packuments.insert((*name).to_string(), doc);
}
Err(e) if fail_fast => return Err(e),
Err(e) => {
fetch_errors.insert((*name).to_string(), e.to_string());
}
}
}
Ok(())
}
type SelectedVersion = (Version, Option<String>, Option<String>, Option<String>);
fn select_version(doc: &Value, range: &Range) -> Option<SelectedVersion> {
let versions = doc.get("versions")?.as_object()?;
let mut best: Option<SelectedVersion> = None;
for (ver_str, meta) in versions {
let Ok(ver) = Version::parse(ver_str) else {
continue;
};
if !range.matches(&ver) {
continue;
}
if best.as_ref().map(|(b, ..)| ver > *b).unwrap_or(true) {
let dist = meta.get("dist");
let string_at = |key: &str| {
dist.and_then(|d| d.get(key))
.and_then(|v| v.as_str())
.map(str::to_string)
};
best = Some((
ver,
string_at("tarball"),
string_at("integrity"),
license_of(meta),
));
}
}
best
}
fn license_of(meta: &Value) -> Option<String> {
crate::package_json::normalize_license(meta)
}
pub use crate::package_json::spec::version_req;
fn dependencies_of(
doc: &Value,
version: &Version,
include_optional: bool,
) -> Vec<(String, String, bool)> {
let meta = doc.get("versions").and_then(|v| v.get(version.to_string()));
let read = |key: &str| -> Vec<(String, String)> {
meta.and_then(|m| m.get(key))
.and_then(|d| d.as_object())
.map(|map| {
map.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default()
};
let mut out: Vec<(String, String, bool)> = read("dependencies")
.into_iter()
.map(|(name, spec)| (name, spec, false))
.collect();
if include_optional {
for (name, spec) in read("optionalDependencies") {
match out.iter_mut().find(|(existing, ..)| *existing == name) {
Some(entry) => *entry = (name, spec, true),
None => out.push((name, spec, true)),
}
}
}
out
}
fn parse_search(doc: &Value) -> Vec<SearchResult> {
let Some(objects) = doc.get("objects").and_then(Value::as_array) else {
return Vec::new();
};
objects
.iter()
.filter_map(|obj| {
let pkg = obj.get("package")?;
let name = pkg.get("name")?.as_str()?.to_string();
let string_field = |key: &str| pkg.get(key).and_then(Value::as_str).map(str::to_string);
let keywords = pkg
.get("keywords")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|k| k.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let homepage = pkg
.get("links")
.and_then(|links| links.get("homepage"))
.and_then(Value::as_str)
.map(str::to_string);
Some(SearchResult {
name,
version: string_field("version").unwrap_or_default(),
description: string_field("description"),
keywords,
date: string_field("date"),
homepage,
})
})
.collect()
}
fn encode_query(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tarball_url_handles_scoped_and_unscoped() {
let reg = Registry::npm();
assert_eq!(
reg.tarball_url("lit", "3.3.3"),
"https://registry.npmjs.org/lit/-/lit-3.3.3.tgz"
);
assert_eq!(
reg.tarball_url("@lit/context", "1.1.6"),
"https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz"
);
}
#[test]
fn select_version_picks_newest_matching() {
let doc = json!({
"versions": {
"3.1.0": { "dist": { "tarball": "https://r/lit-3.1.0.tgz" } },
"3.3.3": {
"license": "BSD-3-Clause",
"dist": {
"tarball": "https://r/lit-3.3.3.tgz",
"integrity": "sha512-deadbeef"
}
},
"4.0.0": { "dist": { "tarball": "https://r/lit-4.0.0.tgz" } },
"2.9.9": {}
}
});
let (ver, tarball, integrity, license) =
select_version(&doc, &"^3".parse().unwrap()).unwrap();
assert_eq!(ver, Version::parse("3.3.3").unwrap());
assert_eq!(tarball.as_deref(), Some("https://r/lit-3.3.3.tgz"));
assert_eq!(integrity.as_deref(), Some("sha512-deadbeef"));
assert_eq!(license.as_deref(), Some("BSD-3-Clause"));
}
#[test]
fn select_version_integrity_is_none_when_absent() {
let doc = json!({ "versions": {
"1.0.0": { "dist": { "tarball": "https://r/x-1.0.0.tgz" } }
}});
let (_, tarball, integrity, _license) =
select_version(&doc, &"^1".parse().unwrap()).unwrap();
assert_eq!(tarball.as_deref(), Some("https://r/x-1.0.0.tgz"));
assert!(integrity.is_none());
}
#[test]
fn select_version_none_when_no_match() {
let doc = json!({ "versions": { "1.0.0": {}, "2.0.0": {} } });
assert!(select_version(&doc, &"^5".parse().unwrap()).is_none());
}
#[test]
fn license_of_normalizes_string_object_and_array_forms() {
assert_eq!(
license_of(&json!({ "license": "MIT" })).as_deref(),
Some("MIT")
);
assert_eq!(
license_of(&json!({ "license": { "type": "Apache-2.0", "url": "x" } })).as_deref(),
Some("Apache-2.0")
);
assert_eq!(
license_of(&json!({ "licenses": [{ "type": "MIT" }, { "type": "Apache-2.0" }] }))
.as_deref(),
Some("MIT OR Apache-2.0")
);
assert_eq!(license_of(&json!({ "dist": {} })), None);
}
fn packument_with(version: &str, deps: &[(&str, &str)]) -> Value {
let dep_map: serde_json::Map<String, Value> = deps
.iter()
.map(|(n, s)| (n.to_string(), json!(*s)))
.collect();
let mut versions = serde_json::Map::new();
versions.insert(
version.to_string(),
json!({
"dist": {
"tarball": format!("https://r/{version}.tgz"),
"integrity": format!("sha512-{version}"),
},
"dependencies": Value::Object(dep_map),
}),
);
json!({ "versions": Value::Object(versions) })
}
#[test]
fn resolve_tree_walks_transitively_dedups_and_handles_cycles() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"a".into(),
packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
);
pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
pkgs.insert("c".into(), packument_with("1.5.0", &[("a", "^1")]));
let roots = vec![("a".to_string(), "^1".parse().unwrap())];
let resolved = Registry::npm()
.resolve_tree_from(&roots, |name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
})
.unwrap();
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["a", "b", "c"]);
let ver = |n: &str| {
resolved
.iter()
.find(|r| r.name == n)
.unwrap()
.version
.to_string()
};
assert_eq!(ver("b"), "1.2.0");
assert_eq!(ver("c"), "1.5.0");
let integrity = |n: &str| {
resolved
.iter()
.find(|r| r.name == n)
.unwrap()
.integrity
.clone()
};
assert_eq!(integrity("b").as_deref(), Some("sha512-1.2.0"));
}
#[test]
fn resolve_tree_resolves_a_transitive_or_range() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"ctx".into(),
packument_with("1.1.6", &[("re", "^1.6.2 || ^2.1.0")]),
);
pkgs.insert("re".into(), packument_with("2.1.0", &[]));
let roots = vec![("ctx".to_string(), "^1".parse().unwrap())];
let resolved = Registry::npm()
.resolve_tree_from(&roots, |name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
})
.unwrap();
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
["ctx", "re"],
"the `||`-ranged transitive dep resolved"
);
assert_eq!(
resolved
.iter()
.find(|r| r.name == "re")
.unwrap()
.version
.to_string(),
"2.1.0"
);
}
#[test]
fn resolve_tree_errors_on_version_conflict() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"x".into(),
json!({ "versions": {
"1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
"2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
}}),
);
pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
let roots = vec![
("x".to_string(), "^1".parse().unwrap()),
("y".to_string(), "^1".parse().unwrap()),
];
let err = Registry::npm()
.resolve_tree_from(&roots, |name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
})
.unwrap_err();
assert!(err.to_string().contains("version conflict"), "got: {err}");
}
#[test]
fn resolve_tree_nested_keeps_a_version_per_conflicting_range() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"x".into(),
json!({ "versions": {
"1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
"2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
}}),
);
pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
let roots = vec![
("x".to_string(), "^1".parse().unwrap()),
("y".to_string(), "^1".parse().unwrap()),
];
let resolved = Registry::npm()
.resolve_tree_nested_from(&roots, |name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
})
.unwrap();
let pairs: Vec<String> = resolved
.iter()
.map(|r| format!("{}@{}", r.name, r.version))
.collect();
assert_eq!(
pairs,
["x@1.0.0", "x@2.0.0", "y@1.0.0"],
"both conflicting x versions kept, sorted by name then version"
);
}
#[test]
fn resolve_tree_nested_dedupes_and_terminates_on_a_conflicting_cycle() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"a".into(),
json!({ "versions": {
"1.0.0": { "dist": { "tarball": "https://r/a1.tgz" }, "dependencies": { "b": "^1" } },
"2.0.0": { "dist": { "tarball": "https://r/a2.tgz" }, "dependencies": { "b": "^1" } }
}}),
);
pkgs.insert("b".into(), packument_with("1.0.0", &[("a", "^2")]));
let roots = vec![("a".to_string(), "^1".parse().unwrap())];
let resolved = Registry::npm()
.resolve_tree_nested_from(&roots, |name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
})
.unwrap();
let pairs: Vec<String> = resolved
.iter()
.map(|r| format!("{}@{}", r.name, r.version))
.collect();
assert_eq!(pairs, ["a@1.0.0", "a@2.0.0", "b@1.0.0"]);
}
fn packument_with_optional(
version: &str,
deps: &[(&str, &str)],
optional: &[(&str, &str)],
) -> Value {
let mut doc = packument_with(version, deps);
let opt_map: serde_json::Map<String, Value> = optional
.iter()
.map(|(n, s)| (n.to_string(), json!(*s)))
.collect();
doc["versions"][version]["optionalDependencies"] = Value::Object(opt_map);
doc
}
fn source_from(
pkgs: &std::collections::HashMap<String, Value>,
) -> impl Fn(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> + Sync + '_ {
|name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
}
}
fn pairs(resolved: &[Resolved]) -> Vec<String> {
resolved
.iter()
.map(|r| format!("{}@{}", r.name, r.version))
.collect()
}
#[test]
fn resolve_tree_nested_terminates_on_a_mutual_cycle() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert("a".into(), packument_with("1.0.0", &[("b", "^1")]));
pkgs.insert("b".into(), packument_with("1.0.0", &[("a", "^1")]));
let roots = vec![("a".to_string(), "^1".parse().unwrap())];
let resolved = Registry::npm()
.resolve_tree_nested_from(&roots, source_from(&pkgs))
.unwrap();
assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.0.0"]);
}
#[test]
fn resolve_tree_nested_reuses_a_satisfying_version_across_parents() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"a".into(),
packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
);
pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
pkgs.insert("c".into(), packument_with("1.5.0", &[]));
let roots = vec![("a".to_string(), "^1".parse().unwrap())];
let resolved = Registry::npm()
.resolve_tree_nested_from(&roots, source_from(&pkgs))
.unwrap();
assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.2.0", "c@1.5.0"]);
}
#[test]
fn optional_dependencies_traverse_nested_and_stay_ignored_flat() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"root".into(),
packument_with_optional("1.0.0", &[], &[("opt", "^1")]),
);
pkgs.insert("opt".into(), packument_with("1.1.0", &[("leaf", "^1")]));
pkgs.insert("leaf".into(), packument_with("1.0.0", &[]));
let roots = vec![("root".to_string(), "^1".parse().unwrap())];
let resolved = Registry::npm()
.resolve_tree_nested_from(&roots, source_from(&pkgs))
.unwrap();
assert_eq!(pairs(&resolved), ["leaf@1.0.0", "opt@1.1.0", "root@1.0.0"]);
let resolved = Registry::npm()
.resolve_tree_from(&roots, source_from(&pkgs))
.unwrap();
assert_eq!(pairs(&resolved), ["root@1.0.0"]);
}
#[test]
fn dependencies_of_lets_an_optional_entry_override_in_place() {
let doc = packument_with_optional("1.0.0", &[("x", "^1"), ("y", "^1")], &[("x", "^2")]);
let version = Version::parse("1.0.0").unwrap();
assert_eq!(
dependencies_of(&doc, &version, true),
[
("x".to_string(), "^2".to_string(), true),
("y".to_string(), "^1".to_string(), false),
],
"the optional entry replaces the regular one in place, flag flipped"
);
assert_eq!(
dependencies_of(&doc, &version, false),
[
("x".to_string(), "^1".to_string(), false),
("y".to_string(), "^1".to_string(), false),
],
"the install walk never sees optionalDependencies"
);
}
#[test]
fn optional_fetch_failure_is_an_omission_and_siblings_still_resolve() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"root".into(),
packument_with_optional("1.0.0", &[("b", "^1")], &[("gone", "^2")]),
);
pkgs.insert("b".into(), packument_with("1.0.0", &[]));
let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
let (resolved, omissions) = Registry::npm()
.resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
.unwrap();
assert_eq!(pairs(&resolved), ["b@1.0.0", "root@1.0.0"]);
assert_eq!(omissions.len(), 1, "{omissions:?}");
assert_eq!(omissions[0].name, "gone");
assert!(
omissions[0]
.reason
.contains("optional dependency failed to fetch"),
"{}",
omissions[0]
);
}
#[test]
fn optional_version_mismatch_is_an_omission() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"root".into(),
packument_with_optional("1.0.0", &[], &[("opt", "^9")]),
);
pkgs.insert("opt".into(), packument_with("1.0.0", &[]));
let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
let (resolved, omissions) = Registry::npm()
.resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
.unwrap();
assert_eq!(pairs(&resolved), ["root@1.0.0"]);
assert_eq!(omissions.len(), 1);
assert!(
omissions[0].reason.contains("no published version matches"),
"{}",
omissions[0]
);
}
#[test]
fn required_fetch_failure_still_aborts_the_audit_walk() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert("root".into(), packument_with("1.0.0", &[("gone", "^1")]));
let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
let err = Registry::npm()
.resolve_walk(&roots, source_from(&pkgs), |_| {}, NESTED_AUDIT)
.unwrap_err();
assert!(err.to_string().contains("no packument for gone"), "{err}");
}
#[test]
fn a_name_needed_by_a_required_edge_aborts_even_after_an_optional_omission() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert("root".into(), packument_with("1.0.0", &[("a", "^1")]));
pkgs.insert(
"a".into(),
packument_with_optional("1.0.0", &[("b", "^1")], &[("x", "^1")]),
);
pkgs.insert("b".into(), packument_with("1.0.0", &[("x", "^1")]));
let attempts: std::sync::Mutex<std::collections::HashMap<String, usize>> =
std::sync::Mutex::new(std::collections::HashMap::new());
let get = |name: &str| {
*attempts
.lock()
.unwrap()
.entry(name.to_string())
.or_insert(0) += 1;
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
};
let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
let err = Registry::npm()
.resolve_walk(&roots, get, |_| {}, NESTED_AUDIT)
.unwrap_err();
assert!(err.to_string().contains("no packument for x"), "{err}");
assert_eq!(
attempts.into_inner().unwrap().get("x"),
Some(&1),
"a failed name is never re-fetched"
);
}
#[test]
fn transitive_git_spec_is_an_omission_nested_and_an_error_flat() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"a".into(),
packument_with(
"1.0.0",
&[("g", "git+https://github.com/x/y.git"), ("b", "^1")],
),
);
pkgs.insert("b".into(), packument_with("1.0.0", &[]));
let roots3 = vec![("a".to_string(), "^1".parse().unwrap(), false)];
let (resolved, omissions) = Registry::npm()
.resolve_walk(&roots3, source_from(&pkgs), |_| {}, NESTED_AUDIT)
.unwrap();
assert_eq!(pairs(&resolved), ["a@1.0.0", "b@1.0.0"]);
assert_eq!(omissions.len(), 1);
assert_eq!(
omissions[0].to_string(),
"g (git+https://github.com/x/y.git: git dependency)"
);
let roots = vec![("a".to_string(), "^1".parse().unwrap())];
let err = Registry::npm()
.resolve_tree_from(&roots, source_from(&pkgs))
.unwrap_err();
assert!(err.to_string().contains("unsupported version"), "{err}");
}
#[test]
fn npm_alias_to_a_registry_range_resolves_the_target() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"a".into(),
packument_with("1.0.0", &[("aliased", "npm:real@^1")]),
);
pkgs.insert("real".into(), packument_with("1.5.0", &[]));
let roots = vec![("a".to_string(), "^1".parse().unwrap())];
let resolved = Registry::npm()
.resolve_tree_nested_from(&roots, source_from(&pkgs))
.unwrap();
assert_eq!(
pairs(&resolved),
["a@1.0.0", "real@1.5.0"],
"the alias target resolves under its real name"
);
}
#[test]
fn garbage_transitive_range_stays_a_hard_error_under_the_audit_policy() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"a".into(),
packument_with("1.0.0", &[("bad", "%% nope %%")]),
);
let roots = vec![("a".to_string(), "^1".parse().unwrap())];
let err = Registry::npm()
.resolve_tree_nested_from(&roots, source_from(&pkgs))
.unwrap_err();
assert!(
err.to_string().contains("unsupported version"),
"malformed semver must fail clearly, not become an omission: {err}"
);
}
#[test]
fn classify_dep_routes_protocols_aliases_and_ranges() {
let omit_reason = |spec: &str| match classify_dep("p", spec).unwrap() {
EdgeAction::Omit(o) => o.reason,
EdgeAction::Resolve { .. } => panic!("{spec:?} should be an omission"),
};
assert_eq!(omit_reason("workspace:*"), "workspace: protocol");
assert_eq!(omit_reason("link:../x"), "link: protocol");
assert_eq!(
omit_reason("git+ssh://git@github.com/x/y.git"),
"git dependency"
);
assert_eq!(omit_reason("user/repo"), "git dependency");
assert_eq!(omit_reason("https://x.example/p.tgz"), "remote tarball");
assert_eq!(omit_reason("file:../local"), "local path");
assert_eq!(
omit_reason("npm:x@file:../y"),
"npm: alias to a non-registry target"
);
match classify_dep("aliased", "npm:target@^2").unwrap() {
EdgeAction::Resolve { name, .. } => assert_eq!(name, "target"),
EdgeAction::Omit(o) => panic!("alias to registry must resolve, got {o}"),
}
match classify_dep("plain", "^1.2").unwrap() {
EdgeAction::Resolve { name, .. } => assert_eq!(name, "plain"),
EdgeAction::Omit(o) => panic!("registry range must resolve, got {o}"),
}
assert!(classify_dep("bad", "%% nope %%").is_err());
}
#[test]
fn omission_display_names_the_spec_when_present() {
assert_eq!(
Omission::new("g", "git+ssh://x/y", "git dependency").to_string(),
"g (git+ssh://x/y: git dependency)"
);
assert_eq!(
Omission::new("workspaces", "", "not traversed").to_string(),
"workspaces (not traversed)"
);
}
#[test]
fn prefetch_concurrency_stays_within_the_cap() {
use std::sync::atomic::{AtomicUsize, Ordering};
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
let leaves: Vec<String> = (1..=12).map(|i| format!("l{i:02}")).collect();
pkgs.insert(
"root".into(),
packument_with(
"1.0.0",
&leaves
.iter()
.map(|l| (l.as_str(), "^1"))
.collect::<Vec<_>>(),
),
);
for leaf in &leaves {
pkgs.insert(leaf.clone(), packument_with("1.0.0", &[]));
}
let in_flight = AtomicUsize::new(0);
let high_water = AtomicUsize::new(0);
let get = |name: &str| {
let now = in_flight.fetch_add(1, Ordering::SeqCst) + 1;
high_water.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(2));
in_flight.fetch_sub(1, Ordering::SeqCst);
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
};
let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
let (resolved, _) = Registry::npm()
.resolve_walk(&roots, get, |_| {}, NESTED_AUDIT)
.unwrap();
assert_eq!(resolved.len(), 13);
assert!(
high_water.load(Ordering::SeqCst) <= PACKUMENT_CONCURRENCY,
"at most {PACKUMENT_CONCURRENCY} concurrent fetches, saw {}",
high_water.load(Ordering::SeqCst)
);
}
#[test]
fn parallel_prefetch_is_deterministic_and_fetches_each_name_once() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
let parents: Vec<String> = (1..=10).map(|i| format!("p{i:02}")).collect();
pkgs.insert(
"root".into(),
packument_with(
"1.0.0",
&parents
.iter()
.map(|p| (p.as_str(), "^1"))
.collect::<Vec<_>>(),
),
);
for p in &parents {
pkgs.insert(p.clone(), packument_with("1.0.0", &[("shared", "^1")]));
}
pkgs.insert("shared".into(), packument_with("1.0.0", &[]));
let fetches: std::sync::Mutex<std::collections::HashMap<String, usize>> =
std::sync::Mutex::new(std::collections::HashMap::new());
let get = |name: &str| {
*fetches.lock().unwrap().entry(name.to_string()).or_insert(0) += 1;
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
};
let roots = vec![("root".to_string(), "^1".parse().unwrap(), false)];
let seen: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
let begins = std::sync::atomic::AtomicUsize::new(0);
let dones = std::sync::atomic::AtomicUsize::new(0);
let (resolved, _) = Registry::npm()
.resolve_walk(
&roots,
get,
|event| match event {
ResolveEvent::FetchBegin { .. } => {
begins.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
ResolveEvent::FetchDone { .. } => {
dones.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
ResolveEvent::Resolved { count, .. } => seen.lock().unwrap().push(count),
},
NESTED_AUDIT,
)
.unwrap();
let fetches = fetches.into_inner().unwrap();
assert_eq!(fetches.len(), 12);
assert!(
fetches.values().all(|&n| n == 1),
"duplicate fetches: {fetches:?}"
);
assert_eq!(begins.into_inner(), 12);
assert_eq!(dones.into_inner(), 12);
assert_eq!(seen.into_inner().unwrap(), (1..=12).collect::<Vec<_>>());
let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
let expected: Vec<String> = parents
.iter()
.cloned()
.chain(["root".to_string(), "shared".to_string()])
.collect();
assert_eq!(
names,
expected.iter().map(String::as_str).collect::<Vec<_>>()
);
}
#[test]
fn resolve_walk_reports_each_resolution_to_the_observer() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert(
"a".into(),
packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
);
pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
pkgs.insert("c".into(), packument_with("1.5.0", &[]));
let roots = vec![("a".to_string(), "^1".parse().unwrap(), false)];
let seen: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
let (resolved, _) = Registry::npm()
.resolve_walk(
&roots,
|name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
},
|event| {
if let ResolveEvent::Resolved { count, package } = event {
seen.lock()
.unwrap()
.push(format!("{count} {}@{}", package.name, package.version));
}
},
NESTED_AUDIT,
)
.unwrap();
let seen = seen.into_inner().unwrap();
assert_eq!(seen, ["1 a@1.0.0", "2 b@1.2.0", "3 c@1.5.0"]);
assert_eq!(seen.len(), resolved.len());
}
#[test]
fn fetch_events_bracket_each_fetch_once_including_failures() {
let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
pkgs.insert("ok".into(), packument_with("1.0.0", &[]));
let events: std::sync::Mutex<std::collections::HashMap<String, (usize, usize)>> =
std::sync::Mutex::new(std::collections::HashMap::new());
let counts: std::sync::Mutex<Vec<usize>> = std::sync::Mutex::new(Vec::new());
let roots = vec![
("ok".to_string(), "^1".parse().unwrap(), false),
("bad".to_string(), "^1".parse().unwrap(), true),
];
let (resolved, omissions) = Registry::npm()
.resolve_walk(
&roots,
|name| {
pkgs.get(name)
.cloned()
.ok_or_else(|| format!("no packument for {name}").into())
},
|event| match event {
ResolveEvent::FetchBegin { name } => {
events
.lock()
.unwrap()
.entry(name.to_string())
.or_default()
.0 += 1;
}
ResolveEvent::FetchDone { name } => {
events
.lock()
.unwrap()
.entry(name.to_string())
.or_default()
.1 += 1;
}
ResolveEvent::Resolved { count, .. } => counts.lock().unwrap().push(count),
},
NESTED_AUDIT,
)
.unwrap();
let events = events.into_inner().unwrap();
assert_eq!(events.get("ok"), Some(&(1, 1)));
assert_eq!(
events.get("bad"),
Some(&(1, 1)),
"a failed fetch still closes"
);
assert_eq!(counts.into_inner().unwrap(), [1]);
assert_eq!(resolved.len(), 1);
assert_eq!(omissions.len(), 1);
assert!(
omissions[0]
.reason
.contains("optional dependency failed to fetch"),
"{}",
omissions[0]
);
}
#[test]
fn parse_search_extracts_packages_and_skips_malformed() {
let doc = json!({
"objects": [
{ "package": {
"name": "lodash",
"version": "4.17.21",
"description": "Lodash modular utilities.",
"keywords": ["util", "functional"],
"date": "2021-02-20T15:42:16.891Z",
"links": { "npm": "https://www.npmjs.com/package/lodash", "homepage": "https://lodash.com/" }
}},
{ "package": { "version": "1.0.0" } }, { "score": {} } ],
"total": 1
});
let results = parse_search(&doc);
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.name, "lodash");
assert_eq!(r.version, "4.17.21");
assert_eq!(r.description.as_deref(), Some("Lodash modular utilities."));
assert_eq!(r.keywords, ["util", "functional"]);
assert_eq!(r.homepage.as_deref(), Some("https://lodash.com/"));
assert!(r.date.is_some());
}
#[test]
fn parse_search_empty_when_no_objects() {
assert!(parse_search(&json!({})).is_empty());
assert!(parse_search(&json!({ "objects": "nope" })).is_empty());
}
#[test]
fn encode_query_escapes_reserved_characters() {
assert_eq!(encode_query("lodash"), "lodash");
assert_eq!(encode_query("react dom"), "react%20dom");
assert_eq!(encode_query("@lit/context"), "%40lit%2Fcontext");
}
}