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