1use regex::Regex;
8use std::path::Path;
9use thiserror::Error;
10
11pub const MIN_PASSWORD_LENGTH: usize = 12;
17
18pub const MAX_BALANCE_SOL: u64 = 1_000_000_000;
20
21pub const LAMPORTS_PER_SOL: u64 = 1_000_000_000;
23
24#[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#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ValidationWarning {
122 InsecureHttp,
124}
125
126#[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
148pub 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
195pub 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
243pub 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
300pub 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 const BASE58_CHARS: &str = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
318 if !address.chars().all(|c| BASE58_CHARS.contains(c)) {
319 return Err(ValidationError::AddressBadChars);
320 }
321
322 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
334pub 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
351pub 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 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
380pub fn sanitize_filename(name: &str, max_length: usize) -> String {
390 if name.is_empty() {
391 return "unnamed".to_string();
392 }
393
394 let basename = name
396 .rsplit(|c| c == '/' || c == '\\')
397 .next()
398 .unwrap_or(name);
399
400 let no_nulls: String = basename.chars().filter(|&c| c != '\0').collect();
402
403 let re = Regex::new(r"[^\w.\-]").expect("sanitize regex is valid");
405 let mut sanitized = re.replace_all(&no_nulls, "_").to_string();
406
407 if sanitized.starts_with('.') {
409 sanitized = format!("_{}", &sanitized[1..]);
410 }
411
412 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
430pub 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 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#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[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 #[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 #[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 assert_eq!(
684 validate_password_strength("Password123456"),
685 Err(ValidationError::PasswordCommon)
686 );
687 assert_eq!(
689 validate_password_strength("Qwerty123456"),
690 Err(ValidationError::PasswordCommon)
691 );
692 }
693
694 #[test]
697 fn address_valid_system_program() {
698 assert!(validate_solana_address("11111111111111111111111111111111").is_ok());
700 }
701
702 #[test]
703 fn address_valid_typical() {
704 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 assert_eq!(
728 validate_solana_address("0OlI111111111111111111111111111111"),
729 Err(ValidationError::AddressBadChars)
730 );
731 }
732
733 #[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 #[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()); 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 #[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 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 #[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}