package_lock_json_parser/
lib.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use tracing::instrument;
6
7#[derive(Debug, Error)]
8#[error("package-lock.json error")]
9pub enum PackageLockJsonError {
10    #[error("Error parsing file: {0}")]
11    ParseError(#[from] serde_json::Error),
12}
13
14#[derive(Debug, Serialize, Deserialize, Default, Clone, Eq, PartialEq)]
15pub struct PackageLockJson {
16    pub name: String,
17    pub version: Option<String>,
18    #[serde(rename = "lockfileVersion")]
19    pub lockfile_version: u32,
20    pub dependencies: Option<HashMap<String, V1Dependency>>,
21    #[serde(deserialize_with = "deserialize_packages", default)]
22    pub packages: Option<HashMap<String, V2Dependency>>,
23}
24
25#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Default)]
26pub struct V1Dependency {
27    pub version: String,
28    pub resolved: Option<String>,
29    pub integrity: Option<String>,
30    #[serde(default)]
31    pub bundled: bool,
32    #[serde(rename = "dev", default)]
33    pub is_dev: bool,
34    #[serde(rename = "optional", default)]
35    pub is_optional: bool,
36    pub requires: Option<HashMap<String, String>>,
37    pub dependencies: Option<HashMap<String, V1Dependency>>,
38}
39
40#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Default)]
41pub struct V2Dependency {
42    pub version: String,
43    pub name: Option<String>,
44    pub resolved: Option<String>,
45    pub integrity: Option<String>,
46    #[serde(default)]
47    pub bundled: bool,
48    #[serde(rename = "dev", default)]
49    pub is_dev: bool,
50    #[serde(rename = "optional", default)]
51    pub is_optional: bool,
52    #[serde(rename = "devOptional", default)]
53    pub is_dev_optional: bool,
54    #[serde(rename = "inBundle", default)]
55    pub is_in_bundle: bool,
56    #[serde(rename = "hasInstallScript", default)]
57    pub has_install_script: bool,
58    #[serde(rename = "hasShrinkwrap", default)]
59    pub has_shrink_wrap: bool,
60    pub dependencies: Option<HashMap<String, String>>,
61    #[serde(rename = "devDependencies")]
62    pub dev_dependencies: Option<HashMap<String, String>>,
63    #[serde(rename = "optionalDependencies")]
64    pub optional_dependencies: Option<HashMap<String, String>>,
65    #[serde(rename = "peerDependencies")]
66    pub peer_dependencies: Option<HashMap<String, String>>,
67    pub license: Option<String>,
68    pub engines: Option<HashMap<String, String>>,
69    pub bin: Option<HashMap<String, String>>,
70}
71
72#[derive(Debug, Clone, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
73pub struct SimpleDependency {
74    pub name: String,
75    pub version: String,
76    pub is_dev: bool,
77    pub is_optional: bool,
78}
79
80/// Parses a package-lock.json file.
81/// Support v1, v2 and v3 lock files
82#[instrument(skip(content))]
83pub fn parse(
84    content: impl Into<String> + std::fmt::Debug,
85) -> Result<PackageLockJson, PackageLockJsonError> {
86    let mut json: PackageLockJson = serde_json::from_str(&content.into())?;
87    // fix version for v2 and workspaces
88    // version = "file:mainlib" -> version = "0.0.0"
89    if let (Some(dependencies), Some(packages)) =
90        (json.dependencies.as_mut(), json.packages.as_ref())
91    {
92        for (name, dependency) in dependencies {
93            if dependency.version.starts_with("file:") {
94                if let Some(pkg) = packages.get(name) {
95                    dependency.version = pkg.version.clone();
96                }
97            }
98        }
99    }
100    Ok(json)
101}
102
103/// Returns a list of dependencies from a package-lock.json file.
104/// The dependencies returned by this function only show a few fields.
105/// If you need more information, use the parse function.
106#[instrument(skip(content))]
107pub fn parse_dependencies(
108    content: impl Into<String> + std::fmt::Debug,
109) -> Result<Vec<SimpleDependency>, PackageLockJsonError> {
110    let json = parse(content)?;
111    let mut entries = Vec::new();
112    if let Some(dependencies) = json.dependencies {
113        for (name, dependency) in dependencies {
114            entries.push(SimpleDependency {
115                name,
116                version: dependency.version,
117                is_dev: dependency.is_dev,
118                is_optional: dependency.is_optional,
119            });
120        }
121    } else if let Some(packages) = json.packages {
122        for (name, dependency) in packages {
123            entries.push(SimpleDependency {
124                name,
125                version: dependency.version,
126                is_dev: dependency.is_dev,
127                is_optional: dependency.is_optional,
128            });
129        }
130    }
131    Ok(entries)
132}
133
134fn deserialize_packages<'de, D>(
135    deserializer: D,
136) -> Result<Option<HashMap<String, V2Dependency>>, D::Error>
137where
138    D: serde::Deserializer<'de>,
139{
140    let value: Option<HashMap<String, serde_json::Value>> =
141        serde::Deserialize::deserialize(deserializer)?;
142    if let Some(package) = value {
143        let mut packages = HashMap::new();
144        for (key, mut value) in package {
145            if key.is_empty() {
146                // skipping package information as it doesn't follow the schema.
147                tracing::info!("Skipping package information in packages.");
148                continue;
149            }
150            // check for engine bad formats.
151            // some people use an array instead of an object.
152            if let Some(engines) = value.get("engines").and_then(serde_json::Value::as_array) {
153                tracing::warn!(
154                    "Found engines as an array instead of an object. Fixing it. ({})",
155                    key
156                );
157                if engines.is_empty() {
158                    value["engines"] = serde_json::Value::Null;
159                } else {
160                    let mut new_engines = HashMap::new();
161                    for engine in engines {
162                        let engine = engine.as_str().unwrap();
163                        let (name, version) =
164                            engine.split_once(' ').unwrap_or(("not_found", "not_found"));
165                        new_engines.insert(name, version);
166                    }
167                    value["engines"] = serde_json::value::to_value(new_engines).unwrap();
168                }
169            }
170
171            let vclone = value.clone();
172
173            let package = serde_json::from_value::<V2Dependency>(value);
174            match package {
175                Ok(package) => {
176                    let pattern = "node_modules/";
177                    if key.starts_with(pattern) {
178                        if !key.contains("/node_modules/") {
179                            // we are ignoring nested dependencies
180                            let key = key.replace(pattern, "");
181                            packages.insert(key, package);
182                        }
183                    } else {
184                        // possibly workspaces, let's look for name
185                        if let Some(ref name) = package.name {
186                            // if name, we will use it as the key.
187                            // these packages will also have a version with a `node_modules/` prefix.
188                            // as that version won't have a version, it will fail to parse and will be silently ignored.
189                            packages.insert(name.clone(), package);
190                        } else {
191                            packages.insert(key, package);
192                        }
193                    }
194                }
195                Err(e) => {
196                    // swallowing the error as we don't want to break the whole process
197                    // let's just log the error:
198                    tracing::error!(
199                        "Could not parse this dependency: {:?}, ERROR: {}",
200                        vclone,
201                        e
202                    );
203                    continue;
204                }
205            };
206        }
207        Ok(Some(packages))
208    } else {
209        Ok(None)
210    }
211}
212
213#[cfg(test)]
214mod tests {
215
216    use super::*;
217
218    fn expected_v1() -> V1Dependency {
219        V1Dependency{
220            version : "7.18.6".to_string(),
221            resolved: Some("https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz".to_string()),
222            integrity: Some("sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==".to_string()),
223            bundled: false,
224            is_dev: true,
225            is_optional: false,
226            requires: Some(HashMap::from([("js-tokens".to_string(), "^4.0.0".to_string()), ("chalk".to_string(), "^2.0.0".to_string()),("@babel/helper-validator-identifier".to_string(), "^7.18.6".to_string())])),
227            dependencies: Some(HashMap::from([("js-tokens".to_string(), V1Dependency {
228                version: "4.0.0".to_string(),
229                resolved: Some("https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz".to_string()),
230                integrity: Some("sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==".to_string()),
231                is_dev: true,
232                bundled: false,
233                ..V1Dependency::default()
234                })]
235            ))
236        }
237    }
238
239    fn expected_v2() -> V2Dependency {
240        V2Dependency{
241            version : "7.18.6".to_string(),
242            resolved: Some("https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz".to_string()),
243            integrity: Some("sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==".to_string()),
244            bundled: false,
245            is_dev: true,
246            is_optional: false,
247            dependencies: Some(HashMap::from([("js-tokens".to_string(), "^4.0.0".to_string()), ("chalk".to_string(), "^2.0.0".to_string()),("@babel/helper-validator-identifier".to_string(), "^7.18.6".to_string())])),
248            engines: Some(HashMap::from([("node".to_string(), ">=6.9.0".to_string())])),
249            ..V2Dependency::default()
250        }
251    }
252
253    #[test]
254    fn works_without_version() {
255        let content = std::fs::read_to_string("tests/cool-project/package-lock.json").unwrap();
256        let lock_file = parse(content).unwrap();
257        assert_eq!(lock_file.name, "cool-project");
258        assert!(lock_file.version.is_none());
259    }
260
261    #[test]
262    fn cool_project_works() {
263        let content = std::fs::read_to_string("tests/cool-project/package-lock.json").unwrap();
264        let lock_file = parse(content).unwrap();
265        assert_eq!(lock_file.name, "cool-project");
266        assert!(lock_file.version.is_none());
267        assert_eq!(lock_file.lockfile_version, 2);
268
269        assert!(lock_file.dependencies.is_some());
270        assert!(lock_file.packages.is_some());
271
272        let packages = lock_file.packages.unwrap();
273        let cool = packages.get("cool-project").unwrap();
274        assert_eq!(cool.name, Some("cool-project".to_string()));
275        assert_eq!(cool.version, "23.1.21".to_string());
276
277        let dependencies = lock_file.dependencies.unwrap();
278        let cool = dependencies.get("cool-project").unwrap();
279        assert_eq!(cool.version, "23.1.21".to_string());
280    }
281
282    #[test]
283    fn parse_moon_workspace_dependencies_works() {
284        let content = std::fs::read_to_string("tests/workspace/moon/package-lock.json").unwrap();
285        let lock_file = parse(content).unwrap();
286        assert_eq!(lock_file.name, "moon-examples");
287        assert_eq!(lock_file.version, Some("1.2.3".to_string()));
288        assert_eq!(lock_file.lockfile_version, 3);
289
290        assert!(lock_file.dependencies.is_none());
291        assert!(lock_file.packages.is_some());
292
293        let packages = lock_file.packages.unwrap();
294
295        let yaml = packages.get("yaml").unwrap();
296        let expected_yaml = V2Dependency {
297            version: "2.2.2".to_string(),
298            resolved: Some("https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz".to_string()),
299            integrity: Some("sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==".to_string()),
300            is_dev: true,
301            engines: Some(HashMap::from([("node".to_string(), ">= 14".to_string())])),
302            ..V2Dependency::default()
303        };
304        assert_eq!(yaml, &expected_yaml);
305
306        // workspace?
307        let libnpmdiff = packages.get("workspaces/libnpmdiff").unwrap();
308        assert_eq!(libnpmdiff.version, "5.0.17".to_string());
309        assert_eq!(libnpmdiff.license, Some("ISC".to_string()));
310        assert!(libnpmdiff.dependencies.is_some());
311        let dependencies = libnpmdiff.dependencies.as_ref().unwrap();
312        assert!(dependencies.contains_key("pacote"));
313        assert!(dependencies.contains_key("tar"));
314    }
315
316    #[test]
317    fn parse_v2_workspace_dependencies_works() {
318        let content = std::fs::read_to_string("tests/workspace/v2/package-lock.json").unwrap();
319        let lock_file = parse(content).unwrap();
320        assert_eq!(lock_file.name, "test-node-npm");
321        assert_eq!(lock_file.version, Some("1.0.0".to_string()));
322        assert_eq!(lock_file.lockfile_version, 2);
323
324        assert!(lock_file.dependencies.is_some());
325        assert!(lock_file.packages.is_some());
326
327        let packages = lock_file.packages.unwrap();
328
329        let test_node_npm_base = packages.get("test-node-npm-base").unwrap();
330        let expected_base = V2Dependency {
331            version: "1.0.0".to_string(),
332            name: Some("test-node-npm-base".to_string()),
333            dependencies: Some(HashMap::from([("react".to_string(), "17.0.0".to_string())])),
334            ..V2Dependency::default()
335        };
336        assert_eq!(test_node_npm_base, &expected_base);
337
338        // ensure base is not present
339        let base = packages.get("base");
340        assert!(base.is_none());
341
342        // let's check now v1 version
343        let dependencies = lock_file.dependencies.unwrap();
344        let test_node_npm_v1 = dependencies.get("test-node-npm-base").unwrap();
345        assert_eq!(
346            test_node_npm_v1,
347            &V1Dependency {
348                version: "1.0.0".to_string(),
349                requires: Some(HashMap::from([("react".to_string(), "17.0.0".to_string())])),
350                ..V1Dependency::default()
351            }
352        );
353    }
354
355    #[test]
356    fn parse_v3_workspace_dependencies_works() {
357        let content = std::fs::read_to_string("tests/workspace/v3/package-lock.json").unwrap();
358        let lock_file = parse(content).unwrap();
359        assert_eq!(lock_file.name, "kk");
360        assert_eq!(lock_file.version, Some("1.0.0".to_string()));
361        assert_eq!(lock_file.lockfile_version, 3);
362
363        assert!(lock_file.dependencies.is_none());
364        assert!(lock_file.packages.is_some());
365
366        let packages = lock_file.packages.unwrap();
367
368        // check liba
369        let liba = packages.get("liba").unwrap();
370        let expected_liba = V2Dependency {
371            version: "1.0.0".to_string(),
372            resolved: None,
373            integrity: None,
374            bundled: false,
375            is_dev: false,
376            is_optional: false,
377            dependencies: Some(HashMap::from([("libb2".to_string(), "*".to_string())])),
378            license: Some("ISC".to_string()),
379            engines: None,
380            ..V2Dependency::default()
381        };
382        assert_eq!(liba, &expected_liba);
383
384        // ensure libb is not present
385        let libb = packages.get("libb");
386        assert!(libb.is_none());
387
388        // ensure libb2 is present
389        let libb2 = packages.get("libb2").unwrap();
390        let expected_libb2 = V2Dependency {
391            name: Some("libb2".to_string()),
392            version: "1.0.0".to_string(),
393            resolved: None,
394            integrity: None,
395            bundled: false,
396            is_dev: false,
397            is_optional: false,
398            dependencies: None,
399            license: Some("ISC".to_string()),
400            engines: None,
401            ..V2Dependency::default()
402        };
403        assert_eq!(libb2, &expected_libb2);
404    }
405
406    #[test]
407    fn parse_v1_from_file_works() {
408        let content = std::fs::read_to_string("tests/v1/package-lock.json").unwrap();
409        let lock_file = parse(content).unwrap();
410        assert_eq!(lock_file.name, "cxtl");
411        assert_eq!(lock_file.version, Some("1.0.0".to_string()));
412        assert_eq!(lock_file.lockfile_version, 1);
413
414        assert!(lock_file.dependencies.is_some());
415        assert!(lock_file.packages.is_none());
416
417        let dependencies = lock_file.dependencies.unwrap();
418        let babel_highlight = dependencies.get("@babel/highlight").unwrap();
419
420        let expected = expected_v1();
421
422        assert_eq!(babel_highlight, &expected);
423    }
424
425    #[test]
426    fn parse_v2_from_file_works() {
427        let content = std::fs::read_to_string("tests/v2/package-lock.json").unwrap();
428        let lock_file = parse(content).unwrap();
429        assert_eq!(lock_file.name, "cxtl");
430        assert_eq!(lock_file.version, Some("1.0.0".to_string()));
431        assert_eq!(lock_file.lockfile_version, 2);
432
433        assert!(lock_file.dependencies.is_some());
434        assert!(lock_file.packages.is_some());
435
436        // v1
437        let dependencies = lock_file.dependencies.unwrap();
438        let babel_highlight = dependencies.get("@babel/highlight").unwrap();
439
440        let expected = expected_v1();
441        assert_eq!(babel_highlight, &expected);
442
443        // v2
444        let packages = lock_file.packages.unwrap();
445        let babel_highlight = packages.get("@babel/highlight").unwrap();
446
447        let expected = expected_v2();
448
449        assert_eq!(babel_highlight, &expected);
450    }
451
452    #[test]
453    fn parse_v3_from_file_works() {
454        let content = std::fs::read_to_string("tests/v3/package-lock.json").unwrap();
455        let lock_file = parse(content).unwrap();
456        assert_eq!(lock_file.name, "cxtl");
457        assert_eq!(lock_file.version, Some("1.0.0".to_string()));
458        assert_eq!(lock_file.lockfile_version, 3);
459
460        assert!(lock_file.dependencies.is_none());
461        assert!(lock_file.packages.is_some());
462
463        let packages = lock_file.packages.unwrap();
464        let babel_highlight = packages.get("@babel/highlight").unwrap();
465
466        let expected = expected_v2();
467
468        assert_eq!(babel_highlight, &expected);
469    }
470
471    #[test]
472    fn deserialize_packages_works() {
473        let content = r#"{
474            "node_modules/extsprintf": {
475                "version": "1.3.0",
476                "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
477                "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
478                "dev": true,
479                "engines": [
480                    "node >=0.6.0"
481                ]
482            }
483        }"#;
484
485        let mut deserializer = serde_json::Deserializer::from_str(content);
486        let packages = deserialize_packages(&mut deserializer).unwrap().unwrap();
487        // removes node_modules/ from the key
488        let package = packages.get("extsprintf").unwrap();
489        assert_eq!(package.version, "1.3.0");
490        assert!(package.is_dev);
491        assert_eq!(
492            package.engines,
493            Some(HashMap::from([("node".to_string(), ">=0.6.0".to_string())]))
494        );
495    }
496
497    #[test]
498    fn parse_entries_v1_works() {
499        let content = std::fs::read_to_string("tests/v1/package-lock.json").unwrap();
500        let mut dependencies = parse_dependencies(content).unwrap();
501        dependencies.sort();
502
503        let first = dependencies.first().unwrap();
504        assert_eq!(first.name, "@babel/code-frame");
505        assert_eq!(first.version, "7.18.6");
506        assert!(first.is_dev);
507        assert!(!first.is_optional);
508    }
509
510    #[test]
511    fn parse_entries_v2_works() {
512        let content = std::fs::read_to_string("tests/v3/package-lock.json").unwrap();
513        let mut dependencies = parse_dependencies(content).unwrap();
514        dependencies.sort();
515
516        let first = dependencies.first().unwrap();
517        assert_eq!(first.name, "@babel/code-frame");
518        assert_eq!(first.version, "7.18.6");
519        assert!(first.is_dev);
520        assert!(!first.is_optional);
521    }
522}