use regex::Regex;
use std::path::Path;
use thiserror::Error;
pub const MIN_PASSWORD_LENGTH: usize = 12;
pub const MAX_BALANCE_SOL: u64 = 1_000_000_000;
pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ValidationError {
#[error("Device path cannot be empty")]
DevicePathEmpty,
#[error("Invalid device path: contains path traversal sequences")]
DevicePathTraversal,
#[error("Invalid device path: contains null bytes")]
DevicePathNullByte,
#[error("Invalid device path: must start with /dev/")]
DevicePathNotDev,
#[error("Invalid device path: unexpected device name format")]
DevicePathBadFormat,
#[error("Invalid device path: must be a drive letter (e.g., D:)")]
DevicePathNotDriveLetter,
#[error("Mount point cannot be empty")]
MountPointEmpty,
#[error("Invalid mount point: contains path traversal")]
MountPointTraversal,
#[error("Invalid mount point: contains null bytes")]
MountPointNullByte,
#[error("Invalid mount point: not under an allowed prefix for {0}")]
MountPointDisallowed(Platform),
#[error("Password cannot be empty")]
PasswordEmpty,
#[error("Password must be at least {0} characters long")]
PasswordTooShort(usize),
#[error("Password must contain at least one uppercase letter")]
PasswordNoUppercase,
#[error("Password must contain at least one lowercase letter")]
PasswordNoLowercase,
#[error("Password must contain at least one number")]
PasswordNoDigit,
#[error("Password is too common. Please choose a stronger password")]
PasswordCommon,
#[error("Address cannot be empty")]
AddressEmpty,
#[error("Invalid address length")]
AddressLength,
#[error("Invalid characters in address (must be base58)")]
AddressBadChars,
#[error("Invalid Solana address: decoded key is not 32 bytes")]
AddressBadDecode,
#[error("Balance must be non-negative")]
BalanceNegative,
#[error("Balance exceeds maximum possible value ({0} SOL)")]
BalanceTooLarge(u64),
#[error("Amount must be greater than 0")]
AmountNotPositive,
#[error("Amount exceeds maximum ({0} SOL)")]
AmountTooLarge(u64),
#[error("Amount exceeds available balance")]
AmountExceedsBalance,
#[error("Amount has too many decimal places (max 9)")]
AmountPrecision,
#[error("RPC URL cannot be empty")]
RpcUrlEmpty,
#[error("RPC URL must start with http:// or https://")]
RpcUrlBadScheme,
#[error("Invalid RPC URL format")]
RpcUrlBadFormat,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationWarning {
InsecureHttp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Linux,
Darwin,
Windows,
}
impl std::fmt::Display for Platform {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Platform::Linux => write!(f, "Linux"),
Platform::Darwin => write!(f, "Darwin"),
Platform::Windows => write!(f, "Windows"),
}
}
}
pub fn validate_device_path(path: &str, platform: Platform) -> Result<(), ValidationError> {
if path.is_empty() {
return Err(ValidationError::DevicePathEmpty);
}
if path.contains("..") || path.contains("//") {
return Err(ValidationError::DevicePathTraversal);
}
if path.contains('\0') {
return Err(ValidationError::DevicePathNullByte);
}
match platform {
Platform::Linux | Platform::Darwin => {
if !path.starts_with("/dev/") {
return Err(ValidationError::DevicePathNotDev);
}
let re = Regex::new(
r"^/dev/(sd[a-z]\d*|disk\d+s?\d*|mmcblk\d+p?\d*|nvme\d+|nvme\d+n\d+p?\d*)$",
)
.expect("device path regex is valid");
if !re.is_match(path) {
return Err(ValidationError::DevicePathBadFormat);
}
}
Platform::Windows => {
let re = Regex::new(r"(?i)^[A-Z]:\\?$").expect("windows drive regex is valid");
if !re.is_match(path) {
return Err(ValidationError::DevicePathNotDriveLetter);
}
}
}
Ok(())
}
pub fn validate_mount_point(mount_point: &str, platform: Platform) -> Result<(), ValidationError> {
if mount_point.is_empty() {
return Err(ValidationError::MountPointEmpty);
}
if mount_point.contains("..") {
return Err(ValidationError::MountPointTraversal);
}
if mount_point.contains('\0') {
return Err(ValidationError::MountPointNullByte);
}
let resolved = Path::new(mount_point)
.to_str()
.unwrap_or(mount_point);
match platform {
Platform::Linux => {
let allowed = ["/media/", "/mnt/", "/run/media/", "/tmp/solana_usb_"];
if !allowed.iter().any(|prefix| resolved.starts_with(prefix)) {
return Err(ValidationError::MountPointDisallowed(Platform::Linux));
}
}
Platform::Darwin => {
if !resolved.starts_with("/Volumes/") {
return Err(ValidationError::MountPointDisallowed(Platform::Darwin));
}
}
Platform::Windows => {
let re =
Regex::new(r"(?i)^[A-Z]:\\").expect("windows mount point regex is valid");
if !re.is_match(resolved) {
return Err(ValidationError::MountPointDisallowed(Platform::Windows));
}
}
}
Ok(())
}
pub fn validate_password_strength(password: &str) -> Result<(), ValidationError> {
if password.is_empty() {
return Err(ValidationError::PasswordEmpty);
}
if password.len() < MIN_PASSWORD_LENGTH {
return Err(ValidationError::PasswordTooShort(MIN_PASSWORD_LENGTH));
}
if !password.chars().any(|c| c.is_ascii_uppercase()) {
return Err(ValidationError::PasswordNoUppercase);
}
if !password.chars().any(|c| c.is_ascii_lowercase()) {
return Err(ValidationError::PasswordNoLowercase);
}
if !password.chars().any(|c| c.is_ascii_digit()) {
return Err(ValidationError::PasswordNoDigit);
}
const COMMON: &[&str] = &[
"password",
"12345678",
"123456789",
"1234567890",
"qwerty",
"abc123",
"password123",
"admin",
"letmein",
"welcome",
"monkey",
"1234",
"password1",
"123456",
"qwerty123",
"password123456",
"qwerty123456",
];
let lower = password.to_ascii_lowercase();
if COMMON.iter().any(|&common| lower == common) {
return Err(ValidationError::PasswordCommon);
}
Ok(())
}
pub fn validate_solana_address(address: &str) -> Result<(), ValidationError> {
if address.is_empty() {
return Err(ValidationError::AddressEmpty);
}
if address.len() < 32 || address.len() > 44 {
return Err(ValidationError::AddressLength);
}
const BASE58_CHARS: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
if !address.chars().all(|c| BASE58_CHARS.contains(c)) {
return Err(ValidationError::AddressBadChars);
}
let decoded = bs58::decode(address)
.into_vec()
.map_err(|_| ValidationError::AddressBadDecode)?;
if decoded.len() != 32 {
return Err(ValidationError::AddressBadDecode);
}
Ok(())
}
pub fn validate_balance_value(balance: f64) -> Result<(), ValidationError> {
if balance < 0.0 {
return Err(ValidationError::BalanceNegative);
}
if balance > MAX_BALANCE_SOL as f64 {
return Err(ValidationError::BalanceTooLarge(MAX_BALANCE_SOL));
}
Ok(())
}
pub fn validate_amount_sol(amount: f64, max_balance: Option<f64>) -> Result<(), ValidationError> {
if amount <= 0.0 {
return Err(ValidationError::AmountNotPositive);
}
if amount > MAX_BALANCE_SOL as f64 {
return Err(ValidationError::AmountTooLarge(MAX_BALANCE_SOL));
}
if let Some(max) = max_balance {
if amount > max {
return Err(ValidationError::AmountExceedsBalance);
}
}
let lamports = (amount * LAMPORTS_PER_SOL as f64) as u64;
let reconstructed = lamports as f64 / LAMPORTS_PER_SOL as f64;
if (amount - reconstructed).abs() > 1e-9 {
return Err(ValidationError::AmountPrecision);
}
Ok(())
}
pub fn sanitize_filename(name: &str, max_length: usize) -> String {
if name.is_empty() {
return "unnamed".to_string();
}
let basename = name
.rsplit(|c| c == '/' || c == '\\')
.next()
.unwrap_or(name);
let no_nulls: String = basename.chars().filter(|&c| c != '\0').collect();
let re = Regex::new(r"[^\w.\-]").expect("sanitize regex is valid");
let mut sanitized = re.replace_all(&no_nulls, "_").to_string();
if sanitized.starts_with('.') {
sanitized = format!("_{}", &sanitized[1..]);
}
if sanitized.len() > max_length {
if let Some(dot_pos) = sanitized.rfind('.') {
let ext = &sanitized[dot_pos..];
let name_budget = max_length.saturating_sub(ext.len());
sanitized = format!("{}{}", &sanitized[..name_budget], ext);
} else {
sanitized.truncate(max_length);
}
}
if sanitized.is_empty() || sanitized == "." {
return "unnamed".to_string();
}
sanitized
}
pub fn validate_rpc_url(url_str: &str) -> Result<Option<ValidationWarning>, ValidationError> {
if url_str.is_empty() {
return Err(ValidationError::RpcUrlEmpty);
}
if !url_str.starts_with("http://") && !url_str.starts_with("https://") {
return Err(ValidationError::RpcUrlBadScheme);
}
let re = Regex::new(
r"^https?://(?:[A-Za-z0-9\-]+\.)*[A-Za-z0-9\-]+(?::\d{1,5})?(?:/.*)?$",
)
.expect("rpc url regex is valid");
if !re.is_match(url_str) {
return Err(ValidationError::RpcUrlBadFormat);
}
if url_str.starts_with("http://")
&& !url_str.starts_with("http://localhost")
&& !url_str.starts_with("http://127.0.0.1")
{
return Ok(Some(ValidationWarning::InsecureHttp));
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn device_path_valid_linux_sd() {
assert!(validate_device_path("/dev/sda", Platform::Linux).is_ok());
assert!(validate_device_path("/dev/sda1", Platform::Linux).is_ok());
assert!(validate_device_path("/dev/sdb", Platform::Linux).is_ok());
}
#[test]
fn device_path_valid_linux_nvme() {
assert!(validate_device_path("/dev/nvme0", Platform::Linux).is_ok());
assert!(validate_device_path("/dev/nvme0n1", Platform::Linux).is_ok());
assert!(validate_device_path("/dev/nvme0n1p1", Platform::Linux).is_ok());
}
#[test]
fn device_path_valid_linux_mmcblk() {
assert!(validate_device_path("/dev/mmcblk0", Platform::Linux).is_ok());
assert!(validate_device_path("/dev/mmcblk0p1", Platform::Linux).is_ok());
}
#[test]
fn device_path_valid_darwin() {
assert!(validate_device_path("/dev/disk2", Platform::Darwin).is_ok());
assert!(validate_device_path("/dev/disk2s1", Platform::Darwin).is_ok());
}
#[test]
fn device_path_valid_windows() {
assert!(validate_device_path("D:", Platform::Windows).is_ok());
assert!(validate_device_path("D:\\", Platform::Windows).is_ok());
assert!(validate_device_path("E:", Platform::Windows).is_ok());
}
#[test]
fn device_path_empty() {
assert_eq!(
validate_device_path("", Platform::Linux),
Err(ValidationError::DevicePathEmpty)
);
}
#[test]
fn device_path_traversal() {
assert_eq!(
validate_device_path("/dev/../etc/passwd", Platform::Linux),
Err(ValidationError::DevicePathTraversal)
);
assert_eq!(
validate_device_path("/dev//sda", Platform::Linux),
Err(ValidationError::DevicePathTraversal)
);
}
#[test]
fn device_path_null_byte() {
assert_eq!(
validate_device_path("/dev/sda\0", Platform::Linux),
Err(ValidationError::DevicePathNullByte)
);
}
#[test]
fn device_path_not_dev() {
assert_eq!(
validate_device_path("/tmp/sda", Platform::Linux),
Err(ValidationError::DevicePathNotDev)
);
}
#[test]
fn device_path_bad_format() {
assert_eq!(
validate_device_path("/dev/foo", Platform::Linux),
Err(ValidationError::DevicePathBadFormat)
);
}
#[test]
fn device_path_windows_bad() {
assert_eq!(
validate_device_path("/dev/sda", Platform::Windows),
Err(ValidationError::DevicePathNotDriveLetter)
);
}
#[test]
fn mount_point_valid_linux() {
assert!(validate_mount_point("/media/usb", Platform::Linux).is_ok());
assert!(validate_mount_point("/mnt/data", Platform::Linux).is_ok());
assert!(validate_mount_point("/run/media/user/stick", Platform::Linux).is_ok());
assert!(validate_mount_point("/tmp/solana_usb_abc", Platform::Linux).is_ok());
}
#[test]
fn mount_point_valid_darwin() {
assert!(validate_mount_point("/Volumes/USB", Platform::Darwin).is_ok());
}
#[test]
fn mount_point_valid_windows() {
assert!(validate_mount_point("D:\\MyUSB", Platform::Windows).is_ok());
}
#[test]
fn mount_point_empty() {
assert_eq!(
validate_mount_point("", Platform::Linux),
Err(ValidationError::MountPointEmpty)
);
}
#[test]
fn mount_point_traversal() {
assert_eq!(
validate_mount_point("/media/../etc", Platform::Linux),
Err(ValidationError::MountPointTraversal)
);
}
#[test]
fn mount_point_null_byte() {
assert_eq!(
validate_mount_point("/media/usb\0", Platform::Linux),
Err(ValidationError::MountPointNullByte)
);
}
#[test]
fn mount_point_disallowed_linux() {
assert_eq!(
validate_mount_point("/home/user", Platform::Linux),
Err(ValidationError::MountPointDisallowed(Platform::Linux))
);
}
#[test]
fn mount_point_disallowed_darwin() {
assert_eq!(
validate_mount_point("/tmp/usb", Platform::Darwin),
Err(ValidationError::MountPointDisallowed(Platform::Darwin))
);
}
#[test]
fn mount_point_disallowed_windows() {
assert_eq!(
validate_mount_point("/media/usb", Platform::Windows),
Err(ValidationError::MountPointDisallowed(Platform::Windows))
);
}
#[test]
fn password_valid() {
assert!(validate_password_strength("Str0ngP@ssw0rd!").is_ok());
assert!(validate_password_strength("MySecure1Pass").is_ok());
}
#[test]
fn password_empty() {
assert_eq!(
validate_password_strength(""),
Err(ValidationError::PasswordEmpty)
);
}
#[test]
fn password_too_short() {
assert_eq!(
validate_password_strength("Short1A"),
Err(ValidationError::PasswordTooShort(MIN_PASSWORD_LENGTH))
);
}
#[test]
fn password_no_uppercase() {
assert_eq!(
validate_password_strength("alllowercase1"),
Err(ValidationError::PasswordNoUppercase)
);
}
#[test]
fn password_no_lowercase() {
assert_eq!(
validate_password_strength("ALLUPPERCASE1"),
Err(ValidationError::PasswordNoLowercase)
);
}
#[test]
fn password_no_digit() {
assert_eq!(
validate_password_strength("NoDigitsHereAB"),
Err(ValidationError::PasswordNoDigit)
);
}
#[test]
fn password_common() {
assert_eq!(
validate_password_strength("Password123456"),
Err(ValidationError::PasswordCommon)
);
assert_eq!(
validate_password_strength("Qwerty123456"),
Err(ValidationError::PasswordCommon)
);
}
#[test]
fn address_valid_system_program() {
assert!(validate_solana_address("11111111111111111111111111111111").is_ok());
}
#[test]
fn address_valid_typical() {
assert!(validate_solana_address("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").is_ok());
}
#[test]
fn address_empty() {
assert_eq!(
validate_solana_address(""),
Err(ValidationError::AddressEmpty)
);
}
#[test]
fn address_too_short() {
assert_eq!(
validate_solana_address("abc"),
Err(ValidationError::AddressLength)
);
}
#[test]
fn address_bad_chars() {
assert_eq!(
validate_solana_address("0OlI111111111111111111111111111111"),
Err(ValidationError::AddressBadChars)
);
}
#[test]
fn balance_valid() {
assert!(validate_balance_value(0.0).is_ok());
assert!(validate_balance_value(100.5).is_ok());
assert!(validate_balance_value(MAX_BALANCE_SOL as f64).is_ok());
}
#[test]
fn balance_negative() {
assert_eq!(
validate_balance_value(-1.0),
Err(ValidationError::BalanceNegative)
);
}
#[test]
fn balance_too_large() {
assert_eq!(
validate_balance_value(MAX_BALANCE_SOL as f64 + 1.0),
Err(ValidationError::BalanceTooLarge(MAX_BALANCE_SOL))
);
}
#[test]
fn amount_valid() {
assert!(validate_amount_sol(1.0, None).is_ok());
assert!(validate_amount_sol(0.000000001, None).is_ok()); assert!(validate_amount_sol(5.0, Some(10.0)).is_ok());
}
#[test]
fn amount_not_positive() {
assert_eq!(
validate_amount_sol(0.0, None),
Err(ValidationError::AmountNotPositive)
);
assert_eq!(
validate_amount_sol(-1.0, None),
Err(ValidationError::AmountNotPositive)
);
}
#[test]
fn amount_too_large() {
assert_eq!(
validate_amount_sol(MAX_BALANCE_SOL as f64 + 1.0, None),
Err(ValidationError::AmountTooLarge(MAX_BALANCE_SOL))
);
}
#[test]
fn amount_exceeds_balance() {
assert_eq!(
validate_amount_sol(10.0, Some(5.0)),
Err(ValidationError::AmountExceedsBalance)
);
}
#[test]
fn sanitize_empty() {
assert_eq!(sanitize_filename("", 255), "unnamed");
}
#[test]
fn sanitize_path_traversal() {
let result = sanitize_filename("../../etc/passwd", 255);
assert!(!result.contains(".."));
assert!(!result.contains('/'));
assert_eq!(result, "passwd");
}
#[test]
fn sanitize_null_bytes() {
let result = sanitize_filename("file\0name.txt", 255);
assert!(!result.contains('\0'));
}
#[test]
fn sanitize_hidden_file() {
let result = sanitize_filename(".hidden", 255);
assert!(!result.starts_with('.'));
assert_eq!(result, "_hidden");
}
#[test]
fn sanitize_special_chars() {
let result = sanitize_filename("file<>name|test.txt", 255);
assert!(result
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-'));
}
#[test]
fn sanitize_truncate_with_extension() {
let result = sanitize_filename("very_long_name.txt", 10);
assert!(result.len() <= 10);
assert!(result.ends_with(".txt"));
}
#[test]
fn sanitize_normal_filename() {
assert_eq!(sanitize_filename("report.pdf", 255), "report.pdf");
}
#[test]
fn rpc_url_valid_https() {
assert_eq!(
validate_rpc_url("https://api.mainnet-beta.solana.com"),
Ok(None)
);
}
#[test]
fn rpc_url_valid_localhost() {
assert_eq!(validate_rpc_url("http://localhost:8899"), Ok(None));
assert_eq!(validate_rpc_url("http://127.0.0.1:8899"), Ok(None));
}
#[test]
fn rpc_url_insecure_http() {
assert_eq!(
validate_rpc_url("http://example.com:8899"),
Ok(Some(ValidationWarning::InsecureHttp))
);
}
#[test]
fn rpc_url_empty() {
assert_eq!(validate_rpc_url(""), Err(ValidationError::RpcUrlEmpty));
}
#[test]
fn rpc_url_bad_scheme() {
assert_eq!(
validate_rpc_url("ftp://example.com"),
Err(ValidationError::RpcUrlBadScheme)
);
}
#[test]
fn rpc_url_bad_format() {
assert_eq!(
validate_rpc_url("http://"),
Err(ValidationError::RpcUrlBadFormat)
);
}
}