use anyhow::{Result, Context};
use std::path::{Path, PathBuf};
pub const MAX_FILE_SIZE: usize = 10 * 1024 * 1024;
pub const MAX_PATH_LENGTH: usize = 1024;
pub fn validate_path(
path_str: &str,
base_dir: Option<&Path>,
is_write: bool
) -> Result<PathBuf> {
if path_str.len() > MAX_PATH_LENGTH {
return Err(anyhow::anyhow!(
"Path too long: {} characters (max: {})",
path_str.len(),
MAX_PATH_LENGTH
));
}
if path_str.contains("..") {
return Err(anyhow::anyhow!(
"Path traversal detected: '{}'. Paths cannot contain '..' for security",
path_str
));
}
if path_str.trim().is_empty() {
return Err(anyhow::anyhow!("Path cannot be empty"));
}
let path = PathBuf::from(path_str);
let is_relative = path.is_relative();
if is_write {
check_critical_system_files(&path)?;
}
let resolved_path = if let Some(base) = base_dir {
if path.is_absolute() {
path
} else {
base.join(&path)
}
} else {
if path.is_absolute() {
path
} else {
std::env::current_dir()
.context("Cannot get current directory")?
.join(&path)
}
};
let canonical = if resolved_path.exists() {
resolved_path.canonicalize()
.with_context(|| format!("Cannot resolve path: {}", resolved_path.display()))?
} else {
resolved_path.clone()
};
if let Some(base) = base_dir {
let base_canonical = if base.exists() {
base.canonicalize()
.with_context(|| format!("Cannot resolve base directory: {}", base.display()))?
} else {
base.to_path_buf()
};
let is_within_base = if is_relative && !path_str.contains("..") {
true
} else {
resolved_path.starts_with(&base_canonical)
|| canonical.starts_with(&base_canonical)
};
if !is_within_base {
return Err(anyhow::anyhow!(
"Path escapes project directory: '{}'. Resolved path '{}' appears outside '{}'",
path_str,
resolved_path.display(),
base_canonical.display()
));
}
}
Ok(canonical)
}
fn check_critical_system_files(path: &Path) -> Result<()> {
const CRITICAL_FILES: &[&str] = &[
"/etc/passwd",
"/etc/shadow",
"/etc/sudoers",
"/etc/ssh/sshd_config",
"/etc/hosts",
"/etc/fstab",
"/boot/",
"/dev/sda",
"/dev/hda",
"/proc/",
"/sys/",
];
let path_str = path.to_string_lossy();
for critical in CRITICAL_FILES {
if path_str.starts_with(critical) || path_str == *critical {
return Err(anyhow::anyhow!(
"Cannot write to critical system file: '{}'. This is blocked for security",
path.display()
));
}
}
Ok(())
}
pub fn validate_content_size(content: &str) -> Result<()> {
if content.len() > MAX_FILE_SIZE {
return Err(anyhow::anyhow!(
"Content too large: {} bytes (max: {} bytes = {} MB). \
Split into smaller files or use streaming",
content.len(),
MAX_FILE_SIZE,
MAX_FILE_SIZE / 1_000_000
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_path_traversal_blocked() {
let base = TempDir::new().unwrap();
assert!(validate_path("../../../etc/passwd", Some(base.path()), false).is_err());
assert!(validate_path("..\\..\\..\\windows\\system32", Some(base.path()), false).is_err());
assert!(validate_path("/tmp/../etc/passwd", Some(base.path()), false).is_err());
}
#[test]
fn test_safe_relative_paths_allowed() {
let base = TempDir::new().unwrap();
let result1 = validate_path("src/main.rs", Some(base.path()), true); let result2 = validate_path("./build/output.txt", Some(base.path()), true); let result3 = validate_path("config.json", Some(base.path()), true);
assert!(result1.is_ok(), "Relative path 'src/main.rs' should be allowed for write");
assert!(result2.is_ok(), "Relative path './build/output.txt' should be allowed for write");
assert!(result3.is_ok(), "Relative path 'config.json' should be allowed for write");
let result4 = validate_path("newfile.txt", Some(base.path()), false);
assert!(result4.is_ok(), "Safe relative path should be allowed even for read");
}
#[test]
fn test_absolute_paths_handling() {
let base = TempDir::new().unwrap();
let temp_file = base.path().join("test.txt");
std::fs::write(&temp_file, "test content").unwrap();
assert!(validate_path(temp_file.to_str().unwrap(), Some(base.path()), false).is_ok(),
"Absolute path within base should be allowed for existing files");
assert!(validate_path("/etc/passwd", None, true).is_err(),
"Critical system files should be blocked for writes even without base dir");
#[cfg(unix)]
{
let outside_path = "/var/outside.txt";
let result = validate_path(outside_path, Some(base.path()), true);
assert!(result.is_err(),
"Absolute path '{}' outside base should be rejected for write", outside_path);
}
#[cfg(windows)]
{
let outside_path = "C:\\Windows\\outside.txt";
let result = validate_path(outside_path, Some(base.path()), true);
assert!(result.is_err(),
"Absolute path '{}' outside base should be rejected for write", outside_path);
}
}
#[test]
fn test_critical_system_files_blocked() {
assert!(validate_path("/etc/passwd", None, true).is_err(),
"Should block /etc/passwd for write");
assert!(validate_path("/etc/shadow", None, true).is_err(),
"Should block /etc/shadow for write");
assert!(validate_path("/etc/sudoers", None, true).is_err(),
"Should block /etc/sudoers for write");
assert!(validate_path("/etc/passwd", None, false).is_ok(),
"Reading /etc/passwd should be allowed (documented risk)");
assert!(validate_path("/etc/hosts", None, false).is_ok(),
"Reading /etc/hosts should be allowed");
}
#[test]
fn test_path_length_limit() {
let long_path = "a".repeat(MAX_PATH_LENGTH + 1);
assert!(validate_path(&long_path, None, false).is_err(),
"Path exceeding MAX_PATH_LENGTH should be rejected");
let normal_path = "src/main.rs";
assert!(validate_path(normal_path, None, false).is_ok(),
"Normal length relative path should be allowed");
let abs_path = "/tmp/test.txt";
assert!(validate_path(abs_path, None, false).is_ok(),
"Normal length absolute path should be allowed for read");
}
#[test]
fn test_content_size_validation() {
let small = "Hello, world!";
assert!(validate_content_size(small).is_ok());
let large = "x".repeat(MAX_FILE_SIZE + 1);
assert!(validate_content_size(&large).is_err());
let exact = "x".repeat(MAX_FILE_SIZE);
assert!(validate_content_size(&exact).is_ok());
}
#[test]
fn test_empty_path_blocked() {
let base = TempDir::new().unwrap();
assert!(validate_path("", Some(base.path()), false).is_err());
assert!(validate_path(" ", Some(base.path()), false).is_err());
}
#[test]
fn test_symlink_escape_blocked() {
let base = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
let link = base.path().join("escape_link");
#[cfg(unix)]
std::os::unix::fs::symlink(outside.path(), &link).ok();
#[cfg(windows)]
std::os::windows::fs::symlink_file(outside.path(), &link).ok();
if link.exists() {
let result = validate_path("escape_link", Some(base.path()), true);
assert!(result.is_err() || result.unwrap().starts_with(base.path()));
}
}
}