Skip to main content

coldstar_validation/
lib.rs

1//! Input validation and sanitization for ColdStar.
2//!
3//! Provides security-focused validation functions ported from the Python
4//! `security_validation.py` module. Every validator returns `Result<(), ValidationError>`
5//! (or a sanitized value) so callers get structured, actionable errors.
6
7use regex::Regex;
8use std::path::Path;
9use thiserror::Error;
10
11// ---------------------------------------------------------------------------
12// Constants
13// ---------------------------------------------------------------------------
14
15/// Minimum acceptable password length.
16pub const MIN_PASSWORD_LENGTH: usize = 12;
17
18/// Upper-bound balance in SOL (greater than total supply).
19pub const MAX_BALANCE_SOL: u64 = 1_000_000_000;
20
21/// Lamports per SOL.
22pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
23
24// ---------------------------------------------------------------------------
25// Error type
26// ---------------------------------------------------------------------------
27
28/// Structured validation errors with context.
29#[derive(Debug, Error, PartialEq, Eq)]
30pub enum ValidationError {
31    #[error("Device path cannot be empty")]
32    DevicePathEmpty,
33
34    #[error("Invalid device path: contains path traversal sequences")]
35    DevicePathTraversal,
36
37    #[error("Invalid device path: contains null bytes")]
38    DevicePathNullByte,
39
40    #[error("Invalid device path: must start with /dev/")]
41    DevicePathNotDev,
42
43    #[error("Invalid device path: unexpected device name format")]
44    DevicePathBadFormat,
45
46    #[error("Invalid device path: must be a drive letter (e.g., D:)")]
47    DevicePathNotDriveLetter,
48
49    #[error("Mount point cannot be empty")]
50    MountPointEmpty,
51
52    #[error("Invalid mount point: contains path traversal")]
53    MountPointTraversal,
54
55    #[error("Invalid mount point: contains null bytes")]
56    MountPointNullByte,
57
58    #[error("Invalid mount point: not under an allowed prefix for {0}")]
59    MountPointDisallowed(Platform),
60
61    #[error("Password cannot be empty")]
62    PasswordEmpty,
63
64    #[error("Password must be at least {0} characters long")]
65    PasswordTooShort(usize),
66
67    #[error("Password must contain at least one uppercase letter")]
68    PasswordNoUppercase,
69
70    #[error("Password must contain at least one lowercase letter")]
71    PasswordNoLowercase,
72
73    #[error("Password must contain at least one number")]
74    PasswordNoDigit,
75
76    #[error("Password is too common. Please choose a stronger password")]
77    PasswordCommon,
78
79    #[error("Address cannot be empty")]
80    AddressEmpty,
81
82    #[error("Invalid address length")]
83    AddressLength,
84
85    #[error("Invalid characters in address (must be base58)")]
86    AddressBadChars,
87
88    #[error("Invalid Solana address: decoded key is not 32 bytes")]
89    AddressBadDecode,
90
91    #[error("Balance must be non-negative")]
92    BalanceNegative,
93
94    #[error("Balance exceeds maximum possible value ({0} SOL)")]
95    BalanceTooLarge(u64),
96
97    #[error("Amount must be greater than 0")]
98    AmountNotPositive,
99
100    #[error("Amount exceeds maximum ({0} SOL)")]
101    AmountTooLarge(u64),
102
103    #[error("Amount exceeds available balance")]
104    AmountExceedsBalance,
105
106    #[error("Amount has too many decimal places (max 9)")]
107    AmountPrecision,
108
109    #[error("RPC URL cannot be empty")]
110    RpcUrlEmpty,
111
112    #[error("RPC URL must start with http:// or https://")]
113    RpcUrlBadScheme,
114
115    #[error("Invalid RPC URL format")]
116    RpcUrlBadFormat,
117}
118
119/// Optional warning returned alongside a successful validation.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ValidationWarning {
122    /// The RPC URL uses plain HTTP (not HTTPS) on a non-localhost host.
123    InsecureHttp,
124}
125
126// ---------------------------------------------------------------------------
127// Platform enum
128// ---------------------------------------------------------------------------
129
130/// Target operating system for platform-specific validations.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum Platform {
133    Linux,
134    Darwin,
135    Windows,
136}
137
138impl std::fmt::Display for Platform {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            Platform::Linux => write!(f, "Linux"),
142            Platform::Darwin => write!(f, "Darwin"),
143            Platform::Windows => write!(f, "Windows"),
144        }
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Device path validation
150// ---------------------------------------------------------------------------
151
152/// Validate a device path for the given platform.
153///
154/// Rejects path-traversal sequences (`..`, `//`), null bytes, and
155/// platform-inappropriate formats.
156pub fn validate_device_path(path: &str, platform: Platform) -> Result<(), ValidationError> {
157    if path.is_empty() {
158        return Err(ValidationError::DevicePathEmpty);
159    }
160
161    if path.contains("..") || path.contains("//") {
162        return Err(ValidationError::DevicePathTraversal);
163    }
164
165    if path.contains('\0') {
166        return Err(ValidationError::DevicePathNullByte);
167    }
168
169    match platform {
170        Platform::Linux | Platform::Darwin => {
171            if !path.starts_with("/dev/") {
172                return Err(ValidationError::DevicePathNotDev);
173            }
174
175            let re = Regex::new(
176                r"^/dev/(sd[a-z]\d*|disk\d+s?\d*|mmcblk\d+p?\d*|nvme\d+|nvme\d+n\d+p?\d*)$",
177            )
178            .expect("device path regex is valid");
179
180            if !re.is_match(path) {
181                return Err(ValidationError::DevicePathBadFormat);
182            }
183        }
184        Platform::Windows => {
185            let re = Regex::new(r"(?i)^[A-Z]:\\?$").expect("windows drive regex is valid");
186            if !re.is_match(path) {
187                return Err(ValidationError::DevicePathNotDriveLetter);
188            }
189        }
190    }
191
192    Ok(())
193}
194
195// ---------------------------------------------------------------------------
196// Mount point validation
197// ---------------------------------------------------------------------------
198
199/// Validate a mount-point path for the given platform.
200///
201/// Ensures the path lives under an OS-appropriate directory tree.
202pub fn validate_mount_point(mount_point: &str, platform: Platform) -> Result<(), ValidationError> {
203    if mount_point.is_empty() {
204        return Err(ValidationError::MountPointEmpty);
205    }
206
207    if mount_point.contains("..") {
208        return Err(ValidationError::MountPointTraversal);
209    }
210
211    if mount_point.contains('\0') {
212        return Err(ValidationError::MountPointNullByte);
213    }
214
215    let resolved = Path::new(mount_point)
216        .to_str()
217        .unwrap_or(mount_point);
218
219    match platform {
220        Platform::Linux => {
221            let allowed = ["/media/", "/mnt/", "/run/media/", "/tmp/solana_usb_"];
222            if !allowed.iter().any(|prefix| resolved.starts_with(prefix)) {
223                return Err(ValidationError::MountPointDisallowed(Platform::Linux));
224            }
225        }
226        Platform::Darwin => {
227            if !resolved.starts_with("/Volumes/") {
228                return Err(ValidationError::MountPointDisallowed(Platform::Darwin));
229            }
230        }
231        Platform::Windows => {
232            let re =
233                Regex::new(r"(?i)^[A-Z]:\\").expect("windows mount point regex is valid");
234            if !re.is_match(resolved) {
235                return Err(ValidationError::MountPointDisallowed(Platform::Windows));
236            }
237        }
238    }
239
240    Ok(())
241}
242
243// ---------------------------------------------------------------------------
244// Password strength
245// ---------------------------------------------------------------------------
246
247/// Validate that a password meets ColdStar's minimum strength requirements.
248///
249/// Rules: at least 12 characters, at least one uppercase, one lowercase,
250/// one digit, and not in a common-password blocklist.
251pub fn validate_password_strength(password: &str) -> Result<(), ValidationError> {
252    if password.is_empty() {
253        return Err(ValidationError::PasswordEmpty);
254    }
255
256    if password.len() < MIN_PASSWORD_LENGTH {
257        return Err(ValidationError::PasswordTooShort(MIN_PASSWORD_LENGTH));
258    }
259
260    if !password.chars().any(|c| c.is_ascii_uppercase()) {
261        return Err(ValidationError::PasswordNoUppercase);
262    }
263
264    if !password.chars().any(|c| c.is_ascii_lowercase()) {
265        return Err(ValidationError::PasswordNoLowercase);
266    }
267
268    if !password.chars().any(|c| c.is_ascii_digit()) {
269        return Err(ValidationError::PasswordNoDigit);
270    }
271
272    const COMMON: &[&str] = &[
273        "password",
274        "12345678",
275        "123456789",
276        "1234567890",
277        "qwerty",
278        "abc123",
279        "password123",
280        "admin",
281        "letmein",
282        "welcome",
283        "monkey",
284        "1234",
285        "password1",
286        "123456",
287        "qwerty123",
288        "password123456",
289        "qwerty123456",
290    ];
291
292    let lower = password.to_ascii_lowercase();
293    if COMMON.iter().any(|&common| lower == common) {
294        return Err(ValidationError::PasswordCommon);
295    }
296
297    Ok(())
298}
299
300// ---------------------------------------------------------------------------
301// Solana address
302// ---------------------------------------------------------------------------
303
304/// Validate a base58-encoded Solana public-key address.
305///
306/// Checks character set and decoded byte length (must be exactly 32 bytes).
307pub fn validate_solana_address(address: &str) -> Result<(), ValidationError> {
308    if address.is_empty() {
309        return Err(ValidationError::AddressEmpty);
310    }
311
312    if address.len() < 32 || address.len() > 44 {
313        return Err(ValidationError::AddressLength);
314    }
315
316    // Base58 alphabet (Bitcoin variant, used by Solana)
317    const BASE58_CHARS: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
318    if !address.chars().all(|c| BASE58_CHARS.contains(c)) {
319        return Err(ValidationError::AddressBadChars);
320    }
321
322    // Decode and verify 32 bytes
323    let decoded = bs58::decode(address)
324        .into_vec()
325        .map_err(|_| ValidationError::AddressBadDecode)?;
326
327    if decoded.len() != 32 {
328        return Err(ValidationError::AddressBadDecode);
329    }
330
331    Ok(())
332}
333
334// ---------------------------------------------------------------------------
335// Balance / amount
336// ---------------------------------------------------------------------------
337
338/// Validate a balance value (in SOL, as `f64`) is within `[0, MAX_BALANCE_SOL]`.
339pub fn validate_balance_value(balance: f64) -> Result<(), ValidationError> {
340    if balance < 0.0 {
341        return Err(ValidationError::BalanceNegative);
342    }
343
344    if balance > MAX_BALANCE_SOL as f64 {
345        return Err(ValidationError::BalanceTooLarge(MAX_BALANCE_SOL));
346    }
347
348    Ok(())
349}
350
351/// Validate a transfer amount in SOL.
352///
353/// The amount must be positive, must not exceed `max_balance` (if provided),
354/// and must be representable at lamport precision (9 decimal places).
355pub fn validate_amount_sol(amount: f64, max_balance: Option<f64>) -> Result<(), ValidationError> {
356    if amount <= 0.0 {
357        return Err(ValidationError::AmountNotPositive);
358    }
359
360    if amount > MAX_BALANCE_SOL as f64 {
361        return Err(ValidationError::AmountTooLarge(MAX_BALANCE_SOL));
362    }
363
364    if let Some(max) = max_balance {
365        if amount > max {
366            return Err(ValidationError::AmountExceedsBalance);
367        }
368    }
369
370    // Precision check: round-trip through lamports
371    let lamports = (amount * LAMPORTS_PER_SOL as f64) as u64;
372    let reconstructed = lamports as f64 / LAMPORTS_PER_SOL as f64;
373    if (amount - reconstructed).abs() > 1e-9 {
374        return Err(ValidationError::AmountPrecision);
375    }
376
377    Ok(())
378}
379
380// ---------------------------------------------------------------------------
381// Filename sanitization
382// ---------------------------------------------------------------------------
383
384/// Sanitize a filename to prevent path traversal and injection attacks.
385///
386/// Strips directory components, null bytes, and non-`[a-zA-Z0-9._-]`
387/// characters. Prevents hidden files (leading dot) and enforces a maximum
388/// length while preserving the file extension.
389pub fn sanitize_filename(name: &str, max_length: usize) -> String {
390    if name.is_empty() {
391        return "unnamed".to_string();
392    }
393
394    // Strip directory components (take the last segment after / or \)
395    let basename = name
396        .rsplit(|c| c == '/' || c == '\\')
397        .next()
398        .unwrap_or(name);
399
400    // Remove null bytes
401    let no_nulls: String = basename.chars().filter(|&c| c != '\0').collect();
402
403    // Replace everything that isn't word-char, dot, or dash with underscore
404    let re = Regex::new(r"[^\w.\-]").expect("sanitize regex is valid");
405    let mut sanitized = re.replace_all(&no_nulls, "_").to_string();
406
407    // Prevent hidden files
408    if sanitized.starts_with('.') {
409        sanitized = format!("_{}", &sanitized[1..]);
410    }
411
412    // Truncate while preserving extension
413    if sanitized.len() > max_length {
414        if let Some(dot_pos) = sanitized.rfind('.') {
415            let ext = &sanitized[dot_pos..];
416            let name_budget = max_length.saturating_sub(ext.len());
417            sanitized = format!("{}{}", &sanitized[..name_budget], ext);
418        } else {
419            sanitized.truncate(max_length);
420        }
421    }
422
423    if sanitized.is_empty() || sanitized == "." {
424        return "unnamed".to_string();
425    }
426
427    sanitized
428}
429
430// ---------------------------------------------------------------------------
431// RPC URL validation
432// ---------------------------------------------------------------------------
433
434/// Validate an RPC URL.
435///
436/// Returns `Ok(None)` for a clean HTTPS URL, `Ok(Some(InsecureHttp))` when
437/// plain HTTP is used on a non-localhost host, or an appropriate
438/// `Err(ValidationError)` on failure.
439pub fn validate_rpc_url(url_str: &str) -> Result<Option<ValidationWarning>, ValidationError> {
440    if url_str.is_empty() {
441        return Err(ValidationError::RpcUrlEmpty);
442    }
443
444    if !url_str.starts_with("http://") && !url_str.starts_with("https://") {
445        return Err(ValidationError::RpcUrlBadScheme);
446    }
447
448    let re = Regex::new(
449        r"^https?://(?:[A-Za-z0-9\-]+\.)*[A-Za-z0-9\-]+(?::\d{1,5})?(?:/.*)?$",
450    )
451    .expect("rpc url regex is valid");
452
453    if !re.is_match(url_str) {
454        return Err(ValidationError::RpcUrlBadFormat);
455    }
456
457    // Warn about non-localhost HTTP
458    if url_str.starts_with("http://")
459        && !url_str.starts_with("http://localhost")
460        && !url_str.starts_with("http://127.0.0.1")
461    {
462        return Ok(Some(ValidationWarning::InsecureHttp));
463    }
464
465    Ok(None)
466}
467
468// ---------------------------------------------------------------------------
469// Tests
470// ---------------------------------------------------------------------------
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    // -- Device path --
477
478    #[test]
479    fn device_path_valid_linux_sd() {
480        assert!(validate_device_path("/dev/sda", Platform::Linux).is_ok());
481        assert!(validate_device_path("/dev/sda1", Platform::Linux).is_ok());
482        assert!(validate_device_path("/dev/sdb", Platform::Linux).is_ok());
483    }
484
485    #[test]
486    fn device_path_valid_linux_nvme() {
487        assert!(validate_device_path("/dev/nvme0", Platform::Linux).is_ok());
488        assert!(validate_device_path("/dev/nvme0n1", Platform::Linux).is_ok());
489        assert!(validate_device_path("/dev/nvme0n1p1", Platform::Linux).is_ok());
490    }
491
492    #[test]
493    fn device_path_valid_linux_mmcblk() {
494        assert!(validate_device_path("/dev/mmcblk0", Platform::Linux).is_ok());
495        assert!(validate_device_path("/dev/mmcblk0p1", Platform::Linux).is_ok());
496    }
497
498    #[test]
499    fn device_path_valid_darwin() {
500        assert!(validate_device_path("/dev/disk2", Platform::Darwin).is_ok());
501        assert!(validate_device_path("/dev/disk2s1", Platform::Darwin).is_ok());
502    }
503
504    #[test]
505    fn device_path_valid_windows() {
506        assert!(validate_device_path("D:", Platform::Windows).is_ok());
507        assert!(validate_device_path("D:\\", Platform::Windows).is_ok());
508        assert!(validate_device_path("E:", Platform::Windows).is_ok());
509    }
510
511    #[test]
512    fn device_path_empty() {
513        assert_eq!(
514            validate_device_path("", Platform::Linux),
515            Err(ValidationError::DevicePathEmpty)
516        );
517    }
518
519    #[test]
520    fn device_path_traversal() {
521        assert_eq!(
522            validate_device_path("/dev/../etc/passwd", Platform::Linux),
523            Err(ValidationError::DevicePathTraversal)
524        );
525        assert_eq!(
526            validate_device_path("/dev//sda", Platform::Linux),
527            Err(ValidationError::DevicePathTraversal)
528        );
529    }
530
531    #[test]
532    fn device_path_null_byte() {
533        assert_eq!(
534            validate_device_path("/dev/sda\0", Platform::Linux),
535            Err(ValidationError::DevicePathNullByte)
536        );
537    }
538
539    #[test]
540    fn device_path_not_dev() {
541        assert_eq!(
542            validate_device_path("/tmp/sda", Platform::Linux),
543            Err(ValidationError::DevicePathNotDev)
544        );
545    }
546
547    #[test]
548    fn device_path_bad_format() {
549        assert_eq!(
550            validate_device_path("/dev/foo", Platform::Linux),
551            Err(ValidationError::DevicePathBadFormat)
552        );
553    }
554
555    #[test]
556    fn device_path_windows_bad() {
557        assert_eq!(
558            validate_device_path("/dev/sda", Platform::Windows),
559            Err(ValidationError::DevicePathNotDriveLetter)
560        );
561    }
562
563    // -- Mount point --
564
565    #[test]
566    fn mount_point_valid_linux() {
567        assert!(validate_mount_point("/media/usb", Platform::Linux).is_ok());
568        assert!(validate_mount_point("/mnt/data", Platform::Linux).is_ok());
569        assert!(validate_mount_point("/run/media/user/stick", Platform::Linux).is_ok());
570        assert!(validate_mount_point("/tmp/solana_usb_abc", Platform::Linux).is_ok());
571    }
572
573    #[test]
574    fn mount_point_valid_darwin() {
575        assert!(validate_mount_point("/Volumes/USB", Platform::Darwin).is_ok());
576    }
577
578    #[test]
579    fn mount_point_valid_windows() {
580        assert!(validate_mount_point("D:\\MyUSB", Platform::Windows).is_ok());
581    }
582
583    #[test]
584    fn mount_point_empty() {
585        assert_eq!(
586            validate_mount_point("", Platform::Linux),
587            Err(ValidationError::MountPointEmpty)
588        );
589    }
590
591    #[test]
592    fn mount_point_traversal() {
593        assert_eq!(
594            validate_mount_point("/media/../etc", Platform::Linux),
595            Err(ValidationError::MountPointTraversal)
596        );
597    }
598
599    #[test]
600    fn mount_point_null_byte() {
601        assert_eq!(
602            validate_mount_point("/media/usb\0", Platform::Linux),
603            Err(ValidationError::MountPointNullByte)
604        );
605    }
606
607    #[test]
608    fn mount_point_disallowed_linux() {
609        assert_eq!(
610            validate_mount_point("/home/user", Platform::Linux),
611            Err(ValidationError::MountPointDisallowed(Platform::Linux))
612        );
613    }
614
615    #[test]
616    fn mount_point_disallowed_darwin() {
617        assert_eq!(
618            validate_mount_point("/tmp/usb", Platform::Darwin),
619            Err(ValidationError::MountPointDisallowed(Platform::Darwin))
620        );
621    }
622
623    #[test]
624    fn mount_point_disallowed_windows() {
625        assert_eq!(
626            validate_mount_point("/media/usb", Platform::Windows),
627            Err(ValidationError::MountPointDisallowed(Platform::Windows))
628        );
629    }
630
631    // -- Password strength --
632
633    #[test]
634    fn password_valid() {
635        assert!(validate_password_strength("Str0ngP@ssw0rd!").is_ok());
636        assert!(validate_password_strength("MySecure1Pass").is_ok());
637    }
638
639    #[test]
640    fn password_empty() {
641        assert_eq!(
642            validate_password_strength(""),
643            Err(ValidationError::PasswordEmpty)
644        );
645    }
646
647    #[test]
648    fn password_too_short() {
649        assert_eq!(
650            validate_password_strength("Short1A"),
651            Err(ValidationError::PasswordTooShort(MIN_PASSWORD_LENGTH))
652        );
653    }
654
655    #[test]
656    fn password_no_uppercase() {
657        assert_eq!(
658            validate_password_strength("alllowercase1"),
659            Err(ValidationError::PasswordNoUppercase)
660        );
661    }
662
663    #[test]
664    fn password_no_lowercase() {
665        assert_eq!(
666            validate_password_strength("ALLUPPERCASE1"),
667            Err(ValidationError::PasswordNoLowercase)
668        );
669    }
670
671    #[test]
672    fn password_no_digit() {
673        assert_eq!(
674            validate_password_strength("NoDigitsHereAB"),
675            Err(ValidationError::PasswordNoDigit)
676        );
677    }
678
679    #[test]
680    fn password_common() {
681        // "Password123456" lowered = "password123456" which is in the blocklist.
682        // It's 14 chars, has upper + lower + digit -- passes all other checks.
683        assert_eq!(
684            validate_password_strength("Password123456"),
685            Err(ValidationError::PasswordCommon)
686        );
687        // "Qwerty123456" lowered = "qwerty123456", also in blocklist.
688        assert_eq!(
689            validate_password_strength("Qwerty123456"),
690            Err(ValidationError::PasswordCommon)
691        );
692    }
693
694    // -- Solana address --
695
696    #[test]
697    fn address_valid_system_program() {
698        // The System Program address is 32 '1's
699        assert!(validate_solana_address("11111111111111111111111111111111").is_ok());
700    }
701
702    #[test]
703    fn address_valid_typical() {
704        // Known Token Program address
705        assert!(validate_solana_address("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA").is_ok());
706    }
707
708    #[test]
709    fn address_empty() {
710        assert_eq!(
711            validate_solana_address(""),
712            Err(ValidationError::AddressEmpty)
713        );
714    }
715
716    #[test]
717    fn address_too_short() {
718        assert_eq!(
719            validate_solana_address("abc"),
720            Err(ValidationError::AddressLength)
721        );
722    }
723
724    #[test]
725    fn address_bad_chars() {
726        // '0' (zero) and 'O' (capital oh) are not in base58
727        assert_eq!(
728            validate_solana_address("0OlI111111111111111111111111111111"),
729            Err(ValidationError::AddressBadChars)
730        );
731    }
732
733    // -- Balance --
734
735    #[test]
736    fn balance_valid() {
737        assert!(validate_balance_value(0.0).is_ok());
738        assert!(validate_balance_value(100.5).is_ok());
739        assert!(validate_balance_value(MAX_BALANCE_SOL as f64).is_ok());
740    }
741
742    #[test]
743    fn balance_negative() {
744        assert_eq!(
745            validate_balance_value(-1.0),
746            Err(ValidationError::BalanceNegative)
747        );
748    }
749
750    #[test]
751    fn balance_too_large() {
752        assert_eq!(
753            validate_balance_value(MAX_BALANCE_SOL as f64 + 1.0),
754            Err(ValidationError::BalanceTooLarge(MAX_BALANCE_SOL))
755        );
756    }
757
758    // -- Amount SOL --
759
760    #[test]
761    fn amount_valid() {
762        assert!(validate_amount_sol(1.0, None).is_ok());
763        assert!(validate_amount_sol(0.000000001, None).is_ok()); // 1 lamport
764        assert!(validate_amount_sol(5.0, Some(10.0)).is_ok());
765    }
766
767    #[test]
768    fn amount_not_positive() {
769        assert_eq!(
770            validate_amount_sol(0.0, None),
771            Err(ValidationError::AmountNotPositive)
772        );
773        assert_eq!(
774            validate_amount_sol(-1.0, None),
775            Err(ValidationError::AmountNotPositive)
776        );
777    }
778
779    #[test]
780    fn amount_too_large() {
781        assert_eq!(
782            validate_amount_sol(MAX_BALANCE_SOL as f64 + 1.0, None),
783            Err(ValidationError::AmountTooLarge(MAX_BALANCE_SOL))
784        );
785    }
786
787    #[test]
788    fn amount_exceeds_balance() {
789        assert_eq!(
790            validate_amount_sol(10.0, Some(5.0)),
791            Err(ValidationError::AmountExceedsBalance)
792        );
793    }
794
795    // -- Sanitize filename --
796
797    #[test]
798    fn sanitize_empty() {
799        assert_eq!(sanitize_filename("", 255), "unnamed");
800    }
801
802    #[test]
803    fn sanitize_path_traversal() {
804        let result = sanitize_filename("../../etc/passwd", 255);
805        assert!(!result.contains(".."));
806        assert!(!result.contains('/'));
807        // Should strip directory components, keeping only "passwd"
808        assert_eq!(result, "passwd");
809    }
810
811    #[test]
812    fn sanitize_null_bytes() {
813        let result = sanitize_filename("file\0name.txt", 255);
814        assert!(!result.contains('\0'));
815    }
816
817    #[test]
818    fn sanitize_hidden_file() {
819        let result = sanitize_filename(".hidden", 255);
820        assert!(!result.starts_with('.'));
821        assert_eq!(result, "_hidden");
822    }
823
824    #[test]
825    fn sanitize_special_chars() {
826        let result = sanitize_filename("file<>name|test.txt", 255);
827        assert!(result
828            .chars()
829            .all(|c| c.is_alphanumeric() || c == '_' || c == '.' || c == '-'));
830    }
831
832    #[test]
833    fn sanitize_truncate_with_extension() {
834        let result = sanitize_filename("very_long_name.txt", 10);
835        assert!(result.len() <= 10);
836        assert!(result.ends_with(".txt"));
837    }
838
839    #[test]
840    fn sanitize_normal_filename() {
841        assert_eq!(sanitize_filename("report.pdf", 255), "report.pdf");
842    }
843
844    // -- RPC URL --
845
846    #[test]
847    fn rpc_url_valid_https() {
848        assert_eq!(
849            validate_rpc_url("https://api.mainnet-beta.solana.com"),
850            Ok(None)
851        );
852    }
853
854    #[test]
855    fn rpc_url_valid_localhost() {
856        assert_eq!(validate_rpc_url("http://localhost:8899"), Ok(None));
857        assert_eq!(validate_rpc_url("http://127.0.0.1:8899"), Ok(None));
858    }
859
860    #[test]
861    fn rpc_url_insecure_http() {
862        assert_eq!(
863            validate_rpc_url("http://example.com:8899"),
864            Ok(Some(ValidationWarning::InsecureHttp))
865        );
866    }
867
868    #[test]
869    fn rpc_url_empty() {
870        assert_eq!(validate_rpc_url(""), Err(ValidationError::RpcUrlEmpty));
871    }
872
873    #[test]
874    fn rpc_url_bad_scheme() {
875        assert_eq!(
876            validate_rpc_url("ftp://example.com"),
877            Err(ValidationError::RpcUrlBadScheme)
878        );
879    }
880
881    #[test]
882    fn rpc_url_bad_format() {
883        assert_eq!(
884            validate_rpc_url("http://"),
885            Err(ValidationError::RpcUrlBadFormat)
886        );
887    }
888}