Skip to main content

tldr_cli/commands/patterns/
validation.rs

1//! Input validation and path safety utilities for Pattern Analysis commands.
2//!
3//! Provides security-focused validation functions to mitigate:
4//! - **T01 - Path Traversal**: BLOCKED_PREFIXES for system directories
5//! - **T02 - Project Root Enforcement**: validate_file_path_in_project()
6//! - **T03 - Integer Overflow**: Checked arithmetic for depth calculations
7//! - **T08 - Memory Exhaustion**: Resource limit constants
8//!
9//! All file paths are canonicalized and checked against project boundaries.
10//! Resource limits are enforced to prevent denial-of-service conditions.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use super::error::{PatternsError, PatternsResult};
16
17// =============================================================================
18// Resource Limits (TIGER-08 Mitigations)
19// =============================================================================
20
21/// Maximum file size for analysis (10 MB).
22/// Files larger than this will be rejected.
23pub const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
24
25/// Warning threshold for file size (1 MB).
26/// Files larger than this emit a warning but are still processed.
27pub const WARN_FILE_SIZE: u64 = 1024 * 1024;
28
29/// Maximum files to scan in directory analysis.
30pub const MAX_DIRECTORY_FILES: u32 = 1000;
31
32/// Maximum AST traversal depth.
33/// Prevents stack overflow from deeply nested source code.
34pub const MAX_AST_DEPTH: usize = 100;
35
36/// Maximum recursion depth for analysis algorithms.
37/// Used for CFG path enumeration, temporal mining, etc.
38pub const MAX_ANALYSIS_DEPTH: usize = 500;
39
40/// Maximum function name length.
41pub const MAX_FUNCTION_NAME_LEN: usize = 256;
42
43/// Maximum constraints to report per file.
44pub const MAX_CONSTRAINTS_PER_FILE: usize = 500;
45
46/// Maximum methods per class for cohesion analysis.
47pub const MAX_METHODS_PER_CLASS: usize = 200;
48
49/// Maximum fields per class for cohesion analysis.
50pub const MAX_FIELDS_PER_CLASS: usize = 100;
51
52/// Maximum classes per file.
53pub const MAX_CLASSES_PER_FILE: usize = 500;
54
55/// Maximum CFG paths to enumerate (TIGER-04).
56/// Prevents unbounded path enumeration in resources command.
57pub const MAX_PATHS: usize = 1000;
58
59/// Maximum trigrams to collect (TIGER-05).
60/// Prevents memory exhaustion in temporal mining.
61pub const MAX_TRIGRAMS: usize = 10000;
62
63/// Maximum class complexity (methods * fields) for analysis.
64pub const MAX_CLASS_COMPLEXITY: usize = 500;
65
66// =============================================================================
67// Blocked System Directories (TIGER-01)
68// =============================================================================
69
70/// System directories that should never be analyzed (security measure).
71/// Note: We specifically target sensitive system directories.
72const 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/",  // macOS system config
83    "C:\\Windows\\",  // Windows
84    "C:\\System32\\", // Windows
85];
86
87// =============================================================================
88// Path Validation (TIGER-01, TIGER-02)
89// =============================================================================
90
91/// Validate and canonicalize a file path.
92///
93/// This function:
94/// 1. Checks that the path exists
95/// 2. Canonicalizes the path (resolves symlinks, `.`, `..`)
96/// 3. Rejects system directories
97/// 4. Validates UTF-8 encoding
98///
99/// # Arguments
100///
101/// * `path` - The path to validate
102///
103/// # Returns
104///
105/// The canonicalized path if valid, or an error.
106///
107/// # Errors
108///
109/// - `PatternsError::FileNotFound` if the file doesn't exist
110/// - `PatternsError::PathTraversal` if path is a system dir or has invalid encoding
111///
112/// # Example
113///
114/// ```ignore
115/// let valid = validate_file_path(Path::new("src/main.py"))?;
116/// assert!(valid.is_absolute());
117/// ```
118pub fn validate_file_path(path: &Path) -> PatternsResult<PathBuf> {
119    // Check file exists
120    if !path.exists() {
121        return Err(PatternsError::FileNotFound {
122            path: path.to_path_buf(),
123        });
124    }
125
126    // Canonicalize the path (resolves symlinks, .., .)
127    let canonical = fs::canonicalize(path).map_err(|_| PatternsError::FileNotFound {
128        path: path.to_path_buf(),
129    })?;
130
131    // Check for system directories
132    let canonical_str = canonical.to_string_lossy();
133    for blocked in BLOCKED_PREFIXES {
134        // Check with trailing slash for directories, or exact match for files
135        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    // Validate UTF-8 (path.to_str() returns None if not valid UTF-8)
143    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
152/// Validate a file path ensuring it stays within a project root.
153///
154/// This is stricter than `validate_file_path` - it ensures the resolved
155/// path is a descendant of the project root directory.
156///
157/// # Arguments
158///
159/// * `path` - The path to validate
160/// * `project_root` - The project root directory to stay within
161///
162/// # Returns
163///
164/// The canonicalized path if valid and within project root.
165///
166/// # Errors
167///
168/// - `PatternsError::FileNotFound` if the file doesn't exist
169/// - `PatternsError::PathTraversal` if path escapes project root
170pub fn validate_file_path_in_project(path: &Path, project_root: &Path) -> PatternsResult<PathBuf> {
171    // First do basic validation
172    let canonical = validate_file_path(path)?;
173
174    // Canonicalize project root too
175    let canonical_root =
176        fs::canonicalize(project_root).map_err(|_| PatternsError::FileNotFound {
177            path: project_root.to_path_buf(),
178        })?;
179
180    // Check that canonical path starts with canonical root
181    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
190/// Validate and canonicalize a directory path.
191///
192/// # Arguments
193///
194/// * `path` - The path to validate
195///
196/// # Returns
197///
198/// The canonicalized path if valid and is a directory.
199///
200/// # Errors
201///
202/// - `PatternsError::FileNotFound` if the directory doesn't exist
203/// - `PatternsError::NotADirectory` if the path is not a directory
204pub 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
216/// Check if a path contains path traversal patterns.
217///
218/// This is a quick check for suspicious patterns before canonicalization.
219/// Returns true if the path looks suspicious.
220///
221/// # Arguments
222///
223/// * `path` - The path to check
224///
225/// # Returns
226///
227/// `true` if the path contains traversal patterns (`..\` or null bytes)
228pub fn is_path_traversal_attempt(path: &Path) -> bool {
229    let path_str = path.to_string_lossy();
230
231    // Check for explicit traversal patterns
232    if path_str.contains("..") {
233        return true;
234    }
235
236    // Check for null bytes (could be used to truncate paths)
237    if path_str.contains('\0') {
238        return true;
239    }
240
241    false
242}
243
244// =============================================================================
245// File Size Validation (TIGER-08)
246// =============================================================================
247
248/// Validate file size against limits.
249///
250/// # Arguments
251///
252/// * `path` - The path to the file
253///
254/// # Returns
255///
256/// The file size in bytes if within limits.
257///
258/// # Errors
259///
260/// - `PatternsError::FileNotFound` if file doesn't exist
261/// - `PatternsError::FileTooLarge` if file exceeds MAX_FILE_SIZE
262pub 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
279/// Safely read a file with size limits and UTF-8 validation.
280///
281/// This function:
282/// 1. Validates the file path
283/// 2. Checks file size against limits
284/// 3. Reads the file content
285/// 4. Validates UTF-8 encoding
286///
287/// # Arguments
288///
289/// * `path` - The path to the file to read
290///
291/// # Returns
292///
293/// The file contents as a String if successful.
294///
295/// # Errors
296///
297/// - `PatternsError::FileNotFound` if file doesn't exist
298/// - `PatternsError::FileTooLarge` if file exceeds MAX_FILE_SIZE
299/// - `PatternsError::ParseError` if file is not valid UTF-8
300/// - `PatternsError::Io` for other IO errors
301pub fn read_file_safe(path: &Path) -> PatternsResult<String> {
302    // Validate path and size
303    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    // Read the file
317    let content = fs::read(&canonical)?;
318
319    // Validate UTF-8
320    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
326// =============================================================================
327// Depth Checking (TIGER-03)
328// =============================================================================
329
330/// Check if AST depth limit has been exceeded.
331///
332/// Uses checked comparison to avoid any overflow issues.
333///
334/// # Arguments
335///
336/// * `current_depth` - The current traversal depth
337///
338/// # Returns
339///
340/// `Ok(())` if within limits, error otherwise.
341///
342/// # Errors
343///
344/// - `PatternsError::DepthLimitExceeded` if depth >= MAX_AST_DEPTH
345pub 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
356/// Check if analysis depth limit has been exceeded.
357///
358/// Uses saturating arithmetic to prevent overflow.
359///
360/// # Arguments
361///
362/// * `current_depth` - The current analysis depth
363///
364/// # Returns
365///
366/// `Ok(())` if within limits, error otherwise.
367///
368/// # Errors
369///
370/// - `PatternsError::DepthLimitExceeded` if depth >= MAX_ANALYSIS_DEPTH
371pub 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
382/// Check if directory file count limit has been exceeded.
383///
384/// # Arguments
385///
386/// * `count` - The current file count
387///
388/// # Returns
389///
390/// `Ok(())` if within limits, error otherwise.
391///
392/// # Errors
393///
394/// - `PatternsError::TooManyFiles` if count > MAX_DIRECTORY_FILES
395pub 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
406// =============================================================================
407// Function Name Validation
408// =============================================================================
409
410/// Validate a function name for safety.
411///
412/// Ensures the name:
413/// - Is not empty
414/// - Contains only valid identifier characters
415/// - Doesn't exceed maximum length
416/// - Doesn't contain suspicious characters
417///
418/// # Arguments
419///
420/// * `name` - The function name to validate
421///
422/// # Returns
423///
424/// `Ok(())` if valid, error otherwise.
425///
426/// # Errors
427///
428/// - `PatternsError::InvalidParameter` for invalid names
429pub fn validate_function_name(name: &str) -> PatternsResult<()> {
430    // Check empty
431    if name.is_empty() {
432        return Err(PatternsError::InvalidParameter {
433            message: "function name cannot be empty".to_string(),
434        });
435    }
436
437    // Check length
438    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    // Check for suspicious characters that could be used for injection
449    // Valid identifiers: letters, digits, underscore (and some languages allow $)
450    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    // First character should be letter or underscore (standard identifier rules)
462    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// =============================================================================
474// Checked Arithmetic Utilities (TIGER-03)
475// =============================================================================
476
477/// Safely increment a depth counter with overflow protection.
478///
479/// Returns the incremented value or saturates at usize::MAX.
480///
481/// # Arguments
482///
483/// * `depth` - The current depth value
484///
485/// # Returns
486///
487/// The incremented depth (or usize::MAX if overflow would occur)
488#[inline]
489pub fn saturating_depth_increment(depth: usize) -> usize {
490    depth.saturating_add(1)
491}
492
493/// Safely add to a counter with overflow protection.
494///
495/// Returns the sum or saturates at the type maximum.
496///
497/// # Arguments
498///
499/// * `count` - The current count
500/// * `add` - The amount to add
501///
502/// # Returns
503///
504/// The sum (or type max if overflow would occur)
505#[inline]
506pub fn saturating_count_add(count: u32, add: u32) -> u32 {
507    count.saturating_add(add)
508}
509
510/// Check if a value is within a limit using checked arithmetic.
511///
512/// # Arguments
513///
514/// * `value` - The value to check
515/// * `limit` - The maximum allowed value
516///
517/// # Returns
518///
519/// `true` if value < limit
520#[inline]
521pub fn within_limit(value: usize, limit: usize) -> bool {
522    value < limit
523}
524
525// =============================================================================
526// Warning Utilities
527// =============================================================================
528
529/// Check if a file size is large enough to warrant a warning.
530///
531/// # Arguments
532///
533/// * `size` - The file size in bytes
534///
535/// # Returns
536///
537/// `true` if size > WARN_FILE_SIZE
538#[inline]
539pub fn should_warn_file_size(size: u64) -> bool {
540    size > WARN_FILE_SIZE
541}
542
543/// Format a warning message for a large file.
544///
545/// # Arguments
546///
547/// * `path` - The file path
548/// * `size` - The file size in bytes
549///
550/// # Returns
551///
552/// A formatted warning string
553pub 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// =============================================================================
562// Near-Limit Warning Utilities
563// =============================================================================
564
565/// Check if a count is approaching a limit (>80%).
566///
567/// # Arguments
568///
569/// * `count` - The current count
570/// * `limit` - The maximum limit
571///
572/// # Returns
573///
574/// `true` if count > 80% of limit
575#[inline]
576pub fn approaching_limit(count: usize, limit: usize) -> bool {
577    // Use checked arithmetic to avoid overflow
578    let threshold = limit.saturating_mul(80) / 100;
579    count > threshold
580}
581
582/// Log a warning if approaching a limit.
583///
584/// # Arguments
585///
586/// * `count` - The current count
587/// * `limit` - The maximum limit
588/// * `resource_name` - Name of the resource for the warning message
589pub 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    // =========================================================================
607    // Resource Limits Constants Tests (TIGER-08)
608    // =========================================================================
609
610    #[test]
611    fn test_resource_limits_constants() {
612        // Verify TIGER mitigation constants have sensible values
613        assert_eq!(MAX_FILE_SIZE, 10 * 1024 * 1024); // 10 MB
614        assert_eq!(MAX_DIRECTORY_FILES, 1000);
615        assert_eq!(MAX_AST_DEPTH, 100); // TIGER-08
616        assert_eq!(MAX_ANALYSIS_DEPTH, 500);
617        assert_eq!(MAX_PATHS, 1000); // TIGER-04
618        assert_eq!(MAX_TRIGRAMS, 10000); // TIGER-05
619        assert_eq!(MAX_CLASS_COMPLEXITY, 500);
620    }
621
622    // =========================================================================
623    // Path Validation Tests (TIGER-01, TIGER-02)
624    // =========================================================================
625
626    #[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        // Check that path with .. pattern is detected
654        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        // Test that system directories are blocked
694        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 the file exists on this system, it should be rejected
704            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    // =========================================================================
726    // File Size Validation Tests (TIGER-08)
727    // =========================================================================
728
729    #[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        // Write invalid UTF-8 bytes
761        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    // =========================================================================
776    // Depth Checking Tests (TIGER-03)
777    // =========================================================================
778
779    #[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    // =========================================================================
836    // Function Name Validation Tests
837    // =========================================================================
838
839    #[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",  // semicolon
879            "func()",     // parentheses
880            "func{}",     // braces
881            "func`cmd`",  // backticks
882            "func\"name", // quotes
883            "func\\name", // backslash
884            "func/name",  // forward slash
885        ];
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    // =========================================================================
907    // Path Traversal Pattern Detection Tests
908    // =========================================================================
909
910    #[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    // =========================================================================
927    // Checked Arithmetic Tests (TIGER-03)
928    // =========================================================================
929
930    #[test]
931    fn test_checked_depth_increment() {
932        // Test that we use checked arithmetic - verify the function handles edge cases
933        let current = usize::MAX - 1;
934        // This should not panic due to overflow
935        let result = check_analysis_depth(current);
936        assert!(result.is_err()); // Should exceed limit, not overflow
937    }
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        // 80% of 100 = 80
964        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}