Skip to main content

perl_dap/eval/
validator.rs

1//! Expression safety validation for debugger evaluate policy.
2//!
3//! This module provides policy validation logic for detecting dangerous
4//! operations in Perl expressions during debug evaluation. It does not sandbox
5//! the interpreter.
6
7use super::patterns::{
8    ASSIGNMENT_OP_TOKENS_RE, ASSIGNMENT_OPERATORS, DANGEROUS_OPS_RE, REGEX_MUTATION_RE,
9};
10
11/// Error type for unsafe expression detection
12#[derive(Debug, Clone, thiserror::Error)]
13pub enum ValidationError {
14    /// Expression contains a dangerous operation
15    #[error(
16        "Safe evaluation mode: potentially mutating operation '{0}' not allowed (use allowSideEffects: true)"
17    )]
18    DangerousOperation(String),
19
20    /// Expression contains an assignment operator
21    #[error(
22        "Safe evaluation mode: assignment operator '{0}' not allowed (use allowSideEffects: true)"
23    )]
24    AssignmentOperator(String),
25
26    /// Expression contains increment/decrement operators
27    #[error(
28        "Safe evaluation mode: increment/decrement operators not allowed (use allowSideEffects: true)"
29    )]
30    IncrementDecrement,
31
32    /// Expression contains backticks (shell execution)
33    #[error(
34        "Safe evaluation mode: backticks (shell execution) not allowed (use allowSideEffects: true)"
35    )]
36    Backticks,
37
38    /// Expression contains a regex mutation operator (s///, tr///, y///)
39    #[error(
40        "Safe evaluation mode: regex mutation operator '{0}' not allowed (use allowSideEffects: true)"
41    )]
42    RegexMutation(String),
43
44    /// Expression contains newlines (potential command injection)
45    #[error("Expression cannot contain newlines")]
46    ContainsNewlines,
47}
48
49/// Result type for expression validation
50pub type ValidationResult = Result<(), ValidationError>;
51
52/// Safe expression evaluator for policy validation.
53///
54/// Validates that expressions are safe for evaluation during debugging,
55/// blocking operations that could mutate state or have side effects when the
56/// caller has not opted into `allowSideEffects`.
57#[derive(Debug, Clone, Default)]
58pub struct SafeEvaluator {
59    // Future: could add configuration options here
60}
61
62impl SafeEvaluator {
63    /// Create a new safe evaluator
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Validate that an expression is safe for evaluation
69    ///
70    /// # Arguments
71    ///
72    /// * `expression` - The Perl expression to validate
73    ///
74    /// # Returns
75    ///
76    /// `Ok(())` if the expression is safe, or an error describing why it's unsafe.
77    pub fn validate(&self, expression: &str) -> ValidationResult {
78        // Check for newlines (command injection vector)
79        if expression.contains('\n') || expression.contains('\r') {
80            return Err(ValidationError::ContainsNewlines);
81        }
82
83        // Check for backticks (shell execution)
84        if expression.contains('`') {
85            return Err(ValidationError::Backticks);
86        }
87
88        // Check for assignment operators while avoiding comparison false positives
89        if let Ok(re) = ASSIGNMENT_OP_TOKENS_RE.as_ref() {
90            for mat in re.find_iter(expression) {
91                let op = mat.as_str();
92                if ASSIGNMENT_OPERATORS.contains(&op) {
93                    return Err(ValidationError::AssignmentOperator(op.to_string()));
94                }
95            }
96        }
97
98        // Check for increment/decrement operators
99        if expression.contains("++") || expression.contains("--") {
100            return Err(ValidationError::IncrementDecrement);
101        }
102
103        // Check for dangerous operations using regex
104        self.check_dangerous_operations(expression)?;
105
106        // Check for regex mutation operators
107        self.check_regex_mutation(expression)?;
108
109        Ok(())
110    }
111
112    /// Check for dangerous operations in the expression
113    fn check_dangerous_operations(&self, expression: &str) -> ValidationResult {
114        let Some(re) = DANGEROUS_OPS_RE.as_ref().ok() else {
115            // If regex failed to compile, be conservative and allow
116            return Ok(());
117        };
118
119        for mat in re.find_iter(expression) {
120            let op = mat.as_str();
121            let start = mat.start();
122            let end = mat.end();
123
124            // Allow harmless occurrences in single-quoted literals
125            if is_in_single_quotes(expression, start) {
126                continue;
127            }
128
129            // Allow sigil-prefixed identifiers ($print, @say, %exit, *printf)
130            if is_sigil_prefixed_identifier(expression, start) {
131                continue;
132            }
133
134            // Allow ${print} (simple scalar braced variable form)
135            if is_simple_braced_scalar_var(expression, start, end) {
136                continue;
137            }
138
139            // Allow package-qualified names unless it's CORE::
140            if is_package_qualified_not_core(expression, start) {
141                continue;
142            }
143
144            // Block: either bare op or CORE:: qualified
145            return Err(ValidationError::DangerousOperation(op.to_string()));
146        }
147
148        Ok(())
149    }
150
151    /// Check for regex mutation operators (s///, tr///, y///)
152    fn check_regex_mutation(&self, expression: &str) -> ValidationResult {
153        let Some(re) = REGEX_MUTATION_RE.as_ref().ok() else {
154            return Ok(());
155        };
156
157        for mat in re.find_iter(expression) {
158            let op = mat.as_str();
159            let start = mat.start();
160
161            // Allow sigil-prefixed identifiers ($s, $tr, $y)
162            if is_sigil_prefixed_identifier(expression, start) {
163                continue;
164            }
165
166            // Allow escape sequences like \s, \y
167            if is_escape_sequence(expression, start) {
168                continue;
169            }
170
171            return Err(ValidationError::RegexMutation(op.trim().to_string()));
172        }
173
174        Ok(())
175    }
176}
177
178/// Check if a position in a string is inside single quotes
179fn is_in_single_quotes(s: &str, idx: usize) -> bool {
180    let mut in_sq = false;
181    let mut escaped = false;
182
183    for (i, ch) in s.char_indices() {
184        if i >= idx {
185            break;
186        }
187        if in_sq {
188            if escaped {
189                escaped = false;
190            } else if ch == '\\' {
191                escaped = true;
192            } else if ch == '\'' {
193                in_sq = false;
194            }
195        } else if ch == '\'' {
196            in_sq = true;
197        }
198    }
199
200    in_sq
201}
202
203/// Check if a match is preceded by CORE:: (which means it IS dangerous)
204fn is_core_qualified(s: &str, op_start: usize) -> bool {
205    let s_bytes = s.as_bytes();
206    // Check for GLOBAL prefix first
207    if op_start >= 8 && &s_bytes[op_start - 8..op_start] == b"GLOBAL::" {
208        // If GLOBAL, require CORE::GLOBAL::op
209        return op_start >= 14 && &s_bytes[op_start - 14..op_start - 8] == b"CORE::";
210    }
211
212    // Check for regular CORE:: prefix
213    op_start >= 6 && &s_bytes[op_start - 6..op_start] == b"CORE::"
214}
215
216/// Check if the match is a sigil-prefixed identifier ($print, @say, %exit, *dump)
217fn is_sigil_prefixed_identifier(s: &str, op_start: usize) -> bool {
218    let bytes = s.as_bytes();
219    if op_start == 0 {
220        return false;
221    }
222
223    // Must be preceded by a sigil
224    if !matches!(bytes[op_start - 1], b'$' | b'@' | b'%' | b'*') {
225        return false;
226    }
227
228    // Security: Check it's not being used for code execution (&$sub or ->$method)
229    let mut i = op_start - 1;
230    while i > 0 && bytes[i - 1].is_ascii_whitespace() {
231        i -= 1;
232    }
233
234    if i > 0 {
235        let prev = bytes[i - 1];
236
237        // &$sub is a code dereference (dangerous)
238        if prev == b'&' {
239            return false;
240        }
241
242        // ->$method is a method call (potentially dangerous)
243        if prev == b'>' && i > 1 && bytes[i - 2] == b'-' {
244            return false;
245        }
246
247        // Handle braced dereference &{ $sub }
248        if prev == b'{' {
249            i -= 1;
250            while i > 0 && bytes[i - 1].is_ascii_whitespace() {
251                i -= 1;
252            }
253            if i > 0 && bytes[i - 1] == b'&' {
254                return false;
255            }
256        }
257    }
258
259    true
260}
261
262/// Check if the match is a simple braced scalar variable ${print}
263fn is_simple_braced_scalar_var(s: &str, op_start: usize, op_end: usize) -> bool {
264    let bytes = s.as_bytes();
265
266    // Scan left for `${` (allow whitespace between)
267    let mut i = op_start;
268    while i > 0 && bytes[i - 1].is_ascii_whitespace() {
269        i -= 1;
270    }
271    if i < 1 || bytes[i - 1] != b'{' {
272        return false;
273    }
274    i -= 1;
275    while i > 0 && bytes[i - 1].is_ascii_whitespace() {
276        i -= 1;
277    }
278    if i < 1 || bytes[i - 1] != b'$' {
279        return false;
280    }
281
282    // Scan right for `}` (allow whitespace between)
283    let mut j = op_end;
284    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
285        j += 1;
286    }
287    j < bytes.len() && bytes[j] == b'}'
288}
289
290/// Check if the match is package-qualified (Foo::print) but not CORE::
291fn is_package_qualified_not_core(s: &str, op_start: usize) -> bool {
292    let bytes = s.as_bytes();
293    if op_start < 2 || bytes[op_start - 1] != b':' || bytes[op_start - 2] != b':' {
294        return false;
295    }
296    // It's qualified, but we need to check it's not CORE::
297    !is_core_qualified(s, op_start)
298}
299
300/// Check if the match is an escape sequence (preceded by backslash)
301fn is_escape_sequence(s: &str, match_start: usize) -> bool {
302    if match_start == 0 {
303        return false;
304    }
305    s.as_bytes()[match_start - 1] == b'\\'
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_safe_expressions() {
314        let evaluator = SafeEvaluator::new();
315
316        // Simple arithmetic
317        assert!(evaluator.validate("$x + $y").is_ok());
318        assert!(evaluator.validate("$hash{key}").is_ok());
319        assert!(evaluator.validate("$array[0]").is_ok());
320        assert!(evaluator.validate("length($str)").is_ok());
321
322        // Package-qualified (not CORE)
323        assert!(evaluator.validate("Foo::print").is_ok());
324        assert!(evaluator.validate("My::Module::system").is_ok());
325    }
326
327    #[test]
328    fn test_dangerous_operations() {
329        let evaluator = SafeEvaluator::new();
330
331        // Code execution
332        assert!(evaluator.validate("eval('code')").is_err());
333        assert!(evaluator.validate("system('ls')").is_err());
334        assert!(evaluator.validate("exec('/bin/sh')").is_err());
335
336        // I/O
337        assert!(evaluator.validate("print 'hello'").is_err());
338        assert!(evaluator.validate("open(FH, '<', 'file')").is_err());
339    }
340
341    #[test]
342    fn test_sigil_prefixed_identifiers() {
343        let evaluator = SafeEvaluator::new();
344
345        // These should be allowed (they're variable names, not operations)
346        assert!(evaluator.validate("$print").is_ok());
347        assert!(evaluator.validate("@say").is_ok());
348        assert!(evaluator.validate("%exit").is_ok());
349        assert!(evaluator.validate("$system_name").is_ok());
350    }
351
352    #[test]
353    fn test_braced_variables() {
354        let evaluator = SafeEvaluator::new();
355
356        // ${print} is a variable, should be allowed
357        assert!(evaluator.validate("${print}").is_ok());
358    }
359
360    #[test]
361    fn test_assignment_operators() {
362        let evaluator = SafeEvaluator::new();
363
364        assert!(evaluator.validate("$x = 1").is_err());
365        assert!(evaluator.validate("$x += 1").is_err());
366        assert!(evaluator.validate("$x .= 'str'").is_err());
367    }
368
369    #[test]
370    fn test_increment_decrement() {
371        let evaluator = SafeEvaluator::new();
372
373        assert!(evaluator.validate("$x++").is_err());
374        assert!(evaluator.validate("++$x").is_err());
375        assert!(evaluator.validate("$x--").is_err());
376    }
377
378    #[test]
379    fn test_backticks() {
380        let evaluator = SafeEvaluator::new();
381
382        assert!(evaluator.validate("`ls -la`").is_err());
383    }
384
385    #[test]
386    fn test_newlines() {
387        let evaluator = SafeEvaluator::new();
388
389        assert!(evaluator.validate("1\nprint 'hacked'").is_err());
390        assert!(evaluator.validate("1\rprint 'hacked'").is_err());
391    }
392
393    #[test]
394    fn test_regex_mutation() {
395        let evaluator = SafeEvaluator::new();
396
397        assert!(evaluator.validate("s/foo/bar/").is_err());
398        assert!(evaluator.validate("tr/a-z/A-Z/").is_err());
399        assert!(evaluator.validate("y/abc/xyz/").is_err());
400    }
401
402    #[test]
403    fn test_regex_mutation_detected_after_allowed_identifier() {
404        let evaluator = SafeEvaluator::new();
405
406        assert!(evaluator.validate("$s + s/foo/bar/").is_err());
407        assert!(evaluator.validate("$tr + tr/a-z/A-Z/").is_err());
408    }
409
410    #[test]
411    fn test_regex_mutation_detected_after_allowed_escape_sequence() {
412        let evaluator = SafeEvaluator::new();
413
414        assert!(evaluator.validate("/\\s+/ && s/foo/bar/").is_err());
415    }
416
417    #[test]
418    fn test_escape_sequences_allowed() {
419        let evaluator = SafeEvaluator::new();
420
421        // \s in a regex match pattern should be allowed (it's not s///)
422        // However, our simple regex catches it - this is a known limitation
423        // The validator allows escape sequences like \s
424        assert!(evaluator.validate("/\\s+/").is_ok());
425    }
426
427    #[test]
428    fn test_comparison_operators_not_misclassified_as_assignments() {
429        let evaluator = SafeEvaluator::new();
430
431        assert!(evaluator.validate("$x == 1").is_ok());
432        assert!(evaluator.validate("$x != 1").is_ok());
433        assert!(evaluator.validate("$x <= 1").is_ok());
434        assert!(evaluator.validate("$x >= 1").is_ok());
435    }
436
437    #[test]
438    fn test_core_qualified_dangerous_operations_are_blocked() {
439        let evaluator = SafeEvaluator::new();
440
441        assert!(evaluator.validate("CORE::print 'hello'").is_err());
442        assert!(evaluator.validate("CORE::GLOBAL::system('ls')").is_err());
443    }
444
445    #[test]
446    fn test_code_dereference_and_method_call_with_sigils_are_blocked() {
447        let evaluator = SafeEvaluator::new();
448
449        assert!(evaluator.validate("&$system").is_err());
450        assert!(evaluator.validate("$obj->$print").is_err());
451        assert!(evaluator.validate("&{ $exec }").is_err());
452    }
453
454    #[test]
455    fn test_single_quoted_strings() {
456        let evaluator = SafeEvaluator::new();
457
458        // Ops inside single quotes should be allowed (they're literal strings)
459        assert!(evaluator.validate("'print this'").is_ok());
460        assert!(evaluator.validate("'system call'").is_ok());
461    }
462}