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> {
27 package_dir_up(from_dir, name, None)
28}
29
30pub 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
60pub fn package_file(from_dir: &Path, name: &str, subpath: &str) -> Result<PathBuf> {
71 package_file_up(from_dir, name, subpath, None)
72}
73
74pub 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 subpath
114 .strip_prefix("./")
115 .unwrap_or(subpath)
116 .trim_start_matches('/')
117 .to_string()
118 };
119 let candidate = safe_join(&dir, &relative)?;
120 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 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 assert!(package_file(&start, "bootstrap-icons", "icons/missing.svg").is_err());
190 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 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 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 assert!(package_dir(&deep, "pkg").is_ok());
233 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 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 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 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 assert!(package_file(tmp.path(), "pkg", "leak.txt").is_err());
282 assert!(package_file(tmp.path(), "pkg", "ok.svg").is_ok());
283 }
284}