use anyhow::{anyhow, bail, Result};
use regex::Regex;
use std::path::PathBuf;
const MAX_SCAN_INPUT_SIZE: usize = 10 * 1024 * 1024;
const MIN_USER_PORT: u16 = 1024;
const MAX_PORT: u16 = 65535;
const MAX_PATH_LENGTH: usize = 4096;
const VALID_FEATURES: &[&str] = &[
"unicode",
"injection",
"path",
"advanced",
"enhanced",
"all",
];
const VALID_FORMATS: &[&str] = &["json", "text", "minimal", "compact", "dashboard"];
static SAFE_NAME_PATTERN: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap());
pub fn validate_file_path(path: &str) -> Result<PathBuf> {
if path.len() > MAX_PATH_LENGTH {
bail!("Path too long: maximum {} characters", MAX_PATH_LENGTH);
}
if path.contains('\0') {
bail!("Invalid path: contains null bytes");
}
let path_buf = PathBuf::from(path);
if path.contains("..") {
bail!("Invalid path: directory traversal detected");
}
let canonical = if path_buf.exists() {
path_buf
.canonicalize()
.map_err(|e| anyhow!("Failed to resolve path: {}", e))?
} else {
if let Some(parent) = path_buf.parent() {
if parent.exists() && !parent.is_dir() {
bail!("Parent path is not a directory");
}
}
path_buf
};
Ok(canonical)
}
pub fn validate_scan_input(input: &str) -> Result<String> {
if input.len() > MAX_SCAN_INPUT_SIZE {
bail!(
"Input too large: maximum {} MB",
MAX_SCAN_INPUT_SIZE / 1024 / 1024
);
}
if let Err(e) = std::str::from_utf8(input.as_bytes()) {
bail!("Invalid UTF-8 input: {}", e);
}
Ok(input.to_string())
}
pub fn validate_port(port: u16) -> Result<u16> {
if port < MIN_USER_PORT {
bail!(
"Port {} is reserved. Use ports {} and above",
port,
MIN_USER_PORT
);
}
if port > MAX_PORT {
bail!("Invalid port: {} exceeds maximum", port);
}
Ok(port)
}
pub fn validate_feature_name(feature: &str) -> Result<String> {
let feature_lower = feature.to_lowercase();
if VALID_FEATURES.contains(&feature_lower.as_str()) {
return Ok(feature_lower);
}
if !SAFE_NAME_PATTERN.is_match(feature) {
bail!("Invalid feature name: must be alphanumeric with dashes/underscores");
}
if feature.len() > 50 {
bail!("Feature name too long: maximum 50 characters");
}
Ok(feature.to_string())
}
pub fn validate_format(format: &str) -> Result<String> {
let format_lower = format.to_lowercase();
if VALID_FORMATS.contains(&format_lower.as_str()) {
Ok(format_lower)
} else {
Ok("text".to_string())
}
}
pub fn sanitize_output(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(ch) = chars.next() {
match ch {
'\n' | '\t' => result.push(ch),
'\x1b' => {
result.push(ch);
if chars.next() == Some('[') {
result.push('[');
for ch in chars.by_ref() {
result.push(ch);
if ch == 'm' || !ch.is_ascii() {
break;
}
}
}
},
_ if ch.is_control() => {
},
_ => result.push(ch),
}
}
result
}
pub struct CommandValidator;
impl CommandValidator {
pub fn validate_scan(input: &str, is_text: bool) -> Result<String> {
if is_text {
validate_scan_input(input)
} else {
validate_file_path(input).map(|p| p.to_string_lossy().to_string())
}
}
pub fn validate_info_feature(feature: Option<&str>) -> Result<Option<String>> {
match feature {
Some(f) => validate_feature_name(f).map(Some),
None => Ok(None),
}
}
pub fn validate_dashboard_port(port: u16) -> Result<u16> {
validate_port(port)
}
pub fn validate_format(format: &str) -> Result<String> {
validate_format(format)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_file_path_validation() {
assert!(validate_file_path("/tmp/test.txt").is_ok());
assert!(validate_file_path("./local.file").is_ok());
assert!(validate_file_path("../../../etc/passwd").is_err());
assert!(validate_file_path("/tmp/../etc/passwd").is_err());
assert!(validate_file_path("/tmp/test\0.txt").is_err());
let long_path = "a".repeat(5000);
assert!(validate_file_path(&long_path).is_err());
}
#[test]
fn test_scan_input_validation() {
assert!(validate_scan_input("Hello, world!").is_ok());
assert!(validate_scan_input("Unicode: ä½ å¥½").is_ok());
let large_input = "x".repeat(MAX_SCAN_INPUT_SIZE + 1);
assert!(validate_scan_input(&large_input).is_err());
}
#[test]
fn test_port_validation() {
assert_eq!(validate_port(3000).unwrap(), 3000);
assert_eq!(validate_port(8080).unwrap(), 8080);
assert_eq!(validate_port(65535).unwrap(), 65535);
assert!(validate_port(80).is_err());
assert!(validate_port(443).is_err());
assert!(validate_port(22).is_err());
}
#[test]
fn test_feature_name_validation() {
assert_eq!(validate_feature_name("unicode").unwrap(), "unicode");
assert_eq!(validate_feature_name("INJECTION").unwrap(), "injection");
assert_eq!(
validate_feature_name("custom-feature").unwrap(),
"custom-feature"
);
assert_eq!(validate_feature_name("test_123").unwrap(), "test_123");
assert!(validate_feature_name("../../etc").is_err());
assert!(validate_feature_name("feature with spaces").is_err());
assert!(validate_feature_name("feature!@#").is_err());
}
#[test]
fn test_format_validation() {
assert_eq!(validate_format("json").unwrap(), "json");
assert_eq!(validate_format("JSON").unwrap(), "json");
assert_eq!(validate_format("minimal").unwrap(), "minimal");
assert_eq!(validate_format("invalid").unwrap(), "text");
assert_eq!(validate_format("").unwrap(), "text");
}
#[test]
fn test_output_sanitization() {
assert_eq!(sanitize_output("Hello, world!"), "Hello, world!");
assert_eq!(
sanitize_output("Line 1\nLine 2\tTabbed"),
"Line 1\nLine 2\tTabbed"
);
assert_eq!(
sanitize_output("\x1b[31mRed text\x1b[0m"),
"\x1b[31mRed text\x1b[0m"
);
assert_eq!(sanitize_output("Bad\x00\x01\x02chars"), "Badchars");
assert_eq!(sanitize_output("Bell\x07Alert"), "BellAlert");
}
}
#[cfg(test)]
mod fuzz_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn fuzz_path_validation(path in "\\PC*") {
let _ = validate_file_path(&path);
}
#[test]
fn fuzz_input_validation(input in "\\PC*") {
let _ = validate_scan_input(&input);
}
#[test]
fn fuzz_feature_validation(feature in "\\PC*") {
let _ = validate_feature_name(&feature);
}
#[test]
fn fuzz_output_sanitization(output in "\\PC*") {
let sanitized = sanitize_output(&output);
for ch in sanitized.chars() {
assert!(
!ch.is_control() ||
ch == '\n' ||
ch == '\t' ||
ch == '\x1b'
);
}
}
}
}