Skip to content

Commit 230a2fb

Browse files
committed
Fuzzing: seeded media, argument and API fuzzers, run in CI on the fuzz-test label
npm run fuzz drives the FFmpeg class in Chromium with a seeded PRNG. Strategies live in tests/fuzz/strategies; crashes are saved with the calls that led to them and replayed with --repro.
1 parent 95ca3b8 commit 230a2fb

10 files changed

Lines changed: 413 additions & 1 deletion

File tree

‎.github/workflows/CI.yml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,30 @@ jobs:
168168
path: |
169169
tests/heavy/results
170170
tests/heavy/baseline
171+
fuzz:
172+
# Fuzzing: on demand, or on pull requests labelled "fuzz-test".
173+
if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'fuzz-test')
174+
runs-on: ubuntu-latest
175+
needs: [build-core]
176+
steps:
177+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
178+
with:
179+
persist-credentials: false
180+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
181+
with:
182+
name: ffmpeg-core
183+
path: packages/core/dist
184+
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
185+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
186+
with:
187+
node-version: 24
188+
cache: pnpm
189+
- run: pnpm install --frozen-lockfile
190+
- run: pnpm test:install
191+
- name: Fuzz
192+
run: pnpm fuzz --time=900 --seed=${{ github.run_id }}
193+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
194+
if: failure()
195+
with:
196+
name: fuzz-crashes
197+
path: tests/fuzz/crashes

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ dist
1010
.netlify
1111
.cache
1212
tests/heavy/results
13+
tests/fuzz/crashes

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"test:docker": "docker build -f tests/Dockerfile -t ffmpeg-wasm-tests . && docker run --rm --ipc=host ffmpeg-wasm-tests",
2020
"test:install": "playwright install --with-deps chromium",
2121
"test:heavy": "vitest run --config tests/heavy/vitest.config.js",
22-
"bench": "vitest bench --run --config tests/heavy/vitest.config.js"
22+
"bench": "vitest bench --run --config tests/heavy/vitest.config.js",
23+
"fuzz": "node tests/fuzz/fuzz.mjs"
2324
},
2425
"devDependencies": {
2526
"@ffmpeg/core": "workspace:*",

‎tests/fuzz/README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Fuzzing
2+
3+
```sh
4+
pnpm fuzz # 2 minutes, every strategy, random seed
5+
pnpm fuzz --time=600 --strategy=media --seed=42
6+
pnpm fuzz --repro=tests/fuzz/crashes/args-42-17.json
7+
```
8+
9+
The fuzzer drives the real `FFmpeg` class in Chromium, like the unit tests,
10+
with a seeded PRNG, so a seed reproduces a whole run. A case **fails** when a
11+
call rejects with a trap or worker error, the core stops answering, or the case
12+
hangs. An FFmpeg error exit code is fine. Each failure is saved to
13+
`tests/fuzz/crashes/<strategy>-<seed>-<case>.json` with every call since the
14+
core was last (re)loaded, since some crashes depend on earlier commands, and
15+
`--repro` replays it.
16+
17+
Strategies are files in `strategies/` exporting
18+
`generate(rng, seeds) -> [[method, ...args], ...]`, a list of calls on the
19+
`FFmpeg` class:
20+
21+
| strategy | what it does |
22+
| --- | --- |
23+
| `media` | mutates seed media (bit flips, truncation, duplicated chunks, splices) and decodes and probes it: every demuxer and decoder |
24+
| `args` | random filters, encoders, muxers and options with edge-case values on generated input |
25+
| `api` | random sequences of file and command calls with odd paths and data |
26+
27+
Seed media is generated with ffmpeg.wasm itself (`SEEDS` in `lib.mjs`) and
28+
cached in `.cache/fuzz-seeds`. To fuzz a new format, add a line there; to fuzz
29+
something new, add a strategy file.
30+
31+
CI runs 15 minutes of fuzzing on pull requests labelled `fuzz-test` (and on
32+
manual dispatch), and uploads any crashes as an artifact.

‎tests/fuzz/fuzz.mjs‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Fuzzes ffmpeg.wasm through the FFmpeg class in Chromium and saves every
2+
// case that crashes or hangs the core, so it can be replayed.
3+
//
4+
// pnpm fuzz 2 minutes, every strategy
5+
// pnpm fuzz --time=600 --strategy=media --seed=42
6+
// pnpm fuzz --repro=tests/fuzz/crashes/media-42-17.json
7+
//
8+
// Strategies live in tests/fuzz/strategies/<name>.mjs and export
9+
// generate(rng, seeds) -> [[method, ...args], ...] calls on FFmpeg. A case
10+
// fails when a call rejects with a trap or worker error (not an ordinary
11+
// error such as a missing file) or doesn't finish in time.
12+
import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
13+
import { join } from "node:path";
14+
import { chromium } from "playwright";
15+
import { startServer, root } from "../helpers/server.js";
16+
import { rng, SEEDS, SEED_INPUT } from "./lib.mjs";
17+
18+
const args = Object.fromEntries(
19+
process.argv.slice(2).map((a) => {
20+
const [key, value = true] = a.replace(/^--/, "").split("=");
21+
return [key, value];
22+
})
23+
);
24+
const seconds = Number(args.time ?? 120);
25+
const seed = Number(args.seed ?? Date.now() % 1e6);
26+
const names = (args.strategy ?? readdirSync(join(root, "tests/fuzz/strategies")).map((f) => f.replace(".mjs", "")).join(",")).split(",");
27+
const strategies = Object.fromEntries(await Promise.all(names.map(async (n) => [n, await import(`./strategies/${n}.mjs`)])));
28+
const TIMEOUT_MS = 20000;
29+
const CRASH = /RuntimeError|out of bounds|unreachable|Aborted|signature mismatch|table index|worker failed|not loaded|stack|OOM|Cannot enlarge/i;
30+
const crashes = join(root, "tests/fuzz/crashes");
31+
32+
const { server, url } = await startServer();
33+
const browser = await chromium.launch();
34+
const page = await browser.newPage();
35+
await page.goto(`${url}/tests/fuzz/page.html`);
36+
await page.waitForFunction(() => window.ready);
37+
const reload = () => page.evaluate(() => window.reload());
38+
await reload();
39+
40+
// Calls since the core was (re)loaded: a crash can depend on earlier ones.
41+
let history = [];
42+
43+
// Runs one case; returns the reason it failed, or null.
44+
async function run(calls, log = () => {}) {
45+
history.push(...calls);
46+
const hang = new Promise((r) => setTimeout(() => r("hang"), TIMEOUT_MS * calls.length + 10000));
47+
const results = await Promise.race([page.evaluate(([c, t]) => window.runCase(c, t), [calls, TIMEOUT_MS]), hang]);
48+
log(results);
49+
const failure =
50+
results === "hang" ? "hang" :
51+
results.find((x) => x.error && CRASH.test(x.error))?.error ??
52+
((await page.evaluate(() => window.alive())) ? null : "the core stopped answering after this case");
53+
if (failure) {
54+
lastResults = results;
55+
await page.reload();
56+
await page.waitForFunction(() => window.ready);
57+
await reload();
58+
}
59+
return failure ?? null;
60+
}
61+
let lastResults;
62+
63+
if (args.repro) {
64+
const saved = JSON.parse(readFileSync(args.repro, "utf8"));
65+
console.log(`${args.repro}: ${(await run(saved.calls, console.log)) ?? "no crash"}`);
66+
await browser.close();
67+
server.close();
68+
process.exit(0);
69+
}
70+
71+
// Seed media, generated once with ffmpeg.wasm and cached.
72+
const seedDir = join(root, ".cache/fuzz-seeds");
73+
mkdirSync(seedDir, { recursive: true });
74+
const seeds = [];
75+
for (const [name, out] of SEEDS) {
76+
const path = join(seedDir, name);
77+
let bytes;
78+
try {
79+
bytes = readFileSync(path);
80+
} catch {
81+
const [result] = await page.evaluate(([args]) => window.runCase([["exec", args]], 60000), [[...SEED_INPUT, ...out, `/${name}`]]);
82+
if (result.ok !== 0) throw new Error(`seed ${name}: ${JSON.stringify(result)}`);
83+
bytes = Buffer.from(await page.evaluate((n) => window.readBytes(n), `/${name}`));
84+
writeFileSync(path, bytes);
85+
}
86+
seeds.push([name, new Uint8Array(bytes)]);
87+
}
88+
89+
console.log(`fuzzing ${names.join(", ")} for ${seconds}s, seed ${seed}`);
90+
const r = rng(seed);
91+
const stats = Object.fromEntries(names.map((n) => [n, { cases: 0, crashes: 0 }]));
92+
const end = Date.now() + seconds * 1000;
93+
for (let i = 0; Date.now() < end; i++) {
94+
// A fresh core every so often keeps crash histories short and replayable.
95+
if (i % 200 === 0 && history.length) {
96+
history = [];
97+
await reload();
98+
}
99+
const name = names[i % names.length];
100+
const calls = strategies[name].generate(r, seeds);
101+
stats[name].cases++;
102+
const failure = await run(calls);
103+
if (!failure) continue;
104+
stats[name].crashes++;
105+
mkdirSync(crashes, { recursive: true });
106+
const file = join(crashes, `${name}-${seed}-${i}.json`);
107+
// `calls` replays everything since the last reload; the last ones crashed.
108+
writeFileSync(file, JSON.stringify({ strategy: name, seed, iteration: i, failure, results: lastResults, calls: history }));
109+
history = [];
110+
console.log(`✗ ${name} case ${i}: ${failure.slice(0, 200)}\n saved ${file}`);
111+
}
112+
113+
await browser.close();
114+
server.close();
115+
for (const [name, s] of Object.entries(stats)) console.log(`${name}: ${s.cases} cases, ${s.crashes} crashes`);
116+
process.exit(Object.values(stats).some((s) => s.crashes) ? 1 : 0);

‎tests/fuzz/lib.mjs‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Shared helpers for the fuzz strategies.
2+
3+
/** Small deterministic PRNG (mulberry32), so every case can be replayed. */
4+
export function rng(seed) {
5+
let a = seed >>> 0;
6+
const next = () => {
7+
a = (a + 0x6d2b79f5) >>> 0;
8+
let t = a;
9+
t = Math.imul(t ^ (t >>> 15), t | 1);
10+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
11+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
12+
};
13+
const int = (n) => Math.floor(next() * n);
14+
const pick = (list) => list[int(list.length)];
15+
return { next, int, pick };
16+
}
17+
18+
const INTERESTING = [0x00, 0x01, 0x7f, 0x80, 0xff];
19+
20+
/** Returns a copy of `bytes` with 1-4 random mutations. */
21+
export function mutate(r, bytes, other) {
22+
let out = Uint8Array.from(bytes);
23+
for (let n = 1 + r.int(4); n > 0 && out.length; n--) {
24+
const at = r.int(out.length);
25+
switch (r.int(6)) {
26+
case 0: // flip a bit
27+
out[at] ^= 1 << r.int(8);
28+
break;
29+
case 1: // interesting byte
30+
out[at] = r.pick(INTERESTING);
31+
break;
32+
case 2: { // overwrite a run with random bytes
33+
const len = Math.min(1 + r.int(16), out.length - at);
34+
for (let i = 0; i < len; i++) out[at + i] = r.int(256);
35+
break;
36+
}
37+
case 3: // truncate
38+
out = out.subarray(0, at);
39+
break;
40+
case 4: { // duplicate a chunk
41+
const len = Math.min(1 + r.int(256), out.length - at);
42+
const copy = new Uint8Array(out.length + len);
43+
copy.set(out.subarray(0, at + len));
44+
copy.set(out.subarray(at, at + len), at + len);
45+
copy.set(out.subarray(at + len), at + 2 * len);
46+
out = copy;
47+
break;
48+
}
49+
case 5: { // splice in part of another seed
50+
const cut = other.subarray(r.int(other.length));
51+
const spliced = new Uint8Array(at + cut.length);
52+
spliced.set(out.subarray(0, at));
53+
spliced.set(cut, at);
54+
out = spliced;
55+
break;
56+
}
57+
}
58+
}
59+
return out;
60+
}
61+
62+
/**
63+
* Seed media, generated with ffmpeg.wasm itself (small, so mutations reach
64+
* interesting code quickly): [file name, ffmpeg output arguments].
65+
*/
66+
const V = ["-map", "0:v", "-t", "0.3"];
67+
const A = ["-map", "1:a", "-t", "0.3"];
68+
const AV = ["-map", "0:v", "-map", "1:a", "-t", "0.3"];
69+
export const SEEDS = [
70+
["h264.mp4", [...AV, "-c:v", "libx264", "-c:a", "aac"]],
71+
["hevc.mp4", [...V, "-c:v", "libx265"]],
72+
["vp8.webm", [...AV, "-c:v", "libvpx", "-c:a", "libvorbis"]],
73+
["vp9.webm", [...AV, "-c:v", "libvpx-vp9", "-c:a", "libopus"]],
74+
["theora.ogv", [...V, "-c:v", "libtheora"]],
75+
["mpeg4.avi", [...AV, "-c:v", "mpeg4", "-c:a", "libmp3lame"]],
76+
["mpeg2.ts", [...AV, "-c:v", "mpeg2video", "-c:a", "mp2"]],
77+
["mjpeg.mov", [...AV, "-c:v", "mjpeg", "-c:a", "pcm_s16le"]],
78+
["ffv1.mkv", [...AV, "-c:v", "ffv1", "-c:a", "flac"]],
79+
["frame.png", ["-map", "0:v", "-frames:v", "1"]],
80+
["frame.jpg", ["-map", "0:v", "-frames:v", "1"]],
81+
["frame.webp", ["-map", "0:v", "-c:v", "libwebp", "-frames:v", "1"]],
82+
["anim.gif", V],
83+
["sound.mp3", A],
84+
["sound.aac", A],
85+
["sound.opus", A],
86+
["sound.ogg", [...A, "-c:a", "libvorbis"]],
87+
["sound.flac", A],
88+
["sound.wav", A],
89+
];
90+
export const SEED_INPUT = ["-f", "lavfi", "-i", "testsrc=s=64x48:r=10", "-f", "lavfi", "-i", "sine=f=440"];

‎tests/fuzz/page.html‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<title>ffmpeg.wasm fuzzing</title>
6+
</head>
7+
<body>
8+
<script type="module">
9+
import { FFmpeg } from "/packages/ffmpeg/dist/esm/index.js";
10+
11+
const coreURL = `${location.origin}/packages/core/dist/esm/ffmpeg-core.js`;
12+
let ffmpeg;
13+
14+
window.reload = async () => {
15+
ffmpeg?.terminate();
16+
ffmpeg = new FFmpeg();
17+
await ffmpeg.load({ coreURL });
18+
};
19+
20+
// Runs one case: a list of [method, ...args] calls on the FFmpeg class
21+
// (byte arrays arrive as plain arrays) and returns each call's result
22+
// or error. The runner decides which errors are crashes.
23+
window.runCase = async (calls, timeout) => {
24+
const results = [];
25+
for (const [method, ...args] of calls) {
26+
const real = args.map((a) => (Array.isArray(a) && typeof a[0] === "number" ? Uint8Array.from(a) : a));
27+
const extra = method === "exec" || method === "ffprobe" ? [timeout] : [];
28+
try {
29+
const r = await ffmpeg[method](...real, ...extra);
30+
results.push({ method, ok: r instanceof Uint8Array ? r.length : r });
31+
} catch (e) {
32+
results.push({ method, error: String(e?.message ?? e) });
33+
}
34+
}
35+
return results;
36+
};
37+
38+
// Whether the core still answers, i.e. the last case didn't kill it.
39+
window.alive = () =>
40+
Promise.race([
41+
ffmpeg.listDir("/").then(() => true, () => false),
42+
new Promise((r) => setTimeout(() => r(false), 5000)),
43+
]);
44+
45+
window.readBytes = async (path) => Array.from(await ffmpeg.readFile(path));
46+
47+
window.ready = true;
48+
</script>
49+
</body>
50+
</html>

‎tests/fuzz/strategies/api.mjs‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Random sequences of FFmpeg class calls with odd paths and data.
2+
const NAMES = ["a", "dir", "ünïcödé-🎬", "with space", "..", ".", "a/b/c", "x".repeat(300), "__proto__", ""];
3+
4+
export function generate(r) {
5+
const path = () => `/${r.pick(NAMES)}${r.int(2) ? `/${r.pick(NAMES)}` : ""}`;
6+
const bytes = () => Array.from({ length: r.pick([0, 1, 1000, 100000]) }, () => r.int(256));
7+
const CALLS = [
8+
() => ["writeFile", path(), bytes()],
9+
() => ["writeFile", path(), "text ✓"],
10+
() => ["readFile", path()],
11+
() => ["readFile", path(), "utf8"],
12+
() => ["deleteFile", path()],
13+
() => ["rename", path(), path()],
14+
() => ["createDir", path()],
15+
() => ["listDir", path()],
16+
() => ["deleteDir", path()],
17+
() => ["exec", ["-i", path(), "-f", "null", "-"]],
18+
() => ["ffprobe", ["-show_format", path()]],
19+
];
20+
return Array.from({ length: 3 + r.int(12) }, () => r.pick(CALLS)());
21+
}

0 commit comments

Comments
 (0)