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/// How much of a packument to request from the registry.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
11pub enum PackumentDetail {
12    /// The abbreviated install document (`application/vnd.npm.install-v1+json`) — much smaller and
13    /// faster, but it omits each version's `license`.
14    #[default]
15    Abbreviated,
16    /// The full document, including `license`.
17    Full,
18}
19
20impl PackumentDetail {
21    /// The `Accept` header for this detail level (`None` = the registry's default JSON).
22    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
30/// An npm-compatible registry. Defaults to the public registry and the abbreviated packument.
31pub struct Registry {
32    pub base_url: String,
33    /// How much of each packument to fetch. Abbreviated by default (faster); `Full` includes
34    /// per-version `license`.
35    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/// A resolved package version: the exact version, the tarball to fetch, and the
48/// registry's `dist.integrity` SRI for that tarball (when the packument publishes one).
49///
50/// `#[non_exhaustive]` so further fields can be added without a breaking change — this
51/// type is only ever *constructed* inside the crate; callers receive and read it.
52#[derive(Debug, Clone)]
53#[non_exhaustive]
54pub struct Resolved {
55    pub name: String,
56    pub version: Version,
57    pub tarball_url: String,
58    /// The registry's Subresource-Integrity hash (`sha512-<base64>`), when the packument
59    /// carries one — verified against the downloaded bytes before extraction. `None` for a
60    /// synthesized tarball URL or a packument entry without `dist.integrity`.
61    pub integrity: Option<String>,
62    /// The version's declared license, normalized to a single SPDX-ish string from the
63    /// packument's `license` string / legacy `{ "type": … }` object / `licenses[]` array.
64    /// `None` when the packument declares none. Carried so a generated lockfile can record
65    /// it for license/compliance tooling (npm's own lockfiles do the same).
66    pub license: Option<String>,
67}
68
69impl Registry {
70    /// The public npm registry (`https://registry.npmjs.org`).
71    pub fn npm() -> Self {
72        Self::default()
73    }
74
75    /// A registry at a custom base URL (e.g. a private mirror).
76    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    /// Set how much of each packument to fetch (e.g. [`PackumentDetail::Full`] to capture license).
84    pub fn with_detail(mut self, detail: PackumentDetail) -> Self {
85        self.detail = detail;
86        self
87    }
88
89    /// Conventional tarball URL for an exact `version`. Handles scoped names:
90    /// `@scope/pkg` → `<base>/@scope/pkg/-/pkg-<version>.tgz`.
91    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    /// Fetch the package metadata document ("packument").
97    pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
98        // Scoped names are URL-encoded in the path: `@scope/pkg` → `@scope%2fpkg`.
99        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    /// Resolve the newest published version of `name` matching the `range`.
109    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    /// Resolve the transitive dependency graph of `roots` into a **flat** set — one
128    /// version per package name (the npm v3+ `node_modules` layout). Each package's
129    /// `dependencies` are read straight from the registry metadata (no tarball
130    /// extraction), every child resolved to its newest matching version, and the set
131    /// de-duplicated by name. Cyclic graphs terminate (a name is resolved once).
132    /// Returns the packages sorted by name.
133    ///
134    /// MVP limitation: a single version per package name. Two *incompatible*
135    /// requirements on the same package — a genuine conflict npm would resolve by
136    /// nesting — is reported as an error rather than silently mis-resolved.
137    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    /// [`resolve_tree`](Self::resolve_tree) with an injectable packument source, so the
145    /// graph walk can be unit-tested without the network.
146    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; // already resolved to a satisfying version — dedup
163                }
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                // Transitive deps routinely use npm `||`/space ranges; parse the full grammar.
183                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
208/// The fields [`select_version`] extracts for the newest matching version:
209/// `(version, dist.tarball, dist.integrity, license)`.
210type SelectedVersion = (Version, Option<String>, Option<String>, Option<String>);
211
212/// Pick the newest version in a packument's `versions` map that satisfies the `range`,
213/// returning it with the `dist.tarball` URL, the `dist.integrity` SRI, and the declared
214/// `license` the registry advertises (each `None` if absent). Factored out for unit testing
215/// without network access.
216fn 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
244/// Normalize a packument version entry's license to a single SPDX-ish string. npm uses a
245/// `license` string today; older packages used a `{ "type": … }` object or a
246/// `licenses: [{ "type": … }]` array — collapse all three (joining a multi-entry array with
247/// `" OR "`), returning `None` when none is declared.
248fn license_of(meta: &Value) -> Option<String> {
249    crate::package_json::normalize_license(meta)
250}
251
252/// The npm dependency-spec → `VersionReq` parser lives in the [`crate::package_json`] module
253/// (the package-spec grammar); re-exported here for back-compat as `registry::version_req`.
254pub use crate::package_json::spec::version_req;
255
256/// The `dependencies` of a specific version, read from a packument, as `(name, spec)`
257/// pairs. The full packument carries each version's `dependencies` inline, so the
258/// transitive walk discovers children without extracting any tarball.
259fn 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        // The registry's dist.integrity rides along so node_modules can verify the tarball.
311        assert_eq!(integrity.as_deref(), Some("sha512-deadbeef"));
312        // The declared license rides along too, so a generated lockfile can record it.
313        assert_eq!(license.as_deref(), Some("BSD-3-Clause"));
314    }
315
316    #[test]
317    fn select_version_integrity_is_none_when_absent() {
318        // A dist with a tarball but no integrity → integrity None. node_modules then refuses
319        // to install it unverified (from_lockfile is likewise strict on a missing sha512).
320        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        // Modern SPDX string (what nearly every package publishes today).
338        assert_eq!(
339            license_of(&json!({ "license": "MIT" })).as_deref(),
340            Some("MIT")
341        );
342        // Legacy `{ type }` object.
343        assert_eq!(
344            license_of(&json!({ "license": { "type": "Apache-2.0", "url": "x" } })).as_deref(),
345            Some("Apache-2.0")
346        );
347        // Legacy `licenses: [{ type }]` array → joined with " OR ".
348        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        // None declared.
354        assert_eq!(license_of(&json!({ "dist": {} })), None);
355    }
356
357    /// A one-version packument carrying a `dependencies` map, mirroring the registry's
358    /// shape, so the graph walk can be exercised without the network.
359    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        // a@1 → {b ^1, c ^1}; b@1 → {c ^1} (shared); c@1 → {a ^1} (cycle back to root).
381        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        // Each of a, b, c resolved exactly once (cycle + shared dep deduped), sorted by name.
399        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        // dist.integrity threads through the transitive walk, ready for verification.
413        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        // Regression: a transitive dep with an npm `||` range (e.g. @lit/context →
427        // @lit/reactive-element `^1.6.2 || ^2.1.0`) must resolve, not fail to parse the `||`.
428        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        // root requires x ^1; root also requires y, and y requires x ^2 → incompatible.
464        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}