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