use which::which;
use std::path::{Path, PathBuf};
use crate::config::ConfigError;
pub fn command_exists(cmd: &str) -> bool {
which(cmd).is_ok()
}
pub fn validate_and_sanitize_path(path_str: &str) -> Result<PathBuf, ConfigError> {
let path = Path::new(path_str);
if path_str.is_empty() {
return Err(ConfigError::InvalidConfig(
"Path cannot be empty".to_string()
));
}
let path_str_normalized = path_str.replace('\\', "/");
if path_str_normalized.contains("../") || path_str_normalized.contains("~/") {
return Err(ConfigError::InvalidConfig(
"Path traversal (../) or home directory (~) not allowed".to_string()
));
}
if path.is_absolute() {
let path_str_lower = path_str_normalized.to_lowercase();
let dangerous_patterns = [
"/etc", "/usr", "/bin", "/sbin", "/lib", "/boot", "/dev", "/proc",
"/sys", "/root", "/var", "/opt", "/tmp", "/home", "/mnt", "/media",
"c:/windows", "c:\\windows", "d:/windows", "d:\\windows",
"c:/program files", "c:\\program files", "c:/program files (x86)", "c:\\program files (x86)",
"c:/system32", "c:\\system32", "c:/winnt", "c:\\winnt",
"/applications", "/library", "/system", "/users",
"/windows", "/program files", "/system32", "/winnt",
];
for pattern in &dangerous_patterns {
let pattern_lower = pattern.to_lowercase();
if path_str_lower.starts_with(&pattern_lower) {
let path_len = path_str_lower.len();
let pattern_len = pattern_lower.len();
if path_len == pattern_len ||
path_str_lower.chars().nth(pattern_len).map_or(false, |c| c == '/' || c == '\\') {
return Err(ConfigError::InvalidConfig(
format!("Access to system directory '{}' not allowed", pattern)
));
}
}
}
}
let canonical_path = match path.canonicalize() {
Ok(path) => path,
Err(_) => {
return Err(ConfigError::InvalidConfig(
"Path does not exist or cannot be accessed".to_string()
));
}
};
if let Ok(current_dir) = std::env::current_dir() {
if let Ok(current_canonical) = current_dir.canonicalize() {
if !canonical_path.starts_with(¤t_canonical) {
return Err(ConfigError::InvalidConfig(
"Path must be within the current directory tree".to_string()
));
}
}
}
Ok(canonical_path)
}
pub fn validate_exclude_dir_name(dir_name: &str) -> Result<(), ConfigError> {
if dir_name.is_empty() {
return Err(ConfigError::InvalidConfig(
"Exclude directory name cannot be empty".to_string()
));
}
if dir_name.contains("..") || dir_name.contains('/') || dir_name.contains('\\') {
return Err(ConfigError::InvalidConfig(
format!("Invalid exclude directory name: '{}'", dir_name)
));
}
if dir_name == "." || dir_name == ".." {
return Err(ConfigError::InvalidConfig(
format!("Reserved directory name cannot be excluded: '{}'", dir_name)
));
}
let dir_name_lower = dir_name.to_lowercase();
let windows_reserved = [
"con", "prn", "aux", "nul",
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
];
if windows_reserved.contains(&dir_name_lower.as_str()) {
return Err(ConfigError::InvalidConfig(
format!("Windows reserved name cannot be used as exclude directory: '{}'", dir_name)
));
}
if dir_name.len() > 255 {
return Err(ConfigError::InvalidConfig(
"Exclude directory name too long (max 255 characters)".to_string()
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_command_exists() {
assert!(command_exists("cargo"));
assert!(!command_exists("a-command-that-does-not-exist"));
}
#[test]
fn test_validate_and_sanitize_path_reject_traversal() {
assert!(validate_and_sanitize_path("../etc/passwd").is_err());
assert!(validate_and_sanitize_path("../../etc").is_err());
assert!(validate_and_sanitize_path("../../../usr/bin").is_err());
assert!(validate_and_sanitize_path("file/../etc/passwd").is_err());
assert!(validate_and_sanitize_path("dir/../../etc").is_err());
}
#[test]
fn test_validate_and_sanitize_path_reject_home_directory() {
assert!(validate_and_sanitize_path("~/etc/passwd").is_err());
assert!(validate_and_sanitize_path("~/").is_err());
assert!(validate_and_sanitize_path("~/Documents").is_err());
}
#[test]
fn test_validate_and_sanitize_path_reject_system_directories() {
assert!(validate_and_sanitize_path("/etc/passwd").is_err());
assert!(validate_and_sanitize_path("/usr/bin").is_err());
assert!(validate_and_sanitize_path("/bin/sh").is_err());
assert!(validate_and_sanitize_path("/etc/").is_err());
assert!(validate_and_sanitize_path("C:\\Windows\\System32").is_err());
assert!(validate_and_sanitize_path("C:/Windows/System32").is_err());
assert!(validate_and_sanitize_path("C:\\Program Files").is_err());
assert!(validate_and_sanitize_path("/Applications").is_err());
assert!(validate_and_sanitize_path("/System/Library").is_err());
}
#[test]
fn test_validate_and_sanitize_path_reject_windows_traversal() {
assert!(validate_and_sanitize_path("..\\..\\Windows").is_err());
assert!(validate_and_sanitize_path("file\\..\\etc").is_err());
assert!(validate_and_sanitize_path("dir\\..\\..\\etc").is_err());
}
#[test]
fn test_validate_and_sanitize_path_allow_valid_paths() {
assert!(validate_and_sanitize_path(".").is_ok());
let temp_dir = tempfile::TempDir::new_in(".").unwrap();
let temp_path = temp_dir.path();
let current_dir = std::env::current_dir().unwrap();
let relative_path = temp_path.strip_prefix(¤t_dir).unwrap_or(temp_path);
assert!(validate_and_sanitize_path(relative_path.to_str().unwrap()).is_ok());
let test_file = temp_path.join("test_file");
std::fs::write(&test_file, "test").unwrap();
let relative_test_file = test_file.strip_prefix(¤t_dir).unwrap_or(&test_file);
assert!(validate_and_sanitize_path(relative_test_file.to_str().unwrap()).is_ok());
}
#[test]
fn test_validate_and_sanitize_path_reject_nonexistent() {
assert!(validate_and_sanitize_path("/nonexistent/path").is_err());
assert!(validate_and_sanitize_path("nonexistent_dir").is_err());
assert!(validate_and_sanitize_path("../nonexistent").is_err());
}
#[test]
fn test_validate_and_sanitize_path_empty_path() {
assert!(validate_and_sanitize_path("").is_err());
}
#[test]
fn test_validate_exclude_dir_name_valid() {
assert!(validate_exclude_dir_name("node_modules").is_ok());
assert!(validate_exclude_dir_name("target").is_ok());
assert!(validate_exclude_dir_name("build").is_ok());
assert!(validate_exclude_dir_name("dist").is_ok());
assert!(validate_exclude_dir_name("vendor").is_ok());
assert!(validate_exclude_dir_name("custom_dir").is_ok());
}
#[test]
fn test_validate_exclude_dir_name_invalid() {
assert!(validate_exclude_dir_name("").is_err());
assert!(validate_exclude_dir_name(".").is_err());
assert!(validate_exclude_dir_name("..").is_err());
assert!(validate_exclude_dir_name("../malicious").is_err());
assert!(validate_exclude_dir_name("../../etc").is_err());
assert!(validate_exclude_dir_name("dir/../etc").is_err());
assert!(validate_exclude_dir_name("path/with/slashes").is_err());
assert!(validate_exclude_dir_name("path\\with\\backslashes").is_err());
}
#[test]
fn test_validate_exclude_dir_name_reserved_names() {
assert!(validate_exclude_dir_name("con").is_err());
assert!(validate_exclude_dir_name("prn").is_err());
assert!(validate_exclude_dir_name("aux").is_err());
assert!(validate_exclude_dir_name("nul").is_err());
assert!(validate_exclude_dir_name("com1").is_err());
assert!(validate_exclude_dir_name("lpt1").is_err());
}
#[test]
fn test_validate_exclude_dir_name_too_long() {
let long_name = "a".repeat(256);
assert!(validate_exclude_dir_name(&long_name).is_err());
let max_name = "a".repeat(255);
assert!(validate_exclude_dir_name(&max_name).is_ok());
}
}