use anyhow::{Context, Result};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, Error)]
pub enum ValidationError {
#[error("Input validation failed: {0}")]
InvalidInput(String),
#[error("Input too long: {current} > {max}")]
TooLong { current: usize, max: usize },
#[error("Input contains malicious content")]
MaliciousContent,
#[error("Invalid format")]
InvalidFormat,
}
pub const MAX_MESSAGE_LENGTH: usize = 100_000; pub const MAX_USERNAME_LENGTH: usize = 64;
pub const MAX_PATH_LENGTH: usize = 260; pub const MAX_FOUR_WORDS_LENGTH: usize = 100;
#[derive(Debug, Clone)]
pub struct InputValidator {
#[allow(dead_code)]
four_words_pattern: Option<Regex>,
username_pattern: Option<Regex>,
sql_injection_pattern: Option<Regex>,
script_injection_pattern: Option<Regex>,
}
impl Default for InputValidator {
fn default() -> Self {
Self::new()
}
}
impl InputValidator {
pub fn new() -> Self {
Self {
four_words_pattern: Regex::new(r"^[a-z]+-[a-z]+-[a-z]+-[a-z]+$").ok(),
username_pattern: Regex::new(r"^[a-zA-Z0-9_-]{3,64}$").ok(),
sql_injection_pattern: Regex::new(
r"(?i)\b(select|insert|update|delete|drop|create|alter|exec|union)\b",
)
.ok(),
script_injection_pattern: Regex::new(r"(?i)(<script|javascript:|vbscript:|on\w+\s*=)")
.ok(),
}
}
pub fn validate_four_words(&self, input: &str) -> Result<String, ValidationError> {
if input.is_empty() {
return Err(ValidationError::InvalidInput(
"Four-word address cannot be empty".to_string(),
));
}
if input.len() > MAX_FOUR_WORDS_LENGTH {
return Err(ValidationError::TooLong {
current: input.len(),
max: MAX_FOUR_WORDS_LENGTH,
});
}
if self.contains_malicious_content(input) {
return Err(ValidationError::MaliciousContent);
}
let sanitized = input.trim().to_lowercase();
let candidate = sanitized.replace([' ', '_'], "-");
if !crate::identity::validate_identity_format(&candidate) {
return Err(ValidationError::InvalidFormat);
}
Ok(candidate)
}
pub fn validate_four_word_address(&self, input: &str) -> Result<String, ValidationError> {
self.validate_four_words(input)
}
pub fn validate_title(&self, input: &str) -> Result<String, ValidationError> {
if input.is_empty() {
return Err(ValidationError::InvalidInput(
"Title cannot be empty".to_string(),
));
}
if input.len() > 200 {
return Err(ValidationError::TooLong {
current: input.len(),
max: 200,
});
}
if self.contains_malicious_content(input) {
return Err(ValidationError::MaliciousContent);
}
Ok(input.trim().to_string())
}
pub fn validate_description(&self, input: &str) -> Result<String, ValidationError> {
if input.len() > 1000 {
return Err(ValidationError::TooLong {
current: input.len(),
max: 1000,
});
}
if self.contains_malicious_content(input) {
return Err(ValidationError::MaliciousContent);
}
Ok(input.trim().to_string())
}
pub fn validate_content(&self, input: &str) -> Result<String, ValidationError> {
if input.is_empty() {
return Err(ValidationError::InvalidInput(
"Content cannot be empty".to_string(),
));
}
if input.len() > 10 * 1024 * 1024 {
return Err(ValidationError::TooLong {
current: input.len(),
max: 10 * 1024 * 1024,
});
}
if self
.script_injection_pattern
.as_ref()
.is_some_and(|re| re.is_match(input))
{
return Err(ValidationError::MaliciousContent);
}
Ok(input.to_string())
}
pub fn validate_tags(&self, tags: &[String]) -> Result<Vec<String>, ValidationError> {
if tags.len() > 10 {
return Err(ValidationError::TooLong {
current: tags.len(),
max: 10,
});
}
let mut validated_tags = Vec::new();
for tag in tags {
if tag.is_empty() {
continue; }
if tag.len() > 50 {
return Err(ValidationError::TooLong {
current: tag.len(),
max: 50,
});
}
if self.contains_malicious_content(tag) {
return Err(ValidationError::MaliciousContent);
}
validated_tags.push(tag.trim().to_lowercase());
}
Ok(validated_tags)
}
pub fn validate_file_name(&self, input: &str) -> Result<String, ValidationError> {
if input.is_empty() {
return Err(ValidationError::InvalidInput(
"File name cannot be empty".to_string(),
));
}
if input.len() > 255 {
return Err(ValidationError::TooLong {
current: input.len(),
max: 255,
});
}
if input.contains('/') || input.contains('\\') || input.contains('\0') {
return Err(ValidationError::InvalidFormat);
}
if self.contains_malicious_content(input) {
return Err(ValidationError::MaliciousContent);
}
Ok(input.trim().to_string())
}
pub fn validate_content_type(&self, input: &str) -> Result<String, ValidationError> {
if input.is_empty() {
return Err(ValidationError::InvalidInput(
"Content type cannot be empty".to_string(),
));
}
if input.len() > 100 {
return Err(ValidationError::TooLong {
current: input.len(),
max: 100,
});
}
if self.contains_malicious_content(input) {
return Err(ValidationError::MaliciousContent);
}
Ok(input.trim().to_lowercase())
}
pub fn validate_uuid(&self, input: &str) -> Result<String, ValidationError> {
if input.is_empty() {
return Err(ValidationError::InvalidInput(
"UUID cannot be empty".to_string(),
));
}
let uuid_pattern = Regex::new(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
)
.map_err(|_| ValidationError::InvalidFormat)?;
if !uuid_pattern.is_match(input) {
return Err(ValidationError::InvalidFormat);
}
Ok(input.to_lowercase())
}
pub fn validate_username(&self, input: &str) -> Result<String> {
if input.is_empty() {
return Err(anyhow::anyhow!("Username cannot be empty"));
}
if input.len() > MAX_USERNAME_LENGTH {
return Err(anyhow::anyhow!(
"Username too long: {} > {}",
input.len(),
MAX_USERNAME_LENGTH
));
}
if self.contains_malicious_content(input) {
return Err(anyhow::anyhow!(
"Username contains potentially malicious content"
));
}
let sanitized = input.trim();
if !self
.username_pattern
.as_ref()
.is_some_and(|re| re.is_match(sanitized))
{
return Err(anyhow::anyhow!(
"Invalid username format. Only alphanumeric characters, hyphens, and underscores allowed"
));
}
Ok(sanitized.to_string())
}
pub fn validate_message_content(&self, input: &str) -> Result<String> {
if input.is_empty() {
return Err(anyhow::anyhow!("Message content cannot be empty"));
}
if input.len() > MAX_MESSAGE_LENGTH {
return Err(anyhow::anyhow!(
"Message too long: {} > {}",
input.len(),
MAX_MESSAGE_LENGTH
));
}
if self
.sql_injection_pattern
.as_ref()
.is_some_and(|re| re.is_match(input))
{
return Err(anyhow::anyhow!(
"Message content contains potentially malicious SQL patterns"
));
}
if self
.script_injection_pattern
.as_ref()
.is_some_and(|re| re.is_match(input))
{
return Err(anyhow::anyhow!(
"Message content contains potentially malicious scripts"
));
}
let sanitized = input
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\r' || *c == '\t')
.collect::<String>();
Ok(sanitized)
}
pub fn validate_file_path(&self, input: &str) -> Result<PathBuf> {
if input.is_empty() {
return Err(anyhow::anyhow!("File path cannot be empty"));
}
if input.len() > MAX_PATH_LENGTH {
return Err(anyhow::anyhow!(
"File path too long: {} > {}",
input.len(),
MAX_PATH_LENGTH
));
}
if input.contains("..")
|| input.contains("./")
|| input.contains("\\..")
|| input.contains(".\\")
{
return Err(anyhow::anyhow!(
"Path contains directory traversal patterns"
));
}
if Path::new(input).is_absolute() {
return Err(anyhow::anyhow!("Absolute paths not allowed"));
}
if input.contains('\0') || input.contains('\x01') {
return Err(anyhow::anyhow!("Path contains invalid characters"));
}
let path = PathBuf::from(input);
match path.canonicalize() {
Ok(canonical) => {
if canonical.to_string_lossy().contains("..") {
return Err(anyhow::anyhow!(
"Canonicalized path contains traversal patterns"
));
}
}
Err(_) => {
}
}
Ok(path)
}
fn contains_malicious_content(&self, input: &str) -> bool {
if self
.sql_injection_pattern
.as_ref()
.is_some_and(|re| re.is_match(input))
{
return true;
}
if self
.script_injection_pattern
.as_ref()
.is_some_and(|re| re.is_match(input))
{
return true;
}
if input.contains('\0')
|| input
.chars()
.any(|c| c.is_control() && c != '\n' && c != '\r' && c != '\t')
{
return true;
}
false
}
pub fn validate_json_input<T>(&self, input: &str) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
if input.is_empty() {
return Err(anyhow::anyhow!("JSON input cannot be empty"));
}
if input.len() > MAX_MESSAGE_LENGTH {
return Err(anyhow::anyhow!(
"JSON input too large: {} > {}",
input.len(),
MAX_MESSAGE_LENGTH
));
}
serde_json::from_str(input).with_context(|| "Failed to parse JSON input")
}
pub fn sanitize_string(&self, input: &str, max_length: usize) -> Result<String> {
if input.is_empty() {
return Err(anyhow::anyhow!("Input cannot be empty"));
}
if input.len() > max_length {
return Err(anyhow::anyhow!(
"Input too long: {} > {}",
input.len(),
max_length
));
}
if self.contains_malicious_content(input) {
return Err(anyhow::anyhow!(
"Input contains potentially malicious content"
));
}
let sanitized = input
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\r' || *c == '\t')
.collect::<String>()
.trim()
.to_string();
if sanitized.is_empty() {
return Err(anyhow::anyhow!("Input becomes empty after sanitization"));
}
Ok(sanitized)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedFourWords {
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedUsername {
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedMessage {
pub content: String,
pub message_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatedPath {
pub path: String,
}
lazy_static::lazy_static! {
static ref USERNAME_REGEX: Option<Regex> = Regex::new(r"^[a-zA-Z0-9_-]{3,64}$").ok();
}
pub type ValidationResult<T> = Result<T, ValidationError>;
pub trait ValidatedInput: Sized {
fn validate_with(validator: &InputValidator, input: &str) -> Result<Self>;
}
impl ValidatedInput for ValidatedFourWords {
fn validate_with(validator: &InputValidator, input: &str) -> Result<Self> {
let validated_value = validator.validate_four_words(input)?;
let instance = Self {
value: validated_value,
};
Ok(instance)
}
}
impl ValidatedInput for ValidatedUsername {
fn validate_with(validator: &InputValidator, input: &str) -> Result<Self> {
let validated_value = validator.validate_username(input)?;
let instance = Self {
value: validated_value,
};
Ok(instance)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_four_words_validation() {
let validator = InputValidator::new();
assert!(
validator
.validate_four_words("hello-world-test-network")
.is_ok()
);
assert!(validator.validate_four_words("a-b-c-d").is_ok());
assert!(validator.validate_four_words("hello-world-test").is_err()); assert!(
validator
.validate_four_words("hello_world_test_network")
.is_err()
); assert!(
validator
.validate_four_words("hello world test network")
.is_err()
); assert!(validator.validate_four_words("").is_err()); assert!(
validator
.validate_four_words("Hello-World-Test-Network")
.is_ok()
); }
#[test]
fn test_username_validation() {
let validator = InputValidator::new();
assert!(validator.validate_username("user123").is_ok());
assert!(validator.validate_username("test-user").is_ok());
assert!(validator.validate_username("user_name").is_ok());
assert!(validator.validate_username("ab").is_err()); assert!(validator.validate_username("user@example.com").is_err()); assert!(validator.validate_username("").is_err()); assert!(validator.validate_username(&"a".repeat(65)).is_err()); }
#[test]
fn test_path_validation() {
let validator = InputValidator::new();
assert!(validator.validate_file_path("documents/test.md").is_ok());
assert!(validator.validate_file_path("file.txt").is_ok());
assert!(validator.validate_file_path("../etc/passwd").is_err());
assert!(
validator
.validate_file_path("documents/../../../secret")
.is_err()
);
assert!(validator.validate_file_path("/absolute/path").is_err());
assert!(validator.validate_file_path("").is_err());
}
#[test]
fn test_malicious_content_detection() {
let validator = InputValidator::new();
assert!(
validator
.validate_username("admin'; DROP TABLE users;--")
.is_err()
);
assert!(
validator
.validate_message_content("SELECT * FROM secrets")
.is_err()
);
assert!(
validator
.validate_message_content("<script>alert('xss')</script>")
.is_err()
);
assert!(
validator
.validate_message_content("javascript:alert('xss')")
.is_err()
);
assert!(
validator
.validate_message_content("This is a normal message")
.is_ok()
);
}
#[test]
fn test_message_length_limits() {
let validator = InputValidator::new();
let long_message = "a".repeat(MAX_MESSAGE_LENGTH + 1);
assert!(validator.validate_message_content(&long_message).is_err());
let max_message = "a".repeat(MAX_MESSAGE_LENGTH);
assert!(validator.validate_message_content(&max_message).is_ok());
}
#[test]
fn test_json_validation() {
let validator = InputValidator::new();
let valid_json = r#"{"name": "test", "value": 123}"#;
let result: Result<serde_json::Value, _> = validator.validate_json_input(valid_json);
assert!(result.is_ok());
let invalid_json = r#"{"name": "test", "value": }"#;
let result: Result<serde_json::Value, _> = validator.validate_json_input(invalid_json);
assert!(result.is_err());
let empty_json = "";
let result: Result<serde_json::Value, _> = validator.validate_json_input(empty_json);
assert!(result.is_err());
let oversized_json = format!(r#"{{"data": "{}"}}"#, "x".repeat(MAX_MESSAGE_LENGTH));
let result: Result<serde_json::Value, _> = validator.validate_json_input(&oversized_json);
assert!(result.is_err());
}
#[test]
fn test_sanitize_string() {
let validator = InputValidator::new();
let result = validator.sanitize_string("Hello World", 100);
assert_eq!(result.unwrap(), "Hello World");
let result = validator.sanitize_string("Hello\n\r\tWorld", 100);
assert_eq!(result.unwrap(), "Hello\n\r\tWorld");
let result = validator.sanitize_string("Hello\x00World", 100);
assert!(result.is_err());
let result = validator.sanitize_string("", 100);
assert!(result.is_err());
let long_string = "a".repeat(101);
let result = validator.sanitize_string(&long_string, 100);
assert!(result.is_err());
let result = validator.sanitize_string("\x01\x02\x03", 100);
assert!(result.is_err());
}
#[test]
fn test_title_validation() {
let validator = InputValidator::new();
let result = validator.validate_title("My Project Title");
assert_eq!(result.unwrap(), "My Project Title");
let result = validator.validate_title("");
assert!(result.is_err());
let long_title = "a".repeat(201);
let result = validator.validate_title(&long_title);
assert!(result.is_err());
let result = validator.validate_title("Project <script>alert('xss')</script>");
assert!(result.is_err());
}
#[test]
fn test_description_validation() {
let validator = InputValidator::new();
let result = validator.validate_description("This is a project description");
assert_eq!(result.unwrap(), "This is a project description");
let result = validator.validate_description("");
assert_eq!(result.unwrap(), "");
let long_desc = "a".repeat(1001);
let result = validator.validate_description(&long_desc);
assert!(result.is_err());
let result =
validator.validate_description("Description with <script>alert('xss')</script> code");
assert!(result.is_err());
}
#[test]
fn test_content_validation() {
let validator = InputValidator::new();
let result = validator.validate_content("This is valid content");
assert_eq!(result.unwrap(), "This is valid content");
let result = validator.validate_content("");
assert!(result.is_err());
let long_content = "a".repeat(10 * 1024 * 1024 + 1);
let result = validator.validate_content(&long_content);
assert!(result.is_err());
let result =
validator.validate_content("Content with <script>alert('xss')</script> scripts");
assert!(result.is_err());
}
#[test]
fn test_tags_validation() {
let validator = InputValidator::new();
let tags = vec![
"rust".to_string(),
"security".to_string(),
"p2p".to_string(),
];
let result = validator.validate_tags(&tags);
assert_eq!(result.unwrap().len(), 3);
let many_tags = (0..11).map(|i| format!("tag{}", i)).collect::<Vec<_>>();
let result = validator.validate_tags(&many_tags);
assert!(result.is_err());
let oversized_tag = "a".repeat(51);
let tags = vec![oversized_tag];
let result = validator.validate_tags(&tags);
assert!(result.is_err());
let tags = vec![
"normal".to_string(),
"<script>alert('xss')</script>".to_string(),
];
let result = validator.validate_tags(&tags);
assert!(result.is_err());
}
#[test]
fn test_file_name_validation() {
let validator = InputValidator::new();
assert!(validator.validate_file_name("document.pdf").is_ok());
assert!(validator.validate_file_name("my-file_v2.txt").is_ok());
assert!(validator.validate_file_name("").is_err());
let long_name = "a".repeat(256);
assert!(validator.validate_file_name(&long_name).is_err());
assert!(validator.validate_file_name("../secret.txt").is_err());
assert!(validator.validate_file_name("../../../etc/passwd").is_err());
assert!(validator.validate_file_name("file.txt\x00").is_err());
assert!(validator.validate_file_name("file<script>.txt").is_err());
}
#[test]
fn test_content_type_validation() {
let validator = InputValidator::new();
assert!(validator.validate_content_type("application/pdf").is_ok());
assert!(validator.validate_content_type("text/plain").is_ok());
assert!(validator.validate_content_type("image/jpeg").is_ok());
assert!(validator.validate_content_type("").is_err());
let long_type = "a".repeat(101);
assert!(validator.validate_content_type(&long_type).is_err());
assert!(
validator
.validate_content_type("text/html<script>")
.is_err()
);
}
#[test]
fn test_uuid_validation() {
let validator = InputValidator::new();
let valid_uuid = "550e8400-e29b-41d4-a716-446655440000";
let result = validator.validate_uuid(valid_uuid);
assert_eq!(result.unwrap(), valid_uuid.to_lowercase());
assert!(validator.validate_uuid("").is_err());
assert!(validator.validate_uuid("not-a-uuid").is_err());
assert!(validator.validate_uuid("550e8400-e29b-41d4-a716").is_err()); assert!(
validator
.validate_uuid("550e8400-e29b-41d4-a716-446655440000-extra")
.is_err()
); }
}