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.
21///
22/// The ascent runs to the filesystem root: a same-named package in a `node_modules`
23/// **above** the project (a parent workspace, `~/node_modules`) is found and used.
24/// When the starting directory is untrusted, prefer [`package_dir_within`] to stop the
25/// ascent at the project boundary.
26pub fn package_dir(from_dir: &Path, name: &str) -> Result<PathBuf> {
27    package_dir_up(from_dir, name, None)
28}
29
30/// [`package_dir`], but the ascent stops at `boundary` (inclusive): a package installed
31/// only above it is not found. The boundary is canonicalized once; each candidate level is
32/// compared canonically, so a symlinked start directory cannot slip past it.
33pub fn package_dir_within(from_dir: &Path, name: &str, boundary: &Path) -> Result<PathBuf> {
34    let boundary = boundary.canonicalize().map_err(|e| -> crate::Error {
35        format!("resolve boundary {}: {e}", boundary.display()).into()
36    })?;
37    package_dir_up(from_dir, name, Some(&boundary))
38}
39
40fn package_dir_up(from_dir: &Path, name: &str, boundary: Option<&Path>) -> Result<PathBuf> {
41    validate_package_name(name)?;
42    let mut cursor = Some(from_dir);
43    while let Some(dir) = cursor {
44        let candidate = dir.join("node_modules").join(name);
45        if candidate.is_dir() {
46            return Ok(candidate);
47        }
48        if boundary.is_some_and(|b| dir.canonicalize().ok().as_deref() == Some(b)) {
49            break;
50        }
51        cursor = dir.parent();
52    }
53    let scope = match boundary {
54        Some(b) => format!("between {} and {}", from_dir.display(), b.display()),
55        None => format!("above {}", from_dir.display()),
56    };
57    Err(format!("package {name:?} is not installed in any node_modules {scope}").into())
58}
59
60/// Resolve `<name>/<subpath>` to a real file inside the installed package.
61///
62/// When the package declares `exports`, only the subpaths it maps resolve — one it
63/// does not is refused, mirroring Node's `ERR_PACKAGE_PATH_NOT_EXPORTED`; otherwise
64/// the file is addressed directly (Node's behavior for a package without `exports`,
65/// e.g. `bootstrap-icons`). The result is canonicalized and guaranteed to sit inside the
66/// package's real directory — an in-package symlink resolving outside it is refused.
67///
68/// The package lookup ascends to the filesystem root; see [`package_file_within`] for the
69/// bounded variant to use on untrusted trees.
70pub fn package_file(from_dir: &Path, name: &str, subpath: &str) -> Result<PathBuf> {
71    package_file_up(from_dir, name, subpath, None)
72}
73
74/// [`package_file`], with the package lookup bounded at `boundary` ([`package_dir_within`]).
75pub fn package_file_within(
76    from_dir: &Path,
77    name: &str,
78    subpath: &str,
79    boundary: &Path,
80) -> Result<PathBuf> {
81    let boundary = boundary.canonicalize().map_err(|e| -> crate::Error {
82        format!("resolve boundary {}: {e}", boundary.display()).into()
83    })?;
84    package_file_up(from_dir, name, subpath, Some(&boundary))
85}
86
87fn package_file_up(
88    from_dir: &Path,
89    name: &str,
90    subpath: &str,
91    boundary: Option<&Path>,
92) -> Result<PathBuf> {
93    let dir = package_dir_up(from_dir, name, boundary)?;
94    let manifest = dir.join("package.json");
95    let relative = if manifest.is_file() {
96        let package = PackageJson::from_path(&manifest)?;
97        match package.resolve_asset(subpath) {
98            Some(relative) => relative,
99            None if package.has_exports() => {
100                return Err(format!(
101                    "{name}/{subpath}: not exported by {name} \
102                     (its package.json `exports` does not map this subpath)"
103                )
104                .into())
105            }
106            None => {
107                return Err(format!("{name}/{subpath}: refuses an unsafe or empty subpath").into())
108            }
109        }
110    } else {
111        // A package without a manifest is unusual; address the file directly, with
112        // path-safety applied by `safe_join` below.
113        subpath
114            .strip_prefix("./")
115            .unwrap_or(subpath)
116            .trim_start_matches('/')
117            .to_string()
118    };
119    let candidate = safe_join(&dir, &relative)?;
120    // Resolve through the package's real on-disk location and require the result to stay
121    // inside it. `subpath` is attacker-influenced (an `npm://` symlink target, or a
122    // dependency's own layout), and a symlink *inside* the package could otherwise
123    // redirect the read outside it — so containment is enforced on the canonical path,
124    // not on the joined string `safe_join` only checks structurally.
125    let package_root = dir.canonicalize()?;
126    let real = candidate
127        .canonicalize()
128        .map_err(|e| -> crate::Error { format!("{name}/{subpath}: {e}").into() })?;
129    if !real.starts_with(&package_root) {
130        return Err(format!("{name}/{subpath}: resolves outside {name}").into());
131    }
132    if !real.is_file() {
133        return Err(format!("{name}/{subpath}: not a file in the package").into());
134    }
135    Ok(real)
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::fs;
142    use tempfile::tempdir;
143
144    /// Lay out `<root>/node_modules/<name>/` with a `package.json` (`manifest`) and a
145    /// set of `(relative-path, contents)` files.
146    fn install(root: &Path, name: &str, manifest: &str, files: &[(&str, &str)]) {
147        let pkg = root.join("node_modules").join(name);
148        fs::create_dir_all(&pkg).unwrap();
149        fs::write(pkg.join("package.json"), manifest).unwrap();
150        for (rel, body) in files {
151            let path = pkg.join(rel);
152            fs::create_dir_all(path.parent().unwrap()).unwrap();
153            fs::write(path, body).unwrap();
154        }
155    }
156
157    #[test]
158    fn package_dir_walks_up_to_node_modules() {
159        let tmp = tempdir().unwrap();
160        install(
161            tmp.path(),
162            "bootstrap-icons",
163            r#"{"name":"bootstrap-icons"}"#,
164            &[],
165        );
166        let deep = tmp.path().join("web").join("icons").join("bi");
167        fs::create_dir_all(&deep).unwrap();
168        assert_eq!(
169            package_dir(&deep, "bootstrap-icons").unwrap(),
170            tmp.path().join("node_modules").join("bootstrap-icons")
171        );
172        assert!(package_dir(&deep, "not-installed").is_err());
173    }
174
175    #[test]
176    fn package_file_addresses_directly_without_exports() {
177        let tmp = tempdir().unwrap();
178        install(
179            tmp.path(),
180            "bootstrap-icons",
181            r#"{"name":"bootstrap-icons","files":["icons/*.svg"]}"#,
182            &[("icons/eye.svg", "<svg/>")],
183        );
184        let start = tmp.path().join("web");
185        fs::create_dir_all(&start).unwrap();
186        let file = package_file(&start, "bootstrap-icons", "icons/eye.svg").unwrap();
187        assert_eq!(fs::read_to_string(&file).unwrap(), "<svg/>");
188        // A file the package does not contain errors rather than returning a missing path.
189        assert!(package_file(&start, "bootstrap-icons", "icons/missing.svg").is_err());
190        // Traversal is refused.
191        assert!(package_file(&start, "bootstrap-icons", "../escape.svg").is_err());
192    }
193
194    #[test]
195    fn package_file_honors_exports_encapsulation() {
196        let tmp = tempdir().unwrap();
197        install(
198            tmp.path(),
199            "guarded",
200            r#"{"exports":{"./icons/*":"./dist/icons/*.svg"}}"#,
201            &[("dist/icons/eye.svg", "<svg/>"), ("secret.txt", "nope")],
202        );
203        let file = package_file(tmp.path(), "guarded", "icons/eye").unwrap();
204        assert!(file.ends_with("dist/icons/eye.svg"));
205        // Present on disk but outside `exports` → refused.
206        assert!(package_file(tmp.path(), "guarded", "secret.txt").is_err());
207    }
208
209    #[test]
210    fn scoped_package_resolves() {
211        let tmp = tempdir().unwrap();
212        install(
213            tmp.path(),
214            "@scope/pkg",
215            r#"{"name":"@scope/pkg"}"#,
216            &[("a/b.svg", "x")],
217        );
218        let file = package_file(tmp.path(), "@scope/pkg", "a/b.svg").unwrap();
219        assert_eq!(fs::read_to_string(file).unwrap(), "x");
220    }
221
222    #[test]
223    fn package_dir_within_stops_the_ascent_at_the_boundary() {
224        let tmp = tempdir().unwrap();
225        // The package is installed at the tmp root; the project lives below it.
226        install(tmp.path(), "pkg", r#"{"name":"pkg"}"#, &[("a.svg", "x")]);
227        let project = tmp.path().join("project");
228        let deep = project.join("web/icons");
229        fs::create_dir_all(&deep).unwrap();
230
231        // Unbounded: found above the project (Node semantics).
232        assert!(package_dir(&deep, "pkg").is_ok());
233        // Bounded at the project: the same package is out of reach.
234        assert!(package_dir_within(&deep, "pkg", &project).is_err());
235        assert!(package_file_within(&deep, "pkg", "a.svg", &project).is_err());
236    }
237
238    #[test]
239    fn package_dir_within_checks_the_boundary_level_itself() {
240        let tmp = tempdir().unwrap();
241        // Installed *at* the boundary: inclusive, so it resolves.
242        let project = tmp.path().join("project");
243        install(&project, "pkg", r#"{"name":"pkg"}"#, &[("a.svg", "x")]);
244        let deep = project.join("web/icons");
245        fs::create_dir_all(&deep).unwrap();
246
247        assert!(package_dir_within(&deep, "pkg", &project).is_ok());
248        assert_eq!(
249            fs::read_to_string(package_file_within(&deep, "pkg", "a.svg", &project).unwrap())
250                .unwrap(),
251            "x"
252        );
253    }
254
255    #[test]
256    fn package_dir_refuses_an_absolute_name_before_touching_the_disk() {
257        // `Path::join` with an absolute name would replace the base — the name guard
258        // fires first, so `/etc` is a validation error, not a lookup.
259        let tmp = tempdir().unwrap();
260        assert!(package_dir(tmp.path(), "/etc").is_err());
261        assert!(package_file(tmp.path(), "/", "etc/passwd").is_err());
262    }
263
264    #[cfg(unix)]
265    #[test]
266    fn package_file_refuses_an_in_package_symlink_that_escapes() {
267        use std::os::unix::fs::symlink;
268        let tmp = tempdir().unwrap();
269        install(
270            tmp.path(),
271            "pkg",
272            r#"{"name":"pkg"}"#,
273            &[("ok.svg", "<svg/>")],
274        );
275        // A secret outside the package, and an in-package symlink pointing at it.
276        std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
277        let pkg = tmp.path().join("node_modules").join("pkg");
278        symlink(tmp.path().join("secret.txt"), pkg.join("leak.txt")).unwrap();
279
280        // The escaping symlink is refused; an ordinary in-package file still resolves.
281        assert!(package_file(tmp.path(), "pkg", "leak.txt").is_err());
282        assert!(package_file(tmp.path(), "pkg", "ok.svg").is_ok());
283    }
284}