Skip to main content

npm_utils/package_json/
lock.rs

1//! `package-lock.json` (lockfileVersion 2 or 3) parsing, per
2//! <https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json>.
3//!
4//! [`Lockfile::parse`] reads the flat `packages` map into faithful [`LockedPackage`] data.
5//! [`render_v3`] is the inverse: it emits a `lockfileVersion`-3 document for a flat resolved set
6//! (what `cargo npm-utils add`/`upgrade` write). Both are pure — they touch no filesystem and
7//! resolve no paths: a caller turns a [`LockedPackage::key`] into an install path itself, so this
8//! parser stays pure and the path-safety check lives with the installer. lockfileVersion 1 (the
9//! legacy hierarchical `dependencies` tree, with no `packages` map) is unsupported.
10
11use std::path::Path;
12
13use serde_json::{Map, Value};
14
15use super::{manifest, spec};
16use crate::registry::Registry;
17
18/// A parsed `package-lock.json`.
19#[derive(Debug, Clone)]
20pub struct Lockfile {
21    /// The `lockfileVersion` (always ≥ 2 here).
22    pub version: u64,
23    /// Every entry of the `packages` map, sorted by key — so install order, and thus
24    /// `.bin` name-collision resolution, is deterministic. Includes the root `""` entry.
25    pub packages: Vec<LockedPackage>,
26}
27
28/// One entry of the `packages` map.
29#[derive(Debug, Clone)]
30pub struct LockedPackage {
31    /// The map key: `""` for the root project, else a `node_modules/…`-relative path.
32    pub key: String,
33    /// The package name — the segment after the last `node_modules/` (empty for the root).
34    pub name: String,
35    /// `version` from the entry (empty for the root or a pure link).
36    pub version: String,
37    /// `resolved` — the registry URL, git source, or `file:` path; `None` if absent.
38    pub resolved: Option<String>,
39    /// `integrity` — the Subresource-Integrity string (`sha512-…`); `None` if absent.
40    pub integrity: Option<String>,
41    /// `license` — the package's declared SPDX license string, when the lockfile records one
42    /// (npm writes it per package; so does this crate's [`render_v3`]). `None` if absent.
43    /// Read so SBOM/compliance output ([`crate::sbom`]) can carry it.
44    pub license: Option<String>,
45    /// `dev` — strictly in the devDependencies tree.
46    pub dev: bool,
47    /// `optional` — strictly in the optionalDependencies tree.
48    pub optional: bool,
49    /// `devOptional` — both a dev and an optional dependency.
50    pub dev_optional: bool,
51    /// `link: true` — a symlink to a local path; nothing is fetched.
52    pub link: bool,
53    /// `os` constraints (npm spelling — `darwin`, `linux`, `win32`; `!`-negation allowed).
54    pub os: Vec<String>,
55    /// `cpu` constraints (npm spelling — `x64`, `arm64`, `ia32`; `!`-negation allowed).
56    pub cpu: Vec<String>,
57    /// `bin` as `(name, path-within-package)` pairs.
58    pub bin: Vec<(String, String)>,
59}
60
61impl Lockfile {
62    /// Parse a `package-lock.json` document (lockfileVersion 2 or 3).
63    pub fn parse(s: &str) -> Result<Lockfile, Box<dyn std::error::Error + Send + Sync>> {
64        let json: Value = serde_json::from_str(s)?;
65        let version = json
66            .get("lockfileVersion")
67            .and_then(Value::as_u64)
68            .unwrap_or(0);
69        if version < 2 {
70            return Err(format!(
71                "package-lock.json lockfileVersion {version} is unsupported \
72                 (need 2 or 3, which carry the `packages` map)"
73            )
74            .into());
75        }
76        let packages = json
77            .get("packages")
78            .and_then(Value::as_object)
79            .ok_or("package-lock.json has no `packages` map")?;
80        let mut out: Vec<LockedPackage> = packages
81            .iter()
82            .filter_map(|(key, entry)| {
83                entry
84                    .as_object()
85                    .map(|entry| LockedPackage::from_entry(key, entry))
86            })
87            .collect();
88        out.sort_by(|a, b| a.key.cmp(&b.key));
89        Ok(Lockfile {
90            version,
91            packages: out,
92        })
93    }
94
95    /// The entries an npm-tarball installer fetches on the given host: real (non-root)
96    /// `node_modules/…` packages that aren't links and whose `os`/`cpu` match. `host_os` and
97    /// `host_arch` are Rust's `std::env::consts::{OS, ARCH}` spellings. Whether each entry's
98    /// `resolved` is actually an http(s) registry tarball is left to the caller — see
99    /// [`LockedPackage::is_registry_tarball`].
100    pub fn installable(&self, host_os: &str, host_arch: &str) -> Vec<&LockedPackage> {
101        self.packages
102            .iter()
103            .filter(|p| p.key.starts_with("node_modules/") && !p.link)
104            .filter(|p| p.matches_platform(host_os, host_arch))
105            .collect()
106    }
107}
108
109impl crate::package_json::License for LockedPackage {
110    /// The license the lockfile recorded for this package, if any.
111    fn license(&self) -> Option<String> {
112        self.license.clone()
113    }
114}
115
116/// A resolved package to record in a generated lockfile — the write-side input mirroring a parsed
117/// [`LockedPackage`], kept to the flat-tree fields [`render_v3`] emits.
118#[derive(Debug, Clone)]
119pub struct LockEntry {
120    /// Package name (the `node_modules/<name>` key segment).
121    pub name: String,
122    /// Exact resolved version.
123    pub version: String,
124    /// The registry tarball URL — the entry's `resolved`.
125    pub resolved: String,
126    /// The `sha512-…` Subresource-Integrity, when the registry advertised one.
127    pub integrity: Option<String>,
128    /// The package's declared SPDX license, recorded for license/compliance tooling
129    /// (npm's own lockfiles carry it too).
130    pub license: Option<String>,
131}
132
133/// Render a `lockfileVersion`-3 `package-lock.json` for a **flat** dependency tree: a root `""`
134/// entry (the project `name`/`version` and its direct dependency ranges) plus one
135/// `node_modules/<name>` entry per resolved package. Keys are emitted in npm's order
136/// (`name`, `version`, `lockfileVersion`, `requires`, `packages`) thanks to `serde_json`'s
137/// `preserve_order`.
138///
139/// Scope (documented, intentional): this is an **npm-compatible v3 lock for the registry/prod
140/// tree** that round-trips through [`Lockfile::parse`] and installs via
141/// [`crate::install::from_lockfile`] — it is *not* a byte-for-byte npm reproduction. The flat set
142/// from [`crate::registry::Registry::resolve_tree`] carries no dev/optional classification, so no
143/// `dev`/`optional` flags are emitted, and `peerDependencies`/`bundleDependencies` and per-package
144/// `dependencies` back-references are omitted.
145pub fn render_v3(
146    root_name: &str,
147    root_version: &str,
148    direct: &[(String, String)],
149    entries: &[LockEntry],
150) -> String {
151    use serde_json::json;
152
153    let mut packages = Map::new();
154
155    // The root project entry, keyed "".
156    let mut root = Map::new();
157    root.insert("name".into(), json!(root_name));
158    root.insert("version".into(), json!(root_version));
159    if !direct.is_empty() {
160        let mut deps = Map::new();
161        for (name, range) in direct {
162            deps.insert(name.clone(), json!(range));
163        }
164        root.insert("dependencies".into(), Value::Object(deps));
165    }
166    packages.insert(String::new(), Value::Object(root));
167
168    // One node_modules/<name> entry per resolved package, in the order given (resolve_tree
169    // returns them sorted by name).
170    for entry in entries {
171        let mut pkg = Map::new();
172        pkg.insert("version".into(), json!(entry.version));
173        pkg.insert("resolved".into(), json!(entry.resolved));
174        if let Some(integrity) = &entry.integrity {
175            pkg.insert("integrity".into(), json!(integrity));
176        }
177        if let Some(license) = &entry.license {
178            pkg.insert("license".into(), json!(license));
179        }
180        packages.insert(format!("node_modules/{}", entry.name), Value::Object(pkg));
181    }
182
183    let doc = json!({
184        "name": root_name,
185        "version": root_version,
186        "lockfileVersion": 3,
187        "requires": true,
188        "packages": Value::Object(packages),
189    });
190    let mut out = serde_json::to_string_pretty(&doc).expect("serialize package-lock.json");
191    out.push('\n');
192    out
193}
194
195/// Resolve a `package.json`-shaped manifest's **registry** dependencies into a flat tree and
196/// render it as a `lockfileVersion`-3 `package-lock.json` string (with per-package `license`).
197/// Talks to `registry` over the network but touches no filesystem and installs no
198/// `node_modules/` — the lockfile-only half of `add`/`upgrade`. Non-registry deps (git /
199/// `file:`) are skipped: recorded in the manifest, but not resolvable to a registry tarball.
200pub fn render_v3_from_manifest(
201    doc: &Value,
202    registry: &Registry,
203) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
204    let direct = manifest::dependencies(doc);
205    let roots: Vec<(String, spec::Range)> = direct
206        .iter()
207        .filter(|(_, range)| spec::Spec::parse(range).is_registry())
208        .map(
209            |(name, range)| -> Result<(String, spec::Range), Box<dyn std::error::Error + Send + Sync>> {
210                Ok((name.clone(), spec::Range::parse(range)?))
211            },
212        )
213        .collect::<Result<Vec<_>, _>>()?;
214
215    let entries: Vec<LockEntry> = registry
216        .resolve_tree(&roots)?
217        .into_iter()
218        .map(|r| LockEntry {
219            name: r.name,
220            version: r.version.to_string(),
221            resolved: r.tarball_url,
222            integrity: r.integrity,
223            license: r.license,
224        })
225        .collect();
226
227    let name = doc.get("name").and_then(Value::as_str).unwrap_or("");
228    let version = doc
229        .get("version")
230        .and_then(Value::as_str)
231        .unwrap_or("0.0.0");
232    Ok(render_v3(name, version, &direct, &entries))
233}
234
235/// Read a `package.json`-shaped manifest and (re)write its `package-lock.json` from the
236/// registry — pure Rust, no Node, no npm, no `node_modules/`. This is the "update the
237/// lockfile" primitive for build scripts and vendoring flows (where the lock is a manifest of
238/// resolved versions + licenses, not an install); [`render_v3_from_manifest`] is the in-memory
239/// core. Resolves against the public npm registry.
240pub fn write_from_manifest(
241    manifest_path: &Path,
242    lockfile_path: &Path,
243    registry: &Registry,
244) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
245    let text = std::fs::read_to_string(manifest_path)
246        .map_err(|e| format!("reading {}: {e}", manifest_path.display()))?;
247    let doc: Value = serde_json::from_str(&text)
248        .map_err(|e| format!("parsing {}: {e}", manifest_path.display()))?;
249    let lockfile = render_v3_from_manifest(&doc, registry)?;
250    std::fs::write(lockfile_path, lockfile)
251        .map_err(|e| format!("writing {}: {e}", lockfile_path.display()))?;
252    Ok(())
253}
254
255impl LockedPackage {
256    fn from_entry(key: &str, entry: &Map<String, Value>) -> LockedPackage {
257        let name = key
258            .rsplit_once("node_modules/")
259            .map(|(_, n)| n)
260            .unwrap_or(key)
261            .to_string();
262        LockedPackage {
263            bin: bin_entries(entry, &name),
264            key: key.to_string(),
265            name,
266            version: string_field(entry, "version"),
267            resolved: opt_string(entry, "resolved"),
268            integrity: opt_string(entry, "integrity"),
269            license: opt_string(entry, "license"),
270            dev: bool_field(entry, "dev"),
271            optional: bool_field(entry, "optional"),
272            dev_optional: bool_field(entry, "devOptional"),
273            link: bool_field(entry, "link"),
274            os: string_list(entry, "os"),
275            cpu: string_list(entry, "cpu"),
276        }
277    }
278
279    /// Whether `resolved` is an http(s) registry tarball — the only source `npm-utils` fetches.
280    pub fn is_registry_tarball(&self) -> bool {
281        self.resolved
282            .as_deref()
283            .is_some_and(|r| r.starts_with("https://") || r.starts_with("http://"))
284    }
285
286    /// Whether the host satisfies this entry's `os`/`cpu`. `host_os`/`host_arch` are Rust's
287    /// `std::env::consts::{OS, ARCH}`; they are mapped to npm's spelling before comparing.
288    pub fn matches_platform(&self, host_os: &str, host_arch: &str) -> bool {
289        constraint_allows(&self.os, node_os(host_os))
290            && constraint_allows(&self.cpu, node_cpu(host_arch))
291    }
292}
293
294/// npm `os`/`cpu` matching: a positive list must include `host`; a `!`-prefixed value excludes
295/// it; an empty constraint allows everything.
296pub fn constraint_allows(constraint: &[String], host: &str) -> bool {
297    let mut has_positive = false;
298    let mut matched_positive = false;
299    for item in constraint {
300        if let Some(excluded) = item.strip_prefix('!') {
301            if excluded == host {
302                return false;
303            }
304        } else {
305            has_positive = true;
306            if item == host {
307                matched_positive = true;
308            }
309        }
310    }
311    !has_positive || matched_positive
312}
313
314const OS_MAP: &[(&str, &str)] = &[("macos", "darwin"), ("windows", "win32")];
315const CPU_MAP: &[(&str, &str)] = &[("x86_64", "x64"), ("aarch64", "arm64"), ("x86", "ia32")];
316
317/// Map a Rust `std::env::consts::OS` value to npm's `os` spelling (`linux` is shared).
318fn node_os(rust: &str) -> &str {
319    map_value(rust, OS_MAP)
320}
321
322/// Map a Rust `std::env::consts::ARCH` value to npm's `cpu` spelling.
323fn node_cpu(rust: &str) -> &str {
324    map_value(rust, CPU_MAP)
325}
326
327fn map_value<'a>(rust: &'a str, map: &[(&'static str, &'static str)]) -> &'a str {
328    map.iter()
329        .find(|(r, _)| *r == rust)
330        .map(|(_, n)| *n)
331        .unwrap_or(rust)
332}
333
334fn string_field(entry: &Map<String, Value>, key: &str) -> String {
335    entry
336        .get(key)
337        .and_then(Value::as_str)
338        .unwrap_or_default()
339        .to_string()
340}
341
342fn opt_string(entry: &Map<String, Value>, key: &str) -> Option<String> {
343    entry.get(key).and_then(Value::as_str).map(str::to_string)
344}
345
346fn bool_field(entry: &Map<String, Value>, key: &str) -> bool {
347    entry.get(key).and_then(Value::as_bool).unwrap_or(false)
348}
349
350fn string_list(entry: &Map<String, Value>, key: &str) -> Vec<String> {
351    entry
352        .get(key)
353        .and_then(Value::as_array)
354        .map(|a| {
355            a.iter()
356                .filter_map(Value::as_str)
357                .map(str::to_string)
358                .collect()
359        })
360        .unwrap_or_default()
361}
362
363/// The `(bin-name, path-in-package)` pairs an entry exposes. npm allows either an object
364/// (`{"foo": "cli.js"}`) or a bare string (the bin takes the package's unscoped name).
365fn bin_entries(entry: &Map<String, Value>, name: &str) -> Vec<(String, String)> {
366    match entry.get("bin") {
367        Some(Value::String(path)) => {
368            let bin_name = name.rsplit('/').next().unwrap_or(name).to_string();
369            vec![(bin_name, path.clone())]
370        }
371        Some(Value::Object(map)) => map
372            .iter()
373            .filter_map(|(n, v)| v.as_str().map(|p| (n.clone(), p.to_string())))
374            .collect(),
375        _ => Vec::new(),
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    // A lockfileVersion-3 fixture exercising the field variety: a runtime dep, a scoped dep,
384    // a dev dep with a `bin` map, an off-platform optional native dep, and a `file:` link.
385    const SAMPLE_LOCK: &str = r#"{
386      "name": "harness",
387      "lockfileVersion": 3,
388      "packages": {
389        "": { "name": "harness", "devDependencies": { "typescript": "^5" } },
390        "node_modules/@scope/pkg": {
391          "version": "1.2.3",
392          "resolved": "https://registry.npmjs.org/@scope/pkg/-/pkg-1.2.3.tgz",
393          "integrity": "sha512-BBBB"
394        },
395        "node_modules/typescript": {
396          "version": "5.9.3",
397          "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
398          "integrity": "sha512-AAAA",
399          "dev": true,
400          "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }
401        },
402        "node_modules/fsevents": {
403          "version": "2.3.2",
404          "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
405          "integrity": "sha512-CCCC",
406          "dev": true,
407          "optional": true,
408          "os": ["darwin"]
409        },
410        "node_modules/local-link": { "resolved": "file:../local", "link": true }
411      }
412    }"#;
413
414    fn names(packages: &[&LockedPackage]) -> Vec<String> {
415        packages.iter().map(|p| p.name.clone()).collect()
416    }
417
418    #[test]
419    fn parses_fields_and_selects_installable_per_host() {
420        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
421        assert_eq!(lock.version, 3);
422
423        // On linux/x86_64: the scoped dep + typescript. The darwin-only optional is skipped;
424        // the root "" and the `file:` link are never installable.
425        assert_eq!(
426            names(&lock.installable("linux", "x86_64")),
427            ["@scope/pkg", "typescript"]
428        );
429        // On macos/aarch64 the darwin-only fsevents joins (sorted by key).
430        assert_eq!(
431            names(&lock.installable("macos", "aarch64")),
432            ["@scope/pkg", "fsevents", "typescript"]
433        );
434
435        // Fields parsed: dev flag, integrity, the full bin map.
436        let ts = lock
437            .packages
438            .iter()
439            .find(|p| p.name == "typescript")
440            .unwrap();
441        assert!(ts.dev);
442        assert_eq!(ts.integrity.as_deref(), Some("sha512-AAAA"));
443        assert!(ts.bin.iter().any(|(n, p)| n == "tsc" && p == "bin/tsc"));
444        assert!(ts.bin.iter().any(|(n, _)| n == "tsserver"));
445        // The link entry is parsed (faithful) but excluded from installable.
446        assert!(lock.packages.iter().any(|p| p.link));
447    }
448
449    #[test]
450    fn distinguishes_registry_tarballs_from_other_sources() {
451        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
452        let ts = lock
453            .packages
454            .iter()
455            .find(|p| p.name == "typescript")
456            .unwrap();
457        assert!(
458            ts.is_registry_tarball(),
459            "https resolved is a registry tarball"
460        );
461        let link = lock.packages.iter().find(|p| p.link).unwrap();
462        assert!(!link.is_registry_tarball(), "a file: link is not");
463    }
464
465    #[test]
466    fn rejects_lockfile_version_1() {
467        // v1 has no `packages` map — the hierarchical `dependencies` tree is unsupported.
468        assert!(Lockfile::parse(r#"{"lockfileVersion":1,"dependencies":{}}"#).is_err());
469    }
470
471    #[test]
472    fn constraint_allows_follows_npm_os_cpu_rules() {
473        let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
474        assert!(constraint_allows(&[], "linux"), "no constraint allows all");
475        assert!(constraint_allows(&v(&["linux"]), "linux"));
476        assert!(!constraint_allows(&v(&["darwin"]), "linux"));
477        assert!(constraint_allows(&v(&["darwin", "linux"]), "linux"));
478        assert!(constraint_allows(&v(&["!win32"]), "linux"));
479        assert!(!constraint_allows(&v(&["!linux"]), "linux"));
480    }
481
482    #[test]
483    fn matches_platform_maps_rust_host_to_npm_spelling() {
484        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
485        let fsevents = lock.packages.iter().find(|p| p.name == "fsevents").unwrap();
486        // os:["darwin"] — excluded on a linux host, allowed on macos (rust "macos" → "darwin").
487        assert!(!fsevents.matches_platform("linux", "x86_64"));
488        assert!(fsevents.matches_platform("macos", "aarch64"));
489    }
490
491    #[test]
492    fn render_v3_emits_npm_order_and_round_trips_through_parse() {
493        let entries = vec![
494            LockEntry {
495                name: "ms".into(),
496                version: "2.1.3".into(),
497                resolved: "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz".into(),
498                integrity: Some("sha512-MS".into()),
499                license: Some("MIT".into()),
500            },
501            LockEntry {
502                name: "@scope/pkg".into(),
503                version: "1.0.0".into(),
504                resolved: "https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz".into(),
505                integrity: Some("sha512-SP".into()),
506                license: None,
507            },
508        ];
509        let direct = vec![("ms".to_string(), "^2".to_string())];
510        let json = render_v3("fixture", "1.0.0", &direct, &entries);
511
512        // Top-level keys come out in npm's order (preserve_order), not alphabetized.
513        let doc: Value = serde_json::from_str(&json).unwrap();
514        let keys: Vec<&str> = doc
515            .as_object()
516            .unwrap()
517            .keys()
518            .map(String::as_str)
519            .collect();
520        assert_eq!(
521            keys,
522            ["name", "version", "lockfileVersion", "requires", "packages"]
523        );
524        // The root "" entry records the direct dependency ranges from package.json.
525        assert_eq!(doc["packages"][""]["dependencies"]["ms"], "^2");
526
527        // A declared license is emitted per package; omitted when None.
528        assert_eq!(doc["packages"]["node_modules/ms"]["license"], "MIT");
529        assert!(doc["packages"]["node_modules/@scope/pkg"]
530            .get("license")
531            .is_none());
532
533        // It parses back as a v3 lock; the two registry entries are installable (root "" and
534        // any link excluded), sorted by key, with integrity + resolved threaded through.
535        let lock = Lockfile::parse(&json).unwrap();
536        assert_eq!(lock.version, 3);
537        let names: Vec<&str> = lock
538            .installable("linux", "x86_64")
539            .iter()
540            .map(|p| p.name.as_str())
541            .collect();
542        assert_eq!(names, ["@scope/pkg", "ms"]);
543        let ms = lock.packages.iter().find(|p| p.name == "ms").unwrap();
544        assert_eq!(ms.integrity.as_deref(), Some("sha512-MS"));
545        assert!(
546            ms.is_registry_tarball(),
547            "resolved is an https registry tarball"
548        );
549    }
550}