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 / set a field /
21//!   upsert a dependency) for the CLI's `init`/`add` and for assembling a publishable manifest.
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/// Normalize a `license` declaration to a single SPDX-ish string. Handles npm's modern `license`
33/// string, the legacy `{ "type": … }` object, and the legacy `licenses: [{ "type": … }]` array
34/// (joined with `" OR "`); `None` when none is declared. Shared by the registry (reading a
35/// packument) and the [`License`] readers (a manifest or a lockfile entry).
36pub(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
58/// Programmatic access to a declared license, from either a parsed `package.json`
59/// ([`PackageJson`]) or a parsed lockfile entry ([`lock::LockedPackage`]). Lets a consumer source
60/// a package's license from whichever it has — the lockfile when it records one, the manifest
61/// otherwise.
62pub trait License {
63    /// The declared SPDX-ish license string, if any.
64    fn license(&self) -> Option<String>;
65}
66
67/// A dependency parsed from a `package.json` `dependencies` map.
68#[derive(Debug, Clone)]
69pub struct Dependency {
70    pub name: String,
71    pub version: String,
72    /// True when the spec points at a git/GitHub source rather than a registry
73    /// version (e.g. `github:owner/repo#ref`).
74    pub is_git: bool,
75}
76
77/// Parse the `dependencies` section of a `package.json`.
78pub 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
110/// Reject npm package names whose characters could escape a path or URL — a path-safety allowlist,
111/// not a spec validator. Allowed: ASCII alphanumerics plus `.`, `_`, `-`, `@`, and `/` (scoped);
112/// empty, over-long, and any `..` are rejected. So is any empty `/`-separated segment — a leading
113/// `/` would make `Path::join(name)` replace its base with an absolute path, and a trailing or
114/// doubled `/` smuggles empty components into layouts that assume a name is one or two segments —
115/// and the exact name `.`, which addresses a directory rather than a package. Case is
116/// intentionally *not* restricted: npm steers
117/// new packages to lowercase, but the registry still hosts legacy mixed-case names, and a truly
118/// invalid name simply 404s — enforcing case here would only reject valid installs. Anything
119/// outside the allowlist is a typo or a crafted entry meant to traverse a path later — fail loudly.
120/// `pub(crate)` so the CLI's source grammar screens spec names through the same allowlist.
121pub(crate) fn validate_package_name(
122    name: &str,
123) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
124    if name.is_empty() || name.len() > 200 {
125        return Err(format!("package name {name:?} has invalid length").into());
126    }
127    if name.contains("..") {
128        return Err(format!("package name {name:?} contains '..'").into());
129    }
130    if name == "." || name.split('/').any(|segment| segment.is_empty()) {
131        return Err(format!("package name {name:?} is not a relative name").into());
132    }
133    if !name
134        .bytes()
135        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'@' | b'/'))
136    {
137        return Err(format!("package name {name:?} contains disallowed characters").into());
138    }
139    Ok(())
140}
141
142/// Reject versions outside the semver-adjacent alphabet, before the value ends
143/// up in a URL, a cache filename, or a marker — none of which should contain a
144/// path separator.
145fn validate_version(version: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
146    if version.is_empty() || version.len() > 100 {
147        return Err(format!("version {version:?} has invalid length").into());
148    }
149    if version.contains("..") {
150        return Err(format!("version {version:?} contains '..'").into());
151    }
152    if !version
153        .bytes()
154        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
155    {
156        return Err(format!("version {version:?} contains disallowed characters").into());
157    }
158    Ok(())
159}
160
161/// Extract a bare version from a spec string. Handles `"1.2.3"`, `"^1.2.3"`,
162/// `"~1.2.3"`, and git URLs (`"...#ref"` → `ref`).
163fn extract_version(value: &str) -> String {
164    if value.contains("github.com") || value.starts_with("git") {
165        if let Some(hash_pos) = value.rfind('#') {
166            return value[hash_pos + 1..].to_string();
167        }
168    }
169    value
170        .trim_start_matches('^')
171        .trim_start_matches('~')
172        .to_string()
173}
174
175/// The `"type"` field of a `package.json` (Node defaults to CommonJS).
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum PackageType {
178    Module,
179    CommonJs,
180}
181
182/// An import-map-worthy entry derived from a package's `package.json`.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub enum Entry {
185    /// The bare specifier (`name`) → a target relative path (the `.` export).
186    Bare(String),
187    /// A concrete subpath (`name/<subpath>`) → a target relative path.
188    Subpath { subpath: String, target: String },
189    /// A subpath *pattern* (`"./…/*"` export). `subpath` is the prefix before `*`
190    /// (e.g. `"helpers/"` or `""`), `dir` the target directory before `*` (e.g.
191    /// `"dist/"`).
192    Prefix { subpath: String, dir: String },
193}
194
195/// Browser-favoring conditional-`exports` resolver over a parsed `package.json`.
196///
197/// Resolves the bare entry and subpaths to relative file paths using the
198/// condition order browsers want — `browser` → `module` → `import` → `default`
199/// (never `node`/`require`) — with a `module` → `browser` → `main` fallback when
200/// there is no `exports` field. Enough of the Node resolution algorithm to
201/// generate an ES-module import map; not a general-purpose resolver.
202#[derive(Debug, Clone)]
203pub struct PackageJson {
204    raw: Value,
205}
206
207impl License for PackageJson {
208    /// The manifest's declared license (`license` string, or the legacy object / `licenses[]` array).
209    fn license(&self) -> Option<String> {
210        normalize_license(&self.raw)
211    }
212}
213
214/// Conditions tried, in order, for a browser ES-module import map.
215const BROWSER_CONDITIONS: &[&str] = &["browser", "module", "import", "default"];
216
217impl PackageJson {
218    /// Read and parse a `package.json` from disk.
219    pub fn from_path(path: &Path) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
220        Self::from_json(&fs::read_to_string(path)?)
221    }
222
223    /// Parse a `package.json` from a JSON string (e.g. read out of a tarball).
224    pub fn from_json(s: &str) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
225        Ok(Self::from_value(serde_json::from_str(s)?))
226    }
227
228    /// Wrap an already-parsed JSON document.
229    pub fn from_value(raw: Value) -> Self {
230        Self { raw }
231    }
232
233    /// The `"name"` field, if present.
234    pub fn name(&self) -> Option<&str> {
235        self.raw.get("name").and_then(Value::as_str)
236    }
237
238    /// The `"version"` field, if present.
239    pub fn version(&self) -> Option<&str> {
240        self.raw.get("version").and_then(Value::as_str)
241    }
242
243    /// `Module` when `"type": "module"`, else `CommonJs` (Node's default).
244    pub fn package_type(&self) -> PackageType {
245        match self.raw.get("type").and_then(Value::as_str) {
246            Some("module") => PackageType::Module,
247            _ => PackageType::CommonJs,
248        }
249    }
250
251    /// Resolve the bare entry (the `.` export) to a relative path, for the browser.
252    pub fn resolve_main(&self) -> Option<String> {
253        if let Some(exports) = self.raw.get("exports") {
254            if let Some(s) = exports.as_str() {
255                return safe_target(s);
256            }
257            if let Some(obj) = exports.as_object() {
258                return if is_subpath_map(obj) {
259                    obj.get(".")
260                        .and_then(select_condition)
261                        .and_then(|s| safe_target(&s))
262                } else {
263                    select_condition(exports).and_then(|s| safe_target(&s))
264                };
265            }
266        }
267        // No usable `exports`: fall back to module → browser → main.
268        if let Some(s) = self.raw.get("module").and_then(Value::as_str) {
269            return safe_target(s);
270        }
271        if let Some(browser) = self.raw.get("browser") {
272            if let Some(s) = browser.as_str() {
273                return safe_target(s);
274            }
275            if let (Some(map), Some(main)) = (
276                browser.as_object(),
277                self.raw.get("main").and_then(Value::as_str),
278            ) {
279                let main = safe_target(main)?;
280                for (key, value) in map {
281                    if safe_target(key).as_deref() == Some(main.as_str()) {
282                        if let Some(s) = value.as_str() {
283                            return safe_target(s);
284                        }
285                    }
286                }
287            }
288        }
289        self.raw
290            .get("main")
291            .and_then(Value::as_str)
292            .and_then(safe_target)
293    }
294
295    /// Resolve a subpath (e.g. `"./helpers/decorate"`; leading `./` optional) via
296    /// the `exports` map — exact key first, then the longest `"./…/*"` pattern.
297    pub fn resolve_subpath(&self, subpath: &str) -> Option<String> {
298        let key = normalize_subpath_key(subpath);
299        let exports = self.raw.get("exports")?.as_object()?;
300        if !is_subpath_map(exports) {
301            return None;
302        }
303        if let Some(value) = exports.get(&key) {
304            return select_condition(value).and_then(|s| safe_target(&s));
305        }
306        let mut best_len = 0usize;
307        let mut best: Option<String> = None;
308        for (pattern, value) in exports {
309            let Some(star) = pattern.find('*') else {
310                continue;
311            };
312            let (prefix, suffix) = (&pattern[..star], &pattern[star + 1..]);
313            if key.len() >= prefix.len() + suffix.len()
314                && key.starts_with(prefix)
315                && key.ends_with(suffix)
316            {
317                let matched = &key[prefix.len()..key.len() - suffix.len()];
318                if let Some(target) = select_condition(value) {
319                    if let Some(resolved) = safe_target(&target.replace('*', matched)) {
320                        if best.is_none() || prefix.len() > best_len {
321                            best_len = prefix.len();
322                            best = Some(resolved);
323                        }
324                    }
325                }
326            }
327        }
328        best
329    }
330
331    /// Whether the manifest declares an `exports` field. Per Node, `exports` *gates*
332    /// access: only the subpaths it maps are reachable, so a caller resolving an
333    /// arbitrary file must treat an unmatched subpath as refused
334    /// (`ERR_PACKAGE_PATH_NOT_EXPORTED`) rather than reaching past it.
335    pub fn has_exports(&self) -> bool {
336        self.raw.get("exports").is_some()
337    }
338
339    /// Resolve an arbitrary file `subpath` (e.g. `"icons/eye.svg"`; a leading `./`
340    /// is optional) to a package-relative path. With an `exports` field, only what
341    /// `exports` maps is reachable (via [`resolve_subpath`](Self::resolve_subpath));
342    /// without one, every file is addressable directly (Node's behavior for a
343    /// package that declares no `exports`), so the normalized subpath is returned.
344    /// `None` when `exports` gates the subpath out, or the path would escape the
345    /// package.
346    pub fn resolve_asset(&self, subpath: &str) -> Option<String> {
347        if self.has_exports() {
348            self.resolve_subpath(subpath)
349        } else {
350            safe_target(subpath)
351        }
352    }
353
354    /// Enumerate the import-map-worthy entries: the bare entry, concrete subpaths,
355    /// and `"./*"`-pattern prefixes.
356    pub fn entries(&self) -> Vec<Entry> {
357        let mut entries = Vec::new();
358        match self.raw.get("exports") {
359            Some(Value::Object(obj)) if is_subpath_map(obj) => {
360                for (key, value) in obj {
361                    if key == "." {
362                        if let Some(t) = select_condition(value).and_then(|s| safe_target(&s)) {
363                            entries.push(Entry::Bare(t));
364                        }
365                    } else if let Some(sub) = key.strip_prefix("./") {
366                        if let Some(star) = sub.find('*') {
367                            if let Some(dir) = select_condition(value).and_then(|t| target_dir(&t))
368                            {
369                                entries.push(Entry::Prefix {
370                                    subpath: sub[..star].to_string(),
371                                    dir,
372                                });
373                            }
374                        } else if let Some(t) =
375                            select_condition(value).and_then(|s| safe_target(&s))
376                        {
377                            entries.push(Entry::Subpath {
378                                subpath: sub.to_string(),
379                                target: t,
380                            });
381                        }
382                    }
383                }
384            }
385            // exports as a string or a pure conditions object, or no exports:
386            // only the bare entry (via resolve_main's logic + fallbacks).
387            _ => {
388                if let Some(t) = self.resolve_main() {
389                    entries.push(Entry::Bare(t));
390                }
391            }
392        }
393        entries
394    }
395
396    /// Every relative path the resolution references (concrete targets + pattern
397    /// directories) — used to keep the right files when vendoring, even under `src/`.
398    pub fn referenced_paths(&self) -> Vec<String> {
399        self.entries()
400            .into_iter()
401            .map(|e| match e {
402                Entry::Bare(t) | Entry::Subpath { target: t, .. } => t,
403                Entry::Prefix { dir, .. } => dir,
404            })
405            .collect()
406    }
407}
408
409/// Whether an `exports` object is a subpath map (keys like `"."`, `"./x"`) rather
410/// than a bare conditions map (keys like `"import"`, `"default"`).
411fn is_subpath_map(obj: &serde_json::Map<String, Value>) -> bool {
412    obj.keys().any(|k| k.starts_with('.'))
413}
414
415/// Pick the first target matching the browser condition order, recursing into
416/// nested condition objects and `exports` arrays (ordered fallbacks).
417fn select_condition(node: &Value) -> Option<String> {
418    match node {
419        Value::String(s) => Some(s.clone()),
420        Value::Array(arr) => arr.iter().find_map(select_condition),
421        Value::Object(map) => BROWSER_CONDITIONS
422            .iter()
423            .find_map(|cond| map.get(*cond).and_then(select_condition)),
424        _ => None,
425    }
426}
427
428/// Normalize a target: strip a leading `./`, reject `..`/empty (path traversal).
429fn safe_target(s: &str) -> Option<String> {
430    let t = s.strip_prefix("./").unwrap_or(s).trim_start_matches('/');
431    if t.is_empty() || t.split('/').any(|seg| seg == "..") {
432        return None;
433    }
434    Some(t.to_string())
435}
436
437/// `"./helpers/foo"` / `"helpers/foo"` → the canonical `"./helpers/foo"` key.
438fn normalize_subpath_key(subpath: &str) -> String {
439    if subpath.starts_with("./") {
440        subpath.to_string()
441    } else {
442        format!("./{}", subpath.trim_start_matches('/'))
443    }
444}
445
446/// The directory portion of a pattern target before `*` (e.g. `"./dist/*.js"` →
447/// `"dist/"`, `"./*.js"` → `""`). `None` if it would escape.
448fn target_dir(target: &str) -> Option<String> {
449    let star = target.find('*')?;
450    let before = target[..star].strip_prefix("./").unwrap_or(&target[..star]);
451    if before.split('/').any(|seg| seg == "..") {
452        return None;
453    }
454    Some(before.trim_start_matches('/').to_string())
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use tempfile::tempdir;
461
462    #[test]
463    fn package_names_are_confined_to_relative_segments() {
464        for valid in ["lit", "@scope/pkg", "MixedCase", "a.b-c_d"] {
465            assert!(validate_package_name(valid).is_ok(), "{valid:?}");
466        }
467        // A leading `/` joins as an absolute path; `.`/`..` and empty segments address
468        // directories rather than packages.
469        for invalid in [
470            "/etc",
471            "/",
472            ".",
473            "..",
474            "@scope/",
475            "@//pkg",
476            "a//b",
477            "pkg/",
478            "../x",
479            "a..b/../c",
480        ] {
481            assert!(validate_package_name(invalid).is_err(), "{invalid:?}");
482        }
483    }
484
485    #[test]
486    fn parses_pinned_caret_and_git_specs() {
487        let tmp = tempdir().unwrap();
488        let p = tmp.path().join("package.json");
489        fs::write(
490            &p,
491            r#"{ "dependencies": {
492                "lit": "3.3.3",
493                "bootstrap": "^5.3.8",
494                "forked": "github:owner/repo#abc123"
495            } }"#,
496        )
497        .unwrap();
498
499        let deps = parse_dependencies(&p).unwrap();
500        assert_eq!(deps["lit"].version, "3.3.3");
501        assert!(!deps["lit"].is_git);
502        assert_eq!(deps["bootstrap"].version, "5.3.8");
503        assert_eq!(deps["forked"].version, "abc123");
504        assert!(deps["forked"].is_git);
505    }
506
507    #[test]
508    fn resolve_main_from_exports_and_fallbacks() {
509        // exports."." with conditions -> default.
510        let a = PackageJson::from_json(
511            r#"{"exports":{".":{"types":"./dev.d.ts","default":"./index.js"},"./decorators.js":{"default":"./decorators.js"}}}"#,
512        )
513        .unwrap();
514        assert_eq!(a.resolve_main().as_deref(), Some("index.js"));
515        assert_eq!(
516            a.resolve_subpath("./decorators.js").as_deref(),
517            Some("decorators.js")
518        );
519
520        // nested browser condition map under ".".
521        let b = PackageJson::from_json(
522            r#"{"type":"module","exports":{".":{"browser":{"development":"./development/lit-html.js","default":"./lit-html.js"},"default":"./lit-html.js"}}}"#,
523        )
524        .unwrap();
525        assert_eq!(b.resolve_main().as_deref(), Some("lit-html.js"));
526
527        // no exports -> module wins over main.
528        let c = PackageJson::from_json(
529            r#"{"main":"dist/js/bootstrap.js","module":"dist/js/bootstrap.esm.js"}"#,
530        )
531        .unwrap();
532        assert_eq!(
533            c.resolve_main().as_deref(),
534            Some("dist/js/bootstrap.esm.js")
535        );
536    }
537
538    #[test]
539    fn resolve_subpath_picks_import_condition_for_cjs_package() {
540        // CommonJS package, no ".", helper subpaths whose "import" condition is the
541        // ESM build under src/helpers/esm/.
542        let rt = PackageJson::from_json(
543            r#"{"type":"commonjs","exports":{"./helpers/decorate":[{"node":"./src/helpers/decorate.js","import":"./src/helpers/esm/decorate.js","default":"./src/helpers/decorate.js"}]}}"#,
544        )
545        .unwrap();
546        assert_eq!(rt.package_type(), PackageType::CommonJs);
547        assert!(rt.resolve_main().is_none());
548        assert_eq!(
549            rt.resolve_subpath("./helpers/decorate").as_deref(),
550            Some("src/helpers/esm/decorate.js")
551        );
552        assert_eq!(
553            rt.resolve_subpath("helpers/decorate").as_deref(),
554            Some("src/helpers/esm/decorate.js")
555        );
556        assert!(rt
557            .referenced_paths()
558            .iter()
559            .any(|p| p == "src/helpers/esm/decorate.js"));
560    }
561
562    #[test]
563    fn condition_order_prefers_browser_and_import_never_node() {
564        let x = PackageJson::from_json(
565            r#"{"exports":{".":{"node":"./n.js","require":"./r.js","import":"./esm.js","default":"./def.js"}}}"#,
566        )
567        .unwrap();
568        assert_eq!(x.resolve_main().as_deref(), Some("esm.js"));
569
570        let y = PackageJson::from_json(
571            r#"{"exports":{".":{"module":"./m.js","browser":"./b.js","default":"./d.js"}}}"#,
572        )
573        .unwrap();
574        assert_eq!(y.resolve_main().as_deref(), Some("b.js"));
575    }
576
577    #[test]
578    fn subpath_pattern_becomes_prefix_entry() {
579        let pkg = PackageJson::from_json(r#"{"exports":{".":"./index.js","./*":"./dist/*.js"}}"#)
580            .unwrap();
581        assert_eq!(pkg.resolve_subpath("./foo").as_deref(), Some("dist/foo.js"));
582        assert!(pkg.entries().iter().any(
583            |e| matches!(e, Entry::Prefix { subpath, dir } if subpath.is_empty() && dir == "dist/")
584        ));
585        assert!(pkg
586            .entries()
587            .iter()
588            .any(|e| matches!(e, Entry::Bare(t) if t == "index.js")));
589    }
590
591    #[test]
592    fn rejects_path_traversal_targets() {
593        let evil = PackageJson::from_json(r#"{"exports":{".":"../escape.js"}}"#).unwrap();
594        assert!(evil.resolve_main().is_none());
595    }
596
597    #[test]
598    fn resolve_asset_gates_on_exports_else_addresses_directly() {
599        // No `exports`: any file is addressable directly (the bootstrap-icons shape).
600        let plain = PackageJson::from_json(r#"{"name":"bootstrap-icons","files":["icons/*.svg"]}"#)
601            .unwrap();
602        assert!(!plain.has_exports());
603        assert_eq!(
604            plain.resolve_asset("icons/eye.svg").as_deref(),
605            Some("icons/eye.svg")
606        );
607        assert_eq!(
608            plain.resolve_asset("./icons/eye.svg").as_deref(),
609            Some("icons/eye.svg")
610        );
611        assert!(plain.resolve_asset("../escape.svg").is_none());
612
613        // With `exports`: only mapped subpaths resolve; anything else is gated out.
614        let gated = PackageJson::from_json(r#"{"exports":{"./icons/*":"./icons/*.svg"}}"#).unwrap();
615        assert!(gated.has_exports());
616        assert_eq!(
617            gated.resolve_asset("icons/eye").as_deref(),
618            Some("icons/eye.svg")
619        );
620        assert!(gated.resolve_asset("secret/keys.json").is_none());
621    }
622}