diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c3015629a..d6ae2e834 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,6 +28,8 @@ jobs: with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable - name: MSVC (windows) uses: ilammy/msvc-dev-cmd@v1 if: contains(matrix.os, 'windows') diff --git a/.github/workflows/run-all-tests-main.yaml b/.github/workflows/run-all-tests-main.yaml index 6d415ebfd..f0c3a2c75 100644 --- a/.github/workflows/run-all-tests-main.yaml +++ b/.github/workflows/run-all-tests-main.yaml @@ -29,6 +29,8 @@ jobs: with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable - name: install dependencies run: npm ci - name: build project diff --git a/.github/workflows/run-all-tests-pr.yaml b/.github/workflows/run-all-tests-pr.yaml index e657966d5..dc400b711 100644 --- a/.github/workflows/run-all-tests-pr.yaml +++ b/.github/workflows/run-all-tests-pr.yaml @@ -24,6 +24,8 @@ jobs: with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable - name: install dependencies run: npm ci - name: install clang-tidy @@ -67,6 +69,8 @@ jobs: with: node-version: ${{ matrix.node }} cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable - name: MSVC (windows) uses: ilammy/msvc-dev-cmd@v1 if: contains(matrix.os, 'windows') @@ -95,6 +99,8 @@ jobs: with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable - name: MSVC (windows) uses: ilammy/msvc-dev-cmd@v1 if: contains(matrix.os, 'windows') diff --git a/README.md b/README.md index 85873303e..e9a21f67f 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ Jazzer.js is a coverage-guided, in-process fuzzer for the [Node.js](https://nodejs.org) platform developed by -[Code Intelligence](https://www.code-intelligence.com). It is based on -[libFuzzer](https://llvm.org/docs/LibFuzzer.html) and brings many of its +[Code Intelligence](https://www.code-intelligence.com). It supports +[libFuzzer](https://llvm.org/docs/LibFuzzer.html) and +[LibAFL](https://github.com/AFLplusplus/LibAFL) backends and brings instrumentation-powered mutation features to the JavaScript ecosystem. ## Quickstart @@ -47,6 +48,9 @@ To use Jazzer.js in your own project follow these few simple steps: npx jazzer FuzzTarget ``` + To run with the LibAFL backend instead of the default libFuzzer backend, add + `--engine=afl`. + 4. Enjoy fuzzing! ## Usage diff --git a/benchmarks/engine_smoke/.gitignore b/benchmarks/engine_smoke/.gitignore new file mode 100644 index 000000000..91b88bfe4 --- /dev/null +++ b/benchmarks/engine_smoke/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +work/ diff --git a/benchmarks/engine_smoke/anomaly.js b/benchmarks/engine_smoke/anomaly.js new file mode 100644 index 000000000..0d46a6efd --- /dev/null +++ b/benchmarks/engine_smoke/anomaly.js @@ -0,0 +1,189 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const benchmarkDirectory = __dirname; +const workDirectory = path.join(benchmarkDirectory, "work", "anomalies"); +const engineTarget = path.join( + benchmarkDirectory, + "..", + "..", + "tests", + "engine", + "fuzz.js", +); +const asyncTarget = path.join(benchmarkDirectory, "anomaly_fuzz.js"); + +function removeIfExists(targetPath) { + fs.rmSync(targetPath, { force: true, recursive: true }); +} + +function ensureDirectory(targetPath) { + fs.mkdirSync(targetPath, { recursive: true }); +} + +function runCommand(label, args, cwd, outputDirectory, expectedStatus = 0) { + console.log(`\n[anomaly] ${label}`); + console.log(`[anomaly] command: npx ${args.join(" ")}`); + ensureDirectory(outputDirectory); + const sanitizedLabel = label.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + const stdoutPath = path.join(outputDirectory, `${sanitizedLabel}.stdout.log`); + const stderrPath = path.join(outputDirectory, `${sanitizedLabel}.stderr.log`); + const stdoutFd = fs.openSync(stdoutPath, "w"); + const stderrFd = fs.openSync(stderrPath, "w"); + const startedAt = Date.now(); + const proc = spawnSync("npx", args, { + cwd, + env: { ...process.env }, + shell: true, + stdio: ["ignore", stdoutFd, stderrFd], + windowsHide: true, + }); + const elapsedMs = Date.now() - startedAt; + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + + if (proc.status !== expectedStatus) { + throw new Error( + `${label} failed with exit code ${proc.status}\nSTDOUT (${stdoutPath}):\n${fs.readFileSync(stdoutPath, "utf8")}\nSTDERR (${stderrPath}):\n${fs.readFileSync(stderrPath, "utf8")}`, + ); + } + + return { + elapsedMs, + stderrPath, + stdoutPath, + }; +} + +function parseExecsPerSecond(stderrPath) { + const stderr = fs.readFileSync(stderrPath, "utf8"); + const match = stderr.match(/speed:\s+([0-9.]+) exec\/s/); + if (!match) { + throw new Error(`No LibAFL done line found in ${stderrPath}`); + } + return Number.parseFloat(match[1]); +} + +function runGuidedNumericSmoke() { + const outputDirectory = path.join(workDirectory, "guided-numeric"); + const corpusDirectory = path.join(outputDirectory, "corpus"); + removeIfExists(outputDirectory); + ensureDirectory(corpusDirectory); + fs.writeFileSync(path.join(corpusDirectory, "seed"), Buffer.alloc(4)); + + const result = runCommand( + "guided numeric solve", + [ + "jazzer", + engineTarget, + "-f", + "guided_numeric", + "--engine=afl", + "--sync", + "--disable_bug_detectors=.*", + "--", + corpusDirectory, + "-runs=4000", + "-seed=1337", + "-max_len=16", + `-artifact_prefix=${outputDirectory}${path.sep}`, + ], + benchmarkDirectory, + outputDirectory, + 77, + ); + + const output = + fs.readFileSync(result.stdoutPath, "utf8") + + fs.readFileSync(result.stderrPath, "utf8"); + if (!output.includes("AFL numeric guidance finding")) { + throw new Error("Guided numeric smoke did not report the expected finding"); + } + + return { + name: "guided-numeric", + elapsedMs: result.elapsedMs, + }; +} + +function runAsyncSmoke() { + const outputDirectory = path.join(workDirectory, "async-smoke"); + const corpusDirectory = path.join(outputDirectory, "corpus"); + removeIfExists(outputDirectory); + ensureDirectory(corpusDirectory); + fs.writeFileSync(path.join(corpusDirectory, "seed"), "async-seed"); + + const result = runCommand( + "async throughput smoke", + [ + "jazzer", + asyncTarget, + "-f", + "async_smoke", + "--engine=afl", + "--disable_bug_detectors=.*", + "--", + corpusDirectory, + "-runs=2000", + "-seed=9001", + "-max_len=128", + `-artifact_prefix=${outputDirectory}${path.sep}`, + ], + benchmarkDirectory, + outputDirectory, + ); + + const execsPerSecond = parseExecsPerSecond(result.stderrPath); + if (execsPerSecond <= 0) { + throw new Error("Async smoke reported a non-positive exec/sec rate"); + } + if (result.elapsedMs > 30000) { + throw new Error( + `Async smoke took unexpectedly long: ${result.elapsedMs} ms`, + ); + } + + return { + name: "async-smoke", + elapsedMs: result.elapsedMs, + execsPerSecond, + }; +} + +function main() { + ensureDirectory(workDirectory); + const results = [runGuidedNumericSmoke(), runAsyncSmoke()]; + for (const result of results) { + const stats = [`elapsed_ms=${result.elapsedMs}`]; + if (result.execsPerSecond !== undefined) { + stats.push(`execs_per_second=${result.execsPerSecond}`); + } + console.log(`[anomaly] ${result.name}: ${stats.join(" ")}`); + } + fs.writeFileSync( + path.join(workDirectory, "results.json"), + JSON.stringify(results, null, 2), + ); + console.log( + `\n[anomaly] wrote machine-readable results to ${path.join(workDirectory, "results.json")}`, + ); +} + +main(); diff --git a/benchmarks/engine_smoke/anomaly_fuzz.js b/benchmarks/engine_smoke/anomaly_fuzz.js new file mode 100644 index 000000000..fd31e8624 --- /dev/null +++ b/benchmarks/engine_smoke/anomaly_fuzz.js @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.async_smoke = function (data) { + let checksum = 0; + for (const byte of data) { + checksum = ((checksum * 33) ^ byte) & 0xffff; + } + + return new Promise((resolve) => { + setImmediate(() => { + if (checksum === 0x1337) { + // Exercise an extra branch without turning this into a finding target. + checksum ^= data.length; + } + resolve(checksum); + }); + }); +}; diff --git a/benchmarks/engine_smoke/fuzz.js b/benchmarks/engine_smoke/fuzz.js new file mode 100644 index 000000000..690274041 --- /dev/null +++ b/benchmarks/engine_smoke/fuzz.js @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const qs = require("qs"); + +const { FuzzedDataProvider } = require("@jazzer.js/core"); + +module.exports.fuzz = function (data) { + const provider = new FuzzedDataProvider(data); + const input = provider.consumeRemainingAsString(); + + const parseOptions = { + allowDots: provider.consumeBoolean(), + allowEmptyArrays: provider.consumeBoolean(), + allowPrototypes: provider.consumeBoolean(), + arrayLimit: provider.consumeIntegralInRange(0, 32), + charset: provider.pickValue(["utf-8", "iso-8859-1"]), + charsetSentinel: provider.consumeBoolean(), + comma: provider.consumeBoolean(), + decodeDotInKeys: provider.consumeBoolean(), + depth: provider.consumeIntegralInRange(0, 16), + duplicates: provider.pickValue(["combine", "first", "last"]), + ignoreQueryPrefix: provider.consumeBoolean(), + interpretNumericEntities: provider.consumeBoolean(), + parameterLimit: provider.consumeIntegralInRange(1, 256), + parseArrays: provider.consumeBoolean(), + plainObjects: provider.consumeBoolean(), + strictDepth: provider.consumeBoolean(), + strictNullHandling: provider.consumeBoolean(), + }; + + let parsed; + try { + parsed = qs.parse(input, parseOptions); + } catch { + return; + } + + try { + qs.stringify(parsed, { + addQueryPrefix: provider.consumeBoolean(), + allowDots: provider.consumeBoolean(), + allowEmptyArrays: provider.consumeBoolean(), + arrayFormat: provider.pickValue([ + "indices", + "brackets", + "repeat", + "comma", + ]), + charset: provider.pickValue(["utf-8", "iso-8859-1"]), + charsetSentinel: provider.consumeBoolean(), + commaRoundTrip: provider.consumeBoolean(), + delimiter: provider.pickValue(["&", ";"]), + encode: provider.consumeBoolean(), + encodeDotInKeys: provider.consumeBoolean(), + indices: provider.consumeBoolean(), + skipNulls: provider.consumeBoolean(), + strictNullHandling: provider.consumeBoolean(), + }); + } catch { + // Smoke target: ignore library-level parse/stringify failures. + } +}; diff --git a/benchmarks/engine_smoke/package.json b/benchmarks/engine_smoke/package.json new file mode 100644 index 000000000..aa448401c --- /dev/null +++ b/benchmarks/engine_smoke/package.json @@ -0,0 +1,15 @@ +{ + "name": "jazzerjs-engine-smoke", + "version": "1.0.0", + "private": true, + "description": "Manual 30-second smoke benchmark for libFuzzer vs LibAFL.", + "scripts": { + "smoke": "node run.js", + "smoke:anomalies": "node anomaly.js" + }, + "devDependencies": { + "@jazzer.js/core": "file:../../packages/core", + "istanbul-lib-coverage": "^3.2.2", + "qs": "^6.14.0" + } +} diff --git a/benchmarks/engine_smoke/run.js b/benchmarks/engine_smoke/run.js new file mode 100644 index 000000000..6e2539e13 --- /dev/null +++ b/benchmarks/engine_smoke/run.js @@ -0,0 +1,182 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const libCoverage = require("istanbul-lib-coverage"); + +const benchmarkDirectory = __dirname; +const workDirectory = path.join(benchmarkDirectory, "work"); +const fuzzTarget = path.join(benchmarkDirectory, "fuzz.js"); +const seedCorpusDirectory = path.join(benchmarkDirectory, "seeds"); +const seconds = Number.parseInt(process.argv[2] ?? "30", 10); + +function removeIfExists(targetPath) { + fs.rmSync(targetPath, { force: true, recursive: true }); +} + +function ensureDirectory(targetPath) { + fs.mkdirSync(targetPath, { recursive: true }); +} + +function runCommand(label, args, engineDirectory) { + console.log(`\n[smoke] ${label}`); + console.log(`[smoke] command: npx ${args.join(" ")}`); + ensureDirectory(engineDirectory); + const sanitizedLabel = label.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + const stdoutPath = path.join(engineDirectory, `${sanitizedLabel}.stdout.log`); + const stderrPath = path.join(engineDirectory, `${sanitizedLabel}.stderr.log`); + const stdoutFd = fs.openSync(stdoutPath, "w"); + const stderrFd = fs.openSync(stderrPath, "w"); + const proc = spawnSync("npx", args, { + cwd: benchmarkDirectory, + env: { ...process.env }, + shell: true, + stdio: ["ignore", stdoutFd, stderrFd], + windowsHide: true, + }); + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + if (proc.status !== 0) { + throw new Error( + `${label} failed with exit code ${proc.status}\nSTDOUT (${stdoutPath}):\n${fs.readFileSync(stdoutPath, "utf8")}\nSTDERR (${stderrPath}):\n${fs.readFileSync(stderrPath, "utf8")}`, + ); + } + return { stdoutPath, stderrPath }; +} + +function countFiles(directory) { + if (!fs.existsSync(directory)) { + return 0; + } + return fs + .readdirSync(directory) + .filter((entry) => fs.lstatSync(path.join(directory, entry)).isFile()) + .length; +} + +function summarizeCoverage(coverageDirectory) { + const coverageFile = path.join(coverageDirectory, "coverage-final.json"); + const rawCoverage = JSON.parse(fs.readFileSync(coverageFile, "utf8")); + const coverageMap = libCoverage.createCoverageMap(rawCoverage); + const librarySummary = libCoverage.createCoverageSummary(); + const normalizedNeedle = `${path.sep}node_modules${path.sep}qs${path.sep}`; + + const files = coverageMap + .files() + .filter((filePath) => path.normalize(filePath).includes(normalizedNeedle)); + for (const filePath of files) { + librarySummary.merge(coverageMap.fileCoverageFor(filePath).toSummary()); + } + + return { + files: files.length, + lines: librarySummary.data.lines.pct, + branches: librarySummary.data.branches.pct, + functions: librarySummary.data.functions.pct, + statements: librarySummary.data.statements.pct, + }; +} + +function runSmoke(engine) { + const engineDirectory = path.join(workDirectory, engine); + const generatedCorpusDirectory = path.join( + engineDirectory, + "generated-corpus", + ); + const artifactDirectory = path.join(engineDirectory, "artifacts"); + const coverageDirectory = path.join(engineDirectory, "coverage"); + + removeIfExists(engineDirectory); + ensureDirectory(generatedCorpusDirectory); + ensureDirectory(artifactDirectory); + + runCommand( + `${engine} fuzzing`, + [ + "jazzer", + fuzzTarget, + generatedCorpusDirectory, + seedCorpusDirectory, + "--sync", + "--disable_bug_detectors=.*", + `--engine=${engine}`, + "-i=fuzz.js", + "-i=node_modules/qs/", + "--", + `-max_total_time=${seconds}`, + `-artifact_prefix=${artifactDirectory}${path.sep}`, + ], + engineDirectory, + ); + + removeIfExists(coverageDirectory); + runCommand( + `${engine} regression coverage`, + [ + "jazzer", + fuzzTarget, + generatedCorpusDirectory, + seedCorpusDirectory, + "--sync", + "--mode=regression", + "--coverage", + `--coverage_directory=${coverageDirectory}`, + "--coverage_reporters=json", + "--disable_bug_detectors=.*", + `--engine=${engine}`, + "-i=fuzz.js", + "-i=node_modules/qs/", + ], + engineDirectory, + ); + + return { + engine, + seconds, + generatedCorpusEntries: countFiles(generatedCorpusDirectory), + coverage: summarizeCoverage(coverageDirectory), + }; +} + +function printResult(result) { + console.log(`\n[smoke] ${result.engine}`); + console.log( + `[smoke] generated corpus entries: ${result.generatedCorpusEntries}`, + ); + console.log( + `[smoke] library coverage: lines=${result.coverage.lines}% branches=${result.coverage.branches}% functions=${result.coverage.functions}% statements=${result.coverage.statements}% across ${result.coverage.files} files`, + ); +} + +function main() { + ensureDirectory(workDirectory); + const results = [runSmoke("libfuzzer"), runSmoke("afl")]; + for (const result of results) { + printResult(result); + } + fs.writeFileSync( + path.join(workDirectory, "results.json"), + JSON.stringify(results, null, 2), + ); + console.log( + `\n[smoke] wrote machine-readable results to ${path.join(workDirectory, "results.json")}`, + ); +} + +main(); diff --git a/benchmarks/engine_smoke/seeds/basic.txt b/benchmarks/engine_smoke/seeds/basic.txt new file mode 100644 index 000000000..118274dd8 --- /dev/null +++ b/benchmarks/engine_smoke/seeds/basic.txt @@ -0,0 +1 @@ +a=b&c=d diff --git a/benchmarks/engine_smoke/seeds/encoded.txt b/benchmarks/engine_smoke/seeds/encoded.txt new file mode 100644 index 000000000..73bb8b819 --- /dev/null +++ b/benchmarks/engine_smoke/seeds/encoded.txt @@ -0,0 +1 @@ +utf8=%E2%9C%93&filters[color]=blue&filters[size]=xl&page=2 diff --git a/benchmarks/engine_smoke/seeds/nested.txt b/benchmarks/engine_smoke/seeds/nested.txt new file mode 100644 index 000000000..68cbd1817 --- /dev/null +++ b/benchmarks/engine_smoke/seeds/nested.txt @@ -0,0 +1 @@ +user[name]=alice&user[roles][]=admin&user[roles][]=author diff --git a/docs/fuzz-settings.md b/docs/fuzz-settings.md index 43f3489a3..cef462ef0 100644 --- a/docs/fuzz-settings.md +++ b/docs/fuzz-settings.md @@ -589,13 +589,52 @@ JAZZER_FUZZ_ENTRY_POINT=buzz npx jazzer my-fuzz-file _Note:_ In Jest mode, this option cannot be set via environment variable. Instead use the native Jest flag `--testNamePattern` as described above. +### `engine` : [string] + +Default: "libfuzzer" + +Select the native fuzzing backend. + +- `libfuzzer`: use the existing libFuzzer backend. +- `afl` (alias for `libafl`): use the LibAFL backend. + +**CLI:** Select the backend with `--engine`, for example: + +```bash +npx jazzer my-fuzz-file --engine=afl +``` + +**Jest:** Set it in `.jazzerjsrc.json`: + +```json +{ + "engine": "afl" +} +``` + +_Note:_ The LibAFL backend currently supports _fuzzing_ mode only. + ### `fuzzerOptions` : [array\] Default: [] -Pass options to native fuzzing engine (Jazzer.js uses libFuzzer). +Pass options to the selected native fuzzing engine. + +For `engine=libfuzzer`, Jazzer.js supports the full libFuzzer-style argument +list. + +For `engine=afl`/`engine=libafl`, Jazzer.js currently supports these options: + +- `-runs=` +- `-seed=` +- `-max_len=` +- `-max_total_time=` +- `-artifact_prefix=` +- non-flag entries interpreted as corpus directories + +Unsupported engine-specific flags are rejected with an explicit error. -For a list of available options, see the +For the `libfuzzer` backend, see the [libFuzzer documentation](https://llvm.org/docs/LibFuzzer.html#options). To get a quick overview of all available options, call Jazzer.js with the libFuzzer argument `-help`. Here is an example for the CLI mode: diff --git a/package.json b/package.json index db6f55df2..927e16cc2 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "test:default": "npm run test:jest", "test:linux:darwin": "npm run test:jest && cd tests && sh ../scripts/run_all.sh fuzz", "test:win32": "npm run test:jest && cd tests && ..\\scripts\\run_all.bat fuzz", - "test:jest": "jest && npm run test --ws --if-present", - "test:jest:coverage": "jest --coverage", - "test:jest:watch": "jest --watch", + "test:jest": "jest --maxWorkers=25% && npm run test --ws --if-present -- --maxWorkers=25%", + "test:jest:coverage": "jest --coverage --maxWorkers=25%", + "test:jest:watch": "jest --watch --maxWorkers=25%", "example": "run-script-os", "example:linux:darwin": "cd examples && sh ../scripts/run_all.sh dryRun", "example:win32": "cd examples && ..\\scripts\\run_all.bat dryRun", diff --git a/packages/core/cli.ts b/packages/core/cli.ts index 13ccdf5bb..a471f6662 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -44,7 +44,7 @@ yargs(process.argv.slice(2)) 'Start a fuzzing run using the "fuzz" function exported by "target" ' + 'and use the directory "corpus" to store newly generated inputs. ' + 'Also pass the "-max_total_time" flag to the internal fuzzing engine ' + - "(libFuzzer) to stop the fuzzing run after 60 seconds.", + "to stop the fuzzing run after 60 seconds.", ) .epilogue("Happy fuzzing!") .command( @@ -56,7 +56,7 @@ yargs(process.argv.slice(2)) 'The "corpus" directory is optional and can be used to provide initial ' + "seed input. It is also used to store interesting inputs between fuzzing " + "runs.\n\n" + - "To pass options to the internal fuzzing engine (libFuzzer) use a " + + "To pass options to the internal fuzzing engine use a " + 'double-dash, "--", to mark the end of the normal fuzzer arguments. ' + "An example is shown in the examples section of this help message.", (yargs: Argv) => { @@ -177,6 +177,14 @@ yargs(process.argv.slice(2)) group: "Fuzzer:", type: "string", }) + .option("engine", { + alias: ["backend"], + defaultDescription: `${JSON.stringify(defaultCLIOptions.engine)}`, + describe: + "Fuzzing engine backend. Use 'afl' (alias 'libafl') for the default LibAFL backend or 'libfuzzer' to run the libFuzzer backend.", + group: "Fuzzer:", + type: "string", + }) .option("dryRun", { alias: ["dry_run", "d"], defaultDescription: `${JSON.stringify(defaultCLIOptions.dryRun)}`, diff --git a/packages/core/core.ts b/packages/core/core.ts index 4e7c0e8f3..8d0831e2c 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -43,7 +43,12 @@ import { reportFinding, } from "./finding"; import { getJazzerJsGlobal, jazzerJs } from "./globals"; -import { buildFuzzerOption, OptionsManager } from "./options"; +import { + buildLibAflOptions, + buildLibFuzzerOptions, + OptionsManager, + resolveEngine, +} from "./options"; import { ensureFilepath, importModule } from "./utils"; // Remove temporary files on exit @@ -245,24 +250,47 @@ export async function startFuzzingNoInit( // Currently only SIGINT is handled this way, as SIGSEGV has to be handled // by the native addon and directly stops the process. const signalHandler = (signal: number): void => { + if (signal === 0) { + return; + } reportFinding(new FuzzerSignalFinding(signal), false); }; try { - const fuzzerOptions = buildFuzzerOption(options); - if (options.get("sync")) { - await fuzzer.fuzzer.startFuzzing( - fuzzFn, - fuzzerOptions, - // In synchronous mode, we cannot use the SIGINT handler in Node, - // because the event loop is blocked by the fuzzer, and the handler - // won't be called until the fuzzing process is finished. - // Hence, we pass a callback function to the native fuzzer and - // register a SIGINT handler there. - signalHandler, - ); + if (resolveEngine(options.get("engine")) === "libfuzzer") { + const fuzzerOptions = buildLibFuzzerOptions(options); + if (options.get("sync")) { + await fuzzer.fuzzer.startFuzzing( + fuzzFn, + fuzzerOptions, + // In synchronous mode, we cannot use the SIGINT handler in Node, + // because the event loop is blocked by the fuzzer, and the handler + // won't be called until the fuzzing process is finished. + // Hence, we pass a callback function to the native fuzzer and + // register a SIGINT handler there. + signalHandler, + ); + } else { + await fuzzer.fuzzer.startFuzzingAsync(fuzzFn, fuzzerOptions); + } } else { - await fuzzer.fuzzer.startFuzzingAsync(fuzzFn, fuzzerOptions); + const libAflOptions = buildLibAflOptions(options); + const libAflFuzzer = fuzzer.fuzzer as unknown as { + startLibAfl: ( + fuzzFn: FindingAwareFuzzTarget, + options: typeof libAflOptions, + jsStopCallback: (signal: number) => void, + ) => Promise; + startLibAflAsync: ( + fuzzFn: FindingAwareFuzzTarget, + options: typeof libAflOptions, + ) => Promise; + }; + if (options.get("sync")) { + await libAflFuzzer.startLibAfl(fuzzFn, libAflOptions, signalHandler); + } else { + await libAflFuzzer.startLibAflAsync(fuzzFn, libAflOptions); + } } // Fuzzing ended without a finding, due to -max_total_time or -runs. return reportFuzzingResult(undefined, options.get("expectedErrors")); @@ -369,6 +397,10 @@ export function asFindingAwareFuzzFn( ): FindingAwareFuzzTarget { function printAndDump(error: unknown): void { cleanErrorStack(error); + const shouldDumpWithLibFuzzer = + ( + globalThis as typeof globalThis & { options?: OptionsManager } + ).options?.get("engine") !== "libafl"; if ( !( error instanceof FuzzerSignalFinding && @@ -376,7 +408,7 @@ export function asFindingAwareFuzzFn( ) ) { printFinding(error); - if (dumpCrashingInput) { + if (dumpCrashingInput && shouldDumpWithLibFuzzer) { fuzzer.fuzzer.printAndDumpCrashingInput(); } } diff --git a/packages/core/dictionary.ts b/packages/core/dictionary.ts index 82e8c1373..1a9bcb326 100644 --- a/packages/core/dictionary.ts +++ b/packages/core/dictionary.ts @@ -37,6 +37,7 @@ export class Dictionary { } function getDictionary(): Dictionary { + globalThis.JazzerJS ??= new Map(); return getOrSetJazzerJsGlobal("dictionary", new Dictionary()); } diff --git a/packages/core/options.test.ts b/packages/core/options.test.ts index d91a58a78..cecc6ab67 100644 --- a/packages/core/options.test.ts +++ b/packages/core/options.test.ts @@ -14,13 +14,20 @@ * limitations under the License. */ +import fs from "fs"; +import os from "os"; +import path from "path"; + import { + buildLibAflOptions, defaultCLIOptions, + defaultJestOptions, fromSnakeCase, fromSnakeCaseWithPrefix, Options, OptionsManager, OptionSource, + resolveEngine, spawnsSubprocess, validateKeySource, } from "./options"; @@ -70,6 +77,14 @@ describe("options", () => { }); describe("merge", () => { + it("uses LibAFL as default CLI engine", () => { + expect(defaultCLIOptions.engine).toBe("libafl"); + }); + + it("keeps libFuzzer as default Jest engine", () => { + expect(defaultJestOptions.engine).toBe("libfuzzer"); + }); + it("New options with lower priorities will not be added", () => { const baseOptions = OptionsManager.attachSource( defaultCLIOptions, @@ -314,6 +329,116 @@ describe("buildLibFuzzerOptions", () => { }); }); +describe("libafl options", () => { + it("normalizes engine aliases", () => { + expect(resolveEngine("libfuzzer")).toBe("libfuzzer"); + expect(resolveEngine("afl")).toBe("libafl"); + expect(resolveEngine("libafl")).toBe("libafl"); + expect(() => resolveEngine("unknown")).toThrow("Unknown fuzzing engine"); + }); + + it("builds structured LibAFL options from fuzzer options", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + timeout: 1234, + fuzzerOptions: [ + "corpus-main", + "corpus-seed", + "-runs=99", + "-seed=1337", + "-max_len=1024", + "-max_total_time=42", + "-artifact_prefix=/tmp/artifacts/", + ], + }, + OptionSource.CommandLineArguments, + ); + + expect(buildLibAflOptions(manager)).toEqual({ + mode: "fuzzing", + runs: 99, + seed: 1337, + maxLen: 1024, + timeoutMillis: 1234, + maxTotalTimeSeconds: 42, + artifactPrefix: "/tmp/artifacts/", + corpusDirectories: ["corpus-main", "corpus-seed"], + dictionaryFiles: [], + }); + }); + + it("rejects unsupported options in LibAFL mode", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + fuzzerOptions: ["-fork=1"], + }, + OptionSource.CommandLineArguments, + ); + + expect(() => buildLibAflOptions(manager)).toThrow("not supported"); + }); + + it("supports regression mode in LibAFL mode", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + mode: "regression", + fuzzerOptions: ["corpus"], + }, + OptionSource.CommandLineArguments, + ); + + expect(buildLibAflOptions(manager)).toEqual({ + mode: "regression", + runs: 0, + seed: 0, + maxLen: 4096, + timeoutMillis: 5000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: ["corpus"], + dictionaryFiles: [], + }); + }); + + it("supports dictionary entries in LibAFL mode", () => { + const tempDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "jazzer-libafl-dict-"), + ); + const dictionaryPath = path.join(tempDirectory, "seed.dict"); + fs.writeFileSync(dictionaryPath, '"Amazing"\n'); + + try { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions) + .merge( + { + engine: "libafl", + fuzzerOptions: ["corpus", `-dict=${dictionaryPath}`], + }, + OptionSource.CommandLineArguments, + ) + .merge( + { dictionaryEntries: ["banana"] }, + OptionSource.JestFuzzTestOptions, + ); + + const built = buildLibAflOptions(manager); + expect(built.corpusDirectories).toEqual(["corpus"]); + expect(built.dictionaryFiles).toHaveLength(1); + expect(fs.readFileSync(built.dictionaryFiles[0], "utf8")).toContain( + "\\x62\\x61\\x6e\\x61\\x6e\\x61", + ); + expect(fs.readFileSync(built.dictionaryFiles[0], "utf8")).toContain( + "Amazing", + ); + } finally { + fs.rmSync(tempDirectory, { force: true, recursive: true }); + } + }); +}); + function expectDefaultsExceptKeys( options: Options, source: OptionSource, diff --git a/packages/core/options.ts b/packages/core/options.ts index 66dfae2d7..3e864cbff 100644 --- a/packages/core/options.ts +++ b/packages/core/options.ts @@ -22,6 +22,18 @@ import * as tmp from "tmp"; import { useDictionaryByParams } from "./dictionary"; import { replaceAll } from "./utils"; +export type LibAflOptions = { + mode: "fuzzing" | "regression"; + runs: number; + seed: number; + maxLen: number; + timeoutMillis: number; + maxTotalTimeSeconds: number; + artifactPrefix: string; + corpusDirectories: string[]; + dictionaryFiles: string[]; +}; + /** * Jazzer.js options structure expected by the fuzzer. * @@ -30,6 +42,8 @@ import { replaceAll } from "./utils"; * options. */ export interface Options { + // Fuzzing backend engine. + engine: "libfuzzer" | "libafl"; // Enable source code coverage report generation. coverage: boolean; // Directory to write coverage reports to. @@ -85,6 +99,7 @@ export type OptionsWithPrintableSource = { // These options can be set from the Jest fuzz test. const allowedFuzzTestOptions = [ + "engine", "dictionaryEntries", "fuzzerOptions", "sync", @@ -93,6 +108,7 @@ const allowedFuzzTestOptions = [ export type AllowedFuzzTestOptions = (typeof allowedFuzzTestOptions)[number]; export const defaultCLIOptions: Options = Object.freeze({ + engine: "libafl", coverage: false, coverageDirectory: "coverage", coverageReporters: ["json", "text", "lcov", "clover"], // default Jest reporters @@ -115,6 +131,7 @@ export const defaultCLIOptions: Options = Object.freeze({ export const defaultJestOptions: Options = Object.freeze({ ...defaultCLIOptions, + engine: "libfuzzer", mode: "regression", }); @@ -149,6 +166,22 @@ export enum OptionSource { JestFuzzTestOptions, } +export type FuzzingEngine = Options["engine"]; + +export function resolveEngine(engine: string): FuzzingEngine { + switch (engine) { + case "libfuzzer": + return "libfuzzer"; + case "libafl": + case "afl": + return "libafl"; + default: + throw new Error( + `Unknown fuzzing engine '${engine}'. Supported engines are 'libfuzzer' and 'afl'.`, + ); + } +} + type DefaultSourceInfo = { name: string; transformKey: KeyFormatSource; @@ -421,7 +454,7 @@ function setProperty(obj: T, key: K, value: T[K]) { obj[key] = value; } -export function buildFuzzerOption(options: OptionsManager) { +export function buildLibFuzzerOptions(options: OptionsManager) { let params: string[] = []; params = optionDependentParams(options, params); params = forkedExecutionParams(params); @@ -436,6 +469,120 @@ export function buildFuzzerOption(options: OptionsManager) { return params; } +// Backwards-compatible alias for existing call sites. +export const buildFuzzerOption = buildLibFuzzerOptions; + +export function buildLibAflOptions(options: OptionsManager): LibAflOptions { + if (options.get("timeout") <= 0) { + throw new Error("timeout must be > 0"); + } + + const normalizedFuzzerOptions = useDictionaryByParams( + options.get("fuzzerOptions"), + options.get("dictionaryEntries"), + ); + + let runs = 0; + let seed = 0; + let maxLen = 4096; + let maxTotalTimeSeconds = 0; + let artifactPrefix = ""; + const corpusDirectories: string[] = []; + const dictionaryFiles: string[] = []; + + for (const option of normalizedFuzzerOptions) { + if (!option.startsWith("-")) { + corpusDirectories.push(option); + continue; + } + + if (option.startsWith("-runs=")) { + runs = parsePositiveOrZeroInteger("runs", option.substring(6)); + continue; + } + if (option.startsWith("-seed=")) { + seed = parsePositiveOrZeroInteger("seed", option.substring(6)); + continue; + } + if (option.startsWith("-max_len=")) { + maxLen = parsePositiveInteger("max_len", option.substring(9)); + continue; + } + if (option.startsWith("-max_total_time=")) { + maxTotalTimeSeconds = parsePositiveOrZeroInteger( + "max_total_time", + option.substring(16), + ); + continue; + } + if (option.startsWith("-artifact_prefix=")) { + artifactPrefix = option.substring(17); + continue; + } + if (option.startsWith("-dict=")) { + dictionaryFiles.splice(0, dictionaryFiles.length, option.substring(6)); + continue; + } + + throw new Error( + `Option '${option}' is not supported by the '${resolveEngine(options.get("engine"))}' engine`, + ); + } + + printOptions(options); + if (process.env.JAZZER_DEBUG) { + console.error( + `DEBUG: [core] LibAFL options: ${JSON.stringify( + { + mode: options.get("mode"), + runs, + seed, + maxLen, + maxTotalTimeSeconds, + timeoutMillis: options.get("timeout"), + artifactPrefix, + corpusDirectories, + dictionaryFiles, + }, + null, + 2, + )}`, + ); + } + + return { + mode: options.get("mode"), + runs, + seed, + maxLen, + timeoutMillis: options.get("timeout"), + maxTotalTimeSeconds, + artifactPrefix, + corpusDirectories, + dictionaryFiles, + }; +} + +function parsePositiveInteger(name: string, value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + `Option '${name}' must be a positive integer, got '${value}'`, + ); + } + return parsed; +} + +function parsePositiveOrZeroInteger(name: string, value: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error( + `Option '${name}' must be a non-negative integer, got '${value}'`, + ); + } + return parsed; +} + export function printOptions(options: OptionsManager, infix = "") { if (process.env.JAZZER_DEBUG) { console.error( diff --git a/packages/core/utils.test.ts b/packages/core/utils.test.ts index 703364c79..e17a6d12c 100644 --- a/packages/core/utils.test.ts +++ b/packages/core/utils.test.ts @@ -36,6 +36,18 @@ describe("core", () => { }); }); describe("prepareArgs", () => { + it("does not add an undefined engine", () => { + const args = { + _: ["-some_arg=value"], + corpus: [], + fuzzTarget: "filename.js", + }; + const options = prepareArgs(args); + expect( + Object.prototype.hasOwnProperty.call(options, "engine"), + ).toBeFalsy(); + }); + it("converts fuzzer args to strings", () => { const args = { _: ["-some_arg=value", "-other_arg", 123], @@ -54,5 +66,16 @@ describe("core", () => { ], }); }); + + it("normalizes engine alias", () => { + const args = { + _: [], + corpus: [], + engine: "afl", + fuzzTarget: "filename.js", + }; + const options = prepareArgs(args); + expect(options.engine).toBe("libafl"); + }); }); }); diff --git a/packages/core/utils.ts b/packages/core/utils.ts index 5d439583e..4bca53ab6 100644 --- a/packages/core/utils.ts +++ b/packages/core/utils.ts @@ -74,6 +74,11 @@ export function prepareArgs(args: any) { .concat(args._) .map((e: unknown) => e + ""), }; + if (options.engine !== undefined) { + options.engine = options.engine === "afl" ? "libafl" : options.engine; + } else { + delete options.engine; + } if (options.fuzzerOptions.length === 0) { delete options.fuzzerOptions; } diff --git a/packages/fuzzer/CMakeLists.txt b/packages/fuzzer/CMakeLists.txt index 0331affaa..9dad180e0 100644 --- a/packages/fuzzer/CMakeLists.txt +++ b/packages/fuzzer/CMakeLists.txt @@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.15) project(jazzerjs) find_package(Patch REQUIRED) +find_program(CARGO_EXECUTABLE cargo REQUIRED) set(CMAKE_CXX_STANDARD 17) # mostly supported since GCC 7 set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -72,6 +73,55 @@ set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node") target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_JS_INC}) target_link_libraries(${PROJECT_NAME} ${CMAKE_JS_LIB}) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(RUST_TARGET_TRIPLE "aarch64-unknown-linux-gnu") + else() + set(RUST_TARGET_TRIPLE "x86_64-unknown-linux-gnu") + endif() + set(RUST_STATICLIB_NAME "libjazzerjs_libafl_runtime.a") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + if(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") + set(RUST_TARGET_TRIPLE "x86_64-apple-darwin") + elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + set(RUST_TARGET_TRIPLE "aarch64-apple-darwin") + elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(RUST_TARGET_TRIPLE "aarch64-apple-darwin") + else() + set(RUST_TARGET_TRIPLE "x86_64-apple-darwin") + endif() + set(RUST_STATICLIB_NAME "libjazzerjs_libafl_runtime.a") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(RUST_TARGET_TRIPLE "x86_64-pc-windows-msvc") + set(RUST_STATICLIB_NAME "jazzerjs_libafl_runtime.lib") +endif() + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CARGO_PROFILE_DIR "debug") + set(CARGO_PROFILE_FLAG "") +else() + set(CARGO_PROFILE_DIR "release") + set(CARGO_PROFILE_FLAG "--release") +endif() + +set(RUST_CRATE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/rust") +set(RUST_TARGET_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargo-target") +set(RUST_STATICLIB_PATH + "${RUST_TARGET_DIR}/${RUST_TARGET_TRIPLE}/${CARGO_PROFILE_DIR}/${RUST_STATICLIB_NAME}") + +add_custom_command( + OUTPUT ${RUST_STATICLIB_PATH} + COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${RUST_TARGET_DIR} + ${CARGO_EXECUTABLE} build --manifest-path ${RUST_CRATE_DIR}/Cargo.toml + --target ${RUST_TARGET_TRIPLE} ${CARGO_PROFILE_FLAG} + WORKING_DIRECTORY ${RUST_CRATE_DIR} + DEPENDS ${RUST_CRATE_DIR}/Cargo.toml ${RUST_CRATE_DIR}/src/lib.rs + COMMENT "Building the LibAFL runtime static library") + +add_custom_target(jazzerjs_libafl_runtime ALL DEPENDS ${RUST_STATICLIB_PATH}) +add_dependencies(${PROJECT_NAME} jazzerjs_libafl_runtime) +target_link_libraries(${PROJECT_NAME} ${RUST_STATICLIB_PATH}) + # We're not sure why but sometimes systems don't end up setting LLVM_TARGET_TRIPLE used in llvm's cmake to eventually # set COMPILER_RT_DEFAULT_TARGET which is necessary for compiler-rt to build # So this will either take it from an envvar or try to set it to a sane value until we can figure out why it's broken diff --git a/packages/fuzzer/README.md b/packages/fuzzer/README.md index 9333484c9..ce8ef55a1 100644 --- a/packages/fuzzer/README.md +++ b/packages/fuzzer/README.md @@ -1,16 +1,17 @@ # @jazzer.js/fuzzer -This module provides a native Node.js addon which loads libfuzzer into Node.js. -Users can install it with `npm install`, which tries to download a prebuilt -shared object from GitHub but falls back to compilation on the user's machine if -there is no suitable binary. - -Loading the addon initializes libFuzzer and the sanitizer runtime. Users can -then start the fuzzer with the exported `startFuzzing` or `startFuzzingAsync` -functions; see [the test](fuzzer.test.ts) for an example. In sync mode -(`--sync`), the fuzzer runs on the main thread and blocks the event loop. In the -default async mode, libFuzzer runs on a separate native thread and communicates -with the JS event loop via a thread-safe function. +This module provides a native Node.js addon that hosts Jazzer.js fuzzing +backends inside Node.js. Users can install it with `npm install`, which tries to +download a prebuilt shared object from GitHub but falls back to compilation on +the user's machine if there is no suitable binary. + +Loading the addon initializes the sanitizer runtime and fuzzing hooks. Users can +start the libFuzzer backend with `startFuzzing` or `startFuzzingAsync`, and the +LibAFL backend with `startLibAfl` or `startLibAflAsync`; see +[the tests](fuzzer.test.ts) for examples. In sync mode (`--sync`), the fuzzer +runs on the main thread and blocks the event loop. In the default async mode, +the native backend runs on a separate thread and communicates with the JS event +loop via a thread-safe function. ## Development diff --git a/packages/fuzzer/addon.cpp b/packages/fuzzer/addon.cpp index b384ed220..e64444ec6 100644 --- a/packages/fuzzer/addon.cpp +++ b/packages/fuzzer/addon.cpp @@ -16,6 +16,7 @@ #include "fuzzing_async.h" #include "fuzzing_sync.h" +#include "libafl_runtime.h" #include "shared/callbacks.h" #include "shared/libfuzzer.h" @@ -61,6 +62,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports["startFuzzing"] = Napi::Function::New(env); exports["startFuzzingAsync"] = Napi::Function::New(env); + exports["startLibAfl"] = Napi::Function::New(env); + exports["startLibAflAsync"] = Napi::Function::New(env); RegisterCallbackExports(env, exports); return exports; diff --git a/packages/fuzzer/addon.ts b/packages/fuzzer/addon.ts index 96d5b4f4b..8ce4db429 100644 --- a/packages/fuzzer/addon.ts +++ b/packages/fuzzer/addon.ts @@ -27,6 +27,18 @@ export type FuzzTargetCallback = ( export type FuzzTarget = FuzzTargetAsyncOrValue | FuzzTargetCallback; export type FuzzOpts = string[]; +export type LibAflOptions = { + mode: "fuzzing" | "regression"; + runs: number; + seed: number; + maxLen: number; + timeoutMillis: number; + maxTotalTimeSeconds: number; + artifactPrefix: string; + corpusDirectories: string[]; + dictionaryFiles: string[]; +}; + export type StartFuzzingSyncFn = ( fuzzFn: FuzzTarget, fuzzOpts: FuzzOpts, @@ -36,11 +48,19 @@ export type StartFuzzingAsyncFn = ( fuzzFn: FuzzTarget, fuzzOpts: FuzzOpts, ) => Promise; +export type StartLibAflSyncFn = ( + fuzzFn: FuzzTarget, + options: LibAflOptions, + jsStopCallback: (signal: number) => void, +) => Promise; +export type StartLibAflAsyncFn = ( + fuzzFn: FuzzTarget, + options: LibAflOptions, +) => Promise; type NativeAddon = { registerCoverageMap: (buffer: Buffer) => void; registerNewCounters: (oldNumCounters: number, newNumCounters: number) => void; - registerModuleCounters: (buffer: Buffer) => void; traceUnequalStrings: ( hookId: number, @@ -67,6 +87,17 @@ type NativeAddon = { startFuzzing: StartFuzzingSyncFn; startFuzzingAsync: StartFuzzingAsyncFn; + startLibAfl?: StartLibAflSyncFn; + startLibAflAsync?: StartLibAflAsyncFn; + clearCompareFeedbackMap: () => void; + countNonZeroCompareFeedbackSlots: () => number; + countCompareLogEntries: () => number; + countDroppedCompareLogEntries: () => number; +}; + +type LoadedAddon = NativeAddon & { + startLibAfl: StartLibAflSyncFn; + startLibAflAsync: StartLibAflAsyncFn; }; function addonFilename(): string { @@ -81,4 +112,12 @@ function addonFilename(): string { return path.join(dirName, `fuzzer-${process.platform}-${process.arch}.node`); } -export const addon: NativeAddon = require(addonFilename()); +const loadedAddon = require(addonFilename()) as NativeAddon; + +if (!loadedAddon.startLibAfl || !loadedAddon.startLibAflAsync) { + throw new Error( + "The native addon does not export startLibAfl/startLibAflAsync", + ); +} + +export const addon: LoadedAddon = loadedAddon as LoadedAddon; diff --git a/packages/fuzzer/coverage.ts b/packages/fuzzer/coverage.ts index acc807686..5efc3bc42 100644 --- a/packages/fuzzer/coverage.ts +++ b/packages/fuzzer/coverage.ts @@ -16,17 +16,23 @@ import { addon } from "./addon"; +type CoverageRangeAllocator = (filename: string, edgeCount: number) => number; + +function getCoverageRangeAllocator(): CoverageRangeAllocator { + const allocator = (globalThis as Record) + .__jazzer_reserveCoverageRange; + if (typeof allocator !== "function") { + throw new Error("Coverage range allocator was not initialized"); + } + return allocator as CoverageRangeAllocator; +} + export class CoverageTracker { private static readonly MAX_NUM_COUNTERS: number = 1 << 20; private static readonly INITIAL_NUM_COUNTERS: number = 1 << 9; private readonly coverageMap: Buffer; private currentNumCounters: number; - // Per-module counter buffers registered independently with libFuzzer. - // We must prevent GC from reclaiming these while libFuzzer still - // monitors the underlying memory. - private readonly moduleCounters: Buffer[] = []; - constructor() { this.coverageMap = Buffer.alloc(CoverageTracker.MAX_NUM_COUNTERS, 0); this.currentNumCounters = CoverageTracker.INITIAL_NUM_COUNTERS; @@ -71,16 +77,17 @@ export class CoverageTracker { return this.coverageMap.readUint8(edgeId); } - /** - * Allocate an independent counter buffer for a single module and - * register it with libFuzzer as a new coverage region. This lets - * each ESM module own its own counters without sharing global IDs. - */ - createModuleCounters(size: number): Buffer { - const buf = Buffer.alloc(size, 0); - this.moduleCounters.push(buf); - addon.registerModuleCounters(buf); - return buf; + createModuleCounters(filename: string, edgeCount: number): Buffer { + if (!Number.isInteger(edgeCount) || edgeCount < 0) { + throw new Error(`Invalid edge count: ${edgeCount}`); + } + if (edgeCount === 0) { + return Buffer.alloc(0); + } + + const firstEdgeId = getCoverageRangeAllocator()(filename, edgeCount); + this.enlargeCountersBufferIfNeeded(firstEdgeId + edgeCount - 1); + return this.coverageMap.subarray(firstEdgeId, firstEdgeId + edgeCount); } } diff --git a/packages/fuzzer/fuzzer.ts b/packages/fuzzer/fuzzer.ts index 1330bb44a..a00da42d2 100644 --- a/packages/fuzzer/fuzzer.ts +++ b/packages/fuzzer/fuzzer.ts @@ -22,6 +22,7 @@ export type { FuzzTarget, FuzzTargetAsyncOrValue, FuzzTargetCallback, + LibAflOptions, } from "./addon"; export interface Fuzzer { @@ -29,6 +30,8 @@ export interface Fuzzer { tracer: Tracer; startFuzzing: typeof addon.startFuzzing; startFuzzingAsync: typeof addon.startFuzzingAsync; + startLibAfl: typeof addon.startLibAfl; + startLibAflAsync: typeof addon.startLibAflAsync; printAndDumpCrashingInput: typeof addon.printAndDumpCrashingInput; printReturnInfo: typeof addon.printReturnInfo; } @@ -38,6 +41,8 @@ export const fuzzer: Fuzzer = { tracer: tracer, startFuzzing: addon.startFuzzing, startFuzzingAsync: addon.startFuzzingAsync, + startLibAfl: addon.startLibAfl, + startLibAflAsync: addon.startLibAflAsync, printAndDumpCrashingInput: addon.printAndDumpCrashingInput, printReturnInfo: addon.printReturnInfo, }; diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp new file mode 100644 index 000000000..694b0f7c9 --- /dev/null +++ b/packages/fuzzer/libafl_runtime.cpp @@ -0,0 +1,1297 @@ +// Copyright 2026 Code Intelligence GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "libafl_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#define GetPID _getpid +#else +#include +#define GetPID getpid +#endif + +#include "shared/coverage.h" +#include "shared/libfuzzer.h" +#include "shared/tracing.h" +#include "utils.h" + +namespace { +constexpr int kExecutionContinue = 0; +constexpr int kExecutionFinding = 1; +constexpr int kExecutionStop = 2; +constexpr int kExecutionFatal = 3; +constexpr int kExecutionTimeout = 4; + +constexpr int kRuntimeOk = 0; +constexpr int kRuntimeFoundFinding = 1; +constexpr int kRuntimeStopped = 2; +constexpr int kRuntimeFatal = 3; +constexpr int kRuntimeFoundTimeout = 4; + +struct ParsedRuntimeOptions { + enum class Mode { + kFuzzing, + kRegression, + }; + + Mode mode = Mode::kFuzzing; + uint64_t runs = 0; + uint64_t seed = 1; + size_t max_len = 4096; + uint64_t timeout_millis = 5000; + uint64_t max_total_time_seconds = 0; + std::string artifact_prefix; + std::vector corpus_directories; + std::vector dictionary_files; +}; + +std::string FormatDuration(std::chrono::steady_clock::duration duration) { + const auto total_seconds = + std::chrono::duration_cast(duration).count(); + const auto hours = total_seconds / 3600; + const auto minutes = (total_seconds % 3600) / 60; + const auto seconds = total_seconds % 60; + + std::ostringstream stream; + if (hours > 0) { + stream << hours << "h " << minutes << "m " << seconds << "s"; + } else if (minutes > 0) { + stream << minutes << "m " << seconds << "s"; + } else { + stream << seconds << "s"; + } + return stream.str(); +} + +std::string FormatRunLimit(uint64_t runs) { + if (runs == 0) { + return "unlimited"; + } + + return std::to_string(runs); +} + +std::string FormatTotalTimeLimit(uint64_t max_total_time_seconds) { + if (max_total_time_seconds == 0) { + return "unlimited"; + } + + return FormatDuration(std::chrono::seconds(max_total_time_seconds)); +} + +bool ShouldColorizeOutput() { + if (std::getenv("NO_COLOR") != nullptr) { + return false; + } + + const auto *term = std::getenv("TERM"); + if (term != nullptr && std::string(term) == "dumb") { + return false; + } + +#ifdef _WIN32 + return _isatty(_fileno(stderr)) != 0; +#else + return isatty(fileno(stderr)) != 0; +#endif +} + +std::string StartMarker() { + if (!ShouldColorizeOutput()) { + return "[>]"; + } + + return "\x1b[34m[>]\x1b[0m"; +} + +std::string FormatInitedField(const std::string &label, + const std::string &value) { + const auto first = value.find_first_not_of(' '); + const auto trimmed = first == std::string::npos + ? std::string_view("") + : std::string_view(value).substr(first); + std::ostringstream stream; + stream << " " << std::left << std::setw(15) << label << ' ' << trimmed; + return stream.str(); +} + +std::string EmptyEdgesMetric() { return " -/ - ( -%)"; } + +void PrintRegressionStart(const ParsedRuntimeOptions &options, + size_t replay_inputs) { + std::cerr + << StartMarker() << " INITED\n" + << FormatInitedField("mode:", "regression") << '\n' + << FormatInitedField("seed:", std::to_string(options.seed)) << '\n' + << FormatInitedField("loaded_inputs:", std::to_string(replay_inputs)) + << '\n' + << FormatInitedField("edges:", EmptyEdgesMetric()) << '\n' + << FormatInitedField("timeout:", + std::to_string(options.timeout_millis) + " ms") + << '\n' + << FormatInitedField("max_len:", std::to_string(options.max_len)) << '\n' + << FormatInitedField("runs:", FormatRunLimit(options.runs)) << '\n' + << FormatInitedField("max_total_time:", + FormatTotalTimeLimit(options.max_total_time_seconds)) + << std::endl; +} + +void PrintRegressionDone(std::chrono::steady_clock::time_point started_at, + uint64_t executions, size_t replay_inputs) { + const auto elapsed = std::chrono::steady_clock::now() - started_at; + const auto elapsed_seconds = std::chrono::duration(elapsed).count(); + const auto execs_per_sec = elapsed_seconds > 0.0 + ? executions / elapsed_seconds + : static_cast(executions); + + std::cerr << "[libafl::done] mode: regression, run time: " + << FormatDuration(elapsed) << ", replay_inputs: " << replay_inputs + << ", executions: " << executions + << ", exec/sec: " << static_cast(execs_per_sec) + << std::endl; +} + +struct SyncWatchdogState { + std::thread thread; + std::mutex mutex; + std::condition_variable cv; + bool should_stop = false; + bool execution_armed = false; + std::chrono::steady_clock::time_point deadline; + std::vector current_input; +}; + +struct SyncFuzzTargetContext { + SyncFuzzTargetContext(Napi::Env env, Napi::Function target, + Napi::Function js_stop_callback, + ParsedRuntimeOptions options) + : env(env), target(target), is_resolved(false), + deferred(Napi::Promise::Deferred::New(env)), + js_stop_callback(js_stop_callback), options(std::move(options)) {} + + Napi::Env env; + Napi::Function target; + bool is_resolved; + Napi::Promise::Deferred deferred; + Napi::Function js_stop_callback; + ParsedRuntimeOptions options; + SyncWatchdogState watchdog; + volatile std::sig_atomic_t signal_status = 0; + volatile int sigints = 0; + std::jmp_buf execution_context; +}; + +struct AsyncExecutionState { + std::promise promise; + std::atomic settled = false; +}; + +struct AsyncDataType { + std::vector data; + std::shared_ptr state; + + AsyncDataType() = delete; +}; + +struct AsyncFuzzTargetContext { + explicit AsyncFuzzTargetContext(Napi::Env env, ParsedRuntimeOptions options) + : deferred(Napi::Promise::Deferred::New(env)), + options(std::move(options)) {} + + std::thread native_thread; + Napi::Promise::Deferred deferred; + ParsedRuntimeOptions options; + bool is_resolved = false; + bool is_done_called = false; + int run_status = kRuntimeOk; + volatile int sigints = 0; + std::jmp_buf execution_context; +}; + +using AsyncFinalizerDataType = void; +void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, + AsyncFuzzTargetContext *context, AsyncDataType *data); +using AsyncTsfn = + Napi::TypedThreadSafeFunction; + +SyncFuzzTargetContext *gActiveSyncContext = nullptr; +AsyncFuzzTargetContext *gActiveAsyncContext = nullptr; +AsyncTsfn gAsyncTsfn; +JazzerLibAflFindingInfo gFindingInfo{}; + +void ClearFindingInfo() { std::memset(&gFindingInfo, 0, sizeof(gFindingInfo)); } + +void CopyFindingField(char *destination, size_t destination_size, + const std::string &value) { + if (destination == nullptr || destination_size == 0) { + return; + } + + std::memset(destination, 0, destination_size); + const auto copied = std::min(destination_size - 1, value.size()); + if (copied > 0) { + std::memcpy(destination, value.data(), copied); + } +} + +std::string CollapseWhitespace(const std::string &value) { + std::string collapsed; + collapsed.reserve(value.size()); + + bool previous_was_space = false; + for (const auto character : value) { + if (std::isspace(static_cast(character)) != 0) { + if (!collapsed.empty() && !previous_was_space) { + collapsed.push_back(' '); + } + previous_was_space = true; + continue; + } + + collapsed.push_back(character); + previous_was_space = false; + } + + if (!collapsed.empty() && collapsed.back() == ' ') { + collapsed.pop_back(); + } + + return collapsed; +} + +std::string TrimStackFrame(const std::string &frame) { + const auto first = frame.find_first_not_of(" \t"); + if (first == std::string::npos) { + return ""; + } + + auto trimmed = frame.substr(first); + constexpr char kAtPrefix[] = "at "; + if (trimmed.rfind(kAtPrefix, 0) == 0) { + trimmed.erase(0, sizeof(kAtPrefix) - 1); + } + + if (!trimmed.empty() && trimmed.back() == ')') { + const auto open_paren = trimmed.rfind('('); + if (open_paren != std::string::npos && open_paren + 1 < trimmed.size()) { + return trimmed.substr(open_paren + 1, trimmed.size() - open_paren - 2); + } + } + + return trimmed; +} + +std::string DescribeJsError(Napi::Env env, const Napi::Value &error) { + std::string summary = error.ToString().Utf8Value(); + if (!error.IsObject()) { + return CollapseWhitespace(summary); + } + + const auto stack_value = error.As().Get("stack"); + if (!stack_value.IsString()) { + return CollapseWhitespace(summary); + } + + std::istringstream stream(stack_value.As().Utf8Value()); + std::string line; + std::getline(stream, line); + while (std::getline(stream, line)) { + const auto frame = TrimStackFrame(line); + if (frame.empty()) { + continue; + } + summary.append(" in ").append(frame); + break; + } + + return CollapseWhitespace(summary); +} + +std::string DescribeTimeout(uint64_t timeout_millis) { + return "timeout after " + std::to_string(timeout_millis) + " ms"; +} + +void RecordFindingInfo(const std::string &artifact, + const std::string &summary) { + gFindingInfo.has_value = 1; + CopyFindingField(gFindingInfo.artifact, sizeof(gFindingInfo.artifact), + artifact); + CopyFindingField(gFindingInfo.summary, sizeof(gFindingInfo.summary), summary); +} + +std::string DigestInput(const uint8_t *data, size_t size) { + uint64_t hash = 1469598103934665603ULL; + for (size_t i = 0; i < size; ++i) { + hash ^= static_cast(data[i]); + hash *= 1099511628211ULL; + } + + std::array words{}; + for (auto &word : words) { + hash ^= hash >> 33; + hash *= 0xff51afd7ed558ccdULL; + hash ^= hash >> 33; + hash *= 0xc4ceb9fe1a85ec53ULL; + hash ^= hash >> 33; + word = static_cast(hash); + } + + std::ostringstream stream; + stream << std::hex << std::setfill('0'); + for (const auto word : words) { + stream << std::setw(8) << word; + } + return stream.str(); +} + +std::filesystem::path ArtifactPath(const std::string &artifact_prefix, + const std::string &kind, + const std::string &digest) { + const auto filename = kind + "-" + digest; + + if (artifact_prefix.empty()) { + return std::filesystem::current_path() / filename; + } + + const auto has_directory_semantics = + artifact_prefix.back() == '/' || artifact_prefix.back() == '\\'; + std::filesystem::path prefix_path(artifact_prefix); + if (has_directory_semantics || (std::filesystem::exists(prefix_path) && + std::filesystem::is_directory(prefix_path))) { + return prefix_path / filename; + } + + return std::filesystem::path(artifact_prefix + filename); +} + +std::string WriteArtifact(const std::string &artifact_prefix, + const std::string &kind, const uint8_t *data, + size_t size, bool emit_info = true) { + if (data == nullptr && size != 0) { + return ""; + } + + try { + const auto digest = DigestInput(data, size); + const auto artifact_path = ArtifactPath(artifact_prefix, kind, digest); + + if (!artifact_path.parent_path().empty()) { + std::filesystem::create_directories(artifact_path.parent_path()); + } + + std::ofstream output(artifact_path, + std::ios::binary | std::ios::out | std::ios::trunc); + if (!output.is_open()) { + std::cerr << "ERROR: Failed to open artifact file '" + << artifact_path.string() << "'" << std::endl; + return ""; + } + + if (size > 0) { + output.write(reinterpret_cast(data), + static_cast(size)); + } + if (!output.good()) { + std::cerr << "ERROR: Failed to write artifact file '" + << artifact_path.string() << "'" << std::endl; + return ""; + } + + if (emit_info) { + std::cerr << "INFO: Wrote " << kind << " input to " + << artifact_path.string() << std::endl; + } + return artifact_path.filename().string(); + } catch (const std::exception &exception) { + std::cerr << "ERROR: Failed to persist " << kind + << " artifact: " << exception.what() << std::endl; + return ""; + } +} + +[[noreturn]] void ExitOnTimeout(uint64_t timeout_millis, + const std::string &artifact_prefix, + const std::vector &input) { + std::cerr << "ERROR: Exceeded timeout of " << timeout_millis + << " ms for one fuzz target execution." << std::endl; + const auto artifact = + WriteArtifact(artifact_prefix, "timeout", input.data(), input.size()); + RecordFindingInfo(artifact, DescribeTimeout(timeout_millis)); + _Exit(libfuzzer::EXIT_ERROR_TIMEOUT); +} + +[[noreturn]] void ExitWithUnexpectedError(const std::exception &exception) { + std::cerr << "==" << static_cast(GetPID()) + << "== Jazzer.js: Unexpected Error: " << exception.what() + << std::endl; + libfuzzer::PrintCrashingInput(); + _Exit(libfuzzer::EXIT_ERROR_CODE); +} + +void RejectDeferredIfNeeded(AsyncFuzzTargetContext *context, + const Napi::Value &error) { + if (context->is_resolved) { + return; + } + context->deferred.Reject(error); + context->is_resolved = true; +} + +bool TrySetExecutionStatus(const std::shared_ptr &state, + int status) { + bool expected = false; + if (!state->settled.compare_exchange_strong(expected, true, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + return false; + } + state->promise.set_value(status); + return true; +} + +void ReportAsyncFinding(AsyncFuzzTargetContext *context, Napi::Env env, + const std::shared_ptr &state, + const Napi::Value &error, + const std::vector &input) { + if (TrySetExecutionStatus(state, kExecutionFinding)) { + const auto artifact = + WriteArtifact(context->options.artifact_prefix, "crash", input.data(), + input.size(), false); + RecordFindingInfo(artifact, DescribeJsError(env, error)); + } + RejectDeferredIfNeeded(context, error); +} + +ParsedRuntimeOptions ParseRuntimeOptions(Napi::Env env, + const Napi::Object &js_opts) { + ParsedRuntimeOptions parsed; + + const auto mode = js_opts.Get("mode"); + const auto runs = js_opts.Get("runs"); + const auto seed = js_opts.Get("seed"); + const auto max_len = js_opts.Get("maxLen"); + const auto timeout_millis = js_opts.Get("timeoutMillis"); + const auto max_total_time_seconds = js_opts.Get("maxTotalTimeSeconds"); + const auto artifact_prefix = js_opts.Get("artifactPrefix"); + const auto corpus_directories = js_opts.Get("corpusDirectories"); + const auto dictionary_files = js_opts.Get("dictionaryFiles"); + + if (!mode.IsUndefined() && !mode.IsString()) { + throw Napi::Error::New( + env, "The LibAFL options object expects mode to be 'fuzzing' or " + "'regression'"); + } + + if (!runs.IsNumber() || !seed.IsNumber() || !max_len.IsNumber() || + !timeout_millis.IsNumber() || !max_total_time_seconds.IsNumber() || + !artifact_prefix.IsString() || !corpus_directories.IsArray() || + !dictionary_files.IsArray()) { + throw Napi::Error::New( + env, "The LibAFL backend expects an options object with mode, runs, " + "seed, maxLen, timeoutMillis, maxTotalTimeSeconds, " + "artifactPrefix, corpusDirectories, and dictionaryFiles"); + } + + if (mode.IsString()) { + const auto mode_value = mode.As().Utf8Value(); + if (mode_value == "regression") { + parsed.mode = ParsedRuntimeOptions::Mode::kRegression; + } else if (mode_value == "fuzzing") { + parsed.mode = ParsedRuntimeOptions::Mode::kFuzzing; + } else { + throw Napi::Error::New( + env, "The LibAFL options object expects mode to be 'fuzzing' or " + "'regression'"); + } + } + + const auto runs_value = runs.As().Int64Value(); + const auto seed_value = seed.As().Int64Value(); + const auto max_len_value = max_len.As().Int64Value(); + const auto timeout_millis_value = + timeout_millis.As().Int64Value(); + const auto max_total_time_seconds_value = + max_total_time_seconds.As().Int64Value(); + + if (runs_value < 0 || seed_value < 0 || max_len_value < 0 || + timeout_millis_value < 0 || max_total_time_seconds_value < 0) { + throw Napi::Error::New( + env, "The LibAFL options object does not allow negative values"); + } + + parsed.runs = static_cast(runs_value); + parsed.seed = static_cast(seed_value); + parsed.max_len = static_cast(max_len_value); + parsed.timeout_millis = static_cast(timeout_millis_value); + parsed.max_total_time_seconds = + static_cast(max_total_time_seconds_value); + parsed.artifact_prefix = artifact_prefix.As().Utf8Value(); + + const auto dirs = corpus_directories.As(); + for (uint32_t i = 0; i < dirs.Length(); ++i) { + auto dir = dirs.Get(i); + if (!dir.IsString()) { + throw Napi::Error::New( + env, "LibAFL corpusDirectories entries must be strings"); + } + parsed.corpus_directories.push_back(dir.As().Utf8Value()); + } + + const auto dicts = dictionary_files.As(); + for (uint32_t i = 0; i < dicts.Length(); ++i) { + auto dict = dicts.Get(i); + if (!dict.IsString()) { + throw Napi::Error::New(env, + "LibAFL dictionaryFiles entries must be strings"); + } + parsed.dictionary_files.push_back(dict.As().Utf8Value()); + } + + if (parsed.max_len == 0) { + throw Napi::Error::New(env, "The LibAFL backend requires maxLen to be > 0"); + } + if (parsed.timeout_millis == 0) { + throw Napi::Error::New( + env, "The LibAFL backend requires timeoutMillis to be > 0"); + } + + return parsed; +} + +JazzerLibAflRuntimeSharedMaps SharedMapsForRuntime(Napi::Env env) { + auto *edges = CoverageCounters(); + const auto edges_capacity = CoverageCountersCapacity(); + auto *edges_size = CoverageCountersSizePointer(); + auto *cmp = CompareFeedbackMap(); + const auto cmp_len = CompareFeedbackMapSize(); + auto *compare_log = CompareLog(); + auto *finding_info = &gFindingInfo; + + if (edges == nullptr || edges_capacity == 0 || edges_size == nullptr || + cmp == nullptr || cmp_len == 0 || compare_log == nullptr || + finding_info == nullptr) { + throw Napi::Error::New( + env, + "Coverage maps were not initialized before the LibAFL backend started"); + } + + return {edges, edges_capacity, edges_size, cmp, + cmp_len, compare_log, finding_info}; +} + +bool CollectRegressionCorpusFiles( + const std::vector &corpus_directories, + std::vector *files) { + for (const auto &directory : corpus_directories) { + const std::filesystem::path directory_path(directory); + std::error_code error; + + if (!std::filesystem::exists(directory_path, error)) { + if (error) { + std::cerr << "[libafl] fatal: failed to access corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + } else { + std::cerr << "[libafl] fatal: corpus directory does not exist: '" + << directory_path.string() << "'" << std::endl; + } + return false; + } + + if (!std::filesystem::is_directory(directory_path, error)) { + if (error) { + std::cerr << "[libafl] fatal: failed to inspect corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + } else { + std::cerr << "[libafl] fatal: corpus path is not a directory: '" + << directory_path.string() << "'" << std::endl; + } + return false; + } + + std::filesystem::recursive_directory_iterator iterator( + directory_path, + std::filesystem::directory_options::skip_permission_denied, error); + const auto end = std::filesystem::recursive_directory_iterator(); + if (error) { + std::cerr << "[libafl] fatal: failed to iterate corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + return false; + } + + for (; iterator != end; iterator.increment(error)) { + if (error) { + std::cerr << "[libafl] fatal: failed to iterate corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + return false; + } + + const auto is_regular_file = iterator->is_regular_file(error); + if (error) { + std::cerr << "[libafl] fatal: failed to inspect corpus entry '" + << iterator->path().string() << "': " << error.message() + << std::endl; + return false; + } + if (is_regular_file) { + files->push_back(iterator->path()); + } + } + } + + std::sort(files->begin(), files->end()); + return true; +} + +bool ReadRegressionInput(const std::filesystem::path &file_path, size_t max_len, + std::vector *input) { + input->clear(); + std::ifstream stream(file_path, std::ios::binary); + if (!stream.is_open()) { + std::cerr << "[libafl] fatal: failed to open corpus input '" + << file_path.string() << "'" << std::endl; + return false; + } + + constexpr size_t kChunkSize = 4096; + std::array buffer{}; + while (stream.good() && input->size() < max_len) { + const auto remaining = max_len - input->size(); + const auto to_read = static_cast( + std::min(remaining, buffer.size())); + stream.read(buffer.data(), to_read); + const auto bytes_read = stream.gcount(); + if (bytes_read <= 0) { + break; + } + input->insert(input->end(), buffer.begin(), buffer.begin() + bytes_read); + } + + if (stream.bad()) { + std::cerr << "[libafl] fatal: failed to read corpus input '" + << file_path.string() << "'" << std::endl; + return false; + } + + return true; +} + +bool ReachedMaxTotalTime(const ParsedRuntimeOptions &options, + std::chrono::steady_clock::time_point started_at) { + if (options.max_total_time_seconds == 0) { + return false; + } + return std::chrono::steady_clock::now() - started_at >= + std::chrono::seconds(options.max_total_time_seconds); +} + +int ReplayRegressionInputs( + const ParsedRuntimeOptions &options, + const std::function &execute_one) { + std::vector corpus_files; + if (!CollectRegressionCorpusFiles(options.corpus_directories, + &corpus_files)) { + return kRuntimeFatal; + } + + const auto started_at = std::chrono::steady_clock::now(); + const auto replay_inputs = corpus_files.size() + 1; + uint64_t executions = 0; + static constexpr uint8_t kEmptyInputByte = 0; + std::vector current_input; + + PrintRegressionStart(options, replay_inputs); + + auto execute_input = [&](const uint8_t *data, size_t size) -> int { + if (options.runs != 0 && executions >= options.runs) { + return kRuntimeOk; + } + if (ReachedMaxTotalTime(options, started_at)) { + return kRuntimeStopped; + } + + const auto status = execute_one(data, size); + executions++; + switch (status) { + case kExecutionContinue: + return kRuntimeOk; + case kExecutionFinding: + return kRuntimeFoundFinding; + case kExecutionStop: + return kRuntimeStopped; + case kExecutionFatal: + return kRuntimeFatal; + case kExecutionTimeout: + return kRuntimeFoundTimeout; + default: + std::cerr << "[libafl] fatal: unknown execution status: " << status + << std::endl; + return kRuntimeFatal; + } + }; + + auto status = execute_input(&kEmptyInputByte, 0); + if (status != kRuntimeOk) { + if (status == kRuntimeStopped) { + PrintRegressionDone(started_at, executions, replay_inputs); + } + return status; + } + + for (const auto &file_path : corpus_files) { + if (!ReadRegressionInput(file_path, options.max_len, ¤t_input)) { + return kRuntimeFatal; + } + + const auto *data = + current_input.empty() ? &kEmptyInputByte : current_input.data(); + status = execute_input(data, current_input.size()); + if (status != kRuntimeOk) { + if (status == kRuntimeStopped) { + PrintRegressionDone(started_at, executions, replay_inputs); + } + return status; + } + } + + PrintRegressionDone(started_at, executions, replay_inputs); + return kRuntimeOk; +} + +void StartSyncWatchdog(SyncFuzzTargetContext *context) { + if (context->options.timeout_millis == 0) { + return; + } + + context->watchdog.thread = std::thread([context]() { + auto &watchdog = context->watchdog; + std::unique_lock lock(watchdog.mutex); + while (true) { + watchdog.cv.wait(lock, [&watchdog] { + return watchdog.should_stop || watchdog.execution_armed; + }); + + if (watchdog.should_stop) { + return; + } + + const auto deadline = watchdog.deadline; + const auto resumed = + watchdog.cv.wait_until(lock, deadline, [&watchdog, deadline] { + return watchdog.should_stop || !watchdog.execution_armed || + watchdog.deadline != deadline; + }); + if (resumed) { + if (watchdog.should_stop) { + return; + } + continue; + } + + auto timed_out_input = watchdog.current_input; + lock.unlock(); + ExitOnTimeout(context->options.timeout_millis, + context->options.artifact_prefix, timed_out_input); + } + }); +} + +void ArmSyncWatchdog(SyncFuzzTargetContext *context, const uint8_t *data, + size_t size) { + if (context->options.timeout_millis == 0) { + return; + } + + auto &watchdog = context->watchdog; + std::lock_guard lock(watchdog.mutex); + watchdog.current_input.assign(data, data + size); + watchdog.deadline = + std::chrono::steady_clock::now() + + std::chrono::milliseconds(context->options.timeout_millis); + watchdog.execution_armed = true; + watchdog.cv.notify_one(); +} + +void DisarmSyncWatchdog(SyncFuzzTargetContext *context) { + if (context->options.timeout_millis == 0) { + return; + } + + auto &watchdog = context->watchdog; + std::lock_guard lock(watchdog.mutex); + watchdog.execution_armed = false; + watchdog.current_input.clear(); + watchdog.cv.notify_one(); +} + +void StopSyncWatchdog(SyncFuzzTargetContext *context) { + if (context->options.timeout_millis == 0) { + return; + } + + auto &watchdog = context->watchdog; + { + std::lock_guard lock(watchdog.mutex); + watchdog.should_stop = true; + watchdog.execution_armed = false; + } + watchdog.cv.notify_one(); + if (watchdog.thread.joinable()) { + watchdog.thread.join(); + } +} + +class ScopedSyncWatchdog { +public: + ScopedSyncWatchdog(SyncFuzzTargetContext *context, const uint8_t *data, + size_t size) + : context_(context) { + ArmSyncWatchdog(context_, data, size); + } + + ~ScopedSyncWatchdog() { DisarmSyncWatchdog(context_); } + +private: + SyncFuzzTargetContext *context_; +}; + +void SyncSigintHandler(int signum) { + std::cerr << std::endl; + gActiveSyncContext->signal_status = signum; + if (gActiveSyncContext->sigints > 0) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + gActiveSyncContext->sigints++; +} + +void SyncErrorSignalHandler(int signum) { + gActiveSyncContext->signal_status = signum; + std::longjmp(gActiveSyncContext->execution_context, signum); +} + +int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { + auto *context = static_cast(user_data); + auto scope = Napi::HandleScope(context->env); + ScopedSyncWatchdog watchdog(context, data, size); + + ClearCoverageCounters(); + ClearCompareFeedbackMap(); + + try { + auto buffer = Napi::Buffer::Copy(context->env, data, size); + if (setjmp(context->execution_context) == 0) { + auto result = context->target.Call({buffer}); + if (result.IsPromise()) { + AsyncReturnsHandler(); + } else { + SyncReturnsHandler(); + } + } + } catch (const Napi::Error &error) { + if (!context->is_resolved) { + const auto artifact = WriteArtifact(context->options.artifact_prefix, + "crash", data, size, false); + RecordFindingInfo(artifact, DescribeJsError(context->env, error.Value())); + context->is_resolved = true; + context->deferred.Reject(error.Value()); + } + return kExecutionFinding; + } catch (const std::exception &exception) { + ExitWithUnexpectedError(exception); + } + + if (context->signal_status != 0) { + if (context->signal_status == SIGSEGV) { + std::cerr << "==" << static_cast(GetPID()) + << "== Segmentation Fault" << std::endl; + libfuzzer::PrintCrashingInput(); + _Exit(libfuzzer::EXIT_ERROR_SEGV); + } + + auto exit_code = Napi::Number::New(context->env, 0); + if (context->signal_status != SIGINT) { + exit_code = Napi::Number::New(context->env, context->signal_status); + } + + context->js_stop_callback.Call({exit_code}); + context->signal_status = 0; + return kExecutionStop; + } + + return kExecutionContinue; +} + +void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, + AsyncFuzzTargetContext *context, AsyncDataType *input) { + auto state = input->state; + const auto current_input = input->data; + + try { + if (context->sigints > 0) { + TrySetExecutionStatus(state, kExecutionStop); + context->deferred.Resolve(env.Undefined()); + context->is_resolved = true; + return; + } + + if (setjmp(context->execution_context) == SIGSEGV) { + std::cerr << "==" << static_cast(GetPID()) + << "== Segmentation Fault" << std::endl; + libfuzzer::PrintCrashingInput(); + _Exit(libfuzzer::EXIT_ERROR_SEGV); + } + + if (env == nullptr) { + TrySetExecutionStatus(state, kExecutionFatal); + return; + } + + auto buffer = Napi::Buffer::Copy(env, current_input.data(), + current_input.size()); + auto parameter_count = js_fuzz_callback.As() + .Get("length") + .As() + .Int32Value(); + + if (parameter_count > 1) { + context->is_done_called = false; + auto done = Napi::Function::New(env, [=](const Napi::CallbackInfo &info) { + if (context->is_resolved) { + return; + } + + if (context->is_done_called) { + auto error = + Napi::Error::New(env, "Expected done to be called once, but it " + "was called multiple times.") + .Value(); + ReportAsyncFinding(context, env, state, error, current_input); + return; + } + + context->is_done_called = true; + const auto has_error = + info.Length() > 0 && !(info[0].IsNull() || info[0].IsUndefined()); + if (has_error) { + auto error = info[0]; + if (!error.IsObject()) { + error = Napi::Error::New(env, error.ToString()).Value(); + } + ReportAsyncFinding(context, env, state, error, current_input); + } else { + TrySetExecutionStatus(state, kExecutionContinue); + } + }); + + auto result = js_fuzz_callback.Call({buffer, done}); + if (result.IsPromise()) { + AsyncReturnsHandler(); + auto error = + Napi::Error::New(env, "Internal fuzzer error - Either async or " + "done callback based fuzz tests allowed.") + .Value(); + ReportAsyncFinding(context, env, state, error, current_input); + } else { + SyncReturnsHandler(); + } + return; + } + + auto result = js_fuzz_callback.Call({buffer}); + if (result.IsPromise()) { + AsyncReturnsHandler(); + auto js_promise = result.As(); + auto then = js_promise.Get("then").As(); + then.Call(js_promise, + {Napi::Function::New(env, + [=](const Napi::CallbackInfo &) { + TrySetExecutionStatus( + state, kExecutionContinue); + }), + Napi::Function::New(env, [=](const Napi::CallbackInfo &info) { + auto error = + info.Length() > 0 + ? info[0] + : Napi::Error::New(env, "Unknown promise rejection") + .Value(); + if (!error.IsObject()) { + error = Napi::Error::New(env, error.ToString()).Value(); + } + ReportAsyncFinding(context, env, state, error, + current_input); + })}); + } else { + SyncReturnsHandler(); + TrySetExecutionStatus(state, kExecutionContinue); + } + } catch (const Napi::Error &error) { + ReportAsyncFinding(context, env, state, error.Value(), current_input); + } catch (const std::exception &exception) { + TrySetExecutionStatus(state, kExecutionFatal); + auto message = + std::string("Internal fuzzer error - ").append(exception.what()); + RejectDeferredIfNeeded(context, Napi::Error::New(env, message).Value()); + } +} + +void AsyncSigintHandler(int signum) { + std::cerr << std::endl; + if (gActiveAsyncContext->sigints > 0) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + gActiveAsyncContext->sigints = signum; +} + +void AsyncErrorSignalHandler(int signum) { + std::longjmp(gActiveAsyncContext->execution_context, signum); +} + +int ExecuteAsyncInput(void *user_data, const uint8_t *data, size_t size) { + auto *context = static_cast(user_data); + + ClearCoverageCounters(); + ClearCompareFeedbackMap(); + + auto execution_state = std::make_shared(); + auto *input = new AsyncDataType{ + std::vector(data, data + size), + execution_state, + }; + + auto future = execution_state->promise.get_future(); + auto status = gAsyncTsfn.BlockingCall(input); + if (status != napi_ok) { + delete input; + Napi::Error::Fatal("StartLibAflAsync", + "TypedThreadSafeFunction.BlockingCall() failed"); + } + + if (context->options.timeout_millis > 0) { + auto timeout = std::chrono::milliseconds(context->options.timeout_millis); + if (future.wait_for(timeout) == std::future_status::timeout) { + ExitOnTimeout(context->options.timeout_millis, + context->options.artifact_prefix, input->data); + } + } + + try { + auto result = future.get(); + delete input; + return result; + } catch (const std::exception &exception) { + delete input; + ExitWithUnexpectedError(exception); + } +} +} // namespace + +Napi::Value StartLibAfl(const Napi::CallbackInfo &info) { + if (info.Length() != 3 || !info[0].IsFunction() || !info[1].IsObject() || + !info[2].IsFunction()) { + throw Napi::Error::New( + info.Env(), + "Need three arguments, which must be the fuzz target function, a " + "LibAFL options object, and a stop callback"); + } + + auto options = ParseRuntimeOptions(info.Env(), info[1].As()); + auto maps = SharedMapsForRuntime(info.Env()); + + SyncFuzzTargetContext context(info.Env(), info[0].As(), + info[2].As(), + std::move(options)); + gActiveSyncContext = &context; + + StartSyncWatchdog(&context); + signal(SIGINT, SyncSigintHandler); + signal(SIGSEGV, SyncErrorSignalHandler); + + auto status = kRuntimeOk; + if (context.options.mode == ParsedRuntimeOptions::Mode::kRegression) { + status = ReplayRegressionInputs( + context.options, [&context](const uint8_t *data, size_t size) { + return ExecuteSyncInput(&context, data, size); + }); + } else { + std::vector corpus_directories; + corpus_directories.reserve(context.options.corpus_directories.size()); + for (const auto &directory : context.options.corpus_directories) { + corpus_directories.push_back(directory.c_str()); + } + std::vector dictionary_files; + dictionary_files.reserve(context.options.dictionary_files.size()); + for (const auto &dictionary : context.options.dictionary_files) { + dictionary_files.push_back(dictionary.c_str()); + } + + JazzerLibAflRuntimeOptions runtime_options{ + context.options.runs, + context.options.seed, + context.options.max_len, + context.options.timeout_millis, + context.options.max_total_time_seconds, + corpus_directories.empty() ? nullptr : corpus_directories.data(), + corpus_directories.size(), + dictionary_files.empty() ? nullptr : dictionary_files.data(), + dictionary_files.size(), + }; + status = jazzer_libafl_runtime_run(&runtime_options, &maps, + ExecuteSyncInput, &context); + } + + signal(SIGINT, SIG_DFL); + signal(SIGSEGV, SIG_DFL); + StopSyncWatchdog(&context); + gActiveSyncContext = nullptr; + + if (status == kRuntimeFatal && !context.is_resolved) { + context.is_resolved = true; + context.deferred.Reject( + Napi::Error::New(info.Env(), "The LibAFL backend failed internally") + .Value()); + } else if (status == kRuntimeFoundTimeout && !context.is_resolved) { + context.is_resolved = true; + context.deferred.Reject( + Napi::Error::New(info.Env(), + "Exceeded timeout while executing one fuzz input") + .Value()); + } else if (status == kRuntimeFoundFinding && !context.is_resolved) { + context.is_resolved = true; + context.deferred.Reject( + Napi::Error::New(info.Env(), + "The LibAFL backend found a crashing input") + .Value()); + } + + if (!context.is_resolved) { + context.deferred.Resolve(context.env.Undefined()); + } + + return context.deferred.Promise(); +} + +Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info) { + if (info.Length() != 2 || !info[0].IsFunction() || !info[1].IsObject()) { + throw Napi::Error::New(info.Env(), + "Need two arguments, which must be the fuzz target " + "function and a LibAFL options object"); + } + + auto options = ParseRuntimeOptions(info.Env(), info[1].As()); + auto maps = SharedMapsForRuntime(info.Env()); + auto *context = new AsyncFuzzTargetContext(info.Env(), std::move(options)); + + gAsyncTsfn = AsyncTsfn::New( + info.Env(), info[0].As(), "LibAflAsyncAddon", 0, 1, + context, + [](Napi::Env env, AsyncFinalizerDataType *, AsyncFuzzTargetContext *ctx) { + ctx->native_thread.join(); + if (ctx->run_status == kRuntimeFatal && !ctx->is_resolved) { + ctx->deferred.Reject( + Napi::Error::New(env, "The LibAFL backend failed internally") + .Value()); + } else if (ctx->run_status == kRuntimeFoundTimeout && + !ctx->is_resolved) { + ctx->deferred.Reject( + Napi::Error::New( + env, "Exceeded timeout while executing one fuzz input") + .Value()); + } else if (ctx->run_status == kRuntimeFoundFinding && + !ctx->is_resolved) { + ctx->deferred.Reject( + Napi::Error::New(env, "The LibAFL backend found a crashing input") + .Value()); + } else if (!ctx->is_resolved) { + ctx->deferred.Resolve(env.Undefined()); + } + delete ctx; + }); + + context->native_thread = std::thread( + [maps](AsyncFuzzTargetContext *ctx) { + gActiveAsyncContext = ctx; + signal(SIGSEGV, AsyncErrorSignalHandler); + signal(SIGINT, AsyncSigintHandler); + + if (ctx->options.mode == ParsedRuntimeOptions::Mode::kRegression) { + ctx->run_status = ReplayRegressionInputs( + ctx->options, [ctx](const uint8_t *data, size_t size) { + return ExecuteAsyncInput(ctx, data, size); + }); + } else { + std::vector corpus_directories; + corpus_directories.reserve(ctx->options.corpus_directories.size()); + for (const auto &directory : ctx->options.corpus_directories) { + corpus_directories.push_back(directory.c_str()); + } + std::vector dictionary_files; + dictionary_files.reserve(ctx->options.dictionary_files.size()); + for (const auto &dictionary : ctx->options.dictionary_files) { + dictionary_files.push_back(dictionary.c_str()); + } + + JazzerLibAflRuntimeOptions runtime_options{ + ctx->options.runs, + ctx->options.seed, + ctx->options.max_len, + ctx->options.timeout_millis, + ctx->options.max_total_time_seconds, + corpus_directories.empty() ? nullptr : corpus_directories.data(), + corpus_directories.size(), + dictionary_files.empty() ? nullptr : dictionary_files.data(), + dictionary_files.size(), + }; + ctx->run_status = jazzer_libafl_runtime_run(&runtime_options, &maps, + ExecuteAsyncInput, ctx); + } + signal(SIGINT, SIG_DFL); + signal(SIGSEGV, SIG_DFL); + gActiveAsyncContext = nullptr; + gAsyncTsfn.Release(); + }, + context); + + return context->deferred.Promise(); +} diff --git a/packages/fuzzer/libafl_runtime.h b/packages/fuzzer/libafl_runtime.h new file mode 100644 index 000000000..56f7bdc32 --- /dev/null +++ b/packages/fuzzer/libafl_runtime.h @@ -0,0 +1,65 @@ +// Copyright 2026 Code Intelligence GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include + +#include "shared/tracing.h" + +constexpr std::size_t kFindingInfoArtifactBytes = 256; +constexpr std::size_t kFindingInfoSummaryBytes = 1024; + +extern "C" { +struct JazzerLibAflFindingInfo { + uint8_t has_value; + char artifact[kFindingInfoArtifactBytes]; + char summary[kFindingInfoSummaryBytes]; +}; + +struct JazzerLibAflRuntimeOptions { + uint64_t runs; + uint64_t seed; + size_t max_len; + uint64_t timeout_millis; + uint64_t max_total_time_seconds; + const char **corpus_directories; + size_t corpus_directories_len; + const char **dictionary_files; + size_t dictionary_files_len; +}; + +struct JazzerLibAflRuntimeSharedMaps { + uint8_t *edges; + size_t edges_capacity; + size_t *edges_size; + uint8_t *cmp; + size_t cmp_len; + JazzerLibAflCompareLog *compare_log; + JazzerLibAflFindingInfo *finding_info; +}; + +typedef int (*JazzerLibAflExecuteCallback)(void *user_data, const uint8_t *data, + size_t size); + +int jazzer_libafl_runtime_run(const JazzerLibAflRuntimeOptions *options, + const JazzerLibAflRuntimeSharedMaps *maps, + JazzerLibAflExecuteCallback execute_one, + void *user_data); +} + +Napi::Value StartLibAfl(const Napi::CallbackInfo &info); +Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info); diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts new file mode 100644 index 000000000..ef54f711b --- /dev/null +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -0,0 +1,98 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { addon } from "./addon"; +import { fuzzer } from "./fuzzer"; + +const libAflOptions = { + mode: "fuzzing" as const, + runs: 32, + seed: 1234, + maxLen: 64, + timeoutMillis: 1000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], +}; + +describe("LibAFL runtime", () => { + it("runs synchronous fuzz targets through the native runtime", async () => { + let invocations = 0; + + await addon.startLibAfl( + () => { + invocations++; + }, + libAflOptions, + () => undefined, + ); + + expect(invocations).toBeGreaterThan(0); + }); + + it("preserves async invocation ordering through the event loop", async () => { + let lastInvocationCount = 0; + let invocationCount = 1; + + await addon.startLibAflAsync(async () => { + const value = await new Promise((resolve) => { + queueMicrotask(() => { + setImmediate(() => resolve(invocationCount++)); + }); + }); + + if (value !== lastInvocationCount + 1) { + throw new Error( + `Invalid invocation order: received ${value}, last ${lastInvocationCount}`, + ); + } + + lastInvocationCount = value; + }, libAflOptions); + + expect(lastInvocationCount).toBeGreaterThan(0); + }); + + it("records compare feedback in the shared native map", async () => { + addon.clearCompareFeedbackMap(); + + await addon.startLibAfl( + (data: Buffer) => { + const text = data.toString("utf8"); + fuzzer.tracer.traceStrCmp(text, "jazzer", "===", 11); + fuzzer.tracer.traceNumberCmp(data.length, 7, "===", 12); + fuzzer.tracer.tracePcIndir(13, data.length); + }, + { + mode: "fuzzing", + runs: 1, + seed: 9, + maxLen: 16, + timeoutMillis: 1000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], + }, + () => undefined, + ); + + expect(addon.countNonZeroCompareFeedbackSlots()).toBeGreaterThan(0); + expect(addon.countCompareLogEntries()).toBeGreaterThan(0); + expect(addon.countDroppedCompareLogEntries()).toBe(0); + }); +}); diff --git a/packages/fuzzer/package.json b/packages/fuzzer/package.json index dd895e26c..53ce9191a 100644 --- a/packages/fuzzer/package.json +++ b/packages/fuzzer/package.json @@ -1,7 +1,7 @@ { "name": "@jazzer.js/fuzzer", "version": "4.0.0", - "description": "Jazzer.js libfuzzer-based fuzzer for Node.js", + "description": "Jazzer.js native fuzzing backends for Node.js", "homepage": "https://github.com/CodeIntelligenceTesting/jazzer.js#readme", "author": "Code Intelligence", "license": "Apache-2.0", @@ -18,6 +18,7 @@ "scripts": { "prebuild": "cmake-js build --out build", "build": "node ../../scripts/build-fuzzer.js", + "benchmark:libafl": "node runtime/benchmark.js", "format:fix": "clang-format -i *.cpp shared/*.cpp shared/*.h", "lint": "find . -path ./build -prune -type f -o -iname '*.h' -o -iname '*.cpp' | xargs clang-tidy" }, diff --git a/packages/fuzzer/runtime/benchmark.js b/packages/fuzzer/runtime/benchmark.js new file mode 100644 index 000000000..8b08482ee --- /dev/null +++ b/packages/fuzzer/runtime/benchmark.js @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { addon } = require("../dist/addon.js"); +const { fuzzer } = require("../dist/fuzzer.js"); + +const runs = Number(process.env.JAZZER_LIBAFL_RUNS ?? "20000"); +const seed = Number(process.env.JAZZER_LIBAFL_SEED ?? "1337"); +const maxLen = Number(process.env.JAZZER_LIBAFL_MAX_LEN ?? "64"); + +const libFuzzerArgs = [ + "jazzer-libfuzzer-benchmark", + `-runs=${runs}`, + `-seed=${seed}`, + `-max_len=${maxLen}`, +]; +const libAflOptions = { + mode: "fuzzing", + runs, + seed, + maxLen, + timeoutMillis: 1000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], +}; + +async function measure(name, start) { + console.log(`\nRunning ${name}...`); + let invocations = 0; + const startedAt = process.hrtime.bigint(); + await start(() => { + invocations++; + }); + const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9; + return { + name, + invocations, + elapsedSeconds, + execsPerSecond: invocations / elapsedSeconds, + }; +} + +async function measureCompareHeavy(name, start) { + console.log(`\nRunning ${name}...`); + let invocations = 0; + const startedAt = process.hrtime.bigint(); + await start((data) => { + invocations++; + const text = data.toString("utf8"); + for (let i = 0; i < 32; i++) { + fuzzer.tracer.traceStrCmp(text, `cmp-${i}`, "===", i + 1); + fuzzer.tracer.traceNumberCmp(data.length, i, "===", i + 128); + fuzzer.tracer.tracePcIndir(i + 512, data.length ^ i); + } + }); + const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9; + return { + name, + invocations, + elapsedSeconds, + execsPerSecond: invocations / elapsedSeconds, + }; +} + +function printResult(result) { + console.log( + `${result.name.padEnd(28)} ${result.invocations + .toString() + .padStart( + 8, + )} execs ${result.elapsedSeconds.toFixed(3).padStart(8)} s ${result.execsPerSecond + .toFixed(0) + .padStart(10)} exec/s`, + ); +} + +async function main() { + console.log( + `Benchmarking with runs=${runs}, seed=${seed}, max_len=${maxLen}`, + ); + + const results = []; + results.push( + await measure("libFuzzer sync trivial", (target) => + addon.startFuzzing(target, libFuzzerArgs, () => undefined), + ), + ); + results.push( + await measure("LibAFL sync trivial", (target) => + addon.startLibAfl(target, libAflOptions, () => undefined), + ), + ); + results.push( + await measure("libFuzzer async trivial", (target) => + addon.startFuzzingAsync( + () => + new Promise((resolve) => { + target(); + setImmediate(resolve); + }), + libFuzzerArgs, + ), + ), + ); + results.push( + await measure("LibAFL async trivial", (target) => + addon.startLibAflAsync( + () => + new Promise((resolve) => { + target(); + setImmediate(resolve); + }), + libAflOptions, + ), + ), + ); + results.push( + await measureCompareHeavy("libFuzzer compare-heavy", (target) => + addon.startFuzzing(target, libFuzzerArgs, () => undefined), + ), + ); + results.push( + await measureCompareHeavy("LibAFL compare-heavy", (target) => + addon.startLibAfl(target, libAflOptions, () => undefined), + ), + ); + + console.log(""); + for (const result of results) { + printResult(result); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/fuzzer/rust/Cargo.lock b/packages/fuzzer/rust/Cargo.lock new file mode 100644 index 000000000..dcaf48870 --- /dev/null +++ b/packages/fuzzer/rust/Cargo.lock @@ -0,0 +1,1370 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "arbitrary-int" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitbybit" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec187a89ab07e209270175faf9e07ceb2755d984954e58a2296e325ddece2762" +dependencies = [ + "arbitrary-int 1.3.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "siphasher", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jazzerjs-libafl-runtime" +version = "0.1.0" +dependencies = [ + "libafl", + "libafl_bolts", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libafl" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e13655171e69ad9094dd1be1948950a36d228f01a7cb9f6d8477090d98c6e4" +dependencies = [ + "ahash", + "arbitrary-int 2.1.1", + "backtrace", + "bincode", + "bitbybit", + "const_format", + "const_panic", + "fastbloom", + "fs2", + "hashbrown 0.16.1", + "libafl_bolts", + "libafl_derive", + "libc", + "libm", + "log", + "meminterval", + "nix", + "num-traits", + "postcard", + "regex", + "rustversion", + "serde", + "serde_json", + "serial_test", + "tuple_list", + "typed-builder", + "uuid", + "wait-timeout", + "winapi", + "windows", +] + +[[package]] +name = "libafl_bolts" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cbae44f69156f035ae2196b135ad27ea95020767e6787bfe45e8c2438c67b9" +dependencies = [ + "ahash", + "backtrace", + "ctor", + "erased-serde", + "hashbrown 0.16.1", + "hostname", + "libafl_derive", + "libc", + "log", + "mach2", + "miniz_oxide", + "nix", + "num_enum", + "once_cell", + "postcard", + "rand_core", + "rustversion", + "serde", + "serial_test", + "static_assertions", + "tuple_list", + "typeid", + "uds", + "uuid", + "wide", + "winapi", + "windows", + "windows-core", + "windows-result", + "xxhash-rust", +] + +[[package]] +name = "libafl_derive" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61adf76899bffdcd15ae7fea42b978e7df7cf9213aacdd8cdcda89e4bb3bc32d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "meminterval" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e0f9a537564310a87dc77d5c88a407e27dd0aa740e070f0549439cfcc68fcfd" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tuple_list" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "141fb9f71ee586d956d7d6e4d5a9ef8e946061188520140f7591b668841d502e" + +[[package]] +name = "typed-builder" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + +[[package]] +name = "uds" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c31f06fce836457fe3ef09a59f83fe8db95d270b11cd78f40a4666c4d1661" +dependencies = [ + "libc", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/fuzzer/rust/Cargo.toml b/packages/fuzzer/rust/Cargo.toml new file mode 100644 index 000000000..c11c2bbc2 --- /dev/null +++ b/packages/fuzzer/rust/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "jazzerjs-libafl-runtime" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[lib] +name = "jazzerjs_libafl_runtime" +crate-type = ["staticlib"] + +[profile.release] +panic = "abort" + +[profile.dev] +panic = "abort" + +[dependencies] +libafl = "0.15.4" +libafl_bolts = "0.15.4" diff --git a/packages/fuzzer/rust/src/compare_log.rs b/packages/fuzzer/rust/src/compare_log.rs new file mode 100644 index 000000000..2305219c4 --- /dev/null +++ b/packages/fuzzer/rust/src/compare_log.rs @@ -0,0 +1,190 @@ +use std::borrow::Cow; + +use libafl::{ + executors::ExitKind, + mutators::Tokens, + observers::{ + cmp::{CmpValues, CmpValuesMetadata, CmplogBytes}, + Observer, + }, + Error, HasMetadata, +}; +use libafl_bolts::Named; + +pub const COMPARE_LOG_ENTRY_BYTES: usize = 32; +pub const COMPARE_LOG_MAX_ENTRIES: usize = 1024; + +const MAX_PROMOTED_TOKENS_PER_EXEC: usize = 64; +const MAX_PROMOTED_TOKENS_TOTAL: usize = 1024; +const COMPARE_LOG_SIGNED_FLAG: u8 = 1 << 0; + +const COMPARE_KIND_INTEGER: u8 = 1; +const COMPARE_KIND_STRING_EQUALITY: u8 = 2; +const COMPARE_KIND_STRING_CONTAINMENT: u8 = 3; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +pub struct JazzerLibAflCompareLogEntry { + pub kind: u8, + pub flags: u8, + pub left_len: u8, + pub right_len: u8, + pub left_value: u64, + pub right_value: u64, + pub left_bytes: [u8; COMPARE_LOG_ENTRY_BYTES], + pub right_bytes: [u8; COMPARE_LOG_ENTRY_BYTES], +} + +#[repr(C)] +#[derive(Debug)] +pub struct JazzerLibAflCompareLog { + pub used: u32, + pub dropped: u32, + pub entries: [JazzerLibAflCompareLogEntry; COMPARE_LOG_MAX_ENTRIES], +} + +#[derive(Clone, Debug)] +pub struct JazzerCompareLogObserver { + name: Cow<'static, str>, + compare_log: *mut JazzerLibAflCompareLog, +} + +impl JazzerCompareLogObserver { + pub fn new(compare_log: *mut JazzerLibAflCompareLog) -> Self { + Self { + name: Cow::Borrowed("jazzer-compare-log"), + compare_log, + } + } + + fn compare_log(&self) -> Option<&JazzerLibAflCompareLog> { + unsafe { self.compare_log.as_ref() } + } +} + +impl Named for JazzerCompareLogObserver { + fn name(&self) -> &Cow<'static, str> { + &self.name + } +} + +impl Observer for JazzerCompareLogObserver +where + S: HasMetadata, +{ + fn pre_exec(&mut self, state: &mut S, _input: &I) -> Result<(), Error> { + if let Some(metadata) = state.metadata_map_mut().get_mut::() { + metadata.list.clear(); + } + Ok(()) + } + + fn post_exec(&mut self, state: &mut S, _input: &I, _exit_kind: &ExitKind) -> Result<(), Error> { + let Some(compare_log) = self.compare_log() else { + return Ok(()); + }; + + let entry_count = usize::min(compare_log.used as usize, COMPARE_LOG_MAX_ENTRIES); + let mut cmp_values = Vec::with_capacity(entry_count); + let mut promoted_tokens = Vec::new(); + for entry in compare_log.entries.iter().take(entry_count) { + if let Some(value) = cmp_value_for_entry(entry) { + cmp_values.push(value); + } + if promoted_tokens.len() < MAX_PROMOTED_TOKENS_PER_EXEC { + if let Some(token) = promoted_token_for_entry(entry) { + promoted_tokens.push(token); + } + } + } + + let metadata = state.metadata_or_insert_with(CmpValuesMetadata::new); + metadata.list.clear(); + metadata.list.extend(cmp_values); + + if !promoted_tokens.is_empty() { + let tokens = state.metadata_or_insert_with(Tokens::new); + for token in promoted_tokens { + if tokens.len() >= MAX_PROMOTED_TOKENS_TOTAL { + break; + } + tokens.add_token(&token); + } + } + + Ok(()) + } +} + +fn cmp_value_for_entry(entry: &JazzerLibAflCompareLogEntry) -> Option { + match entry.kind { + COMPARE_KIND_INTEGER => Some(cmp_value_for_integer(entry)), + COMPARE_KIND_STRING_EQUALITY => cmp_value_for_string_equality(entry), + _ => None, + } +} + +fn promoted_token_for_entry(entry: &JazzerLibAflCompareLogEntry) -> Option> { + match entry.kind { + COMPARE_KIND_STRING_EQUALITY => token_from_entry(&entry.right_bytes, entry.right_len), + COMPARE_KIND_STRING_CONTAINMENT => token_from_entry(&entry.left_bytes, entry.left_len), + _ => None, + } +} + +fn token_from_entry(bytes: &[u8; COMPARE_LOG_ENTRY_BYTES], len: u8) -> Option> { + let len = usize::min(len as usize, COMPARE_LOG_ENTRY_BYTES); + if len == 0 { + return None; + } + Some(bytes[..len].to_vec()) +} + +fn cmp_value_for_string_equality(entry: &JazzerLibAflCompareLogEntry) -> Option { + let left_len = usize::min(entry.left_len as usize, COMPARE_LOG_ENTRY_BYTES); + let right_len = usize::min(entry.right_len as usize, COMPARE_LOG_ENTRY_BYTES); + if left_len == 0 || right_len == 0 { + return None; + } + + let mut left = [0; COMPARE_LOG_ENTRY_BYTES]; + left[..left_len].copy_from_slice(&entry.left_bytes[..left_len]); + let mut right = [0; COMPARE_LOG_ENTRY_BYTES]; + right[..right_len].copy_from_slice(&entry.right_bytes[..right_len]); + Some(CmpValues::Bytes(( + CmplogBytes::from_buf_and_len(left, left_len as u8), + CmplogBytes::from_buf_and_len(right, right_len as u8), + ))) +} + +fn cmp_value_for_integer(entry: &JazzerLibAflCompareLogEntry) -> CmpValues { + if entry.flags & COMPARE_LOG_SIGNED_FLAG != 0 { + cmp_value_for_signed_integer(entry.left_value as i64, entry.right_value as i64) + } else { + cmp_value_for_unsigned_integer(entry.left_value, entry.right_value) + } +} + +fn cmp_value_for_unsigned_integer(left: u64, right: u64) -> CmpValues { + if let (Ok(left), Ok(right)) = (u8::try_from(left), u8::try_from(right)) { + CmpValues::U8((left, right, false)) + } else if let (Ok(left), Ok(right)) = (u16::try_from(left), u16::try_from(right)) { + CmpValues::U16((left, right, false)) + } else if let (Ok(left), Ok(right)) = (u32::try_from(left), u32::try_from(right)) { + CmpValues::U32((left, right, false)) + } else { + CmpValues::U64((left, right, false)) + } +} + +fn cmp_value_for_signed_integer(left: i64, right: i64) -> CmpValues { + if let (Ok(left), Ok(right)) = (i8::try_from(left), i8::try_from(right)) { + CmpValues::U8((left as u8, right as u8, false)) + } else if let (Ok(left), Ok(right)) = (i16::try_from(left), i16::try_from(right)) { + CmpValues::U16((left as u16, right as u16, false)) + } else if let (Ok(left), Ok(right)) = (i32::try_from(left), i32::try_from(right)) { + CmpValues::U32((left as u32, right as u32, false)) + } else { + CmpValues::U64((left as u64, right as u64, false)) + } +} diff --git a/packages/fuzzer/rust/src/lib.rs b/packages/fuzzer/rust/src/lib.rs new file mode 100644 index 000000000..655c34c16 --- /dev/null +++ b/packages/fuzzer/rust/src/lib.rs @@ -0,0 +1,1001 @@ +mod compare_log; + +use core::ffi::{c_char, c_void}; +use core::ptr; +use std::cell::{Cell, RefCell}; +use std::ffi::CStr; +use std::fs; +use std::io::IsTerminal; +use std::path::PathBuf; +use std::rc::Rc; +use std::slice; +use std::time::{Duration, Instant}; + +use libafl::{ + corpus::{CachedOnDiskCorpus, Corpus, InMemoryCorpus}, + events::SimpleEventManager, + executors::{inprocess::InProcessExecutor, ExitKind, ShadowExecutor}, + feedback_or_fast, + feedbacks::{CrashFeedback, MaxMapFeedback, TimeoutFeedback}, + fuzzer::{Evaluator, Fuzzer, StdFuzzer}, + inputs::{BytesInput, HasTargetBytes}, + monitors::{ + stats::{ClientStatsManager, UserStats, UserStatsValue}, + Monitor, + }, + mutators::{ + havoc_mutations::havoc_mutations, scheduled::HavocScheduledMutator, tokens_mutations, + I2SRandReplace, Tokens, + }, + observers::{ + CanTrack, HitcountsMapObserver, StdMapObserver, VariableMapObserver, + }, + schedulers::{ + powersched::PowerSchedule, IndexesLenTimeMinimizerScheduler, PowerQueueScheduler, + }, + stages::{calibrate::CalibrationStage, shadow::ShadowTracingStage, StdPowerMutationalStage}, + state::{HasCorpus, HasExecutions, HasMaxSize, HasSolutions, StdState}, + Error, HasMetadata, +}; +use libafl_bolts::{ + rands::StdRand, + tuples::{tuple_list, Merge}, + AsSlice, ClientId, +}; + +use crate::compare_log::{JazzerCompareLogObserver, JazzerLibAflCompareLog}; + +const EXECUTION_CONTINUE: i32 = 0; +const EXECUTION_FINDING: i32 = 1; +const EXECUTION_STOP: i32 = 2; +const EXECUTION_FATAL: i32 = 3; +const EXECUTION_TIMEOUT: i32 = 4; + +const RUNTIME_OK: i32 = 0; +const RUNTIME_FOUND_FINDING: i32 = 1; +const RUNTIME_STOPPED: i32 = 2; +const RUNTIME_FATAL: i32 = 3; +const RUNTIME_FOUND_TIMEOUT: i32 = 4; + +const FINDING_INFO_ARTIFACT_BYTES: usize = 256; +const FINDING_INFO_SUMMARY_BYTES: usize = 1024; +const EXECUTION_FIELD_WIDTH: usize = 10; +const DEFAULT_MONITOR_TIMEOUT: Duration = Duration::from_secs(15); + +#[repr(C)] +pub struct JazzerLibAflFindingInfo { + pub has_value: u8, + pub artifact: [u8; FINDING_INFO_ARTIFACT_BYTES], + pub summary: [u8; FINDING_INFO_SUMMARY_BYTES], +} + +#[repr(C)] +pub struct JazzerLibAflRuntimeOptions { + pub runs: u64, + pub seed: u64, + pub max_len: usize, + pub timeout_millis: u64, + pub max_total_time_seconds: u64, + pub corpus_directories: *const *const c_char, + pub corpus_directories_len: usize, + pub dictionary_files: *const *const c_char, + pub dictionary_files_len: usize, +} + +#[repr(C)] +pub struct JazzerLibAflRuntimeSharedMaps { + pub edges: *mut u8, + pub edges_capacity: usize, + pub edges_size: *mut usize, + pub cmp: *mut u8, + pub cmp_len: usize, + pub compare_log: *mut JazzerLibAflCompareLog, + pub finding_info: *mut JazzerLibAflFindingInfo, +} + +pub type JazzerLibAflExecuteCallback = + unsafe extern "C" fn(user_data: *mut c_void, data: *const u8, size: usize) -> i32; + +#[derive(Clone, Copy)] +struct RatioMetric { + numerator: u64, + denominator: u64, +} + +#[derive(Clone, Copy)] +struct ProgressSnapshot { + executions: u64, + edges: Option, + corpus_size: u64, + execs_per_sec: f64, + objective_size: u64, + stability: Option, + elapsed: Duration, +} + +struct MonitorState { + campaign_started: bool, + colors_enabled: bool, + last_edges_are_synthetic: bool, + last_status_output_at: Option, + last_progress: Option, +} + +#[derive(Clone, Copy)] +enum StatusEvent { + Testcase, + Heartbeat, + Objective, + Done, +} + +#[derive(Clone)] +struct LibAflMonitor { + state: Rc>, + finding_info: *mut JazzerLibAflFindingInfo, +} + +impl LibAflMonitor { + fn new(finding_info: *mut JazzerLibAflFindingInfo) -> (Self, Rc>) { + let state = Rc::new(RefCell::new(MonitorState { + campaign_started: false, + colors_enabled: should_colorize_output(), + last_edges_are_synthetic: false, + last_status_output_at: None, + last_progress: None, + })); + + ( + Self { + state: state.clone(), + finding_info, + }, + state, + ) + } +} + +impl Monitor for LibAflMonitor { + fn display( + &mut self, + client_stats_manager: &mut ClientStatsManager, + event_msg: &str, + sender_id: ClientId, + ) -> Result<(), Error> { + let Some(event) = (match event_msg { + "Testcase" => Some(StatusEvent::Testcase), + "Objective" => Some(StatusEvent::Objective), + _ => None, + }) else { + return Ok(()); + }; + + let (campaign_started, colors_enabled, last_edges_are_synthetic) = { + let state = self.state.borrow(); + ( + state.campaign_started, + state.colors_enabled, + state.last_edges_are_synthetic, + ) + }; + let snapshot = + build_progress_snapshot(client_stats_manager, sender_id, last_edges_are_synthetic)?; + self.state.borrow_mut().last_progress = Some(snapshot); + + if !campaign_started + && matches!(event, StatusEvent::Testcase) + && !snapshot.corpus_size.is_power_of_two() + { + return Ok(()); + } + + match event { + StatusEvent::Objective => { + let finding_info = read_finding_info(self.finding_info); + eprintln!( + "{}", + format_objective_line(snapshot.executions, finding_info, colors_enabled), + ); + } + StatusEvent::Testcase => { + eprintln!( + "{}", + format_progress_line(event, snapshot, colors_enabled, campaign_started), + ); + } + StatusEvent::Heartbeat | StatusEvent::Done => unreachable!(), + } + + self.state.borrow_mut().last_status_output_at = Some(Instant::now()); + + Ok(()) + } +} + +fn format_duration(duration: Duration) -> String { + let total_seconds = duration.as_secs(); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + + if hours > 0 { + format!("{hours}h{minutes:02}m{seconds:02}s") + } else if minutes > 0 { + format!("{minutes}m{seconds:02}s") + } else { + format!("{seconds}s") + } +} + +fn should_colorize_output() -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + + if matches!(std::env::var("TERM"), Ok(term) if term == "dumb") { + return false; + } + + std::io::stderr().is_terminal() +} + +fn monitor_timeout() -> Duration { + match std::env::var("JAZZER_LIBAFL_MONITOR_TIMEOUT_MS") { + Ok(value) => value + .parse::() + .ok() + .filter(|timeout| *timeout > 0) + .map(Duration::from_millis) + .unwrap_or(DEFAULT_MONITOR_TIMEOUT), + Err(_) => DEFAULT_MONITOR_TIMEOUT, + } +} + +fn ratio_from_user_stat(user_stat: Option<&UserStats>) -> Option { + let UserStatsValue::Ratio(numerator, denominator) = user_stat?.value() else { + return None; + }; + Some(RatioMetric { + numerator: *numerator, + denominator: *denominator, + }) +} + +fn format_ratio_metric(metric: Option) -> String { + let Some(metric) = metric else { + return " -/ - ( -%)".to_string(); + }; + + if metric.denominator == 0 { + return format!("{:>4}/{:<4} ( -%)", metric.numerator, metric.denominator); + } + + let percentage = metric.numerator.saturating_mul(100) / metric.denominator; + format!( + "{:>4}/{:<4} ({:>3}%)", + metric.numerator, metric.denominator, percentage + ) +} + +fn colorize_marker(marker: &str, sgr_code: &str, colors_enabled: bool) -> String { + if colors_enabled { + format!("\x1b[{sgr_code}m{marker}\x1b[0m") + } else { + marker.to_string() + } +} + +fn marker_text(event: StatusEvent) -> &'static str { + match event { + StatusEvent::Testcase => "[+]", + StatusEvent::Heartbeat => "[*]", + StatusEvent::Objective => "[!]", + StatusEvent::Done => "[=]", + } +} + +fn event_color_code(event: StatusEvent) -> &'static str { + match event { + StatusEvent::Testcase => "32", + StatusEvent::Heartbeat => "2", + StatusEvent::Objective => "1;31", + StatusEvent::Done => "34", + } +} + +fn marker_for_event(event: StatusEvent, colors_enabled: bool) -> String { + colorize_marker(marker_text(event), event_color_code(event), colors_enabled) +} + +fn start_marker(colors_enabled: bool) -> String { + colorize_marker("[>]", "34", colors_enabled) +} + +fn format_inited_field(label: &str, value: impl std::fmt::Display) -> String { + let value = value.to_string(); + format!(" {label:<15} {}", value.trim_start()) +} + +fn build_progress_snapshot( + client_stats_manager: &mut ClientStatsManager, + sender_id: ClientId, + hide_edges: bool, +) -> Result { + let (executions, corpus_size, execs_per_sec, objective_size, elapsed) = { + let global_stats = client_stats_manager.global_stats(); + ( + global_stats.total_execs, + global_stats.corpus_size, + global_stats.execs_per_sec, + global_stats.objective_size, + global_stats.run_time, + ) + }; + let client_stats = client_stats_manager.client_stats_for(sender_id)?; + Ok(ProgressSnapshot { + executions, + edges: if hide_edges { + None + } else { + ratio_from_user_stat(client_stats.get_user_stats("edges")) + }, + corpus_size, + execs_per_sec, + objective_size, + stability: ratio_from_user_stat(client_stats.get_user_stats("stability")), + elapsed, + }) +} + +fn progress_marker(event: StatusEvent, in_campaign: bool, colors_enabled: bool) -> String { + let marker = if matches!(event, StatusEvent::Testcase) && !in_campaign { + "[i]" + } else { + marker_text(event) + }; + + colorize_marker(marker, event_color_code(event), colors_enabled) +} + +fn format_progress_line( + event: StatusEvent, + snapshot: ProgressSnapshot, + colors_enabled: bool, + in_campaign: bool, +) -> String { + let marker = if colors_enabled && !in_campaign { + progress_marker(event, false, true) + } else { + progress_marker(event, in_campaign, false) + }; + let line = format!( + "{} #{:4} | exec/s: {:>8.1} | obj: {:>3} | stab: {} | t: {}", + marker, + snapshot.executions, + format_ratio_metric(snapshot.edges), + snapshot.corpus_size, + if snapshot.execs_per_sec.is_finite() { + snapshot.execs_per_sec + } else { + 0.0 + }, + snapshot.objective_size, + format_ratio_metric(snapshot.stability), + format_duration(snapshot.elapsed), + width = EXECUTION_FIELD_WIDTH, + ); + + if colors_enabled && in_campaign { + format!("\x1b[{}m{}\x1b[0m", event_color_code(event), line) + } else { + line + } +} + +fn maybe_print_final_init_testcase(state: &mut MonitorState, loaded_inputs: usize) { + let Some(snapshot) = state.last_progress else { + return; + }; + + if snapshot.corpus_size == 0 + || snapshot.corpus_size.is_power_of_two() + || snapshot.corpus_size != loaded_inputs as u64 + { + return; + } + + eprintln!( + "{}", + format_progress_line(StatusEvent::Testcase, snapshot, state.colors_enabled, false), + ); + state.last_status_output_at = Some(Instant::now()); +} + +fn build_idle_progress_snapshot( + state: &S, + started_at: Instant, + monitor_state: &MonitorState, +) -> ProgressSnapshot +where + S: HasCorpus + HasExecutions + HasSolutions, +{ + let executions = *state.executions(); + let elapsed = started_at.elapsed(); + let execs_per_sec = if elapsed.as_secs_f64() > 0.0 { + executions as f64 / elapsed.as_secs_f64() + } else { + 0.0 + }; + + ProgressSnapshot { + executions, + edges: monitor_state + .last_progress + .and_then(|snapshot| snapshot.edges), + corpus_size: state.corpus().count() as u64, + execs_per_sec, + objective_size: state.solutions().count() as u64, + stability: monitor_state + .last_progress + .and_then(|snapshot| snapshot.stability), + elapsed, + } +} + +fn maybe_emit_idle_heartbeat( + monitor_state: &mut MonitorState, + state: &S, + started_at: Instant, + monitor_timeout: Duration, +) where + S: HasCorpus + HasExecutions + HasSolutions, +{ + let Some(last_status_output_at) = monitor_state.last_status_output_at else { + return; + }; + + if last_status_output_at.elapsed() < monitor_timeout { + return; + } + + let snapshot = build_idle_progress_snapshot(state, started_at, monitor_state); + eprintln!( + "{}", + format_progress_line( + StatusEvent::Heartbeat, + snapshot, + monitor_state.colors_enabled, + true, + ), + ); + monitor_state.last_progress = Some(snapshot); + monitor_state.last_status_output_at = Some(Instant::now()); +} + +#[derive(Clone)] +struct FindingInfo { + artifact: Option, + summary: Option, +} + +fn read_zero_terminated_string(bytes: &[u8]) -> Option { + let len = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + if len == 0 { + return None; + } + + Some(String::from_utf8_lossy(&bytes[..len]).into_owned()) +} + +fn read_finding_info(finding_info: *mut JazzerLibAflFindingInfo) -> FindingInfo { + let Some(finding_info) = (unsafe { finding_info.as_ref() }) else { + return FindingInfo { + artifact: None, + summary: None, + }; + }; + + if finding_info.has_value == 0 { + return FindingInfo { + artifact: None, + summary: None, + }; + } + + FindingInfo { + artifact: read_zero_terminated_string(&finding_info.artifact), + summary: read_zero_terminated_string(&finding_info.summary), + } +} + +fn format_objective_line( + executions: u64, + finding_info: FindingInfo, + colors_enabled: bool, +) -> String { + let artifact = finding_info + .artifact + .unwrap_or_else(|| "".to_string()); + let summary = finding_info + .summary + .unwrap_or_else(|| "finding".to_string()); + let line = format!( + "{} #{:, + colors_enabled: bool, +) { + let elapsed = started_at.elapsed(); + let elapsed_seconds = elapsed.as_secs_f64(); + let execs_per_sec = if elapsed_seconds > 0.0 { + executions as f64 / elapsed_seconds + } else { + 0.0 + }; + let edges = last_progress.and_then(|snapshot| snapshot.edges); + + eprintln!( + "{} #{:, + colors_enabled: bool, +) { + let runs = if options.runs == 0 { + "unlimited".to_string() + } else { + options.runs.to_string() + }; + let max_total_time = if options.max_total_time_seconds == 0 { + "unlimited".to_string() + } else { + format_duration(Duration::from_secs(options.max_total_time_seconds)) + }; + + eprintln!( + "{} INITED\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + start_marker(colors_enabled), + format_inited_field("mode:", "fuzzing"), + format_inited_field("seed:", options.seed), + format_inited_field("loaded_inputs:", loaded_inputs), + format_inited_field("edges:", format_ratio_metric(edges)), + format_inited_field("timeout:", format!("{} ms", options.timeout_millis)), + format_inited_field("max_len:", options.max_len), + format_inited_field("runs:", runs), + format_inited_field("max_total_time:", max_total_time), + ); +} + +fn clear_shared_map(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + + unsafe { + ptr::write_bytes(ptr, 0, len); + } +} + +fn clear_compare_log(ptr: *mut JazzerLibAflCompareLog) { + if ptr.is_null() { + return; + } + + unsafe { + ptr::write_bytes(ptr, 0, 1); + } +} + +fn clear_finding_info(ptr: *mut JazzerLibAflFindingInfo) { + if ptr.is_null() { + return; + } + + unsafe { + ptr::write_bytes(ptr, 0, 1); + } +} + +fn edge_map_len(maps: &JazzerLibAflRuntimeSharedMaps) -> usize { + if maps.edges_size.is_null() { + 0 + } else { + unsafe { (*maps.edges_size).min(maps.edges_capacity) } + } +} + +fn has_non_zero_coverage(ptr: *mut u8, len: usize) -> bool { + if ptr.is_null() || len == 0 { + return false; + } + + unsafe { slice::from_raw_parts(ptr, len).iter().any(|slot| *slot != 0) } +} + +fn ensure_non_empty_edge_map(ptr: *mut u8, len: usize) -> bool { + if has_non_zero_coverage(ptr, len) { + return false; + } + + if ptr.is_null() || len == 0 { + return false; + } + + unsafe { + let map = slice::from_raw_parts_mut(ptr, len); + // Power scheduling rejects corpus entries that never hit any edge. + // Preserve the old behavior for uninstrumented callbacks by marking + // one synthetic edge only when the target left every coverage region untouched. + map[0] = 1; + } + + true +} + +unsafe fn parse_corpus_directories(options: &JazzerLibAflRuntimeOptions) -> Option> { + if options.corpus_directories.is_null() || options.corpus_directories_len == 0 { + return Some(Vec::new()); + } + + let mut result = Vec::with_capacity(options.corpus_directories_len); + let directories = + std::slice::from_raw_parts(options.corpus_directories, options.corpus_directories_len); + for directory in directories { + if directory.is_null() { + return None; + } + let path = CStr::from_ptr(*directory).to_string_lossy().to_string(); + result.push(PathBuf::from(path)); + } + Some(result) +} + +unsafe fn parse_dictionary_files(options: &JazzerLibAflRuntimeOptions) -> Option> { + if options.dictionary_files.is_null() || options.dictionary_files_len == 0 { + return Some(Vec::new()); + } + + let mut result = Vec::with_capacity(options.dictionary_files_len); + let files = std::slice::from_raw_parts(options.dictionary_files, options.dictionary_files_len); + for file in files { + if file.is_null() { + return None; + } + let path = CStr::from_ptr(*file).to_string_lossy().to_string(); + result.push(PathBuf::from(path)); + } + Some(result) +} + +fn resolve_main_corpus_directory( + corpus_dirs: &[PathBuf], + seed: u64, +) -> Result { + let directory = if let Some(first) = corpus_dirs.first() { + first.clone() + } else { + std::env::temp_dir().join(format!( + "jazzerjs-libafl-runtime-{}-{}", + std::process::id(), + seed, + )) + }; + fs::create_dir_all(&directory)?; + Ok(directory) +} + +fn load_dictionary_tokens(files: &[PathBuf]) -> Result { + if files.is_empty() { + return Ok(Tokens::new()); + } + + Tokens::new().add_from_files(files.iter()) +} + +#[no_mangle] +pub unsafe extern "C" fn jazzer_libafl_runtime_run( + options: *const JazzerLibAflRuntimeOptions, + maps: *const JazzerLibAflRuntimeSharedMaps, + execute_one: JazzerLibAflExecuteCallback, + user_data: *mut c_void, +) -> i32 { + if options.is_null() || maps.is_null() { + eprintln!("[libafl] fatal: null options or maps pointer"); + return RUNTIME_FATAL; + } + + let options = &*options; + let maps = &*maps; + if maps.edges.is_null() + || maps.edges_capacity == 0 + || maps.edges_size.is_null() + || maps.cmp.is_null() + || maps.cmp_len == 0 + || maps.compare_log.is_null() + || maps.finding_info.is_null() + { + eprintln!("[libafl] fatal: shared maps are missing"); + return RUNTIME_FATAL; + } + + let corpus_dirs = match parse_corpus_directories(options) { + Some(dirs) => dirs, + None => { + eprintln!("[libafl] fatal: invalid corpus directories"); + return RUNTIME_FATAL; + } + }; + let dictionary_files = match parse_dictionary_files(options) { + Some(files) => files, + None => { + eprintln!("[libafl] fatal: invalid dictionary files"); + return RUNTIME_FATAL; + } + }; + + let main_corpus_dir = match resolve_main_corpus_directory(&corpus_dirs, options.seed) { + Ok(directory) => directory, + Err(error) => { + eprintln!("[libafl] fatal: failed to prepare corpus directory: {error:?}"); + return RUNTIME_FATAL; + } + }; + + let (monitor, monitor_state) = LibAflMonitor::new(maps.finding_info); + let mut mgr = SimpleEventManager::new(monitor); + + let edges_observer = HitcountsMapObserver::new( + VariableMapObserver::from_mut_ptr( + "edges", + maps.edges, + maps.edges_capacity, + maps.edges_size, + ), + ) + .track_indices(); + let cmp_observer = + HitcountsMapObserver::new(StdMapObserver::from_mut_ptr("cmp", maps.cmp, maps.cmp_len)); + + let mut feedback = MaxMapFeedback::new(&edges_observer); + let mut objective = feedback_or_fast!(CrashFeedback::new(), TimeoutFeedback::new()); + let mut state = match StdState::new( + StdRand::with_seed(options.seed), + match CachedOnDiskCorpus::no_meta(&main_corpus_dir, 256) { + Ok(corpus) => corpus, + Err(error) => { + eprintln!("[libafl] fatal: failed to create on-disk corpus: {error:?}"); + return RUNTIME_FATAL; + } + }, + InMemoryCorpus::new(), + &mut feedback, + &mut objective, + ) { + Ok(state) => state, + Err(error) => { + eprintln!("[libafl] fatal: failed to create fuzzing state: {error:?}"); + return RUNTIME_FATAL; + } + }; + state.set_max_size(options.max_len); + + match load_dictionary_tokens(&dictionary_files) { + Ok(tokens) => { + if !tokens.is_empty() { + state.add_metadata(tokens); + } + } + Err(error) => { + eprintln!("[libafl] fatal: failed to load dictionary tokens: {error:?}"); + return RUNTIME_FATAL; + } + } + + let calibration_stage = CalibrationStage::ignore_stability(&feedback); + let scheduler = IndexesLenTimeMinimizerScheduler::new( + &edges_observer, + PowerQueueScheduler::new(&mut state, &edges_observer, PowerSchedule::fast()), + ); + let mut fuzzer = StdFuzzer::new(scheduler, feedback, objective); + let mutator = HavocScheduledMutator::new( + havoc_mutations() + .merge(tokens_mutations()) + .merge(tuple_list!(I2SRandReplace::new())), + ); + let mut stages = tuple_list!( + calibration_stage, + ShadowTracingStage::new(), + StdPowerMutationalStage::new(mutator), + ); + let stop_requested = Cell::new(false); + let fatal_error = Cell::new(false); + let timeout_found = Cell::new(false); + + let mut harness = |input: &BytesInput| { + clear_shared_map(maps.edges, edge_map_len(maps)); + clear_shared_map(maps.cmp, maps.cmp_len); + clear_compare_log(maps.compare_log); + clear_finding_info(maps.finding_info); + + let bytes = input.target_bytes(); + let bytes = bytes.as_slice(); + let size = bytes.len().min(options.max_len); + let status = unsafe { execute_one(user_data, bytes.as_ptr(), size) }; + let synthetic_edges = ensure_non_empty_edge_map( + maps.edges, + edge_map_len(maps), + ); + monitor_state.borrow_mut().last_edges_are_synthetic = synthetic_edges; + match status { + EXECUTION_CONTINUE => ExitKind::Ok, + EXECUTION_FINDING => ExitKind::Crash, + EXECUTION_STOP => { + stop_requested.set(true); + ExitKind::Ok + } + EXECUTION_FATAL => { + fatal_error.set(true); + ExitKind::Ok + } + EXECUTION_TIMEOUT => { + timeout_found.set(true); + ExitKind::Timeout + } + _ => { + fatal_error.set(true); + ExitKind::Ok + } + } + }; + + let executor = match InProcessExecutor::new( + &mut harness, + tuple_list!(edges_observer, cmp_observer), + &mut fuzzer, + &mut state, + &mut mgr, + ) { + Ok(executor) => executor, + Err(error) => { + eprintln!("[libafl] fatal: failed to create executor: {error:?}"); + return RUNTIME_FATAL; + } + }; + let shadow_observer = JazzerCompareLogObserver::new(maps.compare_log); + let mut executor = ShadowExecutor::new(executor, tuple_list!(shadow_observer)); + + if !corpus_dirs.is_empty() && state.must_load_initial_inputs() { + if state + .load_initial_inputs(&mut fuzzer, &mut executor, &mut mgr, &corpus_dirs) + .is_err() + { + eprintln!("[libafl] fatal: failed to load initial corpus inputs"); + return RUNTIME_FATAL; + } + } + + if state.corpus().count() == 0 + && fuzzer + .add_input(&mut state, &mut executor, &mut mgr, BytesInput::new(vec![])) + .is_err() + { + eprintln!("[libafl] fatal: failed to seed empty testcase"); + return RUNTIME_FATAL; + } + + { + let mut monitor_state = monitor_state.borrow_mut(); + maybe_print_final_init_testcase(&mut monitor_state, state.corpus().count()); + print_runtime_start( + options, + state.corpus().count(), + monitor_state + .last_progress + .and_then(|snapshot| snapshot.edges), + monitor_state.colors_enabled, + ); + monitor_state.last_status_output_at = Some(Instant::now()); + monitor_state.campaign_started = true; + } + + let started_at = Instant::now(); + let monitor_timeout = monitor_timeout(); + let max_total_time = if options.max_total_time_seconds == 0 { + None + } else { + Some(Duration::from_secs(options.max_total_time_seconds)) + }; + + let initial_executions = *state.executions(); + let mut status = RUNTIME_OK; + let done_reason = loop { + if options.runs != 0 + && state.executions().saturating_sub(initial_executions) >= options.runs + { + break "runs"; + } + if let Some(max_total_time) = max_total_time { + if started_at.elapsed() >= max_total_time { + status = RUNTIME_STOPPED; + break "max_total_time"; + } + } + + if let Err(error) = fuzzer.fuzz_one(&mut stages, &mut executor, &mut state, &mut mgr) { + eprintln!("[libafl] fatal: fuzz_one returned an error: {error:?}"); + return RUNTIME_FATAL; + } + if fatal_error.get() { + return RUNTIME_FATAL; + } + + if timeout_found.get() { + return RUNTIME_FOUND_TIMEOUT; + } + + if state.solutions().count() > 0 { + return RUNTIME_FOUND_FINDING; + } + + if stop_requested.get() { + status = RUNTIME_STOPPED; + break "stop_requested"; + } + + maybe_emit_idle_heartbeat( + &mut monitor_state.borrow_mut(), + &state, + started_at, + monitor_timeout, + ); + }; + + let monitor_state = monitor_state.borrow(); + print_runtime_done( + done_reason, + started_at, + *state.executions(), + state.solutions().count(), + monitor_state.last_progress, + monitor_state.colors_enabled, + ); + + status +} diff --git a/packages/fuzzer/shared/callbacks.cpp b/packages/fuzzer/shared/callbacks.cpp index 30365436e..235019fec 100644 --- a/packages/fuzzer/shared/callbacks.cpp +++ b/packages/fuzzer/shared/callbacks.cpp @@ -21,8 +21,6 @@ void RegisterCallbackExports(Napi::Env env, Napi::Object exports) { Napi::Function::New(env); exports["registerNewCounters"] = Napi::Function::New(env); - exports["registerModuleCounters"] = - Napi::Function::New(env); exports["traceUnequalStrings"] = Napi::Function::New(env); exports["traceStringContainment"] = @@ -30,4 +28,12 @@ void RegisterCallbackExports(Napi::Env env, Napi::Object exports) { exports["traceIntegerCompare"] = Napi::Function::New(env); exports["tracePcIndir"] = Napi::Function::New(env); + exports["clearCompareFeedbackMap"] = + Napi::Function::New(env); + exports["countNonZeroCompareFeedbackSlots"] = + Napi::Function::New(env); + exports["countCompareLogEntries"] = + Napi::Function::New(env); + exports["countDroppedCompareLogEntries"] = + Napi::Function::New(env); } diff --git a/packages/fuzzer/shared/coverage.cpp b/packages/fuzzer/shared/coverage.cpp index d1cb3a682..fc5b0876d 100644 --- a/packages/fuzzer/shared/coverage.cpp +++ b/packages/fuzzer/shared/coverage.cpp @@ -15,6 +15,7 @@ #include #include +#include extern "C" { void __sanitizer_cov_8bit_counters_init(uint8_t *start, uint8_t *end); @@ -24,8 +25,11 @@ void __sanitizer_cov_pcs_init(const uintptr_t *pcs_beg, namespace { // Shared coverage counter buffer populated from JavaScript using Buffer. -// Individual slices are registered with libFuzzer by RegisterNewCounters. +// It is preallocated on the JavaScript side; registerNewCounters grows the +// active prefix that the fuzzing backends should observe. uint8_t *gCoverageCounters = nullptr; +std::size_t gCoverageCountersCapacity = 0; +std::size_t gCoverageCountersSize = 0; // PC-Table is used by libFuzzer to keep track of program addresses // corresponding to coverage counters. The flags determine whether the @@ -76,6 +80,7 @@ void RegisterCoverageMap(const Napi::CallbackInfo &info) { auto buf = info[0].As>(); gCoverageCounters = reinterpret_cast(buf.Data()); + gCoverageCountersCapacity = buf.Length(); } void RegisterNewCounters(const Napi::CallbackInfo &info) { @@ -96,28 +101,31 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { info.Env(), "new_num_counters must not be smaller than old_num_counters"); } + if (static_cast(new_num_counters) > gCoverageCountersCapacity) { + throw Napi::Error::New(info.Env(), + "new_num_counters exceeds the coverage map size"); + } if (new_num_counters == old_num_counters) { return; } RegisterCounterRange(gCoverageCounters + old_num_counters, gCoverageCounters + new_num_counters); + gCoverageCountersSize = static_cast(new_num_counters); } -// Register an independent coverage counter region for a single ES module. -// libFuzzer supports multiple disjoint counter regions; each call here -// hands it a fresh one. -void RegisterModuleCounters(const Napi::CallbackInfo &info) { - if (info.Length() != 1 || !info[0].IsBuffer()) { - throw Napi::Error::New(info.Env(), - "Need one argument: a Buffer of 8-bit counters"); - } +uint8_t *CoverageCounters() { return gCoverageCounters; } - auto buf = info[0].As>(); - auto size = buf.Length(); - if (size == 0) { +std::size_t CoverageCountersCapacity() { return gCoverageCountersCapacity; } + +std::size_t CoverageCountersSize() { return gCoverageCountersSize; } + +std::size_t *CoverageCountersSizePointer() { return &gCoverageCountersSize; } + +void ClearCoverageCounters() { + if (gCoverageCounters == nullptr || gCoverageCountersSize == 0) { return; } - RegisterCounterRange(buf.Data(), buf.Data() + size); + std::memset(gCoverageCounters, 0, gCoverageCountersSize); } diff --git a/packages/fuzzer/shared/coverage.h b/packages/fuzzer/shared/coverage.h index ffbd7333a..ac84c7514 100644 --- a/packages/fuzzer/shared/coverage.h +++ b/packages/fuzzer/shared/coverage.h @@ -13,8 +13,15 @@ // limitations under the License. #pragma once +#include +#include #include void RegisterCoverageMap(const Napi::CallbackInfo &info); void RegisterNewCounters(const Napi::CallbackInfo &info); -void RegisterModuleCounters(const Napi::CallbackInfo &info); + +uint8_t *CoverageCounters(); +std::size_t CoverageCountersCapacity(); +std::size_t CoverageCountersSize(); +std::size_t *CoverageCountersSizePointer(); +void ClearCoverageCounters(); diff --git a/packages/fuzzer/shared/libfuzzer.h b/packages/fuzzer/shared/libfuzzer.h index d243d67c0..748b94c19 100644 --- a/packages/fuzzer/shared/libfuzzer.h +++ b/packages/fuzzer/shared/libfuzzer.h @@ -20,6 +20,7 @@ namespace libfuzzer { extern void (*PrintCrashingInput)(); const int EXIT_ERROR_CODE = 77; +const int EXIT_ERROR_TIMEOUT = 70; // Signals should exit with code 128+n, see // https://tldp.org/LDP/abs/html/exitcodes.html diff --git a/packages/fuzzer/shared/tracing.cpp b/packages/fuzzer/shared/tracing.cpp index ee68e55d0..5172116ec 100644 --- a/packages/fuzzer/shared/tracing.cpp +++ b/packages/fuzzer/shared/tracing.cpp @@ -14,6 +14,10 @@ #include "tracing.h" +#include +#include +#include + // We expect these symbols to exist in the current plugin, provided either by // libfuzzer or by the native agent. extern "C" { @@ -26,6 +30,84 @@ void __sanitizer_cov_trace_const_cmp8_with_pc(uintptr_t called_pc, void __sanitizer_cov_trace_pc_indir_with_pc(void *caller_pc, uintptr_t callee); } +namespace { +std::array gCompareFeedbackMap{}; +JazzerLibAflCompareLog gCompareLog{}; + +constexpr uint8_t kCompareLogSignedFlag = 1 << 0; + +void RecordCompareFeedback(uint64_t value) { + auto index = static_cast(value % kCompareFeedbackMapSize); + auto &slot = gCompareFeedbackMap[index]; + slot = slot == 255 ? 1 : static_cast(slot + 1); +} + +uint8_t ClampCompareBytesLength(std::size_t length) { + return static_cast(std::min(length, kCompareLogEntryBytes)); +} + +void CopyCompareBytes(uint8_t *destination, const std::string &source) { + const auto copied = ClampCompareBytesLength(source.size()); + std::memset(destination, 0, kCompareLogEntryBytes); + if (copied == 0) { + return; + } + std::memcpy(destination, source.data(), copied); +} + +JazzerLibAflCompareLogEntry *NextCompareLogEntry() { + if (gCompareLog.used >= kCompareLogMaxEntries) { + gCompareLog.dropped++; + return nullptr; + } + auto *entry = &gCompareLog.entries[gCompareLog.used++]; + std::memset(entry, 0, sizeof(*entry)); + return entry; +} + +void RecordIntegerCompareLog(int64_t left, int64_t right) { + auto *entry = NextCompareLogEntry(); + if (entry == nullptr) { + return; + } + entry->kind = static_cast(JazzerLibAflCompareKind::kInteger); + if (left < 0 || right < 0) { + entry->flags |= kCompareLogSignedFlag; + } + entry->left_value = static_cast(left); + entry->right_value = static_cast(right); +} + +void RecordStringCompareLog(JazzerLibAflCompareKind kind, + const std::string &left, const std::string &right) { + auto *entry = NextCompareLogEntry(); + if (entry == nullptr) { + return; + } + entry->kind = static_cast(kind); + entry->left_len = ClampCompareBytesLength(left.size()); + entry->right_len = ClampCompareBytesLength(right.size()); + CopyCompareBytes(entry->left_bytes, left); + CopyCompareBytes(entry->right_bytes, right); +} + +void RecordStringFeedback(uint64_t id, const std::string &first, + const std::string &second) { + uint64_t hash = id * 0x9e3779b185ebca87ULL; + const auto limit = std::min({first.size(), second.size(), 32}); + hash ^= static_cast(first.size()) << 32; + hash ^= static_cast(second.size()) << 1; + for (std::size_t i = 0; i < limit; ++i) { + hash ^= static_cast(static_cast(first[i])) + << ((i % 8) * 8); + hash ^= static_cast(static_cast(second[i])) + << (((i + 3) % 8) * 8); + RecordCompareFeedback(hash + i); + } + RecordCompareFeedback(hash); +} +} // namespace + // Record a comparison between two strings in the target that returned unequal. void TraceUnequalStrings(const Napi::CallbackInfo &info) { if (info.Length() != 3) { @@ -38,6 +120,9 @@ void TraceUnequalStrings(const Napi::CallbackInfo &info) { auto s1 = info[1].As().Utf8Value(); auto s2 = info[2].As().Utf8Value(); + RecordStringFeedback(id, s1, s2); + RecordStringCompareLog(JazzerLibAflCompareKind::kStringEquality, s1, s2); + // strcmp returns zero on equality, and libfuzzer doesn't care about the // result beyond whether it's zero or not. __sanitizer_weak_hook_strcmp((void *)id, s1.c_str(), s2.c_str(), 1); @@ -55,6 +140,10 @@ void TraceStringContainment(const Napi::CallbackInfo &info) { auto needle = info[1].As().Utf8Value(); auto haystack = info[2].As().Utf8Value(); + RecordStringFeedback(id, needle, haystack); + RecordStringCompareLog(JazzerLibAflCompareKind::kStringContainment, needle, + haystack); + // libFuzzer currently ignores the result, which allows us to simply pass a // valid but arbitrary pointer here instead of performing an actual strstr // operation. @@ -72,6 +161,10 @@ void TraceIntegerCompare(const Napi::CallbackInfo &info) { auto id = info[0].As().Int64Value(); auto arg1 = info[1].As().Int64Value(); auto arg2 = info[2].As().Int64Value(); + RecordCompareFeedback(static_cast(id) ^ + static_cast(arg1) ^ + (static_cast(arg2) << 1)); + RecordIntegerCompareLog(arg1, arg2); __sanitizer_cov_trace_const_cmp8_with_pc(id, arg1, arg2); } @@ -83,5 +176,59 @@ void TracePcIndir(const Napi::CallbackInfo &info) { auto id = info[0].As().Int64Value(); auto state = info[1].As().Int64Value(); + RecordCompareFeedback(static_cast(id) ^ + (static_cast(state) << 1)); __sanitizer_cov_trace_pc_indir_with_pc((void *)id, state); } + +void ClearCompareFeedbackMap(const Napi::CallbackInfo &info) { + if (info.Length() != 0) { + throw Napi::Error::New(info.Env(), + "This function does not accept arguments"); + } + + ClearCompareFeedbackMap(); +} + +Napi::Value CountNonZeroCompareFeedbackSlots(const Napi::CallbackInfo &info) { + if (info.Length() != 0) { + throw Napi::Error::New(info.Env(), + "This function does not accept arguments"); + } + + const auto count = static_cast( + std::count_if(gCompareFeedbackMap.begin(), gCompareFeedbackMap.end(), + [](uint8_t value) { return value != 0; })); + return Napi::Number::New(info.Env(), count); +} + +Napi::Value CountCompareLogEntries(const Napi::CallbackInfo &info) { + if (info.Length() != 0) { + throw Napi::Error::New(info.Env(), + "This function does not accept arguments"); + } + + return Napi::Number::New(info.Env(), gCompareLog.used); +} + +Napi::Value CountDroppedCompareLogEntries(const Napi::CallbackInfo &info) { + if (info.Length() != 0) { + throw Napi::Error::New(info.Env(), + "This function does not accept arguments"); + } + + return Napi::Number::New(info.Env(), gCompareLog.dropped); +} + +uint8_t *CompareFeedbackMap() { return gCompareFeedbackMap.data(); } + +std::size_t CompareFeedbackMapSize() { return gCompareFeedbackMap.size(); } + +void ClearCompareFeedbackMap() { + std::memset(gCompareFeedbackMap.data(), 0, gCompareFeedbackMap.size()); + ClearCompareLog(); +} + +JazzerLibAflCompareLog *CompareLog() { return &gCompareLog; } + +void ClearCompareLog() { std::memset(&gCompareLog, 0, sizeof(gCompareLog)); } diff --git a/packages/fuzzer/shared/tracing.h b/packages/fuzzer/shared/tracing.h index d85c8e854..03bef091f 100644 --- a/packages/fuzzer/shared/tracing.h +++ b/packages/fuzzer/shared/tracing.h @@ -13,9 +13,49 @@ // limitations under the License. #pragma once +#include +#include #include +constexpr std::size_t kCompareFeedbackMapSize = 1 << 16; +constexpr std::size_t kCompareLogEntryBytes = 32; +constexpr std::size_t kCompareLogMaxEntries = 1024; + +enum class JazzerLibAflCompareKind : uint8_t { + kInteger = 1, + kStringEquality = 2, + kStringContainment = 3, +}; + +struct JazzerLibAflCompareLogEntry { + uint8_t kind; + uint8_t flags; + uint8_t left_len; + uint8_t right_len; + uint64_t left_value; + uint64_t right_value; + uint8_t left_bytes[kCompareLogEntryBytes]; + uint8_t right_bytes[kCompareLogEntryBytes]; +}; + +struct JazzerLibAflCompareLog { + uint32_t used; + uint32_t dropped; + JazzerLibAflCompareLogEntry entries[kCompareLogMaxEntries]; +}; + void TraceUnequalStrings(const Napi::CallbackInfo &info); void TraceStringContainment(const Napi::CallbackInfo &info); void TraceIntegerCompare(const Napi::CallbackInfo &info); void TracePcIndir(const Napi::CallbackInfo &info); + +void ClearCompareFeedbackMap(const Napi::CallbackInfo &info); +Napi::Value CountNonZeroCompareFeedbackSlots(const Napi::CallbackInfo &info); +Napi::Value CountCompareLogEntries(const Napi::CallbackInfo &info); +Napi::Value CountDroppedCompareLogEntries(const Napi::CallbackInfo &info); + +uint8_t *CompareFeedbackMap(); +std::size_t CompareFeedbackMapSize(); +void ClearCompareFeedbackMap(); +JazzerLibAflCompareLog *CompareLog(); +void ClearCompareLog(); diff --git a/packages/fuzzer/tsconfig.json b/packages/fuzzer/tsconfig.json index 8ef3f91ff..2d70535b6 100644 --- a/packages/fuzzer/tsconfig.json +++ b/packages/fuzzer/tsconfig.json @@ -4,5 +4,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["build", "dist", "cmake-build-*"] + "exclude": ["build", "dist", "runtime", "cmake-build-*"] } diff --git a/packages/instrumentor/edgeIdStrategy.ts b/packages/instrumentor/edgeIdStrategy.ts index dfc0c25e3..5ecac38ef 100644 --- a/packages/instrumentor/edgeIdStrategy.ts +++ b/packages/instrumentor/edgeIdStrategy.ts @@ -40,6 +40,7 @@ if (process.listeners) { export interface EdgeIdStrategy { nextEdgeId(): number; + reserveEdgeRange(filename: string, idCount: number): number; startForSourceFile(filename: string): void; commitIdCount(filename: string): void; } @@ -52,6 +53,15 @@ export abstract class IncrementingEdgeIdStrategy implements EdgeIdStrategy { return this._nextEdgeId++; } + reserveEdgeRange(_filename: string, idCount: number): number { + if (!Number.isInteger(idCount) || idCount < 0) { + throw new Error(`Invalid edge count: ${idCount}`); + } + const firstId = this._nextEdgeId; + this._nextEdgeId += idCount; + return firstId; + } + abstract startForSourceFile(filename: string): void; abstract commitIdCount(filename: string): void; } @@ -76,6 +86,29 @@ interface EdgeIdInfo { idCount: number; } +function parseIdInfoLine(line: string): EdgeIdInfo { + const parts = line.split(","); + if (parts.length !== 3) { + throw new Error( + `Expected ID file line to be ,,, got ` + + `"${line}"`, + ); + } + return { + filename: parts[0], + firstId: parseInt(parts[1], 10), + idCount: parseInt(parts[2], 10), + }; +} + +function nextFreeId(idInfo: EdgeIdInfo[]): number { + if (idInfo.length === 0) { + return 0; + } + const last = idInfo[idInfo.length - 1]; + return last.firstId + last.idCount; +} + /** * A strategy for edge ID generation that synchronizes the IDs assigned to a source file * with other processes via the specified `idSyncFile`. The edge information stored as a @@ -95,93 +128,69 @@ export class FileSyncIdStrategy extends IncrementingEdgeIdStrategy { } startForSourceFile(filename: string): void { - // We resort to busy waiting since the `Transformer` required by istanbul's `hookRequire` - // must be a synchronous function returning the transformed code. - for (;;) { - const isLocked = lock.checkSync(this.idSyncFile); - if (isLocked) { - // If the ID sync file is already locked, wait for a random period of time - // between 0 and 100 milliseconds. Waiting for different periods reduces - // the chance of all processes wanting to acquire the lock at the same time. - this.wait(this.randomIntFromInterval(0, 100)); - continue; - } - try { - // Acquire the lock for the ID sync file and look for the initial edge ID and - // corresponding number of inserted counters. - this.releaseLockOnSyncFile = lock.lockSync(this.idSyncFile); - const idInfo = fs - .readFileSync(this.idSyncFile, "utf8") - .toString() - .split(os.EOL) - .filter((line) => line.length !== 0) - .map((line): EdgeIdInfo => { - const parts = line.split(","); - if (parts.length !== 3) { - lock.unlockSync(this.idSyncFile); - throw Error( - `Expected ID file line to be of the form ,,", got "${line}"`, - ); - } - return { - filename: parts[0], - firstId: parseInt(parts[1], 10), - idCount: parseInt(parts[2], 10), - }; - }); - const idInfoForFile = idInfo.filter( - (info) => info.filename === filename, - ); + const idInfo = this.acquireLockAndReadIdInfo(); + const idInfoForFile = idInfo.filter((info) => info.filename === filename); - switch (idInfoForFile.length) { - case 0: - // We are the first to encounter this source file and thus need to hold the lock - // until the file has been instrumented and we know the required number of edge IDs. - // - // Compute the next free ID as the maximum over the sums of first ID and ID count, starting at 0 if - // this is the first ID to be assigned. Since this is the only way new lines are added to - // the file, the maximum is always attained by the last line. - this.firstEdgeId = - idInfo.length !== 0 - ? idInfo[idInfo.length - 1].firstId + - idInfo[idInfo.length - 1].idCount - : 0; - break; - case 1: - // This source file has already been instrumented elsewhere, so we just return the first ID and - // ID count reported from there and release the lock right away. The caller is still expected - // to call commitIdCount. - this.firstEdgeId = idInfoForFile[0].firstId; - this.cachedIdCount = idInfoForFile[0].idCount; - this.releaseLockOnSyncFile(); - break; - default: - this.releaseLockOnSyncFile(); - console.error( - `ERROR: Multiple entries for ${filename} in ID sync file`, - ); - process.exit(FileSyncIdStrategy.fatalExitCode); - } + switch (idInfoForFile.length) { + case 0: + // Keep the lock until commitIdCount() records the final range. + this.firstEdgeId = nextFreeId(idInfo); + this.cachedIdCount = undefined; break; - } catch (e) { - // Retry to wait for the lock to be release it is acquired by another process - // in the time window between last successful check and trying to acquire it. - if (this.isLockAlreadyHeldError(e)) { - continue; - } + case 1: + this.firstEdgeId = idInfoForFile[0].firstId; + this.cachedIdCount = idInfoForFile[0].idCount; + this.releaseLock(); + break; + default: + this.releaseLock(); + console.error( + `ERROR: Multiple entries for ${filename} in ID sync file`, + ); + process.exit(FileSyncIdStrategy.fatalExitCode); + } - // Before rethrowing the exception, release the lock if we have already acquired it. - if (this.releaseLockOnSyncFile !== undefined) { - this.releaseLockOnSyncFile(); - } + this._nextEdgeId = this.firstEdgeId; + } - // Stop waiting for the lock if we encounter other errors. Also, rethrow the error. - throw e; + reserveEdgeRange(filename: string, idCount: number): number { + const idInfo = this.acquireLockAndReadIdInfo(); + try { + const idInfoForFile = idInfo.filter((info) => info.filename === filename); + switch (idInfoForFile.length) { + case 0: { + const firstId = nextFreeId(idInfo); + fs.appendFileSync( + this.idSyncFile, + `${filename},${firstId},${idCount}${os.EOL}`, + ); + this._nextEdgeId = Math.max(this._nextEdgeId, firstId + idCount); + return firstId; + } + case 1: + if (idInfoForFile[0].idCount !== idCount) { + throw new Error( + `${filename} has ${idCount} edges, but ` + + `${idInfoForFile[0].idCount} edges reserved in ` + + "ID sync file", + ); + } + this._nextEdgeId = Math.max( + this._nextEdgeId, + idInfoForFile[0].firstId + idCount, + ); + return idInfoForFile[0].firstId; + default: + console.error( + `ERROR: Multiple entries for ${filename} in ID sync file`, + ); + process.exit(FileSyncIdStrategy.fatalExitCode); } + } finally { + this.releaseLock(); } - - this._nextEdgeId = this.firstEdgeId; } + commitIdCount(filename: string): void { if (this.firstEdgeId === undefined) { throw Error("commitIdCount() is called before startForSourceFile()"); @@ -210,13 +219,43 @@ export class FileSyncIdStrategy extends IncrementingEdgeIdStrategy { this.idSyncFile, `${filename},${this.firstEdgeId},${usedIdsCount}${os.EOL}`, ); - this.releaseLockOnSyncFile(); - this.releaseLockOnSyncFile = undefined; + this.releaseLock(); this.firstEdgeId = undefined; this.cachedIdCount = undefined; } } + private acquireLockAndReadIdInfo(): EdgeIdInfo[] { + for (;;) { + if (lock.checkSync(this.idSyncFile)) { + this.wait(this.randomIntFromInterval(0, 100)); + continue; + } + try { + this.releaseLockOnSyncFile = lock.lockSync(this.idSyncFile); + return fs + .readFileSync(this.idSyncFile, "utf8") + .toString() + .split(os.EOL) + .filter((line) => line.length !== 0) + .map(parseIdInfoLine); + } catch (e) { + if (this.isLockAlreadyHeldError(e)) { + continue; + } + this.releaseLock(); + throw e; + } + } + } + + private releaseLock() { + if (this.releaseLockOnSyncFile !== undefined) { + this.releaseLockOnSyncFile(); + this.releaseLockOnSyncFile = undefined; + } + } + private wait(timeout: number) { // This is a workaround to synchronously sleep for a `timout` milliseconds. // The static Atomics.wait() method verifies that a given position in an Int32Array @@ -241,6 +280,10 @@ export class ZeroEdgeIdStrategy implements EdgeIdStrategy { return 0; } + reserveEdgeRange(_filename: string, _idCount: number): number { + return 0; + } + startForSourceFile(filename: string): void { // Nothing to do here } diff --git a/packages/instrumentor/esm-loader.mts b/packages/instrumentor/esm-loader.mts index 72ec199c4..5c4864062 100644 --- a/packages/instrumentor/esm-loader.mts +++ b/packages/instrumentor/esm-loader.mts @@ -167,6 +167,10 @@ function instrumentModule(code: string, filename: string): string | null { filename, sourceFileName: filename, sourceMaps: true, + // Ignore host-project Babel config so ESM instrumentation keeps the + // module format intact and doesn't inherit target-specific transforms. + babelrc: false, + configFile: false, plugins, sourceType: "module", }); @@ -187,7 +191,7 @@ function instrumentModule(code: string, filename: string): string | null { // SourceMapRegistry so that source-map-support can remap stack // traces back to the original source. const preambleLines = [ - `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${edges});`, + `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${JSON.stringify(filename)}, ${edges});`, ]; if (transformed.map) { diff --git a/packages/instrumentor/esmSourceMaps.test.ts b/packages/instrumentor/esmSourceMaps.test.ts index 27900356a..5101cbe11 100644 --- a/packages/instrumentor/esmSourceMaps.test.ts +++ b/packages/instrumentor/esmSourceMaps.test.ts @@ -52,7 +52,7 @@ function instrumentModule( } const preambleLines = [ - `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${edges});`, + `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${JSON.stringify(filename)}, ${edges});`, ]; let shiftedMap: SourceMap | null = null; diff --git a/packages/instrumentor/instrument.ts b/packages/instrumentor/instrument.ts index d1cd194f3..d7b66c879 100644 --- a/packages/instrumentor/instrument.ts +++ b/packages/instrumentor/instrument.ts @@ -104,6 +104,10 @@ export class Instrumentor { filename: string, map: SourceMap, ) => registry.registerSourceMap(filename, map); + (globalThis as Record).__jazzer_reserveCoverageRange = ( + filename: string, + idCount: number, + ) => this.idStrategy.reserveEdgeRange(filename, idCount); return this.sourceMapRegistry.installSourceMapSupport(); } @@ -187,6 +191,10 @@ export class Instrumentor { filename: filename, sourceFileName: filename, sourceMaps: true, + // Ignore host-project Babel config so Jazzer's runtime transforms stay + // deterministic and don't pick up polyfill injection from the fuzz target. + babelrc: false, + configFile: false, plugins: plugins, ...options, }); diff --git a/tests/bug-detectors/general.test.js b/tests/bug-detectors/general.test.js index f94d5c79c..13fe74e29 100644 --- a/tests/bug-detectors/general.test.js +++ b/tests/bug-detectors/general.test.js @@ -176,6 +176,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalEvil") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(200) .forkMode(3) .build(); @@ -195,6 +196,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalFriendly") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(200) .forkMode(3) .build(); @@ -214,6 +216,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalEvilAsync") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(10) .forkMode(3) .build(); @@ -233,6 +236,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalFriendlyAsync") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(200) .forkMode(3) .build(); diff --git a/tests/code_coverage/coverage.test.js b/tests/code_coverage/coverage.test.js index 0d18f1dba..8d8a473a8 100644 --- a/tests/code_coverage/coverage.test.js +++ b/tests/code_coverage/coverage.test.js @@ -222,7 +222,15 @@ function executeFuzzTest( verbose = false, ) { removeCoverageDir(coverageOutputDir); - let options = ["jazzer", "fuzz", "-e", excludePattern, "--corpus", "corpus"]; + let options = [ + "jazzer", + "fuzz", + "--engine=libfuzzer", + "-e", + excludePattern, + "--corpus", + "corpus", + ]; // add dry run option if (dryRun) options.push("-d"); if (includeLib) { diff --git a/tests/done_callback/package.json b/tests/done_callback/package.json index 0d54782fb..50daba017 100644 --- a/tests/done_callback/package.json +++ b/tests/done_callback/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles callback based fuzz targets", "scripts": { - "fuzz": "jazzer fuzz --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=2386907168", - "dryRun": "jazzer fuzz -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=2386907168", + "dryRun": "jazzer fuzz --engine=libfuzzer -- -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/engine/engine.test.js b/tests/engine/engine.test.js new file mode 100644 index 000000000..9ef5d112a --- /dev/null +++ b/tests/engine/engine.test.js @@ -0,0 +1,437 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + cleanCrashFilesIn, + FuzzingExitCode, + FuzzTestBuilder, + JestRegressionExitCode, + TimeoutExitCode, +} = require("../helpers.js"); + +async function withTempGuidanceDirectory(callback) { + const directory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "jazzer-libafl-guidance-"), + ); + try { + return await callback(directory); + } finally { + await fs.promises.rm(directory, { force: true, recursive: true }); + } +} + +function runLibAflCli(cwd, entryPoint, extraFuzzerOptions = [], extraEnv = {}) { + const proc = spawnSync( + "npx", + [ + "jazzer", + "fuzz.js", + "-f", + entryPoint, + "--engine=afl", + "--sync", + "--disable_bug_detectors=.*", + "--", + ...extraFuzzerOptions, + ], + { + cwd, + env: { ...process.env, ...extraEnv }, + shell: true, + stdio: "pipe", + windowsHide: true, + }, + ); + return { + status: proc.status, + output: proc.stdout.toString() + proc.stderr.toString(), + }; +} + +function findOutputLine(output, prefix) { + return output.split(/\r?\n/).find((line) => line.startsWith(prefix)); +} + +describe("Engine selection", () => { + const testDirectory = __dirname; + const jestProjectDirectory = path.join(testDirectory, "jest_project"); + + beforeEach(async () => { + await cleanCrashFilesIn(testDirectory); + await cleanCrashFilesIn(jestProjectDirectory); + }); + + describe("CLI fuzzing", () => { + it("runs with the LibAFL backend", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(testDirectory) + .fuzzEntryPoint("fuzz") + .disableBugDetectors([".*"]) + .engine("afl") + .runs(250) + .seed(1337) + .build() + .execute(); + + expect(fuzzTest.stderr).not.toContain("Unknown fuzzing engine"); + expect(fuzzTest.stderr).toContain("[>] INITED"); + expect(fuzzTest.stderr).toContain(" mode: fuzzing"); + expect(fuzzTest.stderr).toContain(" seed: 1337"); + expect(fuzzTest.stderr).toContain(" loaded_inputs: 1"); + expect(fuzzTest.stderr).toMatch(/ edges:\s{10}\S/); + expect(fuzzTest.stderr).toContain(" timeout: 5000 ms"); + expect(fuzzTest.stderr).toContain(" max_len: 4096"); + expect(fuzzTest.stderr).toContain(" runs: 250"); + expect(fuzzTest.stderr).toContain(" max_total_time: unlimited"); + expect(fuzzTest.stderr).toContain("[=] #"); + expect(fuzzTest.stderr).toContain("| DONE"); + }); + + it("prints aligned testcase, heartbeat, and done lines", async () => { + await withTempGuidanceDirectory(async (directory) => { + const { status, output } = runLibAflCli( + testDirectory, + "fuzz", + [ + "-max_total_time=1", + "-seed=1337", + "-max_len=32", + `-artifact_prefix=${directory}${path.sep}`, + ], + { JAZZER_LIBAFL_MONITOR_TIMEOUT_MS: "50" }, + ); + + expect(status).toBe(0); + const testcaseLine = findOutputLine(output, "[i]"); + const heartbeatLine = findOutputLine(output, "[*]"); + + expect(testcaseLine).toBeDefined(); + expect(heartbeatLine).toBeDefined(); + expect(testcaseLine.indexOf("| edges:")).toBe( + heartbeatLine.indexOf("| edges:"), + ); + expect(testcaseLine.indexOf("| corp:")).toBe( + heartbeatLine.indexOf("| corp:"), + ); + expect(testcaseLine.indexOf("| exec/s:")).toBe( + heartbeatLine.indexOf("| exec/s:"), + ); + expect(testcaseLine.indexOf("| obj:")).toBe( + heartbeatLine.indexOf("| obj:"), + ); + expect(testcaseLine.indexOf("| stab:")).toBe( + heartbeatLine.indexOf("| stab:"), + ); + expect(testcaseLine.indexOf("| t:")).toBe( + heartbeatLine.indexOf("| t:"), + ); + + expect(output).toContain("[=] #"); + expect(output).toContain("| DONE"); + expect(output).toContain("reason: max_total_time"); + expect(output).toContain("time: "); + expect(output).toContain("edges: "); + expect(output).toContain("crashes: 0"); + expect(output).toContain("speed: "); + }); + }); + + it("only reports power-of-two testcase milestones while loading corpus", async () => { + await withTempGuidanceDirectory(async (directory) => { + const corpusDirectory = path.join(directory, "seed-corpus"); + await fs.promises.mkdir(corpusDirectory, { recursive: true }); + + for (let i = 1; i <= 6; i++) { + await fs.promises.writeFile( + path.join(corpusDirectory, `seed-${i}.txt`), + Buffer.from([i]), + ); + } + + const { status, output } = runLibAflCli( + testDirectory, + "seed_progress", + [ + corpusDirectory, + "-runs=1", + "-seed=1337", + "-max_len=32", + `-artifact_prefix=${directory}${path.sep}`, + ], + { JAZZER_LIBAFL_MONITOR_TIMEOUT_MS: "1" }, + ); + + expect(status).toBe(0); + const initOutput = output.split("[>] INITED", 1)[0]; + expect(initOutput).not.toContain("[*]"); + const testcaseLines = initOutput + .split(/\r?\n/) + .filter((line) => line.startsWith("[i]")); + + expect(testcaseLines).toHaveLength(4); + expect(testcaseLines[0]).toContain("| corp: 1 |"); + expect(testcaseLines[1]).toContain("| corp: 2 |"); + expect(testcaseLines[2]).toContain("| corp: 4 |"); + expect(testcaseLines[3]).toContain("| corp: 6 |"); + }); + }); + + it("rejects unsupported libFuzzer options in LibAFL mode", () => { + const fuzzTest = new FuzzTestBuilder() + .dir(testDirectory) + .fuzzEntryPoint("fuzz") + .disableBugDetectors([".*"]) + .engine("afl") + .forkMode(1) + .runs(1) + .build(); + + expect(() => fuzzTest.execute()).toThrow(FuzzingExitCode); + }); + + it("supports regression mode in LibAFL mode", async () => { + const corpusDirectory = path.join(testDirectory, "regression_corpus"); + await fs.promises.rm(corpusDirectory, { force: true, recursive: true }); + await fs.promises.mkdir(corpusDirectory, { recursive: true }); + await fs.promises.writeFile( + path.join(corpusDirectory, "seed"), + "afl-regression-hit", + ); + + try { + const proc = spawnSync( + "npx", + [ + "jazzer", + "fuzz", + "-f", + "regression", + "--engine=afl", + "--mode=regression", + "--disable_bug_detectors=.*", + "--", + corpusDirectory, + ], + { + cwd: testDirectory, + env: { ...process.env }, + shell: true, + stdio: "pipe", + windowsHide: true, + }, + ); + + expect(proc.status).toBe(Number(FuzzingExitCode)); + const output = proc.stdout.toString() + proc.stderr.toString(); + expect(output).toContain("[>] INITED"); + expect(output).toContain(" mode: regression"); + expect(output).toMatch(/ seed:\s+\d+/); + expect(output).toContain(" loaded_inputs: 2"); + expect(output).toContain(" edges: -/ - ( -%)"); + expect(output).toContain(" timeout: 5000 ms"); + expect(output).toContain(" max_len: 4096"); + expect(output).toContain(" runs: unlimited"); + expect(output).toContain(" max_total_time: unlimited"); + expect(output).toContain("AFL regression finding"); + } finally { + await fs.promises.rm(corpusDirectory, { + force: true, + recursive: true, + }); + } + }); + + it("finds integer comparisons with LibAFL compare guidance", async () => { + await withTempGuidanceDirectory(async (directory) => { + const corpusDirectory = path.join(directory, "numeric-corpus"); + await fs.promises.mkdir(corpusDirectory, { recursive: true }); + await fs.promises.writeFile( + path.join(corpusDirectory, "seed"), + Buffer.alloc(4), + ); + + const { status, output } = runLibAflCli( + testDirectory, + "guided_numeric", + [ + corpusDirectory, + "-runs=4000", + "-seed=1337", + "-max_len=16", + `-artifact_prefix=${directory}${path.sep}`, + ], + ); + + expect(status).toBe(Number(FuzzingExitCode)); + expect(output).toContain("AFL numeric guidance finding"); + }); + }); + + it("promotes equality targets into LibAFL tokens", async () => { + await withTempGuidanceDirectory(async (directory) => { + const corpusDirectory = path.join(directory, "equality-corpus"); + await fs.promises.mkdir(corpusDirectory, { recursive: true }); + await fs.promises.writeFile(path.join(corpusDirectory, "seed"), ""); + + const { status, output } = runLibAflCli( + testDirectory, + "guided_equality", + [ + corpusDirectory, + "-runs=4000", + "-seed=1441", + "-max_len=32", + `-artifact_prefix=${directory}${path.sep}`, + ], + ); + + expect(status).toBe(Number(FuzzingExitCode)); + expect(output).toContain("AFL equality guidance finding"); + expect(output).toMatch( + /\[!\] #\d+\s+\| artifact: crash-[0-9a-f]+ \| Error: AFL equality guidance finding/, + ); + }); + }); + + it("promotes containment needles into LibAFL tokens", async () => { + await withTempGuidanceDirectory(async (directory) => { + const corpusDirectory = path.join(directory, "containment-corpus"); + await fs.promises.mkdir(corpusDirectory, { recursive: true }); + await fs.promises.writeFile(path.join(corpusDirectory, "seed"), ""); + + const { status, output } = runLibAflCli( + testDirectory, + "guided_containment", + [ + corpusDirectory, + "-runs=4000", + "-seed=1777", + "-max_len=32", + `-artifact_prefix=${directory}${path.sep}`, + ], + ); + + expect(status).toBe(Number(FuzzingExitCode)); + expect(output).toContain("AFL containment guidance finding"); + }); + }); + + it("uses dictionaries with LibAFL token mutations", async () => { + await withTempGuidanceDirectory(async (directory) => { + const corpusDirectory = path.join(directory, "dictionary-corpus"); + const dictionaryPath = path.join(directory, "tokens.dict"); + await fs.promises.mkdir(corpusDirectory, { recursive: true }); + await fs.promises.writeFile(path.join(corpusDirectory, "seed"), ""); + await fs.promises.writeFile(dictionaryPath, '"from-dictionary"\n'); + + const { status, output } = runLibAflCli( + testDirectory, + "dictionary_target", + [ + corpusDirectory, + "-runs=4000", + "-seed=2333", + "-max_len=32", + `-dict=${dictionaryPath}`, + `-artifact_prefix=${directory}${path.sep}`, + ], + ); + + expect(status).toBe(Number(FuzzingExitCode)); + expect(output).toContain("AFL dictionary guidance finding"); + }); + }); + + it("fails fast on asynchronous hangs in LibAFL mode", async () => { + const fuzzTest = new FuzzTestBuilder() + .dir(testDirectory) + .fuzzEntryPoint("timeout_async") + .disableBugDetectors([".*"]) + .engine("afl") + .runs(1) + .timeout(200) + .build(); + + expect(() => fuzzTest.execute()).toThrow(TimeoutExitCode); + expect(fuzzTest.stderr).toContain("Exceeded timeout"); + const crashFiles = await cleanCrashFilesIn(testDirectory); + expect(crashFiles).toHaveLength(1); + expect(crashFiles[0]).toContain("timeout-"); + }); + + it("fails fast on synchronous hangs in LibAFL mode", async () => { + const fuzzTest = new FuzzTestBuilder() + .dir(testDirectory) + .fuzzEntryPoint("timeout_sync") + .disableBugDetectors([".*"]) + .engine("afl") + .sync(true) + .runs(1) + .timeout(200) + .build(); + + expect(() => fuzzTest.execute()).toThrow(TimeoutExitCode); + expect(fuzzTest.stderr).toContain("Exceeded timeout"); + const crashFiles = await cleanCrashFilesIn(testDirectory); + expect(crashFiles).toHaveLength(1); + expect(crashFiles[0]).toContain("timeout-"); + }); + }); + + describe("Jest integration", () => { + it("runs fuzzing mode with the LibAFL backend", async () => { + const fuzzTest = new FuzzTestBuilder() + .dir(jestProjectDirectory) + .disableBugDetectors([".*"]) + .engine("afl") + .jestRunInFuzzingMode(true) + .jestTestFile("jest.fuzz.js") + .jestTestName("afl engine smoke finding") + .runs(500) + .build(); + + expect(() => fuzzTest.execute()).toThrow(JestRegressionExitCode); + expect(fuzzTest.stdout + fuzzTest.stderr).toContain( + "AFL engine smoke finding", + ); + await cleanCrashFilesIn(jestProjectDirectory); + }); + + it("surfaces timeout failures in Jest fuzzing mode", async () => { + const fuzzTest = new FuzzTestBuilder() + .dir(jestProjectDirectory) + .disableBugDetectors([".*"]) + .engine("afl") + .jestRunInFuzzingMode(true) + .jestTestFile("jest.fuzz.js") + .jestTestName("afl engine timeout finding") + .timeout(200) + .runs(1) + .build(); + + expect(() => fuzzTest.execute()).toThrow(TimeoutExitCode); + expect(fuzzTest.stderr).toContain("Exceeded timeout"); + const crashFiles = await cleanCrashFilesIn(jestProjectDirectory); + expect(crashFiles).toHaveLength(1); + expect(crashFiles[0]).toContain("timeout-"); + }); + }); +}); diff --git a/tests/engine/fuzz.js b/tests/engine/fuzz.js new file mode 100644 index 000000000..35f4a4f3a --- /dev/null +++ b/tests/engine/fuzz.js @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.fuzz = function (data) { + if (data.length > 1024 * 1024) { + throw new Error("Unexpectedly large input"); + } +}; + +module.exports.timeout_sync = function (_data) { + while (true) { + // Busy loop on purpose to exercise hard timeout handling. + } +}; + +module.exports.timeout_async = function (_data) { + return new Promise(() => { + // Never resolve on purpose to exercise cooperative timeout handling. + }); +}; + +module.exports.regression = function (data) { + if (data.toString() === "afl-regression-hit") { + throw new Error("AFL regression finding"); + } +}; + +module.exports.guided_numeric = function (data) { + if (data.length < 4) { + return; + } + + const value = data.readUInt32LE(0); + if (Fuzzer.tracer.traceNumberCmp(value, 0x41424344, "===", 2001)) { + throw new Error("AFL numeric guidance finding"); + } +}; + +module.exports.guided_equality = function (data) { + const text = data.toString("utf8"); + Fuzzer.tracer.guideTowardsEquality(text, "libafl=eq", 2002); + if (text === "libafl=eq") { + throw new Error("AFL equality guidance finding"); + } +}; + +module.exports.guided_containment = function (data) { + const text = data.toString("utf8"); + Fuzzer.tracer.guideTowardsContainment("afl-token", text, 2003); + if (text.includes("afl-token")) { + throw new Error("AFL containment guidance finding"); + } +}; + +module.exports.dictionary_target = function (data) { + if (data.toString("utf8").includes("from-dictionary")) { + throw new Error("AFL dictionary guidance finding"); + } +}; + +module.exports.seed_progress = function (data) { + const firstByte = data[0] ?? 0; + switch (firstByte) { + case 1: + return; + case 2: + return; + case 3: + return; + case 4: + return; + case 5: + return; + case 6: + return; + case 7: + return; + case 8: + return; + default: + return; + } +}; diff --git a/tests/engine/jest_project/.gitignore b/tests/engine/jest_project/.gitignore new file mode 100644 index 000000000..ee9b755c4 --- /dev/null +++ b/tests/engine/jest_project/.gitignore @@ -0,0 +1,3 @@ +.jazzerjsrc.json +.cifuzz-corpus +jest.fuzz diff --git a/tests/engine/jest_project/jest.config.js b/tests/engine/jest_project/jest.config.js new file mode 100644 index 000000000..dd3b0bd12 --- /dev/null +++ b/tests/engine/jest_project/jest.config.js @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports = { + testRunner: "@jazzer.js/jest-runner", + testEnvironment: "node", + testMatch: ["/*.fuzz.js"], + testTimeout: 60000, +}; diff --git a/tests/engine/jest_project/jest.fuzz.js b/tests/engine/jest_project/jest.fuzz.js new file mode 100644 index 000000000..dc560cb1a --- /dev/null +++ b/tests/engine/jest_project/jest.fuzz.js @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +require("@jazzer.js/jest-runner"); + +describe("AFL engine", () => { + it.fuzz("afl engine smoke finding", (data) => { + if (data.length > 0) { + throw new Error("AFL engine smoke finding"); + } + }); + + it.fuzz("afl engine timeout finding", async (_data) => { + await new Promise(() => { + // Never resolve on purpose. + }); + }); +}); diff --git a/tests/engine/package.json b/tests/engine/package.json new file mode 100644 index 000000000..065c4016f --- /dev/null +++ b/tests/engine/package.json @@ -0,0 +1,20 @@ +{ + "name": "jazzerjs-engine-tests", + "version": "1.0.0", + "description": "Engine selection integration tests.", + "scripts": { + "fuzz": "jest" + }, + "devDependencies": { + "@jazzer.js/core": "file:../../packages/core", + "@jazzer.js/jest-runner": "file:../../packages/jest-runner", + "@types/jest": "^29.5.3", + "jest": "^29.6.2", + "ts-jest": "^29.0.5", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + }, + "jest": { + "testTimeout": 60000 + } +} diff --git a/tests/esm_cjs_mixed/esm_cjs_mixed.test.js b/tests/esm_cjs_mixed/esm_cjs_mixed.test.js index c0e7519fc..66d3ce21b 100644 --- a/tests/esm_cjs_mixed/esm_cjs_mixed.test.js +++ b/tests/esm_cjs_mixed/esm_cjs_mixed.test.js @@ -47,4 +47,21 @@ describeOrSkip("Mixed CJS + ESM instrumentation", () => { fuzzTest.execute(); expect(fuzzTest.stderr).toContain("Found the mixed CJS+ESM secret!"); }); + + it("should report real edge coverage with the LibAFL backend", () => { + const fuzzTest = new FuzzTestBuilder() + .fuzzEntryPoint("fuzz") + .fuzzFile("fuzz.mjs") + .dir(__dirname) + .engine("afl") + .disableBugDetectors([".*"]) + .runs(1) + .seed(1337) + .build(); + + fuzzTest.execute(); + expect(fuzzTest.stderr).toContain("[>] INITED"); + expect(fuzzTest.stderr).toMatch(/\bedges:\s+\d+\/\d+/); + expect(fuzzTest.stderr).not.toContain("edges: -/ - ( -%)"); + }); }); diff --git a/tests/esm_instrumentation/esm_instrumentation.test.js b/tests/esm_instrumentation/esm_instrumentation.test.js index 957b3efee..45e6fc419 100644 --- a/tests/esm_instrumentation/esm_instrumentation.test.js +++ b/tests/esm_instrumentation/esm_instrumentation.test.js @@ -70,4 +70,21 @@ describeOrSkip("ESM instrumentation", () => { fuzzTest.execute(); expect(fuzzTest.stderr).toContain("Found the ESM secret!"); }); + + it("should report edges for a lazily imported ESM module in LibAFL", () => { + const fuzzTest = new FuzzTestBuilder() + .fuzzEntryPoint("fuzz") + .fuzzFile("fuzz-lazy.mjs") + .dir(__dirname) + .engine("afl") + .disableBugDetectors([".*"]) + .runs(1) + .seed(1337) + .build(); + + fuzzTest.execute(); + expect(fuzzTest.stderr).toContain("[>] INITED"); + expect(fuzzTest.stderr).toMatch(/\bedges:\s+\d+\/\d+/); + expect(fuzzTest.stderr).not.toContain("edges: -/ - ( -%)"); + }); }); diff --git a/tests/esm_instrumentation/fuzz-lazy.mjs b/tests/esm_instrumentation/fuzz-lazy.mjs new file mode 100644 index 000000000..614c3a2ed --- /dev/null +++ b/tests/esm_instrumentation/fuzz-lazy.mjs @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @param { Buffer } data + */ +export async function fuzz(data) { + const { checkSecret } = await import("./target.mjs"); + checkSecret(data.toString()); +} diff --git a/tests/fork_mode/package.json b/tests/fork_mode/package.json index f04ed1d4b..41b9cf5da 100644 --- a/tests/fork_mode/package.json +++ b/tests/fork_mode/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how to use libFuzzer's fork mode in Jazzer.js", "scripts": { - "fuzz": "jazzer fuzz --sync --disableBugDetectors='.*' -- -fork=3", - "dryRun": "jazzer fuzz --sync -- -fork=3 -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --sync --disableBugDetectors='.*' -- -fork=3", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync -- -fork=3 -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/helpers.js b/tests/helpers.js index 8ca16ac41..aa23ea8f4 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -49,6 +49,7 @@ class FuzzTest { expectedErrors, asJson, timeout, + engine, ) { this.logTestOutput = logTestOutput; this.includes = includes; @@ -74,6 +75,7 @@ class FuzzTest { this.expectedErrors = expectedErrors; this.asJson = asJson; this.timeout = timeout; + this.engine = engine; } // Runs the fuzz test in another process using `spawnSync`. @@ -104,6 +106,7 @@ class FuzzTest { if (this.verbose) options.push("--verbose"); if (this.dryRun !== undefined) options.push("--dry_run=" + this.dryRun); if (this.timeout !== undefined) options.push("--timeout=" + this.timeout); + if (this.engine !== undefined) options.push("--engine=" + this.engine); for (const include of this.includes) { options.push("-i=" + include); } @@ -177,6 +180,9 @@ class FuzzTest { if (this.verbose) { config.verbose = this.verbose; } + if (this.engine !== undefined) { + config.engine = this.engine; + } // Write jest config file even if it exists fs.writeFileSync( @@ -298,6 +304,7 @@ class FuzzTestBuilder { _expectedErrors = []; _asJson = false; _timeout = undefined; + _engine = "libfuzzer"; /** * @param {boolean} logTestOutput - whether to print the output of the fuzz test to the console. @@ -502,6 +509,11 @@ class FuzzTestBuilder { return this; } + engine(engine) { + this._engine = engine; + return this; + } + build() { if (this._jestTestFile === "" && this._fuzzEntryPoint === "") { throw new Error("fuzzEntryPoint or jestTestFile are not set."); @@ -536,6 +548,7 @@ class FuzzTestBuilder { this._expectedErrors, this._asJson, this._timeout, + this._engine, ); } } diff --git a/tests/promise/package.json b/tests/promise/package.json index 0ec55ccc3..31fdd35de 100644 --- a/tests/promise/package.json +++ b/tests/promise/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles promise based fuzz targets", "scripts": { - "fuzz": "jazzer fuzz --fuzz_function fuzz_promise --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz --fuzz_function fuzz_promise -- -runs=1 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --fuzz_function fuzz_promise --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=3088388356", + "dryRun": "jazzer fuzz --engine=libfuzzer --fuzz_function fuzz_promise -- -runs=1 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/string_compare/package.json b/tests/string_compare/package.json index 76b5fbd16..5e93c4cc4 100644 --- a/tests/string_compare/package.json +++ b/tests/string_compare/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles string comparisons in the code", "scripts": { - "fuzz": "jazzer fuzz --sync --disableBugDetectors='.*' -x Error -- -runs=5000000 -seed=111994470", - "dryRun": "jazzer fuzz --sync -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --sync --disableBugDetectors='.*' -x Error -- -runs=5000000 -seed=111994470", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync -- -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/timeout/package.json b/tests/timeout/package.json index f35a9da32..00862b521 100644 --- a/tests/timeout/package.json +++ b/tests/timeout/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "Timeout test: checking that the handler for the SIGALRM signal does not return with error code.", "scripts": { - "timeout": "jazzer fuzz -f=timeout --timeout=1000 --disableBugDetectors='.*' -- -runs=5000 -seed=1234", - "fuzz": "jazzer fuzz --timeout=1000 -- -runs=5000 -seed=1234", + "timeout": "jazzer fuzz --engine=libfuzzer -f=timeout --timeout=1000 --disableBugDetectors='.*' -- -runs=5000 -seed=1234", + "fuzz": "jazzer fuzz --engine=libfuzzer --timeout=1000 -- -runs=5000 -seed=1234", "dryRun": "echo \"skipped\"" }, "devDependencies": { diff --git a/tests/value_profiling/package.json b/tests/value_profiling/package.json index 38b5e9ca9..fedb62155 100644 --- a/tests/value_profiling/package.json +++ b/tests/value_profiling/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles integer comparisons in the code", "scripts": { - "fuzz": "jazzer fuzz --sync --disableBugDetectors='.*' -x Error -- -runs=4000000 -seed=1428686921 -use_value_profile=1", - "dryRun": "jazzer fuzz --sync -- -use_value_profile=1 -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --sync --disableBugDetectors='.*' -x Error -- -runs=4000000 -seed=1428686921 -use_value_profile=1", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync -- -use_value_profile=1 -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core"