use std::path::{Path, PathBuf};
use crate::{Error, Result};
pub(crate) const SCHEME: &str = "npm://";
pub(crate) fn link_target(path: &Path) -> Option<String> {
if !std::fs::symlink_metadata(path)
.ok()?
.file_type()
.is_symlink()
{
return None;
}
let target = std::fs::read_link(path).ok()?;
let target = target.to_str()?;
target.starts_with(SCHEME).then(|| target.to_owned())
}
pub(crate) struct Reference {
pub package: String,
pub subpath: String,
pub directory: bool,
}
pub(crate) fn parse(target: &str) -> Option<Reference> {
let rest = target.strip_prefix(SCHEME)?;
let directory = rest.ends_with('/');
let rest = rest.trim_end_matches('/');
let (package, subpath) = match rest.strip_prefix('@') {
Some(scoped) => {
let mut parts = scoped.splitn(3, '/');
let scope = parts.next().filter(|s| !s.is_empty())?;
let name = parts.next().filter(|s| !s.is_empty())?;
(format!("@{scope}/{name}"), parts.next().unwrap_or(""))
}
None => {
let mut parts = rest.splitn(2, '/');
let name = parts.next().filter(|s| !s.is_empty())?;
(name.to_owned(), parts.next().unwrap_or(""))
}
};
Some(Reference {
package,
subpath: subpath.to_owned(),
directory,
})
}
pub(crate) fn resolve(from_dir: &Path, reference: &Reference) -> Result<Vec<(PathBuf, PathBuf)>> {
if reference.directory {
let dir = npm_utils::resolve::package_dir(from_dir, &reference.package)
.map_err(|e| Error::Build(format!("npm://{}: {e}", reference.package)))?;
let package_root = dir
.canonicalize()
.map_err(|e| Error::Build(format!("npm://{}: {e}", reference.package)))?;
let base = if reference.subpath.is_empty() {
package_root.clone()
} else {
let joined = npm_utils::path_safety::safe_join(&dir, &reference.subpath)
.map_err(|e| Error::Build(format!("npm://{}: {e}", reference.package)))?;
let real = joined.canonicalize().map_err(|e| {
Error::Build(format!(
"npm://{}/{}: {e}",
reference.package, reference.subpath
))
})?;
if !real.starts_with(&package_root) {
return Err(Error::Build(format!(
"npm://{}/{}: resolves outside the package",
reference.package, reference.subpath
)));
}
real
};
if !base.is_dir() {
return Err(Error::Build(format!(
"npm://{}/{}: not a directory in the package",
reference.package, reference.subpath
)));
}
let mut files = Vec::new();
for entry in walkdir::WalkDir::new(&base).sort_by_file_name() {
let entry =
entry.map_err(|e| Error::Build(format!("npm://{}: {e}", reference.package)))?;
if entry.file_type().is_file() {
let rel = entry
.path()
.strip_prefix(&base)
.map_err(|e| Error::Build(e.to_string()))?;
files.push((rel.to_path_buf(), entry.path().to_path_buf()));
}
}
Ok(files)
} else {
let file =
npm_utils::resolve::package_file(from_dir, &reference.package, &reference.subpath)
.map_err(|e| Error::Build(format!("npm://{}: {e}", reference.package)))?;
Ok(vec![(PathBuf::new(), file)])
}
}
#[cfg(feature = "axum")]
pub(crate) fn serve_target(root: &Path, relative: &Path) -> Option<PathBuf> {
let mut prefix = root.to_path_buf();
let mut components = relative.components();
while let Some(component) = components.next() {
prefix.push(component.as_os_str());
let Some(target) = link_target(&prefix) else {
continue;
};
let reference = parse(&target)?;
let from_dir = std::fs::canonicalize(prefix.parent()?).ok()?;
let remainder = components.as_path();
let file = if reference.directory || !remainder.as_os_str().is_empty() {
let package_root = npm_utils::resolve::package_dir(&from_dir, &reference.package)
.ok()?
.canonicalize()
.ok()?;
let mut file = package_root.clone();
if !reference.subpath.is_empty() {
file = npm_utils::path_safety::safe_join(&file, &reference.subpath).ok()?;
}
if !remainder.as_os_str().is_empty() {
file = npm_utils::path_safety::safe_join(&file, remainder.to_str()?).ok()?;
}
let real = file.canonicalize().ok()?;
if !real.starts_with(&package_root) {
return None;
}
real
} else {
npm_utils::resolve::package_file(&from_dir, &reference.package, &reference.subpath)
.ok()?
};
return file.is_file().then_some(file);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_file_and_directory_references() {
let file = parse("npm://bootstrap-icons/icons/eye.svg").unwrap();
assert_eq!(file.package, "bootstrap-icons");
assert_eq!(file.subpath, "icons/eye.svg");
assert!(!file.directory);
let dir = parse("npm://bootstrap-icons/icons/").unwrap();
assert_eq!(dir.package, "bootstrap-icons");
assert_eq!(dir.subpath, "icons");
assert!(dir.directory);
let scoped = parse("npm://@scope/pkg/a/b.svg").unwrap();
assert_eq!(scoped.package, "@scope/pkg");
assert_eq!(scoped.subpath, "a/b.svg");
assert!(parse("./icons/eye.svg").is_none());
assert!(parse("npm://").is_none());
}
#[cfg(unix)]
#[test]
fn directory_mount_skips_an_escaping_symlink() {
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let icons = tmp.path().join("node_modules/pkg/icons");
std::fs::create_dir_all(&icons).unwrap();
std::fs::write(
tmp.path().join("node_modules/pkg/package.json"),
r#"{"name":"pkg"}"#,
)
.unwrap();
std::fs::write(icons.join("real.svg"), "<svg/>").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "secret").unwrap();
symlink(tmp.path().join("secret.txt"), icons.join("leak.svg")).unwrap();
let web = tmp.path().join("web");
std::fs::create_dir_all(&web).unwrap();
let reference = parse("npm://pkg/icons/").unwrap();
let out = resolve(&web, &reference).unwrap();
let names: Vec<_> = out
.iter()
.map(|(rel, _)| rel.to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["real.svg"]);
}
#[cfg(all(unix, feature = "axum"))]
#[test]
fn resolves_and_serves_a_file_reference() {
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let icons = tmp.path().join("node_modules/bootstrap-icons/icons");
std::fs::create_dir_all(&icons).unwrap();
std::fs::write(
tmp.path().join("node_modules/bootstrap-icons/package.json"),
r#"{"name":"bootstrap-icons"}"#,
)
.unwrap();
std::fs::write(icons.join("eye.svg"), "<svg/>").unwrap();
let bi = tmp.path().join("web/icons/bi");
std::fs::create_dir_all(&bi).unwrap();
symlink("npm://bootstrap-icons/icons/eye.svg", bi.join("eye.svg")).unwrap();
let reference = parse("npm://bootstrap-icons/icons/eye.svg").unwrap();
let out = resolve(&bi.canonicalize().unwrap(), &reference).unwrap();
assert_eq!(out.len(), 1);
assert!(out[0].0.as_os_str().is_empty());
assert_eq!(std::fs::read_to_string(&out[0].1).unwrap(), "<svg/>");
let web = tmp.path().join("web");
let served = serve_target(&web, Path::new("icons/bi/eye.svg")).unwrap();
assert_eq!(std::fs::read_to_string(served).unwrap(), "<svg/>");
assert!(serve_target(&web, Path::new("icons/bi")).is_none());
}
}