Skip to main content

npm_utils/package_json/
mod.rs

1//! Pure-Rust npm manifest + lockfile schemas, modeled on the npm specs:
2//!
3//! - <https://docs.npmjs.com/cli/v8/configuring-npm/package-lock-json>
4//! - <https://docs.npmjs.com/cli/v8/using-npm/package-spec>
5//!
6//! A module of `npm-utils` (`npm_utils::package_json`). It parses, resolves, and renders —
7//! manifests and lockfiles as pure `Value`/string transforms — but never writes files, hits the
8//! network, or resolves untrusted paths, which keeps its strict spec-conformance tests pure and
9//! self-contained (the CLI does the file IO).
10//!
11//! Four pieces:
12//!
13//! - this module root: `package.json` — its `dependencies` specs and a browser-favoring
14//!   conditional-`exports` resolver (enough of Node's algorithm to build an ES-module
15//!   import map).
16//! - [`spec`] — the npm "package spec" dependency grammar ([`spec::Spec`]) and
17//!   [`spec::version_req`].
18//! - [`lock`] — `package-lock.json` (v2/v3) parsing into a faithful [`lock::Lockfile`], and
19//!   [`lock::render_v3`] for emitting one.
20//! - [`manifest`] — pure write-side `package.json` transforms (scaffold / upsert a dependency)
21//!   for the CLI's `init`/`add`.
22
23pub 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/// A dependency parsed from a `package.json` `dependencies` map.
33#[derive(Debug, Clone)]
34pub struct Dependency {
35    pub name: String,
36    pub version: String,
37    /// True when the spec points at a git/GitHub source rather than a registry
38    /// version (e.g. `github:owner/repo#ref`).
39    pub is_git: bool,
40}
41
42/// Parse the `dependencies` section of a `package.json`.
43pub 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
75/// Reject npm package names whose characters could escape a path or URL — a path-safety allowlist,
76/// not a spec validator. Allowed: ASCII alphanumerics plus `.`, `_`, `-`, `@`, and `/` (scoped);
77/// empty, over-long, and any `..` are rejected. Case is intentionally *not* restricted: npm steers
78/// new packages to lowercase, but the registry still hosts legacy mixed-case names, and a truly
79/// invalid name simply 404s — enforcing case here would only reject valid installs. Anything
80/// outside the allowlist is a typo or a crafted entry meant to traverse a path later — fail loudly.
81fn 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
97/// Reject versions outside the semver-adjacent alphabet, before the value ends
98/// up in a URL, a cache filename, or a marker — none of which should contain a
99/// path separator.
100fn 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
116/// Extract a bare version from a spec string. Handles `"1.2.3"`, `"^1.2.3"`,
117/// `"~1.2.3"`, and git URLs (`"...#ref"` → `ref`).
118fn 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/// The `"type"` field of a `package.json` (Node defaults to CommonJS).
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum PackageType {
133    Module,
134    CommonJs,
135}
136
137/// An import-map-worthy entry derived from a package's `package.json`.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub enum Entry {
140    /// The bare specifier (`name`) → a target relative path (the `.` export).
141    Bare(String),
142    /// A concrete subpath (`name/<subpath>`) → a target relative path.
143    Subpath { subpath: String, target: String },
144    /// A subpath *pattern* (`"./…/*"` export). `subpath` is the prefix before `*`
145    /// (e.g. `"helpers/"` or `""`), `dir` the target directory before `*` (e.g.
146    /// `"dist/"`).
147    Prefix { subpath: String, dir: String },
148}
149
150/// Browser-favoring conditional-`exports` resolver over a parsed `package.json`.
151///
152/// Resolves the bare entry and subpaths to relative file paths using the
153/// condition order browsers want — `browser` → `module` → `import` → `default`
154/// (never `node`/`require`) — with a `module` → `browser` → `main` fallback when
155/// there is no `exports` field. Enough of the Node resolution algorithm to
156/// generate an ES-module import map; not a general-purpose resolver.
157#[derive(Debug, Clone)]
158pub struct PackageJson {
159    raw: Value,
160}
161
162/// Conditions tried, in order, for a browser ES-module import map.
163const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
164
165impl PackageJson {
166    /// Read and parse a `package.json` from disk.
167    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    /// Parse a `package.json` from a JSON string (e.g. read out of a tarball).
172    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    /// Wrap an already-parsed JSON document.
177    pub fn from_value(raw: Value) -> Self {
178        Self { raw }
179    }
180
181    /// The `"name"` field, if present.
182    pub fn name(&self) -> Option<&str> {
183        self.raw.get("name").and_then(Value::as_str)
184    }
185
186    /// The `"version"` field, if present.
187    pub fn version(&self) -> Option<&str> {
188        self.raw.get("version").and_then(Value::as_str)
189    }
190
191    /// `Module` when `"type": "module"`, else `CommonJs` (Node's default).
192    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    /// Resolve the bare entry (the `.` export) to a relative path, for the browser.
200    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        // No usable `exports`: fall back to module → browser → main.
216        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    /// Resolve a subpath (e.g. `"./helpers/decorate"`; leading `./` optional) via
244    /// the `exports` map — exact key first, then the longest `"./…/*"` pattern.
245    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    /// Enumerate the import-map-worthy entries: the bare entry, concrete subpaths,
280    /// and `"./*"`-pattern prefixes.
281    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            // exports as a string or a pure conditions object, or no exports:
311            // only the bare entry (via resolve_main's logic + fallbacks).
312            _ => {
313                if let Some(t) = self.resolve_main() {
314                    entries.push(Entry::Bare(t));
315                }
316            }
317        }
318        entries
319    }
320
321    /// Every relative path the resolution references (concrete targets + pattern
322    /// directories) — used to keep the right files when vendoring, even under `src/`.
323    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
334/// Whether an `exports` object is a subpath map (keys like `"."`, `"./x"`) rather
335/// than a bare conditions map (keys like `"import"`, `"default"`).
336fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
337    obj.keys().any(|k| k.starts_with('.'))
338}
339
340/// Pick the first target matching the browser condition order, recursing into
341/// nested condition objects and `exports` arrays (ordered fallbacks).
342fn 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
353/// Normalize a target: strip a leading `./`, reject `..`/empty (path traversal).
354fn 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
362/// `"./helpers/foo"` / `"helpers/foo"` → the canonical `"./helpers/foo"` key.
363fn 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
371/// The directory portion of a pattern target before `*` (e.g. `"./dist/*.js"` →
372/// `"dist/"`, `"./*.js"` → `""`). `None` if it would escape.
373fn 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        // exports."." with conditions -> default.
412        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        // nested browser condition map under ".".
423        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        // no exports -> module wins over main.
430        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        // CommonJS package, no ".", helper subpaths whose "import" condition is the
443        // ESM build under src/helpers/esm/.
444        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}