mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
Problem: expression text is input we don't control, and nothing fuzzed the expression parser and evaluator. A seeded mutation fuzzer found stack overflows (a few hundred `?:`/`=>`/`if`, a few thousand `.`/`<`/`()` links, 75 nested template substitutions or regex groups, a chain inside one template), hangs (an expression reading its own layer's transform nested 2^16 times; fanning-out reads; 10M-iteration loops re-run per read; loops calling looping functions), an i64 overflow panic in wiggle noise, and NaN path vertices tripping the rasterizer's clipping assertion. Fix: a dependency-free seeded fuzzer (crates/expr/src/tests/fuzz.rs: real AE-style seeds, char/token mutations, worker thread with a 2 MiB stack, catch_unwind, deadline, env vars for long runs); a weighted nesting estimate covering chains without brackets and template/regex insides; cycle detection per (property, time, text); a per-evaluation budget of 20 000 script runs and 5 s (runs are interrupted through boa's budgeted evaluation; native only); loop limit 1M; wrapping noise lattice indices; non-finite path results rejected. Each fix has a regression test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
908 lines
32 KiB
Rust
908 lines
32 KiB
Rust
//! Source-to-source rewrite that gives JavaScript After Effects' array maths.
|
||
//!
|
||
//! AE's legacy expression engine lets `+ - * /` operate on arrays (`value + [10, 0]`,
|
||
//! `[1, 2] * 2`). We get the same by rewriting every binary `+ - * /` into a helper call
|
||
//! (`__add(a, b)`, `__sub`, `__mul`, `__div`) and unary minus into `__neg(a)`; the helpers fall
|
||
//! back to the native operator for numbers and strings, so string concatenation is unchanged.
|
||
//! Compound assignments (`x += [1, 0]`) become `x = __add(x, (…))`.
|
||
//!
|
||
//! The rewriter is a small tokenizer plus a precedence-aware operand/chain parser over bracket
|
||
//! groups. It is deliberately lenient: anything it does not understand is copied verbatim, and
|
||
//! every newline is preserved so the engine's error line numbers match the user's text.
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum Kind {
|
||
Ident,
|
||
Num,
|
||
Str,
|
||
Template,
|
||
Regex,
|
||
Punct,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct Tok {
|
||
kind: Kind,
|
||
text: String,
|
||
/// Whitespace (comments become spaces/newlines) before the token.
|
||
ws: String,
|
||
/// A line break precedes the token.
|
||
nl: bool,
|
||
}
|
||
|
||
impl Tok {
|
||
fn is(&self, p: &str) -> bool {
|
||
self.kind == Kind::Punct && self.text == p
|
||
}
|
||
fn is_kw(&self, k: &[&str]) -> bool {
|
||
self.kind == Kind::Ident && k.contains(&self.text.as_str())
|
||
}
|
||
}
|
||
|
||
enum Item {
|
||
Tok(Tok),
|
||
Group { open: Tok, inner: Vec<Item>, close: Option<Tok> },
|
||
}
|
||
|
||
impl Item {
|
||
fn tok(&self) -> Option<&Tok> {
|
||
if let Item::Tok(t) = self { Some(t) } else { None }
|
||
}
|
||
fn is(&self, p: &str) -> bool {
|
||
self.tok().is_some_and(|t| t.is(p))
|
||
}
|
||
fn group(&self) -> Option<&str> {
|
||
if let Item::Group { open, .. } = self { Some(open.text.as_str()) } else { None }
|
||
}
|
||
fn ws(&self) -> &str {
|
||
match self {
|
||
Item::Tok(t) => &t.ws,
|
||
Item::Group { open, .. } => &open.ws,
|
||
}
|
||
}
|
||
fn nl(&self) -> bool {
|
||
match self {
|
||
Item::Tok(t) => t.nl,
|
||
Item::Group { open, .. } => open.nl,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Keywords that end an operand chain (statement or lower-precedence syntax).
|
||
const DELIM_KW: &[&str] = &[
|
||
"break",
|
||
"case",
|
||
"catch",
|
||
"class",
|
||
"const",
|
||
"continue",
|
||
"debugger",
|
||
"default",
|
||
"do",
|
||
"else",
|
||
"export",
|
||
"extends",
|
||
"finally",
|
||
"for",
|
||
"if",
|
||
"import",
|
||
"in",
|
||
"instanceof",
|
||
"let",
|
||
"return",
|
||
"switch",
|
||
"throw",
|
||
"try",
|
||
"var",
|
||
"while",
|
||
"with",
|
||
"yield",
|
||
"of",
|
||
];
|
||
/// Prefix operators spelled as keywords.
|
||
const PREFIX_KW: &[&str] = &["typeof", "void", "delete", "await", "new"];
|
||
/// Keywords followed by a parenthesised header that is not an operand.
|
||
const HEADER_KW: &[&str] = &["if", "while", "for", "switch", "catch", "with"];
|
||
|
||
const PUNCT: &[&str] = &[
|
||
">>>=", "...", "===", "!==", "**=", "<<=", ">>=", ">>>", "&&=", "||=", "??=", "=>", "==", "!=", "<=", ">=", "&&", "||", "??", "?.", "++", "--", "+=", "-=",
|
||
"*=", "/=", "%=", "&=", "|=", "^=", "**", "<<", ">>",
|
||
];
|
||
|
||
fn is_ident_start(c: char) -> bool {
|
||
c.is_alphabetic() || c == '_' || c == '$' || (!c.is_ascii() && !c.is_whitespace())
|
||
}
|
||
fn is_ident_part(c: char) -> bool {
|
||
is_ident_start(c) || c.is_ascii_digit()
|
||
}
|
||
|
||
/// Can a `/` after `prev` start a regular expression literal?
|
||
fn regex_allowed(prev: Option<&Tok>) -> bool {
|
||
match prev {
|
||
None => true,
|
||
Some(t) => match t.kind {
|
||
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => false,
|
||
Kind::Ident => t.is_kw(DELIM_KW) || t.is_kw(PREFIX_KW),
|
||
Kind::Punct => !matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn skip_string(c: &[char], mut i: usize) -> usize {
|
||
let q = c[i];
|
||
i += 1;
|
||
while i < c.len() && c[i] != q && c[i] != '\n' {
|
||
if c[i] == '\\' {
|
||
i += 1;
|
||
}
|
||
i += 1;
|
||
}
|
||
(i + 1).min(c.len())
|
||
}
|
||
|
||
fn skip_template(c: &[char], mut i: usize) -> usize {
|
||
i += 1;
|
||
let mut depth = 0usize;
|
||
while i < c.len() {
|
||
match c[i] {
|
||
'\\' => i += 1,
|
||
'`' if depth == 0 => return i + 1,
|
||
'$' if c.get(i + 1) == Some(&'{') => {
|
||
depth += 1;
|
||
i += 1;
|
||
}
|
||
'{' if depth > 0 => depth += 1,
|
||
'}' if depth > 0 => depth -= 1,
|
||
'\'' | '"' if depth > 0 => {
|
||
i = skip_string(c, i);
|
||
continue;
|
||
}
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
c.len()
|
||
}
|
||
|
||
fn skip_number(c: &[char], mut i: usize) -> usize {
|
||
if c[i] == '0' && c.get(i + 1).is_some_and(|x| matches!(x, 'x' | 'X' | 'b' | 'B' | 'o' | 'O')) {
|
||
i += 2;
|
||
while i < c.len() && (c[i].is_ascii_alphanumeric() || c[i] == '_') {
|
||
i += 1;
|
||
}
|
||
return i;
|
||
}
|
||
let mut seen_dot = false;
|
||
while i < c.len() {
|
||
let x = c[i];
|
||
if x.is_ascii_digit() || x == '_' {
|
||
i += 1;
|
||
} else if x == '.' && !seen_dot {
|
||
seen_dot = true;
|
||
i += 1;
|
||
} else if (x == 'e' || x == 'E') && c.get(i + 1).is_some_and(|y| y.is_ascii_digit() || *y == '+' || *y == '-') {
|
||
i += 2;
|
||
while i < c.len() && c[i].is_ascii_digit() {
|
||
i += 1;
|
||
}
|
||
break;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
if c.get(i) == Some(&'n') {
|
||
i += 1;
|
||
}
|
||
i
|
||
}
|
||
|
||
fn skip_regex(c: &[char], mut i: usize) -> usize {
|
||
i += 1;
|
||
let mut class = false;
|
||
while i < c.len() && c[i] != '\n' {
|
||
match c[i] {
|
||
'\\' => i += 1,
|
||
'[' => class = true,
|
||
']' => class = false,
|
||
'/' if !class => break,
|
||
_ => {}
|
||
}
|
||
i += 1;
|
||
}
|
||
i += 1;
|
||
while i < c.len() && is_ident_part(c[i]) {
|
||
i += 1;
|
||
}
|
||
i.min(c.len())
|
||
}
|
||
|
||
fn punct_len(c: &[char], i: usize) -> usize {
|
||
for p in PUNCT {
|
||
let n = p.chars().count();
|
||
if i + n <= c.len() && c[i..i + n].iter().copied().eq(p.chars()) {
|
||
// `a?.5:b` is a conditional, not optional chaining.
|
||
if *p == "?." && c.get(i + 2).is_some_and(|d| d.is_ascii_digit()) {
|
||
return 1;
|
||
}
|
||
return n;
|
||
}
|
||
}
|
||
1
|
||
}
|
||
|
||
fn tokenize(src: &str) -> (Vec<Tok>, String) {
|
||
let c: Vec<char> = src.chars().collect();
|
||
let mut out: Vec<Tok> = Vec::new();
|
||
let mut ws = String::new();
|
||
let mut i = 0;
|
||
while i < c.len() {
|
||
let ch = c[i];
|
||
if ch.is_whitespace() {
|
||
ws.push(ch);
|
||
i += 1;
|
||
continue;
|
||
}
|
||
if ch == '/' && c.get(i + 1) == Some(&'/') {
|
||
while i < c.len() && c[i] != '\n' {
|
||
i += 1;
|
||
}
|
||
ws.push(' ');
|
||
continue;
|
||
}
|
||
if ch == '/' && c.get(i + 1) == Some(&'*') {
|
||
i += 2;
|
||
while i < c.len() && !(c[i] == '*' && c.get(i + 1) == Some(&'/')) {
|
||
if c[i] == '\n' {
|
||
ws.push('\n');
|
||
}
|
||
i += 1;
|
||
}
|
||
i = (i + 2).min(c.len());
|
||
ws.push(' ');
|
||
continue;
|
||
}
|
||
let start = i;
|
||
let kind = if ch == '"' || ch == '\'' {
|
||
i = skip_string(&c, i);
|
||
Kind::Str
|
||
} else if ch == '`' {
|
||
i = skip_template(&c, i);
|
||
Kind::Template
|
||
} else if ch.is_ascii_digit() || (ch == '.' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit())) {
|
||
i = skip_number(&c, i);
|
||
Kind::Num
|
||
} else if is_ident_start(ch) {
|
||
while i < c.len() && is_ident_part(c[i]) {
|
||
i += 1;
|
||
}
|
||
Kind::Ident
|
||
} else if ch == '/' && regex_allowed(out.last()) {
|
||
i = skip_regex(&c, i);
|
||
Kind::Regex
|
||
} else {
|
||
i += punct_len(&c, i);
|
||
Kind::Punct
|
||
};
|
||
let nl = ws.contains('\n');
|
||
out.push(Tok { kind, text: c[start..i].iter().collect(), ws: std::mem::take(&mut ws), nl });
|
||
}
|
||
(out, ws)
|
||
}
|
||
|
||
fn closer(open: &str) -> &'static str {
|
||
match open {
|
||
"(" => ")",
|
||
"[" => "]",
|
||
_ => "}",
|
||
}
|
||
}
|
||
|
||
/// Bracket tree. Stray closers stay as plain tokens (the engine reports the syntax error).
|
||
fn build(toks: Vec<Tok>) -> Vec<Item> {
|
||
let mut stack: Vec<(Tok, Vec<Item>)> = Vec::new();
|
||
let mut cur: Vec<Item> = Vec::new();
|
||
for t in toks {
|
||
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||
stack.push((t, std::mem::take(&mut cur)));
|
||
} else if t.kind == Kind::Punct
|
||
&& matches!(t.text.as_str(), ")" | "]" | "}")
|
||
&& stack.last().is_some_and(|(o, _)| closer(&o.text) == t.text)
|
||
&& let Some((open, parent)) = stack.pop()
|
||
{
|
||
let inner = std::mem::replace(&mut cur, parent);
|
||
cur.push(Item::Group { open, inner, close: Some(t) });
|
||
} else {
|
||
cur.push(Item::Tok(t));
|
||
}
|
||
}
|
||
while let Some((open, parent)) = stack.pop() {
|
||
let inner = std::mem::replace(&mut cur, parent);
|
||
cur.push(Item::Group { open, inner, close: None });
|
||
}
|
||
cur
|
||
}
|
||
|
||
/// A rewritten operand or expression: leading whitespace kept apart so helper calls start where
|
||
/// the expression did (keeps automatic semicolon insertion unchanged).
|
||
struct Piece {
|
||
ws: String,
|
||
body: String,
|
||
}
|
||
|
||
fn raw(item: &Item) -> Piece {
|
||
match item {
|
||
Item::Tok(t) => Piece { ws: t.ws.clone(), body: t.text.clone() },
|
||
Item::Group { open, inner, close } => {
|
||
let mut body = open.text.clone();
|
||
body.push_str(&rewrite_seq(inner));
|
||
if let Some(c) = close {
|
||
body.push_str(&c.ws);
|
||
body.push_str(&c.text);
|
||
}
|
||
Piece { ws: open.ws.clone(), body }
|
||
}
|
||
}
|
||
}
|
||
|
||
fn starts_operand(items: &[Item], i: usize) -> bool {
|
||
match &items[i] {
|
||
Item::Group { open, .. } => open.text != "{",
|
||
Item::Tok(t) => match t.kind {
|
||
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true,
|
||
Kind::Ident => !t.is_kw(DELIM_KW),
|
||
Kind::Punct => matches!(t.text.as_str(), "-" | "+" | "!" | "~" | "++" | "--"),
|
||
},
|
||
}
|
||
}
|
||
|
||
/// Parse one unary/postfix operand starting at `i`.
|
||
fn operand(items: &[Item], i: usize) -> Option<(Piece, usize)> {
|
||
let first = items.get(i)?;
|
||
if !starts_operand(items, i) {
|
||
return None;
|
||
}
|
||
// Prefix operators.
|
||
if let Some(t) = first.tok()
|
||
&& (t.kind == Kind::Punct || t.is_kw(PREFIX_KW))
|
||
{
|
||
let (inner, j) = operand(items, i + 1)?;
|
||
let body = if t.is("-") {
|
||
if items[i + 1].tok().is_some_and(|n| n.kind == Kind::Num) {
|
||
format!("-{}{}", inner.ws, inner.body)
|
||
} else {
|
||
format!("__neg({}{})", inner.ws, inner.body)
|
||
}
|
||
} else {
|
||
let sep = if t.kind == Kind::Ident && inner.ws.is_empty() { " " } else { "" };
|
||
format!("{}{sep}{}{}", t.text, inner.ws, inner.body)
|
||
};
|
||
return Some((Piece { ws: t.ws.clone(), body }, j));
|
||
}
|
||
let mut p = raw(first);
|
||
let mut j = i + 1;
|
||
// `function name(args) { body }` is one atom.
|
||
if first.tok().is_some_and(|t| t.is_kw(&["function"])) {
|
||
if items.get(j).is_some_and(|x| x.is("*")) {
|
||
push(&mut p, &items[j]);
|
||
j += 1;
|
||
}
|
||
if items.get(j).and_then(Item::tok).is_some_and(|t| t.kind == Kind::Ident) {
|
||
push(&mut p, &items[j]);
|
||
j += 1;
|
||
}
|
||
for g in ["(", "{"] {
|
||
if items.get(j).and_then(Item::group) == Some(g) {
|
||
push(&mut p, &items[j]);
|
||
j += 1;
|
||
}
|
||
}
|
||
}
|
||
// Postfix: member access, calls, indexing, tagged templates, `x++`.
|
||
while let Some(it) = items.get(j) {
|
||
let member = it.is(".") || it.is("?.");
|
||
let call = matches!(it.group(), Some("(" | "[")) || it.tok().is_some_and(|t| t.kind == Kind::Template);
|
||
let incr = (it.is("++") || it.is("--")) && !it.nl();
|
||
if !(member || call || incr) {
|
||
break;
|
||
}
|
||
push(&mut p, it);
|
||
j += 1;
|
||
if member
|
||
&& let Some(n) = items.get(j)
|
||
&& (n.tok().is_some_and(|t| t.kind == Kind::Ident) || matches!(n.group(), Some("(" | "[")))
|
||
{
|
||
push(&mut p, n);
|
||
j += 1;
|
||
}
|
||
}
|
||
// Exponentiation binds tighter than `*` and is right-associative: keep it native.
|
||
if items.get(j).is_some_and(|x| x.is("**"))
|
||
&& let Some((rhs, k)) = operand(items, j + 1)
|
||
{
|
||
p.body.push_str(items[j].ws());
|
||
p.body.push_str("**");
|
||
p.body.push_str(&rhs.ws);
|
||
p.body.push_str(&rhs.body);
|
||
j = k;
|
||
}
|
||
Some((p, j))
|
||
}
|
||
|
||
fn push(p: &mut Piece, item: &Item) {
|
||
let r = raw(item);
|
||
p.body.push_str(&r.ws);
|
||
p.body.push_str(&r.body);
|
||
}
|
||
|
||
fn helper(op: &str) -> Option<&'static str> {
|
||
Some(match op {
|
||
"+" => "__add",
|
||
"-" => "__sub",
|
||
"*" => "__mul",
|
||
"/" => "__div",
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Fold `a op b op c…` with `* / %` binding tighter than `+ -`.
|
||
fn fold(operands: Vec<Piece>, ops: Vec<(String, String)>) -> Piece {
|
||
let mut terms: Vec<Piece> = Vec::new();
|
||
let mut add_ops: Vec<(String, String)> = Vec::new();
|
||
let mut it = operands.into_iter();
|
||
let mut acc = it.next().unwrap_or(Piece { ws: String::new(), body: String::new() });
|
||
for ((op, ws), rhs) in ops.into_iter().zip(it) {
|
||
if op == "+" || op == "-" {
|
||
terms.push(acc);
|
||
add_ops.push((op, ws));
|
||
acc = rhs;
|
||
} else {
|
||
acc = bin(acc, &op, &ws, rhs);
|
||
}
|
||
}
|
||
terms.push(acc);
|
||
let mut it = terms.into_iter();
|
||
let mut acc = it.next().unwrap_or(Piece { ws: String::new(), body: String::new() });
|
||
for ((op, ws), rhs) in add_ops.into_iter().zip(it) {
|
||
acc = bin(acc, &op, &ws, rhs);
|
||
}
|
||
acc
|
||
}
|
||
|
||
fn bin(a: Piece, op: &str, op_ws: &str, b: Piece) -> Piece {
|
||
let body = match helper(op) {
|
||
Some(h) => format!("{h}({}, {op_ws}{}{})", a.body, b.ws, b.body),
|
||
None => format!("({}{op_ws}{op}{}{})", a.body, b.ws, b.body),
|
||
};
|
||
Piece { ws: a.ws, body }
|
||
}
|
||
|
||
fn is_arith(item: &Item) -> bool {
|
||
item.tok().is_some_and(|t| t.kind == Kind::Punct && matches!(t.text.as_str(), "+" | "-" | "*" | "/" | "%"))
|
||
}
|
||
|
||
/// Parse `operand (op operand)*` from `i`.
|
||
fn chain(items: &[Item], i: usize) -> Option<(Piece, usize)> {
|
||
let (first, mut j) = operand(items, i)?;
|
||
let mut operands = vec![first];
|
||
let mut ops = Vec::new();
|
||
while let Some(op) = items.get(j).filter(|x| is_arith(x)).and_then(Item::tok) {
|
||
let Some((rhs, k)) = operand(items, j + 1) else { break };
|
||
ops.push((op.text.clone(), op.ws.clone()));
|
||
operands.push(rhs);
|
||
j = k;
|
||
}
|
||
Some((fold(operands, ops), j))
|
||
}
|
||
|
||
/// End of the right-hand side of an assignment starting at `i`.
|
||
fn assignment_end(items: &[Item], i: usize) -> usize {
|
||
let mut j = i;
|
||
while j < items.len() {
|
||
let it = &items[j];
|
||
if it.is(";") || it.is(",") {
|
||
break;
|
||
}
|
||
if j > i && it.nl() && !items.get(j - 1).is_some_and(|p| p.tok().is_some_and(|t| t.kind == Kind::Punct && !t.is(")") && !t.is("]"))) {
|
||
break;
|
||
}
|
||
j += 1;
|
||
}
|
||
j
|
||
}
|
||
|
||
fn rewrite_seq(items: &[Item]) -> String {
|
||
let mut out = String::new();
|
||
let mut i = 0;
|
||
while i < items.len() {
|
||
let it = &items[i];
|
||
if it.tok().is_some_and(|t| t.is_kw(HEADER_KW)) && items.get(i + 1).and_then(Item::group) == Some("(") {
|
||
push_piece(&mut out, raw(it));
|
||
push_piece(&mut out, raw(&items[i + 1]));
|
||
i += 2;
|
||
continue;
|
||
}
|
||
if let Some((p, j)) = chain(items, i) {
|
||
// Compound assignment: `x += v` → `x = __add(x, (v))` (the target is evaluated twice,
|
||
// which only matters for targets with side effects).
|
||
if let Some(op) = items.get(j).and_then(Item::tok).filter(|t| t.kind == Kind::Punct && matches!(t.text.as_str(), "+=" | "-=" | "*=" | "/=")) {
|
||
let end = assignment_end(items, j + 1);
|
||
let rhs = rewrite_seq(&items[j + 1..end]);
|
||
let h = helper(&op.text[..1]).unwrap_or("__add");
|
||
out.push_str(&p.ws);
|
||
out.push_str(&format!("{} ={}{h}({}, ({rhs}))", p.body, op.ws, p.body));
|
||
i = end;
|
||
continue;
|
||
}
|
||
push_piece(&mut out, p);
|
||
i = j;
|
||
} else {
|
||
push_piece(&mut out, raw(it));
|
||
i += 1;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn push_piece(out: &mut String, p: Piece) {
|
||
out.push_str(&p.ws);
|
||
out.push_str(&p.body);
|
||
}
|
||
|
||
/// The deepest nesting an expression may have, in levels of brackets, before and after the
|
||
/// rewrite (which nests a call per arithmetic operator). The JavaScript parser and compiler
|
||
/// recurse per level and a 2 MiB thread overflows its stack (aborting the app) at about 75
|
||
/// nested brackets, so deeper expressions are rejected with an error instead. Constructs that
|
||
/// nest without brackets count as part of a level ([`too_nested`]).
|
||
pub const MAX_NESTING: usize = 32;
|
||
|
||
/// Stack cost of one level of brackets (also of a template substitution, a regex group and a
|
||
/// prefix operator, which cost the parser about as much or are never chained in real code).
|
||
const LEVEL: usize = 256;
|
||
/// Stack cost of a construct the parser recurses on without a bracket: `?:`, `=>`, assignments,
|
||
/// `**`, `||`, labels, `if`/`else`/`while`/`for`/`with`/`do` without a block (about 1/8 of a
|
||
/// bracket: 256 of them in a row are allowed; boa overflows from about 750).
|
||
const HEAVY: usize = 32;
|
||
/// Stack cost of a link in a left-deep chain (binary operators, member access, calls, indexing,
|
||
/// the comma operator): the tree is built in a loop but compiled recursively (overflows from
|
||
/// about 3 800 links).
|
||
const LIGHT: usize = 6;
|
||
|
||
/// One bracket level of [`too_nested`]'s walk.
|
||
#[derive(Default)]
|
||
struct Level {
|
||
/// `[`: commas separate elements (a loop in the parser), not operands.
|
||
array: bool,
|
||
/// Cost of the chains of the current statement or element.
|
||
chain: usize,
|
||
/// Comma operators so far in the current statement.
|
||
commas: usize,
|
||
/// `else` branches in a row: `if … else if … else if …` nests even across `;`.
|
||
elses: usize,
|
||
/// The next token starts a statement.
|
||
fresh: bool,
|
||
}
|
||
|
||
impl Level {
|
||
fn boundary(&mut self) {
|
||
self.chain = 0;
|
||
self.commas = 0;
|
||
self.fresh = true;
|
||
}
|
||
}
|
||
|
||
const ASSIGN: &[&str] = &["=", "+=", "-=", "*=", "/=", "%=", "**=", "<<=", ">>=", ">>>=", "&=", "|=", "^=", "&&=", "||=", "??="];
|
||
|
||
/// Can `t` end an expression statement (so a line break after it may end the statement)?
|
||
fn ends_value(t: &Tok) -> bool {
|
||
match t.kind {
|
||
Kind::Num | Kind::Str | Kind::Template | Kind::Regex => true,
|
||
Kind::Ident => !t.is_kw(DELIM_KW) && !t.is_kw(PREFIX_KW),
|
||
Kind::Punct => matches!(t.text.as_str(), ")" | "]" | "}" | "++" | "--"),
|
||
}
|
||
}
|
||
|
||
/// Can `t` only start a new statement after a value (automatic semicolon insertion)?
|
||
fn starts_statement(t: &Tok) -> bool {
|
||
match t.kind {
|
||
Kind::Num | Kind::Str => true,
|
||
Kind::Ident => !t.is_kw(&["in", "instanceof", "of"]),
|
||
Kind::Punct => matches!(t.text.as_str(), "{" | "!" | "~" | "++" | "--"),
|
||
Kind::Template | Kind::Regex => false,
|
||
}
|
||
}
|
||
|
||
/// Bracket depth inside a template literal (its `${…}` substitutions) or a regex (its groups).
|
||
fn inner_depth(text: &str) -> usize {
|
||
let (mut d, mut max) = (0usize, 0usize);
|
||
for c in text.chars() {
|
||
match c {
|
||
'(' | '[' | '{' => d += 1,
|
||
')' | ']' | '}' => d = d.saturating_sub(1),
|
||
_ => {}
|
||
}
|
||
max = max.max(d);
|
||
}
|
||
max
|
||
}
|
||
|
||
/// The code of a template literal's `${…}` substitutions (the outermost ones).
|
||
fn substitutions(template: &str) -> Vec<String> {
|
||
let c: Vec<char> = template.chars().collect();
|
||
let mut out = vec![];
|
||
let mut i = 1;
|
||
while i < c.len() {
|
||
match c[i] {
|
||
'\\' => i += 2,
|
||
'$' if c.get(i + 1) == Some(&'{') => {
|
||
let start = i + 2;
|
||
let (mut j, mut depth) = (start, 0usize);
|
||
while j < c.len() {
|
||
match c[j] {
|
||
'{' => depth += 1,
|
||
'}' if depth == 0 => break,
|
||
'}' => depth -= 1,
|
||
'\'' | '"' => {
|
||
j = skip_string(&c, j);
|
||
continue;
|
||
}
|
||
'`' => {
|
||
j = skip_template(&c, j);
|
||
continue;
|
||
}
|
||
_ => {}
|
||
}
|
||
j += 1;
|
||
}
|
||
out.push(c.get(start..j.min(c.len())).unwrap_or_default().iter().collect());
|
||
i = j + 1;
|
||
}
|
||
_ => i += 1,
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Whether `toks` nest deeper than [`MAX_NESTING`] levels: an estimate of the parser's stack
|
||
/// use, summing brackets, runs of prefix operators and, per bracket level, the chains of the
|
||
/// current statement (or array element) weighted by what each link costs the parser.
|
||
fn too_nested(toks: &[Tok]) -> bool {
|
||
too_nested_from(toks, 0)
|
||
}
|
||
|
||
/// [`too_nested`] for code that already sits `base` deep (a template substitution).
|
||
fn too_nested_from(toks: &[Tok], base: usize) -> bool {
|
||
let mut levels = vec![Level::default()];
|
||
let mut prefix = 0usize;
|
||
let mut prev: Option<&Tok> = None;
|
||
for t in toks {
|
||
let p = t.kind == Kind::Punct;
|
||
if let Some(l) = levels.last_mut() {
|
||
if t.nl && prev.is_some_and(ends_value) && starts_statement(t) {
|
||
l.boundary();
|
||
}
|
||
if l.fresh {
|
||
if !t.is_kw(&["else"]) {
|
||
l.elses = 0;
|
||
}
|
||
l.fresh = false;
|
||
}
|
||
}
|
||
let mut inner = 0;
|
||
if p && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||
// A call or index is a link in its chain too (`f()()()…`).
|
||
if t.text != "{"
|
||
&& let Some(l) = levels.last_mut()
|
||
{
|
||
l.chain = l.chain.saturating_add(LIGHT);
|
||
}
|
||
levels.push(Level { array: t.text == "[", ..Level::default() });
|
||
} else if p && matches!(t.text.as_str(), ")" | "]" | "}") {
|
||
if levels.len() > 1 {
|
||
levels.pop();
|
||
}
|
||
} else if let Some(l) = levels.last_mut() {
|
||
if t.is(";") {
|
||
l.boundary();
|
||
} else if t.is(",") {
|
||
l.commas += usize::from(!l.array);
|
||
l.chain = l.commas.saturating_mul(LIGHT);
|
||
} else if t.is_kw(&["else"]) {
|
||
l.elses += 1;
|
||
} else if (p && (matches!(t.text.as_str(), "?" | ":" | "=>" | "**" | "||") || ASSIGN.contains(&t.text.as_str())))
|
||
|| t.is_kw(&["if", "while", "for", "with", "do"])
|
||
{
|
||
l.chain = l.chain.saturating_add(HEAVY);
|
||
} else if p || t.is_kw(&["in", "instanceof"]) {
|
||
l.chain = l.chain.saturating_add(LIGHT);
|
||
} else if matches!(t.kind, Kind::Template | Kind::Regex) {
|
||
// A tagged template is a link like a call.
|
||
l.chain = l.chain.saturating_add(LIGHT);
|
||
inner = inner_depth(&t.text);
|
||
}
|
||
}
|
||
let is_prefix = (p && matches!(t.text.as_str(), "!" | "~" | "-" | "+" | "++" | "--")) || t.is_kw(&["typeof", "void", "delete", "new", "await"]);
|
||
prefix = if is_prefix { prefix + 1 } else { 0 };
|
||
let mut cost = (levels.len() - 1 + prefix + inner).saturating_mul(LEVEL).saturating_add(base);
|
||
for l in &levels {
|
||
cost = cost.saturating_add(l.chain).saturating_add(l.elses.saturating_mul(HEAVY));
|
||
}
|
||
if cost > MAX_NESTING * LEVEL {
|
||
return true;
|
||
}
|
||
// The code inside `${…}` is parsed like any other, one level deeper (the depth check
|
||
// above bounds this recursion).
|
||
if t.kind == Kind::Template
|
||
&& t.text.contains("${")
|
||
&& substitutions(&t.text)
|
||
.iter()
|
||
.any(|sub| too_nested_from(&tokenize(sub).0, cost.saturating_sub(inner.saturating_mul(LEVEL)).saturating_add(LEVEL)))
|
||
{
|
||
return true;
|
||
}
|
||
prev = Some(t);
|
||
}
|
||
false
|
||
}
|
||
|
||
/// An error when `src` nests deeper than [`MAX_NESTING`] levels (before or after the rewrite).
|
||
pub fn check_nesting(src: &str) -> Result<(), String> {
|
||
let (toks, tail) = tokenize(src);
|
||
if too_nested(&toks) {
|
||
return Err(too_deep());
|
||
}
|
||
let mut out = rewrite_seq(&build(toks));
|
||
out.push_str(&tail);
|
||
if too_nested(&tokenize(&out).0) {
|
||
return Err(too_deep());
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn too_deep() -> String {
|
||
format!("Error: expression is nested too deeply (more than {MAX_NESTING} levels of brackets, or too long a chain of operators)")
|
||
}
|
||
|
||
/// Rewrite an expression's source for array-aware arithmetic. Line breaks are preserved.
|
||
/// An expression nested deeper than [`MAX_NESTING`] becomes a `throw` of that error.
|
||
pub fn rewrite(src: &str) -> String {
|
||
let (toks, tail) = tokenize(src);
|
||
if too_nested(&toks) {
|
||
return throw_too_deep();
|
||
}
|
||
let mut out = rewrite_seq(&build(toks));
|
||
out.push_str(&tail);
|
||
if too_nested(&tokenize(&out).0) {
|
||
return throw_too_deep();
|
||
}
|
||
out
|
||
}
|
||
|
||
fn throw_too_deep() -> String {
|
||
format!("throw new RangeError({:?})", too_deep().trim_start_matches("Error: "))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{MAX_NESTING, check_nesting, rewrite};
|
||
|
||
/// Compare ignoring spaces (newlines are significant).
|
||
fn n(s: &str) -> String {
|
||
s.replace(' ', "")
|
||
}
|
||
|
||
#[test]
|
||
fn precedence_and_unary() {
|
||
assert_eq!(n(&rewrite("a + b * c")), n("__add(a, __mul(b, c))"));
|
||
assert_eq!(n(&rewrite("-x * 2")), n("__mul(__neg(x), 2)"));
|
||
assert_eq!(n(&rewrite("-2*x")), n("__mul(-2, x)"));
|
||
assert_eq!(n(&rewrite("a % b + c")), n("__add((a % b), c)"));
|
||
assert_eq!(n(&rewrite("a - b - c")), n("__sub(__sub(a, b), c)"));
|
||
}
|
||
|
||
#[test]
|
||
fn calls_members_and_groups() {
|
||
assert_eq!(n(&rewrite("f(a+b)[0] / 2")), n("__div(f(__add(a, b))[0], 2)"));
|
||
assert_eq!(n(&rewrite("thisComp.layer(\"A\").transform.position[0] + 1")), n("__add(thisComp.layer(\"A\").transform.position[0], 1)"));
|
||
assert_eq!(n(&rewrite("x = a ? b + 1 : c")), n("x = a ? __add(b, 1) : c"));
|
||
assert_eq!(n(&rewrite("if (a) -b; else c")), n("if (a) __neg(b); else c"));
|
||
assert_eq!(n(&rewrite("return -x")), n("return __neg(x)"));
|
||
}
|
||
|
||
#[test]
|
||
fn strings_regex_comments_and_lines() {
|
||
assert_eq!(n(&rewrite("'a+b' + \"c\"")), n("__add('a+b', \"c\")"));
|
||
assert_eq!(n(&rewrite("s.replace(/a+b/g, '')")), n("s.replace(/a+b/g, '')"));
|
||
assert_eq!(n(&rewrite("a // x + y\n+ b")), n("__add(a, \n b)"));
|
||
assert_eq!(rewrite("a /* + */ * b").matches("__mul").count(), 1);
|
||
assert_eq!(rewrite("x\n\n+ 1").lines().count(), 3);
|
||
assert_eq!(n(&rewrite("`${a+b}` + 1")), n("__add(`${a+b}`, 1)"));
|
||
}
|
||
|
||
#[test]
|
||
fn compound_assignment_and_functions() {
|
||
assert_eq!(n(&rewrite("x += [1, 0];")), n("x = __add(x, ( [1, 0]));"));
|
||
assert_eq!(n(&rewrite("function f(a) { return a * 2 }")), n("function f(a) { return __mul(a, 2) }"));
|
||
assert_eq!(n(&rewrite("var g = (a) => a - 1")), n("var g = (a) => __sub(a, 1)"));
|
||
assert_eq!(n(&rewrite("typeof a + 'x'")), n("__add(typeof a, 'x')"));
|
||
assert_eq!(n(&rewrite("i++ + 1")), n("__add(i++, 1)"));
|
||
assert_eq!(n(&rewrite("2 ** 3 * 2")), n("__mul(2 ** 3, 2)"));
|
||
}
|
||
|
||
#[test]
|
||
fn deep_nesting_is_an_error_not_a_stack_overflow() {
|
||
// Each level costs the JavaScript parser stack: ~60 nested brackets overflowed a 2 MiB
|
||
// thread and aborted the app; 100 000 overflowed the rewriter itself.
|
||
for n in [MAX_NESTING + 1, 100, 100_000] {
|
||
let parens = "(".repeat(n) + "1" + &")".repeat(n);
|
||
let unclosed = "[".repeat(n);
|
||
let sum = vec!["x"; n + 1].join(" + ");
|
||
let nots = "!".repeat(n) + "x";
|
||
for src in [parens, unclosed, sum, nots] {
|
||
assert!(rewrite(&src).starts_with("throw new RangeError("), "{n}");
|
||
assert!(check_nesting(&src).is_err());
|
||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
|
||
}
|
||
}
|
||
let ok = "(".repeat(MAX_NESTING - 2) + "a + b" + &")".repeat(MAX_NESTING - 2);
|
||
assert!(check_nesting(&ok).is_ok());
|
||
assert!(crate::check_syntax(&ok).is_ok());
|
||
assert!(check_nesting(&vec!["x"; 20].join(" + ")).is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn long_chains_without_brackets_are_an_error_not_a_stack_overflow() {
|
||
// Found by the expression fuzzer (#786): the parser also recurses on chains that need no
|
||
// bracket, so a few hundred `?:`, `=>` or `if`, or a few thousand `.`/`<`/`()` links,
|
||
// aborted the app with a stack overflow; so did 75 nested template substitutions or
|
||
// regex groups (both hidden inside one token), and 6 000 prefix `new`/`++` the rewriter.
|
||
let chains: [(&str, &str); 19] = [
|
||
("x?x:", ""),
|
||
("()=>", ""),
|
||
("a=>", ""),
|
||
("if(x)", ""),
|
||
("if(x)x;else ", ""),
|
||
("x=", ""),
|
||
("x**", ""),
|
||
("x||", ""),
|
||
("x<", ""),
|
||
("x,", ""),
|
||
("x.y", ""),
|
||
("f()", ""),
|
||
("a:", ""),
|
||
("new ", ""),
|
||
("++", ""),
|
||
("`${", "}`"),
|
||
("s.replace(/(", ")/, '')"),
|
||
("`${x??", "}`"),
|
||
("{a:", "}"),
|
||
];
|
||
for (open, close) in chains {
|
||
for n in [2_000, 100_000] {
|
||
let src = open.repeat(n) + "1" + &close.repeat(n);
|
||
assert!(rewrite(&src).starts_with("throw new RangeError("), "{open} × {n}");
|
||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"), "{open} × {n}");
|
||
}
|
||
}
|
||
// A chain inside one template substitution (the template is one token to the rewriter).
|
||
let src = format!("`${{{}1}}`", "x??".repeat(100_000));
|
||
assert!(crate::check_syntax(&src).unwrap_err().contains("nested too deeply"));
|
||
}
|
||
|
||
#[test]
|
||
fn long_real_expressions_still_parse() {
|
||
let lines = |n: usize, line: &str| vec![line; n].join("\n");
|
||
let else_ifs = (0..200).map(|i| format!("if (time < {i}) {i}; else ")).collect::<String>() + "0";
|
||
let numbers = format!("[{}][0]", (0..5000).map(|i| i.to_string()).collect::<Vec<_>>().join(", "));
|
||
let points = format!("createPath([{}], [], [], true)", (0..500).map(|i| format!("[{i}, {}]", i * 2)).collect::<Vec<_>>().join(", "));
|
||
let object = format!("var o = {{{}}}; o.k0", (0..200).map(|i| format!("k{i}: time > {i} ? {i} : -{i}")).collect::<Vec<_>>().join(", "));
|
||
let program = "var x = 0\n".to_string() + &lines(300, "x = x + thisComp.layer(1).transform.position[0] * 2 - 1") + "\nx";
|
||
let ifs = "var y = 0\n".to_string() + &lines(300, "if (time > 1) y = y + 1") + "\ny";
|
||
let chained = vec!["thisComp.layer(1).transform.position.valueAtTime(time - 0.1)[0]"; 30].join(" + ");
|
||
let template = "`${thisLayer.name}: ${Math.floor(time * 10) / 10} s, ${`${index}`} {}`".to_string();
|
||
for src in [else_ifs, numbers, points, object, program, ifs, chained, template] {
|
||
assert!(check_nesting(&src).is_ok(), "{}", &src[..80]);
|
||
assert!(crate::check_syntax(&src).is_ok(), "{}", &src[..80]);
|
||
}
|
||
}
|
||
}
|