1pub mod lock;
20pub mod spec;
21
22use serde_json::Value;
23use std::collections::HashMap;
24use std::fs;
25use std::path::Path;
26
27#[derive(Debug, Clone)]
29pub struct Dependency {
30 pub name: String,
31 pub version: String,
32 pub is_git: bool,
35}
36
37pub fn parse_dependencies(
39 package_json_path: &Path,
40) -> Result<HashMap<String, Dependency>, Box<dyn std::error::Error>> {
41 let content = fs::read_to_string(package_json_path)?;
42 let json: Value = serde_json::from_str(&content)?;
43
44 let deps = json
45 .get("dependencies")
46 .and_then(|d| d.as_object())
47 .ok_or("no dependencies section found in package.json")?;
48
49 let mut dependencies = HashMap::new();
50 for (name, value) in deps {
51 if let Some(version_str) = value.as_str() {
52 let is_git = version_str.contains("github.com") || version_str.starts_with("git");
53 let version = extract_version(version_str);
54 validate_package_name(name)?;
55 validate_version(&version)?;
56 dependencies.insert(
57 name.clone(),
58 Dependency {
59 name: name.clone(),
60 version,
61 is_git,
62 },
63 );
64 }
65 }
66
67 Ok(dependencies)
68}
69
70fn validate_package_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
75 if name.is_empty() || name.len() > 200 {
76 return Err(format!("package name {name:?} has invalid length").into());
77 }
78 if name.contains("..") {
79 return Err(format!("package name {name:?} contains '..'").into());
80 }
81 if !name
82 .bytes()
83 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'@' | b'/'))
84 {
85 return Err(format!("package name {name:?} contains disallowed characters").into());
86 }
87 Ok(())
88}
89
90fn validate_version(version: &str) -> Result<(), Box<dyn std::error::Error>> {
94 if version.is_empty() || version.len() > 100 {
95 return Err(format!("version {version:?} has invalid length").into());
96 }
97 if version.contains("..") {
98 return Err(format!("version {version:?} contains '..'").into());
99 }
100 if !version
101 .bytes()
102 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
103 {
104 return Err(format!("version {version:?} contains disallowed characters").into());
105 }
106 Ok(())
107}
108
109fn extract_version(value: &str) -> String {
112 if value.contains("github.com") || value.starts_with("git") {
113 if let Some(hash_pos) = value.rfind('#') {
114 return value[hash_pos + 1..].to_string();
115 }
116 }
117 value
118 .trim_start_matches('^')
119 .trim_start_matches('~')
120 .to_string()
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum PackageType {
126 Module,
127 CommonJs,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum Entry {
133 Bare(String),
135 Subpath { subpath: String, target: String },
137 Prefix { subpath: String, dir: String },
141}
142
143#[derive(Debug, Clone)]
151pub struct PackageJson {
152 raw: Value,
153}
154
155const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
157
158impl PackageJson {
159 pub fn from_path(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
161 Self::from_json(&fs::read_to_string(path)?)
162 }
163
164 pub fn from_json(s: &str) -> Result<Self, Box<dyn std::error::Error>> {
166 Ok(Self::from_value(serde_json::from_str(s)?))
167 }
168
169 pub fn from_value(raw: Value) -> Self {
171 Self { raw }
172 }
173
174 pub fn name(&self) -> Option<&str> {
176 self.raw.get("name").and_then(Value::as_str)
177 }
178
179 pub fn version(&self) -> Option<&str> {
181 self.raw.get("version").and_then(Value::as_str)
182 }
183
184 pub fn package_type(&self) -> PackageType {
186 match self.raw.get("type").and_then(Value::as_str) {
187 Some("module") => PackageType::Module,
188 _ => PackageType::CommonJs,
189 }
190 }
191
192 pub fn resolve_main(&self) -> Option<String> {
194 if let Some(exports) = self.raw.get("exports") {
195 if let Some(s) = exports.as_str() {
196 return safe_target(s);
197 }
198 if let Some(obj) = exports.as_object() {
199 return if is_subpath_map(obj) {
200 obj.get(".")
201 .and_then(select_condition)
202 .and_then(|s| safe_target(&s))
203 } else {
204 select_condition(exports).and_then(|s| safe_target(&s))
205 };
206 }
207 }
208 if let Some(s) = self.raw.get("module").and_then(Value::as_str) {
210 return safe_target(s);
211 }
212 if let Some(browser) = self.raw.get("browser") {
213 if let Some(s) = browser.as_str() {
214 return safe_target(s);
215 }
216 if let (Some(map), Some(main)) = (
217 browser.as_object(),
218 self.raw.get("main").and_then(Value::as_str),
219 ) {
220 let main = safe_target(main)?;
221 for (key, value) in map {
222 if safe_target(key).as_deref() == Some(main.as_str()) {
223 if let Some(s) = value.as_str() {
224 return safe_target(s);
225 }
226 }
227 }
228 }
229 }
230 self.raw
231 .get("main")
232 .and_then(Value::as_str)
233 .and_then(safe_target)
234 }
235
236 pub fn resolve_subpath(&self, subpath: &str) -> Option<String> {
239 let key = normalize_subpath_key(subpath);
240 let exports = self.raw.get("exports")?.as_object()?;
241 if !is_subpath_map(exports) {
242 return None;
243 }
244 if let Some(value) = exports.get(&key) {
245 return select_condition(value).and_then(|s| safe_target(&s));
246 }
247 let mut best_len = 0usize;
248 let mut best: Option<String> = None;
249 for (pattern, value) in exports {
250 let Some(star) = pattern.find('*') else {
251 continue;
252 };
253 let (prefix, suffix) = (&pattern[..star], &pattern[star + 1..]);
254 if key.len() >= prefix.len() + suffix.len()
255 && key.starts_with(prefix)
256 && key.ends_with(suffix)
257 {
258 let matched = &key[prefix.len()..key.len() - suffix.len()];
259 if let Some(target) = select_condition(value) {
260 if let Some(resolved) = safe_target(&target.replace('*', matched)) {
261 if best.is_none() || prefix.len() > best_len {
262 best_len = prefix.len();
263 best = Some(resolved);
264 }
265 }
266 }
267 }
268 }
269 best
270 }
271
272 pub fn entries(&self) -> Vec<Entry> {
275 let mut entries = Vec::new();
276 match self.raw.get("exports") {
277 Some(Value::Object(obj)) if is_subpath_map(obj) => {
278 for (key, value) in obj {
279 if key == "." {
280 if let Some(t) = select_condition(value).and_then(|s| safe_target(&s)) {
281 entries.push(Entry::Bare(t));
282 }
283 } else if let Some(sub) = key.strip_prefix("./") {
284 if let Some(star) = sub.find('*') {
285 if let Some(dir) = select_condition(value).and_then(|t| target_dir(&t))
286 {
287 entries.push(Entry::Prefix {
288 subpath: sub[..star].to_string(),
289 dir,
290 });
291 }
292 } else if let Some(t) =
293 select_condition(value).and_then(|s| safe_target(&s))
294 {
295 entries.push(Entry::Subpath {
296 subpath: sub.to_string(),
297 target: t,
298 });
299 }
300 }
301 }
302 }
303 _ => {
306 if let Some(t) = self.resolve_main() {
307 entries.push(Entry::Bare(t));
308 }
309 }
310 }
311 entries
312 }
313
314 pub fn referenced_paths(&self) -> Vec<String> {
317 self.entries()
318 .into_iter()
319 .map(|e| match e {
320 Entry::Bare(t) | Entry::Subpath { target: t, .. } => t,
321 Entry::Prefix { dir, .. } => dir,
322 })
323 .collect()
324 }
325}
326
327fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
330 obj.keys().any(|k| k.starts_with('.'))
331}
332
333fn select_condition(node: &Value) -> Option<String> {
336 match node {
337 Value::String(s) => Some(s.clone()),
338 Value::Array(arr) => arr.iter().find_map(select_condition),
339 Value::Object(map) => BROWSER_CONDITIONS
340 .iter()
341 .find_map(|cond| map.get(*cond).and_then(select_condition)),
342 _ => None,
343 }
344}
345
346fn safe_target(s: &str) -> Option<String> {
348 let t = s.strip_prefix("./").unwrap_or(s).trim_start_matches('/');
349 if t.is_empty() || t.split('/').any(|seg| seg == "..") {
350 return None;
351 }
352 Some(t.to_string())
353}
354
355fn normalize_subpath_key(subpath: &str) -> String {
357 if subpath.starts_with("./") {
358 subpath.to_string()
359 } else {
360 format!("./{}", subpath.trim_start_matches('/'))
361 }
362}
363
364fn target_dir(target: &str) -> Option<String> {
367 let star = target.find('*')?;
368 let before = target[..star].strip_prefix("./").unwrap_or(&target[..star]);
369 if before.split('/').any(|seg| seg == "..") {
370 return None;
371 }
372 Some(before.trim_start_matches('/').to_string())
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use tempfile::tempdir;
379
380 #[test]
381 fn parses_pinned_caret_and_git_specs() {
382 let tmp = tempdir().unwrap();
383 let p = tmp.path().join("package.json");
384 fs::write(
385 &p,
386 r#"{ "dependencies": {
387 "lit": "3.3.3",
388 "bootstrap": "^5.3.8",
389 "forked": "github:owner/repo#abc123"
390 } }"#,
391 )
392 .unwrap();
393
394 let deps = parse_dependencies(&p).unwrap();
395 assert_eq!(deps["lit"].version, "3.3.3");
396 assert!(!deps["lit"].is_git);
397 assert_eq!(deps["bootstrap"].version, "5.3.8");
398 assert_eq!(deps["forked"].version, "abc123");
399 assert!(deps["forked"].is_git);
400 }
401
402 #[test]
403 fn resolve_main_from_exports_and_fallbacks() {
404 let a = PackageJson::from_json(
406 r#"{"exports":{".":{"types":"./dev.d.ts","default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
407 )
408 .unwrap();
409 assert_eq!(a.resolve_main().as_deref(), Some("index.js"));
410 assert_eq!(
411 a.resolve_subpath("./decorators.js").as_deref(),
412 Some("decorators.js")
413 );
414
415 let b = PackageJson::from_json(
417 r#"{"type":"module","exports":{".":{"browser":{"development":"./development/lit-html.js","default":"./lit-html.js"},"default":"./lit-html.js"}}}"#,
418 )
419 .unwrap();
420 assert_eq!(b.resolve_main().as_deref(), Some("lit-html.js"));
421
422 let c = PackageJson::from_json(
424 r#"{"main":"dist/js/bootstrap.js","module":"dist/js/bootstrap.esm.js"}"#,
425 )
426 .unwrap();
427 assert_eq!(
428 c.resolve_main().as_deref(),
429 Some("dist/js/bootstrap.esm.js")
430 );
431 }
432
433 #[test]
434 fn resolve_subpath_picks_import_condition_for_cjs_package() {
435 let rt = PackageJson::from_json(
438 r#"{"type":"commonjs","exports":{"./helpers/decorate":[{"node":"./src/helpers/decorate.js","import":"./src/helpers/esm/decorate.js","default":"./src/helpers/decorate.js"}]}}"#,
439 )
440 .unwrap();
441 assert_eq!(rt.package_type(), PackageType::CommonJs);
442 assert!(rt.resolve_main().is_none());
443 assert_eq!(
444 rt.resolve_subpath("./helpers/decorate").as_deref(),
445 Some("src/helpers/esm/decorate.js")
446 );
447 assert_eq!(
448 rt.resolve_subpath("helpers/decorate").as_deref(),
449 Some("src/helpers/esm/decorate.js")
450 );
451 assert!(rt
452 .referenced_paths()
453 .iter()
454 .any(|p| p == "src/helpers/esm/decorate.js"));
455 }
456
457 #[test]
458 fn condition_order_prefers_browser_and_import_never_node() {
459 let x = PackageJson::from_json(
460 r#"{"exports":{".":{"node":"./n.js","require":"./r.js","import":"./esm.js","default":"./def.js"}}}"#,
461 )
462 .unwrap();
463 assert_eq!(x.resolve_main().as_deref(), Some("esm.js"));
464
465 let y = PackageJson::from_json(
466 r#"{"exports":{".":{"module":"./m.js","browser":"./b.js","default":"./d.js"}}}"#,
467 )
468 .unwrap();
469 assert_eq!(y.resolve_main().as_deref(), Some("b.js"));
470 }
471
472 #[test]
473 fn subpath_pattern_becomes_prefix_entry() {
474 let pkg = PackageJson::from_json(r#"{"exports":{".":"./index.js","./*":"./dist/*.js"}}"#)
475 .unwrap();
476 assert_eq!(pkg.resolve_subpath("./foo").as_deref(), Some("dist/foo.js"));
477 assert!(pkg.entries().iter().any(
478 |e| matches!(e, Entry::Prefix { subpath, dir } if subpath.is_empty() && dir == "dist/")
479 ));
480 assert!(pkg
481 .entries()
482 .iter()
483 .any(|e| matches!(e, Entry::Bare(t) if t == "index.js")));
484 }
485
486 #[test]
487 fn rejects_path_traversal_targets() {
488 let evil = PackageJson::from_json(r#"{"exports":{".":"../escape.js"}}"#).unwrap();
489 assert!(evil.resolve_main().is_none());
490 }
491}