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> {
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));
}
}
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)
}
#[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);
}
}