Skip to main content

npm_utils/
package_json.rs

1//! Minimal `package.json` reader for consumers that pin dependency versions
2//! there (rather than resolving against the registry).
3
4use serde_json::Value;
5use std::collections::HashMap;
6use std::fs;
7use std::path::Path;
8
9/// A dependency parsed from a `package.json` `dependencies` map.
10#[derive(Debug, Clone)]
11pub struct Dependency {
12    pub name: String,
13    pub version: String,
14    /// True when the spec points at a git/GitHub source rather than a registry
15    /// version (e.g. `github:owner/repo#ref`).
16    pub is_git: bool,
17}
18
19/// Parse the `dependencies` section of a `package.json`.
20pub fn parse_dependencies(
21    package_json_path: &Path,
22) -> Result<HashMap<String, Dependency>, Box<dyn std::error::Error>> {
23    let content = fs::read_to_string(package_json_path)?;
24    let json: Value = serde_json::from_str(&content)?;
25
26    let deps = json
27        .get("dependencies")
28        .and_then(|d| d.as_object())
29        .ok_or("no dependencies section found in package.json")?;
30
31    let mut dependencies = HashMap::new();
32    for (name, value) in deps {
33        if let Some(version_str) = value.as_str() {
34            let is_git = version_str.contains("github.com") || version_str.starts_with("git");
35            let version = extract_version(version_str);
36            validate_package_name(name)?;
37            validate_version(&version)?;
38            dependencies.insert(
39                name.clone(),
40                Dependency {
41                    name: name.clone(),
42                    version,
43                    is_git,
44                },
45            );
46        }
47    }
48
49    Ok(dependencies)
50}
51
52/// Reject npm package names whose characters could escape a path or URL. npm
53/// restricts names to lowercase letters, digits, `.`, `_`, `-`, `@`, and `/`
54/// (scoped). Anything else is a typo or a crafted entry meant to traverse a
55/// path later — fail loudly.
56fn validate_package_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
57    if name.is_empty() || name.len() > 200 {
58        return Err(format!("package name {name:?} has invalid length").into());
59    }
60    if name.contains("..") {
61        return Err(format!("package name {name:?} contains '..'").into());
62    }
63    if !name
64        .bytes()
65        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'@' | b'/'))
66    {
67        return Err(format!("package name {name:?} contains disallowed characters").into());
68    }
69    Ok(())
70}
71
72/// Reject versions outside the semver-adjacent alphabet, before the value ends
73/// up in a URL, a cache filename, or a marker — none of which should contain a
74/// path separator.
75fn validate_version(version: &str) -> Result<(), Box<dyn std::error::Error>> {
76    if version.is_empty() || version.len() > 100 {
77        return Err(format!("version {version:?} has invalid length").into());
78    }
79    if version.contains("..") {
80        return Err(format!("version {version:?} contains '..'").into());
81    }
82    if !version
83        .bytes()
84        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
85    {
86        return Err(format!("version {version:?} contains disallowed characters").into());
87    }
88    Ok(())
89}
90
91/// Extract a bare version from a spec string. Handles `"1.2.3"`, `"^1.2.3"`,
92/// `"~1.2.3"`, and git URLs (`"...#ref"` → `ref`).
93fn extract_version(value: &str) -> String {
94    if value.contains("github.com") || value.starts_with("git") {
95        if let Some(hash_pos) = value.rfind('#') {
96            return value[hash_pos + 1..].to_string();
97        }
98    }
99    value
100        .trim_start_matches('^')
101        .trim_start_matches('~')
102        .to_string()
103}
104
105/// The `"type"` field of a `package.json` (Node defaults to CommonJS).
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum PackageType {
108    Module,
109    CommonJs,
110}
111
112/// An import-map-worthy entry derived from a package's `package.json`.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Entry {
115    /// The bare specifier (`name`) → a target relative path (the `.` export).
116    Bare(String),
117    /// A concrete subpath (`name/<subpath>`) → a target relative path.
118    Subpath { subpath: String, target: String },
119    /// A subpath *pattern* (`"./…/*"` export). `subpath` is the prefix before `*`
120    /// (e.g. `"helpers/"` or `""`), `dir` the target directory before `*` (e.g.
121    /// `"dist/"`).
122    Prefix { subpath: String, dir: String },
123}
124
125/// Browser-favoring conditional-`exports` resolver over a parsed `package.json`.
126///
127/// Resolves the bare entry and subpaths to relative file paths using the
128/// condition order browsers want — `browser` → `module` → `import` → `default`
129/// (never `node`/`require`) — with a `module` → `browser` → `main` fallback when
130/// there is no `exports` field. Enough of the Node resolution algorithm to
131/// generate an ES-module import map; not a general-purpose resolver.
132#[derive(Debug, Clone)]
133pub struct PackageJson {
134    raw: Value,
135}
136
137/// Conditions tried, in order, for a browser ES-module import map.
138const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
139
140impl PackageJson {
141    /// Read and parse a `package.json` from disk.
142    pub fn from_path(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
143        Self::from_json(&fs::read_to_string(path)?)
144    }
145
146    /// Parse a `package.json` from a JSON string (e.g. read out of a tarball).
147    pub fn from_json(s: &str) -> Result<Self, Box<dyn std::error::Error>> {
148        Ok(Self::from_value(serde_json::from_str(s)?))
149    }
150
151    /// Wrap an already-parsed JSON document.
152    pub fn from_value(raw: Value) -> Self {
153        Self { raw }
154    }
155
156    /// The `"name"` field, if present.
157    pub fn name(&self) -> Option<&str> {
158        self.raw.get("name").and_then(Value::as_str)
159    }
160
161    /// The `"version"` field, if present.
162    pub fn version(&self) -> Option<&str> {
163        self.raw.get("version").and_then(Value::as_str)
164    }
165
166    /// `Module` when `"type": "module"`, else `CommonJs` (Node's default).
167    pub fn package_type(&self) -> PackageType {
168        match self.raw.get("type").and_then(Value::as_str) {
169            Some("module") => PackageType::Module,
170            _ => PackageType::CommonJs,
171        }
172    }
173
174    /// Resolve the bare entry (the `.` export) to a relative path, for the browser.
175    pub fn resolve_main(&self) -> Option<String> {
176        if let Some(exports) = self.raw.get("exports") {
177            if let Some(s) = exports.as_str() {
178                return safe_target(s);
179            }
180            if let Some(obj) = exports.as_object() {
181                return if is_subpath_map(obj) {
182                    obj.get(".")
183                        .and_then(select_condition)
184                        .and_then(|s| safe_target(&s))
185                } else {
186                    select_condition(exports).and_then(|s| safe_target(&s))
187                };
188            }
189        }
190        // No usable `exports`: fall back to module → browser → main.
191        if let Some(s) = self.raw.get("module").and_then(Value::as_str) {
192            return safe_target(s);
193        }
194        if let Some(browser) = self.raw.get("browser") {
195            if let Some(s) = browser.as_str() {
196                return safe_target(s);
197            }
198            if let (Some(map), Some(main)) = (
199                browser.as_object(),
200                self.raw.get("main").and_then(Value::as_str),
201            ) {
202                let main = safe_target(main)?;
203                for (key, value) in map {
204                    if safe_target(key).as_deref() == Some(main.as_str()) {
205                        if let Some(s) = value.as_str() {
206                            return safe_target(s);
207                        }
208                    }
209                }
210            }
211        }
212        self.raw
213            .get("main")
214            .and_then(Value::as_str)
215            .and_then(safe_target)
216    }
217
218    /// Resolve a subpath (e.g. `"./helpers/decorate"`; leading `./` optional) via
219    /// the `exports` map — exact key first, then the longest `"./…/*"` pattern.
220    pub fn resolve_subpath(&self, subpath: &str) -> Option<String> {
221        let key = normalize_subpath_key(subpath);
222        let exports = self.raw.get("exports")?.as_object()?;
223        if !is_subpath_map(exports) {
224            return None;
225        }
226        if let Some(value) = exports.get(&key) {
227            return select_condition(value).and_then(|s| safe_target(&s));
228        }
229        let mut best_len = 0usize;
230        let mut best: Option<String> = None;
231        for (pattern, value) in exports {
232            let Some(star) = pattern.find('*') else {
233                continue;
234            };
235            let (prefix, suffix) = (&pattern[..star], &pattern[star + 1..]);
236            if key.len() >= prefix.len() + suffix.len()
237                && key.starts_with(prefix)
238                && key.ends_with(suffix)
239            {
240                let matched = &key[prefix.len()..key.len() - suffix.len()];
241                if let Some(target) = select_condition(value) {
242                    if let Some(resolved) = safe_target(&target.replace('*', matched)) {
243                        if best.is_none() || prefix.len() > best_len {
244                            best_len = prefix.len();
245                            best = Some(resolved);
246                        }
247                    }
248                }
249            }
250        }
251        best
252    }
253
254    /// Enumerate the import-map-worthy entries: the bare entry, concrete subpaths,
255    /// and `"./*"`-pattern prefixes.
256    pub fn entries(&self) -> Vec<Entry> {
257        let mut entries = Vec::new();
258        match self.raw.get("exports") {
259            Some(Value::Object(obj)) if is_subpath_map(obj) => {
260                for (key, value) in obj {
261                    if key == "." {
262                        if let Some(t) = select_condition(value).and_then(|s| safe_target(&s)) {
263                            entries.push(Entry::Bare(t));
264                        }
265                    } else if let Some(sub) = key.strip_prefix("./") {
266                        if let Some(star) = sub.find('*') {
267                            if let Some(dir) = select_condition(value).and_then(|t| target_dir(&t))
268                            {
269                                entries.push(Entry::Prefix {
270                                    subpath: sub[..star].to_string(),
271                                    dir,
272                                });
273                            }
274                        } else if let Some(t) =
275                            select_condition(value).and_then(|s| safe_target(&s))
276                        {
277                            entries.push(Entry::Subpath {
278                                subpath: sub.to_string(),
279                                target: t,
280                            });
281                        }
282                    }
283                }
284            }
285            // exports as a string or a pure conditions object, or no exports:
286            // only the bare entry (via resolve_main's logic + fallbacks).
287            _ => {
288                if let Some(t) = self.resolve_main() {
289                    entries.push(Entry::Bare(t));
290                }
291            }
292        }
293        entries
294    }
295
296    /// Every relative path the resolution references (concrete targets + pattern
297    /// directories) — used to keep the right files when vendoring, even under `src/`.
298    pub fn referenced_paths(&self) -> Vec<String> {
299        self.entries()
300            .into_iter()
301            .map(|e| match e {
302                Entry::Bare(t) | Entry::Subpath { target: t, .. } => t,
303                Entry::Prefix { dir, .. } => dir,
304            })
305            .collect()
306    }
307}
308
309/// Whether an `exports` object is a subpath map (keys like `"."`, `"./x"`) rather
310/// than a bare conditions map (keys like `"import"`, `"default"`).
311fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
312    obj.keys().any(|k| k.starts_with('.'))
313}
314
315/// Pick the first target matching the browser condition order, recursing into
316/// nested condition objects and `exports` arrays (ordered fallbacks).
317fn select_condition(node: &Value) -> Option<String> {
318    match node {
319        Value::String(s) => Some(s.clone()),
320        Value::Array(arr) => arr.iter().find_map(select_condition),
321        Value::Object(map) => BROWSER_CONDITIONS
322            .iter()
323            .find_map(|cond| map.get(*cond).and_then(select_condition)),
324        _ => None,
325    }
326}
327
328/// Normalize a target: strip a leading `./`, reject `..`/empty (path traversal).
329fn safe_target(s: &str) -> Option<String> {
330    let t = s.strip_prefix("./").unwrap_or(s).trim_start_matches('/');
331    if t.is_empty() || t.split('/').any(|seg| seg == "..") {
332        return None;
333    }
334    Some(t.to_string())
335}
336
337/// `"./helpers/foo"` / `"helpers/foo"` → the canonical `"./helpers/foo"` key.
338fn normalize_subpath_key(subpath: &str) -> String {
339    if subpath.starts_with("./") {
340        subpath.to_string()
341    } else {
342        format!("./{}", subpath.trim_start_matches('/'))
343    }
344}
345
346/// The directory portion of a pattern target before `*` (e.g. `"./dist/*.js"` →
347/// `"dist/"`, `"./*.js"` → `""`). `None` if it would escape.
348fn target_dir(target: &str) -> Option<String> {
349    let star = target.find('*')?;
350    let before = target[..star].strip_prefix("./").unwrap_or(&target[..star]);
351    if before.split('/').any(|seg| seg == "..") {
352        return None;
353    }
354    Some(before.trim_start_matches('/').to_string())
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use tempfile::tempdir;
361
362    #[test]
363    fn parses_pinned_caret_and_git_specs() {
364        let tmp = tempdir().unwrap();
365        let p = tmp.path().join("package.json");
366        fs::write(
367            &p,
368            r#"{ "dependencies": {
369                "lit": "3.3.3",
370                "bootstrap": "^5.3.8",
371                "forked": "github:owner/repo#abc123"
372            } }"#,
373        )
374        .unwrap();
375
376        let deps = parse_dependencies(&p).unwrap();
377        assert_eq!(deps["lit"].version, "3.3.3");
378        assert!(!deps["lit"].is_git);
379        assert_eq!(deps["bootstrap"].version, "5.3.8");
380        assert_eq!(deps["forked"].version, "abc123");
381        assert!(deps["forked"].is_git);
382    }
383
384    #[test]
385    fn resolve_main_from_exports_and_fallbacks() {
386        // exports."." with conditions -> default.
387        let a = PackageJson::from_json(
388            r#"{"exports":{".":{"types":"./dev.d.ts","default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
389        )
390        .unwrap();
391        assert_eq!(a.resolve_main().as_deref(), Some("index.js"));
392        assert_eq!(
393            a.resolve_subpath("./decorators.js").as_deref(),
394            Some("decorators.js")
395        );
396
397        // nested browser condition map under ".".
398        let b = PackageJson::from_json(
399            r#"{"type":"module","exports":{".":{"browser":{"development":"./development/lit-html.js","default":"./lit-html.js"},"default":"./lit-html.js"}}}"#,
400        )
401        .unwrap();
402        assert_eq!(b.resolve_main().as_deref(), Some("lit-html.js"));
403
404        // no exports -> module wins over main.
405        let c = PackageJson::from_json(
406            r#"{"main":"dist/js/bootstrap.js","module":"dist/js/bootstrap.esm.js"}"#,
407        )
408        .unwrap();
409        assert_eq!(
410            c.resolve_main().as_deref(),
411            Some("dist/js/bootstrap.esm.js")
412        );
413    }
414
415    #[test]
416    fn resolve_subpath_picks_import_condition_for_cjs_package() {
417        // CommonJS package, no ".", helper subpaths whose "import" condition is the
418        // ESM build under src/helpers/esm/.
419        let rt = PackageJson::from_json(
420            r#"{"type":"commonjs","exports":{"./helpers/decorate":[{"node":"./src/helpers/decorate.js","import":"./src/helpers/esm/decorate.js","default":"./src/helpers/decorate.js"}]}}"#,
421        )
422        .unwrap();
423        assert_eq!(rt.package_type(), PackageType::CommonJs);
424        assert!(rt.resolve_main().is_none());
425        assert_eq!(
426            rt.resolve_subpath("./helpers/decorate").as_deref(),
427            Some("src/helpers/esm/decorate.js")
428        );
429        assert_eq!(
430            rt.resolve_subpath("helpers/decorate").as_deref(),
431            Some("src/helpers/esm/decorate.js")
432        );
433        assert!(rt
434            .referenced_paths()
435            .iter()
436            .any(|p| p == "src/helpers/esm/decorate.js"));
437    }
438
439    #[test]
440    fn condition_order_prefers_browser_and_import_never_node() {
441        let x = PackageJson::from_json(
442            r#"{"exports":{".":{"node":"./n.js","require":"./r.js","import":"./esm.js","default":"./def.js"}}}"#,
443        )
444        .unwrap();
445        assert_eq!(x.resolve_main().as_deref(), Some("esm.js"));
446
447        let y = PackageJson::from_json(
448            r#"{"exports":{".":{"module":"./m.js","browser":"./b.js","default":"./d.js"}}}"#,
449        )
450        .unwrap();
451        assert_eq!(y.resolve_main().as_deref(), Some("b.js"));
452    }
453
454    #[test]
455    fn subpath_pattern_becomes_prefix_entry() {
456        let pkg = PackageJson::from_json(r#"{"exports":{".":"./index.js","./*":"./dist/*.js"}}"#)
457            .unwrap();
458        assert_eq!(pkg.resolve_subpath("./foo").as_deref(), Some("dist/foo.js"));
459        assert!(pkg.entries().iter().any(
460            |e| matches!(e, Entry::Prefix { subpath, dir } if subpath.is_empty() && dir == "dist/")
461        ));
462        assert!(pkg
463            .entries()
464            .iter()
465            .any(|e| matches!(e, Entry::Bare(t) if t == "index.js")));
466    }
467
468    #[test]
469    fn rejects_path_traversal_targets() {
470        let evil = PackageJson::from_json(r#"{"exports":{".":"../escape.js"}}"#).unwrap();
471        assert!(evil.resolve_main().is_none());
472    }
473}