use super::*;
pub(super) 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
}
pub(super) fn is_core_qualified(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;
}
let end = op_start - 2;
let mut start = end;
while start > 0 {
let b = bytes[start - 1];
if b.is_ascii_alphanumeric() || b == b'_' {
start -= 1;
} else {
break;
}
}
let seg = &s[start..end];
if seg == "CORE" {
return true;
}
if seg != "GLOBAL" {
return false;
}
if start < 2 || bytes[start - 1] != b':' || bytes[start - 2] != b':' {
return false;
}
let end2 = start - 2;
let mut start2 = end2;
while start2 > 0 {
let b = bytes[start2 - 1];
if b.is_ascii_alphanumeric() || b == b'_' {
start2 -= 1;
} else {
break;
}
}
&s[start2..end2] == "CORE"
}
pub(super) 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
}
pub(super) 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'}'
}
pub(super) 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)
}
pub(super) fn validate_safe_expression(expression: &str) -> Option<String> {
if let Some(re) = assignment_ops_re() {
for mat in re.find_iter(expression) {
let op = mat.as_str();
let start = mat.start();
if is_in_single_quotes(expression, start) {
continue;
}
match op {
"=" | "+=" | "-=" | "*=" | "/=" | "%=" | "**=" | ".=" | "&=" | "|=" | "^="
| "<<=" | ">>=" | "&&=" | "||=" | "//=" | "x=" => {
return Some(format!(
"Safe evaluation mode: assignment operator '{}' not allowed (use allowSideEffects: true)",
op
));
}
_ => {}
}
}
}
if let Some(re) = deref_re() {
if re.is_match(expression) {
return Some(
"Safe evaluation mode: dynamic subroutine calls (&{...}) not allowed (use allowSideEffects: true)"
.to_string(),
);
}
}
if let Some(re) = glob_re() {
if re.is_match(expression) {
return Some(
"Safe evaluation mode: glob operations (<*...>) not allowed (use allowSideEffects: true)"
.to_string(),
);
}
}
if expression.trim().starts_with('<') {
return Some(
"Safe evaluation mode: file handle reads (<...>) and globs not allowed (use allowSideEffects: true)"
.to_string(),
);
}
if let Some(re) = dangerous_ops_re() {
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 Some(format!(
"Safe evaluation mode: potentially mutating operation '{}' not allowed (use allowSideEffects: true)",
op
));
}
}
if let Some(re) = regex_mutation_re() {
if let Some(mat) = re.find(expression) {
let op = mat.as_str();
let start = mat.start();
if is_sigil_prefixed_identifier(expression, start) {
} else if is_escape_sequence(expression, start) {
} else {
return Some(format!(
"Safe evaluation mode: regex mutation operator '{}' not allowed (use allowSideEffects: true)",
op.trim()
));
}
}
}
if expression.contains("++") || expression.contains("--") {
return Some(
"Safe evaluation mode: increment/decrement operators not allowed (use allowSideEffects: true)"
.to_string(),
);
}
if expression.contains('`') {
return Some(
"Safe evaluation mode: backticks (shell execution) not allowed (use allowSideEffects: true)"
.to_string(),
);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_eval_allows_identifiers_named_like_ops() {
let allowed = [
"$print", "@say", "%exit", "*printf", "${print}", "${ print }", "'print'", "Foo::print", "My::Module::exit", ];
for expr in allowed {
let err = validate_safe_expression(expr);
assert!(err.is_none(), "unexpected block for {expr:?}: {err:?}");
}
}
#[test]
fn safe_eval_still_blocks_real_ops() {
let blocked = [
"print",
"print $x",
"say 'hello'",
"exit",
"exit 0",
"eval '$x'",
"eval { }",
"system 'ls'",
"exec '/bin/sh'",
"fork",
"kill 9, $$",
"CORE::print $x",
"CORE::GLOBAL::exit",
"$obj->print",
"$obj->system('ls')",
];
for expr in blocked {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "expected block for {expr:?}");
}
}
#[test]
fn test_safe_eval_mutating_regex_ops() {
let blocked = [
"$x =~ s/a/b/",
"s/a/b/",
"$x =~ tr/a/b/",
"tr/a/b/",
"y/a/b/",
"$x =~ y/a/b/", ];
for expr in blocked {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "expected block for {expr:?}");
}
}
#[test]
fn test_safe_eval_allows_regex_literals_with_escape_sequences() {
let allowed = [
r#"/\s+/"#, r#"/string/"#, r#"/tricky/"#, r#"/yay/"#, r#"$s"#, r#"$tr"#, r#"$y"#, r#"qr/\s+/"#, ];
for expr in allowed {
let err = validate_safe_expression(expr);
assert!(err.is_none(), "unexpected block for {expr:?}: {err:?}");
}
}
#[test]
fn safe_eval_blocks_new_dangerous_ops() {
let blocked = [
"eval '$code'",
"kill 9, $pid",
"exit 1",
"dump",
"fork",
"chroot '/tmp'",
"print STDERR 'x'",
"say 'hello'",
"printf '%s', $x",
];
for expr in blocked {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "expected block for {expr:?}");
}
}
#[test]
fn safe_eval_blocks_extended_ops_v2() {
let blocked = [
"glob '*'",
"readline $fh",
"ioctl $fh, 1, 1",
"srand",
"dbmopen %h, 'file', 0666",
"shmget $key, 10, 0666",
"select $r, $w, $e, 0",
"shutdown $socket, 2",
];
for expr in blocked {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "expected block for {expr:?}");
}
}
#[test]
fn safe_eval_blocks_mutation_and_resource_ops() {
let blocked = [
"bless $ref, 'Class'",
"reset 'a-z'",
"umask 0022",
"binmode $fh",
"opendir $dh, '.'",
"closedir $dh",
"seek $fh, 0, 0",
"sysseek $fh, 0, 0",
"setpgrp",
"setpriority 0, 0, 10",
"formline",
"write",
"lock $ref",
"pipe $r, $w",
"socketpair $r, $w, 1, 1, 1",
"setsockopt $s, 1, 1, 1",
"utime 1, 1, 'file'",
"readdir $dh",
];
for expr in blocked {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "expected block for {expr:?}");
}
}
#[test]
fn test_safe_eval_blocks_dereference_execution() {
let allowed = ["$system", "@exec", "%fork"];
for expr in allowed {
let err = validate_safe_expression(expr);
assert!(err.is_none(), "unexpected block for {expr:?}: {err:?}");
}
let blocked = [
"&$system",
"& $system",
"&{$system}", "$obj->$system",
"$obj-> $system",
];
for expr in blocked {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "expected block for {expr:?}");
}
}
#[test]
fn test_safe_eval_bypass_prevention() {
let bypasses = [
"&{'sys'.'tem'}('ls')", "& { 'sys' . 'tem' }", "<*.txt>", "CORE::print", ];
for expr in bypasses {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "Expression '{}' should be blocked but was allowed", expr);
}
}
#[test]
fn test_safe_eval_assignment_ops_precision() {
let allowed = [
"$a == $b",
"$a != $b",
"$a <= $b",
"$a >= $b",
"$a <=> $b",
"$a =~ /regex/",
"$a !~ /regex/",
"$a ~~ $b", "$a && $b",
"$a || $b",
"$a // $b",
"$a & $b",
"$a | $b",
"$a ^ $b",
"$a << $b",
"$a >> $b",
"1..10",
];
for expr in allowed {
let err = validate_safe_expression(expr);
assert!(err.is_none(), "unexpected block for {expr:?}: {err:?}");
}
let blocked = [
"$a = 1",
"$a += 1",
"$a -= 1",
"$a *= 1",
"$a /= 1",
"$a %= 1",
"$a **= 1",
"$a .= 's'",
"$a &= 1",
"$a |= 1",
"$a ^= 1",
"$a <<= 1",
"$a >>= 1",
"$a &&= 1",
"$a ||= 1",
"$a //= 1",
"$a x= 3", ];
for expr in blocked {
let err = validate_safe_expression(expr);
assert!(err.is_some(), "expected block for {expr:?}");
}
}
}