use anyhow::{Result, bail};
use tracing::warn;
pub fn sanitize_command(command: &str) -> Result<String> {
if command.trim().is_empty() {
bail!("Empty command not allowed");
}
const MAX_COMMAND_LENGTH: usize = 16384; if command.len() > MAX_COMMAND_LENGTH {
bail!(
"Command too long: {} bytes (max: {} bytes)",
command.len(),
MAX_COMMAND_LENGTH
);
}
if command.contains('\0') {
bail!("Command contains null bytes");
}
let dangerous_patterns = [
("$(", "command substitution"),
("${", "variable substitution with manipulation"),
("`", "backtick command substitution"),
("\n&", "background process after newline"),
(";\n", "command chaining with newline"),
("|\n", "pipe with newline"),
("\\x00", "hex null byte"),
("\\0", "octal null byte"),
(":(){ :|:& };:", "fork bomb"),
("while true", "potential infinite loop"),
("yes |", "potential resource exhaustion"),
];
for (pattern, description) in &dangerous_patterns {
if command.contains(pattern) {
warn!(
"Potentially dangerous pattern detected in command: {} ({})",
pattern, description
);
}
}
let redirection_count = command.matches('>').count() + command.matches('<').count();
if redirection_count > 10 {
warn!("Excessive redirections in command: {}", redirection_count);
}
let pipe_count = command.matches('|').count();
if pipe_count > 10 {
warn!("Excessive pipes in command: {}", pipe_count);
}
Ok(command.to_string())
}
pub fn sanitize_hostname(hostname: &str) -> Result<String> {
if hostname.trim().is_empty() {
bail!("Empty hostname not allowed");
}
const MAX_HOSTNAME_LENGTH: usize = 253; if hostname.len() > MAX_HOSTNAME_LENGTH {
bail!(
"Hostname too long: {} bytes (max: {} bytes)",
hostname.len(),
MAX_HOSTNAME_LENGTH
);
}
let is_ipv6_bracketed = hostname.starts_with('[') && hostname.ends_with(']');
let is_ipv6_raw = !is_ipv6_bracketed && hostname.contains(':');
if is_ipv6_bracketed {
let ipv6_addr = &hostname[1..hostname.len() - 1];
if !ipv6_addr.chars().all(|c| c.is_ascii_hexdigit() || c == ':') {
bail!("Invalid IPv6 address format: {hostname}");
}
} else if is_ipv6_raw {
if !hostname.chars().all(|c| c.is_ascii_hexdigit() || c == ':') {
bail!("Invalid IPv6 address format: {hostname}");
}
} else {
let valid_chars = |c: char| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_';
if !hostname.chars().all(valid_chars) {
bail!("Invalid characters in hostname: {hostname}");
}
if hostname.contains("..") {
bail!("Double dots not allowed in hostname");
}
for segment in hostname.split('.') {
if segment.starts_with('-') || segment.ends_with('-') {
bail!("Hostname segments cannot start or end with hyphen");
}
}
}
Ok(hostname.to_string())
}
pub fn sanitize_username(username: &str) -> Result<String> {
if username.trim().is_empty() {
bail!("Empty username not allowed");
}
const MAX_USERNAME_LENGTH: usize = 32;
if username.len() > MAX_USERNAME_LENGTH {
bail!(
"Username too long: {} bytes (max: {} bytes)",
username.len(),
MAX_USERNAME_LENGTH
);
}
let valid_chars = |c: char| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.';
if !username.chars().all(valid_chars) {
bail!("Invalid characters in username: {username}");
}
if let Some(first_char) = username.chars().next()
&& !first_char.is_ascii_alphabetic()
&& first_char != '_'
{
bail!("Username must start with letter or underscore");
}
Ok(username.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_command_valid() {
assert!(sanitize_command("ls -la").is_ok());
assert!(sanitize_command("echo 'hello world'").is_ok());
assert!(sanitize_command("ps aux | grep ssh").is_ok());
}
#[test]
fn test_sanitize_command_empty() {
assert!(sanitize_command("").is_err());
assert!(sanitize_command(" ").is_err());
}
#[test]
fn test_sanitize_command_null_bytes() {
assert!(sanitize_command("ls\0").is_err());
assert!(sanitize_command("echo\0test").is_err());
}
#[test]
fn test_sanitize_hostname_valid() {
assert!(sanitize_hostname("example.com").is_ok());
assert!(sanitize_hostname("192.168.1.1").is_ok());
assert!(sanitize_hostname("[::1]").is_ok());
assert!(sanitize_hostname("[2001:db8::1]").is_ok());
assert!(sanitize_hostname("::1").is_ok()); assert!(sanitize_hostname("2001:db8::1").is_ok()); assert!(sanitize_hostname("fe80::1").is_ok()); assert!(sanitize_hostname("my-server.local").is_ok());
}
#[test]
fn test_sanitize_hostname_invalid() {
assert!(sanitize_hostname("").is_err());
assert!(sanitize_hostname("example..com").is_err());
assert!(sanitize_hostname("-example.com").is_err());
assert!(sanitize_hostname("example.com-").is_err());
assert!(sanitize_hostname("exam ple.com").is_err());
assert!(sanitize_hostname("example.com;ls").is_err());
}
#[test]
fn test_sanitize_username_valid() {
assert!(sanitize_username("john_doe").is_ok());
assert!(sanitize_username("user123").is_ok());
assert!(sanitize_username("_system").is_ok());
assert!(sanitize_username("alice-bob").is_ok());
}
#[test]
fn test_sanitize_username_invalid() {
assert!(sanitize_username("").is_err());
assert!(sanitize_username("123user").is_err()); assert!(sanitize_username("user name").is_err()); assert!(sanitize_username("user@host").is_err()); assert!(sanitize_username(&"a".repeat(33)).is_err()); }
}