Skip to main content

npm_utils/
package_json.rs

1//! Minimal `package.json` reader for consumers that pin dependency versions
2//! there (rather than resolving against the registry).
3
4use serde_json::Value;
5use std::collections::HashMap;
6use std::fs;
7use std::path::Path;
8
9/// A dependency parsed from a `package.json` `dependencies` map.
10#[derive(Debug, Clone)]
11pub struct Dependency {
12    pub name: String,
13    pub version: String,
14    /// True when the spec points at a git/GitHub source rather than a registry
15    /// version (e.g. `github:owner/repo#ref`).
16    pub is_git: bool,
17}
18
19/// Parse the `dependencies` section of a `package.json`.
20pub fn parse_dependencies(
21    package_json_path: &Path,
22) -> Result<HashMap<String, Dependency>, Box<dyn std::error::Error>> {
23    let content = fs::read_to_string(package_json_path)?;
24    let json: Value = serde_json::from_str(&content)?;
25
26    let deps = json
27        .get("dependencies")
28        .and_then(|d| d.as_object())
29        .ok_or("no dependencies section found in package.json")?;
30
31    let mut dependencies = HashMap::new();
32    for (name, value) in deps {
33        if let Some(version_str) = value.as_str() {
34            let is_git = version_str.contains("github.com") || version_str.starts_with("git");
35            let version = extract_version(version_str);
36            validate_package_name(name)?;
37            validate_version(&version)?;
38            dependencies.insert(
39                name.clone(),
40                Dependency {
41                    name: name.clone(),
42                    version,
43                    is_git,
44                },
45            );
46        }
47    }
48
49    Ok(dependencies)
50}
51
52/// Reject npm package names whose characters could escape a path or URL. npm
53/// restricts names to lowercase letters, digits, `.`, `_`, `-`, `@`, and `/`
54/// (scoped). Anything else is a typo or a crafted entry meant to traverse a
55/// path later — fail loudly.
56fn validate_package_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
57    if name.is_empty() || name.len() > 200 {
58        return Err(format!("package name {name:?} has invalid length").into());
59    }
60    if name.contains("..") {
61        return Err(format!("package name {name:?} contains '..'").into());
62    }
63    if !name
64        .bytes()
65        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'@' | b'/'))
66    {
67        return Err(format!("package name {name:?} contains disallowed characters").into());
68    }
69    Ok(())
70}
71
72/// Reject versions outside the semver-adjacent alphabet, before the value ends
73/// up in a URL, a cache filename, or a marker — none of which should contain a
74/// path separator.
75fn validate_version(version: &str) -> Result<(), Box<dyn std::error::Error>> {
76    if version.is_empty() || version.len() > 100 {
77        return Err(format!("version {version:?} has invalid length").into());
78    }
79    if version.contains("..") {
80        return Err(format!("version {version:?} contains '..'").into());
81    }
82    if !version
83        .bytes()
84        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
85    {
86        return Err(format!("version {version:?} contains disallowed characters").into());
87    }
88    Ok(())
89}
90
91/// Extract a bare version from a spec string. Handles `"1.2.3"`, `"^1.2.3"`,
92/// `"~1.2.3"`, and git URLs (`"...#ref"` → `ref`).
93fn extract_version(value: &str) -> String {
94    if value.contains("github.com") || value.starts_with("git") {
95        if let Some(hash_pos) = value.rfind('#') {
96            return value[hash_pos + 1..].to_string();
97        }
98    }
99    value
100        .trim_start_matches('^')
101        .trim_start_matches('~')
102        .to_string()
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use tempfile::tempdir;
109
110    #[test]
111    fn parses_pinned_caret_and_git_specs() {
112        let tmp = tempdir().unwrap();
113        let p = tmp.path().join("package.json");
114        fs::write(
115            &p,
116            r#"{ "dependencies": {
117                "lit": "3.3.3",
118                "bootstrap": "^5.3.8",
119                "forked": "github:owner/repo#abc123"
120            } }"#,
121        )
122        .unwrap();
123
124        let deps = parse_dependencies(&p).unwrap();
125        assert_eq!(deps["lit"].version, "3.3.3");
126        assert!(!deps["lit"].is_git);
127        assert_eq!(deps["bootstrap"].version, "5.3.8");
128        assert_eq!(deps["forked"].version, "abc123");
129        assert!(deps["forked"].is_git);
130    }
131}