From a1dd1901cea3e712a066e94f536cc527bf471319 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:30:00 +0200 Subject: [PATCH 01/10] feat(options): add shared option package Centralizing option contracts lets core, Jest, and native backends agree on engine-independent settings without creating package import cycles. --- package-lock.json | 16 + packages/core/package.json | 1 + packages/core/tsconfig.json | 3 + packages/jest-runner/package.json | 1 + packages/jest-runner/tsconfig.json | 3 + packages/options/index.test.ts | 438 ++++++++++++++++++++++ packages/options/index.ts | 564 +++++++++++++++++++++++++++++ packages/options/package.json | 22 ++ packages/options/tsconfig.json | 8 + tsconfig.build.json | 1 + tsconfig.json | 3 +- 11 files changed, 1059 insertions(+), 1 deletion(-) create mode 100644 packages/options/index.test.ts create mode 100644 packages/options/index.ts create mode 100644 packages/options/package.json create mode 100644 packages/options/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 2b609127b..3d0908f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -921,6 +921,10 @@ "resolved": "packages/jest-runner", "link": true }, + "node_modules/@jazzer.js/options": { + "resolved": "packages/options", + "link": true + }, "node_modules/@jest/console": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", @@ -7690,6 +7694,7 @@ "@jazzer.js/fuzzer": "4.0.0", "@jazzer.js/hooking": "4.0.0", "@jazzer.js/instrumentor": "4.0.0", + "@jazzer.js/options": "4.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.7", @@ -7712,6 +7717,7 @@ "version": "4.0.0", "license": "Apache-2.0", "dependencies": { + "@jazzer.js/options": "4.0.0", "bindings": "^1.5.0", "cmake-js": "^8.0.0", "node-addon-api": "^8.7.0" @@ -7779,6 +7785,7 @@ "license": "Apache-2.0", "dependencies": { "@jazzer.js/core": "4.0.0", + "@jazzer.js/options": "4.0.0", "cosmiconfig": "^9.0.0", "istanbul-reports": "^3.1.7" }, @@ -7794,6 +7801,15 @@ "@types/jest": ">=29.0.0", "jest": ">=29.0.0" } + }, + "packages/options": { + "name": "@jazzer.js/options", + "version": "4.0.0", + "license": "Apache-2.0", + "engines": { + "node": ">= 14.0.0", + "npm": ">= 7.0.0" + } } } } diff --git a/packages/core/package.json b/packages/core/package.json index e1fe149bd..5177accd4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "@jazzer.js/fuzzer": "4.0.0", "@jazzer.js/hooking": "4.0.0", "@jazzer.js/instrumentor": "4.0.0", + "@jazzer.js/options": "4.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.7", diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index e06fd9a36..0b03d8920 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -6,6 +6,9 @@ }, "exclude": ["dist"], "references": [ + { + "path": "../options" + }, { "path": "../instrumentor" }, diff --git a/packages/jest-runner/package.json b/packages/jest-runner/package.json index 9bcde0fe7..cfba43dec 100644 --- a/packages/jest-runner/package.json +++ b/packages/jest-runner/package.json @@ -17,6 +17,7 @@ "types": "dist/index.d.ts", "dependencies": { "@jazzer.js/core": "4.0.0", + "@jazzer.js/options": "4.0.0", "cosmiconfig": "^9.0.0", "istanbul-reports": "^3.1.7" }, diff --git a/packages/jest-runner/tsconfig.json b/packages/jest-runner/tsconfig.json index 280fd6161..605a1abf6 100644 --- a/packages/jest-runner/tsconfig.json +++ b/packages/jest-runner/tsconfig.json @@ -6,6 +6,9 @@ }, "exclude": ["dist"], "references": [ + { + "path": "../options" + }, { "path": "../core" } diff --git a/packages/options/index.test.ts b/packages/options/index.test.ts new file mode 100644 index 000000000..3049f6461 --- /dev/null +++ b/packages/options/index.test.ts @@ -0,0 +1,438 @@ +/* + * 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 { + defaultCLIOptions, + defaultJestOptions, + fromSnakeCase, + fromSnakeCaseWithPrefix, + Mode, + Options, + OptionsManager, + OptionSource, + resolveEngine, + resolveMode, + validateKeySource, +} from "./index"; + +describe("options", () => { + describe("OptionsManager", () => { + it("mergeInPlace: options of type string[] are copied", () => { + const input = ["1", "2", "3"]; + const v0 = "CHANGED"; + const v1 = "CHANGED AGAIN"; + + Object.keys(defaultCLIOptions).forEach((key) => { + if (defaultCLIOptions[key as keyof Options] instanceof Array) { + mutateArrayAndCheck(key as keyof Options, input, v0, v1); + } + }); + }); + + it("mergeInPlace: Uint8Array is copied", () => { + const originalArray = new Uint8Array([0, 1, 2, 3, 4, 5]); + const options = new OptionsManager(OptionSource.DefaultCLIOptions); + options.merge( + { dictionaryEntries: [originalArray] }, + OptionSource.JestFuzzTestOptions, + ); + originalArray[0] = 42; + expect(options.get("dictionaryEntries")).not.toStrictEqual(originalArray); + expect(options.get("dictionaryEntries")).toStrictEqual([ + new Uint8Array([0, 1, 2, 3, 4, 5]), + ]); + }); + + it("mergeInPlace: Int8Array is copied", () => { + const originalArray = new Int8Array([-1, 0, 1, 2, 3, 4, 5]); + const options = new OptionsManager(OptionSource.DefaultCLIOptions); + options.merge( + { dictionaryEntries: [originalArray] }, + OptionSource.JestFuzzTestOptions, + ); + originalArray[0] = 42; + expect(options.get("dictionaryEntries")).not.toStrictEqual(originalArray); + expect(options.get("dictionaryEntries")).toStrictEqual([ + new Int8Array([-1, 0, 1, 2, 3, 4, 5]), + ]); + }); + }); + + describe("merge", () => { + it("keeps libFuzzer as default CLI engine", () => { + expect(defaultCLIOptions.engine).toBe("libfuzzer"); + }); + + 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, + OptionSource.JestFuzzTestOptions, + ); + + const mergedOptions = new OptionsManager(baseOptions).merge( + { verbose: "foo", fuzzTarget: "bla" }, + OptionSource.CommandLineArguments, + ); + expect(mergedOptions.getOptions()).not.toHaveProperty("verbose", "foo"); + }); + + it("Only 'Jest fuzz tests' are allowed to set `dictionaryEntries`", () => { + Object.keys(OptionSource) + .filter((k) => isNaN(Number(k))) + .forEach((key) => { + const source = OptionSource[key as keyof typeof OptionSource]; + if (source === OptionSource.JestFuzzTestOptions) { + const options = new OptionsManager( + OptionSource.DefaultCLIOptions, + ).merge({ dictionaryEntries: ["foo"] }, source); + expect(options.getOptionsWithSource()).toHaveProperty( + "dictionaryEntries", + { + value: ["foo"], + source: source, + }, + ); + } else { + expect(() => { + new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { dictionaryEntries: ["foo"] }, + source, + ); + }).toThrow(); + } + }); + }); + }); + + describe("detachSource", () => { + it("options should not change", () => { + // @ts-ignore + const options = OptionsManager.detachSource({ + verbose: { value: false, source: OptionSource.JestFuzzTestOptions }, + dictionaryEntries: { + value: ["1", "2", "3"], + source: OptionSource.JestFuzzTestOptions, + }, + }); + expect(options).toHaveProperty("verbose", false); + expect(options).toHaveProperty("dictionaryEntries", ["1", "2", "3"]); + expect(Object.keys(options).length).toEqual(2); + }); + }); + + describe("processOptions", () => { + it("contains explicit common and backend engine options by default", () => { + expect(defaultCLIOptions).toMatchObject({ + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], + libAflOptions: [], + libFuzzerOptions: [], + maxLen: 4096, + maxTotalTime: 0, + runs: 0, + seed: 0, + }); + }); + + it("prefer configuration file values to defaults", () => { + const manager = new OptionsManager(OptionSource.DefaultJestOptions).merge( + { fuzzTarget: "FOO" }, + OptionSource.ConfigurationFile, + ); + const options = manager.getOptions(); + expect(options).toHaveProperty("fuzzTarget", "FOO"); + expectDefaultsExceptKeys( + options, + OptionSource.DefaultJestOptions, + "fuzzTarget", + ); + }); + it("prefer environment variables to configuration file values", () => { + withEnv("JAZZER_FUZZ_TARGET", "FOO", () => { + withEnv("JAZZER_INCLUDES", '["BAR", "BAZ"]', () => { + withEnv("JAZZER_MAX_LEN", "1337", () => { + withSource( + OptionSource.DefaultJestOptions, + { fuzzTarget: "QUX" }, + OptionSource.ConfigurationFile, + (options) => { + expect(options).toHaveProperty("fuzzTarget", "FOO"); + expect(options).toHaveProperty("includes", ["BAR", "BAZ"]); + expect(options).toHaveProperty("maxLen", 1337); + expectDefaultsExceptKeys( + options, + OptionSource.DefaultJestOptions, + "fuzzTarget", + "includes", + "maxLen", + ); + }, + ); + }); + }); + }); + }); + it("prefer CLI parameters to environment variables", () => { + withEnv("JAZZER_FUZZ_TARGET", "bar", () => { + withSource( + OptionSource.DefaultCLIOptions, + { fuzzTarget: "foo" }, + OptionSource.CommandLineArguments, + (options) => { + expect(options).toHaveProperty("fuzzTarget", "foo"); + expectDefaultsExceptKeys( + options, + OptionSource.DefaultCLIOptions, + "fuzzTarget", + ); + }, + ); + }); + }); + it("includes and excludes are set together", () => { + withSource( + OptionSource.DefaultCLIOptions, + { includes: ["foo"] }, + OptionSource.CommandLineArguments, + (options) => { + expect(options).toHaveProperty("excludes", []); + }, + ); + withSource( + OptionSource.DefaultCLIOptions, + { excludes: ["foo"] }, + OptionSource.CommandLineArguments, + (options) => { + expect(options).toHaveProperty("includes", []); + }, + ); + }); + it("error on unknown option", () => { + expect(() => { + withSource( + OptionSource.DefaultCLIOptions, + { unknown_option: "foo" }, + OptionSource.CommandLineArguments, + () => undefined, + ); + }).toThrow("unknown_option"); + }); + it("error on mismatching type", () => { + expect(() => { + withSource( + OptionSource.DefaultCLIOptions, + { fuzzTarget: false }, + OptionSource.CommandLineArguments, + () => undefined, + ); + }).toThrow("expected type 'string'"); + }); + it("rejects invalid mode values during option merge", () => { + expect(() => { + withSource( + OptionSource.DefaultCLIOptions, + { mode: "nonsense" }, + OptionSource.CommandLineArguments, + () => undefined, + ); + }).toThrow("Unknown fuzzer mode"); + }); + it("options are copied", () => { + const input = { includes: ["foo"] }; + withSource( + OptionSource.DefaultCLIOptions, + input, + OptionSource.CommandLineArguments, + (options) => { + input.includes.push("bar"); + expect(options.includes).not.toContain("bar"); + }, + ); + }); + it("set debug env variable", () => { + withEnv("JAZZER_DEBUG", "", () => { + withSource( + OptionSource.DefaultCLIOptions, + { verbose: true }, + OptionSource.CommandLineArguments, + () => { + expect(process.env.JAZZER_DEBUG).toEqual("1"); + }, + ); + }); + }); + it("does not merge __proto__", () => { + expect(() => { + withSource( + OptionSource.DefaultCLIOptions, + JSON.parse('{"__proto__": {"polluted": 42}}'), + OptionSource.CommandLineArguments, + () => undefined, + ); + }).toThrow(); + }); + }); +}); + +describe("KeyFormatSource", () => { + describe("fromSnakeCase", () => { + it("converts to camelCase", () => { + expect(fromSnakeCase("snake_case")).toEqual("snakeCase"); + expect(fromSnakeCase("Snake_Case")).toEqual("snakeCase"); + expect(fromSnakeCase("SNAKE_CASE")).toEqual("snakeCase"); + expect(fromSnakeCase("SNAKE_CASE_123")).toEqual("snakeCase123"); + expect(fromSnakeCase("SNAKE_CASE_123_")).toEqual("snakeCase123_"); + expect(fromSnakeCase("word")).toEqual("word"); + expect(fromSnakeCase("kebab-case")).toEqual("kebab-case"); + }); + }); + describe("fromSnakeCaseWithPrefix", () => { + it("converts to camelCase", () => { + expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_snake_case")).toEqual( + "snakeCase", + ); + expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_Snake_Case")).toEqual( + "snakeCase", + ); + expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_SNAKE_CASE")).toEqual( + "snakeCase", + ); + expect( + fromSnakeCaseWithPrefix("PREFIX")("PREFIX_SNAKE_CASE_123"), + ).toEqual("snakeCase123"); + expect( + fromSnakeCaseWithPrefix("PREFIX")("PREFIX_SNAKE_CASE_123_"), + ).toEqual("snakeCase123_"); + expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_word")).toEqual("word"); + expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_kebab-case")).toEqual( + "kebab-case", + ); + }); + }); +}); + +describe("engine and mode", () => { + 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("normalizes fuzzing modes", () => { + expect(resolveMode("fuzzing")).toBe(Mode.Fuzzing); + expect(resolveMode("regression")).toBe(Mode.Regression); + expect(() => resolveMode("unknown")).toThrow("Unknown fuzzer mode"); + }); + + it("canonicalizes engine aliases during option merge", () => { + const manager = new OptionsManager(OptionSource.DefaultJestOptions).merge( + { engine: "afl" }, + OptionSource.ConfigurationFile, + ); + + expect(manager.get("engine")).toBe("libafl"); + }); +}); + +function expectDefaultsExceptKeys( + options: Options, + source: OptionSource, + ...ignore: string[] +) { + const defaultOptions = new OptionsManager(source).getOptions(); + Object.keys(defaultOptions).forEach((key: string) => { + if (ignore.includes(key)) return; + expect(options).toHaveProperty(key, defaultOptions[key as keyof Options]); + }); +} + +function withEnv(property: string, value: string, fn: () => void) { + const current = process.env[property]; + try { + process.env[property] = value; + fn(); + } finally { + if (current) { + process.env[property] = current; + } else { + delete process.env[property]; + } + } +} + +function withSource( + initialSource: OptionSource, + args: object, + argsSource: OptionSource, + fn: (options: Options) => void, +) { + const options = new OptionsManager(initialSource).merge(args, argsSource); + fn(options.getOptions()); +} + +function mutateArrayAndCheck( + key: K, + newValue: T[K], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + v0: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + v1: any, +) { + const options = new OptionsManager(OptionSource.DefaultCLIOptions); + const newValueCopy = OptionsManager.copyOptionValue(newValue); + if (!(newValueCopy instanceof Array) || newValueCopy.length < 1) { + throw new Error("Array should have at least 1 elements."); + } + if (!(newValue instanceof Array) || newValueCopy.length < 1) { + throw new Error("Array should have at least 1 elements."); + } + const originalReference = options.get(key); + const originalValue = OptionsManager.copyOptionValue(originalReference); + + let newPriority = OptionSource.CommandLineArguments; + try { + validateKeySource(key, OptionSource.JestFuzzTestOptions); + newPriority = OptionSource.JestFuzzTestOptions; + } catch { + /**/ + } + + options.merge({ [key]: newValue }, newPriority); + const newReference = options.get(key); + if (!(newReference instanceof Array) || newReference.length < 1) { + throw new Error("Array should have at least 1 elements."); + } + const newStoredValue = OptionsManager.copyOptionValue(newReference); + + expect(options.get(key)).toStrictEqual(newValue); + expect(options.get(key)).not.toStrictEqual(originalValue); + expect(options.get(key)).not.toStrictEqual(originalReference); + + newValue[0] = v0; + expect(options.get(key)).toStrictEqual(newStoredValue); + + newReference[0] = v1; + expect(newValue[0]).toStrictEqual(v0); + // @ts-ignore + expect(options.get(key)[0]).toStrictEqual(v1); + return options; +} diff --git a/packages/options/index.ts b/packages/options/index.ts new file mode 100644 index 000000000..77df4a27b --- /dev/null +++ b/packages/options/index.ts @@ -0,0 +1,564 @@ +/* + * 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 * as util from "util"; + +export const Mode = { + Fuzzing: "fuzzing", + Regression: "regression", +} as const; + +export type Mode = (typeof Mode)[keyof typeof Mode]; + +export type LibAflOptions = { + mode: Mode; + runs: number; + runsSet: boolean; + seed: number; + maxLen: number; + timeoutMillis: number; + maxTotalTimeSeconds: number; + artifactPrefix: string; + corpusDirectories: string[]; + dictionaryFiles: string[]; +}; + +/** + * Jazzer.js options structure expected by the fuzzer. + * + * Entry functions, like the CLI or test framework integrations, need to build + * this structure and should use the same property names for exposing their own + * options. + */ +export interface Options { + // Fuzzing backend engine. + engine: "libfuzzer" | "libafl"; + // Enable source code coverage report generation. + coverage: boolean; + // Directory to write coverage reports to. + coverageDirectory: string; + // Coverage reporters to use during report generation. + coverageReporters: string[]; + // Files to load that contain custom hooks. + customHooks: string[]; + // Fuzzing dictionaries + dictionaryEntries: (string | Uint8Array | Int8Array)[]; + // Fuzzing dictionary files. + dictionaryFiles: string[]; + // Disable bug detectors by name. + disableBugDetectors: string[]; + // Whether to add fuzzing instrumentation or not. + dryRun: boolean; + // Part of filepath names to exclude in the instrumentation. + excludes: string[]; + // Expected error name that won't trigger the fuzzer to stop with an error exit code. + expectedErrors: string[]; + // Name of the function that is called by the fuzzer exported by `fuzzTarget`. + fuzzEntryPoint: string; + // Path prefix for crash artifacts. + artifactPrefix: string; + // Corpus directories used for seed inputs and generated inputs. + corpusDirectories: string[]; + // `fuzzTarget` is the name of a module exporting the fuzz function `fuzzEntryPoint`. + fuzzTarget: string; + // Internal: File to sync coverage IDs in fork mode. + idSyncFile: string; + // Part of filepath names to include in the instrumentation. + includes: string[]; + // Fuzzing mode. + mode: Mode; + // Backend-specific options passed to LibAFL. Currently reserved. + libAflOptions: string[]; + // Backend-specific options passed to libFuzzer. + libFuzzerOptions: string[]; + // Maximum fuzz input length in bytes. + maxLen: number; + // Maximum total fuzzing time in seconds. 0 means unlimited. + maxTotalTime: number; + // Number of fuzz target executions. Explicit 0 is forwarded to the backend. + runs: number; + // Fuzzing seed. 0 means Jazzer.js generates a seed. + seed: number; + // Whether to run the fuzzer in sync mode or not. + sync: boolean; + // Timeout for one fuzzing iteration in milliseconds. + timeout: number; + // Verbose logging. + verbose: boolean; +} + +export type OptionWithSource = { + value: Options[K]; + source: OptionSource; +}; +export type OptionsWithSource = { [P in keyof Options]: OptionWithSource

}; + +type OptionWithPrintableSource = { + value: Options[K]; + source: string; +}; + +export type OptionsWithPrintableSource = { + [P in keyof Options]: OptionWithPrintableSource

; +}; + +// These options can be set from the Jest fuzz test. +const allowedFuzzTestOptions = [ + "engine", + "dictionaryEntries", + "dictionaryFiles", + "libAflOptions", + "libFuzzerOptions", + "maxLen", + "maxTotalTime", + "runs", + "seed", + "sync", + "timeout", +] as const; +export type AllowedFuzzTestOptions = (typeof allowedFuzzTestOptions)[number]; + +export const defaultCLIOptions: Options = Object.freeze({ + engine: "libfuzzer", + coverage: false, + coverageDirectory: "coverage", + coverageReporters: ["json", "text", "lcov", "clover"], + customHooks: [], + dictionaryEntries: [], + dictionaryFiles: [], + disableBugDetectors: [], + dryRun: false, + excludes: ["node_modules"], + expectedErrors: [], + artifactPrefix: "", + corpusDirectories: [], + fuzzEntryPoint: "fuzz", + fuzzTarget: "", + idSyncFile: "", + includes: ["*"], + libAflOptions: [], + libFuzzerOptions: [], + maxLen: 4096, + maxTotalTime: 0, + mode: Mode.Fuzzing, + runs: 0, + seed: 0, + sync: false, + timeout: 5000, + verbose: false, +}); + +export const defaultJestOptions: Options = Object.freeze({ + ...defaultCLIOptions, + engine: "libfuzzer", + mode: Mode.Regression, +}); + +export type KeyFormatSource = (key: string) => string; +export const fromCamelCase: KeyFormatSource = (key: string): string => key; + +function replaceAll( + text: string, + pattern: RegExp, + replacer: string | ((substring: string) => string), +): string { + let previous = text; + let current = previous; + do { + previous = current; + current = previous.replace(pattern, replacer as string); + } while (current !== previous); + return current; +} + +export const fromSnakeCase: KeyFormatSource = (key: string): string => { + return replaceAll(key.toLowerCase(), /(_[a-z0-9])/g, (group) => + group.toUpperCase().replace("_", ""), + ); +}; + +export const fromSnakeCaseWithPrefix: (prefix: string) => KeyFormatSource = ( + prefix: string, +): KeyFormatSource => { + const prefixKey = prefix.toLowerCase() + "_"; + return (key: string): string => { + return key.toLowerCase().startsWith(prefixKey) + ? fromSnakeCase(key.substring(prefixKey.length)) + : key; + }; +}; + +// Source of an option is considered when merging options. +// Higher index means higher priority. +export enum OptionSource { + DefaultCLIOptions, + DefaultJestOptions, + InternalJestTimeout, + ConfigurationFile, + EnvironmentVariables, + CommandLineArguments, + JestFuzzTestOptions, + InternalFuzzTestOptions, + GeneratedSeed, +} + +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 'libafl' (alias 'afl').`, + ); + } +} + +function isOneOf>( + obj: T, + value: string, +): value is T[keyof T] { + return Object.values(obj).includes(value as T[keyof T]); +} + +export function resolveMode(mode: string): Mode { + if (isOneOf(Mode, mode)) { + return mode; + } + + throw new Error( + `Unknown fuzzer mode '${mode}'. Supported modes are ${Object.values(Mode) + .map((v) => `'${v}'`) + .join(", ")}.`, + ); +} + +type DefaultSourceInfo = { + name: string; + transformKey: KeyFormatSource; + failOnUnknown: boolean; + parameters?: Options | object; +}; +const defaultOptions: Record = { + [OptionSource.DefaultCLIOptions]: { + name: "Default CLI options", + transformKey: fromCamelCase, + failOnUnknown: true, + parameters: defaultCLIOptions, + }, + [OptionSource.DefaultJestOptions]: { + name: "Default Jest options", + transformKey: fromCamelCase, + failOnUnknown: true, + parameters: defaultJestOptions, + }, + [OptionSource.InternalJestTimeout]: { + name: "Internal Jest timeout", + transformKey: fromCamelCase, + failOnUnknown: true, + }, + [OptionSource.ConfigurationFile]: { + name: "Configuration file", + transformKey: fromCamelCase, + failOnUnknown: true, + }, + [OptionSource.EnvironmentVariables]: { + name: "Environment variables", + transformKey: fromSnakeCaseWithPrefix("JAZZER"), + failOnUnknown: false, + parameters: process.env as object, + }, + [OptionSource.CommandLineArguments]: { + name: "Command line arguments", + transformKey: fromCamelCase, + failOnUnknown: true, + }, + [OptionSource.JestFuzzTestOptions]: { + name: "Jest fuzz test options", + transformKey: fromCamelCase, + failOnUnknown: true, + }, + [OptionSource.InternalFuzzTestOptions]: { + name: "Internal fuzz test options", + transformKey: fromCamelCase, + failOnUnknown: true, + }, + [OptionSource.GeneratedSeed]: { + name: "Generated seed", + transformKey: fromCamelCase, + failOnUnknown: true, + }, +} as const; + +export class OptionsManager { + private readonly _options: OptionsWithSource; + + constructor(obj: OptionSource); + constructor(obj: OptionsWithSource); + constructor(sourceOrOptions: OptionSource | OptionsWithSource) { + if (typeof sourceOrOptions === "number") { + const source = sourceOrOptions; + const initialOptions = defaultOptions[source].parameters as Options; + if (!initialOptions) { + throw new Error( + `Default options for ${source} do not exist. Consider adding them or use a different source.`, + ); + } + this._options = OptionsManager.copyOptions( + OptionsManager.attachSource(initialOptions, source), + ); + this.merge(process.env, OptionSource.EnvironmentVariables); + } else if (typeof sourceOrOptions === "object") { + this._options = OptionsManager.copyOptions(sourceOrOptions); + } else { + throw new Error("Invalid argument"); + } + } + + get(key: K): Options[K] { + return this._options[key].value; + } + + getOptions(): Options { + return OptionsManager.detachSource(this._options); + } + + getOptionsWithSource(): OptionsWithSource { + return this._options; + } + + merge(input: unknown, source: OptionSource) { + const transformKey = defaultOptions[source].transformKey; + const errorOnUnknown = defaultOptions[source].failOnUnknown; + + let includes: typeof this._options.includes.value | undefined = undefined; + let excludes: typeof this._options.excludes.value | undefined = undefined; + + Object.keys(input as object).forEach((k) => { + const transformedKey = transformKey(k); + + // Avoid Object.hasOwn to keep support for older Node versions. + if ( + !Object.prototype.hasOwnProperty.call(defaultCLIOptions, transformedKey) + ) { + if (errorOnUnknown) { + throw new Error(`Unknown Jazzer.js option '${k}'`); + } + return; + } + const key = transformedKey as keyof Options; + if (!validateOptionPermissions(key, source, this._options)) { + return; + } + + const keyType = typeof defaultCLIOptions[key]; + + // @ts-ignore + let resultValue = input[k]; + if ( + [ + OptionSource.CommandLineArguments, + OptionSource.EnvironmentVariables, + ].includes(source) && + keyType !== "string" && + (typeof resultValue === "string" || resultValue instanceof String) + ) { + try { + resultValue = JSON.parse(resultValue.toString()); + } catch { + // Ignore parsing errors and continue with the string value. + } + } + + if (typeof resultValue !== keyType) { + throw new Error( + `Invalid type for Jazzer.js option '${key}', expected type '${keyType}', got '${typeof resultValue}'`, + ); + } + if (key === "engine") { + resultValue = resolveEngine(resultValue); + } else if (key === "mode") { + resultValue = resolveMode(resultValue); + } + resultValue = OptionsManager.copyOptionValue(resultValue); + setProperty(this._options, key, { value: resultValue, source: source }); + + if (key === "includes") { + includes = resultValue; + } else if (key === "excludes") { + excludes = resultValue; + } + }); + + if (input && includes && !excludes) { + this._options.excludes.value = []; + } else if (input && excludes && !includes) { + this._options.includes.value = []; + } + + if (this.get("verbose") || process.env.DEBUG) { + process.env.JAZZER_DEBUG = "1"; + } + return this; + } + + clone(): OptionsManager { + return new OptionsManager(this._options); + } + + static copyOptions(newOptions: OptionsWithSource): OptionsWithSource { + const result: OptionsWithSource = Object.create(null); + Object.entries(newOptions).forEach(([k]) => { + const key = k as keyof Options; + const option = newOptions[key]; + const value = OptionsManager.copyOptionValue(option.value); + const source = option.source; + setProperty(result, key, { + value, + source, + }); + }); + return result; + } + + static copyOptionValue( + input: T[K], + ): T[K] { + if (!input || typeof input !== "object") { + return input; + } + + if (Array.isArray(input)) { + if ( + input.some( + (element) => + element instanceof Uint8Array || element instanceof Int8Array, + ) + ) { + return input.map((element) => { + if (element instanceof Uint8Array || element instanceof Int8Array) { + return element.slice(); + } + return element; + }) as T[K]; + } + + return input.slice() as T[K]; + } + + throw new Error("copyOptionValue: unsupported type: " + typeof input); + } + + static attachSource( + options: Options, + source: OptionSource, + ): OptionsWithSource { + const result: OptionsWithSource = Object.create(null); + Object.entries(options).forEach(([k]) => { + const key = k as keyof Options; + setProperty(result, key, { + value: options[key], + source: source, + }); + }); + return result; + } + + static detachSource(options: OptionsWithSource): Options { + const result: Options = Object.create(null); + Object.entries(options).forEach(([k]) => { + const key = k as keyof Options; + const value = options[key]?.value; + setProperty(result, key, value); + }); + return result; + } +} + +function setProperty(obj: T, key: K, value: T[K]) { + obj[key] = value; +} + +export function toOptionsWithPrintableSources( + options: OptionsManager, +): OptionsWithPrintableSource { + const result: OptionsWithPrintableSource = Object.create(null); + const opts = options.getOptionsWithSource(); + Object.entries(opts).forEach(([k]) => { + const key = k as keyof Options; + const value = opts[key]?.value; + const sourceIndex = opts[key]?.source; + if (sourceIndex !== undefined) { + const source = defaultOptions[sourceIndex].name; + setProperty(result, key, { value, source }); + } + }); + return result; +} + +export function printOptions(options: OptionsManager, infix = "") { + if (process.env.JAZZER_DEBUG) { + console.error( + util.formatWithOptions( + { maxArrayLength: null, depth: null, colors: false }, + `DEBUG: [core] Jazzer.js options ${infix}: \n%O`, + toOptionsWithPrintableSources(options), + ), + ); + } +} + +export function validateKeySource(key: keyof Options, source: OptionSource) { + const sourceName = defaultOptions[source].name; + + if ( + key === "dictionaryEntries" && + source !== OptionSource.JestFuzzTestOptions + ) { + const allowedSource = defaultOptions[OptionSource.JestFuzzTestOptions].name; + throw new Error( + `Tried setting option '${key}' from ${sourceName}, but this option is only available in ${allowedSource}`, + ); + } + + if ( + source === OptionSource.JestFuzzTestOptions && + !allowedFuzzTestOptions.includes(key as AllowedFuzzTestOptions) + ) { + throw new Error(`Option '${key}' is not available from "${sourceName}."`); + } + + if (source === OptionSource.GeneratedSeed && key !== "seed") { + throw new Error(`Option '${key}' is not available from "${sourceName}."`); + } +} + +export function validateOptionPermissions( + key: keyof Options, + source: OptionSource, + options: OptionsWithSource, +): boolean { + validateKeySource(key, source); + if (source === options[key].source) { + throw new Error( + `Option '${key}' already set from ${defaultOptions[source].name}`, + ); + } + return source > options[key].source; +} diff --git a/packages/options/package.json b/packages/options/package.json new file mode 100644 index 000000000..582e11e2e --- /dev/null +++ b/packages/options/package.json @@ -0,0 +1,22 @@ +{ + "name": "@jazzer.js/options", + "version": "4.0.0", + "description": "Jazzer.js option contracts and normalization", + "homepage": "https://github.com/CodeIntelligenceTesting/jazzer.js#readme", + "author": "Code Intelligence", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/CodeIntelligenceTesting/jazzer.js/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/CodeIntelligenceTesting/jazzer.js.git", + "directory": "packages/options" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">= 14.0.0", + "npm": ">= 7.0.0" + } +} diff --git a/packages/options/tsconfig.json b/packages/options/tsconfig.json new file mode 100644 index 000000000..54b6b8b94 --- /dev/null +++ b/packages/options/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist" + }, + "exclude": ["dist"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 6a040d69b..8bfd6cb59 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,6 +1,7 @@ { "files": [], "references": [ + { "path": "packages/options" }, { "path": "packages/bug-detectors" }, { "path": "packages/core" }, { "path": "packages/fuzzer" }, diff --git a/tsconfig.json b/tsconfig.json index b209decf6..4156caefd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "packages/fuzzer" }, { "path": "packages/hooking" }, { "path": "packages/instrumentor" }, - { "path": "packages/jest-runner" } + { "path": "packages/jest-runner" }, + { "path": "packages/options" } ] } From 9197639d25154868542a99dd7b492869cbd0c439 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:30:53 +0200 Subject: [PATCH 02/10] feat(core): expose backend-neutral fuzz options Common fuzzing controls need first-class validation so users do not have to encode backend-independent behavior in libFuzzer argv. --- packages/core/cli.ts | 96 ++++- packages/core/core.ts | 91 +++-- packages/core/dictionary.ts | 1 + packages/core/options.test.ts | 562 +++++++++++----------------- packages/core/options.ts | 683 +++++++++------------------------- packages/core/utils.test.ts | 95 ++++- packages/core/utils.ts | 52 ++- 7 files changed, 673 insertions(+), 907 deletions(-) diff --git a/packages/core/cli.ts b/packages/core/cli.ts index 13ccdf5bb..ff1a8a5a1 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -17,9 +17,14 @@ import yargs, { Argv } from "yargs"; +import { + defaultCLIOptions, + OptionsManager, + OptionSource, +} from "@jazzer.js/options"; + import { FuzzingExitCode, startFuzzing } from "./core"; -import { defaultCLIOptions, OptionsManager, OptionSource } from "./options"; -import { prepareArgs } from "./utils"; +import { normalizeLegacyEngineFlags, prepareArgs } from "./utils"; // Use yargs to parse command line arguments and provide a nice CLI experience. // Default values are provided by the options module and must not be set by yargs. @@ -27,7 +32,7 @@ import { prepareArgs } from "./utils"; // descriptions. // Handling of unsupported parameters is also done via the options module. -yargs(process.argv.slice(2)) +yargs(normalizeLegacyEngineFlags(process.argv.slice(2))) .scriptName("jazzer") .parserConfiguration({ "camel-case-expansion": false, @@ -40,11 +45,10 @@ yargs(process.argv.slice(2)) 'and only instrument code in the "packages/a" and "packages/b" modules.', ) .example( - "$0 package/target corpus -- -max_total_time=60", + "$0 package/target corpus --maxTotalTime=60", '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.", + "Stop the fuzzing run after 60 seconds.", ) .epilogue("Happy fuzzing!") .command( @@ -56,9 +60,8 @@ 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 " + - 'double-dash, "--", to mark the end of the normal fuzzer arguments. ' + - "An example is shown in the examples section of this help message.", + "Backend-specific options can be passed with --libFuzzerOptions " + + "or --libAflOptions.", (yargs: Argv) => { yargs .positional("fuzzTarget", { @@ -177,6 +180,14 @@ yargs(process.argv.slice(2)) group: "Fuzzer:", type: "string", }) + .option("engine", { + alias: ["backend"], + defaultDescription: `${JSON.stringify(defaultCLIOptions.engine)}`, + describe: + "Fuzzing engine backend. Use 'libfuzzer' for the default backend or 'afl' (alias 'libafl') to opt into the LibAFL backend.", + group: "Fuzzer:", + type: "string", + }) .option("dryRun", { alias: ["dry_run", "d"], defaultDescription: `${JSON.stringify(defaultCLIOptions.dryRun)}`, @@ -190,6 +201,73 @@ yargs(process.argv.slice(2)) group: "Fuzzer:", type: "number", }) + .option("runs", { + defaultDescription: `${JSON.stringify(defaultCLIOptions.runs)}`, + describe: + "Number of fuzz target executions. Explicit 0 is forwarded to the backend.", + group: "Fuzzer:", + type: "number", + }) + .option("seed", { + defaultDescription: `${JSON.stringify(defaultCLIOptions.seed)}`, + describe: "Fuzzing seed. 0 lets Jazzer.js generate one.", + group: "Fuzzer:", + type: "number", + }) + .option("maxLen", { + alias: "max_len", + defaultDescription: `${JSON.stringify(defaultCLIOptions.maxLen)}`, + describe: "Maximum fuzz input length in bytes.", + group: "Fuzzer:", + type: "number", + }) + .option("maxTotalTime", { + alias: "max_total_time", + defaultDescription: `${JSON.stringify(defaultCLIOptions.maxTotalTime)}`, + describe: "Maximum total fuzzing time in seconds. 0 means unlimited.", + group: "Fuzzer:", + type: "number", + }) + .option("artifactPrefix", { + alias: "artifact_prefix", + defaultDescription: `${JSON.stringify(defaultCLIOptions.artifactPrefix)}`, + describe: "Path prefix for crash artifacts.", + group: "Fuzzer:", + type: "string", + }) + .option("dictionaryFiles", { + alias: ["dict", "dictionary_files"], + array: true, + defaultDescription: `${JSON.stringify( + defaultCLIOptions.dictionaryFiles, + )}`, + describe: + "Fuzzing dictionary files. Can be specified multiple times.", + group: "Fuzzer:", + type: "string", + }) + .option("libFuzzerOptions", { + alias: "lib_fuzzer_options", + array: true, + defaultDescription: `${JSON.stringify( + defaultCLIOptions.libFuzzerOptions, + )}`, + describe: + "Backend-specific libFuzzer options. Common Jazzer.js " + + "options such as --runs and --timeout are rejected here.", + group: "Fuzzer:", + type: "string", + }) + .option("libAflOptions", { + alias: "lib_afl_options", + array: true, + defaultDescription: `${JSON.stringify(defaultCLIOptions.libAflOptions)}`, + describe: + "Reserved for backend-specific LibAFL options. Common " + + "Jazzer.js options such as --runs and --timeout are rejected here.", + group: "Fuzzer:", + type: "string", + }) .option("sync", { defaultDescription: `${JSON.stringify(defaultCLIOptions.sync)}`, describe: "Run the fuzz target synchronously.", diff --git a/packages/core/core.ts b/packages/core/core.ts index 4e7c0e8f3..40b839ba8 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -32,6 +32,11 @@ import { registerEsmLoaderHooks, registerInstrumentor, } from "@jazzer.js/instrumentor"; +import { + OptionsManager, + OptionSource, + resolveEngine, +} from "@jazzer.js/options"; import { getCallbacks } from "./callback"; import { @@ -43,7 +48,7 @@ import { reportFinding, } from "./finding"; import { getJazzerJsGlobal, jazzerJs } from "./globals"; -import { buildFuzzerOption, OptionsManager } from "./options"; +import { buildLibAflOptions, buildLibFuzzerOptions } from "./options"; import { ensureFilepath, importModule } from "./utils"; // Remove temporary files on exit @@ -84,21 +89,16 @@ declare global { } /** - * Extract -seed=N from the libFuzzer arguments. If absent, generate - * a random seed and inject it so both the instrumentation layer and - * libFuzzer use the same value — making the entire run reproducible - * from a single seed. + * Resolve the seed once so instrumentation and the selected backend share the + * same value. A configured seed of 0 means that Jazzer.js should generate one. */ function resolveInstrumentationSeed(options: OptionsManager): number { - const fuzzerOpts = options.get("fuzzerOptions"); - const seedArg = fuzzerOpts.find((a: string) => a.startsWith("-seed=")); - if (seedArg) { - const parsed = parseInt(seedArg.split("=")[1], 10); - // libFuzzer treats -seed=0 as "pick random", so we do the same. - if (parsed !== 0) return parsed; + const seed = options.get("seed"); + if (seed !== 0) { + return seed; } const generated = Math.floor(Math.random() * 0x7fff_fffe) + 1; // [1, 2^31-1] - fuzzerOpts.push(`-seed=${generated}`); + options.merge({ seed: generated }, OptionSource.GeneratedSeed); console.error(`INFO: Using generated seed: ${generated}`); return generated; } @@ -223,7 +223,11 @@ export async function startFuzzing( registerEsmLoaderHooks(instrumentor); instrumentor.sendHooksToLoader(); const fuzzFn = await loadFuzzFunction(options); - const findingAwareFuzzFn = asFindingAwareFuzzFn(fuzzFn); + const findingAwareFuzzFn = asFindingAwareFuzzFn( + fuzzFn, + true, + options.get("engine"), + ); return startFuzzingNoInit(findingAwareFuzzFn, options).finally(() => { // These post fuzzing actions are only required for invocations through the CLI, // other means of invocation, e.g. via Jest, don't need them. @@ -245,24 +249,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 libFuzzerArgv = buildLibFuzzerOptions(options); + if (options.get("sync")) { + await fuzzer.fuzzer.startFuzzing( + fuzzFn, + libFuzzerArgv, + // 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, libFuzzerArgv); + } } 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")); @@ -366,9 +393,11 @@ async function loadFuzzFunction( export function asFindingAwareFuzzFn( originalFuzzFn: fuzzer.FuzzTarget, dumpCrashingInput = true, + engine = "libfuzzer", ): FindingAwareFuzzTarget { function printAndDump(error: unknown): void { cleanErrorStack(error); + const shouldDumpWithLibFuzzer = resolveEngine(engine) === "libfuzzer"; if ( !( error instanceof FuzzerSignalFinding && @@ -376,7 +405,7 @@ export function asFindingAwareFuzzFn( ) ) { printFinding(error); - if (dumpCrashingInput) { + if (dumpCrashingInput && shouldDumpWithLibFuzzer) { fuzzer.fuzzer.printAndDumpCrashingInput(); } } @@ -474,14 +503,6 @@ export function asFindingAwareFuzzFn( // Export public API from within core module for easy access. export * from "./api"; export { FuzzedDataProvider } from "./FuzzedDataProvider"; -export { - AllowedFuzzTestOptions, - Options, - OptionsManager, - OptionSource, - OptionsWithSource, - printOptions, -} from "./options"; export type { FuzzTarget, 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..790f27acc 100644 --- a/packages/core/options.test.ts +++ b/packages/core/options.test.ts @@ -14,391 +14,251 @@ * limitations under the License. */ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import { Mode, OptionsManager, OptionSource } from "@jazzer.js/options"; + import { - defaultCLIOptions, - fromSnakeCase, - fromSnakeCaseWithPrefix, - Options, - OptionsManager, - OptionSource, + buildLibAflOptions, + buildLibFuzzerOptions, spawnsSubprocess, - validateKeySource, } from "./options"; -describe("options", () => { - describe("OptionsManager", () => { - it("mergeInPlace: options of type string[] are copied", () => { - const input = ["1", "2", "3"]; - const v0 = "CHANGED"; - const v1 = "CHANGED AGAIN"; - - // get all keys of Options for which the type is string[] - Object.keys(defaultCLIOptions).forEach((key) => { - if (defaultCLIOptions[key as keyof Options] instanceof Array) { - mutateArrayAndCheck(key as keyof Options, input, v0, v1); - } - }); +describe("libFuzzer options", () => { + describe("spawnsSubprocess", () => { + it("checks if subprocess libFuzzer flags are present", () => { + expect(spawnsSubprocess(["-fork=1"])).toBeTruthy(); + expect(spawnsSubprocess(["-fork=0"])).toBeFalsy(); + expect( + spawnsSubprocess(["abc", "-foo=0", "-fork=0", "-jobs=1"]), + ).toBeTruthy(); + expect(spawnsSubprocess(["-foo=0"])).toBeFalsy(); + expect(spawnsSubprocess(["abc"])).toBeFalsy(); + expect(spawnsSubprocess(["123"])).toBeFalsy(); }); + }); - it("mergeInPlace: Uint8Array is copied", () => { - const originalArray = new Uint8Array([0, 1, 2, 3, 4, 5]); - const options = new OptionsManager(OptionSource.DefaultCLIOptions); - options.merge( - { dictionaryEntries: [originalArray] }, - OptionSource.JestFuzzTestOptions, - ); - originalArray[0] = 42; - expect(options.get("dictionaryEntries")).not.toStrictEqual(originalArray); - expect(options.get("dictionaryEntries")).toStrictEqual([ - new Uint8Array([0, 1, 2, 3, 4, 5]), - ]); - }); + it("translates common options and appends backend-specific options", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + artifactPrefix: "/tmp/artifacts/", + corpusDirectories: ["corpus-main", "corpus-seed"], + libFuzzerOptions: ["-use_value_profile=1", "-print_final_stats=1"], + maxLen: 1024, + maxTotalTime: 42, + runs: 99, + seed: 1337, + timeout: 1234, + }, + OptionSource.CommandLineArguments, + ); - it("mergeInPlace: Int8Array is copied", () => { - const originalArray = new Int8Array([-1, 0, 1, 2, 3, 4, 5]); - const options = new OptionsManager(OptionSource.DefaultCLIOptions); - options.merge( - { dictionaryEntries: [originalArray] }, - OptionSource.JestFuzzTestOptions, - ); - originalArray[0] = 42; - expect(options.get("dictionaryEntries")).not.toStrictEqual(originalArray); - expect(options.get("dictionaryEntries")).toStrictEqual([ - new Int8Array([-1, 0, 1, 2, 3, 4, 5]), - ]); - }); + expect(buildLibFuzzerOptions(manager)).toEqual([ + "unused_arg0_report_a_bug_if_you_see_this", + "-seed=1337", + "-max_len=1024", + "-timeout=2", + "-runs=99", + "-max_total_time=42", + "-artifact_prefix=/tmp/artifacts/", + "corpus-main", + "corpus-seed", + "-use_value_profile=1", + "-print_final_stats=1", + "-handle_int=0", + "-handle_term=0", + "-handle_segv=0", + ]); }); - describe("merge", () => { - it("New options with lower priorities will not be added", () => { - const baseOptions = OptionsManager.attachSource( - defaultCLIOptions, - OptionSource.JestFuzzTestOptions, - ); - - const mergedOptions = new OptionsManager(baseOptions).merge( - { verbose: "foo", fuzzTarget: "bla" }, - OptionSource.CommandLineArguments, - ); - expect(mergedOptions.getOptions()).not.toHaveProperty("verbose", "foo"); - }); + it("forwards explicitly configured zero runs to libFuzzer", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { runs: 0 }, + OptionSource.CommandLineArguments, + ); - it("Only 'Jest fuzz tests' are allowed to set `dictionaryEntries`", () => { - // Looping over enum keys gives them twice: 1) 0...n; 2) the key names: "JestFuzztestOptions" etc. - Object.keys(OptionSource) - .filter((k) => isNaN(Number(k))) - .forEach((key) => { - const source = OptionSource[key as keyof typeof OptionSource]; - if (source === OptionSource.JestFuzzTestOptions) { - const options = new OptionsManager( - OptionSource.DefaultCLIOptions, - ).merge({ dictionaryEntries: ["foo"] }, source); - expect(options.getOptionsWithSource()).toHaveProperty( - "dictionaryEntries", - { - value: ["foo"], - source: source, - }, - ); - } else { - expect(() => { - new OptionsManager(OptionSource.DefaultCLIOptions).merge( - { dictionaryEntries: ["foo"] }, - source, - ); - }).toThrow(); - } - }); - }); + expect(buildLibFuzzerOptions(manager)).toContain("-runs=0"); }); - describe("detachSource", () => { - it("options should not change", () => { - // @ts-ignore - const options = OptionsManager.detachSource({ - verbose: { value: false, source: OptionSource.JestFuzzTestOptions }, - dictionaryEntries: { - value: ["1", "2", "3"], - source: OptionSource.JestFuzzTestOptions, - }, - }); - expect(options).toHaveProperty("verbose", false); - expect(options).toHaveProperty("dictionaryEntries", ["1", "2", "3"]); - // expect options to have only one property - expect(Object.keys(options).length).toEqual(2); - }); + it("does not turn the default zero runs value into a libFuzzer run limit", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions); + + expect(buildLibFuzzerOptions(manager)).not.toContain("-runs=0"); }); - describe("processOptions", () => { - it("prefer configuration file values to defaults", () => { - const manager = new OptionsManager(OptionSource.DefaultJestOptions).merge( - { fuzzTarget: "FOO" }, - OptionSource.ConfigurationFile, - ); - const options = manager.getOptions(); - expect(options).toHaveProperty("fuzzTarget", "FOO"); - expectDefaultsExceptKeys( - options, - OptionSource.DefaultJestOptions, - "fuzzTarget", - ); - }); - it("prefer environment variables to configuration file values", () => { - withEnv("JAZZER_FUZZ_TARGET", "FOO", () => { - withEnv("JAZZER_INCLUDES", '["BAR", "BAZ"]', () => { - withSource( - OptionSource.DefaultJestOptions, - { fuzzTarget: "QUX" }, - OptionSource.ConfigurationFile, - (options) => { - expect(options).toHaveProperty("fuzzTarget", "FOO"); - expect(options).toHaveProperty("includes", ["BAR", "BAZ"]); - expectDefaultsExceptKeys( - options, - OptionSource.DefaultJestOptions, - "fuzzTarget", - "includes", - ); - }, - ); - }); - }); - }); - it("prefer CLI parameters to environment variables", () => { - withEnv("JAZZER_FUZZ_TARGET", "bar", () => { - withSource( - OptionSource.DefaultCLIOptions, - { fuzzTarget: "foo" }, - OptionSource.CommandLineArguments, - (options) => { - expect(options).toHaveProperty("fuzzTarget", "foo"); - expectDefaultsExceptKeys( - options, - OptionSource.DefaultCLIOptions, - "fuzzTarget", - ); - }, - ); - }); - }); - it("includes and excludes are set together", () => { - withSource( - OptionSource.DefaultCLIOptions, - { includes: ["foo"] }, - OptionSource.CommandLineArguments, - (options) => { - expect(options).toHaveProperty("excludes", []); - }, - ); - withSource( - OptionSource.DefaultCLIOptions, - { excludes: ["foo"] }, + it("rejects common flags in libFuzzer-specific options", () => { + for (const option of ["-runs=1", "-max_len=1", "-timeout=5", "-dict=x"]) { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { libFuzzerOptions: [option] }, OptionSource.CommandLineArguments, - (options) => { - expect(options).toHaveProperty("includes", []); - }, ); - }); - it("error on unknown option", () => { - expect(() => { - withSource( - OptionSource.DefaultCLIOptions, - { unknown_option: "foo" }, - OptionSource.CommandLineArguments, - (options) => {}, - ); - }).toThrow("unknown_option"); - }); - it("error on mismatching type", () => { - expect(() => { - withSource( - OptionSource.DefaultCLIOptions, - { fuzzTarget: false }, - OptionSource.CommandLineArguments, - (options) => {}, - ); - }).toThrow("expected type 'string'"); - }); - it("options are copied", () => { - const input = { includes: ["foo"] }; - withSource( - OptionSource.DefaultCLIOptions, - input, - OptionSource.CommandLineArguments, - (options) => { - input.includes.push("bar"); - expect(options.includes).not.toContain("bar"); - }, + + expect(() => buildLibFuzzerOptions(manager)).toThrow( + "Jazzer.js common option", ); - }); - it("set debug env variable", () => { - withEnv("JAZZER_DEBUG", "", () => { - withSource( - OptionSource.DefaultCLIOptions, - { verbose: true }, - OptionSource.CommandLineArguments, - (options) => { - expect(process.env.JAZZER_DEBUG).toEqual("1"); - }, - ); - }); - withEnv("JAZZER_DEBUG", "", () => { - withEnv("DEBUG", "1", () => { - // const options = buildInitialOptions(OptionSource.DefaultCLIOptions); - // expect(process.env.JAZZER_DEBUG).toEqual("1"); - }); - }); - }); - it("does not merge __proto__", () => { - expect(() => { - withSource( - OptionSource.DefaultCLIOptions, - JSON.parse('{"__proto__": {"polluted": 42}}'), - OptionSource.CommandLineArguments, - (options) => {}, - ); - }).toThrow(); - }); + } }); }); -describe("KeyFormatSource", () => { - describe("fromSnakeCase", () => { - it("converts to camelCase", () => { - expect(fromSnakeCase("snake_case")).toEqual("snakeCase"); - expect(fromSnakeCase("Snake_Case")).toEqual("snakeCase"); - expect(fromSnakeCase("SNAKE_CASE")).toEqual("snakeCase"); - expect(fromSnakeCase("SNAKE_CASE_123")).toEqual("snakeCase123"); - expect(fromSnakeCase("SNAKE_CASE_123_")).toEqual("snakeCase123_"); - expect(fromSnakeCase("word")).toEqual("word"); - expect(fromSnakeCase("kebab-case")).toEqual("kebab-case"); +describe("LibAFL options", () => { + it("builds structured LibAFL options from common options", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + artifactPrefix: "/tmp/artifacts/", + corpusDirectories: ["corpus-main", "corpus-seed"], + engine: "libafl", + maxLen: 1024, + maxTotalTime: 42, + runs: 99, + seed: 1337, + timeout: 1234, + }, + OptionSource.CommandLineArguments, + ); + + expect(buildLibAflOptions(manager)).toEqual({ + mode: Mode.Fuzzing, + runs: 99, + runsSet: true, + seed: 1337, + maxLen: 1024, + timeoutMillis: 1234, + maxTotalTimeSeconds: 42, + artifactPrefix: "/tmp/artifacts/", + corpusDirectories: ["corpus-main", "corpus-seed"], + dictionaryFiles: [], }); }); - describe("fromSnakeCaseWithPrefix", () => { - it("converts to camelCase", () => { - expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_snake_case")).toEqual( - "snakeCase", - ); - expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_Snake_Case")).toEqual( - "snakeCase", - ); - expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_SNAKE_CASE")).toEqual( - "snakeCase", - ); - expect( - fromSnakeCaseWithPrefix("PREFIX")("PREFIX_SNAKE_CASE_123"), - ).toEqual("snakeCase123"); - expect( - fromSnakeCaseWithPrefix("PREFIX")("PREFIX_SNAKE_CASE_123_"), - ).toEqual("snakeCase123_"); - expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_word")).toEqual("word"); - expect(fromSnakeCaseWithPrefix("PREFIX")("PREFIX_kebab-case")).toEqual( - "kebab-case", - ); + + it("preserves explicitly configured zero runs for LibAFL", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + runs: 0, + }, + OptionSource.CommandLineArguments, + ); + + expect(buildLibAflOptions(manager)).toMatchObject({ + runs: 0, + runsSet: true, }); }); -}); -describe("buildLibFuzzerOptions", () => { - describe("spawnsSubprocess", () => { - it("checks if subprocess libFuzzer flags are present", () => { - expect(spawnsSubprocess(["-fork=1"])).toBeTruthy(); - expect(spawnsSubprocess(["-fork=0"])).toBeFalsy(); - expect( - spawnsSubprocess(["abc", "-foo=0", "-fork=0", "-jobs=1"]), - ).toBeTruthy(); - expect(spawnsSubprocess(["-foo=0"])).toBeFalsy(); - expect(spawnsSubprocess(["abc"])).toBeFalsy(); - expect(spawnsSubprocess(["123"])).toBeFalsy(); + it("marks default zero runs as unset for LibAFL", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { engine: "libafl" }, + OptionSource.CommandLineArguments, + ); + + expect(buildLibAflOptions(manager)).toMatchObject({ + runs: 0, + runsSet: false, }); }); -}); -function expectDefaultsExceptKeys( - options: Options, - source: OptionSource, - ...ignore: string[] -) { - const defaultOptions = new OptionsManager(source).getOptions(); - Object.keys(defaultOptions).forEach((key: string) => { - if (ignore.includes(key)) return; - expect(options).toHaveProperty(key, defaultOptions[key as keyof Options]); + it("rejects LibAFL-specific options until they are implemented", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + libAflOptions: ["-some_libafl_option=1"], + }, + OptionSource.CommandLineArguments, + ); + + expect(() => buildLibAflOptions(manager)).toThrow("not supported yet"); }); -} -function withEnv(property: string, value: string, fn: () => void) { - const current = process.env[property]; - try { - process.env[property] = value; - fn(); - } finally { - if (current) { - process.env[property] = current; - } else { - delete process.env[property]; - } - } -} + it("rejects libFuzzer-specific options in LibAFL mode", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + libFuzzerOptions: ["-fork=1"], + }, + OptionSource.CommandLineArguments, + ); -function withSource( - initialSource: OptionSource, - args: object, - argsSource: OptionSource, - fn: (options: Options) => void, -) { - const options = new OptionsManager(initialSource).merge(args, argsSource); - fn(options.getOptions()); -} + expect(() => buildLibAflOptions(manager)).toThrow( + "libFuzzerOptions can only be used", + ); + }); -// Check that OptionsManager.merge() copies new input -function mutateArrayAndCheck( - key: K, - newValue: T[K], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - v0: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - v1: any, -) { - const options = new OptionsManager(OptionSource.DefaultCLIOptions); - const newValueCopy = OptionsManager.copyOptionValue(newValue); - if (!(newValueCopy instanceof Array) || newValueCopy.length < 1) { - throw new Error("Array should have at least 1 elements."); - } - if (!(newValue instanceof Array) || newValueCopy.length < 1) { - throw new Error("Array should have at least 1 elements."); - } - const originalReference = options.get(key); - const originalValue = OptionsManager.copyOptionValue(originalReference); + it("supports regression mode in LibAFL mode", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + corpusDirectories: ["corpus"], + engine: "libafl", + mode: Mode.Regression, + runs: 1, + }, + OptionSource.CommandLineArguments, + ); - let newPriority = OptionSource.CommandLineArguments; - try { - validateKeySource(key, OptionSource.JestFuzzTestOptions); - newPriority = OptionSource.JestFuzzTestOptions; - } catch (e) { - /**/ - } + expect(buildLibAflOptions(manager)).toEqual({ + mode: Mode.Regression, + runs: 0, + runsSet: true, + seed: 0, + maxLen: 4096, + timeoutMillis: 5000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: ["corpus"], + dictionaryFiles: [], + }); + }); - options.merge({ [key]: newValue }, newPriority); - const newReference = options.get(key); - if (!(newReference instanceof Array) || newReference.length < 1) { - throw new Error("Array should have at least 1 elements."); - } - const newStoredValue = OptionsManager.copyOptionValue(newReference); + 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'); - // after merge, value of the option should equal to the newValue, and not equal to the old one - expect(options.get(key)).toStrictEqual(newValue); - expect(options.get(key)).not.toStrictEqual(originalValue); - // also the reference should be different - expect(options.get(key)).not.toStrictEqual(originalReference); + try { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions) + .merge( + { + corpusDirectories: ["corpus"], + dictionaryFiles: [dictionaryPath], + engine: "libafl", + }, + OptionSource.CommandLineArguments, + ) + .merge( + { dictionaryEntries: ["banana"] }, + OptionSource.JestFuzzTestOptions, + ); - // mutate newValue and check that the new value of option is not changed - newValue[0] = v0; - expect(options.get(key)).toStrictEqual(newStoredValue); + 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 }); + } + }); - // mutate the option, and check that newValue is not changed - newReference[0] = v1; - expect(newValue[0]).toStrictEqual(v0); - // @ts-ignore - expect(options.get(key)[0]).toStrictEqual(v1); - return options; -} + it("rejects malformed common integer options", () => { + for (const option of [ + { runs: -1 }, + { maxLen: 0 }, + { seed: 1.5 }, + { maxTotalTime: Number.NaN }, + ]) { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { engine: "libafl", ...option }, + OptionSource.CommandLineArguments, + ); + + expect(() => buildLibAflOptions(manager)).toThrow(); + } + }); +}); diff --git a/packages/core/options.ts b/packages/core/options.ts index 66dfae2d7..b2c1472bf 100644 --- a/packages/core/options.ts +++ b/packages/core/options.ts @@ -15,510 +15,215 @@ */ import fs from "fs"; -import * as util from "util"; import * as tmp from "tmp"; -import { useDictionaryByParams } from "./dictionary"; -import { replaceAll } from "./utils"; +import { + type LibAflOptions, + Mode, + type OptionsManager, + OptionSource, + printOptions, +} from "@jazzer.js/options"; -/** - * Jazzer.js options structure expected by the fuzzer. - * - * Entry functions, like the CLI or test framework integrations, need to build - * this structure and should use the same property names for exposing their own - * options. - */ -export interface Options { - // Enable source code coverage report generation. - coverage: boolean; - // Directory to write coverage reports to. - coverageDirectory: string; - // Coverage reporters to use during report generation. - coverageReporters: string[]; - // Files to load that contain custom hooks. - customHooks: string[]; - // Fuzzing dictionaries - dictionaryEntries: (string | Uint8Array | Int8Array)[]; - // Disable bug detectors by name. - disableBugDetectors: string[]; - // Whether to add fuzzing instrumentation or not. - dryRun: boolean; - // Part of filepath names to exclude in the instrumentation. - excludes: string[]; - // Expected error name that won't trigger the fuzzer to stop with an error exit code. - expectedErrors: string[]; - // Name of the function that is called by the fuzzer exported by `fuzzTarget`. - fuzzEntryPoint: string; - // Options to pass on to the underlying fuzzing engine. - fuzzerOptions: string[]; - // `fuzzTarget` is the name of a module exporting the fuzz function `fuzzEntryPoint`. - fuzzTarget: string; - // Internal: File to sync coverage IDs in fork mode. - idSyncFile: string; - // Part of filepath names to include in the instrumentation. - includes: string[]; - // Fuzzing mode. - mode: "fuzzing" | "regression"; - // Whether to run the fuzzer in sync mode or not. - sync: boolean; - // Timeout for one fuzzing iteration in milliseconds. - timeout: number; - // Verbose logging. - verbose: boolean; -} +import { useDictionaryByParams } from "./dictionary"; -export type OptionWithSource = { - value: Options[K]; - source: OptionSource; +type CommonEngineOptions = { + mode: Mode; + runs?: number; + seed: number; + maxLen: number; + timeoutMillis: number; + maxTotalTimeSeconds: number; + artifactPrefix: string; + corpusDirectories: string[]; + dictionaryFiles: string[]; }; -export type OptionsWithSource = { [P in keyof Options]: OptionWithSource

}; -type OptionWithPrintableSource = { - value: Options[K]; - source: string; -}; +const COMMON_ENGINE_FLAGS = new Map([ + ["-runs", "runs"], + ["-seed", "seed"], + ["-max_len", "maxLen"], + ["-timeout", "timeout"], + ["-max_total_time", "maxTotalTime"], + ["-artifact_prefix", "artifactPrefix"], + ["-dict", "dictionaryFiles"], +]); + +export function buildLibFuzzerOptions(options: OptionsManager): string[] { + const common = buildCommonEngineOptions(options); + const libFuzzerOptions = options.get("libFuzzerOptions"); + if (process.env.JAZZER_INTERNAL_LIBFUZZER_ARGS !== "1") { + validateBackendOptions("libFuzzerOptions", libFuzzerOptions); + } -export type OptionsWithPrintableSource = { - [P in keyof Options]: OptionWithPrintableSource

; -}; + let params = commonLibFuzzerOptions(common).concat(libFuzzerOptions); + params = forkedExecutionParams(params); -// These options can be set from the Jest fuzz test. -const allowedFuzzTestOptions = [ - "dictionaryEntries", - "fuzzerOptions", - "sync", - "timeout", -] as const; -export type AllowedFuzzTestOptions = (typeof allowedFuzzTestOptions)[number]; - -export const defaultCLIOptions: Options = Object.freeze({ - coverage: false, - coverageDirectory: "coverage", - coverageReporters: ["json", "text", "lcov", "clover"], // default Jest reporters - customHooks: [], - dictionaryEntries: [], - disableBugDetectors: [], - dryRun: false, - excludes: ["node_modules"], - expectedErrors: [], - fuzzEntryPoint: "fuzz", - fuzzerOptions: [], - fuzzTarget: "", - idSyncFile: "", - includes: ["*"], - mode: "fuzzing", - sync: false, - timeout: 5000, // default Jest timeout - verbose: false, -}); - -export const defaultJestOptions: Options = Object.freeze({ - ...defaultCLIOptions, - mode: "regression", -}); - -export type KeyFormatSource = (key: string) => string; -export const fromCamelCase: KeyFormatSource = (key: string): string => key; - -export const fromSnakeCase: KeyFormatSource = (key: string): string => { - return replaceAll(key.toLowerCase(), /(_[a-z0-9])/g, (group) => - group.toUpperCase().replace("_", ""), - ); -}; -export const fromSnakeCaseWithPrefix: (prefix: string) => KeyFormatSource = ( - prefix: string, -): KeyFormatSource => { - const prefixKey = prefix.toLowerCase() + "_"; - return (key: string): string => { - return key.toLowerCase().startsWith(prefixKey) - ? fromSnakeCase(key.substring(prefixKey.length)) - : key; - }; -}; + // libFuzzer has to ignore these signals, as they interfere with Node.js + // signal handling and Jazzer.js finding reporting. + params = params.concat("-handle_int=0", "-handle_term=0", "-handle_segv=0"); -// Source of an option is considered when merging options. -// Higher index means higher priority. -export enum OptionSource { - DefaultCLIOptions, - DefaultJestOptions, - InternalJestTimeout, - ConfigurationFile, - EnvironmentVariables, - CommandLineArguments, - JestFuzzTestOptions, + printOptions(options); + logInfoAboutCorpusDirectories(common.corpusDirectories); + return params; } -type DefaultSourceInfo = { - name: string; - transformKey: KeyFormatSource; - failOnUnknown: boolean; - parameters?: Options | object; -}; -const defaultOptions: Record = { - [OptionSource.DefaultCLIOptions]: { - name: "Default CLI options", - transformKey: fromCamelCase, - failOnUnknown: true, - parameters: defaultCLIOptions, - }, - [OptionSource.DefaultJestOptions]: { - name: "Default Jest options", - transformKey: fromCamelCase, - failOnUnknown: true, - parameters: defaultJestOptions, - }, - [OptionSource.InternalJestTimeout]: { - name: "Internal Jest timeout", - transformKey: fromCamelCase, - failOnUnknown: true, - }, - [OptionSource.ConfigurationFile]: { - name: "Configuration file", - transformKey: fromCamelCase, - failOnUnknown: true, - }, - [OptionSource.EnvironmentVariables]: { - name: "Environment variables", - transformKey: fromSnakeCaseWithPrefix("JAZZER"), - failOnUnknown: false, - parameters: process.env as object, - }, - [OptionSource.CommandLineArguments]: { - name: "Command line arguments", - transformKey: fromCamelCase, - failOnUnknown: true, - }, - [OptionSource.JestFuzzTestOptions]: { - name: "Jest fuzz test options", - transformKey: fromCamelCase, - failOnUnknown: true, - }, -} as const; - -export class OptionsManager { - private readonly _options: OptionsWithSource; - - constructor(obj: OptionSource); - constructor(obj: OptionsWithSource); - /** - * Manages merging of options from different sources. - * WARNING: each fuzz test needs a copy (use the `clone()` function) of the OptionsManager, otherwise the fuzz tests will overwrite each other's options. - * @param sourceOrOptions - build options given the `OptionSource`; or use provided options as is. - */ - constructor(sourceOrOptions: OptionSource | OptionsWithSource) { - if (typeof sourceOrOptions === "number") { - const source = sourceOrOptions; - const initialOptions = defaultOptions[source].parameters as Options; - if (!initialOptions) { - throw new Error( - `Default options for ${source} do not exist. Consider adding them or use a different source.`, - ); - } - this._options = OptionsManager.copyOptions( - OptionsManager.attachSource(initialOptions, source), - ); - this.merge(process.env, OptionSource.EnvironmentVariables); - } else if (typeof sourceOrOptions === "object") { - // only used by clone() - this._options = OptionsManager.copyOptions(sourceOrOptions); - } else { - throw new Error("Invalid argument"); - } - } +// Backwards-compatible alias for existing call sites. +export const buildFuzzerOption = buildLibFuzzerOptions; - /** - * Get the value of an option. - * @param key - */ - get(key: K): Options[K] { - return this._options[key].value; +export function buildLibAflOptions(options: OptionsManager): LibAflOptions { + if (options.get("libFuzzerOptions").length > 0) { + throw new Error( + "libFuzzerOptions can only be used with the libFuzzer backend. Use " + + "common Jazzer.js options such as --runs, --seed, --maxLen, and --timeout.", + ); } - /** - * Get raw options without the source information. - * @returns a copy of the options without source information - */ - getOptions(): Options { - return OptionsManager.detachSource(this._options); + const backendLibAflOptions = options.get("libAflOptions"); + if (backendLibAflOptions.length > 0) { + throw new Error( + "LibAFL backend-specific options are not supported yet. Use common " + + "Jazzer.js options such as --runs, --seed, --maxLen, and --timeout.", + ); } - getOptionsWithSource(): OptionsWithSource { - return this._options; + const common = buildCommonEngineOptions(options); + const libAflOptions = { + ...common, + runs: common.runs ?? 0, + runsSet: common.runs !== undefined, + }; + printOptions(options); + if (process.env.JAZZER_DEBUG) { + console.error( + `DEBUG: [core] LibAFL options: ${JSON.stringify(libAflOptions, null, 2)}`, + ); } + return libAflOptions; +} - /** - * Merge new options from `input` given the `source` (aka priority). Same `source` options will result in an error---accumulate the options before writing. - * `input` gets deep cloned to avoid reference keeping and unintended mutations. - * @param input - new options to merge - * @param source - priority of all the options in `input` - */ - merge(input: unknown, source: OptionSource) { - const transformKey = defaultOptions[source].transformKey; - const errorOnUnknown = defaultOptions[source].failOnUnknown; - - let includes: typeof this._options.includes.value | undefined = undefined; - let excludes: typeof this._options.excludes.value | undefined = undefined; - - Object.keys(input as object).forEach((k) => { - const transformedKey = transformKey(k); - - // Use hasOwnProperty to still support node v14. - // eslint-disable-next-line no-prototype-builtins - if (!defaultCLIOptions.hasOwnProperty(transformedKey)) { - if (errorOnUnknown) { - throw new Error(`Unknown Jazzer.js option '${k}'`); - } - return; - } - const key = transformedKey as keyof Options; - if (!validateOptionPermissions(key, source, this._options)) { - return; - } - - const keyType = typeof defaultCLIOptions[key]; - - // No way to dynamically resolve the types here, use (implicit) any for now. - // @ts-ignore - let resultValue = input[k]; - // Try to parse strings as JSON values to support setting arrays and - // objects via environment variables and command line arguments. - if ( - [ - OptionSource.CommandLineArguments, - OptionSource.EnvironmentVariables, - ].includes(source) && - keyType !== "string" && - (typeof resultValue === "string" || resultValue instanceof String) - ) { - try { - resultValue = JSON.parse(resultValue.toString()); - } catch (ignore) { - // Ignore parsing errors and continue with the string value. - } - } - - if (typeof resultValue !== keyType) { - throw new Error( - `Invalid type for Jazzer.js option '${key}', expected type '${keyType}', got '${typeof resultValue}'`, - ); - } - // Deep copy the new value to avoid reference keeping and unintended mutations. - resultValue = OptionsManager.copyOptionValue(resultValue); - setProperty(this._options, key, { value: resultValue, source: source }); - - if (key === "includes") { - includes = resultValue; - } else if (key === "excludes") { - excludes = resultValue; - } - }); - - // Includes and excludes must be set together. - if (input && includes && !excludes) { - this._options.excludes.value = []; - } else if (input && excludes && !includes) { - this._options.includes.value = []; - } - - // Set verbose mode environment variable via option or node DEBUG environment variable. - // Subsequent changes to the `verbose` option will be ignored. - if (this.get("verbose") || process.env.DEBUG) { - process.env.JAZZER_DEBUG = "1"; - } - return this; +function buildCommonEngineOptions( + options: OptionsManager, +): CommonEngineOptions { + const timeoutMillis = positiveInteger("timeout", options.get("timeout")); + let runs = nonNegativeInteger("runs", options.get("runs")); + const runsSet = + options.getOptionsWithSource().runs.source > + OptionSource.DefaultJestOptions; + if (options.get("mode") === Mode.Regression) { + // Regression mode should replay every available corpus input unless the + // user asked to stop for some other reason, mirroring libFuzzer's behavior. + runs = 0; } - clone(): OptionsManager { - return new OptionsManager(this._options); - } + return { + mode: options.get("mode"), + runs: runsSet || options.get("mode") === Mode.Regression ? runs : undefined, + seed: nonNegativeInteger("seed", options.get("seed")), + maxLen: positiveInteger("maxLen", options.get("maxLen")), + timeoutMillis, + maxTotalTimeSeconds: nonNegativeInteger( + "maxTotalTime", + options.get("maxTotalTime"), + ), + artifactPrefix: options.get("artifactPrefix"), + corpusDirectories: options.get("corpusDirectories"), + dictionaryFiles: mergedDictionaryFiles(options), + }; +} - static copyOptions(newOptions: OptionsWithSource): OptionsWithSource { - const result: OptionsWithSource = Object.create(null); - Object.entries(newOptions).forEach(([k]) => { - const key = k as keyof Options; - const option = newOptions[key]; - const value = OptionsManager.copyOptionValue(option.value); - const source = option.source; - setProperty(result, key, { - value, - source, - }); - }); - return result; +function commonLibFuzzerOptions(options: CommonEngineOptions): string[] { + const params = [ + `-seed=${options.seed}`, + `-max_len=${options.maxLen}`, + `-timeout=${Math.ceil(options.timeoutMillis / 1000)}`, + ]; + if (options.mode === Mode.Regression) { + params.push("-runs=0"); + } else if (options.runs !== undefined) { + params.push(`-runs=${options.runs}`); } - - static copyOptionValue( - input: T[K], - ): T[K] { - // simple types - if (!input || typeof input !== "object") { - return input; - } - - if (Array.isArray(input)) { - // (Uint8Array | Int8Array)[] - each sub-array gets copied - if ( - input.some( - (element) => - element instanceof Uint8Array || element instanceof Int8Array, - ) - ) { - return input.map((element) => { - if (element instanceof Uint8Array || element instanceof Int8Array) { - return element.slice(); - } - return element; - }) as T[K]; - } - - // string[] - the array can be copied directly - return input.slice() as T[K]; - } - - throw new Error("copyOptionValue: unsupported type: " + typeof input); + if (options.maxTotalTimeSeconds > 0) { + params.push(`-max_total_time=${options.maxTotalTimeSeconds}`); } - - /** - * Build options with source information attached. - * - * @param options - * @returns a copy of the options with source information - */ - static attachSource( - options: Options, - source: OptionSource, - ): OptionsWithSource { - const result: OptionsWithSource = Object.create(null); - Object.entries(options).forEach(([k]) => { - const key = k as keyof Options; - setProperty(result, key, { - value: options[key], - source: source, - }); - }); - return result; + if (options.artifactPrefix) { + params.push(`-artifact_prefix=${options.artifactPrefix}`); } - - /** - * Remove source information from options. - * - * @param options - * @returns a copy of the options without source information - */ - static detachSource(options: OptionsWithSource): Options { - const result: Options = Object.create(null); - Object.entries(options).forEach(([k]) => { - const key = k as keyof Options; - const value = options[key]?.value; - setProperty(result, key, value); - }); - return result; - } -} - -function setProperty(obj: T, key: K, value: T[K]) { - obj[key] = value; -} - -export function buildFuzzerOption(options: OptionsManager) { - let params: string[] = []; - params = optionDependentParams(options, params); - params = forkedExecutionParams(params); - params = useDictionaryByParams(params, options.get("dictionaryEntries")); - - // libFuzzer has to ignore SIGINT and SIGTERM, as it interferes - // with the Node.js signal handling. - params = params.concat("-handle_int=0", "-handle_term=0", "-handle_segv=0"); - - printOptions(options); - logInfoAboutFuzzerOptions(params); + params.push(...options.dictionaryFiles.map((file) => `-dict=${file}`)); + params.push(...options.corpusDirectories); return params; } -export function printOptions(options: OptionsManager, infix = "") { - if (process.env.JAZZER_DEBUG) { - console.error( - util.formatWithOptions( - // Print everything in the options object. - { maxArrayLength: null, depth: null, colors: false }, - `DEBUG: [core] Jazzer.js options ${infix}: \n%O`, - toOptionsWithPrintableSources(options), - ), - ); - } +function mergedDictionaryFiles(options: OptionsManager): string[] { + const dictionaryOptions = useDictionaryByParams( + options.get("dictionaryFiles").map((file) => `-dict=${file}`), + options.get("dictionaryEntries"), + ).filter((option) => option.startsWith("-dict=")); + const mergedDictionary = dictionaryOptions[dictionaryOptions.length - 1]; + return mergedDictionary ? [mergedDictionary.substring(6)] : []; } -function toOptionsWithPrintableSources( - options: OptionsManager, -): OptionsWithPrintableSource { - const result: OptionsWithPrintableSource = Object.create(null); - const opts = options.getOptionsWithSource(); - Object.entries(opts).forEach(([k]) => { - const key = k as keyof Options; - const value = opts[key]?.value; - const sourceIndex = opts[key]?.source; - if (sourceIndex !== undefined) { - const source = defaultOptions[sourceIndex].name; - setProperty(result, key, { value, source }); +function validateBackendOptions(name: string, options: string[]): void { + for (const option of options) { + if (!option.startsWith("-")) { + throw new Error( + `Backend option '${option}' in '${name}' is not a flag. ` + + "Use corpusDirectories for corpus paths.", + ); } - }); - return result; -} -function logInfoAboutFuzzerOptions(fuzzerOptions: string[]) { - fuzzerOptions.slice(1).forEach((element) => { - if (element.length > 0 && element[0] != "-") { - console.error("INFO: using inputs from:", element); + const flag = option.split("=", 1)[0]; + const commonName = COMMON_ENGINE_FLAGS.get(flag); + if (commonName) { + throw new Error( + `'${flag}' is a Jazzer.js common option. Use '--${commonName}' ` + + "instead of passing it as a backend-specific option.", + ); } - }); -} - -function optionDependentParams( - options: OptionsManager, - params: string[], -): string[] { - if (!options || !options.get("fuzzerOptions")) { - return params; } +} - let opts = options.get("fuzzerOptions"); - if (options.get("mode") === "regression") { - // The last provided option takes precedence - opts = opts.concat("-runs=0"); +function positiveInteger(name: string, value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error( + `Option '${name}' must be a positive integer, got '${value}'`, + ); } + return value; +} - if (options.get("timeout") <= 0) { - throw new Error("timeout must be > 0"); +function nonNegativeInteger(name: string, value: number): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error( + `Option '${name}' must be a non-negative integer, got '${value}'`, + ); } - const inSeconds = Math.ceil(options.get("timeout") / 1000); - opts = opts.concat(`-timeout=${inSeconds}`); + return value; +} - return opts; +function logInfoAboutCorpusDirectories(corpusDirectories: string[]) { + corpusDirectories.forEach((directory) => { + console.error("INFO: using inputs from:", directory); + }); } function forkedExecutionParams(params: string[]): string[] { return [prepareLibFuzzerArg0(params), ...params]; } -function prepareLibFuzzerArg0(fuzzerOptions: string[]): string { - // When we run in a libFuzzer mode that spawns subprocesses, we create a wrapper script - // that can be used as libFuzzer's argv[0]. In the fork mode, the main libFuzzer process - // uses argv[0] to spawn further processes that perform the actual fuzzing. - if (!spawnsSubprocess(fuzzerOptions)) { - // Return a fake argv[0] to start the fuzzer if libFuzzer does not spawn new processes. +function prepareLibFuzzerArg0(libFuzzerArgv: string[]): string { + if (!spawnsSubprocess(libFuzzerArgv)) { return "unused_arg0_report_a_bug_if_you_see_this"; } else { - // Create a wrapper script and return its path. - return createWrapperScript(fuzzerOptions); + return createWrapperScript(libFuzzerArgv); } } -// These flags cause libFuzzer to spawn subprocesses. const SUBPROCESS_FLAGS = ["fork", "jobs", "merge", "minimize_crash"]; -export function spawnsSubprocess(fuzzerOptions: string[]): boolean { - return fuzzerOptions.some((option) => +export function spawnsSubprocess(libFuzzerArgv: string[]): boolean { + return libFuzzerArgv.some((option) => SUBPROCESS_FLAGS.some((flag) => { const name = `-${flag}=`; return option.startsWith(name) && !option.startsWith("0", name.length); @@ -526,9 +231,9 @@ export function spawnsSubprocess(fuzzerOptions: string[]): boolean { ); } -function createWrapperScript(fuzzerOptions: string[]) { - const jazzerArgs = process.argv.filter( - (arg) => arg !== "--" && fuzzerOptions.indexOf(arg) === -1, +function createWrapperScript(libFuzzerArgv: string[]) { + const jazzerArgs = filterLibFuzzerOptions(process.argv).filter( + (arg) => arg !== "--" && !libFuzzerArgv.includes(arg), ); if (jazzerArgs.indexOf("--id_sync_file") === -1) { @@ -542,10 +247,13 @@ function createWrapperScript(fuzzerOptions: string[]) { } const isWindows = process.platform === "win32"; + const envPrefix = isWindows + ? "set JAZZER_INTERNAL_LIBFUZZER_ARGS=1\n" + : "JAZZER_INTERNAL_LIBFUZZER_ARGS=1 "; - const scriptContent = `${isWindows ? "@echo off" : "#!/usr/bin/env sh"} + const scriptContent = `${isWindows ? "@echo off\n" : "#!/usr/bin/env sh\n"} cd "${process.cwd()}" -${jazzerArgs.map((s) => '"' + s + '"').join(" ")} -- ${isWindows ? "%*" : "$@"} +${envPrefix}${jazzerArgs.map((s) => '"' + s + '"').join(" ")} -- ${isWindows ? "%*" : '"$@"'} `; const scriptTempFile = tmp.fileSync({ @@ -559,45 +267,26 @@ ${jazzerArgs.map((s) => '"' + s + '"').join(" ")} -- ${isWindows ? "%*" : "$@"} return scriptTempFile.name; } -// Check two things: -// 1) `dictionaryEntries` can only be set from "Jest fuzz test" source; -// 2) only few approved options can be set from "Jest fuzz test" source. -export function validateKeySource(key: keyof Options, source: OptionSource) { - const sourceName = defaultOptions[source].name; - - // Only "Jest fuzz test" is allowed to set `dictionaryEntries` option. - if ( - key === "dictionaryEntries" && - source !== OptionSource.JestFuzzTestOptions - ) { - const allowedSource = defaultOptions[OptionSource.JestFuzzTestOptions].name; - throw new Error( - `Tried setting option '${key}' from ${sourceName}, but this option is only available in ${allowedSource}`, - ); - } - - // Only selected options can be set from the Jest fuzz test - if ( - source === OptionSource.JestFuzzTestOptions && - !allowedFuzzTestOptions.includes(key as AllowedFuzzTestOptions) - ) { - throw new Error(`Option '${key}' is not available from "${sourceName}."`); - } -} - -// Check if the key can be set from the new source. -// -function validateOptionPermissions( - key: keyof Options, - source: OptionSource, - options: OptionsWithSource, -): boolean { - validateKeySource(key, source); - // Overwriting options from the same source is not allowed---accumulate the options before writing. - if (source === options[key].source) { - throw new Error( - `Option '${key}' already set from ${defaultOptions[source].name}`, - ); +function filterLibFuzzerOptions(args: string[]): string[] { + const result: string[] = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if ( + arg === "--libFuzzerOptions" || + arg === "--lib_fuzzer_options" || + arg === "--lib-fuzzer-options" + ) { + i++; + continue; + } + if ( + arg.startsWith("--libFuzzerOptions=") || + arg.startsWith("--lib_fuzzer_options=") || + arg.startsWith("--lib-fuzzer-options=") + ) { + continue; + } + result.push(arg); } - return source > options[key].source; + return result; } diff --git a/packages/core/utils.test.ts b/packages/core/utils.test.ts index 703364c79..8ad6adebe 100644 --- a/packages/core/utils.test.ts +++ b/packages/core/utils.test.ts @@ -16,7 +16,11 @@ import path from "path"; -import { ensureFilepath, prepareArgs } from "./utils"; +import { + ensureFilepath, + normalizeLegacyEngineFlags, + prepareArgs, +} from "./utils"; describe("core", () => { describe("ensuresFilepath", () => { @@ -36,23 +40,94 @@ describe("core", () => { }); }); describe("prepareArgs", () => { - it("converts fuzzer args to strings", () => { + it("normalizes legacy single-dash engine flags before parsing", () => { + expect( + normalizeLegacyEngineFlags([ + "-runs=4000", + "-seed=1337", + "-max_len=16", + "-artifact_prefix=/tmp/", + "-dict=tokens.dict", + ]), + ).toEqual([ + "--runs=4000", + "--seed=1337", + "--max_len=16", + "--artifact_prefix=/tmp/", + "--dict=tokens.dict", + ]); + }); + + it("leaves regular CLI arguments unchanged", () => { + expect( + normalizeLegacyEngineFlags([ + "fuzz.js", + "-f", + "target", + "--engine=afl", + "corpus", + ]), + ).toEqual(["fuzz.js", "-f", "target", "--engine=afl", "corpus"]); + }); + + it("does not add an undefined engine", () => { const args = { - _: ["-some_arg=value", "-other_arg", 123], + _: [], + corpus: [], + fuzzTarget: "filename.js", + }; + const options = prepareArgs(args); + expect( + Object.prototype.hasOwnProperty.call(options, "engine"), + ).toBeFalsy(); + }); + + it("converts corpus directories to strings", () => { + const args = { + _: [], corpus: ["directory1", "directory2"], fuzzTarget: "filename.js", }; const options = prepareArgs(args); expect(options).toEqual({ fuzzTarget: "file://" + path.join(process.cwd(), "filename.js"), - fuzzerOptions: [ - "directory1", - "directory2", - "-some_arg=value", - "-other_arg", - "123", - ], + corpusDirectories: ["directory1", "directory2"], + }); + }); + + it("rejects engine args after double dash", () => { + const args = { + _: ["-use_value_profile=1"], + corpus: [], + fuzzTarget: "filename.js", + }; + + expect(() => prepareArgs(args)).toThrow("after '--'"); + }); + + it("keeps explicit libFuzzer options", () => { + const args = { + _: [], + corpus: [], + fuzzTarget: "filename.js", + libFuzzerOptions: ["-use_value_profile=1"], + }; + + expect(prepareArgs(args)).toEqual({ + fuzzTarget: "file://" + path.join(process.cwd(), "filename.js"), + libFuzzerOptions: ["-use_value_profile=1"], }); }); + + 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..4e6123289 100644 --- a/packages/core/utils.ts +++ b/packages/core/utils.ts @@ -57,6 +57,28 @@ export function ensureFilepath(filePath: string): string { : fullPath + ".js"; } +const LEGACY_ENGINE_FLAG_PREFIXES = [ + "-runs=", + "-seed=", + "-max_len=", + "-timeout=", + "-max_total_time=", + "-artifact_prefix=", + "-dict=", +]; + +export function normalizeLegacyEngineFlags(argv: string[]): string[] { + return argv.map((arg) => { + if (arg.startsWith("--")) { + return arg; + } + if (LEGACY_ENGINE_FLAG_PREFIXES.some((prefix) => arg.startsWith(prefix))) { + return `-${arg}`; + } + return arg; + }); +} + /** * Transform arguments to common format, add compound properties and * remove framework specific ones, so that the result can be passed on to the @@ -67,15 +89,35 @@ export function ensureFilepath(filePath: string): string { */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function prepareArgs(args: any) { + const engineArgs = (args._ ?? []).map((e: unknown) => e + ""); + const internalLibFuzzerArgs = + process.env.JAZZER_INTERNAL_LIBFUZZER_ARGS === "1" ? engineArgs : []; + if (engineArgs.length > 0 && internalLibFuzzerArgs.length === 0) { + throw new Error( + "Engine options after '--' are no longer supported. Use " + + "--libFuzzerOptions or --libAflOptions.", + ); + } + const options = { ...args, fuzzTarget: ensureFilepath(args.fuzzTarget), - fuzzerOptions: (args.corpus ?? []) - .concat(args._) - .map((e: unknown) => e + ""), + corpusDirectories: (args.corpus ?? []).map((e: unknown) => e + ""), + libFuzzerOptions: + internalLibFuzzerArgs.length > 0 + ? internalLibFuzzerArgs + : args.libFuzzerOptions, }; - if (options.fuzzerOptions.length === 0) { - delete options.fuzzerOptions; + if (options.engine !== undefined) { + options.engine = options.engine === "afl" ? "libafl" : options.engine; + } else { + delete options.engine; + } + if (options.corpusDirectories.length === 0) { + delete options.corpusDirectories; + } + if (options.libFuzzerOptions === undefined) { + delete options.libFuzzerOptions; } delete options._; delete options.corpus; From ebb141dd1b0d6f03a54c2108c502ee1186430263 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:32:05 +0200 Subject: [PATCH 03/10] fix(instrumentor): reserve ESM coverage ranges - lazy ESM modules are also registered in the shared coverage map --- packages/fuzzer/coverage.ts | 37 ++-- packages/instrumentor/edgeIdStrategy.ts | 207 ++++++++++++-------- packages/instrumentor/esm-loader.mts | 6 +- packages/instrumentor/esmSourceMaps.test.ts | 2 +- packages/instrumentor/instrument.ts | 8 + 5 files changed, 161 insertions(+), 99 deletions(-) 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/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, }); From 32783f009cab6872cc5bc549181f72a841893adb Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:34:13 +0200 Subject: [PATCH 04/10] feat(fuzzer): add LibAFL runtime backend --- packages/fuzzer/.gitignore | 1 + packages/fuzzer/CMakeLists.txt | 63 +- packages/fuzzer/README.md | 23 +- packages/fuzzer/addon.cpp | 3 + packages/fuzzer/addon.ts | 34 +- packages/fuzzer/fuzzer.test.ts | 70 + packages/fuzzer/fuzzer.ts | 5 + packages/fuzzer/fuzzing_async.cpp | 23 +- packages/fuzzer/fuzzing_sync.cpp | 27 +- packages/fuzzer/libafl_findings.cpp | 262 ++++ packages/fuzzer/libafl_findings.h | 41 + packages/fuzzer/libafl_options.cpp | 137 ++ packages/fuzzer/libafl_options.h | 47 + packages/fuzzer/libafl_regression.cpp | 326 +++++ packages/fuzzer/libafl_regression.h | 23 + packages/fuzzer/libafl_runtime.cpp | 845 ++++++++++++ packages/fuzzer/libafl_runtime.h | 22 + packages/fuzzer/libafl_runtime.test.ts | 339 +++++ packages/fuzzer/package.json | 4 +- packages/fuzzer/runtime/benchmark.js | 154 +++ packages/fuzzer/rust/Cargo.lock | 1370 ++++++++++++++++++++ packages/fuzzer/rust/Cargo.toml | 20 + packages/fuzzer/rust/src/abi.rs | 162 +++ packages/fuzzer/rust/src/compare_log.rs | 167 +++ packages/fuzzer/rust/src/lib.rs | 31 + packages/fuzzer/rust/src/monitor.rs | 534 ++++++++ packages/fuzzer/rust/src/runtime.rs | 298 +++++ packages/fuzzer/rust/src/runtime_config.rs | 99 ++ packages/fuzzer/rust/src/shared_maps.rs | 120 ++ packages/fuzzer/shared/callbacks.cpp | 10 +- packages/fuzzer/shared/coverage.cpp | 62 +- packages/fuzzer/shared/coverage.h | 9 +- packages/fuzzer/shared/libafl_abi.h | 141 ++ packages/fuzzer/shared/libfuzzer.h | 1 + packages/fuzzer/shared/tracing.cpp | 149 ++- packages/fuzzer/shared/tracing.h | 13 + packages/fuzzer/tsconfig.json | 7 +- 37 files changed, 5602 insertions(+), 40 deletions(-) create mode 100644 packages/fuzzer/libafl_findings.cpp create mode 100644 packages/fuzzer/libafl_findings.h create mode 100644 packages/fuzzer/libafl_options.cpp create mode 100644 packages/fuzzer/libafl_options.h create mode 100644 packages/fuzzer/libafl_regression.cpp create mode 100644 packages/fuzzer/libafl_regression.h create mode 100644 packages/fuzzer/libafl_runtime.cpp create mode 100644 packages/fuzzer/libafl_runtime.h create mode 100644 packages/fuzzer/libafl_runtime.test.ts create mode 100644 packages/fuzzer/runtime/benchmark.js create mode 100644 packages/fuzzer/rust/Cargo.lock create mode 100644 packages/fuzzer/rust/Cargo.toml create mode 100644 packages/fuzzer/rust/src/abi.rs create mode 100644 packages/fuzzer/rust/src/compare_log.rs create mode 100644 packages/fuzzer/rust/src/lib.rs create mode 100644 packages/fuzzer/rust/src/monitor.rs create mode 100644 packages/fuzzer/rust/src/runtime.rs create mode 100644 packages/fuzzer/rust/src/runtime_config.rs create mode 100644 packages/fuzzer/rust/src/shared_maps.rs create mode 100644 packages/fuzzer/shared/libafl_abi.h diff --git a/packages/fuzzer/.gitignore b/packages/fuzzer/.gitignore index 94ce2116e..ffb50e1ab 100644 --- a/packages/fuzzer/.gitignore +++ b/packages/fuzzer/.gitignore @@ -1,3 +1,4 @@ .cache build cmake-build-debug +rust/target diff --git a/packages/fuzzer/CMakeLists.txt b/packages/fuzzer/CMakeLists.txt index 0331affaa..0e27498ad 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,65 @@ 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_CARGO_TOML "${RUST_CRATE_DIR}/Cargo.toml") +set(RUST_CARGO_LOCK "${RUST_CRATE_DIR}/Cargo.lock") +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}") +file(GLOB_RECURSE RUST_SOURCE_FILES CONFIGURE_DEPENDS + "${RUST_CRATE_DIR}/src/*.rs") + +add_custom_command( + OUTPUT ${RUST_STATICLIB_PATH} + COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${RUST_TARGET_DIR} + ${CARGO_EXECUTABLE} build --manifest-path ${RUST_CARGO_TOML} + --target ${RUST_TARGET_TRIPLE} ${CARGO_PROFILE_FLAG} + WORKING_DIRECTORY ${RUST_CRATE_DIR} + DEPENDS ${RUST_CARGO_TOML} ${RUST_CARGO_LOCK} ${RUST_SOURCE_FILES} + 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}) + +if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + # Rust staticlibs do not propagate Windows import libraries to the final + # Node addon target, so link the system APIs used by Rust std explicitly. + target_link_libraries(${PROJECT_NAME} ntdll ws2_32 userenv) +endif() + # 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 @@ -154,7 +214,8 @@ target_include_directories(${PROJECT_NAME} if(CMAKE_SYSTEM_NAME STREQUAL "Linux") target_link_libraries( ${PROJECT_NAME} -Wl,-whole-archive - ${BINARY_DIR}/${LIBFUZZER_STATIC_LIB_PATH} -Wl,-no-whole-archive) + ${BINARY_DIR}/${LIBFUZZER_STATIC_LIB_PATH} -Wl,-no-whole-archive + rt) elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") target_link_libraries( ${PROJECT_NAME} -Wl,-all_load ${BINARY_DIR}/${LIBFUZZER_STATIC_LIB_PATH}) 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..f2c8e134b 100644 --- a/packages/fuzzer/addon.ts +++ b/packages/fuzzer/addon.ts @@ -17,6 +17,8 @@ import * as fs from "fs"; import * as path from "path"; +import type { LibAflOptions } from "@jazzer.js/options"; + export type FuzzTargetAsyncOrValue = ( data: Buffer, ) => unknown | Promise; @@ -26,6 +28,7 @@ export type FuzzTargetCallback = ( ) => unknown; export type FuzzTarget = FuzzTargetAsyncOrValue | FuzzTargetCallback; export type FuzzOpts = string[]; +export type { LibAflOptions } from "@jazzer.js/options"; export type StartFuzzingSyncFn = ( fuzzFn: FuzzTarget, @@ -36,11 +39,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 +78,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 +103,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/fuzzer.test.ts b/packages/fuzzer/fuzzer.test.ts index c18580c4e..045d2b266 100644 --- a/packages/fuzzer/fuzzer.test.ts +++ b/packages/fuzzer/fuzzer.test.ts @@ -14,8 +14,20 @@ * limitations under the License. */ +import { spawnSync } from "child_process"; +import * as path from "path"; + +import { addon } from "./addon"; import { fuzzer } from "./fuzzer"; +function nativeAddonPath(): string { + return path.join( + __dirname, + "prebuilds", + `fuzzer-${process.platform}-${process.arch}.node`, + ); +} + describe("compare hooks", () => { it("traceStrCmp supports equals operators", () => { expect(fuzzer.tracer.traceStrCmp("a", "b", "==", 0)).toBe(false); @@ -49,4 +61,62 @@ describe("incrementCounter", () => { } } }); + + it("rejects invalid counter ranges at the native boundary", () => { + const coverageMap = Buffer.alloc(16); + addon.registerCoverageMap(coverageMap); + for (const [oldNumCounters, newNumCounters] of [ + [-1, 1], + [1.5, 2], + [Number.NaN, 1], + [Number.POSITIVE_INFINITY, 1], + [1, 2.5], + ] as const) { + expect(() => + addon.registerNewCounters(oldNumCounters, newNumCounters), + ).toThrow(); + } + }); + + it("exits cleanly after a synchronous libFuzzer run", () => { + const script = ` + const addon = require(${JSON.stringify(nativeAddonPath())}); + const coverageMap = Buffer.alloc(${1 << 20}); + const timeout = setTimeout(() => { + console.error("process did not exit naturally"); + process.exit(4); + }, 1000); + timeout.unref(); + + addon.registerCoverageMap(coverageMap); + addon.registerNewCounters(0, 512); + + addon + .startFuzzing( + () => undefined, + [ + "jazzer-libfuzzer-exit-test", + "-runs=1", + "-seed=1234", + "-max_len=32", + ], + () => undefined, + ) + .then(() => { + clearTimeout(timeout); + }) + .catch((error) => { + console.error(error); + process.exit(3); + }); + `; + + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 5000, + }); + + expect(result.signal).toBeNull(); + expect(result.status).toBe(0); + }); }); 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/fuzzing_async.cpp b/packages/fuzzer/fuzzing_async.cpp index ef7a4dd0c..61add5649 100644 --- a/packages/fuzzer/fuzzing_async.cpp +++ b/packages/fuzzer/fuzzing_async.cpp @@ -72,6 +72,25 @@ volatile int nSigInts = 0; // jump back to this stored context in case of a segfault. std::jmp_buf executionContext; +class ScopedSignalHandler { +public: + ScopedSignalHandler(int signum, void (*handler)(int)) + : signum_(signum), previous_handler_(std::signal(signum, handler)) {} + + ~ScopedSignalHandler() { + if (previous_handler_ != SIG_ERR) { + std::signal(signum_, previous_handler_); + } + } + + ScopedSignalHandler(const ScopedSignalHandler &) = delete; + ScopedSignalHandler &operator=(const ScopedSignalHandler &) = delete; + +private: + int signum_; + void (*previous_handler_)(int); +}; + void sigintHandler(int signum) { std::cerr << std::endl; // Print a newline after the ^C. // Pressing CTRL+C more than once will terminate the process immediately. @@ -325,8 +344,8 @@ Napi::Value StartFuzzingAsync(const Napi::CallbackInfo &info) { // loop. context->native_thread = std::thread( [](const std::vector &fuzzer_args) { - signal(SIGSEGV, ErrorSignalHandler); - signal(SIGINT, sigintHandler); + ScopedSignalHandler sigsegv_handler(SIGSEGV, ErrorSignalHandler); + ScopedSignalHandler sigint_handler(SIGINT, sigintHandler); StartLibFuzzer(fuzzer_args, FuzzCallbackAsync); gTSFN.Release(); }, diff --git a/packages/fuzzer/fuzzing_sync.cpp b/packages/fuzzer/fuzzing_sync.cpp index 694d2bc6b..5d550934e 100644 --- a/packages/fuzzer/fuzzing_sync.cpp +++ b/packages/fuzzer/fuzzing_sync.cpp @@ -51,6 +51,25 @@ volatile int nSigInts = 0; // Store the execution context of the fuzz target function. The execution will // jump back to this stored context in case of a segfault. std::jmp_buf executionContext; + +class ScopedSignalHandler { +public: + ScopedSignalHandler(int signum, void (*handler)(int)) + : signum_(signum), previous_handler_(std::signal(signum, handler)) {} + + ~ScopedSignalHandler() { + if (previous_handler_ != SIG_ERR) { + std::signal(signum_, previous_handler_); + } + } + + ScopedSignalHandler(const ScopedSignalHandler &) = delete; + ScopedSignalHandler &operator=(const ScopedSignalHandler &) = delete; + +private: + int signum_; + void (*previous_handler_)(int); +}; } // namespace void sigintHandler(int signum) { @@ -175,10 +194,12 @@ Napi::Value StartFuzzing(const Napi::CallbackInfo &info) { Napi::Promise::Deferred::New(info.Env()), info[2].As()}; - signal(SIGINT, sigintHandler); - signal(SIGSEGV, ErrorSignalHandler); + { + ScopedSignalHandler sigint_handler(SIGINT, sigintHandler); + ScopedSignalHandler sigsegv_handler(SIGSEGV, ErrorSignalHandler); - StartLibFuzzer(fuzzer_args, FuzzCallbackSync); + StartLibFuzzer(fuzzer_args, FuzzCallbackSync); + } // Resolve the deferred in case no error could be found during fuzzing. if (!gFuzzTarget->isResolved) { diff --git a/packages/fuzzer/libafl_findings.cpp b/packages/fuzzer/libafl_findings.cpp new file mode 100644 index 000000000..f970dfd79 --- /dev/null +++ b/packages/fuzzer/libafl_findings.cpp @@ -0,0 +1,262 @@ +// 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_findings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#define GetPID _getpid +#else +#include +#define GetPID getpid +#endif + +#include "shared/libfuzzer.h" + +namespace { +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 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); +} +} // namespace + +void ClearFindingInfo(JazzerLibAflFindingInfo *finding_info) { + if (finding_info == nullptr) { + return; + } + + std::memset(finding_info, 0, sizeof(*finding_info)); +} + +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(JazzerLibAflFindingInfo *finding_info, + const std::string &artifact, + const std::string &summary) { + if (finding_info == nullptr) { + return; + } + + finding_info->has_value = 1; + CopyFindingField(finding_info->artifact, sizeof(finding_info->artifact), + artifact); + CopyFindingField(finding_info->summary, sizeof(finding_info->summary), + summary); +} + +std::string WriteArtifact(const std::string &artifact_prefix, + const std::string &kind, const uint8_t *data, + size_t size, bool emit_info) { + 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(JazzerLibAflFindingInfo *finding_info, + 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(finding_info, 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); +} diff --git a/packages/fuzzer/libafl_findings.h b/packages/fuzzer/libafl_findings.h new file mode 100644 index 000000000..f044bbf1f --- /dev/null +++ b/packages/fuzzer/libafl_findings.h @@ -0,0 +1,41 @@ +// 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 +#include + +#include + +#include "shared/libafl_abi.h" + +void ClearFindingInfo(JazzerLibAflFindingInfo *finding_info); +std::string DescribeJsError(Napi::Env env, const Napi::Value &error); +std::string DescribeTimeout(uint64_t timeout_millis); +void RecordFindingInfo(JazzerLibAflFindingInfo *finding_info, + const std::string &artifact, const std::string &summary); +std::string WriteArtifact(const std::string &artifact_prefix, + const std::string &kind, const uint8_t *data, + size_t size, bool emit_info = true); +[[noreturn]] void ExitOnTimeout(JazzerLibAflFindingInfo *finding_info, + uint64_t timeout_millis, + const std::string &artifact_prefix, + const std::vector &input); +[[noreturn]] void ExitWithUnexpectedError(const std::exception &exception); diff --git a/packages/fuzzer/libafl_options.cpp b/packages/fuzzer/libafl_options.cpp new file mode 100644 index 000000000..9d50a9887 --- /dev/null +++ b/packages/fuzzer/libafl_options.cpp @@ -0,0 +1,137 @@ +// 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_options.h" + +#include "shared/coverage.h" +#include "shared/tracing.h" + +LibAflOptions ParseLibAflOptions(Napi::Env env, const Napi::Object &js_opts) { + LibAflOptions parsed; + + const auto mode = js_opts.Get("mode"); + const auto runs = js_opts.Get("runs"); + const auto runs_set = js_opts.Get("runsSet"); + 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() || !runs_set.IsBoolean() || !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, " + "runsSet, seed, maxLen, timeoutMillis, maxTotalTimeSeconds, " + "artifactPrefix, corpusDirectories, and dictionaryFiles"); + } + + if (mode.IsString()) { + const auto mode_value = mode.As().Utf8Value(); + if (mode_value == "regression") { + parsed.mode = LibAflOptions::Mode::kRegression; + } else if (mode_value == "fuzzing") { + parsed.mode = LibAflOptions::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.runs_set = runs_set.As().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 +SharedMapsForLibAflRuntime(Napi::Env env, + JazzerLibAflFindingInfo *finding_info) { + auto *edges = CoverageCounters(); + const auto edges_capacity = CoverageCountersCapacity(); + auto *edges_size = CoverageCountersSizePointer(); + auto *cmp = CompareFeedbackMap(); + const auto cmp_len = CompareFeedbackMapSize(); + auto *compare_log = CompareLog(); + + 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}; +} diff --git a/packages/fuzzer/libafl_options.h b/packages/fuzzer/libafl_options.h new file mode 100644 index 000000000..a88caf5fc --- /dev/null +++ b/packages/fuzzer/libafl_options.h @@ -0,0 +1,47 @@ +// 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 + +#include + +#include "shared/libafl_abi.h" + +struct LibAflOptions { + enum class Mode { + kFuzzing, + kRegression, + }; + + Mode mode = Mode::kFuzzing; + uint64_t runs = 0; + bool runs_set = false; + 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; +}; + +LibAflOptions ParseLibAflOptions(Napi::Env env, const Napi::Object &js_opts); +JazzerLibAflRuntimeSharedMaps +SharedMapsForLibAflRuntime(Napi::Env env, + JazzerLibAflFindingInfo *finding_info); diff --git a/packages/fuzzer/libafl_regression.cpp b/packages/fuzzer/libafl_regression.cpp new file mode 100644 index 000000000..efd2ff45a --- /dev/null +++ b/packages/fuzzer/libafl_regression.cpp @@ -0,0 +1,326 @@ +// 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_regression.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +#include "shared/libafl_abi.h" + +namespace { +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 LibAflOptions &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; +} + +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 LibAflOptions &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); +} +} // namespace + +int ReplayRegressionInputs( + const LibAflOptions &options, + const std::function &execute_one) { + std::vector corpus_files; + if (!CollectRegressionCorpusFiles(options.corpus_directories, + &corpus_files)) { + return kJazzerLibAflRuntimeFatal; + } + + 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 kJazzerLibAflRuntimeOk; + } + if (ReachedMaxTotalTime(options, started_at)) { + return kJazzerLibAflRuntimeStopped; + } + + const auto status = execute_one(data, size); + executions++; + switch (status) { + case kJazzerLibAflExecutionContinue: + return kJazzerLibAflRuntimeOk; + case kJazzerLibAflExecutionFinding: + return kJazzerLibAflRuntimeFoundFinding; + case kJazzerLibAflExecutionStop: + return kJazzerLibAflRuntimeStopped; + case kJazzerLibAflExecutionFatal: + return kJazzerLibAflRuntimeFatal; + case kJazzerLibAflExecutionTimeout: + return kJazzerLibAflRuntimeFoundTimeout; + default: + std::cerr << "[libafl] fatal: unknown execution status: " << status + << std::endl; + return kJazzerLibAflRuntimeFatal; + } + }; + + auto status = execute_input(&kEmptyInputByte, 0); + if (status != kJazzerLibAflRuntimeOk) { + if (status == kJazzerLibAflRuntimeStopped) { + 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 kJazzerLibAflRuntimeFatal; + } + + const auto *data = + current_input.empty() ? &kEmptyInputByte : current_input.data(); + status = execute_input(data, current_input.size()); + if (status != kJazzerLibAflRuntimeOk) { + if (status == kJazzerLibAflRuntimeStopped) { + PrintRegressionDone(started_at, executions, replay_inputs); + } + return status; + } + } + + PrintRegressionDone(started_at, executions, replay_inputs); + return kJazzerLibAflRuntimeOk; +} diff --git a/packages/fuzzer/libafl_regression.h b/packages/fuzzer/libafl_regression.h new file mode 100644 index 000000000..f1f302e7f --- /dev/null +++ b/packages/fuzzer/libafl_regression.h @@ -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. + +#pragma once + +#include + +#include "libafl_options.h" + +int ReplayRegressionInputs( + const LibAflOptions &options, + const std::function &execute_one); diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp new file mode 100644 index 000000000..e43f60e01 --- /dev/null +++ b/packages/fuzzer/libafl_runtime.cpp @@ -0,0 +1,845 @@ +// 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 "libafl_findings.h" +#include "libafl_options.h" +#include "libafl_regression.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 = kJazzerLibAflExecutionContinue; +constexpr int kExecutionFinding = kJazzerLibAflExecutionFinding; +constexpr int kExecutionStop = kJazzerLibAflExecutionStop; +constexpr int kExecutionFatal = kJazzerLibAflExecutionFatal; +constexpr int kExecutionTimeout = kJazzerLibAflExecutionTimeout; + +constexpr int kRuntimeOk = kJazzerLibAflRuntimeOk; +constexpr int kRuntimeFoundFinding = kJazzerLibAflRuntimeFoundFinding; +constexpr int kRuntimeStopped = kJazzerLibAflRuntimeStopped; +constexpr int kRuntimeFatal = kJazzerLibAflRuntimeFatal; +constexpr int kRuntimeFoundTimeout = kJazzerLibAflRuntimeFoundTimeout; + +std::atomic_bool gLibAflRuntimeActive{false}; + +class ScopedLibAflRuntime { +public: + ~ScopedLibAflRuntime() { + gLibAflRuntimeActive.store(false, std::memory_order_release); + } + + ScopedLibAflRuntime(const ScopedLibAflRuntime &) = delete; + ScopedLibAflRuntime &operator=(const ScopedLibAflRuntime &) = delete; + +private: + friend std::unique_ptr + AcquireLibAflRuntime(Napi::Env env); + + ScopedLibAflRuntime() = default; +}; + +class ScopedSignalHandler { +public: + ScopedSignalHandler(int signum, void (*handler)(int)) + : signum_(signum), previous_handler_(std::signal(signum, handler)) {} + + ~ScopedSignalHandler() { + if (previous_handler_ != SIG_ERR) { + std::signal(signum_, previous_handler_); + } + } + + ScopedSignalHandler(const ScopedSignalHandler &) = delete; + ScopedSignalHandler &operator=(const ScopedSignalHandler &) = delete; + +private: + int signum_; + void (*previous_handler_)(int); +}; + +std::unique_ptr AcquireLibAflRuntime(Napi::Env env) { + bool expected = false; + if (!gLibAflRuntimeActive.compare_exchange_strong( + expected, true, std::memory_order_acq_rel, + std::memory_order_acquire)) { + throw Napi::Error::New( + env, "The LibAFL backend only supports one active run per process"); + } + + return std::unique_ptr(new ScopedLibAflRuntime()); +} + +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, LibAflOptions 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; + LibAflOptions options; + SyncWatchdogState watchdog; + volatile std::sig_atomic_t signal_status = 0; + volatile std::sig_atomic_t execution_active = 0; + volatile std::sig_atomic_t sigints = 0; + std::jmp_buf execution_context; +}; + +struct AsyncExecutionState { + std::promise promise; + std::atomic settled = false; + bool done_called = false; + bool done_succeeded = false; + bool callback_invocation_completed = false; +}; + +struct AsyncDataType { + std::vector data; + std::shared_ptr state; + + AsyncDataType() = delete; +}; + +struct AsyncFuzzTargetContext { + explicit AsyncFuzzTargetContext( + Napi::Env env, LibAflOptions options, + std::unique_ptr runtime_guard) + : deferred(Napi::Promise::Deferred::New(env)), + options(std::move(options)), runtime_guard(std::move(runtime_guard)) {} + + std::thread native_thread; + Napi::Promise::Deferred deferred; + Napi::Reference deferred_rejection; + LibAflOptions options; + std::unique_ptr runtime_guard; + bool is_resolved = false; + int run_status = kRuntimeOk; + volatile std::sig_atomic_t execution_active = 0; + volatile std::sig_atomic_t 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 StoreDeferredRejection(AsyncFuzzTargetContext *context, + const Napi::Value &error) { + if (!context->deferred_rejection.IsEmpty()) { + return; + } + context->deferred_rejection = Napi::Persistent(error); +} + +void RejectSyncLibAflRun(SyncFuzzTargetContext *context, + const Napi::Value &error) { + if (context->is_resolved) { + return; + } + + context->is_resolved = true; + context->deferred.Reject(error); +} + +void SettleLibAflRun(Napi::Env env, Napi::Promise::Deferred &deferred, + bool &is_resolved, int status) { + if (is_resolved) { + return; + } + + auto reject = [&](const char *message) { + is_resolved = true; + deferred.Reject(Napi::Error::New(env, message).Value()); + }; + + switch (status) { + case kRuntimeFatal: + reject("The LibAFL backend failed internally"); + return; + case kRuntimeFoundTimeout: + reject("Exceeded timeout while executing one fuzz input"); + return; + case kRuntimeFoundFinding: + reject("The LibAFL backend found a crashing input"); + return; + default: + is_resolved = true; + deferred.Resolve(env.Undefined()); + return; + } +} + +bool IsExecutionSettled(const std::shared_ptr &state) { + return state->settled.load(std::memory_order_acquire); +} + +bool TryClaimExecution(const std::shared_ptr &state) { + bool expected = false; + if (!state->settled.compare_exchange_strong(expected, true, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + return false; + } + return true; +} + +void PublishExecutionStatus(const std::shared_ptr &state, + int status) { + state->promise.set_value(status); +} + +bool TryPublishExecutionStatus( + const std::shared_ptr &state, int status) { + if (!TryClaimExecution(state)) { + return false; + } + PublishExecutionStatus(state, status); + return true; +} + +Napi::Value NormalizeAsyncError(Napi::Env env, const Napi::Value &error) { + if (error.IsObject()) { + return error; + } + return Napi::Error::New(env, error.ToString()).Value(); +} + +void ReportAsyncFinding(AsyncFuzzTargetContext *context, Napi::Env env, + const std::shared_ptr &state, + const Napi::Value &error, + const std::vector &input) { + if (!TryClaimExecution(state)) { + return; + } + + auto normalized_error = NormalizeAsyncError(env, error); + auto summary = std::string("The LibAFL backend found a crashing input"); + const auto artifact = WriteArtifact(context->options.artifact_prefix, "crash", + input.data(), input.size(), false); + try { + summary = DescribeJsError(env, normalized_error); + } catch (const std::exception &exception) { + normalized_error = + Napi::Error::New(env, std::string("Internal fuzzer error - ") + + exception.what()) + .Value(); + summary = normalized_error.ToString().Utf8Value(); + } + + RecordFindingInfo(&gFindingInfo, artifact, summary); + StoreDeferredRejection(context, normalized_error); + PublishExecutionStatus(state, kExecutionFinding); +} + +void ReportAsyncInternalError(AsyncFuzzTargetContext *context, Napi::Env env, + const std::shared_ptr &state, + const std::string &message) { + if (!TryClaimExecution(state)) { + return; + } + + StoreDeferredRejection(context, Napi::Error::New(env, message).Value()); + PublishExecutionStatus(state, kExecutionFatal); +} + +void SettleAsyncLibAflRun(Napi::Env env, AsyncFuzzTargetContext *context) { + if (context->is_resolved) { + return; + } + + if (!context->deferred_rejection.IsEmpty()) { + context->is_resolved = true; + context->deferred.Reject(context->deferred_rejection.Value()); + context->deferred_rejection.Reset(); + return; + } + + SettleLibAflRun(env, context->deferred, context->is_resolved, + context->run_status); +} + +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(&gFindingInfo, 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) { + auto *context = gActiveSyncContext; + if (context == nullptr) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + + context->signal_status = signum; + if (context->sigints > 0) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + context->sigints++; +} + +void SyncErrorSignalHandler(int signum) { + auto *context = gActiveSyncContext; + if (context == nullptr || context->execution_active == 0) { + _Exit(libfuzzer::EXIT_ERROR_SEGV); + } + + context->signal_status = signum; + std::longjmp(context->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); + // Initialize the jump target before signal handlers can treat this + // invocation as actively executing user code. + const auto signal_status = setjmp(context->execution_context); + context->execution_active = 1; + if (signal_status == 0) { + auto result = context->target.Call({buffer}); + if (result.IsPromise()) { + AsyncReturnsHandler(); + } else { + SyncReturnsHandler(); + } + } + context->execution_active = 0; + } catch (const Napi::Error &error) { + context->execution_active = 0; + if (!context->is_resolved) { + const auto artifact = WriteArtifact(context->options.artifact_prefix, + "crash", data, size, false); + RecordFindingInfo(&gFindingInfo, artifact, + DescribeJsError(context->env, error.Value())); + context->is_resolved = true; + context->deferred.Reject(error.Value()); + } + return kExecutionFinding; + } catch (const std::exception &exception) { + context->execution_active = 0; + 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); + } + + try { + context->js_stop_callback.Call({exit_code}); + } catch (const Napi::Error &error) { + context->signal_status = 0; + RejectSyncLibAflRun(context, error.Value()); + return kExecutionFatal; + } catch (const std::exception &exception) { + context->signal_status = 0; + RejectSyncLibAflRun( + context, Napi::Error::New(context->env, + std::string("Internal fuzzer error - ") + + exception.what()) + .Value()); + return kExecutionFatal; + } catch (...) { + context->signal_status = 0; + RejectSyncLibAflRun( + context, + Napi::Error::New( + context->env, + "Internal fuzzer error - stop callback threw a non-standard " + "exception") + .Value()); + return kExecutionFatal; + } + 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) { + TryPublishExecutionStatus(state, kExecutionStop); + return; + } + + // Initialize the jump target before signal handlers can treat this + // invocation as actively executing user code. + const auto signal_status = setjmp(context->execution_context); + context->execution_active = 1; + if (signal_status == SIGSEGV) { + context->execution_active = 0; + std::cerr << "==" << static_cast(GetPID()) + << "== Segmentation Fault" << std::endl; + libfuzzer::PrintCrashingInput(); + _Exit(libfuzzer::EXIT_ERROR_SEGV); + } + + if (env == nullptr) { + context->execution_active = 0; + TryPublishExecutionStatus(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) { + auto done = Napi::Function::New(env, [=](const Napi::CallbackInfo &info) { + if (IsExecutionSettled(state)) { + return; + } + + if (state->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; + } + + state->done_called = true; + const auto has_error = + info.Length() > 0 && !(info[0].IsNull() || info[0].IsUndefined()); + if (has_error) { + ReportAsyncFinding(context, env, state, info[0], current_input); + return; + } + + if (state->callback_invocation_completed) { + TryPublishExecutionStatus(state, kExecutionContinue); + } else { + state->done_succeeded = true; + } + }); + + auto result = js_fuzz_callback.Call({buffer, done}); + state->callback_invocation_completed = true; + context->execution_active = 0; + 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(); + if (state->done_succeeded) { + TryPublishExecutionStatus(state, kExecutionContinue); + } + } + return; + } + + auto result = js_fuzz_callback.Call({buffer}); + context->execution_active = 0; + 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 &) { + TryPublishExecutionStatus( + 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(); + ReportAsyncFinding(context, env, state, error, + current_input); + })}); + } else { + SyncReturnsHandler(); + TryPublishExecutionStatus(state, kExecutionContinue); + } + } catch (const Napi::Error &error) { + context->execution_active = 0; + ReportAsyncFinding(context, env, state, error.Value(), current_input); + } catch (const std::exception &exception) { + context->execution_active = 0; + ReportAsyncInternalError( + context, env, state, + std::string("Internal fuzzer error - ").append(exception.what())); + } +} + +void AsyncSigintHandler(int signum) { + auto *context = gActiveAsyncContext; + if (context == nullptr) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + + if (context->sigints > 0) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + context->sigints = signum; +} + +void AsyncErrorSignalHandler(int signum) { + auto *context = gActiveAsyncContext; + if (context == nullptr || context->execution_active == 0) { + _Exit(libfuzzer::EXIT_ERROR_SEGV); + } + + std::longjmp(context->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(&gFindingInfo, 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); + } +} + +int RunLibAflRuntime(const LibAflOptions &options, + const JazzerLibAflRuntimeSharedMaps &maps, + JazzerLibAflExecuteCallback execute_one, void *user_data) { + std::vector corpus_directories; + corpus_directories.reserve(options.corpus_directories.size()); + for (const auto &directory : options.corpus_directories) { + corpus_directories.push_back(directory.c_str()); + } + + std::vector dictionary_files; + dictionary_files.reserve(options.dictionary_files.size()); + for (const auto &dictionary : options.dictionary_files) { + dictionary_files.push_back(dictionary.c_str()); + } + + JazzerLibAflRuntimeOptions runtime_options{ + options.runs, + static_cast(options.runs_set ? 1 : 0), + options.seed, + options.max_len, + options.timeout_millis, + 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(), + }; + return jazzer_libafl_runtime_run(&runtime_options, &maps, execute_one, + user_data); +} +} // 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 = ParseLibAflOptions(info.Env(), info[1].As()); + auto runtime_guard = AcquireLibAflRuntime(info.Env()); + ClearFindingInfo(&gFindingInfo); + auto maps = SharedMapsForLibAflRuntime(info.Env(), &gFindingInfo); + + SyncFuzzTargetContext context(info.Env(), info[0].As(), + info[2].As(), + std::move(options)); + gActiveSyncContext = &context; + + StartSyncWatchdog(&context); + + auto status = kRuntimeOk; + { + ScopedSignalHandler sigint_handler(SIGINT, SyncSigintHandler); + ScopedSignalHandler sigsegv_handler(SIGSEGV, SyncErrorSignalHandler); + + if (context.options.mode == LibAflOptions::Mode::kRegression) { + status = ReplayRegressionInputs( + context.options, [&context](const uint8_t *data, size_t size) { + return ExecuteSyncInput(&context, data, size); + }); + } else { + status = + RunLibAflRuntime(context.options, maps, ExecuteSyncInput, &context); + } + } + + StopSyncWatchdog(&context); + gActiveSyncContext = nullptr; + + SettleLibAflRun(info.Env(), context.deferred, context.is_resolved, status); + + 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 = ParseLibAflOptions(info.Env(), info[1].As()); + auto runtime_guard = AcquireLibAflRuntime(info.Env()); + ClearFindingInfo(&gFindingInfo); + auto maps = SharedMapsForLibAflRuntime(info.Env(), &gFindingInfo); + auto context = std::make_unique( + info.Env(), std::move(options), std::move(runtime_guard)); + + gAsyncTsfn = AsyncTsfn::New( + info.Env(), info[0].As(), "LibAflAsyncAddon", 0, 1, + context.get(), + [](Napi::Env env, AsyncFinalizerDataType *, AsyncFuzzTargetContext *ctx) { + Napi::HandleScope scope(env); + ctx->native_thread.join(); + ctx->runtime_guard.reset(); + SettleAsyncLibAflRun(env, ctx); + delete ctx; + }); + + auto *context_ptr = context.get(); + context_ptr->native_thread = std::thread( + [maps](AsyncFuzzTargetContext *ctx) { + gActiveAsyncContext = ctx; + { + ScopedSignalHandler sigsegv_handler(SIGSEGV, AsyncErrorSignalHandler); + ScopedSignalHandler sigint_handler(SIGINT, AsyncSigintHandler); + + if (ctx->options.mode == LibAflOptions::Mode::kRegression) { + ctx->run_status = ReplayRegressionInputs( + ctx->options, [ctx](const uint8_t *data, size_t size) { + return ExecuteAsyncInput(ctx, data, size); + }); + } else { + ctx->run_status = + RunLibAflRuntime(ctx->options, maps, ExecuteAsyncInput, ctx); + } + } + gActiveAsyncContext = nullptr; + gAsyncTsfn.Release(); + }, + context_ptr); + + auto promise = context_ptr->deferred.Promise(); + context.release(); + return promise; +} diff --git a/packages/fuzzer/libafl_runtime.h b/packages/fuzzer/libafl_runtime.h new file mode 100644 index 000000000..edd9b5e79 --- /dev/null +++ b/packages/fuzzer/libafl_runtime.h @@ -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. + +#pragma once + +#include + +#include "shared/libafl_abi.h" + +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..1436c3b2a --- /dev/null +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -0,0 +1,339 @@ +/* + * 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 { spawnSync } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { Mode } from "@jazzer.js/options"; + +import { addon } from "./addon"; +import { fuzzer } from "./fuzzer"; + +const libAflOptions = { + mode: Mode.Fuzzing, + runs: 32, + runsSet: true, + seed: 1234, + maxLen: 64, + timeoutMillis: 1000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], +}; + +function nativeAddonPath(): string { + return path.join( + __dirname, + "prebuilds", + `fuzzer-${process.platform}-${process.arch}.node`, + ); +} + +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("rejects overlapping LibAFL runs", async () => { + let releaseFirstInput!: () => void; + let blockedFirstInput = false; + const firstInput = new Promise((resolve) => { + releaseFirstInput = resolve; + }); + + const firstRun = addon.startLibAflAsync( + () => { + if (blockedFirstInput) { + return undefined; + } + blockedFirstInput = true; + return firstInput; + }, + { ...libAflOptions, runs: 1 }, + ); + + try { + expect(() => + addon.startLibAflAsync(() => undefined, { ...libAflOptions, runs: 1 }), + ).toThrow("only supports one active run per process"); + } finally { + releaseFirstInput(); + await firstRun; + } + }); + + it("settles async findings after releasing the native runtime", async () => { + const artifactDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "jazzer-libafl-lifetime-"), + ); + + try { + await expect( + addon.startLibAflAsync( + () => { + throw new Error("first finding"); + }, + { + ...libAflOptions, + artifactPrefix: `${artifactDirectory}${path.sep}`, + }, + ), + ).rejects.toThrow("first finding"); + + await addon.startLibAflAsync(() => undefined, { + ...libAflOptions, + runs: 1, + }); + } finally { + fs.rmSync(artifactDirectory, { force: true, recursive: true }); + } + }); + + it("publishes async finding metadata before the runtime reports it", () => { + const artifactDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "jazzer-libafl-objective-"), + ); + + try { + const script = ` + const addon = require(${JSON.stringify(nativeAddonPath())}); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); + addon.registerNewCounters(0, 512); + + addon.startLibAflAsync( + async () => { + throw new Error("async objective finding"); + }, + ${JSON.stringify({ + ...libAflOptions, + runs: 1, + artifactPrefix: `${artifactDirectory}${path.sep}`, + })}, + ) + .then(() => process.exit(2)) + .catch((error) => { + if (!String(error).includes("async objective finding")) { + console.error(error); + process.exit(3); + } + setTimeout(() => process.exit(0), 0); + }); + `; + + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + }); + + if (result.signal !== null || result.status !== 0) { + throw new Error( + `Child process exited with status ${result.status} and signal ${result.signal}: ${result.stderr}`, + ); + } + + expect(result.stderr).toMatch( + /\[!\] #\d+\s+\| artifact: crash-[0-9a-f]+ \| Error: async objective finding/, + ); + } finally { + fs.rmSync(artifactDirectory, { force: true, recursive: true }); + } + }); + + it("ignores late done callbacks after an input already settled", () => { + const script = ` + const addon = require(${JSON.stringify(nativeAddonPath())}); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); + addon.registerNewCounters(0, 512); + + let invocations = 0; + addon.startLibAflAsync( + (_data, done) => { + invocations += 1; + done(); + if (invocations === 1) { + setImmediate(() => done(new Error("late stale error"))); + } + }, + ${JSON.stringify({ ...libAflOptions, runs: 2 })}, + ) + .then(() => setTimeout(() => process.exit(0), 50)) + .catch((error) => { + console.error(error); + process.exit(1); + }); + `; + + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + }); + + if (result.signal !== null || result.status !== 0) { + throw new Error( + `Child process exited with status ${result.status} and signal ${result.signal}: ${result.stderr}`, + ); + } + }); + + // On Windows, process.kill(..., "SIGINT") terminates the target process + // instead of delivering a recoverable signal event to userland listeners. + (process.platform === "win32" ? it.skip : it)( + "restores previous SIGINT handlers after fuzzing", + () => { + const options = { ...libAflOptions, runs: 1 }; + const script = ` + const addon = require(${JSON.stringify(nativeAddonPath())}); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); + addon.registerNewCounters(0, 512); + + let restored = false; + process.on("SIGINT", () => { + restored = true; + }); + + addon.startLibAfl(() => undefined, ${JSON.stringify(options)}, () => undefined) + .then(() => { + process.kill(process.pid, "SIGINT"); + setTimeout(() => process.exit(restored ? 0 : 2), 50); + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); + `; + + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + }); + + if (result.signal !== null || result.status !== 0) { + throw new Error( + `Child process exited with status ${result.status} and signal ${result.signal}: ${result.stderr}`, + ); + } + }, + ); + + (process.platform === "win32" ? it.skip : it)( + "rejects thrown stop callbacks instead of panicking through Rust", + () => { + const script = ` + const addon = require(${JSON.stringify(nativeAddonPath())}); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); + addon.registerNewCounters(0, 512); + + let invocations = 0; + addon.startLibAfl( + () => { + if (invocations === 0) { + process.kill(process.pid, "SIGINT"); + } + invocations += 1; + }, + ${JSON.stringify({ ...libAflOptions, runs: 1 })}, + () => { + throw new Error("stop callback boom"); + }, + ) + .then(() => process.exit(2)) + .catch((error) => { + if (!String(error).includes("stop callback boom")) { + console.error(error); + process.exit(3); + } + process.exit(0); + }); + + setTimeout(() => process.exit(4), 1000); + `; + + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 5000, + }); + + expect(result.status).toBe(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: Mode.Fuzzing, + runs: 1, + runsSet: true, + 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..8e0fef8f9 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" }, @@ -27,6 +28,7 @@ ] }, "dependencies": { + "@jazzer.js/options": "4.0.0", "bindings": "^1.5.0", "cmake-js": "^8.0.0", "node-addon-api": "^8.7.0" diff --git a/packages/fuzzer/runtime/benchmark.js b/packages/fuzzer/runtime/benchmark.js new file mode 100644 index 000000000..4bd4fce45 --- /dev/null +++ b/packages/fuzzer/runtime/benchmark.js @@ -0,0 +1,154 @@ +/* + * 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 { Mode } = require("../../options/dist/index.js"); +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: Mode.Fuzzing, + runs, + runsSet: true, + 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/abi.rs b/packages/fuzzer/rust/src/abi.rs new file mode 100644 index 000000000..b939064e7 --- /dev/null +++ b/packages/fuzzer/rust/src/abi.rs @@ -0,0 +1,162 @@ +use core::ffi::{c_char, c_void}; + +pub(crate) const EXECUTION_CONTINUE: i32 = 0; +pub(crate) const EXECUTION_FINDING: i32 = 1; +pub(crate) const EXECUTION_STOP: i32 = 2; +pub(crate) const EXECUTION_FATAL: i32 = 3; +pub(crate) const EXECUTION_TIMEOUT: i32 = 4; + +pub(crate) const RUNTIME_OK: i32 = 0; +pub(crate) const RUNTIME_FOUND_FINDING: i32 = 1; +pub(crate) const RUNTIME_STOPPED: i32 = 2; +pub(crate) const RUNTIME_FATAL: i32 = 3; +pub(crate) const RUNTIME_FOUND_TIMEOUT: i32 = 4; + +pub(crate) const FINDING_INFO_ARTIFACT_BYTES: usize = 256; +pub(crate) const FINDING_INFO_SUMMARY_BYTES: usize = 1024; + +pub(crate) const COMPARE_LOG_ENTRY_BYTES: usize = 32; +pub(crate) const COMPARE_LOG_MAX_ENTRIES: usize = 1024; +pub(crate) const COMPARE_LOG_SIGNED_FLAG: u8 = 1 << 0; + +pub(crate) const COMPARE_KIND_INTEGER: u8 = 1; +pub(crate) const COMPARE_KIND_STRING_EQUALITY: u8 = 2; +pub(crate) const COMPARE_KIND_STRING_CONTAINMENT: u8 = 3; + +#[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 runs_set: u8, + 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, +} + +#[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], +} + +pub type JazzerLibAflExecuteCallback = + unsafe extern "C" fn(user_data: *mut c_void, data: *const u8, size: usize) -> i32; + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::{align_of, size_of}; + + #[test] + fn libafl_status_codes_match_cpp_header() { + assert_eq!(EXECUTION_CONTINUE, 0); + assert_eq!(EXECUTION_FINDING, 1); + assert_eq!(EXECUTION_STOP, 2); + assert_eq!(EXECUTION_FATAL, 3); + assert_eq!(EXECUTION_TIMEOUT, 4); + + assert_eq!(RUNTIME_OK, 0); + assert_eq!(RUNTIME_FOUND_FINDING, 1); + assert_eq!(RUNTIME_STOPPED, 2); + assert_eq!(RUNTIME_FATAL, 3); + assert_eq!(RUNTIME_FOUND_TIMEOUT, 4); + } + + #[test] + fn libafl_c_abi_layout_matches_cpp_header() { + assert_eq!(size_of::(), 8); + assert_eq!(COMPARE_LOG_ENTRY_BYTES, 32); + assert_eq!(COMPARE_LOG_MAX_ENTRIES, 1024); + assert_eq!(FINDING_INFO_ARTIFACT_BYTES, 256); + assert_eq!(FINDING_INFO_SUMMARY_BYTES, 1024); + + assert_eq!(size_of::(), 88); + assert_eq!(align_of::(), 8); + assert_eq!( + std::mem::offset_of!(JazzerLibAflCompareLogEntry, left_value), + 8 + ); + assert_eq!( + std::mem::offset_of!(JazzerLibAflCompareLogEntry, right_value), + 16 + ); + assert_eq!( + std::mem::offset_of!(JazzerLibAflCompareLogEntry, left_bytes), + 24 + ); + assert_eq!( + std::mem::offset_of!(JazzerLibAflCompareLogEntry, right_bytes), + 56 + ); + + assert_eq!(size_of::(), 90120); + assert_eq!(align_of::(), 8); + assert_eq!(std::mem::offset_of!(JazzerLibAflCompareLog, entries), 8); + + assert_eq!(size_of::(), 1281); + assert_eq!(align_of::(), 1); + assert_eq!(std::mem::offset_of!(JazzerLibAflFindingInfo, artifact), 1); + assert_eq!(std::mem::offset_of!(JazzerLibAflFindingInfo, summary), 257); + + assert_eq!(size_of::(), 80); + assert_eq!(align_of::(), 8); + assert_eq!(std::mem::offset_of!(JazzerLibAflRuntimeOptions, seed), 16); + assert_eq!( + std::mem::offset_of!(JazzerLibAflRuntimeOptions, corpus_directories), + 48 + ); + assert_eq!( + std::mem::offset_of!(JazzerLibAflRuntimeOptions, dictionary_files), + 64 + ); + + assert_eq!(size_of::(), 56); + assert_eq!(align_of::(), 8); + assert_eq!(std::mem::offset_of!(JazzerLibAflRuntimeSharedMaps, cmp), 24); + assert_eq!( + std::mem::offset_of!(JazzerLibAflRuntimeSharedMaps, compare_log), + 40 + ); + assert_eq!( + std::mem::offset_of!(JazzerLibAflRuntimeSharedMaps, finding_info), + 48 + ); + } +} diff --git a/packages/fuzzer/rust/src/compare_log.rs b/packages/fuzzer/rust/src/compare_log.rs new file mode 100644 index 000000000..9c23ba57d --- /dev/null +++ b/packages/fuzzer/rust/src/compare_log.rs @@ -0,0 +1,167 @@ +use std::borrow::Cow; + +use libafl::{ + executors::ExitKind, + mutators::Tokens, + observers::{ + cmp::{CmpValues, CmpValuesMetadata, CmplogBytes}, + Observer, + }, + Error, HasMetadata, +}; +use libafl_bolts::Named; + +const MAX_PROMOTED_TOKENS_PER_EXEC: usize = 64; +const MAX_PROMOTED_TOKENS_TOTAL: usize = 1024; + +use crate::abi::{ + JazzerLibAflCompareLog, JazzerLibAflCompareLogEntry, COMPARE_KIND_INTEGER, + COMPARE_KIND_STRING_CONTAINMENT, COMPARE_KIND_STRING_EQUALITY, COMPARE_LOG_ENTRY_BYTES, + COMPARE_LOG_MAX_ENTRIES, COMPARE_LOG_SIGNED_FLAG, +}; + +#[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..8a6059276 --- /dev/null +++ b/packages/fuzzer/rust/src/lib.rs @@ -0,0 +1,31 @@ +mod abi; +mod compare_log; +mod monitor; +mod runtime; +mod runtime_config; +mod shared_maps; + +use core::ffi::c_void; + +use crate::abi::{ + JazzerLibAflExecuteCallback, JazzerLibAflRuntimeOptions, JazzerLibAflRuntimeSharedMaps, +}; + +/// Runs one LibAFL fuzzing campaign through the C ABI bridge. +/// +/// # Safety +/// +/// `options` and `maps` must point to valid `#[repr(C)]` values matching +/// `shared/libafl_abi.h` for the whole call. All pointers inside `maps` must +/// reference initialized shared memory regions owned by the native addon, and +/// `execute_one` must remain callable with `user_data` until this function +/// returns. +#[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 { + runtime::run_from_ffi(options, maps, execute_one, user_data) +} diff --git a/packages/fuzzer/rust/src/monitor.rs b/packages/fuzzer/rust/src/monitor.rs new file mode 100644 index 000000000..f6366935a --- /dev/null +++ b/packages/fuzzer/rust/src/monitor.rs @@ -0,0 +1,534 @@ +use std::cell::RefCell; +use std::io::IsTerminal; +use std::rc::Rc; +use std::time::{Duration, Instant}; + +use libafl::{ + corpus::Corpus, + inputs::BytesInput, + monitors::{ + stats::{ClientStatsManager, UserStats, UserStatsValue}, + Monitor, + }, + state::{HasCorpus, HasExecutions, HasSolutions}, + Error, +}; +use libafl_bolts::ClientId; + +use crate::abi::JazzerLibAflFindingInfo; +use crate::runtime_config::RuntimeConfig; + +const EXECUTION_FIELD_WIDTH: usize = 10; +const DEFAULT_MONITOR_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Clone, Copy)] +pub(crate) struct RatioMetric { + numerator: u64, + denominator: u64, +} + +#[derive(Clone, Copy)] +pub(crate) struct ProgressSnapshot { + executions: u64, + pub(crate) edges: Option, + corpus_size: u64, + execs_per_sec: f64, + objective_size: u64, + stability: Option, + elapsed: Duration, +} + +pub(crate) struct MonitorState { + pub(crate) campaign_started: bool, + pub(crate) colors_enabled: bool, + last_edges_are_synthetic: bool, + pub(crate) last_status_output_at: Option, + pub(crate) last_progress: Option, +} + +#[derive(Clone, Copy)] +enum StatusEvent { + Testcase, + Heartbeat, + Objective, + Done, +} + +#[derive(Clone)] +pub(crate) struct LibAflMonitor { + state: Rc>, + finding_info: *mut JazzerLibAflFindingInfo, +} + +impl LibAflMonitor { + pub(crate) 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(()) + } +} + +pub(crate) 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, + } +} + +pub(crate) fn set_last_edges_are_synthetic(state: &Rc>, value: bool) { + state.borrow_mut().last_edges_are_synthetic = value; +} + +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 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 + } +} + +pub(crate) 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, + } +} + +pub(crate) 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 = config + .runs + .map(|runs| runs.to_string()) + .unwrap_or_else(|| "unlimited".to_string()); + let max_total_time = if config.max_total_time_seconds == 0 { + "unlimited".to_string() + } else { + format_duration(Duration::from_secs(config.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:", config.seed), + format_inited_field("loaded_inputs:", loaded_inputs), + format_inited_field("edges:", format_ratio_metric(edges)), + format_inited_field("timeout:", format!("{} ms", config.timeout_millis)), + format_inited_field("max_len:", config.max_len), + format_inited_field("runs:", runs), + format_inited_field("max_total_time:", max_total_time), + ); +} diff --git a/packages/fuzzer/rust/src/runtime.rs b/packages/fuzzer/rust/src/runtime.rs new file mode 100644 index 000000000..ce84d8c1e --- /dev/null +++ b/packages/fuzzer/rust/src/runtime.rs @@ -0,0 +1,298 @@ +use core::ffi::c_void; +use std::cell::Cell; +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}, + mutators::{ + havoc_mutations::havoc_mutations, scheduled::HavocScheduledMutator, tokens_mutations, + I2SRandReplace, + }, + observers::{CanTrack, HitcountsMapObserver, StdMapObserver, VariableMapObserver}, + schedulers::{ + powersched::PowerSchedule, IndexesLenTimeMinimizerScheduler, PowerQueueScheduler, + }, + stages::{calibrate::CalibrationStage, shadow::ShadowTracingStage, StdPowerMutationalStage}, + state::{HasCorpus, HasExecutions, HasMaxSize, HasSolutions, StdState}, + HasMetadata, +}; +use libafl_bolts::{ + rands::StdRand, + tuples::{tuple_list, Merge}, + AsSlice, +}; + +use crate::abi::{ + JazzerLibAflExecuteCallback, JazzerLibAflRuntimeOptions, JazzerLibAflRuntimeSharedMaps, + EXECUTION_CONTINUE, EXECUTION_FATAL, EXECUTION_FINDING, EXECUTION_STOP, EXECUTION_TIMEOUT, + RUNTIME_FATAL, RUNTIME_FOUND_FINDING, RUNTIME_FOUND_TIMEOUT, RUNTIME_OK, RUNTIME_STOPPED, +}; +use crate::compare_log::JazzerCompareLogObserver; +use crate::monitor::{ + maybe_emit_idle_heartbeat, maybe_print_final_init_testcase, monitor_timeout, + print_runtime_done, print_runtime_start, set_last_edges_are_synthetic, LibAflMonitor, +}; +use crate::runtime_config::RuntimeConfig; +use crate::shared_maps::SharedMaps; + +pub(crate) unsafe fn run_from_ffi( + 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; + let maps = match SharedMaps::from_abi(maps) { + Ok(maps) => maps, + Err(error) => { + eprintln!("[libafl] fatal: {error}"); + return RUNTIME_FATAL; + } + }; + let config = match RuntimeConfig::from_abi(options) { + Ok(config) => config, + Err(error) => { + eprintln!("[libafl] fatal: {error}"); + return RUNTIME_FATAL; + } + }; + + run(config, maps, execute_one, user_data) +} + +fn run( + config: RuntimeConfig, + maps: SharedMaps, + execute_one: JazzerLibAflExecuteCallback, + user_data: *mut c_void, +) -> i32 { + let (monitor, monitor_state) = LibAflMonitor::new(maps.finding_info()); + let mut mgr = SimpleEventManager::new(monitor); + + // SharedMaps::from_abi validated these pointers and the native addon owns + // the backing storage for the duration of this runtime call. + let edges_observer = HitcountsMapObserver::new(unsafe { + VariableMapObserver::from_mut_ptr( + "edges", + maps.edges(), + maps.edges_capacity(), + maps.edges_size(), + ) + }) + .track_indices(); + let cmp_observer = HitcountsMapObserver::new(unsafe { + 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(config.seed), + match CachedOnDiskCorpus::no_meta(&config.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(config.max_len); + + match config.load_dictionary_tokens() { + 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| { + maps.clear_for_execution(); + + let bytes = input.target_bytes(); + let bytes = bytes.as_slice(); + let size = bytes.len().min(config.max_len); + let status = unsafe { execute_one(user_data, bytes.as_ptr(), size) }; + let synthetic_edges = maps.ensure_non_empty_edge_map(); + set_last_edges_are_synthetic(&monitor_state, 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 !config.corpus_dirs.is_empty() + && state.must_load_initial_inputs() + && state + .load_initial_inputs(&mut fuzzer, &mut executor, &mut mgr, &config.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( + &config, + 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 config.max_total_time_seconds == 0 { + None + } else { + Some(Duration::from_secs(config.max_total_time_seconds)) + }; + + let initial_executions = *state.executions(); + let mut status = RUNTIME_OK; + let done_reason = loop { + if let Some(runs) = config.runs { + if state.executions().saturating_sub(initial_executions) >= 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/rust/src/runtime_config.rs b/packages/fuzzer/rust/src/runtime_config.rs new file mode 100644 index 000000000..25df2888f --- /dev/null +++ b/packages/fuzzer/rust/src/runtime_config.rs @@ -0,0 +1,99 @@ +use std::ffi::CStr; +use std::fs; +use std::path::PathBuf; + +use libafl::{mutators::Tokens, Error}; + +use crate::abi::JazzerLibAflRuntimeOptions; + +pub(crate) struct RuntimeConfig { + pub(crate) runs: Option, + pub(crate) seed: u64, + pub(crate) max_len: usize, + pub(crate) timeout_millis: u64, + pub(crate) max_total_time_seconds: u64, + pub(crate) corpus_dirs: Vec, + pub(crate) dictionary_files: Vec, + pub(crate) main_corpus_dir: PathBuf, +} + +impl RuntimeConfig { + pub(crate) unsafe fn from_abi(options: &JazzerLibAflRuntimeOptions) -> Result { + let corpus_dirs = parse_path_array( + options.corpus_directories, + options.corpus_directories_len, + "corpus directories", + )?; + let dictionary_files = parse_path_array( + options.dictionary_files, + options.dictionary_files_len, + "dictionary files", + )?; + let main_corpus_dir = resolve_main_corpus_directory(&corpus_dirs, options.seed) + .map_err(|error| format!("failed to prepare corpus directory: {error:?}"))?; + + Ok(Self { + runs: if options.runs_set != 0 { + Some(options.runs) + } else { + None + }, + seed: options.seed, + max_len: options.max_len, + timeout_millis: options.timeout_millis, + max_total_time_seconds: options.max_total_time_seconds, + corpus_dirs, + dictionary_files, + main_corpus_dir, + }) + } + + pub(crate) fn load_dictionary_tokens(&self) -> Result { + if self.dictionary_files.is_empty() { + return Ok(Tokens::new()); + } + + Tokens::new().add_from_files(self.dictionary_files.iter()) + } +} + +unsafe fn parse_path_array( + paths: *const *const core::ffi::c_char, + len: usize, + label: &str, +) -> Result, String> { + if len == 0 { + return Ok(Vec::new()); + } + if paths.is_null() { + return Err(format!("invalid {label}")); + } + + let mut result = Vec::with_capacity(len); + let paths = std::slice::from_raw_parts(paths, len); + for path in paths { + if path.is_null() { + return Err(format!("invalid {label}")); + } + let path = CStr::from_ptr(*path).to_string_lossy().to_string(); + result.push(PathBuf::from(path)); + } + Ok(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) +} diff --git a/packages/fuzzer/rust/src/shared_maps.rs b/packages/fuzzer/rust/src/shared_maps.rs new file mode 100644 index 000000000..58386a894 --- /dev/null +++ b/packages/fuzzer/rust/src/shared_maps.rs @@ -0,0 +1,120 @@ +use core::ptr; +use std::slice; + +use crate::abi::{JazzerLibAflCompareLog, JazzerLibAflFindingInfo, JazzerLibAflRuntimeSharedMaps}; + +#[derive(Clone, Copy)] +pub(crate) struct SharedMaps<'a> { + raw: &'a JazzerLibAflRuntimeSharedMaps, +} + +impl<'a> SharedMaps<'a> { + pub(crate) fn from_abi(raw: &'a JazzerLibAflRuntimeSharedMaps) -> Result { + if raw.edges.is_null() + || raw.edges_capacity == 0 + || raw.edges_size.is_null() + || raw.cmp.is_null() + || raw.cmp_len == 0 + || raw.compare_log.is_null() + || raw.finding_info.is_null() + { + return Err("shared maps are missing"); + } + + Ok(Self { raw }) + } + + pub(crate) fn edges(self) -> *mut u8 { + self.raw.edges + } + + pub(crate) fn edges_capacity(self) -> usize { + self.raw.edges_capacity + } + + pub(crate) fn edges_size(self) -> *mut usize { + self.raw.edges_size + } + + pub(crate) fn cmp(self) -> *mut u8 { + self.raw.cmp + } + + pub(crate) fn cmp_len(self) -> usize { + self.raw.cmp_len + } + + pub(crate) fn compare_log(self) -> *mut JazzerLibAflCompareLog { + self.raw.compare_log + } + + pub(crate) fn finding_info(self) -> *mut JazzerLibAflFindingInfo { + self.raw.finding_info + } + + pub(crate) fn edge_map_len(self) -> usize { + unsafe { (*self.raw.edges_size).min(self.raw.edges_capacity) } + } + + pub(crate) fn clear_for_execution(self) { + clear_shared_map(self.edges(), self.edge_map_len()); + clear_shared_map(self.cmp(), self.cmp_len()); + clear_compare_log(self.compare_log()); + clear_finding_info(self.finding_info()); + } + + pub(crate) fn ensure_non_empty_edge_map(self) -> bool { + let len = self.edge_map_len(); + if has_non_zero_coverage(self.edges(), len) { + return false; + } + + if len == 0 { + return false; + } + + unsafe { + let map = slice::from_raw_parts_mut(self.edges(), 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 + } +} + +fn clear_shared_map(ptr: *mut u8, len: usize) { + if len == 0 { + return; + } + + unsafe { + ptr::write_bytes(ptr, 0, len); + } +} + +fn clear_compare_log(ptr: *mut JazzerLibAflCompareLog) { + unsafe { + ptr::write_bytes(ptr, 0, 1); + } +} + +fn clear_finding_info(ptr: *mut JazzerLibAflFindingInfo) { + unsafe { + ptr::write_bytes(ptr, 0, 1); + } +} + +fn has_non_zero_coverage(ptr: *mut u8, len: usize) -> bool { + if len == 0 { + return false; + } + + unsafe { + slice::from_raw_parts(ptr, len) + .iter() + .any(|slot| *slot != 0) + } +} 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..9f9c918d8 100644 --- a/packages/fuzzer/shared/coverage.cpp +++ b/packages/fuzzer/shared/coverage.cpp @@ -13,8 +13,11 @@ // limitations under the License. #include "coverage.h" +#include #include #include +#include +#include extern "C" { void __sanitizer_cov_8bit_counters_init(uint8_t *start, uint8_t *end); @@ -23,9 +26,14 @@ void __sanitizer_cov_pcs_init(const uintptr_t *pcs_beg, } namespace { +constexpr double kMaxSafeInteger = 9007199254740991.0; + // 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 @@ -62,6 +70,24 @@ void RegisterCounterRange(uint8_t *start, uint8_t *end) { __sanitizer_cov_pcs_init(reinterpret_cast(pc_entries), reinterpret_cast(pc_entries_end)); } + +std::size_t ReadCounterCount(Napi::Env env, const Napi::Value &value, + const char *name) { + if (!value.IsNumber()) { + throw Napi::Error::New(env, std::string(name) + " must be a number"); + } + + const auto count = value.As().DoubleValue(); + if (!std::isfinite(count) || std::trunc(count) != count || count < 0 || + count > kMaxSafeInteger || + count > static_cast(std::numeric_limits::max())) { + throw Napi::Error::New(env, + std::string(name) + + " must be a finite, non-negative safe integer"); + } + + return static_cast(count); +} } // namespace void RegisterCoverageMap(const Napi::CallbackInfo &info) { @@ -76,6 +102,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) { @@ -84,8 +111,10 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { info.Env(), "Need two arguments: the old and new number of counters"); } - auto old_num_counters = info[0].As().Int64Value(); - auto new_num_counters = info[1].As().Int64Value(); + const auto old_num_counters = + ReadCounterCount(info.Env(), info[0], "old_num_counters"); + const auto new_num_counters = + ReadCounterCount(info.Env(), info[1], "new_num_counters"); if (gCoverageCounters == nullptr) { throw Napi::Error::New(info.Env(), @@ -96,28 +125,31 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { info.Env(), "new_num_counters must not be smaller than old_num_counters"); } + if (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 = 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/libafl_abi.h b/packages/fuzzer/shared/libafl_abi.h new file mode 100644 index 000000000..165fde923 --- /dev/null +++ b/packages/fuzzer/shared/libafl_abi.h @@ -0,0 +1,141 @@ +// 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 + +constexpr std::size_t kCompareFeedbackMapSize = 1 << 16; +constexpr std::size_t kCompareLogEntryBytes = 32; +constexpr std::size_t kCompareLogMaxEntries = 1024; +constexpr std::size_t kFindingInfoArtifactBytes = 256; +constexpr std::size_t kFindingInfoSummaryBytes = 1024; + +constexpr int kJazzerLibAflExecutionContinue = 0; +constexpr int kJazzerLibAflExecutionFinding = 1; +constexpr int kJazzerLibAflExecutionStop = 2; +constexpr int kJazzerLibAflExecutionFatal = 3; +constexpr int kJazzerLibAflExecutionTimeout = 4; + +constexpr int kJazzerLibAflRuntimeOk = 0; +constexpr int kJazzerLibAflRuntimeFoundFinding = 1; +constexpr int kJazzerLibAflRuntimeStopped = 2; +constexpr int kJazzerLibAflRuntimeFatal = 3; +constexpr int kJazzerLibAflRuntimeFoundTimeout = 4; + +enum class JazzerLibAflCompareKind : uint8_t { + kInteger = 1, + kStringEquality = 2, + kStringContainment = 3, +}; + +extern "C" { +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]; +}; + +struct JazzerLibAflFindingInfo { + uint8_t has_value; + char artifact[kFindingInfoArtifactBytes]; + char summary[kFindingInfoSummaryBytes]; +}; + +struct JazzerLibAflRuntimeOptions { + uint64_t runs; + uint8_t runs_set; + 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); +} + +#if UINTPTR_MAX == UINT64_MAX +static_assert(sizeof(JazzerLibAflCompareLogEntry) == 88, + "Unexpected JazzerLibAflCompareLogEntry layout"); +static_assert(offsetof(JazzerLibAflCompareLogEntry, left_value) == 8, + "Unexpected left_value offset"); +static_assert(offsetof(JazzerLibAflCompareLogEntry, right_value) == 16, + "Unexpected right_value offset"); +static_assert(offsetof(JazzerLibAflCompareLogEntry, left_bytes) == 24, + "Unexpected left_bytes offset"); +static_assert(offsetof(JazzerLibAflCompareLogEntry, right_bytes) == 56, + "Unexpected right_bytes offset"); + +static_assert(sizeof(JazzerLibAflCompareLog) == 90120, + "Unexpected JazzerLibAflCompareLog layout"); +static_assert(offsetof(JazzerLibAflCompareLog, entries) == 8, + "Unexpected compare log entries offset"); + +static_assert(sizeof(JazzerLibAflFindingInfo) == 1281, + "Unexpected JazzerLibAflFindingInfo layout"); +static_assert(offsetof(JazzerLibAflFindingInfo, artifact) == 1, + "Unexpected finding artifact offset"); +static_assert(offsetof(JazzerLibAflFindingInfo, summary) == 257, + "Unexpected finding summary offset"); + +static_assert(sizeof(JazzerLibAflRuntimeOptions) == 80, + "Unexpected JazzerLibAflRuntimeOptions layout"); +static_assert(offsetof(JazzerLibAflRuntimeOptions, seed) == 16, + "Unexpected seed offset"); +static_assert(offsetof(JazzerLibAflRuntimeOptions, corpus_directories) == 48, + "Unexpected corpus_directories offset"); +static_assert(offsetof(JazzerLibAflRuntimeOptions, dictionary_files) == 64, + "Unexpected dictionary_files offset"); + +static_assert(sizeof(JazzerLibAflRuntimeSharedMaps) == 56, + "Unexpected JazzerLibAflRuntimeSharedMaps layout"); +static_assert(offsetof(JazzerLibAflRuntimeSharedMaps, cmp) == 24, + "Unexpected cmp offset"); +static_assert(offsetof(JazzerLibAflRuntimeSharedMaps, compare_log) == 40, + "Unexpected compare_log offset"); +static_assert(offsetof(JazzerLibAflRuntimeSharedMaps, finding_info) == 48, + "Unexpected finding_info offset"); +#endif 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..b0c050699 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,10 +140,14 @@ 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. - __sanitizer_weak_hook_strstr((void *)id, needle.c_str(), haystack.c_str(), + __sanitizer_weak_hook_strstr((void *)id, haystack.c_str(), needle.c_str(), needle.c_str()); } @@ -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..5bb5a7c57 100644 --- a/packages/fuzzer/shared/tracing.h +++ b/packages/fuzzer/shared/tracing.h @@ -15,7 +15,20 @@ #include +#include "libafl_abi.h" + 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 efa12d4c6..17e264004 100644 --- a/packages/fuzzer/tsconfig.json +++ b/packages/fuzzer/tsconfig.json @@ -4,5 +4,10 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["build", "dist", "cmake-build-*"] + "exclude": ["build", "dist", "runtime", "cmake-build-*"], + "references": [ + { + "path": "../options" + } + ] } From 96e9e1bed013efc50f75cfc204e2fd76c1d9354d Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:34:51 +0200 Subject: [PATCH 05/10] feat(jest): pass normalized engine options --- packages/jest-runner/config.test.ts | 12 ++--- packages/jest-runner/config.ts | 9 +++- packages/jest-runner/fuzz.test.ts | 12 ++--- packages/jest-runner/fuzz.ts | 30 ++++++++---- packages/jest-runner/globalsInterceptor.ts | 2 +- .../jest-runner/testStateInterceptor.test.ts | 15 ++++-- packages/jest-runner/testStateInterceptor.ts | 8 ++-- tests/helpers.js | 48 +++++++++++-------- .../jest_project_ts/integration.fuzz.ts | 2 +- 9 files changed, 82 insertions(+), 56 deletions(-) diff --git a/packages/jest-runner/config.test.ts b/packages/jest-runner/config.test.ts index b46a55d9f..276a27613 100644 --- a/packages/jest-runner/config.test.ts +++ b/packages/jest-runner/config.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { OptionsManager, OptionSource } from "@jazzer.js/core"; +import { Mode, OptionsManager, OptionSource } from "@jazzer.js/options"; import { loadConfig } from "./config"; @@ -39,19 +39,19 @@ describe("Config", () => { }); it("deep copy configurations", () => { const config1 = loadConfig(); - config1.get("fuzzerOptions").push("-runs=100"); + config1.get("libFuzzerOptions").push("-use_value_profile=1"); const config2 = loadConfig({}, "merge-test-jazzerjs"); - expect(config1.get("fuzzerOptions")).not.toEqual( - config2.get("fuzzerOptions"), + expect(config1.get("libFuzzerOptions")).not.toEqual( + config2.get("libFuzzerOptions"), ); }); it("default to regression mode", () => { - expect(loadConfig().get("mode")).toEqual("regression"); + expect(loadConfig().get("mode")).toEqual(Mode.Regression); }); it("set fuzzing mode based on environment variable", () => { try { process.env.JAZZER_FUZZ = "1"; - expect(loadConfig().get("mode")).toEqual("fuzzing"); + expect(loadConfig().get("mode")).toEqual(Mode.Fuzzing); } finally { delete process.env.JAZZER_FUZZ; } diff --git a/packages/jest-runner/config.ts b/packages/jest-runner/config.ts index fa6e1a8f2..0abe6dbdf 100644 --- a/packages/jest-runner/config.ts +++ b/packages/jest-runner/config.ts @@ -16,7 +16,12 @@ import { cosmiconfigSync } from "cosmiconfig"; -import { Options, OptionsManager, OptionSource } from "@jazzer.js/core"; +import { + Mode, + Options, + OptionsManager, + OptionSource, +} from "@jazzer.js/options"; export const TIMEOUT_PLACEHOLDER = Number.MIN_SAFE_INTEGER; @@ -29,7 +34,7 @@ export function loadConfig( // Switch to fuzzing mode if environment variable `JAZZER_FUZZ` is set. if (process.env.JAZZER_FUZZ) { - config.mode = "fuzzing"; + config.mode = Mode.Fuzzing; } // Merge explicitly passed in options, e.g. coverage settings from Jest. Object.assign(config, options); diff --git a/packages/jest-runner/fuzz.test.ts b/packages/jest-runner/fuzz.test.ts index d0b88ae4e..82bb841a4 100644 --- a/packages/jest-runner/fuzz.test.ts +++ b/packages/jest-runner/fuzz.test.ts @@ -19,13 +19,9 @@ import fs from "fs"; import { Circus, Global } from "@jest/types"; import * as tmp from "tmp"; -import { - FindingAwareFuzzTarget, - OptionsManager, - OptionSource, - startFuzzingNoInit, -} from "@jazzer.js/core"; +import { FindingAwareFuzzTarget, startFuzzingNoInit } from "@jazzer.js/core"; import { FuzzTarget } from "@jazzer.js/fuzzer"; +import { Mode, OptionsManager, OptionSource } from "@jazzer.js/options"; import { Corpus } from "./corpus"; import { @@ -41,7 +37,9 @@ const inputsPathsMock = jest.fn(); jest.mock("./corpus", () => { return { Corpus: class Tmp { + generatedInputsDirectory = "generated-corpus"; inputsPaths = inputsPathsMock; + seedInputsDirectory = "seed-corpus"; }, }; }); @@ -76,7 +74,7 @@ describe("fuzz", () => { const inputPaths = mockInputPaths(); const fuzzingConfig = new OptionsManager( OptionSource.DefaultJestOptions, - ).merge({ mode: "fuzzing" }, OptionSource.ConfigurationFile); + ).merge({ mode: Mode.Fuzzing }, OptionSource.ConfigurationFile); await withMockTest(() => { const originalTestNamePattern = jest .fn() diff --git a/packages/jest-runner/fuzz.ts b/packages/jest-runner/fuzz.ts index 3f4a941a0..548712e7c 100644 --- a/packages/jest-runner/fuzz.ts +++ b/packages/jest-runner/fuzz.ts @@ -19,18 +19,21 @@ import * as fs from "fs"; import { Circus, Global } from "@jest/types"; import { - AllowedFuzzTestOptions, asFindingAwareFuzzFn, FindingAwareFuzzTarget, FuzzTarget, FuzzTargetAsyncOrValue, FuzzTargetCallback, + startFuzzingNoInit, +} from "@jazzer.js/core"; +import { + AllowedFuzzTestOptions, + Mode, Options, OptionsManager, OptionSource, printOptions, - startFuzzingNoInit, -} from "@jazzer.js/core"; +} from "@jazzer.js/options"; import { Corpus } from "./corpus"; import { removeTopFramesFromError } from "./errorUtils"; @@ -139,12 +142,13 @@ export function fuzz( const wrappedFn = asFindingAwareFuzzFn( fn, - localConfig.get("mode") === "fuzzing", + localConfig.get("mode") === Mode.Fuzzing, + localConfig.get("engine"), ); - if (localConfig.get("mode") === "regression") { + if (localConfig.get("mode") === Mode.Regression) { runInRegressionMode(name, wrappedFn, corpus, localConfig, globals, mode); - } else if (localConfig.get("mode") === "fuzzing") { + } else if (localConfig.get("mode") === Mode.Fuzzing) { runInFuzzingMode(name, wrappedFn, corpus, localConfig, globals, mode); } else { throw new Error(`Unknown mode ${localConfig.get("mode")}`); @@ -162,10 +166,16 @@ export const runInFuzzingMode = ( ) => { handleMode(mode, globals.test)(name, async () => { const newOptions = options.clone(); - const fuzzerOptions = newOptions.get("fuzzerOptions"); - fuzzerOptions.unshift(corpus.seedInputsDirectory); - fuzzerOptions.unshift(corpus.generatedInputsDirectory); - fuzzerOptions.push("-artifact_prefix=" + corpus.seedInputsDirectory); + newOptions.merge( + { + artifactPrefix: corpus.seedInputsDirectory, + corpusDirectories: [ + corpus.generatedInputsDirectory, + corpus.seedInputsDirectory, + ], + }, + OptionSource.InternalFuzzTestOptions, + ); return startFuzzingNoInit(fn, newOptions).then(({ error }) => { // Throw the found error to mark the test as failed. if (error) throw error; diff --git a/packages/jest-runner/globalsInterceptor.ts b/packages/jest-runner/globalsInterceptor.ts index ce9ae03b6..3097c6a43 100644 --- a/packages/jest-runner/globalsInterceptor.ts +++ b/packages/jest-runner/globalsInterceptor.ts @@ -16,7 +16,7 @@ import Runtime from "jest-runtime"; -import { OptionsManager } from "@jazzer.js/core"; +import { OptionsManager } from "@jazzer.js/options"; import { fuzz } from "./fuzz"; import { InterceptedTestState } from "./testStateInterceptor"; diff --git a/packages/jest-runner/testStateInterceptor.test.ts b/packages/jest-runner/testStateInterceptor.test.ts index 0f7214a44..9604179ac 100644 --- a/packages/jest-runner/testStateInterceptor.test.ts +++ b/packages/jest-runner/testStateInterceptor.test.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Options, OptionsManager, OptionSource } from "@jazzer.js/core"; +import { + Mode, + Options, + OptionsManager, + OptionSource, +} from "@jazzer.js/options"; import { interceptTestState } from "./testStateInterceptor"; @@ -32,7 +37,7 @@ describe("Test state interceptor", () => { }); it("adjust test name pattern in regression mode", () => { - const { env, config } = mockEnvironment({ mode: "regression" }); + const { env, config } = mockEnvironment({ mode: Mode.Regression }); const { originalTestNamePattern } = interceptTestState(env, config); @@ -43,7 +48,7 @@ describe("Test state interceptor", () => { }); it("do not adjust test name pattern in fuzzing mode", () => { - const { env, config } = mockEnvironment({ mode: "fuzzing" }); + const { env, config } = mockEnvironment({ mode: Mode.Fuzzing }); const interceptedTestState = interceptTestState(env, config); @@ -68,7 +73,7 @@ describe("Test state interceptor", () => { } const { env, config, originalHandleTestEvent } = mockEnvironment({ - mode: "fuzzing", + mode: Mode.Fuzzing, }); interceptTestState(env, config); @@ -88,7 +93,7 @@ describe("Test state interceptor", () => { }); it("deactivate Jest timeout in fuzzing mode", () => { - const { env, config } = mockEnvironment({ mode: "fuzzing" }); + const { env, config } = mockEnvironment({ mode: Mode.Fuzzing }); const { currentTestTimeout } = interceptTestState(env, config); diff --git a/packages/jest-runner/testStateInterceptor.ts b/packages/jest-runner/testStateInterceptor.ts index 5bac8cdea..88eda0ec4 100644 --- a/packages/jest-runner/testStateInterceptor.ts +++ b/packages/jest-runner/testStateInterceptor.ts @@ -17,7 +17,7 @@ import { JestEnvironment } from "@jest/environment"; import { Circus } from "@jest/types"; -import { OptionsManager } from "@jazzer.js/core"; +import { Mode, OptionsManager } from "@jazzer.js/options"; // Arbitrary high value to disable Jest timeout. const JEST_TIMEOUT_DISABLED = 1000 * 60 * 24 * 365; @@ -47,7 +47,7 @@ export function interceptTestState( // test inside. This breaks test name pattern matching, so remove "$" from the end of the pattern, // and skip tests not matching the original pattern in the fuzz function. if ( - jazzerConfig.get("mode") == "regression" && + jazzerConfig.get("mode") == Mode.Regression && state.testNamePattern?.source?.endsWith("$") ) { originalTestNamePattern = state.testNamePattern; @@ -60,7 +60,7 @@ export function interceptTestState( // In fuzzing mode, only execute the first encountered (not skipped) fuzz test // and mark all others as skipped. if ( - jazzerConfig.get("mode") === "fuzzing" && + jazzerConfig.get("mode") === Mode.Fuzzing && event.test.mode !== "skip" ) { if ( @@ -78,7 +78,7 @@ export function interceptTestState( } else if (event.name === "test_fn_start") { // Disable Jest timeout in fuzzing mode by setting it to a high value, // otherwise Jest will kill the fuzz test after it's timeout (default 5 seconds). - if (jazzerConfig.get("mode") === "fuzzing") { + if (jazzerConfig.get("mode") === Mode.Fuzzing) { state.testTimeout = JEST_TIMEOUT_DISABLED; } // Use configured timeout as fuzzing timeout as well. Every invocation diff --git a/tests/helpers.js b/tests/helpers.js index 8ca16ac41..2242c8907 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,9 @@ 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); + if (this.runs !== undefined) options.push("--runs=" + this.runs); + if (this.seed) options.push("--seed=" + this.seed); for (const include of this.includes) { options.push("-i=" + include); } @@ -119,12 +124,11 @@ class FuzzTest { for (const expectedError of this.expectedErrors) { options.push("-x=" + expectedError); } - options.push("--"); - if (this.runs !== undefined) options.push("-runs=" + this.runs); - if (this.forkMode) options.push("-fork=" + this.forkMode); - if (this.seed) options.push("-seed=" + this.seed); + if (this.forkMode) { + options.push("--libFuzzerOptions=-fork=" + this.forkMode); + } for (const dictionary of this.dictionaries) { - options.push("-dict=" + dictionary); + options.push("--dict=" + dictionary); } if (useSpawnSync) { this.#spawnTestSync("npx", options, { ...process.env }); @@ -134,18 +138,6 @@ class FuzzTest { } #executeWithJest(useSpawnSync = true) { - const fuzzerOptions = []; - if (this.runs) { - fuzzerOptions.push("-runs=" + this.runs); - } - if (this.seed) { - fuzzerOptions.push("-seed=" + this.seed); - } - const dictionaries = this.dictionaries.map( - (dictionary) => "-dict=" + dictionary, - ); - fuzzerOptions.push(...dictionaries); - const config = {}; if (this.sync !== undefined) { config.sync = this.sync; @@ -162,8 +154,14 @@ class FuzzTest { if (this.disableBugDetectors.length > 0) { config.disableBugDetectors = this.disableBugDetectors; } - if (fuzzerOptions.length > 0) { - config.fuzzerOptions = fuzzerOptions; + if (this.runs !== undefined) { + config.runs = this.runs; + } + if (this.seed) { + config.seed = this.seed; + } + if (this.dictionaries.length > 0) { + config.dictionaryFiles = this.dictionaries; } if (this.customHooks.length > 0) { config.customHooks = this.customHooks; @@ -177,6 +175,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 +299,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. @@ -317,7 +319,7 @@ class FuzzTestBuilder { } /** - * @param {number} runs - libFuzzer's (-runs=) option. Number of times the fuzz target + * @param {number} runs - number of times the fuzz target * function should be executed. */ runs(runs) { @@ -502,6 +504,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 +543,7 @@ class FuzzTestBuilder { this._expectedErrors, this._asJson, this._timeout, + this._engine, ); } } diff --git a/tests/jest_integration/jest_project_ts/integration.fuzz.ts b/tests/jest_integration/jest_project_ts/integration.fuzz.ts index a5fd9fead..489c35659 100644 --- a/tests/jest_integration/jest_project_ts/integration.fuzz.ts +++ b/tests/jest_integration/jest_project_ts/integration.fuzz.ts @@ -60,7 +60,7 @@ describe("Jest TS Integration", () => { }, { sync: true, - fuzzerOptions: ["-runs=101"], + runs: 101, dictionaryEntries: ["Amazing"], }, ); From 9312af7e00ca21586b11971e42375f76ed5ee88f Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:36:15 +0200 Subject: [PATCH 06/10] test(engine): cover backend selection paths Backend-neutral options only matter if CLI and Jest runs prove the right engine is selected and older libFuzzer-only workflows stay pinned on purpose. --- tests/bug-detectors/general.test.js | 4 + tests/code_coverage/coverage.test.js | 13 +- tests/engine/engine.test.js | 435 ++++++++++++++++++ tests/engine/fuzz.js | 100 ++++ tests/engine/jest_project/.gitignore | 3 + tests/engine/jest_project/jest.config.js | 22 + tests/engine/jest_project/jest.fuzz.js | 31 ++ tests/engine/package.json | 20 + tests/esm_cjs_mixed/esm_cjs_mixed.test.js | 17 + .../esm_instrumentation.test.js | 17 + tests/esm_instrumentation/fuzz-lazy.mjs | 23 + 11 files changed, 682 insertions(+), 3 deletions(-) create mode 100644 tests/engine/engine.test.js create mode 100644 tests/engine/fuzz.js create mode 100644 tests/engine/jest_project/.gitignore create mode 100644 tests/engine/jest_project/jest.config.js create mode 100644 tests/engine/jest_project/jest.fuzz.js create mode 100644 tests/engine/package.json create mode 100644 tests/esm_instrumentation/fuzz-lazy.mjs 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..75a750071 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) { @@ -251,8 +259,7 @@ function executeFuzzTest( } options.push("--disableBugDetectors='.*'"); - options.push("--"); - options.push("-runs=0"); + options.push("--mode=regression"); const process = spawnSync("npx", options, { stdio: "pipe", cwd: testDirectory, diff --git a/tests/engine/engine.test.js b/tests/engine/engine.test.js new file mode 100644 index 000000000..1779190ba --- /dev/null +++ b/tests/engine/engine.test.js @@ -0,0 +1,435 @@ +/* + * 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(/ {4}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", + [ + "--maxTotalTime=1", + "--seed=1337", + "--maxLen=32", + `--artifactPrefix=${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", + "--maxLen=32", + `--artifactPrefix=${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(/ {4}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..217cf2049 --- /dev/null +++ b/tests/engine/fuzz.js @@ -0,0 +1,100 @@ +/* + * 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. + */ + +// Make the linter happy when we access Fuzzer. +// Jazzer injects globalThis.Fuzzer at runtime. +/* global Fuzzer:readonly */ + +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()); +} From 4b2cefc578c7e1bbf9fb768fb40d2c07bb33816f Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:36:36 +0200 Subject: [PATCH 07/10] chore(scripts): migrate fuzz command fixtures Examples and test fixtures should exercise the normalized Jazzer.js flags directly so new option handling does not depend on legacy libFuzzer argv conventions. --- .config/test-jazzerjsrc.json | 2 +- end-to-end/package.json | 2 +- examples/FuzzedDataProvider/package.json | 4 ++-- examples/bug-detectors/command-injection/package.json | 6 +++--- examples/bug-detectors/path-traversal/package.json | 4 ++-- examples/bug-detectors/prototype-pollution/package.json | 4 ++-- examples/bug-detectors/ssrf/package.json | 6 +++--- examples/custom-hooks/package.json | 4 ++-- examples/jest_integration/.jazzerjsrc.json | 7 ++----- examples/jpeg/package.json | 4 ++-- examples/jpeg_es6/package.json | 2 +- examples/js-yaml/package.json | 4 ++-- examples/maze/package.json | 4 ++-- examples/spectral/package.json | 2 +- examples/xml/package.json | 2 +- tests/done_callback/package.json | 4 ++-- tests/fork_mode/package.json | 4 ++-- tests/promise/package.json | 4 ++-- tests/return_values/asyncRunnerAsyncReturns/package.json | 4 ++-- tests/return_values/asyncRunnerMixedReturns/package.json | 4 ++-- tests/return_values/asyncRunnerSyncReturns/package.json | 4 ++-- tests/return_values/syncRunnerAsyncReturns/package.json | 4 ++-- tests/return_values/syncRunnerMixedReturns/package.json | 4 ++-- tests/return_values/syncRunnerSyncReturns/package.json | 4 ++-- tests/string_compare/package.json | 4 ++-- tests/timeout/package.json | 4 ++-- tests/value_profiling/package.json | 4 ++-- 27 files changed, 51 insertions(+), 54 deletions(-) diff --git a/.config/test-jazzerjsrc.json b/.config/test-jazzerjsrc.json index bd27cebf0..d41541f0f 100644 --- a/.config/test-jazzerjsrc.json +++ b/.config/test-jazzerjsrc.json @@ -1,5 +1,5 @@ { "includes": ["target"], "excludes": ["nothing"], - "fuzzerOptions": ["-rss_limit_mb=16000"] + "libFuzzerOptions": ["-rss_limit_mb=16000"] } diff --git a/end-to-end/package.json b/end-to-end/package.json index 32e88273a..c35ca837e 100644 --- a/end-to-end/package.json +++ b/end-to-end/package.json @@ -4,7 +4,7 @@ "scripts": { "build": "tsc", "dryRun": "jest", - "fuzz:cli": "jazzer cli-smoke.cjs --sync -- -runs=1 -seed=123456789 -max_len=32", + "fuzz:cli": "jazzer cli-smoke.cjs --sync --runs=1 --seed=123456789 --maxLen=32", "test:runtime": "npm run dryRun && npm run fuzz:cli", "fuzz": "JAZZER_FUZZ=1 jest --coverage", "coverage": "jest --coverage" diff --git a/examples/FuzzedDataProvider/package.json b/examples/FuzzedDataProvider/package.json index 06a037bc2..9fdc292e3 100644 --- a/examples/FuzzedDataProvider/package.json +++ b/examples/FuzzedDataProvider/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how to use FuzzedDataProvider in Jazzer.js", "scripts": { - "fuzz": "jazzer fuzz --sync -x Error -i fuzz.js -- -use_value_profile=1 -print_pcs=1 -print_final_stats=1 -max_len=52 -runs=4000000 -seed=605643277", - "dryRun": "jazzer fuzz --sync -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --sync -x Error -i fuzz.js --maxLen=52 --runs=4000000 --seed=605643277 --libFuzzerOptions=-use_value_profile=1 --libFuzzerOptions=-print_pcs=1 --libFuzzerOptions=-print_final_stats=1", + "dryRun": "jazzer fuzz --sync --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/examples/bug-detectors/command-injection/package.json b/examples/bug-detectors/command-injection/package.json index edb791805..22ba471ad 100644 --- a/examples/bug-detectors/command-injection/package.json +++ b/examples/bug-detectors/command-injection/package.json @@ -7,9 +7,9 @@ "global-modules-path": "^2.3.1" }, "scripts": { - "fuzz": "jazzer fuzz -i global-modules-path --disable_bug_detectors='.*' -h custom-hooks --timeout=100000000 --sync -x Error -- -runs=100000 -print_final_stats=1", - "bugDetectors": "jazzer fuzz -i global-modules-path --timeout=100000000 --sync -- -runs=100000 -print_final_stats=1", - "dryRun": "jazzer fuzz --sync -x Error -- -runs=100000 -seed=123456789" + "fuzz": "jazzer fuzz -i global-modules-path --disable_bug_detectors='.*' -h custom-hooks --timeout=100000000 --sync -x Error --runs=100000 --libFuzzerOptions=-print_final_stats=1", + "bugDetectors": "jazzer fuzz -i global-modules-path --timeout=100000000 --sync --runs=100000 --libFuzzerOptions=-print_final_stats=1", + "dryRun": "jazzer fuzz --sync -x Error --runs=100000 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/examples/bug-detectors/path-traversal/package.json b/examples/bug-detectors/path-traversal/package.json index dd37d5953..4b1ceeed4 100644 --- a/examples/bug-detectors/path-traversal/package.json +++ b/examples/bug-detectors/path-traversal/package.json @@ -7,8 +7,8 @@ "jszip": "3.7.1" }, "scripts": { - "fuzz": "jazzer fuzz -i fuzz.js -i jszip -x Error corpus -- -runs=10000000 -print_final_stats=1 -use_value_profile=1 -max_len=600 -seed=123456789", - "dryRun": "jazzer fuzz --sync -x Error -- -runs=100000 -seed=123456789" + "fuzz": "jazzer fuzz -i fuzz.js -i jszip -x Error corpus --runs=10000000 --maxLen=600 --seed=123456789 --libFuzzerOptions=-print_final_stats=1 --libFuzzerOptions=-use_value_profile=1", + "dryRun": "jazzer fuzz --sync -x Error --runs=100000 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/examples/bug-detectors/prototype-pollution/package.json b/examples/bug-detectors/prototype-pollution/package.json index 9e0ccdc08..62ecb8640 100644 --- a/examples/bug-detectors/prototype-pollution/package.json +++ b/examples/bug-detectors/prototype-pollution/package.json @@ -7,8 +7,8 @@ "protobufjs": "7.5.5" }, "scripts": { - "fuzz": "jazzer fuzz -i protobufjs -i fuzz -e nothing --timeout=60000 -x Error -- -runs=1000000 -print_final_stats=1 -use_value_profile=1 -rss_limit_mb=10000 -dict=userDict.txt", - "dryRun": "jazzer fuzz -i protobufjs -- -runs=100000000 -seed=123456789" + "fuzz": "jazzer fuzz -i protobufjs -i fuzz -e nothing --timeout=60000 -x Error --runs=1000000 --dict=userDict.txt --libFuzzerOptions=-print_final_stats=1 --libFuzzerOptions=-use_value_profile=1 --libFuzzerOptions=-rss_limit_mb=10000", + "dryRun": "jazzer fuzz -i protobufjs --runs=100000000 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/examples/bug-detectors/ssrf/package.json b/examples/bug-detectors/ssrf/package.json index 830cb41a9..145591fcb 100644 --- a/examples/bug-detectors/ssrf/package.json +++ b/examples/bug-detectors/ssrf/package.json @@ -2,9 +2,9 @@ "name": "ssrf-example", "version": "1.0.0", "scripts": { - "fuzz": "jazzer fuzz -h config.js --timeout=60000 -x Error -- -runs=1000000 -print_final_stats=1 -use_value_profile=1 -rss_limit_mb=10000", - "fuzzShowError": "jazzer fuzz -h config.js --timeout=60000 -- -runs=1000000 -print_final_stats=1 -use_value_profile=1 -rss_limit_mb=10000", - "dryRun": "jazzer fuzz -- -runs=100000000 -seed=123456789" + "fuzz": "jazzer fuzz -h config.js --timeout=60000 -x Error --runs=1000000 --libFuzzerOptions=-print_final_stats=1 --libFuzzerOptions=-use_value_profile=1 --libFuzzerOptions=-rss_limit_mb=10000", + "fuzzShowError": "jazzer fuzz -h config.js --timeout=60000 --runs=1000000 --libFuzzerOptions=-print_final_stats=1 --libFuzzerOptions=-use_value_profile=1 --libFuzzerOptions=-rss_limit_mb=10000", + "dryRun": "jazzer fuzz --runs=100000000 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/examples/custom-hooks/package.json b/examples/custom-hooks/package.json index ac5a45575..b911eae31 100644 --- a/examples/custom-hooks/package.json +++ b/examples/custom-hooks/package.json @@ -8,8 +8,8 @@ }, "scripts": { "fuzz": "jazzer fuzz -i jpeg-js -h custom-hooks --sync", - "dryRun": "jazzer fuzz -i jpeg-js --sync -h custom-hooks -- -runs=100 -seed=123456789", - "coverage": "jazzer fuzz -i jpeg-js -i fuzz.js -i custom-hooks.js -h custom-hooks --sync --cov -- -max_total_time=10" + "dryRun": "jazzer fuzz -i jpeg-js --sync -h custom-hooks --runs=100 --seed=123456789", + "coverage": "jazzer fuzz -i jpeg-js -i fuzz.js -i custom-hooks.js -h custom-hooks --sync --cov --maxTotalTime=10" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/examples/jest_integration/.jazzerjsrc.json b/examples/jest_integration/.jazzerjsrc.json index 6ede38507..58c02cf89 100644 --- a/examples/jest_integration/.jazzerjsrc.json +++ b/examples/jest_integration/.jazzerjsrc.json @@ -1,9 +1,6 @@ { "includes": ["target", "integration.fuzz", "worker.fuzz"], "excludes": ["@babel"], - "fuzzerOptions": [ - "-rss_limit_mb=16000", - "-use_value_profile=1", - "-runs=1000000" - ] + "runs": 1000000, + "libFuzzerOptions": ["-rss_limit_mb=16000", "-use_value_profile=1"] } diff --git a/examples/jpeg/package.json b/examples/jpeg/package.json index 88b0427bc..3f03c5d62 100644 --- a/examples/jpeg/package.json +++ b/examples/jpeg/package.json @@ -10,8 +10,8 @@ }, "scripts": { "fuzz": "jazzer fuzz -i jpeg-js --sync", - "dryRun": "jazzer fuzz -i jpeg-js --sync -- -runs=100 -seed=123456789", - "coverage": "jazzer fuzz -i jpeg-js/lib -i fuzz.js --sync --cov -- -max_total_time=1 -seed=123456789" + "dryRun": "jazzer fuzz -i jpeg-js --sync --runs=100 --seed=123456789", + "coverage": "jazzer fuzz -i jpeg-js/lib -i fuzz.js --sync --cov --maxTotalTime=1 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/examples/jpeg_es6/package.json b/examples/jpeg_es6/package.json index a43b15303..45d1c361a 100644 --- a/examples/jpeg_es6/package.json +++ b/examples/jpeg_es6/package.json @@ -11,7 +11,7 @@ }, "scripts": { "fuzz": "jazzer fuzz -i jpeg-js --sync", - "dryRun": "jazzer fuzz -i jpeg-js --sync -- -runs=100 -seed=123456789" + "dryRun": "jazzer fuzz -i jpeg-js --sync --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/examples/js-yaml/package.json b/examples/js-yaml/package.json index 37de7a069..22311c0fe 100644 --- a/examples/js-yaml/package.json +++ b/examples/js-yaml/package.json @@ -4,8 +4,8 @@ "description": "An example showing how Jazzer.js integrates with TypeScript to fuzz js-yaml", "scripts": { "build": "tsc", - "fuzz": "npm run build && jazzer dist/fuzz -i js-yaml -- -use_value_profile=1", - "dryRun": "npm run build && jazzer dist/fuzz -i js-yaml -- -use_value_profile=1 -runs=100 -seed=123456789" + "fuzz": "npm run build && jazzer dist/fuzz -i js-yaml --libFuzzerOptions=-use_value_profile=1", + "dryRun": "npm run build && jazzer dist/fuzz -i js-yaml --runs=100 --seed=123456789 --libFuzzerOptions=-use_value_profile=1" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core", diff --git a/examples/maze/package.json b/examples/maze/package.json index 1036b468a..084b164a1 100644 --- a/examples/maze/package.json +++ b/examples/maze/package.json @@ -4,8 +4,8 @@ "version": "1.0.0", "description": "An example showing you can give Jazzer.js more feedback signals to reach deep program states", "scripts": { - "fuzz": "jazzer fuzz --sync -- -use_value_profile=1", - "dryRun": "jazzer fuzz --sync -- -use_value_profile=1 -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --sync --libFuzzerOptions=-use_value_profile=1", + "dryRun": "jazzer fuzz --sync --runs=100 --seed=123456789 --libFuzzerOptions=-use_value_profile=1" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/examples/spectral/package.json b/examples/spectral/package.json index d2480226c..1535e3b7a 100644 --- a/examples/spectral/package.json +++ b/examples/spectral/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "scripts": { "fuzz": "jazzer spectral-example -i spectral --sync", - "dryRun": "jazzer spectral-example -i spectral --sync -- -runs=100 -seed=123456789" + "dryRun": "jazzer spectral-example -i spectral --sync --runs=100 --seed=123456789" }, "dependencies": { "@stoplight/spectral-parsers": "^1.0.1" diff --git a/examples/xml/package.json b/examples/xml/package.json index 513becc28..268ebb33c 100644 --- a/examples/xml/package.json +++ b/examples/xml/package.json @@ -8,7 +8,7 @@ }, "scripts": { "fuzz": "jazzer fuzz -i xml", - "dryRun": "jazzer fuzz -i xml -- -runs=100 -seed=123456789" + "dryRun": "jazzer fuzz -i xml --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/done_callback/package.json b/tests/done_callback/package.json index 0d54782fb..f594aa864 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/fork_mode/package.json b/tests/fork_mode/package.json index f04ed1d4b..1488d7c20 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='.*' --libFuzzerOptions=-fork=3", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync --runs=100 --seed=123456789 --libFuzzerOptions=-fork=3" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/promise/package.json b/tests/promise/package.json index 0ec55ccc3..fcbc4a8da 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/return_values/asyncRunnerAsyncReturns/package.json b/tests/return_values/asyncRunnerAsyncReturns/package.json index da48b35f2..2c5f63fc9 100644 --- a/tests/return_values/asyncRunnerAsyncReturns/package.json +++ b/tests/return_values/asyncRunnerAsyncReturns/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 -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --runs=5000 --seed=3088388356", + "dryRun": "jazzer fuzz --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/tests/return_values/asyncRunnerMixedReturns/package.json b/tests/return_values/asyncRunnerMixedReturns/package.json index e05cbebca..d1d3a18a2 100644 --- a/tests/return_values/asyncRunnerMixedReturns/package.json +++ b/tests/return_values/asyncRunnerMixedReturns/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 -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --runs=5000 --seed=3088388356", + "dryRun": "jazzer fuzz --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/tests/return_values/asyncRunnerSyncReturns/package.json b/tests/return_values/asyncRunnerSyncReturns/package.json index ca5004c19..cf9578e1b 100644 --- a/tests/return_values/asyncRunnerSyncReturns/package.json +++ b/tests/return_values/asyncRunnerSyncReturns/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 -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --runs=5000 --seed=3088388356", + "dryRun": "jazzer fuzz --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/tests/return_values/syncRunnerAsyncReturns/package.json b/tests/return_values/syncRunnerAsyncReturns/package.json index edf2a82e4..441e00175 100644 --- a/tests/return_values/syncRunnerAsyncReturns/package.json +++ b/tests/return_values/syncRunnerAsyncReturns/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 -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz --sync -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --sync --runs=5000 --seed=3088388356", + "dryRun": "jazzer fuzz --sync --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/tests/return_values/syncRunnerMixedReturns/package.json b/tests/return_values/syncRunnerMixedReturns/package.json index f4789dfaa..e33275693 100644 --- a/tests/return_values/syncRunnerMixedReturns/package.json +++ b/tests/return_values/syncRunnerMixedReturns/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 -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --sync --runs=5000 --seed=3088388356", + "dryRun": "jazzer fuzz --runs=100 --seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../../packages/core" diff --git a/tests/return_values/syncRunnerSyncReturns/package.json b/tests/return_values/syncRunnerSyncReturns/package.json index 683d43fa7..79effc244 100644 --- a/tests/return_values/syncRunnerSyncReturns/package.json +++ b/tests/return_values/syncRunnerSyncReturns/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 -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --sync --runs=5000 --seed=3088388356", + "dryRun": "jazzer fuzz --runs=100 --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..1a8ac2d67 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..2b76da74a 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..f46c8caa3 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 --libFuzzerOptions=-use_value_profile=1", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync --runs=100 --seed=123456789 --libFuzzerOptions=-use_value_profile=1" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" From 401a4f5c4b3aa2852c8c95ea03b0c9f12695bb9b Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:36:58 +0200 Subject: [PATCH 08/10] ci(fuzzer): teach workflows about Rust The LibAFL backend is part of the supported build now, so local checks and CI must format, cache, build, and test the Rust runtime deliberately. --- .github/workflows/release.yaml | 5 +++++ .github/workflows/run-all-tests-main.yaml | 17 ++++++++++++++--- .github/workflows/run-all-tests-pr.yaml | 22 +++++++++++++++++++--- .prettierignore | 1 + package.json | 12 +++++++----- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c3015629a..96ed2c236 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,6 +28,11 @@ jobs: with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable + - name: rust target (macos x64) + if: ${{ matrix.os == 'macos-latest' && matrix.arch == '--arch x86_64' }} + run: rustup target add x86_64-apple-darwin - 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..254f2310f 100644 --- a/.github/workflows/run-all-tests-main.yaml +++ b/.github/workflows/run-all-tests-main.yaml @@ -21,18 +21,29 @@ jobs: path: | packages/fuzzer/prebuilds key: - fuzzer-cache-${{ runner.os }}-${{ - hashFiles('packages/fuzzer/CMakeLists.txt', - 'packages/fuzzer/**/*.h', 'packages/fuzzer/**/*.cpp') }} + fuzzer-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', + 'package.json', 'packages/fuzzer/package.json', + 'packages/fuzzer/CMakeLists.txt', 'packages/fuzzer/**/*.h', + 'packages/fuzzer/**/*.cpp', 'packages/fuzzer/rust/Cargo.toml', + 'packages/fuzzer/rust/Cargo.lock', + 'packages/fuzzer/rust/src/**/*.rs') }} - name: node uses: actions/setup-node@v6 with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt - name: install dependencies run: npm ci - name: build project run: npm run build + - name: check Rust formatting + run: npm run format:rust + - name: test LibAFL runtime + run: cargo test --manifest-path packages/fuzzer/rust/Cargo.toml - name: build fuzzer if: ${{ steps.cache-fuzzer.outputs.cache-hit != 'true' }} run: npm run build --workspace=@jazzer.js/fuzzer diff --git a/.github/workflows/run-all-tests-pr.yaml b/.github/workflows/run-all-tests-pr.yaml index e657966d5..e98932bd6 100644 --- a/.github/workflows/run-all-tests-pr.yaml +++ b/.github/workflows/run-all-tests-pr.yaml @@ -24,6 +24,10 @@ jobs: with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt - name: install dependencies run: npm ci - name: install clang-tidy @@ -31,6 +35,10 @@ jobs: - name: build project # Build project so that imports can be checked during linting run: npm run build + - name: check Rust formatting + run: npm run format:rust + - name: test LibAFL runtime + run: cargo test --manifest-path packages/fuzzer/rust/Cargo.toml - name: build fuzzer # Build the native addon so that CMake downloads libFuzzer and # generates compile_commands.json, which are needed by clang-tidy @@ -40,6 +48,7 @@ jobs: tests: name: unit tests runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: matrix: os: [windows-latest, ubuntu-latest, ubuntu-24.04-arm, macos-latest] @@ -59,14 +68,19 @@ jobs: path: | packages/fuzzer/prebuilds key: - fuzzer-cache-${{ matrix.os }}-${{ - hashFiles('packages/fuzzer/CMakeLists.txt', - 'packages/fuzzer/**/*.h', 'packages/fuzzer/**/*.cpp') }} + fuzzer-cache-${{ matrix.os }}-${{ hashFiles('package-lock.json', + 'package.json', 'packages/fuzzer/package.json', + 'packages/fuzzer/CMakeLists.txt', 'packages/fuzzer/**/*.h', + 'packages/fuzzer/**/*.cpp', 'packages/fuzzer/rust/Cargo.toml', + 'packages/fuzzer/rust/Cargo.lock', + 'packages/fuzzer/rust/src/**/*.rs') }} - name: node uses: actions/setup-node@v6 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 +109,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/.prettierignore b/.prettierignore index cceacdc93..86b0d1c5e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ node_modules .idea .vscode compile_commands.json +packages/fuzzer/rust/target diff --git a/package.json b/package.json index b2fbe8384..f94d4fb1d 100644 --- a/package.json +++ b/package.json @@ -25,16 +25,18 @@ "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", "lint": "eslint . && npm run lint --ws --if-present", "lint:fix": "eslint . --fix && npm run lint:fix --ws --if-present", - "format": "prettier --check . && npm run format --ws --if-present", - "format:fix": "prettier --write --ignore-unknown . && npm run format:fix --ws --if-present", + "format": "prettier --check . && npm run format:rust && npm run format --ws --if-present", + "format:fix": "prettier --write --ignore-unknown . && npm run format:rust:fix && npm run format:fix --ws --if-present", + "format:rust": "cargo fmt --manifest-path packages/fuzzer/rust/Cargo.toml --check", + "format:rust:fix": "cargo fmt --manifest-path packages/fuzzer/rust/Cargo.toml", "check": "npm run format && npm run lint", "fix": "npm run format:fix && npm run lint:fix" }, From a6346601bb677d33276efbfaba5ba8e300328ee9 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:37:13 +0200 Subject: [PATCH 09/10] docs(settings): document normalized engine flags --- README.md | 8 +- docs/fuzz-settings.md | 187 +++++++++++++++++++++------- docs/jest-integration.md | 3 +- examples/jest_integration/readme.md | 3 +- 4 files changed, 149 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 85873303e..2c5e62ee5 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 ``` + CLI fuzzing uses the libFuzzer backend by default. To run with LibAFL + instead, add `--engine=afl`. + 4. Enjoy fuzzing! ## Usage diff --git a/docs/fuzz-settings.md b/docs/fuzz-settings.md index 43f3489a3..75d376070 100644 --- a/docs/fuzz-settings.md +++ b/docs/fuzz-settings.md @@ -16,8 +16,11 @@ There are three ways to configure Jazzer.js: 4. **Jest fuzz test** - some options can be set directly in Jest fuzz test. These options are: ([`timeout`](#timeout--number), [`dictionaryEntries`](#dictionaryentries--arraystring--uint8array--int8array), - [`fuzzerOptions`](#fuzzeroptions--arraystring), and - [`sync`](#sync--boolean)). + [`dictionaryFiles`](#dictionaryfiles--arraystring), + [`libFuzzerOptions`](#libfuzzeroptions--arraystring), + [`libAflOptions`](#libafloptions--arraystring), [`runs`](#runs--number), + [`seed`](#seed--number), [`maxLen`](#maxlen--number), + [`maxTotalTime`](#maxtotaltime--number), and [`sync`](#sync--boolean)). The following preferences apply with increasing priority: @@ -166,19 +169,19 @@ If the fuzzer does not finish, no report will be generated. Pressing CTRL-C to manually stop the fuzzer might result in incomplete coverage reports. To reliably generate coverage reports, it makes sense to run the fuzzer on each input in the corpus only once. This can be accomplished by adding the following -to the option [fuzzerOptions](#fuzzeroptions--arraystring): `-runs=1` (run each -input once and quit); or `-max_total_time=N` (fuzz for N seconds and quit); or -by running the fuzzer in [regression mode](#mode--fuzzingregression) using the -option `--mode=regression`. While it's possible to generate coverage reports by -running Jazzer.js in fuzzing mode, instrumentation for code coverage makes -fuzzing less efficient. +the native Jazzer.js options [`runs`](#runs--number) or +[`maxTotalTime`](#maxtotaltime--number), or by running the fuzzer in +[regression mode](#mode--fuzzingregression) using the option +`--mode=regression`. While it's possible to generate coverage reports by running +Jazzer.js in fuzzing mode, instrumentation for code coverage makes fuzzing less +efficient. **CLI:** To run the fuzz function `buzz` in file `my-fuzz-file.js` for 10 seconds from the command line and generate a code coverage report, add the `--coverage` option without arguments: ```bash -npx jazzer my-fuzz-file --fuzzEntryPoint=buzz --coverage -- -max_total_time=10 +npx jazzer my-fuzz-file --fuzzEntryPoint=buzz --coverage --maxTotalTime=10 ``` **Jest:** Call Jest with `--coverage` flag: @@ -205,7 +208,7 @@ command in order to generate a code coverage report of the fuzz function `fuzz` directory `./corpus` once: ```bash -JAZZER_COVERAGE=true npx jazzer my-fuzz-file ./corpus -- -runs=1 +JAZZER_COVERAGE=true npx jazzer my-fuzz-file ./corpus --runs=1 ``` In Jest mode it is not possible to set this option using an environment @@ -373,6 +376,36 @@ it.fuzz("XML parser", {dictionaryEntries: xmlDictionary}); ``` +### `dictionaryFiles` : [array\] + +Default: [] + +Set fuzzing dictionary files. Dictionaries are common Jazzer.js options because +both backends can use them. Do not pass libFuzzer's `-dict` flag via backend +options; use this option instead. + +**CLI:** Dictionary files can be specified multiple times: + +```bash +npx jazzer my-fuzz-file --dict=xml.txt --dict=html.txt +``` + +**Jest:** Add dictionary files to `.jazzerjsrc.json`: + +```json +{ + "dictionaryFiles": ["xml.txt", "html.txt"] +} +``` + +**Jest fuzz test:** Dictionary files can also be set directly for a fuzz test: + +```javascript +it.fuzz("XML parser", + (data) => {...}, + {dictionaryFiles: ["xml.txt"]}); +``` + ### `disableBugDetectors` : [array\] Default: [] @@ -589,65 +622,124 @@ 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. -### `fuzzerOptions` : [array\] +### `engine` : [string] -Default: [] +Default: `"libfuzzer"` in CLI mode, `"libfuzzer"` in Jest mode + +Select the native fuzzing backend. -Pass options to native fuzzing engine (Jazzer.js uses libFuzzer). +- `libfuzzer`: use the existing libFuzzer backend. +- `afl` (alias for `libafl`): use the LibAFL backend. -For a list of available options, 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: +**CLI:** Select the backend with `--engine`, for example: ```bash -npx jazzer my-fuzz-file -- -help=1 +npx jazzer my-fuzz-file --engine=afl ``` -_Note:_ the libFuzzer option `-timeout` (notice the single dash) is natively -supported in Jazzer.js with the option [`timeout`](#timeout--number) and will be -ignored if passed via `fuzzerOptions`. +**Jest:** Set it in `.jazzerjsrc.json`: -**CLI:** It is not possible to use this flag directly on the command line. -Instead, the options can be passed to libFuzzer after a double-dash `--`. For -example, libFuzzer's flags `-use_value_profile=1` and `-dict=xml.txt` can be set -as follows: +```json +{ + "engine": "afl" +} +``` + +LibAFL supports both `fuzzing` and `regression` mode. + +### `runs` : [number] + +Default: 0 + +Set the number of fuzz target executions. `0` means unlimited in fuzzing mode. +In [regression mode](#mode--fuzzingregression), Jazzer.js always replays all +available inputs and ignores any configured run limit. ```bash -npx jazzer my-fuzz-file -- -use_value_profile=1 -dict=xml.txt +npx jazzer my-fuzz-file --runs=1000 ``` -**Jest:** To pass the options `-use_value_profile=1` and `-dict=xml.txt` to -libFuzzer in Jest mode, add the following to the `.jazzerjsrc.json` file: +### `seed` : [number] + +Default: 0 + +Set the fuzzing seed. `0` lets Jazzer.js generate a seed once and pass the same +value to instrumentation and the selected backend. + +```bash +npx jazzer my-fuzz-file --seed=123456789 +``` + +### `maxLen` : [number] + +Default: 4096 + +Set the maximum generated input length in bytes. + +```bash +npx jazzer my-fuzz-file --maxLen=8192 +``` + +### `maxTotalTime` : [number] + +Default: 0 + +Set the maximum total fuzzing time in seconds. `0` means unlimited. + +```bash +npx jazzer my-fuzz-file --maxTotalTime=60 +``` + +### `artifactPrefix` : [string] + +Default: "" + +Set the prefix used when writing crash artifacts. + +```bash +npx jazzer my-fuzz-file --artifactPrefix=./artifacts/ +``` + +### `libFuzzerOptions` : [array\] + +Default: [] + +Pass backend-specific options to libFuzzer. Common Jazzer.js options such as +`runs`, `seed`, `maxLen`, `maxTotalTime`, `timeout`, `artifactPrefix`, and +`dictionaryFiles` must be configured with their Jazzer.js-native options and are +rejected here. + +For the `libfuzzer` backend, see the +[libFuzzer documentation](https://llvm.org/docs/LibFuzzer.html#options). + +```bash +npx jazzer my-fuzz-file --libFuzzerOptions=-use_value_profile=1 --libFuzzerOptions=-print_final_stats=1 +``` + +**Jest:** Add libFuzzer-specific options to `.jazzerjsrc.json`: ```json { - "fuzzerOptions": ["-use_value_profile=1", "-dict=xml.txt"] + "libFuzzerOptions": ["-use_value_profile=1", "-print_final_stats=1"] } ``` -**Jest fuzz test:** The `fuzzerOptions` can be set directly in the fuzz test in -_fuzzing_ mode by providing it as part of an object with options as the third -argument to the fuzz test. _Note:_ this overrides any prior settings (e.g. -settings loaded from `.jazzerjsrc.json`). Here is an example how make the fuzzer -use the dictionary "xml.txt" for fuzz test "My test 1": +**Jest fuzz test:** Backend-specific libFuzzer options can be set directly for a +fuzz test: ```javascript it.fuzz("My test 1", (data) => {...}, - {fuzzerOptions: ["-dict=xml.txt"]}); + {libFuzzerOptions: ["-use_value_profile=1"]}); ``` -**ENV:** For example, to pass the options `-use_value_profile=1` and -`-dict=xml.txt` to libFuzzer in Jest mode using environmental variable can be -done as follows: +### `libAflOptions` : [array\] -```bash -JAZZER_FUZZER_OPTIONS='["-use_value_profile=1", "-dict=xml.txt"]' npx jest tests.fuzz.js -``` +Default: [] -_Note:_ It is not possible to set this flag in CLI mode via an environment -variable. +Reserved for backend-specific LibAFL options. No LibAFL-specific options are +currently exposed; use common Jazzer.js options such as `runs`, `seed`, +`maxLen`, `maxTotalTime`, and `timeout` instead. #### Value profile @@ -695,7 +787,7 @@ Default: "" Specify a file to synchronize edge IDs used during fuzzing by multiple processes (e.g. in fork mode by adding `-fork=1` to the option -[`fuzzerOption`](#fuzzeroptions--arraystring)). +[`libFuzzerOptions`](#libfuzzeroptions--arraystring)). _Note: This option is intended for internal use only when fuzzing in multi-process mode. It is not possible to set this option on command-line or @@ -811,10 +903,9 @@ coverage will be stored in the seed directory. Inputs that trigger a crash will be stored in the regression directory. In _regression_ mode on command line, Jazzer.js runs each input from the seed -and regression corpus directories on the fuzz target once, and then stops. Under -the hood, this option adds `-runs=0` to the option -[`fuzzerOptions`](#fuzzeroptions--arraystring). Setting the fuzzer option to -`-runs=0` (run each input only once) can be used to achieve the same behavior. +and regression corpus directories on the fuzz target once, and then stops. This +behavior is configured with the native Jazzer.js mode option instead of backend +flags. **Jest:** Default: `"regression"`. diff --git a/docs/jest-integration.md b/docs/jest-integration.md index 72c6b50d9..c92871fee 100644 --- a/docs/jest-integration.md +++ b/docs/jest-integration.md @@ -83,7 +83,8 @@ which can be specified through the CLI client. "includes": ["*"], "excludes": ["node_modules"], "customHooks": [], - "fuzzerOptions": [], + "runs": 1000, + "libFuzzerOptions": [], "sync": false, "timeout": 1000 } diff --git a/examples/jest_integration/readme.md b/examples/jest_integration/readme.md index 0463d917e..063056754 100644 --- a/examples/jest_integration/readme.md +++ b/examples/jest_integration/readme.md @@ -41,7 +41,8 @@ format: "includes": ["*"], "excludes": ["node_modules"], "customHooks": [], - "fuzzerOptions": [], + "runs": 1000, + "libFuzzerOptions": [], "sync": false } ``` From ef85b595c9beb24f3a59cd0fa8cf342aa959b09f Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Wed, 6 May 2026 19:37:28 +0200 Subject: [PATCH 10/10] bench(engine): add backend smoke harness --- benchmarks/engine_smoke/.gitignore | 3 + benchmarks/engine_smoke/anomaly.js | 187 ++++++++++++++++++++++ benchmarks/engine_smoke/anomaly_fuzz.js | 32 ++++ benchmarks/engine_smoke/fuzz.js | 76 +++++++++ benchmarks/engine_smoke/package.json | 15 ++ benchmarks/engine_smoke/run.js | 181 +++++++++++++++++++++ benchmarks/engine_smoke/seeds/basic.txt | 1 + benchmarks/engine_smoke/seeds/encoded.txt | 1 + benchmarks/engine_smoke/seeds/nested.txt | 1 + 9 files changed, 497 insertions(+) create mode 100644 benchmarks/engine_smoke/.gitignore create mode 100644 benchmarks/engine_smoke/anomaly.js create mode 100644 benchmarks/engine_smoke/anomaly_fuzz.js create mode 100644 benchmarks/engine_smoke/fuzz.js create mode 100644 benchmarks/engine_smoke/package.json create mode 100644 benchmarks/engine_smoke/run.js create mode 100644 benchmarks/engine_smoke/seeds/basic.txt create mode 100644 benchmarks/engine_smoke/seeds/encoded.txt create mode 100644 benchmarks/engine_smoke/seeds/nested.txt 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..c3e6ebb29 --- /dev/null +++ b/benchmarks/engine_smoke/anomaly.js @@ -0,0 +1,187 @@ +/* + * 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=.*", + "--runs=4000", + "--seed=1337", + "--maxLen=16", + `--artifactPrefix=${outputDirectory}${path.sep}`, + corpusDirectory, + ], + 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=.*", + "--runs=2000", + "--seed=9001", + "--maxLen=128", + `--artifactPrefix=${outputDirectory}${path.sep}`, + corpusDirectory, + ], + 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..2bd37d418 --- /dev/null +++ b/benchmarks/engine_smoke/run.js @@ -0,0 +1,181 @@ +/* + * 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/", + `--maxTotalTime=${seconds}`, + `--artifactPrefix=${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