use std::path::{Path, PathBuf};
use crate::package_json::{validate_package_name, PackageJson};
use crate::path_safety::safe_join;
use crate::Result;
pub fn package_dir(from_dir: &Path, name: &str) -> Result<PathBuf> {
validate_package_name(name)?;
let mut cursor = Some(from_dir);
while let Some(dir) = cursor {
let candidate = dir.join("node_modules").join(name);
if candidate.is_dir() {
return Ok(candidate);
}
cursor = dir.parent();
}
Err(format!(
"package {name:?} is not installed in any node_modules above {}",
from_dir.display()
)
.into())
}
pub fn package_file(from_dir: &Path, name: &str, subpath: &str) -> Result<PathBuf> {
let dir = package_dir(from_dir, name)?;
let manifest = dir.join("package.json");
let relative = if manifest.is_file() {
let package = PackageJson::from_path(&manifest)?;
match package.resolve_asset(subpath) {
Some(relative) => relative,
None if package.has_exports() => {
return Err(format!(
"{name}/{subpath}: not exported by {name} \
(its package.json `exports` does not map this subpath)"
)
.into())
}
None => {
return Err(format!("{name}/{subpath}: refuses an unsafe or empty subpath").into())
}
}
} else {
subpath
.strip_prefix("./")
.unwrap_or(subpath)
.trim_start_matches('/')
.to_string()
};
let candidate = safe_join(&dir, &relative)?;
let package_root = dir.canonicalize()?;
let real = candidate
.canonicalize()
.map_err(|e| -> crate::Error { format!("{name}/{subpath}: {e}").into() })?;
if !real.starts_with(&package_root) {
return Err(format!("{name}/{subpath}: resolves outside {name}").into());
}
if !real.is_file() {
return Err(format!("{name}/{subpath}: not a file in the package").into());
}
Ok(real)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
fn install(root: &Path, name: &str, manifest: &str, files: &[(&str, &str)]) {
let pkg = root.join("node_modules").join(name);
fs::create_dir_all(&pkg).unwrap();
fs::write(pkg.join("package.json"), manifest).unwrap();
for (rel, body) in files {
let path = pkg.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, body).unwrap();
}
}
#[test]
fn package_dir_walks_up_to_node_modules() {
let tmp = tempdir().unwrap();
install(
tmp.path(),
"bootstrap-icons",
r#"{"name":"bootstrap-icons"}"#,
&[],
);
let deep = tmp.path().join("web").join("icons").join("bi");
fs::create_dir_all(&deep).unwrap();
assert_eq!(
package_dir(&deep, "bootstrap-icons").unwrap(),
tmp.path().join("node_modules").join("bootstrap-icons")
);
assert!(package_dir(&deep, "not-installed").is_err());
}
#[test]
fn package_file_addresses_directly_without_exports() {
let tmp = tempdir().unwrap();
install(
tmp.path(),
"bootstrap-icons",
r#"{"name":"bootstrap-icons","files":["icons/*.svg"]}"#,
&[("icons/eye.svg", "<svg/>")],
);
let start = tmp.path().join("web");
fs::create_dir_all(&start).unwrap();
let file = package_file(&start, "bootstrap-icons", "icons/eye.svg").unwrap();
assert_eq!(fs::read_to_string(&file).unwrap(), "<svg/>");
assert!(package_file(&start, "bootstrap-icons", "icons/missing.svg").is_err());
assert!(package_file(&start, "bootstrap-icons", "../escape.svg").is_err());
}
#[test]
fn package_file_honors_exports_encapsulation() {
let tmp = tempdir().unwrap();
install(
tmp.path(),
"guarded",
r#"{"exports":{"./icons/*":"./dist/icons/*.svg"}}"#,
&[("dist/icons/eye.svg", "<svg/>"), ("secret.txt", "nope")],
);
let file = package_file(tmp.path(), "guarded", "icons/eye").unwrap();
assert!(file.ends_with("dist/icons/eye.svg"));
assert!(package_file(tmp.path(), "guarded", "secret.txt").is_err());
}
#[test]
fn scoped_package_resolves() {
let tmp = tempdir().unwrap();
install(
tmp.path(),
"@scope/pkg",
r#"{"name":"@scope/pkg"}"#,
&[("a/b.svg", "x")],
);
let file = package_file(tmp.path(), "@scope/pkg", "a/b.svg").unwrap();
assert_eq!(fs::read_to_string(file).unwrap(), "x");
}
#[cfg(unix)]
#[test]
fn package_file_refuses_an_in_package_symlink_that_escapes() {
use std::os::unix::fs::symlink;
let tmp = tempdir().unwrap();
install(
tmp.path(),
"pkg",
r#"{"name":"pkg"}"#,
&[("ok.svg", "<svg/>")],
);
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
let pkg = tmp.path().join("node_modules").join("pkg");
symlink(tmp.path().join("secret.txt"), pkg.join("leak.txt")).unwrap();
assert!(package_file(tmp.path(), "pkg", "leak.txt").is_err());
assert!(package_file(tmp.path(), "pkg", "ok.svg").is_ok());
}
}