Skip to main content

npm_utils/
resolve.rs

1//! Locate files inside an installed dependency under `node_modules/`.
2//!
3//! [`package_dir`] finds `node_modules/<name>` by walking up from a starting
4//! directory, the way Node resolves a bare specifier; [`package_file`] then maps a
5//! `<name>/<subpath>` reference to the real file on disk, honoring the package's
6//! `exports` (and its encapsulation) when declared and addressing the file directly
7//! when not. Every result is confined to the resolved package directory via
8//! [`crate::path_safety`] — nothing here writes, fetches over the network, or
9//! resolves a reference outside the package it located.
10
11use std::path::{Path, PathBuf};
12
13use crate::package_json::{validate_package_name, PackageJson};
14use crate::path_safety::safe_join;
15use crate::Result;
16
17/// Find an installed package by walking `node_modules/<name>` upward from `from_dir`
18/// (Node's module-resolution order), returning the first directory that exists.
19/// `name` may be scoped (`@scope/pkg`). Errors when the package is installed nowhere
20/// on the way up — run `npm install` / `web-modules ci` first.
21pub fn package_dir(from_dir: &Path, name: &str) -> Result<PathBuf> {
22    validate_package_name(name)?;
23    let mut cursor = Some(from_dir);
24    while let Some(dir) = cursor {
25        let candidate = dir.join("node_modules").join(name);
26        if candidate.is_dir() {
27            return Ok(candidate);
28        }
29        cursor = dir.parent();
30    }
31    Err(format!(
32        "package {name:?} is not installed in any node_modules above {}",
33        from_dir.display()
34    )
35    .into())
36}
37
38/// Resolve `<name>/<subpath>` to a real file inside the installed package.
39///
40/// When the package declares `exports`, only the subpaths it maps resolve — one it
41/// does not is refused, mirroring Node's `ERR_PACKAGE_PATH_NOT_EXPORTED`; otherwise
42/// the file is addressed directly (Node's behavior for a package without `exports`,
43/// e.g. `bootstrap-icons`). The result is canonicalized and guaranteed to sit inside the
44/// package's real directory — an in-package symlink resolving outside it is refused.
45pub fn package_file(from_dir: &Path, name: &str, subpath: &str) -> Result<PathBuf> {
46    let dir = package_dir(from_dir, name)?;
47    let manifest = dir.join("package.json");
48    let relative = if manifest.is_file() {
49        let package = PackageJson::from_path(&manifest)?;
50        match package.resolve_asset(subpath) {
51            Some(relative) => relative,
52            None if package.has_exports() => {
53                return Err(format!(
54                    "{name}/{subpath}: not exported by {name} \
55                     (its package.json `exports` does not map this subpath)"
56                )
57                .into())
58            }
59            None => {
60                return Err(format!("{name}/{subpath}: refuses an unsafe or empty subpath").into())
61            }
62        }
63    } else {
64        // A package without a manifest is unusual; address the file directly, with
65        // path-safety applied by `safe_join` below.
66        subpath
67            .strip_prefix("./")
68            .unwrap_or(subpath)
69            .trim_start_matches('/')
70            .to_string()
71    };
72    let candidate = safe_join(&dir, &relative)?;
73    // Resolve through the package's real on-disk location and require the result to stay
74    // inside it. `subpath` is attacker-influenced (an `npm://` symlink target, or a
75    // dependency's own layout), and a symlink *inside* the package could otherwise
76    // redirect the read outside it — so containment is enforced on the canonical path,
77    // not on the joined string `safe_join` only checks structurally.
78    let package_root = dir.canonicalize()?;
79    let real = candidate
80        .canonicalize()
81        .map_err(|e| -> crate::Error { format!("{name}/{subpath}: {e}").into() })?;
82    if !real.starts_with(&package_root) {
83        return Err(format!("{name}/{subpath}: resolves outside {name}").into());
84    }
85    if !real.is_file() {
86        return Err(format!("{name}/{subpath}: not a file in the package").into());
87    }
88    Ok(real)
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::fs;
95    use tempfile::tempdir;
96
97    /// Lay out `<root>/node_modules/<name>/` with a `package.json` (`manifest`) and a
98    /// set of `(relative-path, contents)` files.
99    fn install(root: &Path, name: &str, manifest: &str, files: &[(&str, &str)]) {
100        let pkg = root.join("node_modules").join(name);
101        fs::create_dir_all(&pkg).unwrap();
102        fs::write(pkg.join("package.json"), manifest).unwrap();
103        for (rel, body) in files {
104            let path = pkg.join(rel);
105            fs::create_dir_all(path.parent().unwrap()).unwrap();
106            fs::write(path, body).unwrap();
107        }
108    }
109
110    #[test]
111    fn package_dir_walks_up_to_node_modules() {
112        let tmp = tempdir().unwrap();
113        install(
114            tmp.path(),
115            "bootstrap-icons",
116            r#"{"name":"bootstrap-icons"}"#,
117            &[],
118        );
119        let deep = tmp.path().join("web").join("icons").join("bi");
120        fs::create_dir_all(&deep).unwrap();
121        assert_eq!(
122            package_dir(&deep, "bootstrap-icons").unwrap(),
123            tmp.path().join("node_modules").join("bootstrap-icons")
124        );
125        assert!(package_dir(&deep, "not-installed").is_err());
126    }
127
128    #[test]
129    fn package_file_addresses_directly_without_exports() {
130        let tmp = tempdir().unwrap();
131        install(
132            tmp.path(),
133            "bootstrap-icons",
134            r#"{"name":"bootstrap-icons","files":["icons/*.svg"]}"#,
135            &[("icons/eye.svg", "<svg/>")],
136        );
137        let start = tmp.path().join("web");
138        fs::create_dir_all(&start).unwrap();
139        let file = package_file(&start, "bootstrap-icons", "icons/eye.svg").unwrap();
140        assert_eq!(fs::read_to_string(&file).unwrap(), "<svg/>");
141        // A file the package does not contain errors rather than returning a missing path.
142        assert!(package_file(&start, "bootstrap-icons", "icons/missing.svg").is_err());
143        // Traversal is refused.
144        assert!(package_file(&start, "bootstrap-icons", "../escape.svg").is_err());
145    }
146
147    #[test]
148    fn package_file_honors_exports_encapsulation() {
149        let tmp = tempdir().unwrap();
150        install(
151            tmp.path(),
152            "guarded",
153            r#"{"exports":{"./icons/*":"./dist/icons/*.svg"}}"#,
154            &[("dist/icons/eye.svg", "<svg/>"), ("secret.txt", "nope")],
155        );
156        let file = package_file(tmp.path(), "guarded", "icons/eye").unwrap();
157        assert!(file.ends_with("dist/icons/eye.svg"));
158        // Present on disk but outside `exports` → refused.
159        assert!(package_file(tmp.path(), "guarded", "secret.txt").is_err());
160    }
161
162    #[test]
163    fn scoped_package_resolves() {
164        let tmp = tempdir().unwrap();
165        install(
166            tmp.path(),
167            "@scope/pkg",
168            r#"{"name":"@scope/pkg"}"#,
169            &[("a/b.svg", "x")],
170        );
171        let file = package_file(tmp.path(), "@scope/pkg", "a/b.svg").unwrap();
172        assert_eq!(fs::read_to_string(file).unwrap(), "x");
173    }
174
175    #[cfg(unix)]
176    #[test]
177    fn package_file_refuses_an_in_package_symlink_that_escapes() {
178        use std::os::unix::fs::symlink;
179        let tmp = tempdir().unwrap();
180        install(
181            tmp.path(),
182            "pkg",
183            r#"{"name":"pkg"}"#,
184            &[("ok.svg", "<svg/>")],
185        );
186        // A secret outside the package, and an in-package symlink pointing at it.
187        std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
188        let pkg = tmp.path().join("node_modules").join("pkg");
189        symlink(tmp.path().join("secret.txt"), pkg.join("leak.txt")).unwrap();
190
191        // The escaping symlink is refused; an ordinary in-package file still resolves.
192        assert!(package_file(tmp.path(), "pkg", "leak.txt").is_err());
193        assert!(package_file(tmp.path(), "pkg", "ok.svg").is_ok());
194    }
195}