1use std::path::Path;
12
13use serde_json::{Map, Value};
14
15use super::{manifest, spec};
16use crate::registry::{Omission, Registry, ResolveEvent};
17
18#[derive(Debug, Clone)]
20pub struct Lockfile {
21 pub version: u64,
23 pub packages: Vec<LockedPackage>,
26}
27
28#[derive(Debug, Clone)]
30pub struct LockedPackage {
31 pub key: String,
33 pub name: String,
38 pub version: String,
40 pub resolved: Option<String>,
42 pub integrity: Option<String>,
44 pub license: Option<String>,
48 pub dev: bool,
50 pub optional: bool,
52 pub dev_optional: bool,
54 pub link: bool,
56 pub os: Vec<String>,
58 pub cpu: Vec<String>,
60 pub bin: Vec<(String, String)>,
62}
63
64impl Lockfile {
65 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 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 fn license(&self) -> Option<String> {
115 self.license.clone()
116 }
117}
118
119#[derive(Debug, Clone)]
122pub struct LockEntry {
123 pub name: String,
125 pub version: String,
127 pub resolved: String,
129 pub integrity: Option<String>,
131 pub license: Option<String>,
134}
135
136pub 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 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 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
198pub 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
210pub(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
240pub(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#[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
294pub(crate) type AuditRoots = (Vec<(String, spec::Range, bool)>, Vec<Omission>);
297
298#[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
312pub 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
325pub(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 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 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 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
390pub 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
413fn node_os(rust: &str) -> &str {
415 map_value(rust, OS_MAP)
416}
417
418fn 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
459fn 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 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 assert_eq!(
522 names(&lock.installable("linux", "x86_64")),
523 ["@scope/pkg", "typescript"]
524 );
525 assert_eq!(
527 names(&lock.installable("macos", "aarch64")),
528 ["@scope/pkg", "fsevents", "typescript"]
529 );
530
531 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 assert!(lock.packages.iter().any(|p| p.link));
543 }
544
545 #[test]
546 fn name_field_wins_over_the_install_path() {
547 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 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 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 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 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 assert_eq!(doc["packages"][""]["dependencies"]["ms"], "^2");
653
654 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 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}