tldr_cli/commands/patterns/
validation.rs1use std::fs;
13use std::path::{Path, PathBuf};
14
15use super::error::{PatternsError, PatternsResult};
16
17pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
24
25pub const WARN_FILE_SIZE: u64 = 1024 * 1024;
28
29pub const MAX_DIRECTORY_FILES: u32 = 1000;
31
32pub const MAX_AST_DEPTH: usize = 100;
35
36pub const MAX_ANALYSIS_DEPTH: usize = 500;
39
40pub const MAX_FUNCTION_NAME_LEN: usize = 256;
42
43pub const MAX_CONSTRAINTS_PER_FILE: usize = 500;
45
46pub const MAX_METHODS_PER_CLASS: usize = 200;
48
49pub const MAX_FIELDS_PER_CLASS: usize = 100;
51
52pub const MAX_CLASSES_PER_FILE: usize = 500;
54
55pub const MAX_PATHS: usize = 1000;
58
59pub const MAX_TRIGRAMS: usize = 10000;
62
63pub const MAX_CLASS_COMPLEXITY: usize = 500;
65
66const BLOCKED_PREFIXES: &[&str] = &[
73 "/etc/",
74 "/etc/passwd",
75 "/etc/shadow",
76 "/root/",
77 "/sys/",
78 "/proc/",
79 "/dev/",
80 "/var/run/",
81 "/var/log/",
82 "/private/etc/", "C:\\Windows\\", "C:\\System32\\", ];
86
87pub fn validate_file_path(path: &Path) -> PatternsResult<PathBuf> {
119 if !path.exists() {
121 return Err(PatternsError::FileNotFound {
122 path: path.to_path_buf(),
123 });
124 }
125
126 let canonical = fs::canonicalize(path).map_err(|_| PatternsError::FileNotFound {
128 path: path.to_path_buf(),
129 })?;
130
131 let canonical_str = canonical.to_string_lossy();
133 for blocked in BLOCKED_PREFIXES {
134 if canonical_str.starts_with(blocked) || canonical_str == blocked.trim_end_matches('/') {
136 return Err(PatternsError::PathTraversal {
137 path: path.to_path_buf(),
138 });
139 }
140 }
141
142 if canonical.to_str().is_none() {
144 return Err(PatternsError::PathTraversal {
145 path: path.to_path_buf(),
146 });
147 }
148
149 Ok(canonical)
150}
151
152pub fn validate_file_path_in_project(path: &Path, project_root: &Path) -> PatternsResult<PathBuf> {
171 let canonical = validate_file_path(path)?;
173
174 let canonical_root =
176 fs::canonicalize(project_root).map_err(|_| PatternsError::FileNotFound {
177 path: project_root.to_path_buf(),
178 })?;
179
180 if !canonical.starts_with(&canonical_root) {
182 return Err(PatternsError::PathTraversal {
183 path: path.to_path_buf(),
184 });
185 }
186
187 Ok(canonical)
188}
189
190pub fn validate_directory_path(path: &Path) -> PatternsResult<PathBuf> {
205 let canonical = validate_file_path(path)?;
206
207 if !canonical.is_dir() {
208 return Err(PatternsError::NotADirectory {
209 path: path.to_path_buf(),
210 });
211 }
212
213 Ok(canonical)
214}
215
216pub fn is_path_traversal_attempt(path: &Path) -> bool {
229 let path_str = path.to_string_lossy();
230
231 if path_str.contains("..") {
233 return true;
234 }
235
236 if path_str.contains('\0') {
238 return true;
239 }
240
241 false
242}
243
244pub fn validate_file_size(path: &Path) -> PatternsResult<u64> {
263 let canonical = validate_file_path(path)?;
264
265 let metadata = fs::metadata(&canonical)?;
266 let size = metadata.len();
267
268 if size > MAX_FILE_SIZE {
269 return Err(PatternsError::FileTooLarge {
270 path: path.to_path_buf(),
271 bytes: size,
272 max_bytes: MAX_FILE_SIZE,
273 });
274 }
275
276 Ok(size)
277}
278
279pub fn read_file_safe(path: &Path) -> PatternsResult<String> {
302 let canonical = validate_file_path(path)?;
304
305 let metadata = fs::metadata(&canonical)?;
306 let size = metadata.len();
307
308 if size > MAX_FILE_SIZE {
309 return Err(PatternsError::FileTooLarge {
310 path: path.to_path_buf(),
311 bytes: size,
312 max_bytes: MAX_FILE_SIZE,
313 });
314 }
315
316 let content = fs::read(&canonical)?;
318
319 String::from_utf8(content).map_err(|_| PatternsError::ParseError {
321 file: path.to_path_buf(),
322 message: "file is not valid UTF-8".to_string(),
323 })
324}
325
326pub fn check_ast_depth(current_depth: usize) -> PatternsResult<()> {
346 if current_depth >= MAX_AST_DEPTH {
347 Err(PatternsError::DepthLimitExceeded {
348 depth: current_depth.min(u32::MAX as usize) as u32,
349 max_depth: MAX_AST_DEPTH as u32,
350 })
351 } else {
352 Ok(())
353 }
354}
355
356pub fn check_analysis_depth(current_depth: usize) -> PatternsResult<()> {
372 if current_depth >= MAX_ANALYSIS_DEPTH {
373 Err(PatternsError::DepthLimitExceeded {
374 depth: current_depth.min(u32::MAX as usize) as u32,
375 max_depth: MAX_ANALYSIS_DEPTH as u32,
376 })
377 } else {
378 Ok(())
379 }
380}
381
382pub fn check_directory_file_count(count: usize) -> PatternsResult<()> {
396 if count > MAX_DIRECTORY_FILES as usize {
397 Err(PatternsError::TooManyFiles {
398 count: count.min(u32::MAX as usize) as u32,
399 max_files: MAX_DIRECTORY_FILES,
400 })
401 } else {
402 Ok(())
403 }
404}
405
406pub fn validate_function_name(name: &str) -> PatternsResult<()> {
430 if name.is_empty() {
432 return Err(PatternsError::InvalidParameter {
433 message: "function name cannot be empty".to_string(),
434 });
435 }
436
437 if name.len() > MAX_FUNCTION_NAME_LEN {
439 return Err(PatternsError::InvalidParameter {
440 message: format!(
441 "function name too long ({} chars, max {})",
442 name.len(),
443 MAX_FUNCTION_NAME_LEN
444 ),
445 });
446 }
447
448 let suspicious_chars = [
451 ';', '(', ')', '{', '}', '[', ']', '`', '"', '\'', '\\', '/', '\0',
452 ];
453 for c in name.chars() {
454 if suspicious_chars.contains(&c) {
455 return Err(PatternsError::InvalidParameter {
456 message: format!("function name contains invalid character: '{}'", c),
457 });
458 }
459 }
460
461 if let Some(first) = name.chars().next() {
463 if !first.is_alphabetic() && first != '_' {
464 return Err(PatternsError::InvalidParameter {
465 message: "function name must start with letter or underscore".to_string(),
466 });
467 }
468 }
469
470 Ok(())
471}
472
473#[inline]
489pub fn saturating_depth_increment(depth: usize) -> usize {
490 depth.saturating_add(1)
491}
492
493#[inline]
506pub fn saturating_count_add(count: u32, add: u32) -> u32 {
507 count.saturating_add(add)
508}
509
510#[inline]
521pub fn within_limit(value: usize, limit: usize) -> bool {
522 value < limit
523}
524
525#[inline]
539pub fn should_warn_file_size(size: u64) -> bool {
540 size > WARN_FILE_SIZE
541}
542
543pub fn format_large_file_warning(path: &Path, size: u64) -> String {
554 format!(
555 "Warning: {} is large ({:.1} MB), analysis may be slow",
556 path.display(),
557 size as f64 / 1024.0 / 1024.0
558 )
559}
560
561#[inline]
576pub fn approaching_limit(count: usize, limit: usize) -> bool {
577 let threshold = limit.saturating_mul(80) / 100;
579 count > threshold
580}
581
582pub fn warn_if_approaching_limit(count: usize, limit: usize, resource_name: &str) {
590 if approaching_limit(count, limit) {
591 eprintln!(
592 "Warning: {} count ({}) approaching limit ({})",
593 resource_name, count, limit
594 );
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use std::fs;
602 use std::io::Write;
603 use std::path::Path;
604 use tempfile::{tempdir, NamedTempFile};
605
606 #[test]
611 fn test_resource_limits_constants() {
612 assert_eq!(MAX_FILE_SIZE, 10 * 1024 * 1024); assert_eq!(MAX_DIRECTORY_FILES, 1000);
615 assert_eq!(MAX_AST_DEPTH, 100); assert_eq!(MAX_ANALYSIS_DEPTH, 500);
617 assert_eq!(MAX_PATHS, 1000); assert_eq!(MAX_TRIGRAMS, 10000); assert_eq!(MAX_CLASS_COMPLEXITY, 500);
620 }
621
622 #[test]
627 fn test_validate_file_path_normal() {
628 let file = NamedTempFile::new().unwrap();
629 let path = file.path();
630
631 let result = validate_file_path(path);
632 assert!(result.is_ok());
633
634 let canonical = result.unwrap();
635 assert!(canonical.is_absolute());
636 }
637
638 #[test]
639 fn test_validate_file_path_not_exists() {
640 let result = validate_file_path(Path::new("/nonexistent/file.py"));
641 assert!(result.is_err());
642
643 match result.unwrap_err() {
644 PatternsError::FileNotFound { path } => {
645 assert!(path.to_string_lossy().contains("nonexistent"));
646 }
647 e => panic!("Expected FileNotFound error, got {:?}", e),
648 }
649 }
650
651 #[test]
652 fn test_validate_file_path_traversal_blocked_dotdot() {
653 let suspicious = Path::new("../etc/passwd");
655 assert!(is_path_traversal_attempt(suspicious));
656 }
657
658 #[test]
659 fn test_validate_file_path_traversal_blocked_null() {
660 let suspicious = Path::new("file\0.txt");
661 assert!(is_path_traversal_attempt(suspicious));
662 }
663
664 #[test]
665 fn test_validate_file_path_in_project_valid() {
666 let project = tempdir().unwrap();
667 let file_path = project.path().join("src/main.py");
668 fs::create_dir_all(project.path().join("src")).unwrap();
669 fs::write(&file_path, "# test").unwrap();
670
671 let result = validate_file_path_in_project(&file_path, project.path());
672 assert!(result.is_ok());
673 }
674
675 #[test]
676 fn test_validate_file_path_outside_project() {
677 let project = tempdir().unwrap();
678 let outside = tempdir().unwrap();
679 let outside_file = outside.path().join("secret.txt");
680 fs::write(&outside_file, "secret").unwrap();
681
682 let result = validate_file_path_in_project(&outside_file, project.path());
683 assert!(result.is_err());
684
685 match result.unwrap_err() {
686 PatternsError::PathTraversal { .. } => {}
687 e => panic!("Expected PathTraversal error, got {:?}", e),
688 }
689 }
690
691 #[test]
692 fn test_validate_path_blocked_system_dirs() {
693 let blocked = [
695 "/etc/passwd",
696 "/root/.bashrc",
697 "/sys/kernel/config",
698 "/proc/self/status",
699 ];
700
701 for path_str in blocked {
702 let path = Path::new(path_str);
703 if path.exists() {
705 let result = validate_file_path(path);
706 assert!(result.is_err(), "Should reject system path: {}", path_str);
707 }
708 }
709 }
710
711 #[test]
712 fn test_validate_directory_path_exists() {
713 let dir = tempdir().unwrap();
714 let result = validate_directory_path(dir.path());
715 assert!(result.is_ok());
716 }
717
718 #[test]
719 fn test_validate_directory_path_is_file() {
720 let file = NamedTempFile::new().unwrap();
721 let result = validate_directory_path(file.path());
722 assert!(result.is_err());
723 }
724
725 #[test]
730 fn test_validate_file_size_ok() {
731 let mut file = NamedTempFile::new().unwrap();
732 writeln!(file, "small content").unwrap();
733
734 let result = validate_file_size(file.path());
735 assert!(result.is_ok());
736 assert!(result.unwrap() < MAX_FILE_SIZE);
737 }
738
739 #[test]
740 fn test_validate_file_size_not_exists() {
741 let result = validate_file_size(Path::new("/nonexistent"));
742 assert!(result.is_err());
743 }
744
745 #[test]
746 fn test_read_file_safe_success() {
747 let mut file = NamedTempFile::new().unwrap();
748 writeln!(file, "def hello():\n print('hello')").unwrap();
749
750 let content = read_file_safe(file.path()).unwrap();
751 assert!(content.contains("def hello"));
752 assert!(content.contains("print"));
753 }
754
755 #[test]
756 fn test_read_file_safe_not_utf8() {
757 let temp = tempdir().unwrap();
758 let binary_file = temp.path().join("binary.bin");
759
760 let invalid_utf8 = vec![0xFF, 0xFE, 0x00, 0x01];
762 fs::write(&binary_file, invalid_utf8).unwrap();
763
764 let result = read_file_safe(&binary_file);
765 assert!(result.is_err());
766
767 match result.unwrap_err() {
768 PatternsError::ParseError { message, .. } => {
769 assert!(message.contains("UTF-8"));
770 }
771 e => panic!("Expected ParseError, got {:?}", e),
772 }
773 }
774
775 #[test]
780 fn test_check_ast_depth_ok() {
781 assert!(check_ast_depth(0).is_ok());
782 assert!(check_ast_depth(50).is_ok());
783 assert!(check_ast_depth(MAX_AST_DEPTH - 1).is_ok());
784 }
785
786 #[test]
787 fn test_check_ast_depth_exceeded() {
788 let result = check_ast_depth(MAX_AST_DEPTH);
789 assert!(result.is_err());
790
791 match result.unwrap_err() {
792 PatternsError::DepthLimitExceeded { depth, max_depth } => {
793 assert_eq!(depth, MAX_AST_DEPTH as u32);
794 assert_eq!(max_depth, MAX_AST_DEPTH as u32);
795 }
796 e => panic!("Expected DepthLimitExceeded error, got {:?}", e),
797 }
798 }
799
800 #[test]
801 fn test_check_analysis_depth_ok() {
802 assert!(check_analysis_depth(0).is_ok());
803 assert!(check_analysis_depth(MAX_ANALYSIS_DEPTH - 1).is_ok());
804 }
805
806 #[test]
807 fn test_check_analysis_depth_exceeded() {
808 let result = check_analysis_depth(MAX_ANALYSIS_DEPTH);
809 assert!(result.is_err());
810
811 match result.unwrap_err() {
812 PatternsError::DepthLimitExceeded { .. } => {}
813 e => panic!("Expected DepthLimitExceeded error, got {:?}", e),
814 }
815 }
816
817 #[test]
818 fn test_check_directory_file_count_ok() {
819 assert!(check_directory_file_count(0).is_ok());
820 assert!(check_directory_file_count(500).is_ok());
821 assert!(check_directory_file_count(MAX_DIRECTORY_FILES as usize - 1).is_ok());
822 }
823
824 #[test]
825 fn test_check_directory_file_count_exceeded() {
826 let result = check_directory_file_count(MAX_DIRECTORY_FILES as usize + 1);
827 assert!(result.is_err());
828
829 match result.unwrap_err() {
830 PatternsError::TooManyFiles { .. } => {}
831 e => panic!("Expected TooManyFiles error, got {:?}", e),
832 }
833 }
834
835 #[test]
840 fn test_validate_function_name_valid() {
841 assert!(validate_function_name("my_function").is_ok());
842 assert!(validate_function_name("_private").is_ok());
843 assert!(validate_function_name("CamelCase").is_ok());
844 assert!(validate_function_name("func123").is_ok());
845 assert!(validate_function_name("__dunder__").is_ok());
846 }
847
848 #[test]
849 fn test_validate_function_name_empty() {
850 let result = validate_function_name("");
851 assert!(result.is_err());
852
853 match result.unwrap_err() {
854 PatternsError::InvalidParameter { message } => {
855 assert!(message.contains("empty"));
856 }
857 e => panic!("Expected InvalidParameter error, got {:?}", e),
858 }
859 }
860
861 #[test]
862 fn test_validate_function_name_too_long() {
863 let long_name = "a".repeat(MAX_FUNCTION_NAME_LEN + 1);
864 let result = validate_function_name(&long_name);
865 assert!(result.is_err());
866
867 match result.unwrap_err() {
868 PatternsError::InvalidParameter { message } => {
869 assert!(message.contains("too long"));
870 }
871 e => panic!("Expected InvalidParameter error, got {:?}", e),
872 }
873 }
874
875 #[test]
876 fn test_validate_function_name_invalid_chars() {
877 let invalid_names = [
878 "func;drop", "func()", "func{}", "func`cmd`", "func\"name", "func\\name", "func/name", ];
886
887 for name in invalid_names {
888 let result = validate_function_name(name);
889 assert!(result.is_err(), "Should reject: {}", name);
890 }
891 }
892
893 #[test]
894 fn test_validate_function_name_invalid_start() {
895 let result = validate_function_name("123func");
896 assert!(result.is_err());
897
898 match result.unwrap_err() {
899 PatternsError::InvalidParameter { message } => {
900 assert!(message.contains("start with"));
901 }
902 e => panic!("Expected InvalidParameter error, got {:?}", e),
903 }
904 }
905
906 #[test]
911 fn test_is_path_traversal_attempt_dotdot() {
912 assert!(is_path_traversal_attempt(Path::new("../etc/passwd")));
913 assert!(is_path_traversal_attempt(Path::new("foo/../bar")));
914 assert!(is_path_traversal_attempt(Path::new(
915 "..\\Windows\\System32"
916 )));
917 }
918
919 #[test]
920 fn test_is_path_traversal_attempt_normal() {
921 assert!(!is_path_traversal_attempt(Path::new("src/main.rs")));
922 assert!(!is_path_traversal_attempt(Path::new("/home/user/project")));
923 assert!(!is_path_traversal_attempt(Path::new(".")));
924 }
925
926 #[test]
931 fn test_checked_depth_increment() {
932 let current = usize::MAX - 1;
934 let result = check_analysis_depth(current);
936 assert!(result.is_err()); }
938
939 #[test]
940 fn test_saturating_depth_increment() {
941 assert_eq!(saturating_depth_increment(0), 1);
942 assert_eq!(saturating_depth_increment(100), 101);
943 assert_eq!(saturating_depth_increment(usize::MAX), usize::MAX);
944 }
945
946 #[test]
947 fn test_saturating_count_add() {
948 assert_eq!(saturating_count_add(0, 1), 1);
949 assert_eq!(saturating_count_add(100, 50), 150);
950 assert_eq!(saturating_count_add(u32::MAX, 1), u32::MAX);
951 }
952
953 #[test]
954 fn test_within_limit() {
955 assert!(within_limit(0, 100));
956 assert!(within_limit(99, 100));
957 assert!(!within_limit(100, 100));
958 assert!(!within_limit(101, 100));
959 }
960
961 #[test]
962 fn test_approaching_limit() {
963 assert!(!approaching_limit(79, 100));
965 assert!(!approaching_limit(80, 100));
966 assert!(approaching_limit(81, 100));
967 assert!(approaching_limit(100, 100));
968 }
969
970 #[test]
971 fn test_should_warn_file_size() {
972 assert!(!should_warn_file_size(0));
973 assert!(!should_warn_file_size(WARN_FILE_SIZE));
974 assert!(should_warn_file_size(WARN_FILE_SIZE + 1));
975 }
976
977 #[test]
978 fn test_format_large_file_warning() {
979 let warning = format_large_file_warning(Path::new("/test/file.py"), 2 * 1024 * 1024);
980 assert!(warning.contains("file.py"));
981 assert!(warning.contains("2.0 MB"));
982 assert!(warning.contains("Warning"));
983 }
984}