use anyhow::{Result, bail};
use std::path::{Component, Path, PathBuf};
pub fn is_safe_relative_path(path: &str) -> bool {
let path = path.trim();
if path.is_empty() {
return false;
}
if path.contains("..") {
return false;
}
if path.starts_with('/') || path.contains(':') {
return false;
}
true
}
pub fn validate_path_safety(path: &str) -> Result<()> {
if path.is_empty() {
return Ok(());
}
if path.contains("..") {
bail!("Path traversal attempt detected ('..')");
}
if path.contains("~/../") || path.contains("/.../") {
bail!("Advanced path traversal detected");
}
if path.starts_with('/') {
static UNIX_CRITICAL: &[&str] = &["/etc", "/usr", "/bin", "/sbin", "/var", "/boot", "/root", "/dev"];
for prefix in UNIX_CRITICAL {
let is_var_temp_exception = *prefix == "/var"
&& (path.starts_with("/var/folders/")
|| path == "/var/folders"
|| path.starts_with("/var/tmp/")
|| path == "/var/tmp");
if !is_var_temp_exception && matches_critical_prefix(path, prefix) {
bail!("Access to system directory denied: {prefix}");
}
}
}
#[cfg(windows)]
{
let path_lower = path.to_lowercase();
static WIN_CRITICAL: &[&str] = &["c:\\windows", "c:\\program files", "c:\\system32"];
for prefix in WIN_CRITICAL {
if path_lower.starts_with(prefix) {
bail!("Access to Windows system directory denied");
}
}
}
static DANGEROUS_CHARS: &[u8] = b"$`|;&\n\r><\0";
for &c in path.as_bytes() {
if DANGEROUS_CHARS.contains(&c) {
bail!("Path contains dangerous shell characters");
}
}
Ok(())
}
fn matches_critical_prefix(path: &str, prefix: &str) -> bool {
path == prefix || path.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('/'))
}
pub fn safe_archive_relative_path(name: &Path) -> Result<PathBuf> {
let mut safe = PathBuf::new();
for component in name.components() {
match component {
Component::Normal(part) => safe.push(part),
Component::CurDir => {}
Component::ParentDir => {
bail!("unsafe archive path ('..' escapes destination): {}", name.display());
}
Component::RootDir | Component::Prefix(_) => {
bail!("unsafe archive path (absolute): {}", name.display());
}
}
}
Ok(safe)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_nested_relative_names() {
assert_eq!(safe_archive_relative_path(Path::new("a/b/c.txt")).expect("safe"), PathBuf::from("a/b/c.txt"));
}
#[test]
fn collapses_current_dir_components() {
assert_eq!(
safe_archive_relative_path(Path::new("./nested/./file.txt")).expect("safe"),
PathBuf::from("nested/file.txt")
);
}
#[test]
fn accepts_dot_dot_inside_a_single_component() {
assert_eq!(
safe_archive_relative_path(Path::new("v1..2/README.md")).expect("safe"),
PathBuf::from("v1..2/README.md")
);
}
#[test]
fn rejects_parent_components() {
let err = safe_archive_relative_path(Path::new("../../etc/passwd")).expect_err("traversal");
assert!(err.to_string().contains("unsafe archive path"), "unexpected error: {err}");
}
#[test]
fn rejects_absolute_names() {
let err = safe_archive_relative_path(Path::new("/abs.txt")).expect_err("absolute");
assert!(err.to_string().contains("unsafe archive path"), "unexpected error: {err}");
}
#[test]
fn resolves_bare_current_dir_to_empty() {
assert!(safe_archive_relative_path(Path::new(".")).expect("safe").as_os_str().is_empty());
}
#[cfg(windows)]
#[test]
fn rejects_windows_prefixes() {
let err = safe_archive_relative_path(Path::new("C:\\evil.txt")).expect_err("prefix");
assert!(err.to_string().contains("unsafe archive path"), "unexpected error: {err}");
}
}