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