tldr_cli/commands/contracts/
validation.rs1use std::fs;
13use std::path::{Path, PathBuf};
14
15use super::error::{ContractsError, ContractsResult};
16
17pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
24
25pub const WARN_FILE_SIZE: u64 = 1024 * 1024;
28
29pub const MAX_CFG_DEPTH: usize = 1000;
32
33pub const MAX_SSA_NODES: usize = 100_000;
36
37pub const MAX_AST_DEPTH: usize = 100;
40
41pub const MAX_FUNCTION_NAME_LEN: usize = 256;
43
44pub const MAX_CONDITIONS_PER_FUNCTION: usize = 100;
46
47const BLOCKED_PREFIXES: &[&str] = &[
55 "/etc/",
56 "/etc/passwd",
57 "/etc/shadow",
58 "/root/",
59 "/sys/",
60 "/proc/",
61 "/dev/",
62 "/var/run/",
63 "/var/log/",
64 "/private/etc/", "C:\\Windows\\", "C:\\System32\\", ];
68
69pub fn validate_file_path(path: &Path) -> ContractsResult<PathBuf> {
102 if !path.exists() {
104 return Err(ContractsError::FileNotFound {
105 path: path.to_path_buf(),
106 });
107 }
108
109 let canonical = fs::canonicalize(path).map_err(|_| ContractsError::FileNotFound {
111 path: path.to_path_buf(),
112 })?;
113
114 let canonical_str = canonical.to_string_lossy();
116 for blocked in BLOCKED_PREFIXES {
117 if canonical_str.starts_with(blocked) || canonical_str == blocked.trim_end_matches('/') {
119 return Err(ContractsError::PathTraversal {
120 path: path.to_path_buf(),
121 });
122 }
123 }
124
125 if canonical.to_str().is_none() {
127 return Err(ContractsError::PathTraversal {
128 path: path.to_path_buf(),
129 });
130 }
131
132 Ok(canonical)
133}
134
135pub fn validate_file_path_in_project(path: &Path, project_root: &Path) -> ContractsResult<PathBuf> {
154 let canonical = validate_file_path(path)?;
156
157 let canonical_root =
159 fs::canonicalize(project_root).map_err(|_| ContractsError::FileNotFound {
160 path: project_root.to_path_buf(),
161 })?;
162
163 if !canonical.starts_with(&canonical_root) {
165 return Err(ContractsError::PathTraversal {
166 path: path.to_path_buf(),
167 });
168 }
169
170 Ok(canonical)
171}
172
173pub fn has_path_traversal_pattern(path: &Path) -> bool {
178 let path_str = path.to_string_lossy();
179
180 if path_str.contains("..") {
182 return true;
183 }
184
185 if path_str.contains('\0') {
187 return true;
188 }
189
190 false
191}
192
193pub fn validate_line_numbers(start: u32, end: u32, max: u32) -> ContractsResult<()> {
219 if start == 0 {
221 return Err(ContractsError::LineOutsideFunction {
222 line: start,
223 function: "unknown".to_string(),
224 start: 1,
225 end: max,
226 });
227 }
228
229 if end == 0 {
230 return Err(ContractsError::LineOutsideFunction {
231 line: end,
232 function: "unknown".to_string(),
233 start: 1,
234 end: max,
235 });
236 }
237
238 if start > end {
240 return Err(ContractsError::LineOutsideFunction {
241 line: start,
242 function: "unknown".to_string(),
243 start: 1,
244 end,
245 });
246 }
247
248 if start > max {
250 return Err(ContractsError::LineOutsideFunction {
251 line: start,
252 function: "unknown".to_string(),
253 start: 1,
254 end: max,
255 });
256 }
257
258 if end > max {
259 return Err(ContractsError::LineOutsideFunction {
260 line: end,
261 function: "unknown".to_string(),
262 start: 1,
263 end: max,
264 });
265 }
266
267 Ok(())
268}
269
270pub fn validate_function_name(name: &str) -> ContractsResult<()> {
294 if name.is_empty() {
296 return Err(ContractsError::InvalidFunctionName {
297 reason: "function name cannot be empty".to_string(),
298 });
299 }
300
301 if name.len() > MAX_FUNCTION_NAME_LEN {
303 return Err(ContractsError::InvalidFunctionName {
304 reason: format!(
305 "function name too long ({} chars, max {})",
306 name.len(),
307 MAX_FUNCTION_NAME_LEN
308 ),
309 });
310 }
311
312 let suspicious_chars = [
315 ';', '(', ')', '{', '}', '[', ']', '`', '"', '\'', '\\', '/', '\0',
316 ];
317 for c in name.chars() {
318 if suspicious_chars.contains(&c) {
319 return Err(ContractsError::InvalidFunctionName {
320 reason: format!("function name contains invalid character: '{}'", c),
321 });
322 }
323 }
324
325 if let Some(first) = name.chars().next() {
327 if !first.is_alphabetic() && first != '_' {
328 return Err(ContractsError::InvalidFunctionName {
329 reason: "function name must start with letter or underscore".to_string(),
330 });
331 }
332 }
333
334 Ok(())
335}
336
337pub fn read_file_safe(path: &Path) -> ContractsResult<String> {
363 let canonical = validate_file_path(path)?;
365
366 let metadata = fs::metadata(&canonical)?;
368 let size = metadata.len();
369
370 if size > MAX_FILE_SIZE {
371 return Err(ContractsError::FileTooLarge {
372 path: path.to_path_buf(),
373 bytes: size,
374 max_bytes: MAX_FILE_SIZE,
375 });
376 }
377
378 let content = fs::read(&canonical)?;
380
381 String::from_utf8(content).map_err(|_| ContractsError::ParseError {
383 file: path.to_path_buf(),
384 message: "file is not valid UTF-8".to_string(),
385 })
386}
387
388pub fn read_file_safe_with_warning<F>(path: &Path, warn_fn: Option<F>) -> ContractsResult<String>
402where
403 F: FnOnce(&str),
404{
405 let canonical = validate_file_path(path)?;
407
408 let metadata = fs::metadata(&canonical)?;
410 let size = metadata.len();
411
412 if size > MAX_FILE_SIZE {
413 return Err(ContractsError::FileTooLarge {
414 path: path.to_path_buf(),
415 bytes: size,
416 max_bytes: MAX_FILE_SIZE,
417 });
418 }
419
420 if size > WARN_FILE_SIZE {
422 let warning = format!(
423 "Warning: {} is large ({:.1} MB), analysis may be slow",
424 path.display(),
425 size as f64 / 1024.0 / 1024.0
426 );
427 if let Some(f) = warn_fn {
428 f(&warning);
429 } else {
430 eprintln!("{}", warning);
431 }
432 }
433
434 let content = fs::read(&canonical)?;
436
437 String::from_utf8(content).map_err(|_| ContractsError::ParseError {
439 file: path.to_path_buf(),
440 message: "file is not valid UTF-8".to_string(),
441 })
442}
443
444pub fn check_depth_limit(current_depth: usize, max_depth: usize) -> ContractsResult<()> {
452 if current_depth >= max_depth {
453 Err(ContractsError::SliceDepthExceeded {
454 max_depth: max_depth as u32,
455 })
456 } else {
457 Ok(())
458 }
459}
460
461pub fn check_ssa_node_limit(node_count: usize) -> ContractsResult<()> {
463 if node_count > MAX_SSA_NODES {
464 Err(ContractsError::SsaTooLarge {
465 nodes: node_count as u32,
466 max_nodes: MAX_SSA_NODES as u32,
467 })
468 } else {
469 Ok(())
470 }
471}
472
473pub fn check_ast_depth(depth: usize, file: &Path) -> ContractsResult<()> {
475 if depth > MAX_AST_DEPTH {
476 Err(ContractsError::AstTooDeep {
477 file: file.to_path_buf(),
478 depth: depth as u32,
479 max_depth: MAX_AST_DEPTH as u32,
480 })
481 } else {
482 Ok(())
483 }
484}
485
486#[cfg(test)]
491mod tests {
492 use super::*;
493 use std::io::Write;
494 use tempfile::{tempdir, NamedTempFile};
495
496 #[test]
501 fn test_validate_file_path_normal() {
502 let file = NamedTempFile::new().unwrap();
504 let path = file.path();
505
506 let result = validate_file_path(path);
507 assert!(result.is_ok());
508
509 let canonical = result.unwrap();
510 assert!(canonical.is_absolute());
511 }
512
513 #[test]
514 fn test_validate_file_path_not_exists() {
515 let result = validate_file_path(Path::new("/nonexistent/file.py"));
516 assert!(result.is_err());
517
518 match result.unwrap_err() {
519 ContractsError::FileNotFound { path } => {
520 assert!(path.to_string_lossy().contains("nonexistent"));
521 }
522 _ => panic!("Expected FileNotFound error"),
523 }
524 }
525
526 #[test]
527 fn test_validate_file_path_traversal_rejected() {
528 let temp = tempdir().unwrap();
530 let subdir = temp.path().join("subdir");
531 fs::create_dir(&subdir).unwrap();
532
533 let file_path = temp.path().join("secret.txt");
535 fs::write(&file_path, "secret").unwrap();
536
537 let suspicious = subdir.join("..").join("secret.txt");
539 assert!(has_path_traversal_pattern(&suspicious));
540 }
541
542 #[test]
543 fn test_validate_file_path_symlink_outside_project() {
544 let project = tempdir().unwrap();
546 let outside = tempdir().unwrap();
547
548 let outside_file = outside.path().join("secret.txt");
550 fs::write(&outside_file, "secret").unwrap();
551
552 let symlink_path = project.path().join("link.txt");
554
555 #[cfg(unix)]
556 {
557 std::os::unix::fs::symlink(&outside_file, &symlink_path).unwrap();
558
559 let result = validate_file_path_in_project(&symlink_path, project.path());
561 assert!(result.is_err());
562
563 match result.unwrap_err() {
564 ContractsError::PathTraversal { .. } => {}
565 e => panic!("Expected PathTraversal error, got {:?}", e),
566 }
567 }
568 }
569
570 #[test]
571 fn test_validate_file_path_system_dir_rejected() {
572 let blocked = [
576 "/etc/passwd",
577 "/root/.bashrc",
578 "/sys/kernel/config",
579 "/proc/self/status",
580 ];
581
582 for path_str in blocked {
583 let path = Path::new(path_str);
584 if path.exists() {
586 let result = validate_file_path(path);
587 assert!(result.is_err(), "Should reject system path: {}", path_str);
588 }
589 }
590 }
591
592 #[test]
597 fn test_validate_line_numbers_valid_range() {
598 assert!(validate_line_numbers(1, 10, 100).is_ok());
599 assert!(validate_line_numbers(1, 1, 100).is_ok()); assert!(validate_line_numbers(50, 100, 100).is_ok()); }
602
603 #[test]
604 fn test_validate_line_numbers_start_after_end() {
605 let result = validate_line_numbers(10, 5, 100);
606 assert!(result.is_err());
607
608 match result.unwrap_err() {
609 ContractsError::LineOutsideFunction { line, .. } => {
610 assert_eq!(line, 10);
611 }
612 _ => panic!("Expected LineOutsideFunction error"),
613 }
614 }
615
616 #[test]
617 fn test_validate_line_numbers_exceeds_max() {
618 let result = validate_line_numbers(1, 200, 100);
619 assert!(result.is_err());
620
621 match result.unwrap_err() {
622 ContractsError::LineOutsideFunction { line, .. } => {
623 assert_eq!(line, 200);
624 }
625 _ => panic!("Expected LineOutsideFunction error"),
626 }
627 }
628
629 #[test]
630 fn test_validate_line_numbers_zero() {
631 assert!(validate_line_numbers(0, 10, 100).is_err());
632 assert!(validate_line_numbers(1, 0, 100).is_err());
633 }
634
635 #[test]
640 fn test_validate_function_name_valid() {
641 assert!(validate_function_name("my_function").is_ok());
642 assert!(validate_function_name("_private").is_ok());
643 assert!(validate_function_name("CamelCase").is_ok());
644 assert!(validate_function_name("func123").is_ok());
645 assert!(validate_function_name("__dunder__").is_ok());
646 }
647
648 #[test]
649 fn test_validate_function_name_empty() {
650 let result = validate_function_name("");
651 assert!(result.is_err());
652
653 match result.unwrap_err() {
654 ContractsError::InvalidFunctionName { reason } => {
655 assert!(reason.contains("empty"));
656 }
657 _ => panic!("Expected InvalidFunctionName error"),
658 }
659 }
660
661 #[test]
662 fn test_validate_function_name_invalid_chars() {
663 let invalid_names = [
664 "func;drop", "func()", "func{}", "func`cmd`", "func\"name", "func\\name", "func/name", ];
672
673 for name in invalid_names {
674 let result = validate_function_name(name);
675 assert!(result.is_err(), "Should reject: {}", name);
676 }
677 }
678
679 #[test]
680 fn test_validate_function_name_starts_with_digit() {
681 let result = validate_function_name("123func");
682 assert!(result.is_err());
683
684 match result.unwrap_err() {
685 ContractsError::InvalidFunctionName { reason } => {
686 assert!(reason.contains("start with"));
687 }
688 _ => panic!("Expected InvalidFunctionName error"),
689 }
690 }
691
692 #[test]
693 fn test_validate_function_name_too_long() {
694 let long_name = "a".repeat(MAX_FUNCTION_NAME_LEN + 1);
695 let result = validate_function_name(&long_name);
696 assert!(result.is_err());
697
698 match result.unwrap_err() {
699 ContractsError::InvalidFunctionName { reason } => {
700 assert!(reason.contains("too long"));
701 }
702 _ => panic!("Expected InvalidFunctionName error"),
703 }
704 }
705
706 #[test]
711 fn test_read_file_safe_normal() {
712 let mut file = NamedTempFile::new().unwrap();
713 writeln!(file, "def hello():\n print('hello')").unwrap();
714
715 let content = read_file_safe(file.path()).unwrap();
716 assert!(content.contains("def hello"));
717 assert!(content.contains("print"));
718 }
719
720 #[test]
721 fn test_read_file_safe_not_exists() {
722 let result = read_file_safe(Path::new("/nonexistent/file.py"));
723 assert!(result.is_err());
724
725 match result.unwrap_err() {
726 ContractsError::FileNotFound { .. } => {}
727 e => panic!("Expected FileNotFound error, got {:?}", e),
728 }
729 }
730
731 #[test]
732 fn test_read_file_safe_too_large() {
733 let temp = tempdir().unwrap();
735 let _large_file = temp.path().join("large.txt");
736
737 let max_file_size = std::hint::black_box(MAX_FILE_SIZE);
741 assert_eq!(max_file_size, 10 * 1024 * 1024);
742 }
743
744 #[test]
745 fn test_read_file_safe_not_utf8() {
746 let temp = tempdir().unwrap();
747 let binary_file = temp.path().join("binary.bin");
748
749 let invalid_utf8 = vec![0xFF, 0xFE, 0x00, 0x01];
751 fs::write(&binary_file, invalid_utf8).unwrap();
752
753 let result = read_file_safe(&binary_file);
754 assert!(result.is_err());
755
756 match result.unwrap_err() {
757 ContractsError::ParseError { message, .. } => {
758 assert!(message.contains("UTF-8"));
759 }
760 e => panic!("Expected ParseError, got {:?}", e),
761 }
762 }
763
764 #[test]
769 fn test_resource_limits_constants() {
770 assert_eq!(MAX_FILE_SIZE, 10 * 1024 * 1024); assert_eq!(MAX_CFG_DEPTH, 1000); assert_eq!(MAX_SSA_NODES, 100_000); assert_eq!(MAX_AST_DEPTH, 100); }
776
777 #[test]
778 fn test_check_depth_limit() {
779 assert!(check_depth_limit(0, 1000).is_ok());
780 assert!(check_depth_limit(999, 1000).is_ok());
781 assert!(check_depth_limit(1000, 1000).is_err());
782 assert!(check_depth_limit(1001, 1000).is_err());
783 }
784
785 #[test]
786 fn test_check_ssa_node_limit() {
787 assert!(check_ssa_node_limit(0).is_ok());
788 assert!(check_ssa_node_limit(MAX_SSA_NODES).is_ok());
789 assert!(check_ssa_node_limit(MAX_SSA_NODES + 1).is_err());
790 }
791
792 #[test]
793 fn test_check_ast_depth() {
794 let file = Path::new("test.py");
795 assert!(check_ast_depth(0, file).is_ok());
796 assert!(check_ast_depth(MAX_AST_DEPTH, file).is_ok());
797 assert!(check_ast_depth(MAX_AST_DEPTH + 1, file).is_err());
798 }
799
800 #[test]
805 fn test_has_path_traversal_pattern() {
806 assert!(has_path_traversal_pattern(Path::new("../etc/passwd")));
808 assert!(has_path_traversal_pattern(Path::new("foo/../bar")));
809 assert!(has_path_traversal_pattern(Path::new(
810 "..\\Windows\\System32"
811 )));
812
813 assert!(!has_path_traversal_pattern(Path::new("src/main.rs")));
815 assert!(!has_path_traversal_pattern(Path::new("/home/user/project")));
816 assert!(!has_path_traversal_pattern(Path::new(".")));
817 }
818}