1use std::path::Path;
12
13use serde_json::{Map, Value};
14
15use super::{manifest, spec};
16use crate::registry::Registry;
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,
35 pub version: String,
37 pub resolved: Option<String>,
39 pub integrity: Option<String>,
41 pub license: Option<String>,
45 pub dev: bool,
47 pub optional: bool,
49 pub dev_optional: bool,
51 pub link: bool,
53 pub os: Vec<String>,
55 pub cpu: Vec<String>,
57 pub bin: Vec<(String, String)>,
59}
60
61impl Lockfile {
62 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 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 fn license(&self) -> Option<String> {
112 self.license.clone()
113 }
114}
115
116#[derive(Debug, Clone)]
119pub struct LockEntry {
120 pub name: String,
122 pub version: String,
124 pub resolved: String,
126 pub integrity: Option<String>,
128 pub license: Option<String>,
131}
132
133pub 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 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 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
195pub 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
235pub 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 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 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
294pub 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
317fn node_os(rust: &str) -> &str {
319 map_value(rust, OS_MAP)
320}
321
322fn 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
363fn 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 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 assert_eq!(
426 names(&lock.installable("linux", "x86_64")),
427 ["@scope/pkg", "typescript"]
428 );
429 assert_eq!(
431 names(&lock.installable("macos", "aarch64")),
432 ["@scope/pkg", "fsevents", "typescript"]
433 );
434
435 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 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 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 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 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 assert_eq!(doc["packages"][""]["dependencies"]["ms"], "^2");
526
527 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 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}