1use serde_json::{Map, Value};
11
12#[derive(Debug, Clone)]
14pub struct Lockfile {
15 pub version: u64,
17 pub packages: Vec<LockedPackage>,
20}
21
22#[derive(Debug, Clone)]
24pub struct LockedPackage {
25 pub key: String,
27 pub name: String,
29 pub version: String,
31 pub resolved: Option<String>,
33 pub integrity: Option<String>,
35 pub dev: bool,
37 pub optional: bool,
39 pub dev_optional: bool,
41 pub link: bool,
43 pub os: Vec<String>,
45 pub cpu: Vec<String>,
47 pub bin: Vec<(String, String)>,
49}
50
51impl Lockfile {
52 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 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 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 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
137pub 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
160fn node_os(rust: &str) -> &str {
162 map_value(rust, OS_MAP)
163}
164
165fn 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
206fn 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 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 assert_eq!(
269 names(&lock.installable("linux", "x86_64")),
270 ["@scope/pkg", "typescript"]
271 );
272 assert_eq!(
274 names(&lock.installable("macos", "aarch64")),
275 ["@scope/pkg", "fsevents", "typescript"]
276 );
277
278 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 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 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 assert!(!fsevents.matches_platform("linux", "x86_64"));
331 assert!(fsevents.matches_platform("macos", "aarch64"));
332 }
333}