use super::patterns::{
ASSIGNMENT_OP_TOKENS_RE, ASSIGNMENT_OPERATORS, DANGEROUS_OPS_RE, REGEX_MUTATION_RE,
};
#[derive(Debug, Clone, thiserror::Error)]
pub enum ValidationError {
#[error(
"Safe evaluation mode: potentially mutating operation '{0}' not allowed (use allowSideEffects: true)"
)]
DangerousOperation(String),
#[error(
"Safe evaluation mode: assignment operator '{0}' not allowed (use allowSideEffects: true)"
)]
AssignmentOperator(String),
#[error(
"Safe evaluation mode: increment/decrement operators not allowed (use allowSideEffects: true)"
)]
IncrementDecrement,
#[error(
"Safe evaluation mode: backticks (shell execution) not allowed (use allowSideEffects: true)"
)]
Backticks,
#[error(
"Safe evaluation mode: regex mutation operator '{0}' not allowed (use allowSideEffects: true)"
)]
RegexMutation(String),
#[error("Expression cannot contain newlines")]
ContainsNewlines,
}
pub type ValidationResult = Result<(), ValidationError>;
#[derive(Debug, Clone, Default)]
pub struct SafeEvaluator {
}
impl SafeEvaluator {
pub fn new() -> Self {
Self::default()
}
pub fn validate(&self, expression: &str) -> ValidationResult {
if expression.contains('\n') || expression.contains('\r') {
return Err(ValidationError::ContainsNewlines);
}
if expression.contains('`') {
return Err(ValidationError::Backticks);
}
if let Ok(re) = ASSIGNMENT_OP_TOKENS_RE.as_ref() {
for mat in re.find_iter(expression) {
let op = mat.as_str();
if ASSIGNMENT_OPERATORS.contains(&op) {
return Err(ValidationError::AssignmentOperator(op.to_string()));
}
}
}
if expression.contains("++") || expression.contains("--") {
return Err(ValidationError::IncrementDecrement);
}
self.check_dangerous_operations(expression)?;
self.check_regex_mutation(expression)?;
Ok(())
}
fn check_dangerous_operations(&self, expression: &str) -> ValidationResult {
let Some(re) = DANGEROUS_OPS_RE.as_ref().ok() else {
return Ok(());
};
for mat in re.find_iter(expression) {
let op = mat.as_str();
let start = mat.start();
let end = mat.end();
if is_in_single_quotes(expression, start) {
continue;
}
if is_sigil_prefixed_identifier(expression, start) {
continue;
}
if is_simple_braced_scalar_var(expression, start, end) {
continue;
}
if is_package_qualified_not_core(expression, start) {
continue;
}
return Err(ValidationError::DangerousOperation(op.to_string()));
}
Ok(())
}
fn check_regex_mutation(&self, expression: &str) -> ValidationResult {
let Some(re) = REGEX_MUTATION_RE.as_ref().ok() else {
return Ok(());
};
for mat in re.find_iter(expression) {
let op = mat.as_str();
let start = mat.start();
if is_sigil_prefixed_identifier(expression, start) {
continue;
}
if is_escape_sequence(expression, start) {
continue;
}
return Err(ValidationError::RegexMutation(op.trim().to_string()));
}
Ok(())
}
}
fn is_in_single_quotes(s: &str, idx: usize) -> bool {
let mut in_sq = false;
let mut escaped = false;
for (i, ch) in s.char_indices() {
if i >= idx {
break;
}
if in_sq {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '\'' {
in_sq = false;
}
} else if ch == '\'' {
in_sq = true;
}
}
in_sq
}
fn is_core_qualified(s: &str, op_start: usize) -> bool {
let s_bytes = s.as_bytes();
if op_start >= 8 && &s_bytes[op_start - 8..op_start] == b"GLOBAL::" {
return op_start >= 14 && &s_bytes[op_start - 14..op_start - 8] == b"CORE::";
}
op_start >= 6 && &s_bytes[op_start - 6..op_start] == b"CORE::"
}
fn is_sigil_prefixed_identifier(s: &str, op_start: usize) -> bool {
let bytes = s.as_bytes();
if op_start == 0 {
return false;
}
if !matches!(bytes[op_start - 1], b'$' | b'@' | b'%' | b'*') {
return false;
}
let mut i = op_start - 1;
while i > 0 && bytes[i - 1].is_ascii_whitespace() {
i -= 1;
}
if i > 0 {
let prev = bytes[i - 1];
if prev == b'&' {
return false;
}
if prev == b'>' && i > 1 && bytes[i - 2] == b'-' {
return false;
}
if prev == b'{' {
i -= 1;
while i > 0 && bytes[i - 1].is_ascii_whitespace() {
i -= 1;
}
if i > 0 && bytes[i - 1] == b'&' {
return false;
}
}
}
true
}
fn is_simple_braced_scalar_var(s: &str, op_start: usize, op_end: usize) -> bool {
let bytes = s.as_bytes();
let mut i = op_start;
while i > 0 && bytes[i - 1].is_ascii_whitespace() {
i -= 1;
}
if i < 1 || bytes[i - 1] != b'{' {
return false;
}
i -= 1;
while i > 0 && bytes[i - 1].is_ascii_whitespace() {
i -= 1;
}
if i < 1 || bytes[i - 1] != b'$' {
return false;
}
let mut j = op_end;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
j < bytes.len() && bytes[j] == b'}'
}
fn is_package_qualified_not_core(s: &str, op_start: usize) -> bool {
let bytes = s.as_bytes();
if op_start < 2 || bytes[op_start - 1] != b':' || bytes[op_start - 2] != b':' {
return false;
}
!is_core_qualified(s, op_start)
}
fn is_escape_sequence(s: &str, match_start: usize) -> bool {
if match_start == 0 {
return false;
}
s.as_bytes()[match_start - 1] == b'\\'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_expressions() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("$x + $y").is_ok());
assert!(evaluator.validate("$hash{key}").is_ok());
assert!(evaluator.validate("$array[0]").is_ok());
assert!(evaluator.validate("length($str)").is_ok());
assert!(evaluator.validate("Foo::print").is_ok());
assert!(evaluator.validate("My::Module::system").is_ok());
}
#[test]
fn test_dangerous_operations() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("eval('code')").is_err());
assert!(evaluator.validate("system('ls')").is_err());
assert!(evaluator.validate("exec('/bin/sh')").is_err());
assert!(evaluator.validate("print 'hello'").is_err());
assert!(evaluator.validate("open(FH, '<', 'file')").is_err());
}
#[test]
fn test_sigil_prefixed_identifiers() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("$print").is_ok());
assert!(evaluator.validate("@say").is_ok());
assert!(evaluator.validate("%exit").is_ok());
assert!(evaluator.validate("$system_name").is_ok());
}
#[test]
fn test_braced_variables() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("${print}").is_ok());
}
#[test]
fn test_assignment_operators() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("$x = 1").is_err());
assert!(evaluator.validate("$x += 1").is_err());
assert!(evaluator.validate("$x .= 'str'").is_err());
}
#[test]
fn test_increment_decrement() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("$x++").is_err());
assert!(evaluator.validate("++$x").is_err());
assert!(evaluator.validate("$x--").is_err());
}
#[test]
fn test_backticks() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("`ls -la`").is_err());
}
#[test]
fn test_newlines() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("1\nprint 'hacked'").is_err());
assert!(evaluator.validate("1\rprint 'hacked'").is_err());
}
#[test]
fn test_regex_mutation() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("s/foo/bar/").is_err());
assert!(evaluator.validate("tr/a-z/A-Z/").is_err());
assert!(evaluator.validate("y/abc/xyz/").is_err());
}
#[test]
fn test_regex_mutation_detected_after_allowed_identifier() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("$s + s/foo/bar/").is_err());
assert!(evaluator.validate("$tr + tr/a-z/A-Z/").is_err());
}
#[test]
fn test_regex_mutation_detected_after_allowed_escape_sequence() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("/\\s+/ && s/foo/bar/").is_err());
}
#[test]
fn test_escape_sequences_allowed() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("/\\s+/").is_ok());
}
#[test]
fn test_comparison_operators_not_misclassified_as_assignments() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("$x == 1").is_ok());
assert!(evaluator.validate("$x != 1").is_ok());
assert!(evaluator.validate("$x <= 1").is_ok());
assert!(evaluator.validate("$x >= 1").is_ok());
}
#[test]
fn test_core_qualified_dangerous_operations_are_blocked() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("CORE::print 'hello'").is_err());
assert!(evaluator.validate("CORE::GLOBAL::system('ls')").is_err());
}
#[test]
fn test_code_dereference_and_method_call_with_sigils_are_blocked() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("&$system").is_err());
assert!(evaluator.validate("$obj->$print").is_err());
assert!(evaluator.validate("&{ $exec }").is_err());
}
#[test]
fn test_single_quoted_strings() {
let evaluator = SafeEvaluator::new();
assert!(evaluator.validate("'print this'").is_ok());
assert!(evaluator.validate("'system call'").is_ok());
}
}