1use serde_json::{Map, Value};
12
13#[derive(Debug, Clone)]
15pub struct Lockfile {
16 pub version: u64,
18 pub packages: Vec<LockedPackage>,
21}
22
23#[derive(Debug, Clone)]
25pub struct LockedPackage {
26 pub key: String,
28 pub name: String,
30 pub version: String,
32 pub resolved: Option<String>,
34 pub integrity: Option<String>,
36 pub dev: bool,
38 pub optional: bool,
40 pub dev_optional: bool,
42 pub link: bool,
44 pub os: Vec<String>,
46 pub cpu: Vec<String>,
48 pub bin: Vec<(String, String)>,
50}
51
52impl Lockfile {
53 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 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#[derive(Debug, Clone)]
103pub struct LockEntry {
104 pub name: String,
106 pub version: String,
108 pub resolved: String,
110 pub integrity: Option<String>,
112}
113
114pub 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 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 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 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 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
211pub 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
234fn node_os(rust: &str) -> &str {
236 map_value(rust, OS_MAP)
237}
238
239fn 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
280fn 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 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 assert_eq!(
343 names(&lock.installable("linux", "x86_64")),
344 ["@scope/pkg", "typescript"]
345 );
346 assert_eq!(
348 names(&lock.installable("macos", "aarch64")),
349 ["@scope/pkg", "fsevents", "typescript"]
350 );
351
352 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 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 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 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 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 assert_eq!(doc["packages"][""]["dependencies"]["ms"], "^2");
441
442 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}