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