1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum PackageType {
108 Module,
109 CommonJs,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Entry {
115 Bare(String),
117 Subpath { subpath: String, target: String },
119 Prefix { subpath: String, dir: String },
123}
124
125#[derive(Debug, Clone)]
133pub struct PackageJson {
134 raw: Value,
135}
136
137const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
139
140impl PackageJson {
141 pub fn from_path(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
143 Self::from_json(&fs::read_to_string(path)?)
144 }
145
146 pub fn from_json(s: &str) -> Result<Self, Box<dyn std::error::Error>> {
148 Ok(Self::from_value(serde_json::from_str(s)?))
149 }
150
151 pub fn from_value(raw: Value) -> Self {
153 Self { raw }
154 }
155
156 pub fn name(&self) -> Option<&str> {
158 self.raw.get("name").and_then(Value::as_str)
159 }
160
161 pub fn version(&self) -> Option<&str> {
163 self.raw.get("version").and_then(Value::as_str)
164 }
165
166 pub fn package_type(&self) -> PackageType {
168 match self.raw.get("type").and_then(Value::as_str) {
169 Some("module") => PackageType::Module,
170 _ => PackageType::CommonJs,
171 }
172 }
173
174 pub fn resolve_main(&self) -> Option<String> {
176 if let Some(exports) = self.raw.get("exports") {
177 if let Some(s) = exports.as_str() {
178 return safe_target(s);
179 }
180 if let Some(obj) = exports.as_object() {
181 return if is_subpath_map(obj) {
182 obj.get(".")
183 .and_then(select_condition)
184 .and_then(|s| safe_target(&s))
185 } else {
186 select_condition(exports).and_then(|s| safe_target(&s))
187 };
188 }
189 }
190 if let Some(s) = self.raw.get("module").and_then(Value::as_str) {
192 return safe_target(s);
193 }
194 if let Some(browser) = self.raw.get("browser") {
195 if let Some(s) = browser.as_str() {
196 return safe_target(s);
197 }
198 if let (Some(map), Some(main)) = (
199 browser.as_object(),
200 self.raw.get("main").and_then(Value::as_str),
201 ) {
202 let main = safe_target(main)?;
203 for (key, value) in map {
204 if safe_target(key).as_deref() == Some(main.as_str()) {
205 if let Some(s) = value.as_str() {
206 return safe_target(s);
207 }
208 }
209 }
210 }
211 }
212 self.raw
213 .get("main")
214 .and_then(Value::as_str)
215 .and_then(safe_target)
216 }
217
218 pub fn resolve_subpath(&self, subpath: &str) -> Option<String> {
221 let key = normalize_subpath_key(subpath);
222 let exports = self.raw.get("exports")?.as_object()?;
223 if !is_subpath_map(exports) {
224 return None;
225 }
226 if let Some(value) = exports.get(&key) {
227 return select_condition(value).and_then(|s| safe_target(&s));
228 }
229 let mut best_len = 0usize;
230 let mut best: Option<String> = None;
231 for (pattern, value) in exports {
232 let Some(star) = pattern.find('*') else {
233 continue;
234 };
235 let (prefix, suffix) = (&pattern[..star], &pattern[star + 1..]);
236 if key.len() >= prefix.len() + suffix.len()
237 && key.starts_with(prefix)
238 && key.ends_with(suffix)
239 {
240 let matched = &key[prefix.len()..key.len() - suffix.len()];
241 if let Some(target) = select_condition(value) {
242 if let Some(resolved) = safe_target(&target.replace('*', matched)) {
243 if best.is_none() || prefix.len() > best_len {
244 best_len = prefix.len();
245 best = Some(resolved);
246 }
247 }
248 }
249 }
250 }
251 best
252 }
253
254 pub fn entries(&self) -> Vec<Entry> {
257 let mut entries = Vec::new();
258 match self.raw.get("exports") {
259 Some(Value::Object(obj)) if is_subpath_map(obj) => {
260 for (key, value) in obj {
261 if key == "." {
262 if let Some(t) = select_condition(value).and_then(|s| safe_target(&s)) {
263 entries.push(Entry::Bare(t));
264 }
265 } else if let Some(sub) = key.strip_prefix("./") {
266 if let Some(star) = sub.find('*') {
267 if let Some(dir) = select_condition(value).and_then(|t| target_dir(&t))
268 {
269 entries.push(Entry::Prefix {
270 subpath: sub[..star].to_string(),
271 dir,
272 });
273 }
274 } else if let Some(t) =
275 select_condition(value).and_then(|s| safe_target(&s))
276 {
277 entries.push(Entry::Subpath {
278 subpath: sub.to_string(),
279 target: t,
280 });
281 }
282 }
283 }
284 }
285 _ => {
288 if let Some(t) = self.resolve_main() {
289 entries.push(Entry::Bare(t));
290 }
291 }
292 }
293 entries
294 }
295
296 pub fn referenced_paths(&self) -> Vec<String> {
299 self.entries()
300 .into_iter()
301 .map(|e| match e {
302 Entry::Bare(t) | Entry::Subpath { target: t, .. } => t,
303 Entry::Prefix { dir, .. } => dir,
304 })
305 .collect()
306 }
307}
308
309fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
312 obj.keys().any(|k| k.starts_with('.'))
313}
314
315fn select_condition(node: &Value) -> Option<String> {
318 match node {
319 Value::String(s) => Some(s.clone()),
320 Value::Array(arr) => arr.iter().find_map(select_condition),
321 Value::Object(map) => BROWSER_CONDITIONS
322 .iter()
323 .find_map(|cond| map.get(*cond).and_then(select_condition)),
324 _ => None,
325 }
326}
327
328fn safe_target(s: &str) -> Option<String> {
330 let t = s.strip_prefix("./").unwrap_or(s).trim_start_matches('/');
331 if t.is_empty() || t.split('/').any(|seg| seg == "..") {
332 return None;
333 }
334 Some(t.to_string())
335}
336
337fn normalize_subpath_key(subpath: &str) -> String {
339 if subpath.starts_with("./") {
340 subpath.to_string()
341 } else {
342 format!("./{}", subpath.trim_start_matches('/'))
343 }
344}
345
346fn target_dir(target: &str) -> Option<String> {
349 let star = target.find('*')?;
350 let before = target[..star].strip_prefix("./").unwrap_or(&target[..star]);
351 if before.split('/').any(|seg| seg == "..") {
352 return None;
353 }
354 Some(before.trim_start_matches('/').to_string())
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use tempfile::tempdir;
361
362 #[test]
363 fn parses_pinned_caret_and_git_specs() {
364 let tmp = tempdir().unwrap();
365 let p = tmp.path().join("package.json");
366 fs::write(
367 &p,
368 r#"{ "dependencies": {
369 "lit": "3.3.3",
370 "bootstrap": "^5.3.8",
371 "forked": "github:owner/repo#abc123"
372 } }"#,
373 )
374 .unwrap();
375
376 let deps = parse_dependencies(&p).unwrap();
377 assert_eq!(deps["lit"].version, "3.3.3");
378 assert!(!deps["lit"].is_git);
379 assert_eq!(deps["bootstrap"].version, "5.3.8");
380 assert_eq!(deps["forked"].version, "abc123");
381 assert!(deps["forked"].is_git);
382 }
383
384 #[test]
385 fn resolve_main_from_exports_and_fallbacks() {
386 let a = PackageJson::from_json(
388 r#"{"exports":{".":{"types":"./dev.d.ts","default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
389 )
390 .unwrap();
391 assert_eq!(a.resolve_main().as_deref(), Some("index.js"));
392 assert_eq!(
393 a.resolve_subpath("./decorators.js").as_deref(),
394 Some("decorators.js")
395 );
396
397 let b = PackageJson::from_json(
399 r#"{"type":"module","exports":{".":{"browser":{"development":"./development/lit-html.js","default":"./lit-html.js"},"default":"./lit-html.js"}}}"#,
400 )
401 .unwrap();
402 assert_eq!(b.resolve_main().as_deref(), Some("lit-html.js"));
403
404 let c = PackageJson::from_json(
406 r#"{"main":"dist/js/bootstrap.js","module":"dist/js/bootstrap.esm.js"}"#,
407 )
408 .unwrap();
409 assert_eq!(
410 c.resolve_main().as_deref(),
411 Some("dist/js/bootstrap.esm.js")
412 );
413 }
414
415 #[test]
416 fn resolve_subpath_picks_import_condition_for_cjs_package() {
417 let rt = PackageJson::from_json(
420 r#"{"type":"commonjs","exports":{"./helpers/decorate":[{"node":"./src/helpers/decorate.js","import":"./src/helpers/esm/decorate.js","default":"./src/helpers/decorate.js"}]}}"#,
421 )
422 .unwrap();
423 assert_eq!(rt.package_type(), PackageType::CommonJs);
424 assert!(rt.resolve_main().is_none());
425 assert_eq!(
426 rt.resolve_subpath("./helpers/decorate").as_deref(),
427 Some("src/helpers/esm/decorate.js")
428 );
429 assert_eq!(
430 rt.resolve_subpath("helpers/decorate").as_deref(),
431 Some("src/helpers/esm/decorate.js")
432 );
433 assert!(rt
434 .referenced_paths()
435 .iter()
436 .any(|p| p == "src/helpers/esm/decorate.js"));
437 }
438
439 #[test]
440 fn condition_order_prefers_browser_and_import_never_node() {
441 let x = PackageJson::from_json(
442 r#"{"exports":{".":{"node":"./n.js","require":"./r.js","import":"./esm.js","default":"./def.js"}}}"#,
443 )
444 .unwrap();
445 assert_eq!(x.resolve_main().as_deref(), Some("esm.js"));
446
447 let y = PackageJson::from_json(
448 r#"{"exports":{".":{"module":"./m.js","browser":"./b.js","default":"./d.js"}}}"#,
449 )
450 .unwrap();
451 assert_eq!(y.resolve_main().as_deref(), Some("b.js"));
452 }
453
454 #[test]
455 fn subpath_pattern_becomes_prefix_entry() {
456 let pkg = PackageJson::from_json(r#"{"exports":{".":"./index.js","./*":"./dist/*.js"}}"#)
457 .unwrap();
458 assert_eq!(pkg.resolve_subpath("./foo").as_deref(), Some("dist/foo.js"));
459 assert!(pkg.entries().iter().any(
460 |e| matches!(e, Entry::Prefix { subpath, dir } if subpath.is_empty() && dir == "dist/")
461 ));
462 assert!(pkg
463 .entries()
464 .iter()
465 .any(|e| matches!(e, Entry::Bare(t) if t == "index.js")));
466 }
467
468 #[test]
469 fn rejects_path_traversal_targets() {
470 let evil = PackageJson::from_json(r#"{"exports":{".":"../escape.js"}}"#).unwrap();
471 assert!(evil.resolve_main().is_none());
472 }
473}