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::{Omission, Registry, ResolveEvent};
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 entry's `name` field when the lockfile records one (npm writes it
34    /// exactly when the installed path differs from the real package, i.e. an `npm:` alias; the
35    /// root `""` entry carries the project name), else the segment after the last
36    /// `node_modules/` (empty for the root).
37    pub name: String,
38    /// `version` from the entry (empty for the root or a pure link).
39    pub version: String,
40    /// `resolved` — the registry URL, git source, or `file:` path; `None` if absent.
41    pub resolved: Option<String>,
42    /// `integrity` — the Subresource-Integrity string (`sha512-…`); `None` if absent.
43    pub integrity: Option<String>,
44    /// `license` — the package's declared SPDX license string, when the lockfile records one
45    /// (npm writes it per package; so does this crate's [`render_v3`]). `None` if absent.
46    /// Read so SBOM/compliance output ([`crate::sbom`]) can carry it.
47    pub license: Option<String>,
48    /// `dev` — strictly in the devDependencies tree.
49    pub dev: bool,
50    /// `optional` — strictly in the optionalDependencies tree.
51    pub optional: bool,
52    /// `devOptional` — both a dev and an optional dependency.
53    pub dev_optional: bool,
54    /// `link: true` — a symlink to a local path; nothing is fetched.
55    pub link: bool,
56    /// `os` constraints (npm spelling — `darwin`, `linux`, `win32`; `!`-negation allowed).
57    pub os: Vec<String>,
58    /// `cpu` constraints (npm spelling — `x64`, `arm64`, `ia32`; `!`-negation allowed).
59    pub cpu: Vec<String>,
60    /// `bin` as `(name, path-within-package)` pairs.
61    pub bin: Vec<(String, String)>,
62}
63
64impl Lockfile {
65    /// Parse a `package-lock.json` document (lockfileVersion 2 or 3).
66    pub fn parse(s: &str) -> Result<Lockfile, Box<dyn std::error::Error + Send + Sync>> {
67        let json: Value = serde_json::from_str(s)?;
68        let version = json
69            .get("lockfileVersion")
70            .and_then(Value::as_u64)
71            .unwrap_or(0);
72        if version < 2 {
73            return Err(format!(
74                "package-lock.json lockfileVersion {version} is unsupported \
75                 (need 2 or 3, which carry the `packages` map)"
76            )
77            .into());
78        }
79        let packages = json
80            .get("packages")
81            .and_then(Value::as_object)
82            .ok_or("package-lock.json has no `packages` map")?;
83        let mut out: Vec<LockedPackage> = packages
84            .iter()
85            .filter_map(|(key, entry)| {
86                entry
87                    .as_object()
88                    .map(|entry| LockedPackage::from_entry(key, entry))
89            })
90            .collect();
91        out.sort_by(|a, b| a.key.cmp(&b.key));
92        Ok(Lockfile {
93            version,
94            packages: out,
95        })
96    }
97
98    /// The entries an npm-tarball installer fetches on the given host: real (non-root)
99    /// `node_modules/…` packages that aren't links and whose `os`/`cpu` match. `host_os` and
100    /// `host_arch` are Rust's `std::env::consts::{OS, ARCH}` spellings. Whether each entry's
101    /// `resolved` is actually an http(s) registry tarball is left to the caller — see
102    /// [`LockedPackage::is_registry_tarball`].
103    pub fn installable(&self, host_os: &str, host_arch: &str) -> Vec<&LockedPackage> {
104        self.packages
105            .iter()
106            .filter(|p| p.key.starts_with("node_modules/") && !p.link)
107            .filter(|p| p.matches_platform(host_os, host_arch))
108            .collect()
109    }
110}
111
112impl crate::package_json::License for LockedPackage {
113    /// The license the lockfile recorded for this package, if any.
114    fn license(&self) -> Option<String> {
115        self.license.clone()
116    }
117}
118
119/// A resolved package to record in a generated lockfile — the write-side input mirroring a parsed
120/// [`LockedPackage`], kept to the flat-tree fields [`render_v3`] emits.
121#[derive(Debug, Clone)]
122pub struct LockEntry {
123    /// Package name (the `node_modules/<name>` key segment).
124    pub name: String,
125    /// Exact resolved version.
126    pub version: String,
127    /// The registry tarball URL — the entry's `resolved`.
128    pub resolved: String,
129    /// The `sha512-…` Subresource-Integrity, when the registry advertised one.
130    pub integrity: Option<String>,
131    /// The package's declared SPDX license, recorded for license/compliance tooling
132    /// (npm's own lockfiles carry it too).
133    pub license: Option<String>,
134}
135
136/// Render a `lockfileVersion`-3 `package-lock.json` for a **flat** dependency tree: a root `""`
137/// entry (the project `name`/`version` and its direct dependency ranges) plus one
138/// `node_modules/<name>` entry per resolved package. Keys are emitted in npm's order
139/// (`name`, `version`, `lockfileVersion`, `requires`, `packages`) thanks to `serde_json`'s
140/// `preserve_order`.
141///
142/// Scope (documented, intentional): this is an **npm-compatible v3 lock for the registry/prod
143/// tree** that round-trips through [`Lockfile::parse`] and installs via
144/// [`crate::install::from_lockfile`] — it is *not* a byte-for-byte npm reproduction. The flat set
145/// from [`crate::registry::Registry::resolve_tree`] carries no dev/optional classification, so no
146/// `dev`/`optional` flags are emitted, and `peerDependencies`/`bundleDependencies` and per-package
147/// `dependencies` back-references are omitted.
148pub fn render_v3(
149    root_name: &str,
150    root_version: &str,
151    direct: &[(String, String)],
152    entries: &[LockEntry],
153) -> String {
154    use serde_json::json;
155
156    let mut packages = Map::new();
157
158    // The root project entry, keyed "".
159    let mut root = Map::new();
160    root.insert("name".into(), json!(root_name));
161    root.insert("version".into(), json!(root_version));
162    if !direct.is_empty() {
163        let mut deps = Map::new();
164        for (name, range) in direct {
165            deps.insert(name.clone(), json!(range));
166        }
167        root.insert("dependencies".into(), Value::Object(deps));
168    }
169    packages.insert(String::new(), Value::Object(root));
170
171    // One node_modules/<name> entry per resolved package, in the order given (resolve_tree
172    // returns them sorted by name).
173    for entry in entries {
174        let mut pkg = Map::new();
175        pkg.insert("version".into(), json!(entry.version));
176        pkg.insert("resolved".into(), json!(entry.resolved));
177        if let Some(integrity) = &entry.integrity {
178            pkg.insert("integrity".into(), json!(integrity));
179        }
180        if let Some(license) = &entry.license {
181            pkg.insert("license".into(), json!(license));
182        }
183        packages.insert(format!("node_modules/{}", entry.name), Value::Object(pkg));
184    }
185
186    let doc = json!({
187        "name": root_name,
188        "version": root_version,
189        "lockfileVersion": 3,
190        "requires": true,
191        "packages": Value::Object(packages),
192    });
193    let mut out = serde_json::to_string_pretty(&doc).expect("serialize package-lock.json");
194    out.push('\n');
195    out
196}
197
198/// Resolve a `package.json`-shaped manifest's **registry** dependencies into a flat tree and
199/// render it as a `lockfileVersion`-3 `package-lock.json` string (with per-package `license`).
200/// Talks to `registry` over the network but touches no filesystem and installs no
201/// `node_modules/` — the lockfile-only half of `add`/`upgrade`. Non-registry deps (git /
202/// `file:`) are skipped: recorded in the manifest, but not resolvable to a registry tarball.
203pub fn render_v3_from_manifest(
204    doc: &Value,
205    registry: &Registry,
206) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
207    render_v3_from_manifest_observed(doc, registry, |_| {})
208}
209
210/// [`render_v3_from_manifest`] with the resolve walk's progress observer
211/// ([`ResolveEvent`]) — the CLI's lockfile-writing verbs drive their `[resolve]` tasks from it.
212pub(crate) fn render_v3_from_manifest_observed(
213    doc: &Value,
214    registry: &Registry,
215    on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
216) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
217    let direct = manifest::dependencies(doc);
218    let roots = registry_roots(doc)?;
219
220    let entries: Vec<LockEntry> = registry
221        .resolve_tree_observed(&roots, on_resolve)?
222        .into_iter()
223        .map(|r| LockEntry {
224            name: r.name,
225            version: r.version.to_string(),
226            resolved: r.tarball_url,
227            integrity: r.integrity,
228            license: r.license,
229        })
230        .collect();
231
232    let name = doc.get("name").and_then(Value::as_str).unwrap_or("");
233    let version = doc
234        .get("version")
235        .and_then(Value::as_str)
236        .unwrap_or("0.0.0");
237    Ok(render_v3(name, version, &direct, &entries))
238}
239
240/// A manifest's **registry** `dependencies` as resolvable roots — entries whose spec is a
241/// registry range (git / `file:` / tarball specs are skipped), each parsed by the npm range
242/// grammar. The lockfile writer's root extraction; the audit uses [`audit_roots`], which skips
243/// nothing silently.
244pub(crate) fn registry_roots(
245    doc: &Value,
246) -> Result<Vec<(String, spec::Range)>, Box<dyn std::error::Error + Send + Sync>> {
247    manifest::dependencies(doc)
248        .iter()
249        .filter(|(_, range)| spec::Spec::parse(range).is_registry())
250        .map(|(name, range)| Ok((name.clone(), spec::Range::parse(range)?)))
251        .collect()
252}
253
254/// A manifest's dependency roots for an **audit**: `dependencies` merged with
255/// `optionalDependencies` (an optional entry overrides a same-name regular one, as npm applies
256/// them), each classified by [`crate::registry::classify_dep`] — registry ranges and `npm:`
257/// aliases to them become `(name, range, optional)` roots, non-registry specs become
258/// [`Omission`]s with their verbatim spec text, and a manifest declaring `workspaces` records
259/// one omission (workspace packages are not traversed). A malformed range is a hard error
260/// naming the dependency.
261#[cfg_attr(not(feature = "cli"), allow(dead_code))]
262pub(crate) fn audit_roots(
263    doc: &Value,
264) -> Result<AuditRoots, Box<dyn std::error::Error + Send + Sync>> {
265    let mut merged: Vec<(String, String, bool)> = manifest::dependencies(doc)
266        .into_iter()
267        .map(|(name, spec_text)| (name, spec_text, false))
268        .collect();
269    for (name, spec_text) in manifest::optional_dependencies(doc) {
270        match merged.iter_mut().find(|(existing, ..)| *existing == name) {
271            Some(entry) => *entry = (name, spec_text, true),
272            None => merged.push((name, spec_text, true)),
273        }
274    }
275
276    let mut roots = Vec::new();
277    let mut omissions = Vec::new();
278    for (name, spec_text, optional) in merged {
279        let action = crate::registry::classify_dep(&name, &spec_text)
280            .map_err(|e| format!("package.json dependency `{name}`: {e}"))?;
281        match action {
282            crate::registry::EdgeAction::Resolve { name, range } => {
283                roots.push((name, range, optional))
284            }
285            crate::registry::EdgeAction::Omit(omission) => omissions.push(omission),
286        }
287    }
288    if has_workspaces(doc) {
289        omissions.push(Omission::new("workspaces", "", "not traversed"));
290    }
291    Ok((roots, omissions))
292}
293
294/// [`audit_roots`]'s result: the resolvable `(name, range, optional)` roots and the manifest
295/// entries the audit cannot follow.
296pub(crate) type AuditRoots = (Vec<(String, spec::Range, bool)>, Vec<Omission>);
297
298/// Whether the manifest declares any workspaces — the array form or the object form's
299/// `packages` list.
300#[cfg_attr(not(feature = "cli"), allow(dead_code))]
301fn has_workspaces(doc: &Value) -> bool {
302    match doc.get("workspaces") {
303        Some(Value::Array(list)) => !list.is_empty(),
304        Some(Value::Object(map)) => map
305            .get("packages")
306            .and_then(Value::as_array)
307            .is_some_and(|list| !list.is_empty()),
308        _ => false,
309    }
310}
311
312/// Read a `package.json`-shaped manifest and (re)write its `package-lock.json` from the
313/// registry — pure Rust, no Node, no npm, no `node_modules/`. This is the "update the
314/// lockfile" primitive for build scripts and vendoring flows (where the lock is a manifest of
315/// resolved versions + licenses, not an install); [`render_v3_from_manifest`] is the in-memory
316/// core. Resolves against the public npm registry.
317pub fn write_from_manifest(
318    manifest_path: &Path,
319    lockfile_path: &Path,
320    registry: &Registry,
321) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
322    write_from_manifest_observed(manifest_path, lockfile_path, registry, |_| {})
323}
324
325/// [`write_from_manifest`] with the resolve walk's progress observer ([`ResolveEvent`]).
326pub(crate) fn write_from_manifest_observed(
327    manifest_path: &Path,
328    lockfile_path: &Path,
329    registry: &Registry,
330    on_resolve: impl Fn(ResolveEvent<'_>) + Sync,
331) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
332    let text = std::fs::read_to_string(manifest_path)
333        .map_err(|e| format!("reading {}: {e}", manifest_path.display()))?;
334    let doc: Value = serde_json::from_str(&text)
335        .map_err(|e| format!("parsing {}: {e}", manifest_path.display()))?;
336    let lockfile = render_v3_from_manifest_observed(&doc, registry, on_resolve)?;
337    std::fs::write(lockfile_path, lockfile)
338        .map_err(|e| format!("writing {}: {e}", lockfile_path.display()))?;
339    Ok(())
340}
341
342impl LockedPackage {
343    fn from_entry(key: &str, entry: &Map<String, Value>) -> LockedPackage {
344        // Prefer the entry's `name` field: npm records it when the installed path differs from
345        // the real package (an `npm:` alias), and advisory queries, purls, and string-form bins
346        // must name the real package. Install paths keep coming from the key.
347        let name = entry
348            .get("name")
349            .and_then(Value::as_str)
350            .filter(|n| !n.is_empty())
351            .map(str::to_string)
352            .unwrap_or_else(|| {
353                key.rsplit_once("node_modules/")
354                    .map(|(_, n)| n)
355                    .unwrap_or(key)
356                    .to_string()
357            });
358        LockedPackage {
359            bin: bin_entries(entry, &name),
360            key: key.to_string(),
361            name,
362            version: string_field(entry, "version"),
363            resolved: opt_string(entry, "resolved"),
364            integrity: opt_string(entry, "integrity"),
365            license: opt_string(entry, "license"),
366            dev: bool_field(entry, "dev"),
367            optional: bool_field(entry, "optional"),
368            dev_optional: bool_field(entry, "devOptional"),
369            link: bool_field(entry, "link"),
370            os: string_list(entry, "os"),
371            cpu: string_list(entry, "cpu"),
372        }
373    }
374
375    /// Whether `resolved` is an http(s) registry tarball — the only source `npm-utils` fetches.
376    pub fn is_registry_tarball(&self) -> bool {
377        self.resolved
378            .as_deref()
379            .is_some_and(|r| r.starts_with("https://") || r.starts_with("http://"))
380    }
381
382    /// Whether the host satisfies this entry's `os`/`cpu`. `host_os`/`host_arch` are Rust's
383    /// `std::env::consts::{OS, ARCH}`; they are mapped to npm's spelling before comparing.
384    pub fn matches_platform(&self, host_os: &str, host_arch: &str) -> bool {
385        constraint_allows(&self.os, node_os(host_os))
386            && constraint_allows(&self.cpu, node_cpu(host_arch))
387    }
388}
389
390/// npm `os`/`cpu` matching: a positive list must include `host`; a `!`-prefixed value excludes
391/// it; an empty constraint allows everything.
392pub fn constraint_allows(constraint: &[String], host: &str) -> bool {
393    let mut has_positive = false;
394    let mut matched_positive = false;
395    for item in constraint {
396        if let Some(excluded) = item.strip_prefix('!') {
397            if excluded == host {
398                return false;
399            }
400        } else {
401            has_positive = true;
402            if item == host {
403                matched_positive = true;
404            }
405        }
406    }
407    !has_positive || matched_positive
408}
409
410const OS_MAP: &[(&str, &str)] = &[("macos", "darwin"), ("windows", "win32")];
411const CPU_MAP: &[(&str, &str)] = &[("x86_64", "x64"), ("aarch64", "arm64"), ("x86", "ia32")];
412
413/// Map a Rust `std::env::consts::OS` value to npm's `os` spelling (`linux` is shared).
414fn node_os(rust: &str) -> &str {
415    map_value(rust, OS_MAP)
416}
417
418/// Map a Rust `std::env::consts::ARCH` value to npm's `cpu` spelling.
419fn node_cpu(rust: &str) -> &str {
420    map_value(rust, CPU_MAP)
421}
422
423fn map_value<'a>(rust: &'a str, map: &[(&'static str, &'static str)]) -> &'a str {
424    map.iter()
425        .find(|(r, _)| *r == rust)
426        .map(|(_, n)| *n)
427        .unwrap_or(rust)
428}
429
430fn string_field(entry: &Map<String, Value>, key: &str) -> String {
431    entry
432        .get(key)
433        .and_then(Value::as_str)
434        .unwrap_or_default()
435        .to_string()
436}
437
438fn opt_string(entry: &Map<String, Value>, key: &str) -> Option<String> {
439    entry.get(key).and_then(Value::as_str).map(str::to_string)
440}
441
442fn bool_field(entry: &Map<String, Value>, key: &str) -> bool {
443    entry.get(key).and_then(Value::as_bool).unwrap_or(false)
444}
445
446fn string_list(entry: &Map<String, Value>, key: &str) -> Vec<String> {
447    entry
448        .get(key)
449        .and_then(Value::as_array)
450        .map(|a| {
451            a.iter()
452                .filter_map(Value::as_str)
453                .map(str::to_string)
454                .collect()
455        })
456        .unwrap_or_default()
457}
458
459/// The `(bin-name, path-in-package)` pairs an entry exposes. npm allows either an object
460/// (`{"foo": "cli.js"}`) or a bare string (the bin takes the package's unscoped name).
461fn bin_entries(entry: &Map<String, Value>, name: &str) -> Vec<(String, String)> {
462    match entry.get("bin") {
463        Some(Value::String(path)) => {
464            let bin_name = name.rsplit('/').next().unwrap_or(name).to_string();
465            vec![(bin_name, path.clone())]
466        }
467        Some(Value::Object(map)) => map
468            .iter()
469            .filter_map(|(n, v)| v.as_str().map(|p| (n.clone(), p.to_string())))
470            .collect(),
471        _ => Vec::new(),
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    // A lockfileVersion-3 fixture exercising the field variety: a runtime dep, a scoped dep,
480    // a dev dep with a `bin` map, an off-platform optional native dep, and a `file:` link.
481    const SAMPLE_LOCK: &str = r#"{
482      "name": "harness",
483      "lockfileVersion": 3,
484      "packages": {
485        "": { "name": "harness", "devDependencies": { "typescript": "^5" } },
486        "node_modules/@scope/pkg": {
487          "version": "1.2.3",
488          "resolved": "https://registry.npmjs.org/@scope/pkg/-/pkg-1.2.3.tgz",
489          "integrity": "sha512-BBBB"
490        },
491        "node_modules/typescript": {
492          "version": "5.9.3",
493          "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
494          "integrity": "sha512-AAAA",
495          "dev": true,
496          "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }
497        },
498        "node_modules/fsevents": {
499          "version": "2.3.2",
500          "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
501          "integrity": "sha512-CCCC",
502          "dev": true,
503          "optional": true,
504          "os": ["darwin"]
505        },
506        "node_modules/local-link": { "resolved": "file:../local", "link": true }
507      }
508    }"#;
509
510    fn names(packages: &[&LockedPackage]) -> Vec<String> {
511        packages.iter().map(|p| p.name.clone()).collect()
512    }
513
514    #[test]
515    fn parses_fields_and_selects_installable_per_host() {
516        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
517        assert_eq!(lock.version, 3);
518
519        // On linux/x86_64: the scoped dep + typescript. The darwin-only optional is skipped;
520        // the root "" and the `file:` link are never installable.
521        assert_eq!(
522            names(&lock.installable("linux", "x86_64")),
523            ["@scope/pkg", "typescript"]
524        );
525        // On macos/aarch64 the darwin-only fsevents joins (sorted by key).
526        assert_eq!(
527            names(&lock.installable("macos", "aarch64")),
528            ["@scope/pkg", "fsevents", "typescript"]
529        );
530
531        // Fields parsed: dev flag, integrity, the full bin map.
532        let ts = lock
533            .packages
534            .iter()
535            .find(|p| p.name == "typescript")
536            .unwrap();
537        assert!(ts.dev);
538        assert_eq!(ts.integrity.as_deref(), Some("sha512-AAAA"));
539        assert!(ts.bin.iter().any(|(n, p)| n == "tsc" && p == "bin/tsc"));
540        assert!(ts.bin.iter().any(|(n, _)| n == "tsserver"));
541        // The link entry is parsed (faithful) but excluded from installable.
542        assert!(lock.packages.iter().any(|p| p.link));
543    }
544
545    #[test]
546    fn name_field_wins_over_the_install_path() {
547        // npm writes `name` when the installed path differs from the real package — an `npm:`
548        // alias — and on the root entry; a workspace-nested key still derives from its key.
549        let lock = Lockfile::parse(
550            r#"{
551              "name": "ws", "lockfileVersion": 3,
552              "packages": {
553                "": { "name": "ws" },
554                "node_modules/lodash-alias": {
555                  "name": "lodash",
556                  "version": "4.17.11",
557                  "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
558                  "bin": "cli.js"
559                },
560                "app/node_modules/minimist": { "version": "1.2.0" }
561              }
562            }"#,
563        )
564        .unwrap();
565        let by_key = |k: &str| lock.packages.iter().find(|p| p.key == k).unwrap();
566        assert_eq!(by_key("node_modules/lodash-alias").name, "lodash");
567        // The string-form bin takes the real package's (unscoped) name too.
568        assert_eq!(
569            by_key("node_modules/lodash-alias").bin,
570            [("lodash".to_string(), "cli.js".to_string())]
571        );
572        assert_eq!(by_key("app/node_modules/minimist").name, "minimist");
573        assert_eq!(by_key("").name, "ws");
574    }
575
576    #[test]
577    fn distinguishes_registry_tarballs_from_other_sources() {
578        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
579        let ts = lock
580            .packages
581            .iter()
582            .find(|p| p.name == "typescript")
583            .unwrap();
584        assert!(
585            ts.is_registry_tarball(),
586            "https resolved is a registry tarball"
587        );
588        let link = lock.packages.iter().find(|p| p.link).unwrap();
589        assert!(!link.is_registry_tarball(), "a file: link is not");
590    }
591
592    #[test]
593    fn rejects_lockfile_version_1() {
594        // v1 has no `packages` map — the hierarchical `dependencies` tree is unsupported.
595        assert!(Lockfile::parse(r#"{"lockfileVersion":1,"dependencies":{}}"#).is_err());
596    }
597
598    #[test]
599    fn constraint_allows_follows_npm_os_cpu_rules() {
600        let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::<Vec<_>>();
601        assert!(constraint_allows(&[], "linux"), "no constraint allows all");
602        assert!(constraint_allows(&v(&["linux"]), "linux"));
603        assert!(!constraint_allows(&v(&["darwin"]), "linux"));
604        assert!(constraint_allows(&v(&["darwin", "linux"]), "linux"));
605        assert!(constraint_allows(&v(&["!win32"]), "linux"));
606        assert!(!constraint_allows(&v(&["!linux"]), "linux"));
607    }
608
609    #[test]
610    fn matches_platform_maps_rust_host_to_npm_spelling() {
611        let lock = Lockfile::parse(SAMPLE_LOCK).unwrap();
612        let fsevents = lock.packages.iter().find(|p| p.name == "fsevents").unwrap();
613        // os:["darwin"] — excluded on a linux host, allowed on macos (rust "macos" → "darwin").
614        assert!(!fsevents.matches_platform("linux", "x86_64"));
615        assert!(fsevents.matches_platform("macos", "aarch64"));
616    }
617
618    #[test]
619    fn render_v3_emits_npm_order_and_round_trips_through_parse() {
620        let entries = vec![
621            LockEntry {
622                name: "ms".into(),
623                version: "2.1.3".into(),
624                resolved: "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz".into(),
625                integrity: Some("sha512-MS".into()),
626                license: Some("MIT".into()),
627            },
628            LockEntry {
629                name: "@scope/pkg".into(),
630                version: "1.0.0".into(),
631                resolved: "https://registry.npmjs.org/@scope/pkg/-/pkg-1.0.0.tgz".into(),
632                integrity: Some("sha512-SP".into()),
633                license: None,
634            },
635        ];
636        let direct = vec![("ms".to_string(), "^2".to_string())];
637        let json = render_v3("fixture", "1.0.0", &direct, &entries);
638
639        // Top-level keys come out in npm's order (preserve_order), not alphabetized.
640        let doc: Value = serde_json::from_str(&json).unwrap();
641        let keys: Vec<&str> = doc
642            .as_object()
643            .unwrap()
644            .keys()
645            .map(String::as_str)
646            .collect();
647        assert_eq!(
648            keys,
649            ["name", "version", "lockfileVersion", "requires", "packages"]
650        );
651        // The root "" entry records the direct dependency ranges from package.json.
652        assert_eq!(doc["packages"][""]["dependencies"]["ms"], "^2");
653
654        // A declared license is emitted per package; omitted when None.
655        assert_eq!(doc["packages"]["node_modules/ms"]["license"], "MIT");
656        assert!(doc["packages"]["node_modules/@scope/pkg"]
657            .get("license")
658            .is_none());
659
660        // It parses back as a v3 lock; the two registry entries are installable (root "" and
661        // any link excluded), sorted by key, with integrity + resolved threaded through.
662        let lock = Lockfile::parse(&json).unwrap();
663        assert_eq!(lock.version, 3);
664        let names: Vec<&str> = lock
665            .installable("linux", "x86_64")
666            .iter()
667            .map(|p| p.name.as_str())
668            .collect();
669        assert_eq!(names, ["@scope/pkg", "ms"]);
670        let ms = lock.packages.iter().find(|p| p.name == "ms").unwrap();
671        assert_eq!(ms.integrity.as_deref(), Some("sha512-MS"));
672        assert!(
673            ms.is_registry_tarball(),
674            "resolved is an https registry tarball"
675        );
676    }
677
678    #[test]
679    fn audit_roots_merges_optional_over_regular_and_flags_it() {
680        let doc = serde_json::json!({
681            "dependencies": { "a": "^1", "x": "^1" },
682            "optionalDependencies": { "x": "^2", "opt": "^3" }
683        });
684        let (roots, omissions) = audit_roots(&doc).unwrap();
685        let flat: Vec<(String, String, bool)> = roots
686            .into_iter()
687            .map(|(n, r, o)| (n, r.to_string(), o))
688            .collect();
689        assert_eq!(
690            flat,
691            [
692                ("a".to_string(), "^1".to_string(), false),
693                ("x".to_string(), "^2".to_string(), true),
694                ("opt".to_string(), "^3".to_string(), true),
695            ],
696            "the optional entry overrides the regular one in place, flag flipped"
697        );
698        assert!(omissions.is_empty());
699    }
700
701    #[test]
702    fn audit_roots_records_non_registry_specs_and_workspaces_as_omissions() {
703        let doc = serde_json::json!({
704            "dependencies": {
705                "g": "git+ssh://git@github.com/x/y.git",
706                "local": "file:../local",
707                "w": "workspace:*",
708                "keep": "^1"
709            },
710            "workspaces": ["packages/*"]
711        });
712        let (roots, omissions) = audit_roots(&doc).unwrap();
713        assert_eq!(roots.len(), 1, "only the registry dep resolves");
714        assert_eq!(roots[0].0, "keep");
715        let rendered: Vec<String> = omissions.iter().map(ToString::to_string).collect();
716        assert_eq!(
717            rendered,
718            [
719                "g (git+ssh://git@github.com/x/y.git: git dependency)",
720                "local (file:../local: local path)",
721                "w (workspace:*: workspace: protocol)",
722                "workspaces (not traversed)",
723            ],
724            "verbatim spec text, and workspace:* no longer aborts the audit"
725        );
726    }
727
728    #[test]
729    fn audit_roots_resolves_an_alias_target_and_rejects_garbage() {
730        let doc = serde_json::json!({ "dependencies": { "aliased": "npm:real@^2" } });
731        let (roots, omissions) = audit_roots(&doc).unwrap();
732        assert_eq!(roots[0].0, "real", "the alias target is what gets audited");
733        assert!(omissions.is_empty());
734
735        let doc = serde_json::json!({ "dependencies": { "bad": "%% nope %%" } });
736        let err = audit_roots(&doc).unwrap_err().to_string();
737        assert!(
738            err.contains("package.json dependency `bad`"),
739            "malformed ranges stay hard errors naming the dependency: {err}"
740        );
741    }
742
743    #[test]
744    fn has_workspaces_reads_both_declaration_forms() {
745        assert!(has_workspaces(&serde_json::json!({ "workspaces": ["a"] })));
746        assert!(has_workspaces(
747            &serde_json::json!({ "workspaces": { "packages": ["a"] } })
748        ));
749        assert!(!has_workspaces(&serde_json::json!({ "workspaces": [] })));
750        assert!(!has_workspaces(&serde_json::json!({ "name": "x" })));
751    }
752}