mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
#786: fuzz expressions; fix the overflows, hangs and panic it found
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9a7d3e7699
commit
9da66d9063
@@ -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 * 12;
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user