use std::path::{Component, Path, PathBuf};
pub(crate) fn relative_under(prefix: &str, requested: &str) -> Option<String> {
let prefix = prefix.trim_matches('/');
if prefix.is_empty() {
return Some(requested.to_string());
}
if requested == prefix {
return Some(String::new());
}
requested
.strip_prefix(&format!("{prefix}/"))
.map(str::to_string)
}
pub(crate) fn has_traversal(path: &str) -> bool {
Path::new(path).components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
}
pub(crate) fn contained_file(root: &Path, relative: &str) -> Option<PathBuf> {
let real = root.join(relative).canonicalize().ok()?;
let real_root = root.canonicalize().ok()?;
(real.is_file() && real.starts_with(&real_root)).then_some(real)
}
pub(crate) enum Resolved {
File(PathBuf),
#[cfg(feature = "symlink-move")]
Redirect(String),
}
pub(crate) fn resolve_file(
root: &Path,
relative: &str,
mode: crate::SymlinkMode,
) -> Option<Resolved> {
match mode {
crate::SymlinkMode::Follow => contained_file(root, relative).map(Resolved::File),
crate::SymlinkMode::FollowUnsafe => {
let real = root.join(relative).canonicalize().ok()?;
real.is_file().then_some(Resolved::File(real))
}
#[cfg(feature = "symlink-move")]
crate::SymlinkMode::Redirect | crate::SymlinkMode::Move => {
super::symlink_move::resolve(root, relative)
}
}
}
pub(crate) fn content_type(path: &str) -> String {
mime_guess::from_path(path)
.first_or_octet_stream()
.to_string()
}
use crate::static_files::SOURCE_EXTENSIONS;
pub(crate) fn is_source_file(path: &str) -> bool {
has_source_extension(Path::new(path))
}
pub(crate) fn has_source_extension(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| {
SOURCE_EXTENSIONS
.iter()
.any(|s| ext.eq_ignore_ascii_case(s))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_under_root_and_nested() {
assert_eq!(relative_under("/", "app.js").as_deref(), Some("app.js"));
assert_eq!(
relative_under("/modules/contacts/", "modules/contacts/list.js").as_deref(),
Some("list.js")
);
assert_eq!(relative_under("/ui/", "ui").as_deref(), Some(""));
assert_eq!(relative_under("/ui/", "other/x.js"), None);
}
#[test]
fn has_traversal_flags_escapes_only() {
assert!(has_traversal("../secret"));
assert!(has_traversal("a/../../b"));
assert!(has_traversal("/etc/passwd"));
assert!(!has_traversal("app.js"));
assert!(!has_traversal("sub/app.js"));
assert!(!has_traversal("index.html"));
}
#[test]
fn has_traversal_is_lexical_and_leaves_encoded_forms_to_layer_two() {
assert!(!has_traversal("%2e%2e/secret")); assert!(!has_traversal("a%2f..%2fb")); assert!(!has_traversal("app.js%00.ts")); }
#[cfg(unix)]
#[test]
fn has_traversal_does_not_treat_backslash_as_a_separator_on_unix() {
assert!(!has_traversal("..\\..\\secret"));
}
#[test]
fn is_source_file_flags_compiled_inputs() {
assert!(is_source_file("app.ts"));
assert!(is_source_file("a/b.scss"));
assert!(is_source_file("index.html.tera"));
assert!(!is_source_file("app.js"));
assert!(!is_source_file("app.css"));
assert!(!is_source_file("index.html"));
assert!(!is_source_file("logo.svg"));
}
#[test]
fn is_source_file_is_case_insensitive() {
assert!(is_source_file("app.SCSS"));
assert!(is_source_file("App.Scss"));
assert!(is_source_file("main.TS"));
assert!(is_source_file("x.TSX"));
assert!(is_source_file("y.MTS"));
assert!(is_source_file("page.html.TERA"));
}
#[test]
fn has_source_extension_checks_resolved_paths() {
assert!(has_source_extension(Path::new("/abs/web/app.scss")));
assert!(has_source_extension(Path::new("/abs/web/app.SCSS")));
assert!(has_source_extension(Path::new("main.TS")));
assert!(!has_source_extension(Path::new("/abs/web/app.css")));
assert!(!has_source_extension(Path::new("/abs/web/app.js")));
assert!(!has_source_extension(Path::new("/abs/web/app.css.gz")));
assert!(!has_source_extension(Path::new("/abs/web/noext")));
}
#[test]
fn contained_file_keeps_inside_rejects_outside() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("app.js"), b"x").unwrap();
std::fs::write(tmp.path().join("secret"), b"s").unwrap();
assert!(contained_file(&root, "app.js").is_some());
assert!(contained_file(&root, "../secret").is_none());
assert!(contained_file(&root, "nope.js").is_none());
}
#[cfg(unix)]
#[test]
fn contained_file_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(tmp.path().join("outside.js"), b"x").unwrap();
symlink(tmp.path().join("outside.js"), root.join("link.js")).unwrap();
assert!(contained_file(&root, "link.js").is_none());
}
#[cfg(unix)]
#[test]
fn resolve_file_modes_diverge_on_an_escaping_link() {
use crate::SymlinkMode;
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(tmp.path().join("outside.js"), b"x").unwrap();
symlink(tmp.path().join("outside.js"), root.join("link.js")).unwrap();
assert!(resolve_file(&root, "link.js", SymlinkMode::Follow).is_none());
match resolve_file(&root, "link.js", SymlinkMode::FollowUnsafe) {
Some(Resolved::File(real)) => assert!(real.ends_with("outside.js")),
other => panic!("expected the escaped file, got {:?}", other.is_some()),
}
#[cfg(feature = "symlink-move")]
match resolve_file(&root, "link.js", SymlinkMode::Redirect) {
Some(Resolved::Redirect(location)) => {
assert_eq!(
location,
tmp.path().join("outside.js").display().to_string()
);
}
other => panic!("expected a redirect, got {:?}", other.is_some()),
}
}
#[cfg(unix)]
#[test]
fn resolve_file_handles_dangling_links_per_mode() {
use crate::SymlinkMode;
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("web");
std::fs::create_dir_all(&root).unwrap();
symlink(Path::new("missing.js"), root.join("dangling.js")).unwrap();
assert!(resolve_file(&root, "dangling.js", SymlinkMode::Follow).is_none());
assert!(resolve_file(&root, "dangling.js", SymlinkMode::FollowUnsafe).is_none());
#[cfg(feature = "symlink-move")]
match resolve_file(&root, "dangling.js", SymlinkMode::Move) {
Some(Resolved::Redirect(location)) => assert_eq!(location, "missing.js"),
other => panic!("expected a redirect, got {:?}", other.is_some()),
}
}
}