use anyhow::{Result, anyhow};
use std::path::{Component, Path, PathBuf};
pub fn resolve_path(root: &str, uri_path: &str, allow_symlinks: bool) -> Result<PathBuf> {
let decoded_uri = percent_encoding::percent_decode_str(uri_path)
.decode_utf8()
.map_err(|e| anyhow!("Failed to decode URI: {e}"))?;
let root_path = Path::new(root)
.canonicalize()
.map_err(|e| anyhow!("Root path '{root}' is invalid or does not exist: {e}"))?;
let mut final_path = root_path.clone();
for component in Path::new(decoded_uri.as_ref()).components() {
match component {
Component::Normal(c) => final_path.push(c),
Component::RootDir | Component::CurDir | Component::Prefix(_) => {}
Component::ParentDir => {
if final_path > root_path {
final_path.pop();
}
}
}
}
if !allow_symlinks {
match final_path.canonicalize() {
Ok(canonical_final) => {
if !canonical_final.starts_with(&root_path) {
return Err(anyhow!(
"Forbidden: Path traversal attempt detected via symlink"
));
}
return Ok(canonical_final);
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return Ok(final_path);
}
return Err(anyhow!("Path Resolution Security Error: {e}"));
}
}
}
Ok(final_path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_resolve_safe_path() {
let dir = tempdir().unwrap();
let root_canonical = dir.path().canonicalize().unwrap();
let root = root_canonical.to_str().unwrap();
let file_name = "test.txt";
fs::write(dir.path().join(file_name), "hello").unwrap();
let resolved = resolve_path(root, "/test.txt", false).unwrap();
assert!(resolved.ends_with(file_name));
assert!(resolved.exists());
}
#[test]
fn test_path_traversal_prevention() {
let dir = tempdir().unwrap();
let root_canonical = dir.path().canonicalize().unwrap();
let root = root_canonical.to_str().unwrap();
let resolved = resolve_path(root, "/../../etc/passwd", false).unwrap();
assert!(resolved.starts_with(&root_canonical));
assert!(!resolved.to_str().unwrap().contains(".."));
let resolved_enc = resolve_path(root, "/%2e%2e/%2e%2e/etc/shadow", false).unwrap();
assert!(resolved_enc.starts_with(&root_canonical));
assert!(resolved_enc.to_str().unwrap().contains("etc/shadow"));
}
#[test]
fn test_path_normalization() {
let dir = tempdir().unwrap();
let root_canonical = dir.path().canonicalize().unwrap();
let root = root_canonical.to_str().unwrap();
let resolved = resolve_path(root, "/assets//images/./logo.png", false).unwrap();
let expected_suffix = Path::new("assets").join("images").join("logo.png");
assert!(resolved.ends_with(expected_suffix));
}
#[test]
fn test_non_existent_path_safety() {
let dir = tempdir().unwrap();
let root_canonical = dir.path().canonicalize().unwrap();
let root = root_canonical.to_str().unwrap();
let resolved = resolve_path(root, "/missing.html", false).unwrap();
assert!(resolved.starts_with(&root_canonical));
assert!(!resolved.exists());
}
#[test]
fn test_absolute_uri_denial() {
let dir = tempdir().unwrap();
let root_canonical = dir.path().canonicalize().unwrap();
let root = root_canonical.to_str().unwrap();
let resolved = resolve_path(root, "/etc/passwd", false).unwrap();
assert!(resolved.starts_with(&root_canonical));
assert!(resolved.to_str().unwrap().contains("etc/passwd"));
}
#[cfg(unix)]
#[test]
fn test_symlink_traversal_prevention() {
let dir = tempdir().unwrap();
let root_dir = dir.path().join("www");
fs::create_dir(&root_dir).unwrap();
let root = root_dir.to_str().unwrap();
let secret_file = dir.path().join("secret.txt");
fs::write(&secret_file, "top secret").unwrap();
let link_path = root_dir.join("malicious_link");
std::os::unix::fs::symlink(&secret_file, &link_path).unwrap();
let res = resolve_path(root, "/malicious_link", false);
assert!(res.is_err(), "Symlink traversal should be blocked");
assert!(
res
.unwrap_err()
.to_string()
.contains("Path traversal attempt detected")
);
let res_allowed = resolve_path(root, "/malicious_link", true);
assert!(res_allowed.is_ok());
}
}