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(
118 name: &str,
119) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
120 if name.is_empty() || name.len() > 200 {
121 return Err(format!("package name {name:?} has invalid length").into());
122 }
123 if name.contains("..") {
124 return Err(format!("package name {name:?} contains '..'").into());
125 }
126 if !name
127 .bytes()
128 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'@' | b'/'))
129 {
130 return Err(format!("package name {name:?} contains disallowed characters").into());
131 }
132 Ok(())
133}
134
135fn validate_version(version: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
139 if version.is_empty() || version.len() > 100 {
140 return Err(format!("version {version:?} has invalid length").into());
141 }
142 if version.contains("..") {
143 return Err(format!("version {version:?} contains '..'").into());
144 }
145 if !version
146 .bytes()
147 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
148 {
149 return Err(format!("version {version:?} contains disallowed characters").into());
150 }
151 Ok(())
152}
153
154fn extract_version(value: &str) -> String {
157 if value.contains("github.com") || value.starts_with("git") {
158 if let Some(hash_pos) = value.rfind('#') {
159 return value[hash_pos + 1..].to_string();
160 }
161 }
162 value
163 .trim_start_matches('^')
164 .trim_start_matches('~')
165 .to_string()
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum PackageType {
171 Module,
172 CommonJs,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
177pub enum Entry {
178 Bare(String),
180 Subpath { subpath: String, target: String },
182 Prefix { subpath: String, dir: String },
186}
187
188#[derive(Debug, Clone)]
196pub struct PackageJson {
197 raw: Value,
198}
199
200impl License for PackageJson {
201 fn license(&self) -> Option<String> {
203 normalize_license(&self.raw)
204 }
205}
206
207const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
209
210impl PackageJson {
211 pub fn from_path(path: &Path) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
213 Self::from_json(&fs::read_to_string(path)?)
214 }
215
216 pub fn from_json(s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
218 Ok(Self::from_value(serde_json::from_str(s)?))
219 }
220
221 pub fn from_value(raw: Value) -> Self {
223 Self { raw }
224 }
225
226 pub fn name(&self) -> Option<&str> {
228 self.raw.get("name").and_then(Value::as_str)
229 }
230
231 pub fn version(&self) -> Option<&str> {
233 self.raw.get("version").and_then(Value::as_str)
234 }
235
236 pub fn package_type(&self) -> PackageType {
238 match self.raw.get("type").and_then(Value::as_str) {
239 Some("module") => PackageType::Module,
240 _ => PackageType::CommonJs,
241 }
242 }
243
244 pub fn resolve_main(&self) -> Option<String> {
246 if let Some(exports) = self.raw.get("exports") {
247 if let Some(s) = exports.as_str() {
248 return safe_target(s);
249 }
250 if let Some(obj) = exports.as_object() {
251 return if is_subpath_map(obj) {
252 obj.get(".")
253 .and_then(select_condition)
254 .and_then(|s| safe_target(&s))
255 } else {
256 select_condition(exports).and_then(|s| safe_target(&s))
257 };
258 }
259 }
260 if let Some(s) = self.raw.get("module").and_then(Value::as_str) {
262 return safe_target(s);
263 }
264 if let Some(browser) = self.raw.get("browser") {
265 if let Some(s) = browser.as_str() {
266 return safe_target(s);
267 }
268 if let (Some(map), Some(main)) = (
269 browser.as_object(),
270 self.raw.get("main").and_then(Value::as_str),
271 ) {
272 let main = safe_target(main)?;
273 for (key, value) in map {
274 if safe_target(key).as_deref() == Some(main.as_str()) {
275 if let Some(s) = value.as_str() {
276 return safe_target(s);
277 }
278 }
279 }
280 }
281 }
282 self.raw
283 .get("main")
284 .and_then(Value::as_str)
285 .and_then(safe_target)
286 }
287
288 pub fn resolve_subpath(&self, subpath: &str) -> Option<String> {
291 let key = normalize_subpath_key(subpath);
292 let exports = self.raw.get("exports")?.as_object()?;
293 if !is_subpath_map(exports) {
294 return None;
295 }
296 if let Some(value) = exports.get(&key) {
297 return select_condition(value).and_then(|s| safe_target(&s));
298 }
299 let mut best_len = 0usize;
300 let mut best: Option<String> = None;
301 for (pattern, value) in exports {
302 let Some(star) = pattern.find('*') else {
303 continue;
304 };
305 let (prefix, suffix) = (&pattern[..star], &pattern[star + 1..]);
306 if key.len() >= prefix.len() + suffix.len()
307 && key.starts_with(prefix)
308 && key.ends_with(suffix)
309 {
310 let matched = &key[prefix.len()..key.len() - suffix.len()];
311 if let Some(target) = select_condition(value) {
312 if let Some(resolved) = safe_target(&target.replace('*', matched)) {
313 if best.is_none() || prefix.len() > best_len {
314 best_len = prefix.len();
315 best = Some(resolved);
316 }
317 }
318 }
319 }
320 }
321 best
322 }
323
324 pub fn has_exports(&self) -> bool {
329 self.raw.get("exports").is_some()
330 }
331
332 pub fn resolve_asset(&self, subpath: &str) -> Option<String> {
340 if self.has_exports() {
341 self.resolve_subpath(subpath)
342 } else {
343 safe_target(subpath)
344 }
345 }
346
347 pub fn entries(&self) -> Vec<Entry> {
350 let mut entries = Vec::new();
351 match self.raw.get("exports") {
352 Some(Value::Object(obj)) if is_subpath_map(obj) => {
353 for (key, value) in obj {
354 if key == "." {
355 if let Some(t) = select_condition(value).and_then(|s| safe_target(&s)) {
356 entries.push(Entry::Bare(t));
357 }
358 } else if let Some(sub) = key.strip_prefix("./") {
359 if let Some(star) = sub.find('*') {
360 if let Some(dir) = select_condition(value).and_then(|t| target_dir(&t))
361 {
362 entries.push(Entry::Prefix {
363 subpath: sub[..star].to_string(),
364 dir,
365 });
366 }
367 } else if let Some(t) =
368 select_condition(value).and_then(|s| safe_target(&s))
369 {
370 entries.push(Entry::Subpath {
371 subpath: sub.to_string(),
372 target: t,
373 });
374 }
375 }
376 }
377 }
378 _ => {
381 if let Some(t) = self.resolve_main() {
382 entries.push(Entry::Bare(t));
383 }
384 }
385 }
386 entries
387 }
388
389 pub fn referenced_paths(&self) -> Vec<String> {
392 self.entries()
393 .into_iter()
394 .map(|e| match e {
395 Entry::Bare(t) | Entry::Subpath { target: t, .. } => t,
396 Entry::Prefix { dir, .. } => dir,
397 })
398 .collect()
399 }
400}
401
402fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
405 obj.keys().any(|k| k.starts_with('.'))
406}
407
408fn select_condition(node: &Value) -> Option<String> {
411 match node {
412 Value::String(s) => Some(s.clone()),
413 Value::Array(arr) => arr.iter().find_map(select_condition),
414 Value::Object(map) => BROWSER_CONDITIONS
415 .iter()
416 .find_map(|cond| map.get(*cond).and_then(select_condition)),
417 _ => None,
418 }
419}
420
421fn safe_target(s: &str) -> Option<String> {
423 let t = s.strip_prefix("./").unwrap_or(s).trim_start_matches('/');
424 if t.is_empty() || t.split('/').any(|seg| seg == "..") {
425 return None;
426 }
427 Some(t.to_string())
428}
429
430fn normalize_subpath_key(subpath: &str) -> String {
432 if subpath.starts_with("./") {
433 subpath.to_string()
434 } else {
435 format!("./{}", subpath.trim_start_matches('/'))
436 }
437}
438
439fn target_dir(target: &str) -> Option<String> {
442 let star = target.find('*')?;
443 let before = target[..star].strip_prefix("./").unwrap_or(&target[..star]);
444 if before.split('/').any(|seg| seg == "..") {
445 return None;
446 }
447 Some(before.trim_start_matches('/').to_string())
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453 use tempfile::tempdir;
454
455 #[test]
456 fn parses_pinned_caret_and_git_specs() {
457 let tmp = tempdir().unwrap();
458 let p = tmp.path().join("package.json");
459 fs::write(
460 &p,
461 r#"{ "dependencies": {
462 "lit": "3.3.3",
463 "bootstrap": "^5.3.8",
464 "forked": "github:owner/repo#abc123"
465 } }"#,
466 )
467 .unwrap();
468
469 let deps = parse_dependencies(&p).unwrap();
470 assert_eq!(deps["lit"].version, "3.3.3");
471 assert!(!deps["lit"].is_git);
472 assert_eq!(deps["bootstrap"].version, "5.3.8");
473 assert_eq!(deps["forked"].version, "abc123");
474 assert!(deps["forked"].is_git);
475 }
476
477 #[test]
478 fn resolve_main_from_exports_and_fallbacks() {
479 let a = PackageJson::from_json(
481 r#"{"exports":{".":{"types":"./dev.d.ts","default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
482 )
483 .unwrap();
484 assert_eq!(a.resolve_main().as_deref(), Some("index.js"));
485 assert_eq!(
486 a.resolve_subpath("./decorators.js").as_deref(),
487 Some("decorators.js")
488 );
489
490 let b = PackageJson::from_json(
492 r#"{"type":"module","exports":{".":{"browser":{"development":"./development/lit-html.js","default":"./lit-html.js"},"default":"./lit-html.js"}}}"#,
493 )
494 .unwrap();
495 assert_eq!(b.resolve_main().as_deref(), Some("lit-html.js"));
496
497 let c = PackageJson::from_json(
499 r#"{"main":"dist/js/bootstrap.js","module":"dist/js/bootstrap.esm.js"}"#,
500 )
501 .unwrap();
502 assert_eq!(
503 c.resolve_main().as_deref(),
504 Some("dist/js/bootstrap.esm.js")
505 );
506 }
507
508 #[test]
509 fn resolve_subpath_picks_import_condition_for_cjs_package() {
510 let rt = PackageJson::from_json(
513 r#"{"type":"commonjs","exports":{"./helpers/decorate":[{"node":"./src/helpers/decorate.js","import":"./src/helpers/esm/decorate.js","default":"./src/helpers/decorate.js"}]}}"#,
514 )
515 .unwrap();
516 assert_eq!(rt.package_type(), PackageType::CommonJs);
517 assert!(rt.resolve_main().is_none());
518 assert_eq!(
519 rt.resolve_subpath("./helpers/decorate").as_deref(),
520 Some("src/helpers/esm/decorate.js")
521 );
522 assert_eq!(
523 rt.resolve_subpath("helpers/decorate").as_deref(),
524 Some("src/helpers/esm/decorate.js")
525 );
526 assert!(rt
527 .referenced_paths()
528 .iter()
529 .any(|p| p == "src/helpers/esm/decorate.js"));
530 }
531
532 #[test]
533 fn condition_order_prefers_browser_and_import_never_node() {
534 let x = PackageJson::from_json(
535 r#"{"exports":{".":{"node":"./n.js","require":"./r.js","import":"./esm.js","default":"./def.js"}}}"#,
536 )
537 .unwrap();
538 assert_eq!(x.resolve_main().as_deref(), Some("esm.js"));
539
540 let y = PackageJson::from_json(
541 r#"{"exports":{".":{"module":"./m.js","browser":"./b.js","default":"./d.js"}}}"#,
542 )
543 .unwrap();
544 assert_eq!(y.resolve_main().as_deref(), Some("b.js"));
545 }
546
547 #[test]
548 fn subpath_pattern_becomes_prefix_entry() {
549 let pkg = PackageJson::from_json(r#"{"exports":{".":"./index.js","./*":"./dist/*.js"}}"#)
550 .unwrap();
551 assert_eq!(pkg.resolve_subpath("./foo").as_deref(), Some("dist/foo.js"));
552 assert!(pkg.entries().iter().any(
553 |e| matches!(e, Entry::Prefix { subpath, dir } if subpath.is_empty() && dir == "dist/")
554 ));
555 assert!(pkg
556 .entries()
557 .iter()
558 .any(|e| matches!(e, Entry::Bare(t) if t == "index.js")));
559 }
560
561 #[test]
562 fn rejects_path_traversal_targets() {
563 let evil = PackageJson::from_json(r#"{"exports":{".":"../escape.js"}}"#).unwrap();
564 assert!(evil.resolve_main().is_none());
565 }
566
567 #[test]
568 fn resolve_asset_gates_on_exports_else_addresses_directly() {
569 let plain = PackageJson::from_json(r#"{"name":"bootstrap-icons","files":["icons/*.svg"]}"#)
571 .unwrap();
572 assert!(!plain.has_exports());
573 assert_eq!(
574 plain.resolve_asset("icons/eye.svg").as_deref(),
575 Some("icons/eye.svg")
576 );
577 assert_eq!(
578 plain.resolve_asset("./icons/eye.svg").as_deref(),
579 Some("icons/eye.svg")
580 );
581 assert!(plain.resolve_asset("../escape.svg").is_none());
582
583 let gated = PackageJson::from_json(r#"{"exports":{"./icons/*":"./icons/*.svg"}}"#).unwrap();
585 assert!(gated.has_exports());
586 assert_eq!(
587 gated.resolve_asset("icons/eye").as_deref(),
588 Some("icons/eye.svg")
589 );
590 assert!(gated.resolve_asset("secret/keys.json").is_none());
591 }
592}