Skip to main content

npm_utils/
registry.rs

1//! npm registry interaction: tarball URLs, package metadata, and version
2//! resolution against a semver range.
3
4use crate::download;
5use crate::package_json::spec::Range;
6use semver::Version;
7use serde_json::Value;
8
9/// An npm-compatible registry. Defaults to the public registry.
10pub 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/// A resolved package version: the exact version, the tarball to fetch, and the
23/// registry's `dist.integrity` SRI for that tarball (when the packument publishes one).
24///
25/// `#[non_exhaustive]` so further fields can be added without a breaking change — this
26/// type is only ever *constructed* inside the crate; callers receive and read it.
27#[derive(Debug, Clone)]
28#[non_exhaustive]
29pub struct Resolved {
30    pub name: String,
31    pub version: Version,
32    pub tarball_url: String,
33    /// The registry's Subresource-Integrity hash (`sha512-<base64>`), when the packument
34    /// carries one — verified against the downloaded bytes before extraction. `None` for a
35    /// synthesized tarball URL or a packument entry without `dist.integrity`.
36    pub integrity: Option<String>,
37    /// The version's declared license, normalized to a single SPDX-ish string from the
38    /// packument's `license` string / legacy `{ "type": … }` object / `licenses[]` array.
39    /// `None` when the packument declares none. Carried so a generated lockfile can record
40    /// it for license/compliance tooling (npm's own lockfiles do the same).
41    pub license: Option<String>,
42}
43
44impl Registry {
45    /// The public npm registry (`https://registry.npmjs.org`).
46    pub fn npm() -> Self {
47        Self::default()
48    }
49
50    /// A registry at a custom base URL (e.g. a private mirror).
51    pub fn with_base_url(base_url: impl Into<String>) -> Self {
52        Self {
53            base_url: base_url.into(),
54        }
55    }
56
57    /// Conventional tarball URL for an exact `version`. Handles scoped names:
58    /// `@scope/pkg` → `<base>/@scope/pkg/-/pkg-<version>.tgz`.
59    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    /// Fetch the package metadata document ("packument").
65    pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error>> {
66        // Scoped names are URL-encoded in the path: `@scope/pkg` → `@scope%2fpkg`.
67        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    /// Resolve the newest published version of `name` matching the `range`.
77    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    /// Resolve the transitive dependency graph of `roots` into a **flat** set — one
96    /// version per package name (the npm v3+ `node_modules` layout). Each package's
97    /// `dependencies` are read straight from the registry metadata (no tarball
98    /// extraction), every child resolved to its newest matching version, and the set
99    /// de-duplicated by name. Cyclic graphs terminate (a name is resolved once).
100    /// Returns the packages sorted by name.
101    ///
102    /// MVP limitation: a single version per package name. Two *incompatible*
103    /// requirements on the same package — a genuine conflict npm would resolve by
104    /// nesting — is reported as an error rather than silently mis-resolved.
105    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    /// [`resolve_tree`](Self::resolve_tree) with an injectable packument source, so the
113    /// graph walk can be unit-tested without the network.
114    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; // already resolved to a satisfying version — dedup
131                }
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                // Transitive deps routinely use npm `||`/space ranges; parse the full grammar.
151                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
176/// The fields [`select_version`] extracts for the newest matching version:
177/// `(version, dist.tarball, dist.integrity, license)`.
178type SelectedVersion = (Version, Option<String>, Option<String>, Option<String>);
179
180/// Pick the newest version in a packument's `versions` map that satisfies the `range`,
181/// returning it with the `dist.tarball` URL, the `dist.integrity` SRI, and the declared
182/// `license` the registry advertises (each `None` if absent). Factored out for unit testing
183/// without network access.
184fn 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
212/// Normalize a packument version entry's license to a single SPDX-ish string. npm uses a
213/// `license` string today; older packages used a `{ "type": … }` object or a
214/// `licenses: [{ "type": … }]` array — collapse all three (joining a multi-entry array with
215/// `" OR "`), returning `None` when none is declared.
216fn 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
238/// The npm dependency-spec → [`VersionReq`] parser lives in the [`crate::package_json`] module
239/// (the package-spec grammar); re-exported here for back-compat as `registry::version_req`.
240pub use crate::package_json::spec::version_req;
241
242/// The `dependencies` of a specific version, read from a packument, as `(name, spec)`
243/// pairs. The full packument carries each version's `dependencies` inline, so the
244/// transitive walk discovers children without extracting any tarball.
245fn 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        // The registry's dist.integrity rides along so node_modules can verify the tarball.
297        assert_eq!(integrity.as_deref(), Some("sha512-deadbeef"));
298        // The declared license rides along too, so a generated lockfile can record it.
299        assert_eq!(license.as_deref(), Some("BSD-3-Clause"));
300    }
301
302    #[test]
303    fn select_version_integrity_is_none_when_absent() {
304        // A dist with a tarball but no integrity → integrity None. node_modules then refuses
305        // to install it unverified (from_lockfile is likewise strict on a missing sha512).
306        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        // Modern SPDX string (what nearly every package publishes today).
324        assert_eq!(
325            license_of(&json!({ "license": "MIT" })).as_deref(),
326            Some("MIT")
327        );
328        // Legacy `{ type }` object.
329        assert_eq!(
330            license_of(&json!({ "license": { "type": "Apache-2.0", "url": "x" } })).as_deref(),
331            Some("Apache-2.0")
332        );
333        // Legacy `licenses: [{ type }]` array → joined with " OR ".
334        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        // None declared.
340        assert_eq!(license_of(&json!({ "dist": {} })), None);
341    }
342
343    /// A one-version packument carrying a `dependencies` map, mirroring the registry's
344    /// shape, so the graph walk can be exercised without the network.
345    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        // a@1 → {b ^1, c ^1}; b@1 → {c ^1} (shared); c@1 → {a ^1} (cycle back to root).
367        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        // Each of a, b, c resolved exactly once (cycle + shared dep deduped), sorted by name.
385        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        // dist.integrity threads through the transitive walk, ready for verification.
399        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        // Regression: a transitive dep with an npm `||` range (e.g. @lit/context →
413        // @lit/reactive-element `^1.6.2 || ^2.1.0`) must resolve, not fail to parse the `||`.
414        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        // root requires x ^1; root also requires y, and y requires x ^2 → incompatible.
450        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}