use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
const MAX_PATH_VALIDATION_DEPTH: u32 = 20;
fn validate_nonexistent_path(path: &Path, depth: u32) -> Result<PathBuf> {
if depth >= MAX_PATH_VALIDATION_DEPTH {
anyhow::bail!("Path validation depth exceeded (max {MAX_PATH_VALIDATION_DEPTH} levels)");
}
if let Some(parent) = path.parent() {
if parent.as_os_str().is_empty() {
Ok(std::env::current_dir()
.with_context(|| "Failed to get current directory")?
.join(path))
} else if parent.exists() {
let canonical_parent = parent
.canonicalize()
.with_context(|| format!("Failed to canonicalize parent path: {parent:?}"))?;
let file_name = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("Invalid path: no file name component"))?;
let file_name_str = file_name.to_string_lossy();
if file_name_str.contains('/') || file_name_str.contains('\\') {
anyhow::bail!("Invalid file name: contains path separator");
}
Ok(canonical_parent.join(file_name))
} else {
let canonical_parent = validate_nonexistent_path(parent, depth + 1)?;
let file_name = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("Invalid path: no file name component"))?;
let file_name_str = file_name.to_string_lossy();
if file_name_str.contains('/') || file_name_str.contains('\\') {
anyhow::bail!("Invalid file name: contains path separator");
}
Ok(canonical_parent.join(file_name))
}
} else {
Ok(std::env::current_dir()
.with_context(|| "Failed to get current directory")?
.join(path))
}
}
pub fn validate_local_path(path: &Path) -> Result<PathBuf> {
let path_str = path.to_string_lossy();
if path_str.contains("..") {
anyhow::bail!("Path traversal detected: path contains '..'");
}
if path_str.contains("//") {
anyhow::bail!("Invalid path: contains double slashes");
}
let canonical = if path.exists() {
path.canonicalize()
.with_context(|| format!("Failed to canonicalize path: {path:?}"))?
} else {
validate_nonexistent_path(path, 0)?
};
Ok(canonical)
}
pub fn validate_remote_path(path: &str) -> Result<String> {
if path.is_empty() {
anyhow::bail!("Remote path cannot be empty");
}
const MAX_PATH_LENGTH: usize = 4096;
if path.len() > MAX_PATH_LENGTH {
anyhow::bail!("Remote path too long (max {MAX_PATH_LENGTH} characters)");
}
const DANGEROUS_CHARS: &[char] = &[
';', '&', '|', '`', '$', '(', ')', '{', '}', '<', '>', '\n', '\r', '\0', '!', '*', '?',
'[', ']', ];
for &ch in DANGEROUS_CHARS {
if path.contains(ch) {
anyhow::bail!("Remote path contains invalid character: '{ch}'");
}
}
if path.contains("$(") || path.contains("${") || path.contains("`)") {
anyhow::bail!("Remote path contains potential command substitution");
}
if path.contains("../")
|| path.contains("/..")
|| path.starts_with("../")
|| path.starts_with("/..")
|| path.ends_with("/..")
|| path == ".."
{
anyhow::bail!("Remote path contains path traversal sequence");
}
if path.contains("//") && !path.starts_with("//") {
anyhow::bail!("Remote path contains double slashes");
}
let valid_chars = path.chars().all(|c| {
c.is_ascii_alphanumeric()
|| c == '/'
|| c == '\\'
|| c == '.'
|| c == '-'
|| c == '_'
|| c == ' '
|| c == '~'
|| c == '='
|| c == ','
|| c == ':'
|| c == '@'
});
if !valid_chars {
anyhow::bail!("Remote path contains invalid characters");
}
Ok(path.to_string())
}
pub fn validate_hostname(hostname: &str) -> Result<String> {
if hostname.is_empty() {
anyhow::bail!("Hostname cannot be empty");
}
const MAX_HOSTNAME_LENGTH: usize = 253;
if hostname.len() > MAX_HOSTNAME_LENGTH {
anyhow::bail!("Hostname too long (max {MAX_HOSTNAME_LENGTH} characters)");
}
let valid_chars = hostname.chars().all(|c| {
c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':' || c == '[' || c == ']'
});
if !valid_chars {
anyhow::bail!("Hostname contains invalid characters");
}
if hostname.contains("..") || hostname.contains("--") {
anyhow::bail!("Hostname contains suspicious repeated characters");
}
Ok(hostname.to_string())
}
pub fn validate_username(username: &str) -> Result<String> {
if username.is_empty() {
anyhow::bail!("Username cannot be empty");
}
const MAX_USERNAME_LENGTH: usize = 32;
if username.len() > MAX_USERNAME_LENGTH {
anyhow::bail!("Username too long (max {MAX_USERNAME_LENGTH} characters)");
}
let valid_chars = username
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.');
if !valid_chars {
anyhow::bail!("Username contains invalid characters");
}
if username.starts_with('-') {
anyhow::bail!("Username cannot start with a hyphen");
}
Ok(username.to_string())
}
pub fn sanitize_error_message(message: &str) -> String {
let mut sanitized = message.to_string();
if let Some(start) = sanitized.find("user '")
&& let Some(end) = sanitized[start + 6..].find('\'')
{
let before = &sanitized[..start + 5];
let after = &sanitized[start + 6 + end + 1..];
sanitized = format!("{before}<redacted>{after}");
}
let patterns = [
(" on ", " on <host>"),
(" to ", " to <host>"),
(" at ", " at <host>"),
(" from ", " from <host>"),
];
for (pattern, replacement) in &patterns {
if sanitized.contains(pattern) {
let parts: Vec<&str> = sanitized.split(pattern).collect();
let mut result = String::new();
for (i, part) in parts.iter().enumerate() {
result.push_str(part);
if i < parts.len() - 1 {
result.push_str(replacement);
if let Some(next_space) = parts[i + 1].find(' ') {
result.push_str(&parts[i + 1][next_space..]);
}
}
}
sanitized = result;
}
}
let parts: Vec<&str> = sanitized.split_whitespace().collect();
let mut result_parts = Vec::new();
for part in parts {
if part.split('.').count() == 4
&& part
.split('.')
.all(|p| p.parse::<u8>().is_ok() || p.contains(':'))
{
result_parts.push("<ip-address>");
} else {
result_parts.push(part);
}
}
result_parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_local_path() {
assert!(validate_local_path(Path::new("/tmp/test.txt")).is_ok());
assert!(validate_local_path(Path::new("./test.txt")).is_ok());
assert!(validate_local_path(Path::new("../etc/passwd")).is_err());
assert!(validate_local_path(Path::new("/tmp/../etc/passwd")).is_err());
assert!(validate_local_path(Path::new("/tmp//test")).is_err());
}
#[test]
fn test_validate_remote_path() {
assert!(validate_remote_path("/home/user/file.txt").is_ok());
assert!(validate_remote_path("~/documents/report.pdf").is_ok());
assert!(validate_remote_path("C:\\Users\\test\\file.txt").is_ok());
assert!(validate_remote_path("../etc/passwd").is_err());
assert!(validate_remote_path("/tmp/$(whoami)").is_err());
assert!(validate_remote_path("/tmp/test; rm -rf /").is_err());
assert!(validate_remote_path("/tmp/test`id`").is_err());
assert!(validate_remote_path("/tmp/test|cat").is_err());
assert!(validate_remote_path("").is_err());
}
#[test]
fn test_validate_hostname() {
assert!(validate_hostname("example.com").is_ok());
assert!(validate_hostname("192.168.1.1").is_ok());
assert!(validate_hostname("server-01.example.com").is_ok());
assert!(validate_hostname("[::1]").is_ok());
assert!(validate_hostname("example..com").is_err());
assert!(validate_hostname("server--01").is_err());
assert!(validate_hostname("example.com; ls").is_err());
assert!(validate_hostname("").is_err());
}
#[test]
fn test_validate_username() {
assert!(validate_username("john_doe").is_ok());
assert!(validate_username("user123").is_ok());
assert!(validate_username("test.user").is_ok());
assert!(validate_username("-user").is_err());
assert!(validate_username("user@domain").is_err());
assert!(validate_username("user name").is_err());
assert!(validate_username("").is_err());
assert!(validate_username(&"a".repeat(50)).is_err());
}
#[test]
fn test_sanitize_error_message() {
let msg = "192.168.1.1 refused connection";
let sanitized = sanitize_error_message(msg);
assert!(sanitized.contains("<ip-address>"));
assert!(!sanitized.contains("192.168.1.1"));
let msg = "Authentication failed for user 'johndoe'";
let sanitized = sanitize_error_message(msg);
assert!(sanitized.contains("<redacted>"));
assert!(!sanitized.contains("johndoe"));
let msg = "Connection timed out";
let sanitized = sanitize_error_message(msg);
assert_eq!(sanitized, "Connection timed out");
}
}