use crate::errors::AppError;
use serde_json::Value;
use std::collections::HashSet;
use std::net::Ipv4Addr;
use std::sync::OnceLock;
static DISPOSABLE_DOMAINS: OnceLock<HashSet<&str>> = OnceLock::new();
fn disposable_domains() -> &'static HashSet<&'static str> {
DISPOSABLE_DOMAINS.get_or_init(|| {
include_str!("../data/disposable_domains.txt")
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect()
})
}
pub fn disposable_domain_count() -> usize {
disposable_domains().len()
}
pub fn is_disposable_email(email: &str, custom_domains: Option<&HashSet<String>>) -> bool {
let domain = match email.rsplit_once('@') {
Some((_, domain)) => domain.to_lowercase(),
None => return false,
};
if disposable_domains().contains(domain.as_str()) {
return true;
}
if let Some(custom) = custom_domains {
return custom.contains(&domain);
}
false
}
const TYPO_TLDS: &[&str] = &[
"con", "cmo", "ocm", "cm", "vom", "xom", "cpm", "clm", "ney", "met", "bet", "nrt", "ogr", "rog", "prg", "irg", "edi", "rdu", ];
fn is_typo_tld(tld: &str) -> bool {
TYPO_TLDS.contains(&tld)
}
pub fn is_valid_email(email: &str) -> bool {
if email.is_empty() || email.len() > 254 {
return false;
}
if email.contains(' ') {
return false;
}
let parts: Vec<&str> = email.split('@').collect();
if parts.len() != 2 {
return false;
}
let local = parts[0];
let domain = parts[1];
if local.is_empty() || local.len() > 64 {
return false;
}
if local.starts_with('.') || local.ends_with('.') {
return false;
}
if local.contains("..") {
return false;
}
if domain.is_empty() || domain.len() > 253 {
return false;
}
if !domain.contains('.') {
return false;
}
if domain.starts_with('.')
|| domain.ends_with('.')
|| domain.starts_with('-')
|| domain.ends_with('-')
{
return false;
}
for label in domain.split('.') {
if label.starts_with('-') || label.ends_with('-') {
return false;
}
}
if let Some(tld) = domain.rsplit('.').next() {
if tld.len() < 2 {
return false;
}
if !tld.chars().all(|c| c.is_ascii_alphabetic()) {
return false;
}
let tld_lower = tld.to_ascii_lowercase();
if is_typo_tld(&tld_lower) {
return false;
}
}
if !domain
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
return false;
}
if !local.chars().all(|c| {
c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' || c == '+' || c == '!'
}) {
return false;
}
true
}
pub fn is_valid_ipv4(ip: &str) -> bool {
ip.parse::<Ipv4Addr>().is_ok()
}
const SECRET_KEY_PATTERNS: &[&str] = &[
"password",
"passwd",
"secret",
"api_key",
"apikey",
"api-key",
"token",
"auth",
"credential",
"private_key",
"privatekey",
"private-key",
"access_key",
"accesskey",
"access-key",
"secret_key",
"secretkey",
"secret-key",
"bearer",
"jwt",
"session",
"cookie",
"authorization",
];
const SECRET_VALUE_PATTERNS: &[&str] = &[
"sk_live_", "sk_test_", "pk_live_", "pk_test_", "ghp_", "gho_", "ghu_", "ghs_", "AKIA", "eyJ", "bearer ", "basic ", "AIza", "xox", "ssh-rsa", "-----BEGIN", ];
const ALLOWED_REFERENCE_TYPES: &[&str] = &[
"order",
"subscription",
"refund",
"bonus",
"promo",
"correction",
"deposit",
"withdrawal",
];
pub fn validate_reference_type(reference_type: &str) -> Result<(), AppError> {
if !ALLOWED_REFERENCE_TYPES.contains(&reference_type) {
return Err(AppError::Validation(format!(
"Unknown reference_type '{}'. Allowed: {}",
reference_type,
ALLOWED_REFERENCE_TYPES.join(", ")
)));
}
Ok(())
}
const ALLOWED_CURRENCIES: &[&str] = &["SOL", "USD"];
pub fn validate_currency(currency: &str) -> Result<(), AppError> {
if !ALLOWED_CURRENCIES.contains(¤cy) {
return Err(AppError::Validation(format!(
"Unknown currency '{}'. Allowed: {}",
currency,
ALLOWED_CURRENCIES.join(", ")
)));
}
Ok(())
}
pub fn validate_metadata_no_secrets(metadata: Option<&Value>) -> Result<(), AppError> {
let Some(value) = metadata else {
return Ok(());
};
validate_value_recursive(value, 0)
}
fn validate_value_recursive(value: &Value, depth: usize) -> Result<(), AppError> {
if depth > 10 {
return Err(AppError::Validation(
"Metadata nesting too deep (max 10 levels)".into(),
));
}
match value {
Value::Object(map) => {
for (key, val) in map.iter() {
let key_lower = key.to_lowercase();
for pattern in SECRET_KEY_PATTERNS {
if key_lower.contains(pattern) {
return Err(AppError::Validation(format!(
"Metadata key '{}' appears to contain sensitive data. \
Do not store secrets in metadata.",
key
)));
}
}
validate_value_recursive(val, depth + 1)?;
}
}
Value::Array(arr) => {
if arr.len() > 100 {
return Err(AppError::Validation(
"Metadata array too large (max 100 elements)".into(),
));
}
for item in arr {
validate_value_recursive(item, depth + 1)?;
}
}
Value::String(s) => {
if s.len() > 10_000 {
return Err(AppError::Validation(
"Metadata string value too long (max 10000 chars)".into(),
));
}
let s_lower = s.to_lowercase();
for pattern in SECRET_VALUE_PATTERNS {
if s_lower.starts_with(&pattern.to_lowercase()) {
return Err(AppError::Validation(
"Metadata value appears to contain a secret or API key. \
Do not store secrets in metadata."
.into(),
));
}
}
}
_ => {}
}
Ok(())
}
pub fn is_valid_wallet_address(address: &str) -> bool {
if address.len() < 32 || address.len() > 44 {
return false;
}
const BASE58_CHARS: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
address.chars().all(|c| BASE58_CHARS.contains(c))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_emails() {
assert!(is_valid_email("test@example.com"));
assert!(is_valid_email("user.name@example.com"));
assert!(is_valid_email("user+tag@example.com"));
assert!(is_valid_email("user_name@example.co.uk"));
assert!(is_valid_email("a@b.co"));
assert!(is_valid_email("test123@test123.com"));
assert!(is_valid_email("user-name@example.org"));
assert!(is_valid_email("user!@example.com"));
}
#[test]
fn test_invalid_emails_no_at() {
assert!(!is_valid_email("testexample.com"));
assert!(!is_valid_email("test"));
}
#[test]
fn test_invalid_emails_multiple_at() {
assert!(!is_valid_email("test@@example.com"));
assert!(!is_valid_email("test@test@example.com"));
}
#[test]
fn test_invalid_emails_empty_parts() {
assert!(!is_valid_email("@example.com"));
assert!(!is_valid_email("test@"));
assert!(!is_valid_email("@"));
assert!(!is_valid_email(""));
}
#[test]
fn test_invalid_emails_no_tld() {
assert!(!is_valid_email("test@example"));
assert!(!is_valid_email("test@localhost"));
}
#[test]
fn test_invalid_emails_short_tld() {
assert!(!is_valid_email("test@example.c"));
}
#[test]
fn test_invalid_emails_numeric_tld() {
assert!(!is_valid_email("test@example.123"));
}
#[test]
fn test_invalid_emails_spaces() {
assert!(!is_valid_email("test @example.com"));
assert!(!is_valid_email("test@ example.com"));
assert!(!is_valid_email(" test@example.com"));
assert!(!is_valid_email("test@example.com "));
}
#[test]
fn test_invalid_emails_invalid_chars() {
assert!(!is_valid_email("test<>@example.com"));
assert!(!is_valid_email("test@exam ple.com"));
}
#[test]
fn test_invalid_emails_dots() {
assert!(!is_valid_email(".test@example.com"));
assert!(!is_valid_email("test.@example.com"));
assert!(!is_valid_email("test@.example.com"));
assert!(!is_valid_email("test@example.com."));
assert!(!is_valid_email("user..name@example.com"));
assert!(!is_valid_email("user...name@example.com"));
}
#[test]
fn test_invalid_emails_domain_hyphens() {
assert!(!is_valid_email("test@-example.com"));
assert!(!is_valid_email("test@example-.com"));
}
#[test]
fn test_invalid_emails_too_long() {
let long_local = "a".repeat(65);
let long_email = format!("{}@example.com", long_local);
assert!(!is_valid_email(&long_email));
let very_long_email = format!("test@{}.com", "a".repeat(250));
assert!(!is_valid_email(&very_long_email));
}
#[test]
fn test_valid_ipv4() {
assert!(is_valid_ipv4("127.0.0.1"));
assert!(is_valid_ipv4("192.168.1.1"));
assert!(is_valid_ipv4("0.0.0.0"));
assert!(is_valid_ipv4("255.255.255.255"));
}
#[test]
fn test_invalid_ipv4() {
assert!(!is_valid_ipv4("256.0.0.1"));
assert!(!is_valid_ipv4("192.168.1"));
assert!(!is_valid_ipv4("192.168.1.1.1"));
assert!(!is_valid_ipv4("not.an.ip.address"));
assert!(!is_valid_ipv4(""));
}
#[test]
fn test_typo_tlds_rejected() {
assert!(!is_valid_email("test@example.con"));
assert!(!is_valid_email("test@example.cmo"));
assert!(!is_valid_email("test@example.cm"));
assert!(!is_valid_email("test@example.vom"));
assert!(!is_valid_email("test@example.ney"));
assert!(!is_valid_email("test@example.met"));
assert!(!is_valid_email("test@example.ogr"));
assert!(!is_valid_email("test@example.prg"));
assert!(!is_valid_email("test@example.CON"));
assert!(!is_valid_email("test@example.Con"));
}
#[test]
fn test_valid_tlds_allowed() {
assert!(is_valid_email("test@example.com"));
assert!(is_valid_email("test@example.net"));
assert!(is_valid_email("test@example.org"));
assert!(is_valid_email("test@example.edu"));
assert!(is_valid_email("test@example.io"));
assert!(is_valid_email("test@example.co"));
assert!(is_valid_email("test@example.co.uk"));
}
#[test]
fn test_metadata_none_is_valid() {
assert!(validate_metadata_no_secrets(None).is_ok());
}
#[test]
fn test_metadata_safe_values_allowed() {
use serde_json::json;
let metadata = json!({
"order_id": "12345",
"items": ["widget", "gadget"],
"quantity": 5,
"customer_note": "Please ship quickly"
});
assert!(validate_metadata_no_secrets(Some(&metadata)).is_ok());
}
#[test]
fn test_metadata_rejects_password_key() {
use serde_json::json;
let metadata = json!({
"password": "my-secret"
});
let result = validate_metadata_no_secrets(Some(&metadata));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("password"));
}
#[test]
fn test_metadata_rejects_api_key() {
use serde_json::json;
let metadata = json!({
"api_key": "sk_live_xxx"
});
assert!(validate_metadata_no_secrets(Some(&metadata)).is_err());
}
#[test]
fn test_metadata_rejects_secret_key_pattern() {
use serde_json::json;
let metadata = json!({
"user_secret": "abc123"
});
assert!(validate_metadata_no_secrets(Some(&metadata)).is_err());
}
#[test]
fn test_metadata_rejects_stripe_key_value() {
use serde_json::json;
let metadata = json!({
"notes": "sk_live_abcdefg12345"
});
assert!(validate_metadata_no_secrets(Some(&metadata)).is_err());
}
#[test]
fn test_metadata_rejects_jwt_value() {
use serde_json::json;
let metadata = json!({
"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx"
});
assert!(validate_metadata_no_secrets(Some(&metadata)).is_err());
}
#[test]
fn test_metadata_rejects_aws_key_value() {
use serde_json::json;
let metadata = json!({
"info": "AKIAIOSFODNN7EXAMPLE"
});
assert!(validate_metadata_no_secrets(Some(&metadata)).is_err());
}
#[test]
fn test_metadata_rejects_nested_secret() {
use serde_json::json;
let metadata = json!({
"user": {
"profile": {
"api_token": "secret123"
}
}
});
assert!(validate_metadata_no_secrets(Some(&metadata)).is_err());
}
#[test]
fn test_metadata_rejects_too_deep_nesting() {
use serde_json::json;
let mut value = json!("leaf");
for _ in 0..15 {
value = json!({ "nested": value });
}
let result = validate_metadata_no_secrets(Some(&value));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("nesting"));
}
#[test]
fn test_metadata_rejects_large_array() {
use serde_json::json;
let large_array: Vec<i32> = (0..150).collect();
let metadata = json!({ "items": large_array });
let result = validate_metadata_no_secrets(Some(&metadata));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("array"));
}
#[test]
fn test_metadata_rejects_long_string() {
use serde_json::json;
let long_string = "x".repeat(15_000);
let metadata = json!({ "data": long_string });
let result = validate_metadata_no_secrets(Some(&metadata));
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("string"));
}
#[test]
fn test_disposable_email_detected() {
assert!(is_disposable_email("test@mailinator.com", None));
assert!(is_disposable_email("user@guerrillamail.com", None));
assert!(is_disposable_email("temp@10minutemail.com", None));
assert!(is_disposable_email("spam@yopmail.com", None));
assert!(is_disposable_email("throwaway@tempmail.it", None));
}
#[test]
fn test_disposable_email_case_insensitive() {
assert!(is_disposable_email("test@MAILINATOR.COM", None));
assert!(is_disposable_email("test@Mailinator.Com", None));
assert!(is_disposable_email("test@MailInator.COM", None));
}
#[test]
fn test_legitimate_email_allowed() {
assert!(!is_disposable_email("user@gmail.com", None));
assert!(!is_disposable_email("user@outlook.com", None));
assert!(!is_disposable_email("user@yahoo.com", None));
assert!(!is_disposable_email("user@company.com", None));
assert!(!is_disposable_email("user@university.edu", None));
}
#[test]
fn test_disposable_email_invalid_input() {
assert!(!is_disposable_email("notanemail", None));
assert!(!is_disposable_email("", None));
assert!(!is_disposable_email("@", None));
}
#[test]
fn test_disposable_email_custom_domains() {
let custom = HashSet::from(["blocked.example.com".to_string()]);
assert!(is_disposable_email(
"user@blocked.example.com",
Some(&custom)
));
assert!(!is_disposable_email(
"user@allowed.example.com",
Some(&custom)
));
}
#[test]
fn test_disposable_domain_count() {
assert!(disposable_domain_count() > 3000);
}
#[test]
fn test_valid_wallet_addresses() {
assert!(is_valid_wallet_address(
"6o6HrBfnmzpQsMJHJZuQTFhBnXPKadjFnPkKB7p2AFSL"
));
assert!(is_valid_wallet_address("11111111111111111111111111111111"));
assert!(is_valid_wallet_address(
"So11111111111111111111111111111111111111112"
));
}
#[test]
fn test_invalid_wallet_too_short() {
assert!(!is_valid_wallet_address("1234567890123456789012345678901")); assert!(!is_valid_wallet_address("abc"));
assert!(!is_valid_wallet_address(""));
}
#[test]
fn test_invalid_wallet_too_long() {
assert!(!is_valid_wallet_address(
"123456789012345678901234567890123456789012345"
)); }
#[test]
fn test_invalid_wallet_invalid_chars() {
assert!(!is_valid_wallet_address(
"0o6HrBfnmzpQsMJHJZuQTFhBnXPKadjFnPkKB7p2AFSL"
)); assert!(!is_valid_wallet_address(
"Oo6HrBfnmzpQsMJHJZuQTFhBnXPKadjFnPkKB7p2AFSL"
)); assert!(!is_valid_wallet_address(
"Io6HrBfnmzpQsMJHJZuQTFhBnXPKadjFnPkKB7p2AFSL"
)); assert!(!is_valid_wallet_address(
"lo6HrBfnmzpQsMJHJZuQTFhBnXPKadjFnPkKB7p2AFSL"
)); }
}