shell_sanitize_rules/path_traversal.rs
1use shell_sanitize::{Rule, RuleResult, RuleViolation};
2
3/// Rejects input containing path traversal patterns.
4///
5/// Detects:
6/// - `..` components (parent directory traversal)
7/// - Absolute paths (`/` prefix on Unix, drive letters on Windows)
8///
9/// This is a **lexical check** — no filesystem access is performed.
10/// URL-encoded sequences (e.g. `%2e%2e`) or other encodings are **not**
11/// decoded before inspection. Callers must decode the input before
12/// passing it to this rule if encoded traversal is a concern.
13///
14/// # Rationale
15///
16/// Path traversal allows escaping a base directory:
17/// ```text
18/// ../../etc/passwd
19/// /absolute/path/escape
20/// ```
21pub struct PathTraversalRule {
22 /// Whether to reject absolute paths.
23 deny_absolute: bool,
24}
25
26impl Default for PathTraversalRule {
27 fn default() -> Self {
28 Self {
29 deny_absolute: true,
30 }
31 }
32}
33
34impl PathTraversalRule {
35 /// Create a rule that allows absolute paths (only rejects `..`).
36 pub fn allow_absolute() -> Self {
37 Self {
38 deny_absolute: false,
39 }
40 }
41}
42
43impl Rule for PathTraversalRule {
44 fn name(&self) -> &'static str {
45 "path_traversal"
46 }
47
48 fn check(&self, input: &str) -> RuleResult {
49 let mut violations = Vec::new();
50
51 // ASCII-only scan: splitting at '/' and '\\' ensures byte indices
52 // are always valid UTF-8 char boundaries.
53 let bytes = input.as_bytes();
54 let len = bytes.len();
55 let mut start = 0;
56
57 for i in 0..len {
58 if bytes[i] == b'/' || bytes[i] == b'\\' {
59 if i > start && &input[start..i] == ".." {
60 violations.push(
61 RuleViolation::new(self.name(), "parent directory traversal `..` found")
62 .at(start)
63 .with_fragment(".."),
64 );
65 }
66 start = i + 1;
67 }
68 }
69 // Last component
70 if start < len && &input[start..] == ".." {
71 violations.push(
72 RuleViolation::new(self.name(), "parent directory traversal `..` found")
73 .at(start)
74 .with_fragment(".."),
75 );
76 }
77
78 // Check for absolute path
79 if self.deny_absolute {
80 if input.starts_with('/') {
81 violations.push(
82 RuleViolation::new(self.name(), "absolute path (starts with `/`) found")
83 .at(0)
84 .with_fragment("/"),
85 );
86 }
87 // Windows drive letter: C:\ or C:/
88 if len >= 3
89 && bytes[0].is_ascii_alphabetic()
90 && bytes[1] == b':'
91 && (bytes[2] == b'\\' || bytes[2] == b'/')
92 {
93 violations.push(
94 RuleViolation::new(self.name(), "absolute path (drive letter) found")
95 .at(0)
96 .with_fragment(input[..3].to_string()),
97 );
98 }
99 }
100
101 if violations.is_empty() {
102 Ok(())
103 } else {
104 Err(violations)
105 }
106 }
107}