perl_dap/eval/
validator.rs1use super::patterns::{
8 ASSIGNMENT_OP_TOKENS_RE, ASSIGNMENT_OPERATORS, DANGEROUS_OPS_RE, REGEX_MUTATION_RE,
9};
10
11#[derive(Debug, Clone, thiserror::Error)]
13pub enum ValidationError {
14 #[error(
16 "Safe evaluation mode: potentially mutating operation '{0}' not allowed (use allowSideEffects: true)"
17 )]
18 DangerousOperation(String),
19
20 #[error(
22 "Safe evaluation mode: assignment operator '{0}' not allowed (use allowSideEffects: true)"
23 )]
24 AssignmentOperator(String),
25
26 #[error(
28 "Safe evaluation mode: increment/decrement operators not allowed (use allowSideEffects: true)"
29 )]
30 IncrementDecrement,
31
32 #[error(
34 "Safe evaluation mode: backticks (shell execution) not allowed (use allowSideEffects: true)"
35 )]
36 Backticks,
37
38 #[error(
40 "Safe evaluation mode: regex mutation operator '{0}' not allowed (use allowSideEffects: true)"
41 )]
42 RegexMutation(String),
43
44 #[error("Expression cannot contain newlines")]
46 ContainsNewlines,
47}
48
49pub type ValidationResult = Result<(), ValidationError>;
51
52#[derive(Debug, Clone, Default)]
58pub struct SafeEvaluator {
59 }
61
62impl SafeEvaluator {
63 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn validate(&self, expression: &str) -> ValidationResult {
78 if expression.contains('\n') || expression.contains('\r') {
80 return Err(ValidationError::ContainsNewlines);
81 }
82
83 if expression.contains('`') {
85 return Err(ValidationError::Backticks);
86 }
87
88 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 if expression.contains("++") || expression.contains("--") {
100 return Err(ValidationError::IncrementDecrement);
101 }
102
103 self.check_dangerous_operations(expression)?;
105
106 self.check_regex_mutation(expression)?;
108
109 Ok(())
110 }
111
112 fn check_dangerous_operations(&self, expression: &str) -> ValidationResult {
114 let Some(re) = DANGEROUS_OPS_RE.as_ref().ok() else {
115 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 if is_in_single_quotes(expression, start) {
126 continue;
127 }
128
129 if is_sigil_prefixed_identifier(expression, start) {
131 continue;
132 }
133
134 if is_simple_braced_scalar_var(expression, start, end) {
136 continue;
137 }
138
139 if is_package_qualified_not_core(expression, start) {
141 continue;
142 }
143
144 return Err(ValidationError::DangerousOperation(op.to_string()));
146 }
147
148 Ok(())
149 }
150
151 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 if is_sigil_prefixed_identifier(expression, start) {
163 continue;
164 }
165
166 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
178fn 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
203fn is_core_qualified(s: &str, op_start: usize) -> bool {
205 let s_bytes = s.as_bytes();
206 if op_start >= 8 && &s_bytes[op_start - 8..op_start] == b"GLOBAL::" {
208 return op_start >= 14 && &s_bytes[op_start - 14..op_start - 8] == b"CORE::";
210 }
211
212 op_start >= 6 && &s_bytes[op_start - 6..op_start] == b"CORE::"
214}
215
216fn 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 if !matches!(bytes[op_start - 1], b'$' | b'@' | b'%' | b'*') {
225 return false;
226 }
227
228 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 if prev == b'&' {
239 return false;
240 }
241
242 if prev == b'>' && i > 1 && bytes[i - 2] == b'-' {
244 return false;
245 }
246
247 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
262fn is_simple_braced_scalar_var(s: &str, op_start: usize, op_end: usize) -> bool {
264 let bytes = s.as_bytes();
265
266 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 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
290fn 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 !is_core_qualified(s, op_start)
298}
299
300fn 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 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 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 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 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 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 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 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 assert!(evaluator.validate("'print this'").is_ok());
460 assert!(evaluator.validate("'system call'").is_ok());
461 }
462}