use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
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 {
if let Some(parent) = path.parent() {
if parent.as_os_str().is_empty() {
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");
}
canonical_parent.join(file_name)
} else {
validate_local_path(parent)?;
validate_local_path(path)?
}
} else {
std::env::current_dir()
.with_context(|| "Failed to get current directory")?
.join(path)
}
};
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.starts_with("..") || path.ends_with("..") {
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 '") {
if 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 re_patterns = [
r" on [a-zA-Z0-9\.\-]+:[0-9]+",
r" to [a-zA-Z0-9\.\-]+:[0-9]+",
r" at [a-zA-Z0-9\.\-]+:[0-9]+",
r" from [a-zA-Z0-9\.\-]+:[0-9]+",
];
for _pattern in &re_patterns {
if sanitized.contains(" on ")
|| sanitized.contains(" to ")
|| sanitized.contains(" at ")
|| sanitized.contains(" from ")
{
sanitized = sanitized
.replace(" on ", " on <host>")
.replace(" to ", " to <host>")
.replace(" at ", " at <host>")
.replace(" from ", " from <host>");
}
}
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());
}
}