1use crate::download;
5use crate::package_json::spec::Range;
6use semver::Version;
7use serde_json::Value;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
11pub enum PackumentDetail {
12 #[default]
15 Abbreviated,
16 Full,
18}
19
20impl PackumentDetail {
21 fn accept(self) -> Option<&'static str> {
23 match self {
24 PackumentDetail::Abbreviated => Some("application/vnd.npm.install-v1+json"),
25 PackumentDetail::Full => None,
26 }
27 }
28}
29
30pub struct Registry {
32 pub base_url: String,
33 pub detail: PackumentDetail,
36}
37
38impl Default for Registry {
39 fn default() -> Self {
40 Self {
41 base_url: "https://registry.npmjs.org".to_string(),
42 detail: PackumentDetail::Abbreviated,
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct Resolved {
55 pub name: String,
56 pub version: Version,
57 pub tarball_url: String,
58 pub integrity: Option<String>,
62 pub license: Option<String>,
67}
68
69impl Registry {
70 pub fn npm() -> Self {
72 Self::default()
73 }
74
75 pub fn with_base_url(base_url: impl Into<String>) -> Self {
77 Self {
78 base_url: base_url.into(),
79 ..Self::default()
80 }
81 }
82
83 pub fn with_detail(mut self, detail: PackumentDetail) -> Self {
85 self.detail = detail;
86 self
87 }
88
89 pub fn tarball_url(&self, name: &str, version: &str) -> String {
92 let unscoped = name.rsplit('/').next().unwrap_or(name);
93 format!("{}/{}/-/{}-{}.tgz", self.base_url, name, unscoped, version)
94 }
95
96 pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
98 let encoded = match name.strip_prefix('@') {
100 Some(rest) => format!("@{}", rest.replacen('/', "%2f", 1)),
101 None => name.to_string(),
102 };
103 let url = format!("{}/{}", self.base_url, encoded);
104 let bytes = download::fetch_with_accept(&url, self.detail.accept())?;
105 Ok(serde_json::from_slice(&bytes)?)
106 }
107
108 pub fn resolve(
110 &self,
111 name: &str,
112 range: &Range,
113 ) -> Result<Resolved, Box<dyn std::error::Error + Send + Sync>> {
114 let doc = self.packument(name)?;
115 let (version, tarball, integrity, license) = select_version(&doc, range)
116 .ok_or_else(|| format!("no published version of {name} matches {range}"))?;
117 let tarball_url = tarball.unwrap_or_else(|| self.tarball_url(name, &version.to_string()));
118 Ok(Resolved {
119 name: name.to_string(),
120 version,
121 tarball_url,
122 integrity,
123 license,
124 })
125 }
126
127 pub fn resolve_tree(
138 &self,
139 roots: &[(String, Range)],
140 ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>> {
141 self.resolve_tree_from(roots, |name| self.packument(name))
142 }
143
144 fn resolve_tree_from<F>(
147 &self,
148 roots: &[(String, Range)],
149 mut get_packument: F,
150 ) -> Result<Vec<Resolved>, Box<dyn std::error::Error + Send + Sync>>
151 where
152 F: FnMut(&str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>>,
153 {
154 use std::collections::{HashMap, VecDeque};
155 let mut packuments: HashMap<String, Value> = HashMap::new();
156 let mut resolved: HashMap<String, Resolved> = HashMap::new();
157 let mut queue: VecDeque<(String, Range)> = roots.iter().cloned().collect();
158
159 while let Some((name, range)) = queue.pop_front() {
160 if let Some(existing) = resolved.get(&name) {
161 if range.matches(&existing.version) {
162 continue; }
164 return Err(format!(
165 "version conflict for `{name}`: resolved {} but also required `{range}` \
166 (flat node_modules install resolves one version per package)",
167 existing.version
168 )
169 .into());
170 }
171 if !packuments.contains_key(&name) {
172 let doc = get_packument(&name)?;
173 packuments.insert(name.clone(), doc);
174 }
175 let doc = &packuments[&name];
176 let (version, tarball, integrity, license) = select_version(doc, &range)
177 .ok_or_else(|| format!("no published version of {name} matches {range}"))?;
178 let deps = dependencies_of(doc, &version);
179 let tarball_url =
180 tarball.unwrap_or_else(|| self.tarball_url(&name, &version.to_string()));
181 for (dep_name, dep_spec) in deps {
182 let dep_range = Range::parse(&dep_spec).map_err(|e| {
184 format!(
185 "{name}@{version} dependency `{dep_name}`: unsupported version \
186 {dep_spec:?}: {e}"
187 )
188 })?;
189 queue.push_back((dep_name, dep_range));
190 }
191 resolved.insert(
192 name.clone(),
193 Resolved {
194 name,
195 version,
196 tarball_url,
197 integrity,
198 license,
199 },
200 );
201 }
202 let mut out: Vec<Resolved> = resolved.into_values().collect();
203 out.sort_by(|a, b| a.name.cmp(&b.name));
204 Ok(out)
205 }
206}
207
208type SelectedVersion = (Version, Option<String>, Option<String>, Option<String>);
211
212fn select_version(doc: &Value, range: &Range) -> Option<SelectedVersion> {
217 let versions = doc.get("versions")?.as_object()?;
218 let mut best: Option<SelectedVersion> = None;
219 for (ver_str, meta) in versions {
220 let Ok(ver) = Version::parse(ver_str) else {
221 continue;
222 };
223 if !range.matches(&ver) {
224 continue;
225 }
226 if best.as_ref().map(|(b, ..)| ver > *b).unwrap_or(true) {
227 let dist = meta.get("dist");
228 let string_at = |key: &str| {
229 dist.and_then(|d| d.get(key))
230 .and_then(|v| v.as_str())
231 .map(str::to_string)
232 };
233 best = Some((
234 ver,
235 string_at("tarball"),
236 string_at("integrity"),
237 license_of(meta),
238 ));
239 }
240 }
241 best
242}
243
244fn license_of(meta: &Value) -> Option<String> {
249 crate::package_json::normalize_license(meta)
250}
251
252pub use crate::package_json::spec::version_req;
255
256fn dependencies_of(doc: &Value, version: &Version) -> Vec<(String, String)> {
260 doc.get("versions")
261 .and_then(|v| v.get(version.to_string()))
262 .and_then(|meta| meta.get("dependencies"))
263 .and_then(|d| d.as_object())
264 .map(|map| {
265 map.iter()
266 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
267 .collect()
268 })
269 .unwrap_or_default()
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use serde_json::json;
276
277 #[test]
278 fn tarball_url_handles_scoped_and_unscoped() {
279 let reg = Registry::npm();
280 assert_eq!(
281 reg.tarball_url("lit", "3.3.3"),
282 "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz"
283 );
284 assert_eq!(
285 reg.tarball_url("@lit/context", "1.1.6"),
286 "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz"
287 );
288 }
289
290 #[test]
291 fn select_version_picks_newest_matching() {
292 let doc = json!({
293 "versions": {
294 "3.1.0": { "dist": { "tarball": "https://r/lit-3.1.0.tgz" } },
295 "3.3.3": {
296 "license": "BSD-3-Clause",
297 "dist": {
298 "tarball": "https://r/lit-3.3.3.tgz",
299 "integrity": "sha512-deadbeef"
300 }
301 },
302 "4.0.0": { "dist": { "tarball": "https://r/lit-4.0.0.tgz" } },
303 "2.9.9": {}
304 }
305 });
306 let (ver, tarball, integrity, license) =
307 select_version(&doc, &"^3".parse().unwrap()).unwrap();
308 assert_eq!(ver, Version::parse("3.3.3").unwrap());
309 assert_eq!(tarball.as_deref(), Some("https://r/lit-3.3.3.tgz"));
310 assert_eq!(integrity.as_deref(), Some("sha512-deadbeef"));
312 assert_eq!(license.as_deref(), Some("BSD-3-Clause"));
314 }
315
316 #[test]
317 fn select_version_integrity_is_none_when_absent() {
318 let doc = json!({ "versions": {
321 "1.0.0": { "dist": { "tarball": "https://r/x-1.0.0.tgz" } }
322 }});
323 let (_, tarball, integrity, _license) =
324 select_version(&doc, &"^1".parse().unwrap()).unwrap();
325 assert_eq!(tarball.as_deref(), Some("https://r/x-1.0.0.tgz"));
326 assert!(integrity.is_none());
327 }
328
329 #[test]
330 fn select_version_none_when_no_match() {
331 let doc = json!({ "versions": { "1.0.0": {}, "2.0.0": {} } });
332 assert!(select_version(&doc, &"^5".parse().unwrap()).is_none());
333 }
334
335 #[test]
336 fn license_of_normalizes_string_object_and_array_forms() {
337 assert_eq!(
339 license_of(&json!({ "license": "MIT" })).as_deref(),
340 Some("MIT")
341 );
342 assert_eq!(
344 license_of(&json!({ "license": { "type": "Apache-2.0", "url": "x" } })).as_deref(),
345 Some("Apache-2.0")
346 );
347 assert_eq!(
349 license_of(&json!({ "licenses": [{ "type": "MIT" }, { "type": "Apache-2.0" }] }))
350 .as_deref(),
351 Some("MIT OR Apache-2.0")
352 );
353 assert_eq!(license_of(&json!({ "dist": {} })), None);
355 }
356
357 fn packument_with(version: &str, deps: &[(&str, &str)]) -> Value {
360 let dep_map: serde_json::Map<String, Value> = deps
361 .iter()
362 .map(|(n, s)| (n.to_string(), json!(*s)))
363 .collect();
364 let mut versions = serde_json::Map::new();
365 versions.insert(
366 version.to_string(),
367 json!({
368 "dist": {
369 "tarball": format!("https://r/{version}.tgz"),
370 "integrity": format!("sha512-{version}"),
371 },
372 "dependencies": Value::Object(dep_map),
373 }),
374 );
375 json!({ "versions": Value::Object(versions) })
376 }
377
378 #[test]
379 fn resolve_tree_walks_transitively_dedups_and_handles_cycles() {
380 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
382 pkgs.insert(
383 "a".into(),
384 packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
385 );
386 pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
387 pkgs.insert("c".into(), packument_with("1.5.0", &[("a", "^1")]));
388
389 let roots = vec![("a".to_string(), "^1".parse().unwrap())];
390 let resolved = Registry::npm()
391 .resolve_tree_from(&roots, |name| {
392 pkgs.get(name)
393 .cloned()
394 .ok_or_else(|| format!("no packument for {name}").into())
395 })
396 .unwrap();
397
398 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
400 assert_eq!(names, ["a", "b", "c"]);
401 let ver = |n: &str| {
402 resolved
403 .iter()
404 .find(|r| r.name == n)
405 .unwrap()
406 .version
407 .to_string()
408 };
409 assert_eq!(ver("b"), "1.2.0");
410 assert_eq!(ver("c"), "1.5.0");
411
412 let integrity = |n: &str| {
414 resolved
415 .iter()
416 .find(|r| r.name == n)
417 .unwrap()
418 .integrity
419 .clone()
420 };
421 assert_eq!(integrity("b").as_deref(), Some("sha512-1.2.0"));
422 }
423
424 #[test]
425 fn resolve_tree_resolves_a_transitive_or_range() {
426 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
429 pkgs.insert(
430 "ctx".into(),
431 packument_with("1.1.6", &[("re", "^1.6.2 || ^2.1.0")]),
432 );
433 pkgs.insert("re".into(), packument_with("2.1.0", &[]));
434
435 let roots = vec![("ctx".to_string(), "^1".parse().unwrap())];
436 let resolved = Registry::npm()
437 .resolve_tree_from(&roots, |name| {
438 pkgs.get(name)
439 .cloned()
440 .ok_or_else(|| format!("no packument for {name}").into())
441 })
442 .unwrap();
443
444 let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
445 assert_eq!(
446 names,
447 ["ctx", "re"],
448 "the `||`-ranged transitive dep resolved"
449 );
450 assert_eq!(
451 resolved
452 .iter()
453 .find(|r| r.name == "re")
454 .unwrap()
455 .version
456 .to_string(),
457 "2.1.0"
458 );
459 }
460
461 #[test]
462 fn resolve_tree_errors_on_version_conflict() {
463 let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
465 pkgs.insert(
466 "x".into(),
467 json!({ "versions": {
468 "1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
469 "2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
470 }}),
471 );
472 pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
473
474 let roots = vec![
475 ("x".to_string(), "^1".parse().unwrap()),
476 ("y".to_string(), "^1".parse().unwrap()),
477 ];
478 let err = Registry::npm()
479 .resolve_tree_from(&roots, |name| {
480 pkgs.get(name)
481 .cloned()
482 .ok_or_else(|| format!("no packument for {name}").into())
483 })
484 .unwrap_err();
485 assert!(err.to_string().contains("version conflict"), "got: {err}");
486 }
487}