use std::fs;
use std::path::Path;
use tempfile::TempDir;
use turbomcp_cli::path_security::{safe_output_path, sanitize_filename, validate_output_path};
#[test]
fn test_accepts_valid_relative_paths() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
assert!(validate_output_path(base, "tool.json").is_ok());
assert!(validate_output_path(base, "my_tool.json").is_ok());
assert!(validate_output_path(base, "tool-123.json").is_ok());
fs::create_dir_all(base.join("schemas")).unwrap();
assert!(validate_output_path(base, "schemas/tool.json").is_ok());
}
#[test]
fn test_rejects_absolute_paths() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
assert!(validate_output_path(base, "/etc/passwd").is_err());
assert!(validate_output_path(base, "/tmp/malicious").is_err());
assert!(validate_output_path(base, "/root/.ssh/authorized_keys").is_err());
#[cfg(windows)]
{
assert!(validate_output_path(base, "C:\\Windows\\System32").is_err());
assert!(validate_output_path(base, "D:\\secrets.txt").is_err());
}
}
#[test]
fn test_rejects_path_traversal() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
assert!(validate_output_path(base, "..").is_err());
assert!(validate_output_path(base, "../etc/passwd").is_err());
assert!(validate_output_path(base, "../../.ssh/authorized_keys").is_err());
assert!(validate_output_path(base, "../../../etc/shadow").is_err());
assert!(validate_output_path(base, "../../../../../../../../etc/passwd").is_err());
assert!(validate_output_path(base, "subdir/../../../etc/passwd").is_err());
assert!(validate_output_path(base, "a/b/c/../../../../../../../etc/passwd").is_err());
}
#[test]
fn test_sanitize_removes_unsafe_characters() {
assert_eq!(sanitize_filename("my_tool").unwrap(), "my_tool");
assert_eq!(sanitize_filename("tool-123").unwrap(), "tool-123");
assert_eq!(sanitize_filename("tool.v1.json").unwrap(), "tool.v1.json");
assert_eq!(sanitize_filename("my/tool").unwrap(), "mytool");
assert_eq!(sanitize_filename("my\\tool").unwrap(), "mytool");
assert!(sanitize_filename("../../../etc/passwd").is_err());
assert_eq!(sanitize_filename("tool:name").unwrap(), "toolname");
assert_eq!(sanitize_filename("tool*name").unwrap(), "toolname");
assert_eq!(sanitize_filename("tool?name").unwrap(), "toolname");
assert_eq!(sanitize_filename("tool<name>").unwrap(), "toolname");
assert_eq!(sanitize_filename("tool|name").unwrap(), "toolname");
}
#[test]
fn test_rejects_reserved_filenames() {
assert!(sanitize_filename(".").is_err());
assert!(sanitize_filename("..").is_err());
assert!(sanitize_filename("con").is_err());
assert!(sanitize_filename("CON").is_err()); assert!(sanitize_filename("prn").is_err());
assert!(sanitize_filename("aux").is_err());
assert!(sanitize_filename("nul").is_err());
assert!(sanitize_filename("com1").is_err());
assert!(sanitize_filename("com2").is_err());
assert!(sanitize_filename("lpt1").is_err());
assert!(sanitize_filename("lpt2").is_err());
}
#[test]
fn test_rejects_empty_filenames() {
assert!(sanitize_filename("").is_err());
assert!(sanitize_filename(" ").is_err()); assert!(sanitize_filename("///").is_err()); assert!(sanitize_filename("***").is_err()); assert!(sanitize_filename("???").is_err()); }
#[test]
fn test_comprehensive_attack_scenarios() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let base_canonical = base.canonicalize().unwrap();
let attack_patterns = vec![
"../../../etc/passwd",
"../../.ssh/authorized_keys",
"../../../.bash_history",
"../../../../../../../../etc/passwd",
"../../../../../../../../etc/shadow",
"/etc/passwd",
"/root/.ssh/id_rsa",
"/var/log/auth.log",
"..\\..\\..\\windows\\system32",
"C:\\Windows\\System32\\config\\SAM",
"subdir/../../etc/passwd",
"a/b/c/../../../../../../../etc/passwd",
"../../.env",
"../../.aws/credentials",
"..%2F..%2F..%2Fetc%2Fpasswd",
"..%5C..%5C..%5Cwindows",
];
for pattern in attack_patterns {
let result = validate_output_path(base, pattern);
let should_be_rejected = pattern.contains("..") || pattern.starts_with('/');
if should_be_rejected {
assert!(
result.is_err(),
"Should reject malicious path directly: {}",
pattern
);
}
match sanitize_filename(pattern) {
Ok(sanitized) => {
let result = validate_output_path(base, &sanitized);
if let Ok(path) = result {
assert!(
path.starts_with(&base_canonical),
"Sanitized path must be within base dir: {} -> {}",
pattern,
path.display()
);
}
}
Err(_) => {
}
}
}
}
#[test]
fn test_safe_output_path_integration() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let base_canonical = base.canonicalize().unwrap();
let result = safe_output_path(base, "my_tool", "json").unwrap();
assert!(result.starts_with(&base_canonical));
assert!(result.ends_with("my_tool.json"));
let result = safe_output_path(base, "../../../etc/passwd", "json");
assert!(result.is_err(), "Should reject path traversal patterns");
let result = safe_output_path(base, "tool/with/slashes", "json").unwrap();
assert!(result.starts_with(&base_canonical));
assert!(!result.to_string_lossy().contains("/with/"));
let result = safe_output_path(base, "tool", "txt").unwrap();
assert!(result.ends_with("tool.txt"));
let result = safe_output_path(base, "tool", "").unwrap();
assert!(result.ends_with("tool"));
}
#[test]
#[cfg(unix)] fn test_rejects_symlink_escape_attempts() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let external_dir = TempDir::new().unwrap();
let symlink_path = base.join("escape");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
symlink(external_dir.path(), &symlink_path).unwrap();
let result = validate_output_path(base, "escape/malicious.json");
assert!(result.is_err() || !result.unwrap().starts_with(base));
}
}
#[test]
fn test_handles_existing_files() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let base_canonical = base.canonicalize().unwrap();
let test_file = base.join("existing.json");
fs::write(&test_file, "{}").unwrap();
let result = validate_output_path(base, "existing.json");
assert!(result.is_ok());
let validated = result.unwrap();
assert!(validated.starts_with(&base_canonical));
}
#[test]
fn test_handles_nonexistent_subdirectories() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let base_canonical = base.canonicalize().unwrap();
let result = validate_output_path(base, "newdir/file.json");
assert!(result.is_ok());
let validated = result.unwrap();
assert!(validated.starts_with(&base_canonical));
}
#[test]
fn test_handles_unicode_filenames() {
assert!(sanitize_filename("tool_测试").is_ok());
assert!(sanitize_filename("инструмент").is_ok());
assert!(sanitize_filename("أداة").is_ok());
let sanitized = sanitize_filename("测试/工具").unwrap();
assert!(!sanitized.contains('/'));
}
#[test]
fn test_rejects_overly_long_filenames() {
let long_name = "a".repeat(256);
assert!(sanitize_filename(&long_name).is_err());
let ok_name = "a".repeat(255);
assert!(sanitize_filename(&ok_name).is_ok());
}
#[test]
fn test_malicious_server_scenario() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
let base_canonical = base.canonicalize().unwrap();
let malicious_tool_names = vec![
"../../../etc/passwd",
"../../.ssh/authorized_keys",
"/root/.bash_history",
"CON",
"tool|rm -rf /",
"tool; DROP TABLE users;",
];
for tool_name in malicious_tool_names {
match safe_output_path(base, tool_name, "json") {
Ok(path) => {
assert!(
path.starts_with(&base_canonical),
"Path must be within base dir: {}",
path.display()
);
let path_str = path.to_string_lossy();
assert!(!path_str.contains(".."));
assert!(!path_str.contains("/etc/"));
assert!(!path_str.contains(".ssh"));
assert!(!path_str.contains("rm -rf"));
assert!(!path_str.contains("DROP TABLE"));
}
Err(_) => {
}
}
}
assert!(!Path::new("/etc/passwd").exists() || !Path::new("/etc/passwd-test").exists());
}