1use std::path::{Path, PathBuf};
12
13use crate::package_json::{validate_package_name, PackageJson};
14use crate::path_safety::safe_join;
15use crate::Result;
16
17pub 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
38pub 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 subpath
67 .strip_prefix("./")
68 .unwrap_or(subpath)
69 .trim_start_matches('/')
70 .to_string()
71 };
72 let candidate = safe_join(&dir, &relative)?;
73 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 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 assert!(package_file(&start, "bootstrap-icons", "icons/missing.svg").is_err());
143 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 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 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 assert!(package_file(tmp.path(), "pkg", "leak.txt").is_err());
193 assert!(package_file(tmp.path(), "pkg", "ok.svg").is_ok());
194 }
195}