mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
The After Effects stagger idiom id = thisProperty.propertyGroup(2).propertyIndex; failed with "not a callable function": the expression object model had no propertyGroup() and property groups had no propertyIndex. - Property.propertyGroup(countUp = 1) and PropertyGroup.propertyGroup() return the group countUp levels up; from a top-level group they reach the layer. A count below 1, not a number, or above the layer is an error naming propertyGroup, never a crash. - PropertyGroup.propertyIndex: the group's 1-based index among its siblings (Child requests now carry it). - One new host request (Ancestor) walks up from the node's full uid path, so it also works for the property being evaluated. - Tests for the stagger expression, effect parameters, reaching the layer, other layers' properties and groups and out-of-range counts; the fuzzer seeds and tokens include the new members.
364 lines
16 KiB
Rust
364 lines
16 KiB
Rust
//! 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]",
|
|
"thisProperty.propertyGroup(1).propertyIndex * 10 + thisProperty.propertyGroup(1).numProperties",
|
|
"effect(1)(1).propertyGroup(1).propertyGroup(2).name",
|
|
"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(", ".propertyGroup(", ".propertyIndex", "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());
|
|
}
|