Merge pull request #806 from vangel76/gap-fuzz-expressions
#786: fuzz expressions; fix the overflows, hangs and panic it found
This commit is contained in:
commit
ff0ee5a727
|
|
@ -22,7 +22,10 @@
|
|||
//! `seedRandom`), so frames render identically on every thread and platform.
|
||||
//! * **Runtime**: one boa `Context` per thread with compiled scripts cached by text. Scripts read
|
||||
//! the project through memoized host requests (see [`host`]), so nothing borrowed ever enters
|
||||
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels.
|
||||
//! the JS heap. Expressions reading each other nest up to [`MAX_DEPTH`] levels; one that needs
|
||||
//! its own value fails at once, and one evaluation (reads included) runs at most [`MAX_RUNS`]
|
||||
//! scripts for at most [`MAX_TIME`]. Text nested deeper than the parser's stack allows is refused
|
||||
//! ([`rewrite::MAX_NESTING`]); a seeded fuzzer (`tests/fuzz.rs`) keeps all of this honest.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
|
|
@ -38,12 +41,39 @@ use effectcraft_render::{EvalCtx, ExprHost};
|
|||
use effectcraft_time::Tick;
|
||||
|
||||
use host::{Own, Req, Resolver, prop_dims, value_resp};
|
||||
use runtime::{Begin, FRAMES, Frame, Run};
|
||||
use runtime::{Begin, FRAMES, Frame, Owner, RUNS, Run};
|
||||
|
||||
/// Maximum nesting of expressions that read other expressions (cycles stop here).
|
||||
pub const MAX_DEPTH: usize = 16;
|
||||
/// Maximum script re-runs while answering host requests.
|
||||
const MAX_PASSES: usize = 2000;
|
||||
/// Maximum script runs for one outermost evaluation, the expressions it reads included: reads
|
||||
/// that fan out (an expression reading two values whose expressions each read two more…) grow
|
||||
/// exponentially with [`MAX_DEPTH`] and would hang the frame.
|
||||
pub const MAX_RUNS: usize = 20_000;
|
||||
/// Longest one outermost evaluation may take (its script runs and the expressions it reads),
|
||||
/// checked between runs and while a script runs: endless or very long loops, and a script that
|
||||
/// loops a while and then reads many values (re-running the loop once per value), stop here.
|
||||
/// Native builds only (the web build has no clock here; loops stop at the loop limit there).
|
||||
pub const MAX_TIME: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Start the work budget of an outermost evaluation.
|
||||
fn budget_start() {
|
||||
RUNS.with(|r| r.set(0));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
runtime::STARTED.with(|s| s.set(Some(std::time::Instant::now())));
|
||||
}
|
||||
|
||||
/// An error once the outermost evaluation has used up its runs or its time.
|
||||
fn budget_left() -> Result<(), String> {
|
||||
if RUNS.with(|r| r.get()) >= MAX_RUNS {
|
||||
return Err("Error: expression reads too many other expressions' values (circular or fanning-out references?)".into());
|
||||
}
|
||||
if runtime::out_of_time() {
|
||||
return Err(runtime::TIMED_OUT.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The expression engine (stateless; runtimes are per thread).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
|
@ -111,10 +141,23 @@ fn evaluate_out(
|
|||
text_sel: [f64; 2],
|
||||
selector: [f64; 3],
|
||||
) -> Result<runtime::Out, String> {
|
||||
let depth = FRAMES.with(|f| f.borrow().len());
|
||||
let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() };
|
||||
let (depth, cycle) = FRAMES.with(|f| {
|
||||
let f = f.borrow();
|
||||
(f.len(), f.iter().any(|fr| fr.owner.as_ref() == Some(&owner)))
|
||||
});
|
||||
if depth == 0 {
|
||||
budget_start();
|
||||
}
|
||||
if cycle {
|
||||
// Needs its own result: fails at once (the reader falls back to the keyframed value)
|
||||
// instead of nesting to `MAX_DEPTH` once per read on the way.
|
||||
return Err("Error: circular expression reference (the expression needs its own value)".into());
|
||||
}
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err("Error: expressions reference each other too deeply (circular reference?)".into());
|
||||
}
|
||||
budget_left()?;
|
||||
let ctx = EvalCtx { project: ctx.project, comp_id: ctx.comp_id, comp: ctx.comp, time: ctx.time, expr: Some(host), footage: ctx.footage };
|
||||
let comp = ctx.comp_id.0;
|
||||
let path = layer.props.path_of(prop.uid).unwrap_or_else(|| format!("@{}", prop.uid));
|
||||
|
|
@ -133,7 +176,7 @@ fn evaluate_out(
|
|||
selector,
|
||||
};
|
||||
// Answer the requests nearly every expression makes up front.
|
||||
let mut frame = Frame::default();
|
||||
let mut frame = Frame { owner: Some(owner), ..Frame::default() };
|
||||
for req in [Req::CompInfo { comp }, Req::LayerInfo { comp, layer: layer.id.0 }, Req::PropInfo { comp, layer: layer.id.0, path: path.clone() }] {
|
||||
let r = resolver.resolve(&req);
|
||||
frame.memo.insert(req, r);
|
||||
|
|
@ -141,6 +184,8 @@ fn evaluate_out(
|
|||
FRAMES.with(|f| f.borrow_mut().push(frame));
|
||||
let _guard = FrameGuard;
|
||||
for _ in 0..MAX_PASSES {
|
||||
budget_left()?;
|
||||
RUNS.with(|r| r.set(r.get().saturating_add(1)));
|
||||
match runtime::run(text, &begin) {
|
||||
Run::Done(r) => return r,
|
||||
Run::Pending => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub fn noise1(seed: u64, x: f64) -> f64 {
|
|||
let i = x.floor();
|
||||
let u = fade(x - i);
|
||||
let i = i as i64;
|
||||
let (a, b) = (lattice(seed, i), lattice(seed, i + 1));
|
||||
let (a, b) = (lattice(seed, i), lattice(seed, i.wrapping_add(1)));
|
||||
a + (b - a) * u
|
||||
}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ pub fn noise3(x: f64, y: f64, z: f64) -> f64 {
|
|||
let (fx, fy, fz) = (x.floor(), y.floor(), z.floor());
|
||||
let (u, v, w) = (fade(x - fx), fade(y - fy), fade(z - fz));
|
||||
let (ix, iy, iz) = (fx as i64, fy as i64, fz as i64);
|
||||
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[(ix + dx) as u64, (iy + dy) as u64, (iz + dz) as u64])) * 2.0 - 1.0;
|
||||
let g = |dx: i64, dy: i64, dz: i64| unit(mix(&[ix.wrapping_add(dx) as u64, iy.wrapping_add(dy) as u64, iz.wrapping_add(dz) as u64])) * 2.0 - 1.0;
|
||||
let l = |a: f64, b: f64, t: f64| a + (b - a) * t;
|
||||
let x00 = l(g(0, 0, 0), g(1, 0, 0), u);
|
||||
let x10 = l(g(0, 1, 0), g(1, 1, 0), u);
|
||||
|
|
@ -99,6 +99,18 @@ mod tests {
|
|||
assert!((-1.0..=1.0).contains(&noise3(0.3, 4.2, -7.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_inputs_do_not_overflow() {
|
||||
// Found by the expression fuzzer (#786): `wiggle(9007199254740993, 30)` at a late time
|
||||
// put the lattice index at `i64::MAX`, and its neighbour overflowed.
|
||||
let w = wiggle(1, 2, 9_007_199_254_740_993.0, 30.0, 1.0, 0.5, 1e6);
|
||||
assert!(w.iter().all(|v| v.is_finite() && v.abs() <= 30.0));
|
||||
for x in [1e300, -1e300, 9.3e18, -9.3e18] {
|
||||
assert!(noise1(5, x).is_finite());
|
||||
assert!(noise3(x, -x, x).is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiggle_bounds() {
|
||||
for i in 0..2000 {
|
||||
|
|
|
|||
|
|
@ -548,55 +548,230 @@ fn push_piece(out: &mut String, p: Piece) {
|
|||
out.push_str(&p.body);
|
||||
}
|
||||
|
||||
/// The deepest nesting (brackets, plus chains of prefix operators) an expression may have,
|
||||
/// before and after the rewrite (which nests a call per arithmetic operator). The JavaScript
|
||||
/// parser recurses per level and a 2 MiB thread overflows its stack (aborting the app) at
|
||||
/// about 60 levels, so deeper expressions are rejected with an error instead.
|
||||
/// 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;
|
||||
|
||||
fn nesting(toks: &[Tok]) -> usize {
|
||||
let (mut depth, mut prefix, mut max) = (0usize, 0usize, 0usize);
|
||||
for t in toks {
|
||||
if t.kind == Kind::Punct && matches!(t.text.as_str(), "(" | "[" | "{") {
|
||||
depth += 1;
|
||||
} else if t.kind == Kind::Punct && matches!(t.text.as_str(), ")" | "]" | "}") {
|
||||
depth = depth.saturating_sub(1);
|
||||
/// 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),
|
||||
_ => {}
|
||||
}
|
||||
let is_prefix = (t.kind == Kind::Punct && matches!(t.text.as_str(), "!" | "~" | "-" | "+")) || t.is_kw(&["typeof", "void", "delete"]);
|
||||
prefix = if is_prefix { prefix + 1 } else { 0 };
|
||||
max = max.max(depth + prefix);
|
||||
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 nesting(&toks) > MAX_NESTING {
|
||||
if too_nested(&toks) {
|
||||
return Err(too_deep());
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
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 chained operators)")
|
||||
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 nesting(&toks) > MAX_NESTING {
|
||||
if too_nested(&toks) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
let mut out = rewrite_seq(&build(toks));
|
||||
out.push_str(&tail);
|
||||
if nesting(&tokenize(&out).0) > MAX_NESTING {
|
||||
if too_nested(&tokenize(&out).0) {
|
||||
return throw_too_deep();
|
||||
}
|
||||
out
|
||||
|
|
@ -673,4 +848,60 @@ mod tests {
|
|||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ const PRELUDE: &str = include_str!("prelude.js");
|
|||
/// Message of the error thrown when a request isn't answered yet (never shown to users).
|
||||
const PENDING: &str = "\u{1}effectcraft: pending host request";
|
||||
const MAX_SCRIPTS: usize = 4096;
|
||||
/// Loop iterations one script run may make (per function call): a run re-executes once per
|
||||
/// property it reads, so this bounds the time of every pass.
|
||||
pub const LOOP_LIMIT: u64 = 1_000_000;
|
||||
/// The error of an evaluation that ran out of time.
|
||||
pub const TIMED_OUT: &str = "Error: expression takes too long to evaluate";
|
||||
/// The part of boa's stack-size limit error the runtime recovers from (see [`run`]).
|
||||
const STACK_LIMIT: &str = "maximum stack size";
|
||||
|
||||
|
|
@ -24,6 +29,18 @@ const STACK_LIMIT: &str = "maximum stack size";
|
|||
pub struct Frame {
|
||||
pub memo: HashMap<Req, Resp>,
|
||||
pub misses: Vec<Req>,
|
||||
/// What this frame evaluates: an evaluation that needs itself is a cycle.
|
||||
pub owner: Option<Owner>,
|
||||
}
|
||||
|
||||
/// One expression evaluation: the property (comp, layer, uid), the comp time and the text.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Owner {
|
||||
pub comp: u64,
|
||||
pub layer: u64,
|
||||
pub uid: u64,
|
||||
pub time: effectcraft_time::Tick,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -32,6 +49,14 @@ thread_local! {
|
|||
static RUNTIME: RefCell<Option<ManuallyDrop<Result<Runtime, String>>>> = const { RefCell::new(None) };
|
||||
/// Evaluation stack (nested when one expression reads another's value).
|
||||
pub static FRAMES: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
|
||||
/// Script runs since the outermost evaluation on this thread began (nested ones included).
|
||||
pub static RUNS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
thread_local! {
|
||||
/// When the outermost evaluation on this thread began.
|
||||
pub static STARTED: std::cell::Cell<Option<std::time::Instant>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
struct Runtime {
|
||||
|
|
@ -209,7 +234,7 @@ impl Runtime {
|
|||
fn new() -> Result<Runtime, String> {
|
||||
let mut ctx = Context::default();
|
||||
let limits = ctx.runtime_limits_mut();
|
||||
limits.set_loop_iteration_limit(10_000_000);
|
||||
limits.set_loop_iteration_limit(LOOP_LIMIT);
|
||||
limits.set_recursion_limit(400);
|
||||
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 4] = [
|
||||
(js_string!("__h"), 6, native_host),
|
||||
|
|
@ -270,7 +295,11 @@ impl Runtime {
|
|||
if let Err(e) = self.begin.call(&JsValue::undefined(), &args, ctx) {
|
||||
return Run::Done(Err(format_error(&e.to_string(), line_count(text))));
|
||||
}
|
||||
let result = script.evaluate(ctx).and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx));
|
||||
let result = match evaluate_in_time(&script, ctx) {
|
||||
Some(r) => r.and_then(|v| self.finish.call(&JsValue::undefined(), &[v], ctx)),
|
||||
// Abandoned mid-run: the context is unusable now (see `run`).
|
||||
None => return Run::Done(Err(TIMED_OUT.into())),
|
||||
};
|
||||
let pending = FRAMES.with(|f| f.borrow().last().is_some_and(|fr| !fr.misses.is_empty()));
|
||||
if pending {
|
||||
return Run::Pending;
|
||||
|
|
@ -282,6 +311,54 @@ impl Runtime {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run `script`, giving up (`None`) once the outermost evaluation is out of time
|
||||
/// ([`crate::MAX_TIME`]). boa's budgeted evaluation yields every few thousand instructions of
|
||||
/// the script and the functions it calls (not inside callbacks of native functions such as
|
||||
/// `Array.prototype.map`, which stay bounded by the loop limit per call).
|
||||
fn evaluate_in_time(script: &Script, ctx: &mut Context) -> Option<JsResult<JsValue>> {
|
||||
let mut run = std::pin::pin!(script.evaluate_async_with_budget(ctx, 20_000));
|
||||
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
|
||||
loop {
|
||||
if let std::task::Poll::Ready(r) = run.as_mut().poll(&mut cx) {
|
||||
return Some(r);
|
||||
}
|
||||
if out_of_time() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Test hook: this thread's time budget (tests of the run-count budget lift it so that a
|
||||
/// loaded machine cannot reach the clock first).
|
||||
pub static TIME_LIMIT: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(crate::MAX_TIME) };
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn max_time() -> std::time::Duration {
|
||||
#[cfg(test)]
|
||||
{
|
||||
TIME_LIMIT.with(|t| t.get())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
crate::MAX_TIME
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the outermost evaluation on this thread has used up [`crate::MAX_TIME`].
|
||||
pub fn out_of_time() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
STARTED.with(|s| s.get()).is_some_and(|t| t.elapsed() > max_time())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a JS value out of the heap.
|
||||
fn detach(v: &JsValue, ctx: &mut Context, depth: usize) -> Result<Out, String> {
|
||||
if v.is_undefined() {
|
||||
|
|
@ -413,6 +490,13 @@ pub fn run(text: &str, b: &Begin) -> Run {
|
|||
Err(e) => return Run::Done(Err(e.clone())),
|
||||
};
|
||||
match out {
|
||||
Run::Done(Err(e)) if e == TIMED_OUT => {
|
||||
// Abandoned mid-run: start the next evaluation on a fresh context.
|
||||
if let Some(old) = r.take() {
|
||||
drop(ManuallyDrop::into_inner(old));
|
||||
}
|
||||
return Run::Done(Err(e));
|
||||
}
|
||||
Run::Done(Err(e)) if !fresh && e.contains(STACK_LIMIT) => {
|
||||
// Dropped here, mid-thread, where boa's heap is alive (unlike at thread exit).
|
||||
if let Some(old) = r.take() {
|
||||
|
|
@ -500,6 +584,11 @@ pub fn to_value(out: &Out, current: &Value) -> Result<Value, String> {
|
|||
Value::Str(_) => Value::Str(text(out)),
|
||||
Value::Path(_) => match out {
|
||||
Out::Path { points, ins, outs, closed } => {
|
||||
// A vertex that isn't a number (`[[0, 0], [[1, 2], 0]]`) comes through as NaN, which
|
||||
// the rasterizer must never see.
|
||||
if points.iter().chain(ins).chain(outs).flatten().any(|c| !c.is_finite()) {
|
||||
return Err("Error: path points must be finite numbers".into());
|
||||
}
|
||||
let n = points.len();
|
||||
let fit = |v: &[[f64; 2]]| {
|
||||
let mut v = v.to_vec();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
use std::time::Instant;
|
||||
|
||||
mod fuzz;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, ShapePath, TextDoc, Value};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
|
|
@ -383,11 +385,87 @@ fn circular_references_terminate() {
|
|||
f.set_expr(f.b, OPA, "thisComp.layer('A').transform.opacity");
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, OPA, 0.0);
|
||||
assert!(start.elapsed().as_secs_f64() < 10.0);
|
||||
// The cycle bottoms out at the depth limit and falls back to keyframed values.
|
||||
// Only a hang guard (the budgets stop it far sooner); generous for a loaded machine.
|
||||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||||
// The cycle is caught when A needs itself again, and falls back to keyframed values.
|
||||
assert!(matches!(r, Ok(Value::Scalar(_))) || r.is_err(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expression_needing_its_own_value_fails_fast() {
|
||||
// Found by the expression fuzzer (#786): rotation's `toWorld` reads the layer's transform,
|
||||
// which evaluates rotation's expression again, twice per level (layer-to-comp and world
|
||||
// matrices), so the cycle nested to `MAX_DEPTH` 2^16 times and the frame took 20 s.
|
||||
let mut f = fx();
|
||||
f.set_expr(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]");
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||||
assert!(start.elapsed().as_secs_f64() < 100.0, "{:?}", start.elapsed());
|
||||
// The inner read falls back to the keyframed rotation, as in a standalone evaluation.
|
||||
assert_eq!(r, f.ev(f.a, ROT, "fromWorld(toWorld(anchorPoint))[0]", 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanning_out_reads_stop_at_a_budget() {
|
||||
// Two layers reading each other twice at other times: no cycle (the times differ), but
|
||||
// 2^16 evaluations at `MAX_DEPTH`; the evaluation stops after `MAX_RUNS` script runs.
|
||||
let mut f = fx();
|
||||
let text = |other: &str| format!("var r = thisComp.layer('{other}').transform.rotation; r.valueAtTime(time + 0.001) + r.valueAtTime(time + 0.002)");
|
||||
f.set_expr(f.a, ROT, &text("B"));
|
||||
f.set_expr(f.b, ROT, &text("A"));
|
||||
// Take the clock out of the race: on a loaded machine the time budget could otherwise stop
|
||||
// the evaluation before the run count does, with a different error.
|
||||
crate::runtime::TIME_LIMIT.with(|t| t.set(std::time::Duration::from_secs(3600)));
|
||||
let start = Instant::now();
|
||||
let r = eval_property(&f.p, f.cid, f.a, ROT, 0.5);
|
||||
crate::runtime::TIME_LIMIT.with(|t| t.set(crate::MAX_TIME));
|
||||
assert!(start.elapsed().as_secs_f64() < 300.0, "{:?}", start.elapsed());
|
||||
assert!(r.unwrap_err().contains("too many"));
|
||||
// The budget is per outermost evaluation: the next one starts afresh.
|
||||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||||
assert_close!(f.ev(f.b, OPA, "thisComp.layer('A').transform.opacity", 0.0).unwrap().as_f64(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_stop_at_a_million_iterations() {
|
||||
// Found by the expression fuzzer (#786): `for (…; i < 1e15; …)` ran ten million iterations
|
||||
// per run (seconds), once more for every property the script reads after it.
|
||||
let f = fx();
|
||||
assert_close!(f.num("var n = 0; while (n < 900000) n++; n", 0.0), 900_000.0);
|
||||
assert!(f.err("var n = 0; while (n < 2000000) n++; n").contains("iteration"));
|
||||
assert!(f.err("for (;;) {}").contains("iteration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_running_script_stops_at_the_time_budget() {
|
||||
// Found by the expression fuzzer (#786): `smooth(NaN, 1e9)` made a million host reads in
|
||||
// one run (tens of seconds), and the loop limit counts per function call, so loops calling
|
||||
// functions that loop run for hours. The run is now abandoned after `MAX_TIME`.
|
||||
let f = fx();
|
||||
let start = Instant::now();
|
||||
let e = f.err("function g() { for (var i = 0; i < 900000; i++) {} }\nfor (var k = 0; k < 900000; k++) g()");
|
||||
assert!(e.contains("too long"), "{e}");
|
||||
assert!(start.elapsed() < crate::MAX_TIME * 12, "{:?}", start.elapsed());
|
||||
// The thread's runtime starts afresh.
|
||||
assert_close!(f.num("time * 2", 1.0), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_non_numeric_points_is_an_error() {
|
||||
// Found by the expression fuzzer (#786): an array in a vertex became NaN and tripped the
|
||||
// rasterizer's clipping assertion when the mask was drawn.
|
||||
let mut f = fx();
|
||||
let text = "createPath([[0, 0], [[1, 2] * 2, 0], [100, 100]], [], [], true)";
|
||||
assert!(f.ev(f.a, "masks/#1/path", text, 0.0).unwrap_err().contains("finite"));
|
||||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [1e400, 0], [100, 100]], [], [], true)", 0.0).is_err());
|
||||
f.set_expr(f.a, "masks/#1/path", text);
|
||||
let host = Expressions;
|
||||
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
|
||||
r.expr = Some(&host);
|
||||
r.comp_frame(f.cid, Tick::ZERO);
|
||||
assert!(f.ev(f.a, "masks/#1/path", "createPath([[0, 0], [10, 0], [10, 10]], [], [], true)", 0.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_property_reads_keep_the_runtime_usable() {
|
||||
// Errors thrown inside helpers, and the aborts every property read makes until the host
|
||||
|
|
@ -1002,7 +1080,7 @@ fn hundred_wiggles_are_fast() {
|
|||
let per_frame = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
|
||||
println!("100 wiggle properties: {per_frame:.3} ms/frame (checksum {acc})");
|
||||
// Generous bound so unoptimised CI builds pass; release is far below.
|
||||
assert!(per_frame < 100.0, "{per_frame} ms/frame");
|
||||
assert!(per_frame < 1000.0, "{per_frame} ms/frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
//! An expression fuzzer (gaps G3, #786): any expression text must evaluate to a value or an
|
||||
//! error, never a panic, a stack overflow (which aborts the app), a hang or a runaway allocation
|
||||
//! (AGENTS.md "Never crash").
|
||||
//!
|
||||
//! Dependency-free, deterministic and seeded: real After Effects-style expressions (`wiggle`,
|
||||
//! `loopOut`, `valueAtTime`, `thisComp.layer(…)`, effects, masks, markers, text styles, paths…)
|
||||
//! are mutated character-wise (flips, deletions, duplications, splices with other seeds,
|
||||
//! truncation) and token-wise (punctuation, keywords and object-model names inserted, numbers
|
||||
//! swapped for extreme ones, a token repeated into deep nesting or a long operator chain), then
|
||||
//! syntax-checked, evaluated on a scalar, a vector, a text and a path property of the test comp,
|
||||
//! read through another layer's expression and rendered in a small frame. Inputs run on a worker
|
||||
//! thread with the app's default 2 MiB stack under `catch_unwind` and a deadline: a panic or a
|
||||
//! hang is reported with the iteration that reproduces it instead of taking the run down, and a
|
||||
//! stack overflow aborts the run, which fails it all the same (`EFFECTCRAFT_FUZZ_VERBOSE=1`
|
||||
//! prints every input before it runs).
|
||||
//!
|
||||
//! The default run is bounded (iterations and seconds) to stay fast in `cargo test`; a longer run:
|
||||
//! `EFFECTCRAFT_FUZZ_ITERS=200000 EFFECTCRAFT_FUZZ_SECS=600 cargo test -p effectcraft-expr fuzz_`
|
||||
//! (`EFFECTCRAFT_FUZZ_SEED` picks another sequence, `EFFECTCRAFT_FUZZ_START` the first iteration).
|
||||
//! The `mutate` + `eval_input` pair is what a cargo-fuzz target would call.
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use effectcraft_render::{NoFootage, RenderOpts, Renderer};
|
||||
|
||||
use super::{Fx, OPA, POS, ROT, fx, secs};
|
||||
use crate::{Expressions, check_syntax, eval_property, eval_standalone};
|
||||
|
||||
/// splitmix64: tiny, good enough for picking mutations.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = self.0;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
}
|
||||
/// `0..n` (`0` when `n` is 0).
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
|
||||
}
|
||||
fn chance(&mut self, one_in: usize) -> bool {
|
||||
self.below(one_in) == 0
|
||||
}
|
||||
fn pick<'a, T>(&mut self, xs: &'a [T]) -> &'a T {
|
||||
&xs[self.below(xs.len())]
|
||||
}
|
||||
}
|
||||
|
||||
/// Real expressions, in the shapes people write them (and the test comp can answer them).
|
||||
#[rustfmt::skip]
|
||||
const SEEDS: &[&str] = &[
|
||||
"time * 360",
|
||||
"value",
|
||||
"wiggle(2, 50)",
|
||||
"wiggle(5, 20, 3, 0.5, time * 2)",
|
||||
"temporalWiggle(2, 0.1)",
|
||||
"loopOut('cycle')",
|
||||
"loopOut('pingpong', 1)",
|
||||
"loopIn('offset')",
|
||||
"loopOutDuration('continue', 0.5)",
|
||||
"valueAtTime(time - 0.5)",
|
||||
"thisProperty.valueAtTime(time / 2) + velocityAtTime(time)[0]",
|
||||
"speedAtTime(0.5)",
|
||||
"smooth(0.2, 5)",
|
||||
"nearestKey(time).index",
|
||||
"numKeys > 1 ? key(numKeys).value : value",
|
||||
"thisComp.layer('A').transform.rotation.valueAtTime(0.25)",
|
||||
"thisComp.layer(\"B\").transform.position[0] + 10",
|
||||
"thisComp.layer(1).effect('Slider Control')('Slider') * 2",
|
||||
"effect(1).param(1) + effect(1)(1)",
|
||||
"comp('Other').layer(1).name",
|
||||
"comp('Other').width / thisComp.height",
|
||||
"thisLayer.index + thisComp.numLayers",
|
||||
"mask('Mask 1').maskOpacity",
|
||||
"mask('Mask 1').maskPath.points()[0]",
|
||||
"content('Rectangle 1').content('Rectangle Path 1').size[0]",
|
||||
"marker.numKeys > 0 ? marker.key(1).time : 0",
|
||||
"marker.nearestKey(time).comment",
|
||||
"thisComp.marker.key(1).time",
|
||||
"var r = sourceRectAtTime(time, false); [r.left + r.width / 2, r.top + r.height / 2]",
|
||||
"toComp([0, 0])",
|
||||
"fromWorld(toWorld(anchorPoint))",
|
||||
"lookAt([0, 0, 0], [100, 0, 100])",
|
||||
"value + [10, -5]",
|
||||
"[1, 2] * 2 - [0.5, 0.5] / 4",
|
||||
"x = value; x += [1, 0]; x",
|
||||
"linear(time, 0, 1, 0, 100)",
|
||||
"ease(time, 0, 2, [0, 0], [100, 50])",
|
||||
"easeIn(time, 0, 1, 0, 360) + easeOut(time, 1, 2, 0, 10)",
|
||||
"clamp(value * 2, 0, 100)",
|
||||
"length([0, 0], [3, 4]) + dot([1, 0], [0, 1]) + cross([1, 0, 0], [0, 1, 0])[2]",
|
||||
"normalize([3, 4])[0] * degreesToRadians(180)",
|
||||
"seedRandom(5, true); random(100)",
|
||||
"seedRandom(index, false); gaussRandom([10, 20])",
|
||||
"noise(time) * 100",
|
||||
"posterizeTime(12); wiggle(3, 30)",
|
||||
"framesToTime(timeToFrames(time) + 1)",
|
||||
"rgbToHsl([1, 0, 0, 1])[0] + hslToRgb([0.5, 1, 0.5, 1])[1]",
|
||||
"Math.sin(time * Math.PI * 2) * 50",
|
||||
"Math.round(value / 10) * 10",
|
||||
"var s = 0; for (var i = 0; i < 10; i++) { s += i; } s",
|
||||
"function f(a) { return a * 2 } f(value)",
|
||||
"var g = (a, b) => a - b; g(5, 3)",
|
||||
"if (time > 1) { 100 } else { 0 }",
|
||||
"try { thisComp.layer('Missing').name } catch (e) { 0 }",
|
||||
"'Frame ' + timeToFrames(time) + ' of ' + thisComp.duration",
|
||||
"text.sourceText + ' world'",
|
||||
"text.sourceText.style.setText('Hi there').setFontSize(20, 3, 5).setFauxBold(true, 0, 2)",
|
||||
"thisComp.layer('Title').text.sourceText.style.fontSize",
|
||||
"`${thisLayer.name}: ${Math.floor(time)}`",
|
||||
"createPath([[0, 0], [100, 0], [100, 100]], [], [], true)",
|
||||
"var p = thisProperty; p.pointOnPath(0.5)",
|
||||
"[thisComp.width, thisComp.height] / 2",
|
||||
"thisComp.frameDuration * 10",
|
||||
"inPoint + (outPoint - inPoint) * 0.5",
|
||||
"transform.opacity * 0.5 + [0][0]",
|
||||
];
|
||||
|
||||
/// What gets inserted: syntax, keywords, object-model names and odd characters.
|
||||
#[rustfmt::skip]
|
||||
const TOKENS: &[&str] = &[
|
||||
"(", ")", "[", "]", "{", "}", ",", ";", ".", "?.", "?", ":", "=", "==", "=>", "+", "-", "*", "/", "%", "**", "++", "--",
|
||||
"+=", "-=", "*=", "/=", "!", "~", "&&", "||", "??", "...", "'", "\"", "`", "${", "\\", "/*", "*/", "//", "\n", "\r", "\t",
|
||||
"\u{0}", "\u{2028}", "\u{feff}", "é", "\u{1F600}", "\u{fffd}", " in ", " of ", " new ", " typeof ", " void ", " delete ",
|
||||
"function", "return ", "var ", "let ", "const ", "if", "else", "for", "while", "do", "switch", "case ", "break", "try",
|
||||
"catch", "finally", "throw ", "class ", "this", "null", "undefined", "NaN", "Infinity", "true", "-0", "0x", "1e", "1n",
|
||||
"/a+/g", "time", "value", "index", "thisComp", "thisLayer", "thisProperty", "layer(", "comp(", "effect(", "mask(",
|
||||
"content(", "key(", "marker", "valueAtTime(", "wiggle(", "loopOut(", "sourceRectAtTime(", "toComp(", "text.sourceText",
|
||||
".style", ".setText(", "createPath(", "seedRandom(", "random(", "linear(", "__h(", "__begin", "__S", "arguments",
|
||||
];
|
||||
|
||||
/// Numbers swapped in for numeric literals.
|
||||
#[rustfmt::skip]
|
||||
const NUMBERS: &[&str] = &[
|
||||
"0", "-0", "1", "-1", "0.5", "2", "16", "17", "255", "256", "65536", "4294967295", "4294967296", "9007199254740993",
|
||||
"1e308", "-1e308", "1e-320", "1e400", "NaN", "Infinity", "-Infinity", "1e9", "1e15", "-2147483648", "0x7fffffff",
|
||||
];
|
||||
|
||||
/// A fragment that nests (or chains) once per repetition: what overflows a recursive parser.
|
||||
#[rustfmt::skip]
|
||||
const NESTS: &[(&str, &str)] = &[
|
||||
("(", ")"), ("[", "]"), ("{", "}"), ("-", ""), ("!", ""), ("~", ""), ("+", ""), ("++", ""), ("--", ""), ("typeof ", ""),
|
||||
("new ", ""), ("await ", ""), ("void ", ""), ("delete ", ""), ("x**", ""), ("x+", ""), ("x*", ""), ("x=", ""),
|
||||
("x?x:", ""), ("x&&", ""), ("x||", ""), ("x??", ""), ("x<", ""), ("x==", ""), ("x,", ""), ("()=>", ""), ("a=>", ""),
|
||||
("x.", "y"), ("f(", ")"), ("x[", "]"), ("`${", "}`"), ("function(){return ", "}"), ("{a:", "}"), ("if(x)", ""),
|
||||
("x.y", ""), ("f()", ""), ("x[0]", ""),
|
||||
];
|
||||
|
||||
/// One mutated expression from the seeds, decided by the iteration's own generator.
|
||||
fn mutate(r: &mut Rng) -> String {
|
||||
let mut s: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
if r.chance(4) {
|
||||
// Splice two expressions together.
|
||||
let other: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
let at = r.below(s.len() + 1);
|
||||
let glue: Vec<char> = r.pick(&[";\n", " + ", " * ", ", ", "(", "."]).chars().collect();
|
||||
s.splice(at..at, glue.into_iter().chain(other));
|
||||
}
|
||||
for _ in 0..1 + r.below(4) {
|
||||
let len = s.len();
|
||||
match r.below(10) {
|
||||
0 if len > 0 => {
|
||||
// A bit flip in the UTF-8 bytes (invalid sequences become U+FFFD).
|
||||
let mut b = s.iter().collect::<String>().into_bytes();
|
||||
let i = r.below(b.len());
|
||||
b[i] ^= 1 << r.below(8);
|
||||
s = String::from_utf8_lossy(&b).chars().collect();
|
||||
}
|
||||
1 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(16)).min(len);
|
||||
s.drain(a..e);
|
||||
}
|
||||
2 if len > 0 => {
|
||||
let a = r.below(len);
|
||||
let e = (a + 1 + r.below(32)).min(len);
|
||||
let chunk = s[a..e].to_vec();
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, chunk);
|
||||
}
|
||||
3 | 4 => {
|
||||
let t: Vec<char> = r.pick(TOKENS).chars().collect();
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, t);
|
||||
}
|
||||
5 => {
|
||||
// An extreme number in place of a numeric literal (or anywhere).
|
||||
let starts: Vec<usize> = (0..len).filter(|&i| s[i].is_ascii_digit() && (i == 0 || !s[i - 1].is_ascii_alphanumeric())).collect();
|
||||
let n: Vec<char> = r.pick(NUMBERS).chars().collect();
|
||||
if starts.is_empty() {
|
||||
let at = r.below(len + 1);
|
||||
s.splice(at..at, n);
|
||||
} else {
|
||||
let a = *r.pick(&starts);
|
||||
let e = (a..len).find(|&i| !(s[i].is_ascii_alphanumeric() || s[i] == '.')).unwrap_or(len);
|
||||
s.splice(a..e, n);
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
// Deep nesting or a long chain, closed or not.
|
||||
let (open, close) = *r.pick(NESTS);
|
||||
let depth = *r.pick(&[8, 40, 300, 5_000, 100_000]);
|
||||
let at = r.below(len + 1);
|
||||
let mut nest: Vec<char> = open.repeat(depth).chars().collect();
|
||||
nest.push('1');
|
||||
if !r.chance(4) {
|
||||
nest.extend(close.repeat(depth).chars());
|
||||
}
|
||||
s.splice(at..at, nest);
|
||||
}
|
||||
7 if len > 0 => s.truncate(r.below(len)),
|
||||
8 => {
|
||||
// A whole statement in front or behind.
|
||||
let other: Vec<char> = r.pick(SEEDS).chars().collect();
|
||||
if r.chance(2) {
|
||||
s.extend(";\n".chars().chain(other));
|
||||
} else {
|
||||
s.splice(0..0, other.into_iter().chain(";\n".chars()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
s.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Where an input is evaluated: a scalar, a vector, a text and a path property.
|
||||
const TARGETS: &[(usize, &str)] = &[(0, ROT), (0, POS), (2, "text/sourceText"), (0, "masks/#1/path"), (3, ROT), (1, OPA)];
|
||||
const TIMES: &[f64] = &[0.0, 0.5, 1.25, 4.0, -1.0, 1e6];
|
||||
|
||||
/// Syntax-check `text`, evaluate it on one property, read it from another layer's expression
|
||||
/// and render a small frame with it. `Err` only for what must never happen: an `internal error`
|
||||
/// (a panic caught by a guard).
|
||||
fn eval_input(f: &mut Fx, text: &str, target: usize, t: f64) -> Result<(), String> {
|
||||
internal(&check_syntax(text))?;
|
||||
let layers = [f.a, f.b, f.title, f.shape];
|
||||
let (li, path) = TARGETS[target % TARGETS.len()];
|
||||
let layer = layers[li];
|
||||
internal(&eval_standalone(&f.p, f.cid, layer, path, text, t))?;
|
||||
// As the property's own expression, read by B's rotation and drawn by the renderer.
|
||||
f.set_expr(layer, path, text);
|
||||
f.set_expr(f.b, ROT, &format!("var v = thisComp.layer({}).transform.rotation; v + 1", li + 1));
|
||||
internal(&eval_property(&f.p, f.cid, layer, path, t))?;
|
||||
internal(&eval_property(&f.p, f.cid, f.b, ROT, t))?;
|
||||
let host = Expressions;
|
||||
let mut r = Renderer::new(&f.p, &NoFootage, RenderOpts { motion_blur: false, scale: 0.25, ..Default::default() });
|
||||
r.expr = Some(&host);
|
||||
r.comp_frame(f.cid, secs(t));
|
||||
// Leave the fixture as it was.
|
||||
let l = f.layer_mut(layer);
|
||||
if let Some(p) = l.props.prop_mut(path) {
|
||||
p.expr = None;
|
||||
}
|
||||
if let Some(p) = f.layer_mut(f.b).props.prop_mut(ROT) {
|
||||
p.expr = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn internal<T>(r: &Result<T, String>) -> Result<(), String> {
|
||||
match r {
|
||||
Err(e) if e.contains("internal error") => Err(e.clone()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
text: String,
|
||||
target: usize,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
/// A worker thread that keeps its expression runtime between inputs, like a render thread.
|
||||
struct Worker {
|
||||
jobs: Sender<Job>,
|
||||
results: Receiver<Result<(), String>>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn start() -> Result<Worker, String> {
|
||||
let (jobs, rx) = channel::<Job>();
|
||||
let (tx, results) = channel();
|
||||
std::thread::Builder::new()
|
||||
.name("fuzz-expr".into())
|
||||
.stack_size(2 << 20)
|
||||
.spawn(move || {
|
||||
let mut f = fx();
|
||||
for job in rx {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| eval_input(&mut f, &job.text, job.target, job.t)));
|
||||
let r = r.unwrap_or_else(|p| {
|
||||
Err(format!(
|
||||
"panic: {}",
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default()
|
||||
))
|
||||
});
|
||||
let failed = r.is_err();
|
||||
if tx.send(r).is_err() || failed {
|
||||
break; // after a panic the fixture and runtime may be half-updated: start over
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("couldn't start the fuzz thread: {e}"))?;
|
||||
Ok(Worker { jobs, results })
|
||||
}
|
||||
|
||||
/// Run one input: a panic becomes its message, a hang a timeout (the worker is then dropped).
|
||||
fn run(&self, job: Job, deadline: Duration) -> Result<(), String> {
|
||||
self.jobs.send(job).map_err(|_| "the fuzz worker exited".to_string())?;
|
||||
self.results.recv_timeout(deadline).unwrap_or_else(|_| Err(format!("hang: no result within {deadline:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn env_u64(name: &str, default: u64) -> u64 {
|
||||
std::env::var(name).ok().and_then(|v| v.trim().parse().ok()).unwrap_or(default)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_expressions_never_crash() {
|
||||
let seed = env_u64("EFFECTCRAFT_FUZZ_SEED", 0x00EC_E8E2);
|
||||
let start = env_u64("EFFECTCRAFT_FUZZ_START", 0);
|
||||
let iters = env_u64("EFFECTCRAFT_FUZZ_ITERS", 1_500);
|
||||
let budget = Duration::from_secs(env_u64("EFFECTCRAFT_FUZZ_SECS", 5));
|
||||
let verbose = env_u64("EFFECTCRAFT_FUZZ_VERBOSE", 0) != 0;
|
||||
// Every evaluation gives up after `MAX_TIME`, and an input is evaluated a handful of times.
|
||||
let deadline = crate::MAX_TIME * 24;
|
||||
let mut worker = Worker::start().unwrap();
|
||||
// The unmutated seeds evaluate everywhere without crashing.
|
||||
for (i, s) in SEEDS.iter().enumerate() {
|
||||
assert_eq!(worker.run(Job { text: s.to_string(), target: i, t: 0.5 }, deadline), Ok(()), "{s}");
|
||||
}
|
||||
let began = Instant::now();
|
||||
let mut failures = vec![];
|
||||
let mut ran = 0;
|
||||
for i in start..start + iters {
|
||||
if began.elapsed() > budget {
|
||||
break;
|
||||
}
|
||||
let mut r = Rng(seed ^ i.wrapping_mul(0xD6E8_FEB8_6659_FD93));
|
||||
let text = mutate(&mut r);
|
||||
let target = r.below(TARGETS.len());
|
||||
let t = *r.pick(TIMES);
|
||||
if verbose {
|
||||
eprintln!("iteration {i}: target {target}, t {t}, {} chars: {:?}", text.len(), text.chars().take(300).collect::<String>());
|
||||
}
|
||||
ran += 1;
|
||||
if let Err(e) = worker.run(Job { text: text.clone(), target, t }, deadline) {
|
||||
let head: String = text.chars().take(200).collect();
|
||||
failures.push(format!("iteration {i} (seed {seed:#x}, target {target}, t {t}, {} chars): {e}\n input starts: {head:?}", text.len()));
|
||||
if failures.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
worker = Worker::start().unwrap();
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "expressions crashed on {} of {ran} fuzz inputs:\n{}", failures.len(), failures.join("\n"));
|
||||
eprintln!("fuzz_expressions: {ran} inputs in {:?}, no crashes", began.elapsed());
|
||||
}
|
||||
Loading…
Reference in New Issue