use std::path::{Path, PathBuf};
use unicode_normalization::{IsNormalized, UnicodeNormalization, is_nfc_quick};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathError {
ControlChars,
NonNfc,
Hidden,
BlockedExtension(String),
Escape,
NotFound,
ExecBit,
}
pub fn canonical_within_root(
root_dir: &Path,
request_path: &str,
executable_blocklist: &[String],
) -> Result<PathBuf, PathError> {
let trimmed = validate_path_components(request_path, executable_blocklist)?;
let candidate = root_dir.join(&trimmed);
let canonical = std::fs::canonicalize(&candidate).map_err(|_| PathError::NotFound)?;
let root_canonical = std::fs::canonicalize(root_dir).map_err(|_| PathError::NotFound)?;
if !canonical.starts_with(&root_canonical) {
return Err(PathError::Escape);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = canonical.metadata()
&& meta.is_file()
&& meta.permissions().mode() & 0o111 != 0
{
return Err(PathError::ExecBit);
}
}
Ok(canonical)
}
fn validate_path_components(
request_path: &str,
executable_blocklist: &[String],
) -> Result<String, PathError> {
if request_path
.as_bytes()
.iter()
.any(|&b| b < 0x20 || b == 0x7f)
{
return Err(PathError::ControlChars);
}
if is_nfc_quick(request_path.chars()) != IsNormalized::Yes {
return Err(PathError::NonNfc);
}
let nfc: String = request_path.chars().nfc().collect();
let trimmed = nfc.trim_start_matches('/');
for segment in trimmed.split('/') {
if segment == "." || segment == ".." || segment.is_empty() {
continue;
}
if segment.starts_with('.') {
return Err(PathError::Hidden);
}
}
if let Some(dot_idx) = trimmed.rfind('.') {
let ext = &trimmed[dot_idx..];
let ext_lower = ext.to_ascii_lowercase();
if executable_blocklist
.iter()
.any(|b| b.eq_ignore_ascii_case(&ext_lower))
{
return Err(PathError::BlockedExtension(ext_lower));
}
}
Ok(trimmed.to_string())
}
pub fn canonical_within_root_for_create(
root_dir: &Path,
request_path: &str,
executable_blocklist: &[String],
) -> Result<PathBuf, PathError> {
let trimmed = validate_path_components(request_path, executable_blocklist)?;
if trimmed.is_empty() || trimmed.ends_with('/') {
return Err(PathError::NotFound);
}
for segment in trimmed.split('/') {
if segment == "." || segment == ".." {
return Err(PathError::Escape);
}
}
let target = root_dir.join(&trimmed);
let root_canonical = std::fs::canonicalize(root_dir).map_err(|_| PathError::NotFound)?;
let mut existing = target.as_path();
let canonical_ancestor = loop {
match std::fs::canonicalize(existing) {
Ok(c) => break c,
Err(_) => match existing.parent() {
Some(p) => existing = p,
None => return Err(PathError::Escape),
},
}
};
if !canonical_ancestor.starts_with(&root_canonical) {
return Err(PathError::Escape);
}
Ok(target)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{File, write};
use std::io::Write;
fn block() -> Vec<String> {
vec![".cgi".into(), ".php".into(), ".exe".into()]
}
fn setup_root() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().to_path_buf();
write(root.join("index.html"), "<html></html>").unwrap();
std::fs::create_dir(root.join("assets")).unwrap();
write(root.join("assets/logo.png"), [0x89u8, b'P', b'N', b'G']).unwrap();
write(root.join(".hidden"), "secret").unwrap();
write(root.join("evil.cgi"), "#!/bin/sh\n").unwrap();
(dir, root)
}
#[test]
fn happy_path_resolves_within_root() {
let (_d, root) = setup_root();
let result = canonical_within_root(&root, "/index.html", &block()).unwrap();
assert!(result.ends_with("index.html"));
}
#[test]
fn rejects_dotdot_escape() {
let (_d, root) = setup_root();
let err =
canonical_within_root(&root, "/../../etc/passwd", &block()).expect_err("must reject");
assert!(
matches!(err, PathError::NotFound | PathError::Escape),
"got {err:?}"
);
}
#[test]
fn rejects_hidden_file() {
let (_d, root) = setup_root();
let err = canonical_within_root(&root, "/.hidden", &block()).expect_err("must reject");
assert_eq!(err, PathError::Hidden);
}
#[test]
fn rejects_blocklisted_extension() {
let (_d, root) = setup_root();
let err = canonical_within_root(&root, "/evil.cgi", &block()).expect_err("must reject");
assert_eq!(err, PathError::BlockedExtension(".cgi".into()));
}
#[test]
fn rejects_non_nfc() {
let (_d, root) = setup_root();
let non_nfc = "/cafe\u{0301}.html";
let err = canonical_within_root(&root, non_nfc, &block()).expect_err("must reject");
assert_eq!(err, PathError::NonNfc);
}
#[test]
fn rejects_nul_byte() {
let (_d, root) = setup_root();
let err = canonical_within_root(&root, "/index\0.html", &block()).expect_err("must reject");
assert_eq!(err, PathError::ControlChars);
}
#[test]
fn rejects_nested_hidden_segment() {
let (_d, root) = setup_root();
let err =
canonical_within_root(&root, "/assets/.cache", &block()).expect_err("must reject");
assert_eq!(err, PathError::Hidden);
}
#[cfg(unix)]
#[test]
fn rejects_exec_bit_file() {
use std::os::unix::fs::PermissionsExt;
let (_d, root) = setup_root();
let exec_path = root.join("script.bin");
let mut f = File::create(&exec_path).unwrap();
f.write_all(b"#!/bin/sh\n").unwrap();
let mut perms = std::fs::metadata(&exec_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&exec_path, perms).unwrap();
let err = canonical_within_root(&root, "/script.bin", &block()).expect_err("must reject");
assert_eq!(err, PathError::ExecBit);
}
#[test]
fn create_allows_new_file_in_existing_dir() {
let (_d, root) = setup_root();
let target = canonical_within_root_for_create(&root, "/assets/new.css", &block()).unwrap();
assert!(target.ends_with("assets/new.css"));
assert!(!target.exists());
}
#[test]
fn create_allows_new_nested_dir() {
let (_d, root) = setup_root();
let target =
canonical_within_root_for_create(&root, "/blog/2026/post.html", &block()).unwrap();
assert!(target.ends_with("blog/2026/post.html"));
assert!(!root.join("blog").exists(), "validation must not mkdir");
}
#[test]
fn create_rejects_blocklisted_extension() {
let (_d, root) = setup_root();
let err = canonical_within_root_for_create(&root, "/evil.php", &block())
.expect_err("must reject");
assert_eq!(err, PathError::BlockedExtension(".php".into()));
}
#[test]
fn create_rejects_hidden_target() {
let (_d, root) = setup_root();
for p in ["/.htaccess", "/.git/config", "/assets/.secret"] {
let err = canonical_within_root_for_create(&root, p, &block()).unwrap_err();
assert_eq!(err, PathError::Hidden, "path {p}");
}
}
#[test]
fn create_rejects_control_and_non_nfc() {
let (_d, root) = setup_root();
assert_eq!(
canonical_within_root_for_create(&root, "/x\0.html", &block()).unwrap_err(),
PathError::ControlChars,
);
assert_eq!(
canonical_within_root_for_create(&root, "/cafe\u{0301}.html", &block()).unwrap_err(),
PathError::NonNfc,
);
}
#[test]
fn create_rejects_dotdot_and_creates_nothing() {
let (_d, root) = setup_root();
let err = canonical_within_root_for_create(&root, "/../escape/x.html", &block())
.expect_err("must reject");
assert_eq!(err, PathError::Escape);
assert!(!root.parent().unwrap().join("escape").exists());
}
#[test]
fn create_rejects_empty_and_dir_target() {
let (_d, root) = setup_root();
assert_eq!(
canonical_within_root_for_create(&root, "/", &block()).unwrap_err(),
PathError::NotFound,
);
assert_eq!(
canonical_within_root_for_create(&root, "/assets/", &block()).unwrap_err(),
PathError::NotFound,
);
}
#[cfg(unix)]
#[test]
fn create_rejects_symlinked_ancestor_escape() {
use std::os::unix::fs::symlink;
let (_d, root) = setup_root();
let outside = _d.path().parent().unwrap().join("vtc-escape-target");
std::fs::create_dir_all(&outside).unwrap();
symlink(&outside, root.join("link")).unwrap();
let err = canonical_within_root_for_create(&root, "/link/pwned.html", &block())
.expect_err("symlinked ancestor must be rejected");
assert_eq!(err, PathError::Escape);
assert!(!outside.join("pwned.html").exists());
let _ = std::fs::remove_dir_all(&outside);
}
}