npm_utils/
package_json.rs1use serde_json::Value;
5use std::collections::HashMap;
6use std::fs;
7use std::path::Path;
8
9#[derive(Debug, Clone)]
11pub struct Dependency {
12 pub name: String,
13 pub version: String,
14 pub is_git: bool,
17}
18
19pub 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
52fn 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
72fn 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
91fn 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}