1pub mod lock;
24pub mod manifest;
25pub mod spec;
26
27use serde_json::Value;
28use std::collections::HashMap;
29use std::fs;
30use std::path::Path;
31
32pub(crate) fn normalize_license(value: &Value) -> Option<String> {
37 match value.get("license") {
38 Some(Value::String(s)) => return Some(s.clone()),
39 Some(Value::Object(o)) => {
40 if let Some(t) = o.get("type").and_then(Value::as_str) {
41 return Some(t.to_string());
42 }
43 }
44 _ => {}
45 }
46 let types: Vec<String> = value
47 .get("licenses")
48 .and_then(Value::as_array)
49 .map(|arr| {
50 arr.iter()
51 .filter_map(|l| l.get("type").and_then(Value::as_str).map(str::to_string))
52 .collect()
53 })
54 .unwrap_or_default();
55 (!types.is_empty()).then(|| types.join(" OR "))
56}
57
58pub trait License {
63 fn license(&self) -> Option<String>;
65}
66
67#[derive(Debug, Clone)]
69pub struct Dependency {
70 pub name: String,
71 pub version: String,
72 pub is_git: bool,
75}
76
77pub fn parse_dependencies(
79 package_json_path: &Path,
80) -> Result<HashMap<String, Dependency>, Box<dyn std::error::Error + Send + Sync>> {
81 let content = fs::read_to_string(package_json_path)?;
82 let json: Value = serde_json::from_str(&content)?;
83
84 let deps = json
85 .get("dependencies")
86 .and_then(|d| d.as_object())
87 .ok_or("no dependencies section found in package.json")?;
88
89 let mut dependencies = HashMap::new();
90 for (name, value) in deps {
91 if let Some(version_str) = value.as_str() {
92 let is_git = version_str.contains("github.com") || version_str.starts_with("git");
93 let version = extract_version(version_str);
94 validate_package_name(name)?;
95 validate_version(&version)?;
96 dependencies.insert(
97 name.clone(),
98 Dependency {
99 name: name.clone(),
100 version,
101 is_git,
102 },
103 );
104 }
105 }
106
107 Ok(dependencies)
108}
109
110pub(crate) fn validate_package_name(
122 name: &str,
123) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
124 if name.is_empty() || name.len() > 200 {
125 return Err(format!("package name {name:?} has invalid length").into());
126 }
127 if name.contains("..") {
128 return Err(format!("package name {name:?} contains '..'").into());
129 }
130 if name == "." || name.split('/').any(|segment| segment.is_empty()) {
131 return Err(format!("package name {name:?} is not a relative name").into());
132 }
133 if !name
134 .bytes()
135 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'@' | b'/'))
136 {
137 return Err(format!("package name {name:?} contains disallowed characters").into());
138 }
139 Ok(())
140}
141
142fn validate_version(version: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
146 if version.is_empty() || version.len() > 100 {
147 return Err(format!("version {version:?} has invalid length").into());
148 }
149 if version.contains("..") {
150 return Err(format!("version {version:?} contains '..'").into());
151 }
152 if !version
153 .bytes()
154 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
155 {
156 return Err(format!("version {version:?} contains disallowed characters").into());
157 }
158 Ok(())
159}
160
161fn extract_version(value: &str) -> String {
164 if value.contains("github.com") || value.starts_with("git") {
165 if let Some(hash_pos) = value.rfind('#') {
166 return value[hash_pos + 1..].to_string();
167 }
168 }
169 value
170 .trim_start_matches('^')
171 .trim_start_matches('~')
172 .to_string()
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum PackageType {
178 Module,
179 CommonJs,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum Entry {
185 Bare(String),
187 Subpath { subpath: String, target: String },
189 Prefix { subpath: String, dir: String },
193}
194
195#[derive(Debug, Clone)]
203pub struct PackageJson {
204 raw: Value,
205}
206
207impl License for PackageJson {
208 fn license(&self) -> Option<String> {
210 normalize_license(&self.raw)
211 }
212}
213
214const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
216
217impl PackageJson {
218 pub fn from_path(path: &Path) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
220 Self::from_json(&fs::read_to_string(path)?)
221 }
222
223 pub fn from_json(s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
225 Ok(Self::from_value(serde_json::from_str(s)?))
226 }
227
228 pub fn from_value(raw: Value) -> Self {
230 Self { raw }
231 }
232
233 pub fn name(&self) -> Option<&str> {
235 self.raw.get("name").and_then(Value::as_str)
236 }
237
238 pub fn version(&self) -> Option<&str> {
240 self.raw.get("version").and_then(Value::as_str)
241 }
242
243 pub fn package_type(&self) -> PackageType {
245 match self.raw.get("type").and_then(Value::as_str) {
246 Some("module") => PackageType::Module,
247 _ => PackageType::CommonJs,
248 }
249 }
250
251 pub fn resolve_main(&self) -> Option<String> {
253 if let Some(exports) = self.raw.get("exports") {
254 if let Some(s) = exports.as_str() {
255 return safe_target(s);
256 }
257 if let Some(obj) = exports.as_object() {
258 return if is_subpath_map(obj) {
259 obj.get(".")
260 .and_then(select_condition)
261 .and_then(|s| safe_target(&s))
262 } else {
263 select_condition(exports).and_then(|s| safe_target(&s))
264 };
265 }
266 }
267 if let Some(s) = self.raw.get("module").and_then(Value::as_str) {
269 return safe_target(s);
270 }
271 if let Some(browser) = self.raw.get("browser") {
272 if let Some(s) = browser.as_str() {
273 return safe_target(s);
274 }
275 if let (Some(map), Some(main)) = (
276 browser.as_object(),
277 self.raw.get("main").and_then(Value::as_str),
278 ) {
279 let main = safe_target(main)?;
280 for (key, value) in map {
281 if safe_target(key).as_deref() == Some(main.as_str()) {
282 if let Some(s) = value.as_str() {
283 return safe_target(s);
284 }
285 }
286 }
287 }
288 }
289 self.raw
290 .get("main")
291 .and_then(Value::as_str)
292 .and_then(safe_target)
293 }
294
295 pub fn resolve_subpath(&self, subpath: &str) -> Option<String> {
298 let key = normalize_subpath_key(subpath);
299 let exports = self.raw.get("exports")?.as_object()?;
300 if !is_subpath_map(exports) {
301 return None;
302 }
303 if let Some(value) = exports.get(&key) {
304 return select_condition(value).and_then(|s| safe_target(&s));
305 }
306 let mut best_len = 0usize;
307 let mut best: Option<String> = None;
308 for (pattern, value) in exports {
309 let Some(star) = pattern.find('*') else {
310 continue;
311 };
312 let (prefix, suffix) = (&pattern[..star], &pattern[star + 1..]);
313 if key.len() >= prefix.len() + suffix.len()
314 && key.starts_with(prefix)
315 && key.ends_with(suffix)
316 {
317 let matched = &key[prefix.len()..key.len() - suffix.len()];
318 if let Some(target) = select_condition(value) {
319 if let Some(resolved) = safe_target(&target.replace('*', matched)) {
320 if best.is_none() || prefix.len() > best_len {
321 best_len = prefix.len();
322 best = Some(resolved);
323 }
324 }
325 }
326 }
327 }
328 best
329 }
330
331 pub fn has_exports(&self) -> bool {
336 self.raw.get("exports").is_some()
337 }
338
339 pub fn resolve_asset(&self, subpath: &str) -> Option<String> {
347 if self.has_exports() {
348 self.resolve_subpath(subpath)
349 } else {
350 safe_target(subpath)
351 }
352 }
353
354 pub fn entries(&self) -> Vec<Entry> {
357 let mut entries = Vec::new();
358 match self.raw.get("exports") {
359 Some(Value::Object(obj)) if is_subpath_map(obj) => {
360 for (key, value) in obj {
361 if key == "." {
362 if let Some(t) = select_condition(value).and_then(|s| safe_target(&s)) {
363 entries.push(Entry::Bare(t));
364 }
365 } else if let Some(sub) = key.strip_prefix("./") {
366 if let Some(star) = sub.find('*') {
367 if let Some(dir) = select_condition(value).and_then(|t| target_dir(&t))
368 {
369 entries.push(Entry::Prefix {
370 subpath: sub[..star].to_string(),
371 dir,
372 });
373 }
374 } else if let Some(t) =
375 select_condition(value).and_then(|s| safe_target(&s))
376 {
377 entries.push(Entry::Subpath {
378 subpath: sub.to_string(),
379 target: t,
380 });
381 }
382 }
383 }
384 }
385 _ => {
388 if let Some(t) = self.resolve_main() {
389 entries.push(Entry::Bare(t));
390 }
391 }
392 }
393 entries
394 }
395
396 pub fn referenced_paths(&self) -> Vec<String> {
399 self.entries()
400 .into_iter()
401 .map(|e| match e {
402 Entry::Bare(t) | Entry::Subpath { target: t, .. } => t,
403 Entry::Prefix { dir, .. } => dir,
404 })
405 .collect()
406 }
407}
408
409fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
412 obj.keys().any(|k| k.starts_with('.'))
413}
414
415fn select_condition(node: &Value) -> Option<String> {
418 match node {
419 Value::String(s) => Some(s.clone()),
420 Value::Array(arr) => arr.iter().find_map(select_condition),
421 Value::Object(map) => BROWSER_CONDITIONS
422 .iter()
423 .find_map(|cond| map.get(*cond).and_then(select_condition)),
424 _ => None,
425 }
426}
427
428fn safe_target(s: &str) -> Option<String> {
430 let t = s.strip_prefix("./").unwrap_or(s).trim_start_matches('/');
431 if t.is_empty() || t.split('/').any(|seg| seg == "..") {
432 return None;
433 }
434 Some(t.to_string())
435}
436
437fn normalize_subpath_key(subpath: &str) -> String {
439 if subpath.starts_with("./") {
440 subpath.to_string()
441 } else {
442 format!("./{}", subpath.trim_start_matches('/'))
443 }
444}
445
446fn target_dir(target: &str) -> Option<String> {
449 let star = target.find('*')?;
450 let before = target[..star].strip_prefix("./").unwrap_or(&target[..star]);
451 if before.split('/').any(|seg| seg == "..") {
452 return None;
453 }
454 Some(before.trim_start_matches('/').to_string())
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use tempfile::tempdir;
461
462 #[test]
463 fn package_names_are_confined_to_relative_segments() {
464 for valid in ["lit", "@scope/pkg", "MixedCase", "a.b-c_d"] {
465 assert!(validate_package_name(valid).is_ok(), "{valid:?}");
466 }
467 for invalid in [
470 "/etc",
471 "/",
472 ".",
473 "..",
474 "@scope/",
475 "@//pkg",
476 "a//b",
477 "pkg/",
478 "../x",
479 "a..b/../c",
480 ] {
481 assert!(validate_package_name(invalid).is_err(), "{invalid:?}");
482 }
483 }
484
485 #[test]
486 fn parses_pinned_caret_and_git_specs() {
487 let tmp = tempdir().unwrap();
488 let p = tmp.path().join("package.json");
489 fs::write(
490 &p,
491 r#"{ "dependencies": {
492 "lit": "3.3.3",
493 "bootstrap": "^5.3.8",
494 "forked": "github:owner/repo#abc123"
495 } }"#,
496 )
497 .unwrap();
498
499 let deps = parse_dependencies(&p).unwrap();
500 assert_eq!(deps["lit"].version, "3.3.3");
501 assert!(!deps["lit"].is_git);
502 assert_eq!(deps["bootstrap"].version, "5.3.8");
503 assert_eq!(deps["forked"].version, "abc123");
504 assert!(deps["forked"].is_git);
505 }
506
507 #[test]
508 fn resolve_main_from_exports_and_fallbacks() {
509 let a = PackageJson::from_json(
511 r#"{"exports":{".":{"types":"./dev.d.ts","default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
512 )
513 .unwrap();
514 assert_eq!(a.resolve_main().as_deref(), Some("index.js"));
515 assert_eq!(
516 a.resolve_subpath("./decorators.js").as_deref(),
517 Some("decorators.js")
518 );
519
520 let b = PackageJson::from_json(
522 r#"{"type":"module","exports":{".":{"browser":{"development":"./development/lit-html.js","default":"./lit-html.js"},"default":"./lit-html.js"}}}"#,
523 )
524 .unwrap();
525 assert_eq!(b.resolve_main().as_deref(), Some("lit-html.js"));
526
527 let c = PackageJson::from_json(
529 r#"{"main":"dist/js/bootstrap.js","module":"dist/js/bootstrap.esm.js"}"#,
530 )
531 .unwrap();
532 assert_eq!(
533 c.resolve_main().as_deref(),
534 Some("dist/js/bootstrap.esm.js")
535 );
536 }
537
538 #[test]
539 fn resolve_subpath_picks_import_condition_for_cjs_package() {
540 let rt = PackageJson::from_json(
543 r#"{"type":"commonjs","exports":{"./helpers/decorate":[{"node":"./src/helpers/decorate.js","import":"./src/helpers/esm/decorate.js","default":"./src/helpers/decorate.js"}]}}"#,
544 )
545 .unwrap();
546 assert_eq!(rt.package_type(), PackageType::CommonJs);
547 assert!(rt.resolve_main().is_none());
548 assert_eq!(
549 rt.resolve_subpath("./helpers/decorate").as_deref(),
550 Some("src/helpers/esm/decorate.js")
551 );
552 assert_eq!(
553 rt.resolve_subpath("helpers/decorate").as_deref(),
554 Some("src/helpers/esm/decorate.js")
555 );
556 assert!(rt
557 .referenced_paths()
558 .iter()
559 .any(|p| p == "src/helpers/esm/decorate.js"));
560 }
561
562 #[test]
563 fn condition_order_prefers_browser_and_import_never_node() {
564 let x = PackageJson::from_json(
565 r#"{"exports":{".":{"node":"./n.js","require":"./r.js","import":"./esm.js","default":"./def.js"}}}"#,
566 )
567 .unwrap();
568 assert_eq!(x.resolve_main().as_deref(), Some("esm.js"));
569
570 let y = PackageJson::from_json(
571 r#"{"exports":{".":{"module":"./m.js","browser":"./b.js","default":"./d.js"}}}"#,
572 )
573 .unwrap();
574 assert_eq!(y.resolve_main().as_deref(), Some("b.js"));
575 }
576
577 #[test]
578 fn subpath_pattern_becomes_prefix_entry() {
579 let pkg = PackageJson::from_json(r#"{"exports":{".":"./index.js","./*":"./dist/*.js"}}"#)
580 .unwrap();
581 assert_eq!(pkg.resolve_subpath("./foo").as_deref(), Some("dist/foo.js"));
582 assert!(pkg.entries().iter().any(
583 |e| matches!(e, Entry::Prefix { subpath, dir } if subpath.is_empty() && dir == "dist/")
584 ));
585 assert!(pkg
586 .entries()
587 .iter()
588 .any(|e| matches!(e, Entry::Bare(t) if t == "index.js")));
589 }
590
591 #[test]
592 fn rejects_path_traversal_targets() {
593 let evil = PackageJson::from_json(r#"{"exports":{".":"../escape.js"}}"#).unwrap();
594 assert!(evil.resolve_main().is_none());
595 }
596
597 #[test]
598 fn resolve_asset_gates_on_exports_else_addresses_directly() {
599 let plain = PackageJson::from_json(r#"{"name":"bootstrap-icons","files":["icons/*.svg"]}"#)
601 .unwrap();
602 assert!(!plain.has_exports());
603 assert_eq!(
604 plain.resolve_asset("icons/eye.svg").as_deref(),
605 Some("icons/eye.svg")
606 );
607 assert_eq!(
608 plain.resolve_asset("./icons/eye.svg").as_deref(),
609 Some("icons/eye.svg")
610 );
611 assert!(plain.resolve_asset("../escape.svg").is_none());
612
613 let gated = PackageJson::from_json(r#"{"exports":{"./icons/*":"./icons/*.svg"}}"#).unwrap();
615 assert!(gated.has_exports());
616 assert_eq!(
617 gated.resolve_asset("icons/eye").as_deref(),
618 Some("icons/eye.svg")
619 );
620 assert!(gated.resolve_asset("secret/keys.json").is_none());
621 }
622}