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//! It touches no filesystem and resolves no paths: a caller turns a [`LockedPackage::key`]
6//! into an install path itself, so this parser stays pure and the path-safety check lives
7//! with the installer. lockfileVersion 1 (the legacy hierarchical `dependencies` tree, with
8//! no `packages` map) is unsupported.
9
10use serde_json::{Map, Value};
11
12/// A parsed `package-lock.json`.
13#[derive(Debug, Clone)]
14pub struct Lockfile {
15    /// The `lockfileVersion` (always ≥ 2 here).
16    pub version: u64,
17    /// Every entry of the `packages` map, sorted by key — so install order, and thus
18    /// `.bin` name-collision resolution, is deterministic. Includes the root `""` entry.
19    pub packages: Vec<LockedPackage>,
20}
21
22/// One entry of the `packages` map.
23#[derive(Debug, Clone)]
24pub struct LockedPackage {
25    /// The map key: `""` for the root project, else a `node_modules/…`-relative path.
26    pub key: String,
27    /// The package name — the segment after the last `node_modules/` (empty for the root).
28    pub name: String,
29    /// `version` from the entry (empty for the root or a pure link).
30    pub version: String,
31    /// `resolved` — the registry URL, git source, or `file:` path; `None` if absent.
32    pub resolved: Option<String>,
33    /// `integrity` — the Subresource-Integrity string (`sha512-…`); `None` if absent.
34    pub integrity: Option<String>,
35    /// `dev` — strictly in the devDependencies tree.
36    pub dev: bool,
37    /// `optional` — strictly in the optionalDependencies tree.
38    pub optional: bool,
39    /// `devOptional` — both a dev and an optional dependency.
40    pub dev_optional: bool,
41    /// `link: true` — a symlink to a local path; nothing is fetched.
42    pub link: bool,
43    /// `os` constraints (npm spelling — `darwin`, `linux`, `win32`; `!`-negation allowed).
44    pub os: Vec<String>,
45    /// `cpu` constraints (npm spelling — `x64`, `arm64`, `ia32`; `!`-negation allowed).
46    pub cpu: Vec<String>,
47    /// `bin` as `(name, path-within-package)` pairs.
48    pub bin: Vec<(String, String)>,
49}
50
51impl Lockfile {
52    /// Parse a `package-lock.json` document (lockfileVersion 2 or 3).
53    pub fn parse(s: &str) -> Result<Lockfile, Box<dyn std::error::Error>> {
54        let json: Value = serde_json::from_str(s)?;
55        let version = json
56            .get("lockfileVersion")
57            .and_then(Value::as_u64)
58            .unwrap_or(0);
59        if version < 2 {
60            return Err(format!(
61                "package-lock.json lockfileVersion {version} is unsupported \
62                 (need 2 or 3, which carry the `packages` map)"
63            )
64            .into());
65        }
66        let packages = json
67            .get("packages")
68            .and_then(Value::as_object)
69            .ok_or("package-lock.json has no `packages` map")?;
70        let mut out: Vec<LockedPackage> = packages
71            .iter()
72            .filter_map(|(key, entry)| {
73                entry
74                    .as_object()
75                    .map(|entry| LockedPackage::from_entry(key, entry))
76            })
77            .collect();
78        out.sort_by(|a, b| a.key.cmp(&b.key));
79        Ok(Lockfile {
80            version,
81            packages: out,
82        })
83    }
84
85    /// The entries an npm-tarball installer fetches on the given host: real (non-root)
86    /// `node_modules/…` packages that aren't links and whose `os`/`cpu` match. `host_os` and
87    /// `host_arch` are Rust's `std::env::consts::{OS, ARCH}` spellings. Whether each entry's
88    /// `resolved` is actually an http(s) registry tarball is left to the caller — see
89    /// [`LockedPackage::is_registry_tarball`].
90    pub fn installable(&self, host_os: &str, host_arch: &str) -> Vec<&LockedPackage> {
91        self.packages
92            .iter()
93            .filter(|p| p.key.starts_with("node_modules/") && !p.link)
94            .filter(|p| p.matches_platform(host_os, host_arch))
95            .collect()
96    }
97}
98
99impl LockedPackage {
100    fn from_entry(key: &str, entry: &Map<String, Value>) -> LockedPackage {
101        let name = key
102            .rsplit_once("node_modules/")
103            .map(|(_, n)| n)
104            .unwrap_or(key)
105            .to_string();
106        LockedPackage {
107            bin: bin_entries(entry, &name),
108            key: key.to_string(),
109            name,
110            version: string_field(entry, "version"),
111            resolved: opt_string(entry, "resolved"),
112            integrity: opt_string(entry, "integrity"),
113            dev: bool_field(entry, "dev"),
114            optional: bool_field(entry, "optional"),
115            dev_optional: bool_field(entry, "devOptional"),
116            link: bool_field(entry, "link"),
117            os: string_list(entry, "os"),
118            cpu: string_list(entry, "cpu"),
119        }
120    }
121
122    /// Whether `resolved` is an http(s) registry tarball — the only source `npm-utils` fetches.
123    pub fn is_registry_tarball(&self) -> bool {
124        self.resolved
125            .as_deref()
126            .is_some_and(|r| r.starts_with("https://") || r.starts_with("http://"))
127    }
128
129    /// Whether the host satisfies this entry's `os`/`cpu`. `host_os`/`host_arch` are Rust's
130    /// `std::env::consts::{OS, ARCH}`; they are mapped to npm's spelling before comparing.
131    pub fn matches_platform(&self, host_os: &str, host_arch: &str) -> bool {
132        constraint_allows(&self.os, node_os(host_os))
133            && constraint_allows(&self.cpu, node_cpu(host_arch))
134    }
135}
136
137/// npm `os`/`cpu` matching: a positive list must include `host`; a `!`-prefixed value excludes
138/// it; an empty constraint allows everything.
139pub fn constraint_allows(constraint: &[String], host: &str) -> bool {
140    let mut has_positive = false;
141    let mut matched_positive = false;
142    for item in constraint {
143        if let Some(excluded) = item.strip_prefix('!') {
144            if excluded == host {
145                return false;
146            }
147        } else {
148            has_positive = true;
149            if item == host {
150                matched_positive = true;
151            }
152        }
153    }
154    !has_positive || matched_positive
155}
156
157const OS_MAP: &[(&str, &str)] = &[("macos", "darwin"), ("windows", "win32")];
158const CPU_MAP: &[(&str, &str)] = &[("x86_64", "x64"), ("aarch64", "arm64"), ("x86", "ia32")];
159
160/// Map a Rust `std::env::consts::OS` value to npm's `os` spelling (`linux` is shared).
161fn node_os(rust: &str) -> &str {
162    map_value(rust, OS_MAP)
163}
164
165/// Map a Rust `std::env::consts::ARCH` value to npm's `cpu` spelling.
166fn node_cpu(rust: &str) -> &str {
167    map_value(rust, CPU_MAP)
168}
169
170fn map_value<'a>(rust: &'a str, map: &[(&'static str, &'static str)]) -> &'a str {
171    map.iter()
172        .find(|(r, _)| *r == rust)
173        .map(|(_, n)| *n)
174        .unwrap_or(rust)
175}
176
177fn string_field(entry: &Map<String, Value>, key: &str) -> String {
178    entry
179        .get(key)
180        .and_then(Value::as_str)
181        .unwrap_or_default()
182        .to_string()
183}
184
185fn opt_string(entry: &Map<String, Value>, key: &str) -> Option<String> {
186    entry.get(key).and_then(Value::as_str).map(str::to_string)
187}
188
189fn bool_field(entry: &Map<String, Value>, key: &str) -> bool {
190    entry.get(key).and_then(Value::as_bool).unwrap_or(false)
191}
192
193fn string_list(entry: &Map<String, Value>, key: &str) -> Vec<String> {
194    entry
195        .get(key)
196        .and_then(Value::as_array)
197        .map(|a| {
198            a.iter()
199                .filter_map(Value::as_str)
200                .map(str::to_string)
201                .collect()
202        })
203        .unwrap_or_default()
204}
205
206/// The `(bin-name, path-in-package)` pairs an entry exposes. npm allows either an object
207/// (`{"foo": "cli.js"}`) or a bare string (the bin takes the package's unscoped name).
208fn bin_entries(entry: &Map<String, Value>, name: &str) -> Vec<(String, String)> {
209    match entry.get("bin") {
210        Some(Value::String(path)) => {
211            let bin_name = name.rsplit('/').next().unwrap_or(name).to_string();
212            vec![(bin_name, path.clone())]
213        }
214        Some(Value::Object(map)) => map
215            .iter()
216            .filter_map(|(n, v)| v.as_str().map(|p| (n.clone(), p.to_string())))
217            .collect(),
218        _ => Vec::new(),
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    // A lockfileVersion-3 fixture exercising the field variety: a runtime dep, a scoped dep,
227    // a dev dep with a `bin` map, an off-platform optional native dep, and a `file:` link.
228    const SAMPLE_LOCK: &str = r#"{
229      "name": "harness",
230      "lockfileVersion": 3,
231      "packages": {
232        "": { "name": "harness", "devDependencies": { "typescript": "^5" } },
233        "node_modules/@scope/pkg": {
234          "version": "1.2.3",
235          "resolved": "https://registry.npmjs.org/@scope/pkg/-/pkg-1.2.3.tgz",
236          "integrity": "sha512-BBBB"
237        },
238        "node_modules/typescript": {
239          "version": "5.9.3",
240          "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
241          "integrity": "sha512-AAAA",
242          "dev": true,
243          "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }
244        },
245        "node_modules/fsevents": {
246          "version": "2.3.2",
247          "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
248          "integrity": "sha512-CCCC",
249          "dev": true,
250          "optional": true,
251          "os": ["darwin"]
252        },
253        "node_modules/local-link": { "resolved": "file:../local", "link": true }
254      }
255    }"#;
256
257    fn names(packages: &[&LockedPackage]) -> Vec<String> {
258        packages.iter().map(|p| p.name.clone()).collect()
259    }
260
261    #[test]
262    fn parses_fields_and_selects_installable_per_host() {
263        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
264        assert_eq!(lock.version, 3);
265
266        // On linux/x86_64: the scoped dep + typescript. The darwin-only optional is skipped;
267        // the root "" and the `file:` link are never installable.
268        assert_eq!(
269            names(&lock.installable("linux", "x86_64")),
270            ["@scope/pkg", "typescript"]
271        );
272        // On macos/aarch64 the darwin-only fsevents joins (sorted by key).
273        assert_eq!(
274            names(&lock.installable("macos", "aarch64")),
275            ["@scope/pkg", "fsevents", "typescript"]
276        );
277
278        // Fields parsed: dev flag, integrity, the full bin map.
279        let ts = lock
280            .packages
281            .iter()
282            .find(|p| p.name == "typescript")
283            .unwrap();
284        assert!(ts.dev);
285        assert_eq!(ts.integrity.as_deref(), Some("sha512-AAAA"));
286        assert!(ts.bin.iter().any(|(n, p)| n == "tsc" && p == "bin/tsc"));
287        assert!(ts.bin.iter().any(|(n, _)| n == "tsserver"));
288        // The link entry is parsed (faithful) but excluded from installable.
289        assert!(lock.packages.iter().any(|p| p.link));
290    }
291
292    #[test]
293    fn distinguishes_registry_tarballs_from_other_sources() {
294        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
295        let ts = lock
296            .packages
297            .iter()
298            .find(|p| p.name == "typescript")
299            .unwrap();
300        assert!(
301            ts.is_registry_tarball(),
302            "https resolved is a registry tarball"
303        );
304        let link = lock.packages.iter().find(|p| p.link).unwrap();
305        assert!(!link.is_registry_tarball(), "a file: link is not");
306    }
307
308    #[test]
309    fn rejects_lockfile_version_1() {
310        // v1 has no `packages` map — the hierarchical `dependencies` tree is unsupported.
311        assert!(Lockfile::parse(r#"{"lockfileVersion":1,"dependencies":{}}"#).is_err());
312    }
313
314    #[test]
315    fn constraint_allows_follows_npm_os_cpu_rules() {
316        let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
317        assert!(constraint_allows(&[], "linux"), "no constraint allows all");
318        assert!(constraint_allows(&v(&["linux"]), "linux"));
319        assert!(!constraint_allows(&v(&["darwin"]), "linux"));
320        assert!(constraint_allows(&v(&["darwin", "linux"]), "linux"));
321        assert!(constraint_allows(&v(&["!win32"]), "linux"));
322        assert!(!constraint_allows(&v(&["!linux"]), "linux"));
323    }
324
325    #[test]
326    fn matches_platform_maps_rust_host_to_npm_spelling() {
327        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
328        let fsevents = lock.packages.iter().find(|p| p.name == "fsevents").unwrap();
329        // os:["darwin"] — excluded on a linux host, allowed on macos (rust "macos" → "darwin").
330        assert!(!fsevents.matches_platform("linux", "x86_64"));
331        assert!(fsevents.matches_platform("macos", "aarch64"));
332    }
333}