From ae1d1e1dc97a9e8d462686b9f4118cf96a61077a Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 15:55:31 +0200 Subject: [PATCH 01/26] refactor(options): extract shared option contracts Move the pre-existing option model and merge logic into a leaf workspace package so core and jest-runner share one configuration contract without owning duplicate state. Keep backend-specific LibAFL wiring out of this first extraction step. --- package-lock.json | 15 + packages/core/options.test.ts | 383 +------------- packages/core/options.ts | 472 +----------------- packages/core/package.json | 1 + packages/core/tsconfig.json | 3 + packages/jest-runner/config.test.ts | 2 +- packages/jest-runner/config.ts | 2 +- packages/jest-runner/fuzz.test.ts | 8 +- packages/jest-runner/fuzz.ts | 10 +- packages/jest-runner/globalsInterceptor.ts | 2 +- packages/jest-runner/package.json | 1 + .../jest-runner/testStateInterceptor.test.ts | 2 +- packages/jest-runner/testStateInterceptor.ts | 2 +- packages/jest-runner/tsconfig.json | 3 + packages/options/index.ts | 442 ++++++++++++++++ packages/options/options.test.ts | 374 ++++++++++++++ packages/options/package.json | 22 + packages/options/tsconfig.json | 8 + tsconfig.build.json | 1 + 19 files changed, 902 insertions(+), 851 deletions(-) create mode 100644 packages/options/index.ts create mode 100644 packages/options/options.test.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..e0d26d776 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", @@ -7779,6 +7784,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 +7800,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/options.test.ts b/packages/core/options.test.ts index d91a58a78..4debe9918 100644 --- a/packages/core/options.test.ts +++ b/packages/core/options.test.ts @@ -14,290 +14,9 @@ * limitations under the License. */ -import { - defaultCLIOptions, - fromSnakeCase, - fromSnakeCaseWithPrefix, - Options, - OptionsManager, - OptionSource, - spawnsSubprocess, - validateKeySource, -} from "./options"; +import { OptionsManager, OptionSource } from "@jazzer.js/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); - } - }); - }); - - 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("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`", () => { - // 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(); - } - }); - }); - }); - - 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); - }); - }); - - 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"] }, - 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"); - }, - ); - }); - 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("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", - ); - }); - }); -}); +import { spawnsSubprocess } from "./options"; describe("buildLibFuzzerOptions", () => { describe("spawnsSubprocess", () => { @@ -314,91 +33,15 @@ describe("buildLibFuzzerOptions", () => { }); }); -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]); +describe("fuzzer options", () => { + 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(); }); -} - -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()); -} - -// 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); - - let newPriority = OptionSource.CommandLineArguments; - try { - validateKeySource(key, OptionSource.JestFuzzTestOptions); - newPriority = OptionSource.JestFuzzTestOptions; - } catch (e) { - /**/ - } - - 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); - - // 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); - - // mutate newValue and check that the new value of option is not changed - newValue[0] = v0; - expect(options.get(key)).toStrictEqual(newStoredValue); - - // 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; -} +}); diff --git a/packages/core/options.ts b/packages/core/options.ts index 66dfae2d7..6c82965ab 100644 --- a/packages/core/options.ts +++ b/packages/core/options.ts @@ -19,407 +19,14 @@ import * as util from "util"; import * as tmp from "tmp"; -import { useDictionaryByParams } from "./dictionary"; -import { replaceAll } from "./utils"; - -/** - * 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; -} - -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 = [ - "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, -}); +import { + type OptionsManager, + toOptionsWithPrintableSources, +} from "@jazzer.js/options"; -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; - }; -}; - -// Source of an option is considered when merging options. -// Higher index means higher priority. -export enum OptionSource { - DefaultCLIOptions, - DefaultJestOptions, - InternalJestTimeout, - ConfigurationFile, - EnvironmentVariables, - CommandLineArguments, - JestFuzzTestOptions, -} - -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"); - } - } - - /** - * Get the value of an option. - * @param key - */ - get(key: K): Options[K] { - return this._options[key].value; - } - - /** - * Get raw options without the source information. - * @returns a copy of the options without source information - */ - getOptions(): Options { - return OptionsManager.detachSource(this._options); - } - - getOptionsWithSource(): OptionsWithSource { - return this._options; - } - - /** - * 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; - } - - 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] { - // 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); - } - - /** - * 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; - } - - /** - * 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; - } -} +import { useDictionaryByParams } from "./dictionary"; -function setProperty(obj: T, key: K, value: T[K]) { - obj[key] = value; -} +export * from "@jazzer.js/options"; export function buildFuzzerOption(options: OptionsManager) { let params: string[] = []; @@ -440,7 +47,6 @@ 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), @@ -449,23 +55,6 @@ export function printOptions(options: OptionsManager, infix = "") { } } -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; -} - function logInfoAboutFuzzerOptions(fuzzerOptions: string[]) { fuzzerOptions.slice(1).forEach((element) => { if (element.length > 0 && element[0] != "-") { @@ -502,19 +91,13 @@ function forkedExecutionParams(params: string[]): string[] { } 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. return "unused_arg0_report_a_bug_if_you_see_this"; } else { - // Create a wrapper script and return its path. return createWrapperScript(fuzzerOptions); } } -// These flags cause libFuzzer to spawn subprocesses. const SUBPROCESS_FLAGS = ["fork", "jobs", "merge", "minimize_crash"]; export function spawnsSubprocess(fuzzerOptions: string[]): boolean { @@ -558,46 +141,3 @@ ${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}`, - ); - } - return source > options[key].source; -} 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 783e7e5c4..1646b7212 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/config.test.ts b/packages/jest-runner/config.test.ts index b46a55d9f..fdd6f4095 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 { OptionsManager, OptionSource } from "@jazzer.js/options"; import { loadConfig } from "./config"; diff --git a/packages/jest-runner/config.ts b/packages/jest-runner/config.ts index fa6e1a8f2..a9fd9d7e0 100644 --- a/packages/jest-runner/config.ts +++ b/packages/jest-runner/config.ts @@ -16,7 +16,7 @@ import { cosmiconfigSync } from "cosmiconfig"; -import { Options, OptionsManager, OptionSource } from "@jazzer.js/core"; +import { Options, OptionsManager, OptionSource } from "@jazzer.js/options"; export const TIMEOUT_PLACEHOLDER = Number.MIN_SAFE_INTEGER; diff --git a/packages/jest-runner/fuzz.test.ts b/packages/jest-runner/fuzz.test.ts index d0b88ae4e..9e186d631 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 { OptionsManager, OptionSource } from "@jazzer.js/options"; import { Corpus } from "./corpus"; import { diff --git a/packages/jest-runner/fuzz.ts b/packages/jest-runner/fuzz.ts index 3f4a941a0..1e04cb1b9 100644 --- a/packages/jest-runner/fuzz.ts +++ b/packages/jest-runner/fuzz.ts @@ -19,18 +19,20 @@ import * as fs from "fs"; import { Circus, Global } from "@jest/types"; import { - AllowedFuzzTestOptions, asFindingAwareFuzzFn, FindingAwareFuzzTarget, FuzzTarget, FuzzTargetAsyncOrValue, FuzzTargetCallback, - Options, - OptionsManager, - OptionSource, printOptions, startFuzzingNoInit, } from "@jazzer.js/core"; +import { + AllowedFuzzTestOptions, + Options, + OptionsManager, + OptionSource, +} from "@jazzer.js/options"; import { Corpus } from "./corpus"; import { removeTopFramesFromError } from "./errorUtils"; 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/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/testStateInterceptor.test.ts b/packages/jest-runner/testStateInterceptor.test.ts index 0f7214a44..dd5b04120 100644 --- a/packages/jest-runner/testStateInterceptor.test.ts +++ b/packages/jest-runner/testStateInterceptor.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Options, OptionsManager, OptionSource } from "@jazzer.js/core"; +import { Options, OptionsManager, OptionSource } from "@jazzer.js/options"; import { interceptTestState } from "./testStateInterceptor"; diff --git a/packages/jest-runner/testStateInterceptor.ts b/packages/jest-runner/testStateInterceptor.ts index 5bac8cdea..1901bf77d 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 { OptionsManager } from "@jazzer.js/options"; // Arbitrary high value to disable Jest timeout. const JEST_TIMEOUT_DISABLED = 1000 * 60 * 24 * 365; diff --git a/packages/jest-runner/tsconfig.json b/packages/jest-runner/tsconfig.json index 64d5c4d7a..b3975496e 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.ts b/packages/options/index.ts new file mode 100644 index 000000000..9717db4c8 --- /dev/null +++ b/packages/options/index.ts @@ -0,0 +1,442 @@ +/* + * 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. + */ + +/** + * 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; +} + +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 = [ + "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"], + customHooks: [], + dictionaryEntries: [], + disableBugDetectors: [], + dryRun: false, + excludes: ["node_modules"], + expectedErrors: [], + fuzzEntryPoint: "fuzz", + fuzzerOptions: [], + fuzzTarget: "", + idSyncFile: "", + includes: ["*"], + mode: "fuzzing", + sync: false, + timeout: 5000, + 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; + +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, +} + +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); + 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}'`, + ); + } + 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; +} + +// 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; + + 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}."`); + } +} + +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/options.test.ts b/packages/options/options.test.ts new file mode 100644 index 000000000..b68a78869 --- /dev/null +++ b/packages/options/options.test.ts @@ -0,0 +1,374 @@ +/* + * 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, + fromSnakeCase, + fromSnakeCaseWithPrefix, + Options, + OptionsManager, + OptionSource, + 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("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("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"] }, + 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("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", + ); + }); + }); +}); + +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/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..3cfb84e21 --- /dev/null +++ b/packages/options/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.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" }, From bd19af376bffcd248a1dd3b83b91cc6dcc9f7171 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:33:13 +0200 Subject: [PATCH 02/26] feat(core): add LibAFL as a fuzzing backend Wire LibAFL through the addon and option layer so CLI and Jest can run against either native backend. Make CLI fuzzing default to LibAFL and pin the legacy libFuzzer-only tests to keep the old behaviors covered where they still matter. --- packages/core/cli.ts | 12 +- packages/core/core.ts | 66 +- packages/core/dictionary.ts | 1 + packages/core/options.test.ts | 167 ++- packages/core/options.ts | 150 ++- packages/core/utils.test.ts | 23 + packages/core/utils.ts | 5 + packages/fuzzer/CMakeLists.txt | 50 + packages/fuzzer/addon.cpp | 3 + packages/fuzzer/addon.ts | 40 +- packages/fuzzer/fuzzer.ts | 5 + packages/fuzzer/libafl_runtime.cpp | 1128 +++++++++++++++++++ packages/fuzzer/libafl_runtime.h | 51 + packages/fuzzer/libafl_runtime.test.ts | 96 ++ packages/fuzzer/package.json | 3 +- packages/fuzzer/runtime/benchmark.js | 152 +++ packages/fuzzer/rust/Cargo.lock | 1370 ++++++++++++++++++++++++ packages/fuzzer/rust/Cargo.toml | 20 + packages/fuzzer/rust/src/lib.rs | 465 ++++++++ packages/fuzzer/shared/callbacks.cpp | 4 + packages/fuzzer/shared/coverage.cpp | 15 + packages/fuzzer/shared/coverage.h | 6 + packages/fuzzer/shared/libfuzzer.h | 1 + packages/fuzzer/shared/tracing.cpp | 66 ++ packages/fuzzer/shared/tracing.h | 9 + packages/fuzzer/tsconfig.json | 2 +- packages/jest-runner/fuzz.ts | 1 + packages/options/index.ts | 27 +- tests/bug-detectors/general.test.js | 4 + tests/code_coverage/coverage.test.js | 10 +- tests/done_callback/package.json | 4 +- tests/fork_mode/package.json | 4 +- tests/helpers.js | 13 + tests/promise/package.json | 4 +- tests/string_compare/package.json | 4 +- tests/timeout/package.json | 4 +- tests/value_profiling/package.json | 4 +- 37 files changed, 3936 insertions(+), 53 deletions(-) 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/lib.rs diff --git a/packages/core/cli.ts b/packages/core/cli.ts index 13ccdf5bb..a471f6662 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -44,7 +44,7 @@ yargs(process.argv.slice(2)) 'Start a fuzzing run using the "fuzz" function exported by "target" ' + 'and use the directory "corpus" to store newly generated inputs. ' + 'Also pass the "-max_total_time" flag to the internal fuzzing engine ' + - "(libFuzzer) to stop the fuzzing run after 60 seconds.", + "to stop the fuzzing run after 60 seconds.", ) .epilogue("Happy fuzzing!") .command( @@ -56,7 +56,7 @@ yargs(process.argv.slice(2)) 'The "corpus" directory is optional and can be used to provide initial ' + "seed input. It is also used to store interesting inputs between fuzzing " + "runs.\n\n" + - "To pass options to the internal fuzzing engine (libFuzzer) use a " + + "To pass options to the internal fuzzing engine use a " + 'double-dash, "--", to mark the end of the normal fuzzer arguments. ' + "An example is shown in the examples section of this help message.", (yargs: Argv) => { @@ -177,6 +177,14 @@ yargs(process.argv.slice(2)) group: "Fuzzer:", type: "string", }) + .option("engine", { + alias: ["backend"], + defaultDescription: `${JSON.stringify(defaultCLIOptions.engine)}`, + describe: + "Fuzzing engine backend. Use 'afl' (alias 'libafl') for the default LibAFL backend or 'libfuzzer' to run the libFuzzer backend.", + group: "Fuzzer:", + type: "string", + }) .option("dryRun", { alias: ["dry_run", "d"], defaultDescription: `${JSON.stringify(defaultCLIOptions.dryRun)}`, diff --git a/packages/core/core.ts b/packages/core/core.ts index 4e7c0e8f3..cfce39ef1 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -43,7 +43,12 @@ import { reportFinding, } from "./finding"; import { getJazzerJsGlobal, jazzerJs } from "./globals"; -import { buildFuzzerOption, OptionsManager } from "./options"; +import { + buildLibAflOptions, + buildLibFuzzerOptions, + OptionsManager, + resolveEngine, +} from "./options"; import { ensureFilepath, importModule } from "./utils"; // Remove temporary files on exit @@ -223,7 +228,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 +254,47 @@ export async function startFuzzingNoInit( // Currently only SIGINT is handled this way, as SIGSEGV has to be handled // by the native addon and directly stops the process. const signalHandler = (signal: number): void => { + if (signal === 0) { + return; + } reportFinding(new FuzzerSignalFinding(signal), false); }; try { - const fuzzerOptions = buildFuzzerOption(options); - if (options.get("sync")) { - await fuzzer.fuzzer.startFuzzing( - fuzzFn, - fuzzerOptions, - // In synchronous mode, we cannot use the SIGINT handler in Node, - // because the event loop is blocked by the fuzzer, and the handler - // won't be called until the fuzzing process is finished. - // Hence, we pass a callback function to the native fuzzer and - // register a SIGINT handler there. - signalHandler, - ); + if (resolveEngine(options.get("engine")) === "libfuzzer") { + const fuzzerOptions = buildLibFuzzerOptions(options); + if (options.get("sync")) { + await fuzzer.fuzzer.startFuzzing( + fuzzFn, + fuzzerOptions, + // In synchronous mode, we cannot use the SIGINT handler in Node, + // because the event loop is blocked by the fuzzer, and the handler + // won't be called until the fuzzing process is finished. + // Hence, we pass a callback function to the native fuzzer and + // register a SIGINT handler there. + signalHandler, + ); + } else { + await fuzzer.fuzzer.startFuzzingAsync(fuzzFn, fuzzerOptions); + } } else { - await fuzzer.fuzzer.startFuzzingAsync(fuzzFn, fuzzerOptions); + const libAflOptions = buildLibAflOptions(options); + const libAflFuzzer = fuzzer.fuzzer as unknown as { + startLibAfl: ( + fuzzFn: FindingAwareFuzzTarget, + options: typeof libAflOptions, + jsStopCallback: (signal: number) => void, + ) => Promise; + startLibAflAsync: ( + fuzzFn: FindingAwareFuzzTarget, + options: typeof libAflOptions, + ) => Promise; + }; + if (options.get("sync")) { + await libAflFuzzer.startLibAfl(fuzzFn, libAflOptions, signalHandler); + } else { + await libAflFuzzer.startLibAflAsync(fuzzFn, libAflOptions); + } } // Fuzzing ended without a finding, due to -max_total_time or -runs. return reportFuzzingResult(undefined, options.get("expectedErrors")); @@ -366,9 +398,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 +410,7 @@ export function asFindingAwareFuzzFn( ) ) { printFinding(error); - if (dumpCrashingInput) { + if (dumpCrashingInput && shouldDumpWithLibFuzzer) { fuzzer.fuzzer.printAndDumpCrashingInput(); } } diff --git a/packages/core/dictionary.ts b/packages/core/dictionary.ts index 82e8c1373..1a9bcb326 100644 --- a/packages/core/dictionary.ts +++ b/packages/core/dictionary.ts @@ -37,6 +37,7 @@ export class Dictionary { } function getDictionary(): Dictionary { + globalThis.JazzerJS ??= new Map(); return getOrSetJazzerJsGlobal("dictionary", new Dictionary()); } diff --git a/packages/core/options.test.ts b/packages/core/options.test.ts index 4debe9918..d2a213230 100644 --- a/packages/core/options.test.ts +++ b/packages/core/options.test.ts @@ -14,9 +14,32 @@ * limitations under the License. */ -import { OptionsManager, OptionSource } from "@jazzer.js/options"; +import fs from "fs"; +import os from "os"; +import path from "path"; -import { spawnsSubprocess } from "./options"; +import { + defaultCLIOptions, + defaultJestOptions, + Options, + OptionsManager, + OptionSource, + resolveEngine, +} from "@jazzer.js/options"; + +import { buildLibAflOptions, spawnsSubprocess } from "./options"; + +describe("options", () => { + describe("merge", () => { + it("uses LibAFL as default CLI engine", () => { + expect(defaultCLIOptions.engine).toBe("libafl"); + }); + + it("keeps libFuzzer as default Jest engine", () => { + expect(defaultJestOptions.engine).toBe("libfuzzer"); + }); + }); +}); describe("buildLibFuzzerOptions", () => { describe("spawnsSubprocess", () => { @@ -33,15 +56,135 @@ describe("buildLibFuzzerOptions", () => { }); }); -describe("fuzzer options", () => { - 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(); +describe("libafl options", () => { + it("normalizes engine aliases", () => { + expect(resolveEngine("libfuzzer")).toBe("libfuzzer"); + expect(resolveEngine("afl")).toBe("libafl"); + expect(resolveEngine("libafl")).toBe("libafl"); + expect(() => resolveEngine("unknown")).toThrow("Unknown fuzzing engine"); + }); + + it("canonicalizes engine aliases during option merge", () => { + const manager = new OptionsManager(OptionSource.DefaultJestOptions).merge( + { engine: "afl" }, + OptionSource.ConfigurationFile, + ); + + expect(manager.get("engine")).toBe("libafl"); + }); + + it("builds structured LibAFL options from fuzzer options", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + timeout: 1234, + fuzzerOptions: [ + "corpus-main", + "corpus-seed", + "-runs=99", + "-seed=1337", + "-max_len=1024", + "-max_total_time=42", + "-artifact_prefix=/tmp/artifacts/", + ], + }, + OptionSource.CommandLineArguments, + ); + + expect(buildLibAflOptions(manager)).toEqual({ + mode: "fuzzing", + runs: 99, + seed: 1337, + maxLen: 1024, + timeoutMillis: 1234, + maxTotalTimeSeconds: 42, + artifactPrefix: "/tmp/artifacts/", + corpusDirectories: ["corpus-main", "corpus-seed"], + dictionaryFiles: [], + }); + }); + + it("rejects unsupported options in LibAFL mode", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + fuzzerOptions: ["-fork=1"], + }, + OptionSource.CommandLineArguments, + ); + + expect(() => buildLibAflOptions(manager)).toThrow("not supported"); + }); + + it("supports regression mode in LibAFL mode", () => { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + mode: "regression", + fuzzerOptions: ["corpus", "-runs=1"], + }, + OptionSource.CommandLineArguments, + ); + + expect(buildLibAflOptions(manager)).toEqual({ + mode: "regression", + runs: 0, + seed: 0, + maxLen: 4096, + timeoutMillis: 5000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: ["corpus"], + dictionaryFiles: [], + }); + }); + + it("supports dictionary entries in LibAFL mode", () => { + const tempDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), "jazzer-libafl-dict-"), + ); + const dictionaryPath = path.join(tempDirectory, "seed.dict"); + fs.writeFileSync(dictionaryPath, '"Amazing"\n'); + + try { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions) + .merge( + { + engine: "libafl", + fuzzerOptions: ["corpus", `-dict=${dictionaryPath}`], + }, + OptionSource.CommandLineArguments, + ) + .merge( + { dictionaryEntries: ["banana"] }, + OptionSource.JestFuzzTestOptions, + ); + + const built = buildLibAflOptions(manager); + expect(built.corpusDirectories).toEqual(["corpus"]); + expect(built.dictionaryFiles).toHaveLength(1); + expect(fs.readFileSync(built.dictionaryFiles[0], "utf8")).toContain( + "\\x62\\x61\\x6e\\x61\\x6e\\x61", + ); + expect(fs.readFileSync(built.dictionaryFiles[0], "utf8")).toContain( + "Amazing", + ); + } finally { + fs.rmSync(tempDirectory, { force: true, recursive: true }); + } + }); + + it("rejects malformed LibAFL integer flags", () => { + for (const option of ["-runs=1abc", "-max_len=1.5", "-seed="]) { + const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( + { + engine: "libafl", + fuzzerOptions: [option], + }, + OptionSource.CommandLineArguments, + ); + + expect(() => buildLibAflOptions(manager)).toThrow(); + } }); }); diff --git a/packages/core/options.ts b/packages/core/options.ts index 6c82965ab..b8a3aa3c7 100644 --- a/packages/core/options.ts +++ b/packages/core/options.ts @@ -21,6 +21,7 @@ import * as tmp from "tmp"; import { type OptionsManager, + resolveEngine, toOptionsWithPrintableSources, } from "@jazzer.js/options"; @@ -28,14 +29,24 @@ import { useDictionaryByParams } from "./dictionary"; export * from "@jazzer.js/options"; -export function buildFuzzerOption(options: OptionsManager) { +export type LibAflOptions = { + mode: "fuzzing" | "regression"; + runs: number; + seed: number; + maxLen: number; + timeoutMillis: number; + maxTotalTimeSeconds: number; + artifactPrefix: string; + corpusDirectories: string[]; + dictionaryFiles: string[]; +}; + +export function buildLibFuzzerOptions(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); @@ -43,6 +54,138 @@ export function buildFuzzerOption(options: OptionsManager) { return params; } +export const buildFuzzerOption = buildLibFuzzerOptions; + +export function buildLibAflOptions(options: OptionsManager): LibAflOptions { + if (options.get("timeout") <= 0) { + throw new Error("timeout must be > 0"); + } + + const normalizedFuzzerOptions = useDictionaryByParams( + options.get("fuzzerOptions"), + options.get("dictionaryEntries"), + ); + + let runs = 0; + let seed = 0; + let maxLen = 4096; + let maxTotalTimeSeconds = 0; + let artifactPrefix = ""; + const corpusDirectories: string[] = []; + const dictionaryFiles: string[] = []; + + for (const option of normalizedFuzzerOptions) { + if (!option.startsWith("-")) { + corpusDirectories.push(option); + continue; + } + + if (option.startsWith("-runs=")) { + runs = parsePositiveOrZeroInteger("runs", option.substring(6)); + continue; + } + if (option.startsWith("-seed=")) { + seed = parsePositiveOrZeroInteger("seed", option.substring(6)); + continue; + } + if (option.startsWith("-max_len=")) { + maxLen = parsePositiveInteger("max_len", option.substring(9)); + continue; + } + if (option.startsWith("-max_total_time=")) { + maxTotalTimeSeconds = parsePositiveOrZeroInteger( + "max_total_time", + option.substring(16), + ); + continue; + } + if (option.startsWith("-artifact_prefix=")) { + artifactPrefix = option.substring(17); + continue; + } + if (option.startsWith("-dict=")) { + dictionaryFiles.splice(0, dictionaryFiles.length, option.substring(6)); + continue; + } + + throw new Error( + `Option '${option}' is not supported by the '${resolveEngine(options.get("engine"))}' engine`, + ); + } + + if (options.get("mode") === "regression") { + runs = 0; + } + + printOptions(options); + if (process.env.JAZZER_DEBUG) { + console.error( + `DEBUG: [core] LibAFL options: ${JSON.stringify( + { + mode: options.get("mode"), + runs, + seed, + maxLen, + maxTotalTimeSeconds, + timeoutMillis: options.get("timeout"), + artifactPrefix, + corpusDirectories, + dictionaryFiles, + }, + null, + 2, + )}`, + ); + } + + return { + mode: options.get("mode"), + runs, + seed, + maxLen, + timeoutMillis: options.get("timeout"), + maxTotalTimeSeconds, + artifactPrefix, + corpusDirectories, + dictionaryFiles, + }; +} + +function parsePositiveInteger(name: string, value: string): number { + const parsed = parseDecimalInteger(name, value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + `Option '${name}' must be a positive integer, got '${value}'`, + ); + } + return parsed; +} + +function parsePositiveOrZeroInteger(name: string, value: string): number { + const parsed = parseDecimalInteger(name, value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error( + `Option '${name}' must be a non-negative integer, got '${value}'`, + ); + } + return parsed; +} + +function parseDecimalInteger(name: string, value: string): number { + if (!/^\d+$/.test(value)) { + throw new Error(`Option '${name}' must be an integer, got '${value}'`); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new Error( + `Option '${name}' must fit into a safe integer, got '${value}'`, + ); + } + + return parsed; +} + export function printOptions(options: OptionsManager, infix = "") { if (process.env.JAZZER_DEBUG) { console.error( @@ -73,7 +216,6 @@ function optionDependentParams( let opts = options.get("fuzzerOptions"); if (options.get("mode") === "regression") { - // The last provided option takes precedence opts = opts.concat("-runs=0"); } diff --git a/packages/core/utils.test.ts b/packages/core/utils.test.ts index 703364c79..e17a6d12c 100644 --- a/packages/core/utils.test.ts +++ b/packages/core/utils.test.ts @@ -36,6 +36,18 @@ describe("core", () => { }); }); describe("prepareArgs", () => { + it("does not add an undefined engine", () => { + const args = { + _: ["-some_arg=value"], + corpus: [], + fuzzTarget: "filename.js", + }; + const options = prepareArgs(args); + expect( + Object.prototype.hasOwnProperty.call(options, "engine"), + ).toBeFalsy(); + }); + it("converts fuzzer args to strings", () => { const args = { _: ["-some_arg=value", "-other_arg", 123], @@ -54,5 +66,16 @@ describe("core", () => { ], }); }); + + it("normalizes engine alias", () => { + const args = { + _: [], + corpus: [], + engine: "afl", + fuzzTarget: "filename.js", + }; + const options = prepareArgs(args); + expect(options.engine).toBe("libafl"); + }); }); }); diff --git a/packages/core/utils.ts b/packages/core/utils.ts index 5d439583e..4bca53ab6 100644 --- a/packages/core/utils.ts +++ b/packages/core/utils.ts @@ -74,6 +74,11 @@ export function prepareArgs(args: any) { .concat(args._) .map((e: unknown) => e + ""), }; + if (options.engine !== undefined) { + options.engine = options.engine === "afl" ? "libafl" : options.engine; + } else { + delete options.engine; + } if (options.fuzzerOptions.length === 0) { delete options.fuzzerOptions; } diff --git a/packages/fuzzer/CMakeLists.txt b/packages/fuzzer/CMakeLists.txt index 0331affaa..9dad180e0 100644 --- a/packages/fuzzer/CMakeLists.txt +++ b/packages/fuzzer/CMakeLists.txt @@ -3,6 +3,7 @@ cmake_minimum_required(VERSION 3.15) project(jazzerjs) find_package(Patch REQUIRED) +find_program(CARGO_EXECUTABLE cargo REQUIRED) set(CMAKE_CXX_STANDARD 17) # mostly supported since GCC 7 set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -72,6 +73,55 @@ set_target_properties(${PROJECT_NAME} PROPERTIES PREFIX "" SUFFIX ".node") target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_JS_INC}) target_link_libraries(${PROJECT_NAME} ${CMAKE_JS_LIB}) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(RUST_TARGET_TRIPLE "aarch64-unknown-linux-gnu") + else() + set(RUST_TARGET_TRIPLE "x86_64-unknown-linux-gnu") + endif() + set(RUST_STATICLIB_NAME "libjazzerjs_libafl_runtime.a") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + if(CMAKE_OSX_ARCHITECTURES STREQUAL "x86_64") + set(RUST_TARGET_TRIPLE "x86_64-apple-darwin") + elseif(CMAKE_OSX_ARCHITECTURES STREQUAL "arm64") + set(RUST_TARGET_TRIPLE "aarch64-apple-darwin") + elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") + set(RUST_TARGET_TRIPLE "aarch64-apple-darwin") + else() + set(RUST_TARGET_TRIPLE "x86_64-apple-darwin") + endif() + set(RUST_STATICLIB_NAME "libjazzerjs_libafl_runtime.a") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(RUST_TARGET_TRIPLE "x86_64-pc-windows-msvc") + set(RUST_STATICLIB_NAME "jazzerjs_libafl_runtime.lib") +endif() + +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CARGO_PROFILE_DIR "debug") + set(CARGO_PROFILE_FLAG "") +else() + set(CARGO_PROFILE_DIR "release") + set(CARGO_PROFILE_FLAG "--release") +endif() + +set(RUST_CRATE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/rust") +set(RUST_TARGET_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargo-target") +set(RUST_STATICLIB_PATH + "${RUST_TARGET_DIR}/${RUST_TARGET_TRIPLE}/${CARGO_PROFILE_DIR}/${RUST_STATICLIB_NAME}") + +add_custom_command( + OUTPUT ${RUST_STATICLIB_PATH} + COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${RUST_TARGET_DIR} + ${CARGO_EXECUTABLE} build --manifest-path ${RUST_CRATE_DIR}/Cargo.toml + --target ${RUST_TARGET_TRIPLE} ${CARGO_PROFILE_FLAG} + WORKING_DIRECTORY ${RUST_CRATE_DIR} + DEPENDS ${RUST_CRATE_DIR}/Cargo.toml ${RUST_CRATE_DIR}/src/lib.rs + COMMENT "Building the LibAFL runtime static library") + +add_custom_target(jazzerjs_libafl_runtime ALL DEPENDS ${RUST_STATICLIB_PATH}) +add_dependencies(${PROJECT_NAME} jazzerjs_libafl_runtime) +target_link_libraries(${PROJECT_NAME} ${RUST_STATICLIB_PATH}) + # We're not sure why but sometimes systems don't end up setting LLVM_TARGET_TRIPLE used in llvm's cmake to eventually # set COMPILER_RT_DEFAULT_TARGET which is necessary for compiler-rt to build # So this will either take it from an envvar or try to set it to a sane value until we can figure out why it's broken diff --git a/packages/fuzzer/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..c2d9624cf 100644 --- a/packages/fuzzer/addon.ts +++ b/packages/fuzzer/addon.ts @@ -27,6 +27,18 @@ export type FuzzTargetCallback = ( export type FuzzTarget = FuzzTargetAsyncOrValue | FuzzTargetCallback; export type FuzzOpts = string[]; +export type LibAflOptions = { + mode: "fuzzing" | "regression"; + runs: number; + seed: number; + maxLen: number; + timeoutMillis: number; + maxTotalTimeSeconds: number; + artifactPrefix: string; + corpusDirectories: string[]; + dictionaryFiles: string[]; +}; + export type StartFuzzingSyncFn = ( fuzzFn: FuzzTarget, fuzzOpts: FuzzOpts, @@ -36,6 +48,15 @@ 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; @@ -67,6 +88,15 @@ type NativeAddon = { startFuzzing: StartFuzzingSyncFn; startFuzzingAsync: StartFuzzingAsyncFn; + startLibAfl?: StartLibAflSyncFn; + startLibAflAsync?: StartLibAflAsyncFn; + clearCompareFeedbackMap: () => void; + countNonZeroCompareFeedbackSlots: () => number; +}; + +type LoadedAddon = NativeAddon & { + startLibAfl: StartLibAflSyncFn; + startLibAflAsync: StartLibAflAsyncFn; }; function addonFilename(): string { @@ -81,4 +111,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.ts b/packages/fuzzer/fuzzer.ts index 1330bb44a..a00da42d2 100644 --- a/packages/fuzzer/fuzzer.ts +++ b/packages/fuzzer/fuzzer.ts @@ -22,6 +22,7 @@ export type { FuzzTarget, FuzzTargetAsyncOrValue, FuzzTargetCallback, + LibAflOptions, } from "./addon"; export interface Fuzzer { @@ -29,6 +30,8 @@ export interface Fuzzer { tracer: Tracer; startFuzzing: typeof addon.startFuzzing; startFuzzingAsync: typeof addon.startFuzzingAsync; + startLibAfl: typeof addon.startLibAfl; + startLibAflAsync: typeof addon.startLibAflAsync; printAndDumpCrashingInput: typeof addon.printAndDumpCrashingInput; printReturnInfo: typeof addon.printReturnInfo; } @@ -38,6 +41,8 @@ export const fuzzer: Fuzzer = { tracer: tracer, startFuzzing: addon.startFuzzing, startFuzzingAsync: addon.startFuzzingAsync, + startLibAfl: addon.startLibAfl, + startLibAflAsync: addon.startLibAflAsync, printAndDumpCrashingInput: addon.printAndDumpCrashingInput, printReturnInfo: addon.printReturnInfo, }; diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp new file mode 100644 index 000000000..f4718bcca --- /dev/null +++ b/packages/fuzzer/libafl_runtime.cpp @@ -0,0 +1,1128 @@ +// Copyright 2026 Code Intelligence GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "libafl_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#define GetPID _getpid +#else +#include +#define GetPID getpid +#endif + +#include "shared/coverage.h" +#include "shared/libfuzzer.h" +#include "shared/tracing.h" +#include "utils.h" + +namespace { +constexpr int kExecutionContinue = 0; +constexpr int kExecutionFinding = 1; +constexpr int kExecutionStop = 2; +constexpr int kExecutionFatal = 3; +constexpr int kExecutionTimeout = 4; + +constexpr int kRuntimeOk = 0; +constexpr int kRuntimeFoundFinding = 1; +constexpr int kRuntimeStopped = 2; +constexpr int kRuntimeFatal = 3; +constexpr int kRuntimeFoundTimeout = 4; + +struct ParsedRuntimeOptions { + enum class Mode { + kFuzzing, + kRegression, + }; + + Mode mode = Mode::kFuzzing; + uint64_t runs = 0; + uint64_t seed = 1; + size_t max_len = 4096; + uint64_t timeout_millis = 5000; + uint64_t max_total_time_seconds = 0; + std::string artifact_prefix; + std::vector corpus_directories; + std::vector dictionary_files; +}; + +std::string FormatDuration(std::chrono::steady_clock::duration duration) { + const auto total_seconds = + std::chrono::duration_cast(duration).count(); + const auto hours = total_seconds / 3600; + const auto minutes = (total_seconds % 3600) / 60; + const auto seconds = total_seconds % 60; + + std::ostringstream stream; + if (hours > 0) { + stream << hours << "h " << minutes << "m " << seconds << "s"; + } else if (minutes > 0) { + stream << minutes << "m " << seconds << "s"; + } else { + stream << seconds << "s"; + } + return stream.str(); +} + +std::string FormatRunLimit(uint64_t runs) { + if (runs == 0) { + return "unlimited"; + } + + return std::to_string(runs); +} + +std::string FormatTotalTimeLimit(uint64_t max_total_time_seconds) { + if (max_total_time_seconds == 0) { + return "unlimited"; + } + + return FormatDuration(std::chrono::seconds(max_total_time_seconds)); +} + +void PrintRegressionStart(const ParsedRuntimeOptions &options, + size_t replay_inputs) { + std::cerr << "[libafl::start] mode: regression, seed: " << options.seed + << ", replay_inputs: " << replay_inputs + << ", timeout: " << options.timeout_millis + << " ms, max_len: " << options.max_len + << ", runs: " << FormatRunLimit(options.runs) + << ", max_total_time: " + << FormatTotalTimeLimit(options.max_total_time_seconds) + << std::endl; +} + +void PrintRegressionDone(std::chrono::steady_clock::time_point started_at, + uint64_t executions, size_t replay_inputs) { + const auto elapsed = std::chrono::steady_clock::now() - started_at; + const auto elapsed_seconds = std::chrono::duration(elapsed).count(); + const auto execs_per_sec = elapsed_seconds > 0.0 + ? executions / elapsed_seconds + : static_cast(executions); + + std::cerr << "[libafl::done] mode: regression, run time: " + << FormatDuration(elapsed) << ", replay_inputs: " << replay_inputs + << ", executions: " << executions + << ", exec/sec: " << static_cast(execs_per_sec) + << std::endl; +} + +struct SyncWatchdogState { + std::thread thread; + std::mutex mutex; + std::condition_variable cv; + bool should_stop = false; + bool execution_armed = false; + std::chrono::steady_clock::time_point deadline; + std::vector current_input; +}; + +struct SyncFuzzTargetContext { + SyncFuzzTargetContext(Napi::Env env, Napi::Function target, + Napi::Function js_stop_callback, + ParsedRuntimeOptions options) + : env(env), target(target), is_resolved(false), + deferred(Napi::Promise::Deferred::New(env)), + js_stop_callback(js_stop_callback), options(std::move(options)) {} + + Napi::Env env; + Napi::Function target; + bool is_resolved; + Napi::Promise::Deferred deferred; + Napi::Function js_stop_callback; + ParsedRuntimeOptions options; + SyncWatchdogState watchdog; + volatile std::sig_atomic_t signal_status = 0; + volatile int sigints = 0; + std::jmp_buf execution_context; +}; + +struct AsyncExecutionState { + std::promise promise; + std::atomic settled = false; +}; + +struct AsyncDataType { + std::vector data; + std::shared_ptr state; + + AsyncDataType() = delete; +}; + +struct AsyncFuzzTargetContext { + explicit AsyncFuzzTargetContext(Napi::Env env, ParsedRuntimeOptions options) + : deferred(Napi::Promise::Deferred::New(env)), + options(std::move(options)) {} + + std::thread native_thread; + Napi::Promise::Deferred deferred; + ParsedRuntimeOptions options; + bool is_resolved = false; + bool is_done_called = false; + int run_status = kRuntimeOk; + volatile int sigints = 0; + std::jmp_buf execution_context; +}; + +using AsyncFinalizerDataType = void; +void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, + AsyncFuzzTargetContext *context, AsyncDataType *data); +using AsyncTsfn = + Napi::TypedThreadSafeFunction; + +SyncFuzzTargetContext *gActiveSyncContext = nullptr; +AsyncFuzzTargetContext *gActiveAsyncContext = nullptr; +AsyncTsfn gAsyncTsfn; + +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); +} + +void WriteArtifact(const std::string &artifact_prefix, const std::string &kind, + const uint8_t *data, size_t size) { + 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; + } + + std::cerr << "INFO: Wrote " << kind << " input to " + << artifact_path.string() << std::endl; + } catch (const std::exception &exception) { + std::cerr << "ERROR: Failed to persist " << kind + << " artifact: " << exception.what() << std::endl; + } +} + +[[noreturn]] void ExitOnTimeout(uint64_t timeout_millis, + const std::string &artifact_prefix, + const std::vector &input) { + std::cerr << "ERROR: Exceeded timeout of " << timeout_millis + << " ms for one fuzz target execution." << std::endl; + WriteArtifact(artifact_prefix, "timeout", input.data(), input.size()); + _Exit(libfuzzer::EXIT_ERROR_TIMEOUT); +} + +[[noreturn]] void ExitWithUnexpectedError(const std::exception &exception) { + std::cerr << "==" << static_cast(GetPID()) + << "== Jazzer.js: Unexpected Error: " << exception.what() + << std::endl; + libfuzzer::PrintCrashingInput(); + _Exit(libfuzzer::EXIT_ERROR_CODE); +} + +void RejectDeferredIfNeeded(AsyncFuzzTargetContext *context, + const Napi::Value &error) { + if (context->is_resolved) { + return; + } + context->deferred.Reject(error); + context->is_resolved = true; +} + +bool TrySetExecutionStatus(const std::shared_ptr &state, + int status) { + bool expected = false; + if (!state->settled.compare_exchange_strong(expected, true, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + return false; + } + state->promise.set_value(status); + return true; +} + +void ReportAsyncFinding(AsyncFuzzTargetContext *context, Napi::Env env, + const std::shared_ptr &state, + const Napi::Value &error, + const std::vector &input) { + if (TrySetExecutionStatus(state, kExecutionFinding)) { + WriteArtifact(context->options.artifact_prefix, "crash", input.data(), + input.size()); + } + RejectDeferredIfNeeded(context, error); +} + +ParsedRuntimeOptions ParseRuntimeOptions(Napi::Env env, + const Napi::Object &js_opts) { + ParsedRuntimeOptions parsed; + + const auto mode = js_opts.Get("mode"); + const auto runs = js_opts.Get("runs"); + const auto seed = js_opts.Get("seed"); + const auto max_len = js_opts.Get("maxLen"); + const auto timeout_millis = js_opts.Get("timeoutMillis"); + const auto max_total_time_seconds = js_opts.Get("maxTotalTimeSeconds"); + const auto artifact_prefix = js_opts.Get("artifactPrefix"); + const auto corpus_directories = js_opts.Get("corpusDirectories"); + const auto dictionary_files = js_opts.Get("dictionaryFiles"); + + if (!mode.IsUndefined() && !mode.IsString()) { + throw Napi::Error::New( + env, "The LibAFL options object expects mode to be 'fuzzing' or " + "'regression'"); + } + + if (!runs.IsNumber() || !seed.IsNumber() || !max_len.IsNumber() || + !timeout_millis.IsNumber() || !max_total_time_seconds.IsNumber() || + !artifact_prefix.IsString() || !corpus_directories.IsArray() || + !dictionary_files.IsArray()) { + throw Napi::Error::New( + env, "The LibAFL backend expects an options object with mode, runs, " + "seed, maxLen, timeoutMillis, maxTotalTimeSeconds, " + "artifactPrefix, corpusDirectories, and dictionaryFiles"); + } + + if (mode.IsString()) { + const auto mode_value = mode.As().Utf8Value(); + if (mode_value == "regression") { + parsed.mode = ParsedRuntimeOptions::Mode::kRegression; + } else if (mode_value == "fuzzing") { + parsed.mode = ParsedRuntimeOptions::Mode::kFuzzing; + } else { + throw Napi::Error::New( + env, "The LibAFL options object expects mode to be 'fuzzing' or " + "'regression'"); + } + } + + const auto runs_value = runs.As().Int64Value(); + const auto seed_value = seed.As().Int64Value(); + const auto max_len_value = max_len.As().Int64Value(); + const auto timeout_millis_value = + timeout_millis.As().Int64Value(); + const auto max_total_time_seconds_value = + max_total_time_seconds.As().Int64Value(); + + if (runs_value < 0 || seed_value < 0 || max_len_value < 0 || + timeout_millis_value < 0 || max_total_time_seconds_value < 0) { + throw Napi::Error::New( + env, "The LibAFL options object does not allow negative values"); + } + + parsed.runs = static_cast(runs_value); + parsed.seed = static_cast(seed_value); + parsed.max_len = static_cast(max_len_value); + parsed.timeout_millis = static_cast(timeout_millis_value); + parsed.max_total_time_seconds = + static_cast(max_total_time_seconds_value); + parsed.artifact_prefix = artifact_prefix.As().Utf8Value(); + + const auto dirs = corpus_directories.As(); + for (uint32_t i = 0; i < dirs.Length(); ++i) { + auto dir = dirs.Get(i); + if (!dir.IsString()) { + throw Napi::Error::New( + env, "LibAFL corpusDirectories entries must be strings"); + } + parsed.corpus_directories.push_back(dir.As().Utf8Value()); + } + + const auto dicts = dictionary_files.As(); + for (uint32_t i = 0; i < dicts.Length(); ++i) { + auto dict = dicts.Get(i); + if (!dict.IsString()) { + throw Napi::Error::New(env, + "LibAFL dictionaryFiles entries must be strings"); + } + parsed.dictionary_files.push_back(dict.As().Utf8Value()); + } + + if (parsed.max_len == 0) { + throw Napi::Error::New(env, "The LibAFL backend requires maxLen to be > 0"); + } + if (parsed.timeout_millis == 0) { + throw Napi::Error::New( + env, "The LibAFL backend requires timeoutMillis to be > 0"); + } + + return parsed; +} + +JazzerLibAflRuntimeSharedMaps SharedMapsForRuntime(Napi::Env env) { + auto *edges = CoverageCounters(); + const auto edges_len = CoverageCountersSize(); + auto *cmp = CompareFeedbackMap(); + const auto cmp_len = CompareFeedbackMapSize(); + + if (edges == nullptr || edges_len == 0 || cmp == nullptr || cmp_len == 0) { + throw Napi::Error::New( + env, + "Coverage maps were not initialized before the LibAFL backend started"); + } + + return {edges, edges_len, cmp, cmp_len}; +} + +bool CollectRegressionCorpusFiles( + const std::vector &corpus_directories, + std::vector *files) { + for (const auto &directory : corpus_directories) { + const std::filesystem::path directory_path(directory); + std::error_code error; + + if (!std::filesystem::exists(directory_path, error)) { + if (error) { + std::cerr << "[libafl] fatal: failed to access corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + } else { + std::cerr << "[libafl] fatal: corpus directory does not exist: '" + << directory_path.string() << "'" << std::endl; + } + return false; + } + + if (!std::filesystem::is_directory(directory_path, error)) { + if (error) { + std::cerr << "[libafl] fatal: failed to inspect corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + } else { + std::cerr << "[libafl] fatal: corpus path is not a directory: '" + << directory_path.string() << "'" << std::endl; + } + return false; + } + + std::filesystem::recursive_directory_iterator iterator( + directory_path, + std::filesystem::directory_options::skip_permission_denied, error); + const auto end = std::filesystem::recursive_directory_iterator(); + if (error) { + std::cerr << "[libafl] fatal: failed to iterate corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + return false; + } + + for (; iterator != end; iterator.increment(error)) { + if (error) { + std::cerr << "[libafl] fatal: failed to iterate corpus directory '" + << directory_path.string() << "': " << error.message() + << std::endl; + return false; + } + + const auto is_regular_file = iterator->is_regular_file(error); + if (error) { + std::cerr << "[libafl] fatal: failed to inspect corpus entry '" + << iterator->path().string() << "': " << error.message() + << std::endl; + return false; + } + if (is_regular_file) { + files->push_back(iterator->path()); + } + } + } + + std::sort(files->begin(), files->end()); + return true; +} + +bool ReadRegressionInput(const std::filesystem::path &file_path, size_t max_len, + std::vector *input) { + input->clear(); + std::ifstream stream(file_path, std::ios::binary); + if (!stream.is_open()) { + std::cerr << "[libafl] fatal: failed to open corpus input '" + << file_path.string() << "'" << std::endl; + return false; + } + + constexpr size_t kChunkSize = 4096; + std::array buffer{}; + while (stream.good() && input->size() < max_len) { + const auto remaining = max_len - input->size(); + const auto to_read = static_cast( + std::min(remaining, buffer.size())); + stream.read(buffer.data(), to_read); + const auto bytes_read = stream.gcount(); + if (bytes_read <= 0) { + break; + } + input->insert(input->end(), buffer.begin(), buffer.begin() + bytes_read); + } + + if (stream.bad()) { + std::cerr << "[libafl] fatal: failed to read corpus input '" + << file_path.string() << "'" << std::endl; + return false; + } + + return true; +} + +bool ReachedMaxTotalTime(const ParsedRuntimeOptions &options, + std::chrono::steady_clock::time_point started_at) { + if (options.max_total_time_seconds == 0) { + return false; + } + return std::chrono::steady_clock::now() - started_at >= + std::chrono::seconds(options.max_total_time_seconds); +} + +int ReplayRegressionInputs( + const ParsedRuntimeOptions &options, + const std::function &execute_one) { + std::vector corpus_files; + if (!CollectRegressionCorpusFiles(options.corpus_directories, + &corpus_files)) { + return kRuntimeFatal; + } + + const auto started_at = std::chrono::steady_clock::now(); + const auto replay_inputs = corpus_files.size() + 1; + uint64_t executions = 0; + static constexpr uint8_t kEmptyInputByte = 0; + std::vector current_input; + + PrintRegressionStart(options, replay_inputs); + + auto execute_input = [&](const uint8_t *data, size_t size) -> int { + if (options.runs != 0 && executions >= options.runs) { + return kRuntimeOk; + } + if (ReachedMaxTotalTime(options, started_at)) { + return kRuntimeStopped; + } + + const auto status = execute_one(data, size); + executions++; + switch (status) { + case kExecutionContinue: + return kRuntimeOk; + case kExecutionFinding: + return kRuntimeFoundFinding; + case kExecutionStop: + return kRuntimeStopped; + case kExecutionFatal: + return kRuntimeFatal; + case kExecutionTimeout: + return kRuntimeFoundTimeout; + default: + std::cerr << "[libafl] fatal: unknown execution status: " << status + << std::endl; + return kRuntimeFatal; + } + }; + + auto status = execute_input(&kEmptyInputByte, 0); + if (status != kRuntimeOk) { + if (status == kRuntimeStopped) { + PrintRegressionDone(started_at, executions, replay_inputs); + } + return status; + } + + for (const auto &file_path : corpus_files) { + if (!ReadRegressionInput(file_path, options.max_len, ¤t_input)) { + return kRuntimeFatal; + } + + const auto *data = + current_input.empty() ? &kEmptyInputByte : current_input.data(); + status = execute_input(data, current_input.size()); + if (status != kRuntimeOk) { + if (status == kRuntimeStopped) { + PrintRegressionDone(started_at, executions, replay_inputs); + } + return status; + } + } + + PrintRegressionDone(started_at, executions, replay_inputs); + return kRuntimeOk; +} + +void StartSyncWatchdog(SyncFuzzTargetContext *context) { + if (context->options.timeout_millis == 0) { + return; + } + + context->watchdog.thread = std::thread([context]() { + auto &watchdog = context->watchdog; + std::unique_lock lock(watchdog.mutex); + while (true) { + watchdog.cv.wait(lock, [&watchdog] { + return watchdog.should_stop || watchdog.execution_armed; + }); + + if (watchdog.should_stop) { + return; + } + + const auto deadline = watchdog.deadline; + const auto resumed = + watchdog.cv.wait_until(lock, deadline, [&watchdog, deadline] { + return watchdog.should_stop || !watchdog.execution_armed || + watchdog.deadline != deadline; + }); + if (resumed) { + if (watchdog.should_stop) { + return; + } + continue; + } + + auto timed_out_input = watchdog.current_input; + lock.unlock(); + ExitOnTimeout(context->options.timeout_millis, + context->options.artifact_prefix, timed_out_input); + } + }); +} + +void ArmSyncWatchdog(SyncFuzzTargetContext *context, const uint8_t *data, + size_t size) { + if (context->options.timeout_millis == 0) { + return; + } + + auto &watchdog = context->watchdog; + std::lock_guard lock(watchdog.mutex); + watchdog.current_input.assign(data, data + size); + watchdog.deadline = + std::chrono::steady_clock::now() + + std::chrono::milliseconds(context->options.timeout_millis); + watchdog.execution_armed = true; + watchdog.cv.notify_one(); +} + +void DisarmSyncWatchdog(SyncFuzzTargetContext *context) { + if (context->options.timeout_millis == 0) { + return; + } + + auto &watchdog = context->watchdog; + std::lock_guard lock(watchdog.mutex); + watchdog.execution_armed = false; + watchdog.current_input.clear(); + watchdog.cv.notify_one(); +} + +void StopSyncWatchdog(SyncFuzzTargetContext *context) { + if (context->options.timeout_millis == 0) { + return; + } + + auto &watchdog = context->watchdog; + { + std::lock_guard lock(watchdog.mutex); + watchdog.should_stop = true; + watchdog.execution_armed = false; + } + watchdog.cv.notify_one(); + if (watchdog.thread.joinable()) { + watchdog.thread.join(); + } +} + +class ScopedSyncWatchdog { +public: + ScopedSyncWatchdog(SyncFuzzTargetContext *context, const uint8_t *data, + size_t size) + : context_(context) { + ArmSyncWatchdog(context_, data, size); + } + + ~ScopedSyncWatchdog() { DisarmSyncWatchdog(context_); } + +private: + SyncFuzzTargetContext *context_; +}; + +void SyncSigintHandler(int signum) { + std::cerr << std::endl; + gActiveSyncContext->signal_status = signum; + if (gActiveSyncContext->sigints > 0) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + gActiveSyncContext->sigints++; +} + +void SyncErrorSignalHandler(int signum) { + gActiveSyncContext->signal_status = signum; + std::longjmp(gActiveSyncContext->execution_context, signum); +} + +int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { + auto *context = static_cast(user_data); + auto scope = Napi::HandleScope(context->env); + ScopedSyncWatchdog watchdog(context, data, size); + + ClearCoverageCounters(); + ClearCompareFeedbackMap(); + + try { + auto buffer = Napi::Buffer::Copy(context->env, data, size); + if (setjmp(context->execution_context) == 0) { + auto result = context->target.Call({buffer}); + if (result.IsPromise()) { + AsyncReturnsHandler(); + } else { + SyncReturnsHandler(); + } + } + } catch (const Napi::Error &error) { + WriteArtifact(context->options.artifact_prefix, "crash", data, size); + context->is_resolved = true; + context->deferred.Reject(error.Value()); + return kExecutionFinding; + } catch (const std::exception &exception) { + ExitWithUnexpectedError(exception); + } + + if (context->signal_status != 0) { + if (context->signal_status == SIGSEGV) { + std::cerr << "==" << static_cast(GetPID()) + << "== Segmentation Fault" << std::endl; + libfuzzer::PrintCrashingInput(); + _Exit(libfuzzer::EXIT_ERROR_SEGV); + } + + auto exit_code = Napi::Number::New(context->env, 0); + if (context->signal_status != SIGINT) { + exit_code = Napi::Number::New(context->env, context->signal_status); + } + + context->js_stop_callback.Call({exit_code}); + context->signal_status = 0; + return kExecutionStop; + } + + return kExecutionContinue; +} + +void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, + AsyncFuzzTargetContext *context, AsyncDataType *input) { + auto state = input->state; + const auto current_input = input->data; + + try { + if (context->sigints > 0) { + TrySetExecutionStatus(state, kExecutionStop); + context->deferred.Resolve(env.Undefined()); + context->is_resolved = true; + return; + } + + if (setjmp(context->execution_context) == SIGSEGV) { + std::cerr << "==" << static_cast(GetPID()) + << "== Segmentation Fault" << std::endl; + libfuzzer::PrintCrashingInput(); + _Exit(libfuzzer::EXIT_ERROR_SEGV); + } + + if (env == nullptr) { + TrySetExecutionStatus(state, kExecutionFatal); + return; + } + + auto buffer = Napi::Buffer::Copy(env, current_input.data(), + current_input.size()); + auto parameter_count = js_fuzz_callback.As() + .Get("length") + .As() + .Int32Value(); + + if (parameter_count > 1) { + context->is_done_called = false; + auto done = Napi::Function::New(env, [=](const Napi::CallbackInfo &info) { + if (context->is_resolved) { + return; + } + + if (context->is_done_called) { + auto error = + Napi::Error::New(env, "Expected done to be called once, but it " + "was called multiple times.") + .Value(); + ReportAsyncFinding(context, env, state, error, current_input); + return; + } + + context->is_done_called = true; + const auto has_error = + info.Length() > 0 && !(info[0].IsNull() || info[0].IsUndefined()); + if (has_error) { + auto error = info[0]; + if (!error.IsObject()) { + error = Napi::Error::New(env, error.ToString()).Value(); + } + ReportAsyncFinding(context, env, state, error, current_input); + } else { + TrySetExecutionStatus(state, kExecutionContinue); + } + }); + + auto result = js_fuzz_callback.Call({buffer, done}); + if (result.IsPromise()) { + AsyncReturnsHandler(); + auto error = + Napi::Error::New(env, "Internal fuzzer error - Either async or " + "done callback based fuzz tests allowed.") + .Value(); + ReportAsyncFinding(context, env, state, error, current_input); + } else { + SyncReturnsHandler(); + } + return; + } + + auto result = js_fuzz_callback.Call({buffer}); + if (result.IsPromise()) { + AsyncReturnsHandler(); + auto js_promise = result.As(); + auto then = js_promise.Get("then").As(); + then.Call(js_promise, + {Napi::Function::New(env, + [=](const Napi::CallbackInfo &) { + TrySetExecutionStatus( + state, kExecutionContinue); + }), + Napi::Function::New(env, [=](const Napi::CallbackInfo &info) { + auto error = + info.Length() > 0 + ? info[0] + : Napi::Error::New(env, "Unknown promise rejection") + .Value(); + if (!error.IsObject()) { + error = Napi::Error::New(env, error.ToString()).Value(); + } + ReportAsyncFinding(context, env, state, error, + current_input); + })}); + } else { + SyncReturnsHandler(); + TrySetExecutionStatus(state, kExecutionContinue); + } + } catch (const Napi::Error &error) { + ReportAsyncFinding(context, env, state, error.Value(), current_input); + } catch (const std::exception &exception) { + TrySetExecutionStatus(state, kExecutionFatal); + auto message = + std::string("Internal fuzzer error - ").append(exception.what()); + RejectDeferredIfNeeded(context, Napi::Error::New(env, message).Value()); + } +} + +void AsyncSigintHandler(int signum) { + std::cerr << std::endl; + if (gActiveAsyncContext->sigints > 0) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + gActiveAsyncContext->sigints = signum; +} + +void AsyncErrorSignalHandler(int signum) { + std::longjmp(gActiveAsyncContext->execution_context, signum); +} + +int ExecuteAsyncInput(void *user_data, const uint8_t *data, size_t size) { + auto *context = static_cast(user_data); + + ClearCoverageCounters(); + ClearCompareFeedbackMap(); + + auto execution_state = std::make_shared(); + auto *input = new AsyncDataType{ + std::vector(data, data + size), + execution_state, + }; + + auto future = execution_state->promise.get_future(); + auto status = gAsyncTsfn.BlockingCall(input); + if (status != napi_ok) { + delete input; + Napi::Error::Fatal("StartLibAflAsync", + "TypedThreadSafeFunction.BlockingCall() failed"); + } + + if (context->options.timeout_millis > 0) { + auto timeout = std::chrono::milliseconds(context->options.timeout_millis); + if (future.wait_for(timeout) == std::future_status::timeout) { + ExitOnTimeout(context->options.timeout_millis, + context->options.artifact_prefix, input->data); + } + } + + try { + auto result = future.get(); + delete input; + return result; + } catch (const std::exception &exception) { + delete input; + ExitWithUnexpectedError(exception); + } +} +} // namespace + +Napi::Value StartLibAfl(const Napi::CallbackInfo &info) { + if (info.Length() != 3 || !info[0].IsFunction() || !info[1].IsObject() || + !info[2].IsFunction()) { + throw Napi::Error::New( + info.Env(), + "Need three arguments, which must be the fuzz target function, a " + "LibAFL options object, and a stop callback"); + } + + auto options = ParseRuntimeOptions(info.Env(), info[1].As()); + auto maps = SharedMapsForRuntime(info.Env()); + + SyncFuzzTargetContext context(info.Env(), info[0].As(), + info[2].As(), + std::move(options)); + gActiveSyncContext = &context; + + StartSyncWatchdog(&context); + signal(SIGINT, SyncSigintHandler); + signal(SIGSEGV, SyncErrorSignalHandler); + + auto status = kRuntimeOk; + if (context.options.mode == ParsedRuntimeOptions::Mode::kRegression) { + status = ReplayRegressionInputs( + context.options, [&context](const uint8_t *data, size_t size) { + return ExecuteSyncInput(&context, data, size); + }); + } else { + std::vector corpus_directories; + corpus_directories.reserve(context.options.corpus_directories.size()); + for (const auto &directory : context.options.corpus_directories) { + corpus_directories.push_back(directory.c_str()); + } + std::vector dictionary_files; + dictionary_files.reserve(context.options.dictionary_files.size()); + for (const auto &dictionary : context.options.dictionary_files) { + dictionary_files.push_back(dictionary.c_str()); + } + + JazzerLibAflRuntimeOptions runtime_options{ + context.options.runs, + context.options.seed, + context.options.max_len, + context.options.timeout_millis, + context.options.max_total_time_seconds, + corpus_directories.empty() ? nullptr : corpus_directories.data(), + corpus_directories.size(), + dictionary_files.empty() ? nullptr : dictionary_files.data(), + dictionary_files.size(), + }; + status = jazzer_libafl_runtime_run(&runtime_options, &maps, + ExecuteSyncInput, &context); + } + + signal(SIGINT, SIG_DFL); + signal(SIGSEGV, SIG_DFL); + StopSyncWatchdog(&context); + gActiveSyncContext = nullptr; + + if (status == kRuntimeFatal && !context.is_resolved) { + context.is_resolved = true; + context.deferred.Reject( + Napi::Error::New(info.Env(), "The LibAFL backend failed internally") + .Value()); + } else if (status == kRuntimeFoundTimeout && !context.is_resolved) { + context.is_resolved = true; + context.deferred.Reject( + Napi::Error::New(info.Env(), + "Exceeded timeout while executing one fuzz input") + .Value()); + } else if (status == kRuntimeFoundFinding && !context.is_resolved) { + context.is_resolved = true; + context.deferred.Reject( + Napi::Error::New(info.Env(), + "The LibAFL backend found a crashing input") + .Value()); + } + + if (!context.is_resolved) { + context.deferred.Resolve(context.env.Undefined()); + } + + return context.deferred.Promise(); +} + +Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info) { + if (info.Length() != 2 || !info[0].IsFunction() || !info[1].IsObject()) { + throw Napi::Error::New(info.Env(), + "Need two arguments, which must be the fuzz target " + "function and a LibAFL options object"); + } + + auto options = ParseRuntimeOptions(info.Env(), info[1].As()); + auto maps = SharedMapsForRuntime(info.Env()); + auto *context = new AsyncFuzzTargetContext(info.Env(), std::move(options)); + + gAsyncTsfn = AsyncTsfn::New( + info.Env(), info[0].As(), "LibAflAsyncAddon", 0, 1, + context, + [](Napi::Env env, AsyncFinalizerDataType *, AsyncFuzzTargetContext *ctx) { + ctx->native_thread.join(); + if (ctx->run_status == kRuntimeFatal && !ctx->is_resolved) { + ctx->deferred.Reject( + Napi::Error::New(env, "The LibAFL backend failed internally") + .Value()); + } else if (ctx->run_status == kRuntimeFoundTimeout && + !ctx->is_resolved) { + ctx->deferred.Reject( + Napi::Error::New( + env, "Exceeded timeout while executing one fuzz input") + .Value()); + } else if (ctx->run_status == kRuntimeFoundFinding && + !ctx->is_resolved) { + ctx->deferred.Reject( + Napi::Error::New(env, "The LibAFL backend found a crashing input") + .Value()); + } else if (!ctx->is_resolved) { + ctx->deferred.Resolve(env.Undefined()); + } + delete ctx; + }); + + context->native_thread = std::thread( + [maps](AsyncFuzzTargetContext *ctx) { + gActiveAsyncContext = ctx; + signal(SIGSEGV, AsyncErrorSignalHandler); + signal(SIGINT, AsyncSigintHandler); + + if (ctx->options.mode == ParsedRuntimeOptions::Mode::kRegression) { + ctx->run_status = ReplayRegressionInputs( + ctx->options, [ctx](const uint8_t *data, size_t size) { + return ExecuteAsyncInput(ctx, data, size); + }); + } else { + std::vector corpus_directories; + corpus_directories.reserve(ctx->options.corpus_directories.size()); + for (const auto &directory : ctx->options.corpus_directories) { + corpus_directories.push_back(directory.c_str()); + } + std::vector dictionary_files; + dictionary_files.reserve(ctx->options.dictionary_files.size()); + for (const auto &dictionary : ctx->options.dictionary_files) { + dictionary_files.push_back(dictionary.c_str()); + } + + JazzerLibAflRuntimeOptions runtime_options{ + ctx->options.runs, + ctx->options.seed, + ctx->options.max_len, + ctx->options.timeout_millis, + ctx->options.max_total_time_seconds, + corpus_directories.empty() ? nullptr : corpus_directories.data(), + corpus_directories.size(), + dictionary_files.empty() ? nullptr : dictionary_files.data(), + dictionary_files.size(), + }; + ctx->run_status = jazzer_libafl_runtime_run(&runtime_options, &maps, + ExecuteAsyncInput, ctx); + } + signal(SIGINT, SIG_DFL); + signal(SIGSEGV, SIG_DFL); + gActiveAsyncContext = nullptr; + gAsyncTsfn.Release(); + }, + context); + + return context->deferred.Promise(); +} diff --git a/packages/fuzzer/libafl_runtime.h b/packages/fuzzer/libafl_runtime.h new file mode 100644 index 000000000..c4440b498 --- /dev/null +++ b/packages/fuzzer/libafl_runtime.h @@ -0,0 +1,51 @@ +// 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 + +extern "C" { +struct JazzerLibAflRuntimeOptions { + uint64_t runs; + uint64_t seed; + size_t max_len; + uint64_t timeout_millis; + uint64_t max_total_time_seconds; + const char **corpus_directories; + size_t corpus_directories_len; + const char **dictionary_files; + size_t dictionary_files_len; +}; + +struct JazzerLibAflRuntimeSharedMaps { + uint8_t *edges; + size_t edges_len; + uint8_t *cmp; + size_t cmp_len; +}; + +typedef int (*JazzerLibAflExecuteCallback)(void *user_data, const uint8_t *data, + size_t size); + +int jazzer_libafl_runtime_run(const JazzerLibAflRuntimeOptions *options, + const JazzerLibAflRuntimeSharedMaps *maps, + JazzerLibAflExecuteCallback execute_one, + void *user_data); +} + +Napi::Value StartLibAfl(const Napi::CallbackInfo &info); +Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info); diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts new file mode 100644 index 000000000..d276c3ec3 --- /dev/null +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { addon } from "./addon"; +import { fuzzer } from "./fuzzer"; + +const libAflOptions = { + mode: "fuzzing" as const, + runs: 32, + seed: 1234, + maxLen: 64, + timeoutMillis: 1000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], +}; + +describe("LibAFL runtime", () => { + it("runs synchronous fuzz targets through the native runtime", async () => { + let invocations = 0; + + await addon.startLibAfl( + () => { + invocations++; + }, + libAflOptions, + () => undefined, + ); + + expect(invocations).toBeGreaterThan(0); + }); + + it("preserves async invocation ordering through the event loop", async () => { + let lastInvocationCount = 0; + let invocationCount = 1; + + await addon.startLibAflAsync(async () => { + const value = await new Promise((resolve) => { + queueMicrotask(() => { + setImmediate(() => resolve(invocationCount++)); + }); + }); + + if (value !== lastInvocationCount + 1) { + throw new Error( + `Invalid invocation order: received ${value}, last ${lastInvocationCount}`, + ); + } + + lastInvocationCount = value; + }, libAflOptions); + + expect(lastInvocationCount).toBeGreaterThan(0); + }); + + it("records compare feedback in the shared native map", async () => { + addon.clearCompareFeedbackMap(); + + await addon.startLibAfl( + (data: Buffer) => { + const text = data.toString("utf8"); + fuzzer.tracer.traceStrCmp(text, "jazzer", "===", 11); + fuzzer.tracer.traceNumberCmp(data.length, 7, "===", 12); + fuzzer.tracer.tracePcIndir(13, data.length); + }, + { + mode: "fuzzing", + runs: 1, + seed: 9, + maxLen: 16, + timeoutMillis: 1000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], + }, + () => undefined, + ); + + expect(addon.countNonZeroCompareFeedbackSlots()).toBeGreaterThan(0); + }); +}); diff --git a/packages/fuzzer/package.json b/packages/fuzzer/package.json index dd895e26c..53ce9191a 100644 --- a/packages/fuzzer/package.json +++ b/packages/fuzzer/package.json @@ -1,7 +1,7 @@ { "name": "@jazzer.js/fuzzer", "version": "4.0.0", - "description": "Jazzer.js libfuzzer-based fuzzer for Node.js", + "description": "Jazzer.js native fuzzing backends for Node.js", "homepage": "https://github.com/CodeIntelligenceTesting/jazzer.js#readme", "author": "Code Intelligence", "license": "Apache-2.0", @@ -18,6 +18,7 @@ "scripts": { "prebuild": "cmake-js build --out build", "build": "node ../../scripts/build-fuzzer.js", + "benchmark:libafl": "node runtime/benchmark.js", "format:fix": "clang-format -i *.cpp shared/*.cpp shared/*.h", "lint": "find . -path ./build -prune -type f -o -iname '*.h' -o -iname '*.cpp' | xargs clang-tidy" }, diff --git a/packages/fuzzer/runtime/benchmark.js b/packages/fuzzer/runtime/benchmark.js new file mode 100644 index 000000000..8b08482ee --- /dev/null +++ b/packages/fuzzer/runtime/benchmark.js @@ -0,0 +1,152 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { addon } = require("../dist/addon.js"); +const { fuzzer } = require("../dist/fuzzer.js"); + +const runs = Number(process.env.JAZZER_LIBAFL_RUNS ?? "20000"); +const seed = Number(process.env.JAZZER_LIBAFL_SEED ?? "1337"); +const maxLen = Number(process.env.JAZZER_LIBAFL_MAX_LEN ?? "64"); + +const libFuzzerArgs = [ + "jazzer-libfuzzer-benchmark", + `-runs=${runs}`, + `-seed=${seed}`, + `-max_len=${maxLen}`, +]; +const libAflOptions = { + mode: "fuzzing", + runs, + seed, + maxLen, + timeoutMillis: 1000, + maxTotalTimeSeconds: 0, + artifactPrefix: "", + corpusDirectories: [], + dictionaryFiles: [], +}; + +async function measure(name, start) { + console.log(`\nRunning ${name}...`); + let invocations = 0; + const startedAt = process.hrtime.bigint(); + await start(() => { + invocations++; + }); + const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9; + return { + name, + invocations, + elapsedSeconds, + execsPerSecond: invocations / elapsedSeconds, + }; +} + +async function measureCompareHeavy(name, start) { + console.log(`\nRunning ${name}...`); + let invocations = 0; + const startedAt = process.hrtime.bigint(); + await start((data) => { + invocations++; + const text = data.toString("utf8"); + for (let i = 0; i < 32; i++) { + fuzzer.tracer.traceStrCmp(text, `cmp-${i}`, "===", i + 1); + fuzzer.tracer.traceNumberCmp(data.length, i, "===", i + 128); + fuzzer.tracer.tracePcIndir(i + 512, data.length ^ i); + } + }); + const elapsedSeconds = Number(process.hrtime.bigint() - startedAt) / 1e9; + return { + name, + invocations, + elapsedSeconds, + execsPerSecond: invocations / elapsedSeconds, + }; +} + +function printResult(result) { + console.log( + `${result.name.padEnd(28)} ${result.invocations + .toString() + .padStart( + 8, + )} execs ${result.elapsedSeconds.toFixed(3).padStart(8)} s ${result.execsPerSecond + .toFixed(0) + .padStart(10)} exec/s`, + ); +} + +async function main() { + console.log( + `Benchmarking with runs=${runs}, seed=${seed}, max_len=${maxLen}`, + ); + + const results = []; + results.push( + await measure("libFuzzer sync trivial", (target) => + addon.startFuzzing(target, libFuzzerArgs, () => undefined), + ), + ); + results.push( + await measure("LibAFL sync trivial", (target) => + addon.startLibAfl(target, libAflOptions, () => undefined), + ), + ); + results.push( + await measure("libFuzzer async trivial", (target) => + addon.startFuzzingAsync( + () => + new Promise((resolve) => { + target(); + setImmediate(resolve); + }), + libFuzzerArgs, + ), + ), + ); + results.push( + await measure("LibAFL async trivial", (target) => + addon.startLibAflAsync( + () => + new Promise((resolve) => { + target(); + setImmediate(resolve); + }), + libAflOptions, + ), + ), + ); + results.push( + await measureCompareHeavy("libFuzzer compare-heavy", (target) => + addon.startFuzzing(target, libFuzzerArgs, () => undefined), + ), + ); + results.push( + await measureCompareHeavy("LibAFL compare-heavy", (target) => + addon.startLibAfl(target, libAflOptions, () => undefined), + ), + ); + + console.log(""); + for (const result of results) { + printResult(result); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/fuzzer/rust/Cargo.lock b/packages/fuzzer/rust/Cargo.lock new file mode 100644 index 000000000..dcaf48870 --- /dev/null +++ b/packages/fuzzer/rust/Cargo.lock @@ -0,0 +1,1370 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary-int" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825297538d77367557b912770ca3083f778a196054b3ee63b22673c4a3cae0a5" + +[[package]] +name = "arbitrary-int" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993a810118f8f37e9c4411c86f1c4c940a09a7ab34b7bf2d88d06f50c553fab7" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitbybit" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec187a89ab07e209270175faf9e07ceb2755d984954e58a2296e325ddece2762" +dependencies = [ + "arbitrary-int 1.3.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[package]] +name = "ctor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "dtor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastbloom" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "siphasher", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jazzerjs-libafl-runtime" +version = "0.1.0" +dependencies = [ + "libafl", + "libafl_bolts", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libafl" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e13655171e69ad9094dd1be1948950a36d228f01a7cb9f6d8477090d98c6e4" +dependencies = [ + "ahash", + "arbitrary-int 2.1.1", + "backtrace", + "bincode", + "bitbybit", + "const_format", + "const_panic", + "fastbloom", + "fs2", + "hashbrown 0.16.1", + "libafl_bolts", + "libafl_derive", + "libc", + "libm", + "log", + "meminterval", + "nix", + "num-traits", + "postcard", + "regex", + "rustversion", + "serde", + "serde_json", + "serial_test", + "tuple_list", + "typed-builder", + "uuid", + "wait-timeout", + "winapi", + "windows", +] + +[[package]] +name = "libafl_bolts" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cbae44f69156f035ae2196b135ad27ea95020767e6787bfe45e8c2438c67b9" +dependencies = [ + "ahash", + "backtrace", + "ctor", + "erased-serde", + "hashbrown 0.16.1", + "hostname", + "libafl_derive", + "libc", + "log", + "mach2", + "miniz_oxide", + "nix", + "num_enum", + "once_cell", + "postcard", + "rand_core", + "rustversion", + "serde", + "serial_test", + "static_assertions", + "tuple_list", + "typeid", + "uds", + "uuid", + "wide", + "winapi", + "windows", + "windows-core", + "windows-result", + "xxhash-rust", +] + +[[package]] +name = "libafl_derive" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61adf76899bffdcd15ae7fea42b978e7df7cf9213aacdd8cdcda89e4bb3bc32d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "meminterval" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e0f9a537564310a87dc77d5c88a407e27dd0aa740e070f0549439cfcc68fcfd" +dependencies = [ + "num-traits", + "serde", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scc" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" +dependencies = [ + "sdd", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial_test" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +dependencies = [ + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tuple_list" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "141fb9f71ee586d956d7d6e4d5a9ef8e946061188520140f7591b668841d502e" + +[[package]] +name = "typed-builder" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398a3a3c918c96de527dc11e6e846cd549d4508030b8a33e1da12789c856b81a" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e48cea23f68d1f78eb7bc092881b6bb88d3d6b5b7e6234f6f9c911da1ffb221" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" + +[[package]] +name = "uds" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "885c31f06fce836457fe3ef09a59f83fe8db95d270b11cd78f40a4666c4d1661" +dependencies = [ + "libc", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/packages/fuzzer/rust/Cargo.toml b/packages/fuzzer/rust/Cargo.toml new file mode 100644 index 000000000..c11c2bbc2 --- /dev/null +++ b/packages/fuzzer/rust/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "jazzerjs-libafl-runtime" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +publish = false + +[lib] +name = "jazzerjs_libafl_runtime" +crate-type = ["staticlib"] + +[profile.release] +panic = "abort" + +[profile.dev] +panic = "abort" + +[dependencies] +libafl = "0.15.4" +libafl_bolts = "0.15.4" diff --git a/packages/fuzzer/rust/src/lib.rs b/packages/fuzzer/rust/src/lib.rs new file mode 100644 index 000000000..c4e453ba0 --- /dev/null +++ b/packages/fuzzer/rust/src/lib.rs @@ -0,0 +1,465 @@ +use core::ffi::{c_char, c_void}; +use core::ptr; +use std::cell::Cell; +use std::ffi::CStr; +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use libafl::{ + corpus::{CachedOnDiskCorpus, Corpus, InMemoryCorpus}, + events::SimpleEventManager, + executors::{inprocess::InProcessExecutor, ExitKind}, + feedback_or_fast, + feedbacks::{CrashFeedback, MaxMapFeedback, TimeoutFeedback}, + fuzzer::{Evaluator, Fuzzer, StdFuzzer}, + inputs::{BytesInput, HasTargetBytes}, + monitors::{stats::ClientStatsManager, Monitor}, + mutators::{ + havoc_mutations::havoc_mutations, scheduled::HavocScheduledMutator, tokens_mutations, + Tokens, + }, + observers::{CanTrack, HitcountsMapObserver, StdMapObserver}, + schedulers::{IndexesLenTimeMinimizerScheduler, QueueScheduler}, + stages::mutational::StdMutationalStage, + state::{HasCorpus, HasExecutions, HasMaxSize, HasSolutions, StdState}, + Error, HasMetadata, +}; +use libafl_bolts::{ + rands::StdRand, + tuples::{tuple_list, Merge}, + AsSlice, ClientId, +}; + +const EXECUTION_CONTINUE: i32 = 0; +const EXECUTION_FINDING: i32 = 1; +const EXECUTION_STOP: i32 = 2; +const EXECUTION_FATAL: i32 = 3; +const EXECUTION_TIMEOUT: i32 = 4; + +const RUNTIME_OK: i32 = 0; +const RUNTIME_FOUND_FINDING: i32 = 1; +const RUNTIME_STOPPED: i32 = 2; +const RUNTIME_FATAL: i32 = 3; +const RUNTIME_FOUND_TIMEOUT: i32 = 4; + +#[repr(C)] +pub struct JazzerLibAflRuntimeOptions { + pub runs: u64, + pub seed: u64, + pub max_len: usize, + pub timeout_millis: u64, + pub max_total_time_seconds: u64, + pub corpus_directories: *const *const c_char, + pub corpus_directories_len: usize, + pub dictionary_files: *const *const c_char, + pub dictionary_files_len: usize, +} + +#[repr(C)] +pub struct JazzerLibAflRuntimeSharedMaps { + pub edges: *mut u8, + pub edges_len: usize, + pub cmp: *mut u8, + pub cmp_len: usize, +} + +pub type JazzerLibAflExecuteCallback = + unsafe extern "C" fn(user_data: *mut c_void, data: *const u8, size: usize) -> i32; + +struct LibAflMonitor; + +impl Monitor for LibAflMonitor { + fn display( + &mut self, + client_stats_manager: &mut ClientStatsManager, + event_msg: &str, + sender_id: ClientId, + ) -> Result<(), Error> { + let Some(event_name) = (match event_msg { + "Client Heartbeat" => Some("heartbeat"), + "Testcase" => Some("testcase"), + "Objective" => Some("objective"), + "Log" => Some("log"), + _ => None, + }) else { + return Ok(()); + }; + + let (run_time_pretty, corpus_size, objective_size, total_execs, execs_per_sec_pretty) = { + let global_stats = client_stats_manager.global_stats(); + ( + global_stats.run_time_pretty.clone(), + global_stats.corpus_size, + global_stats.objective_size, + global_stats.total_execs, + global_stats.execs_per_sec_pretty.clone(), + ) + }; + let mut user_stats = client_stats_manager + .client_stats_for(sender_id)? + .user_stats() + .iter() + .map(|(key, value)| format!("{key}: {value}")) + .collect::>(); + user_stats.sort(); + let extra = if user_stats.is_empty() { + String::new() + } else { + format!(", {}", user_stats.join(", ")) + }; + + eprintln!( + "[libafl::{event_name}] run time: {}, corpus: {}, objectives: {}, executions: {}, exec/sec: {}{extra}", + run_time_pretty, + corpus_size, + objective_size, + total_execs, + execs_per_sec_pretty, + ); + Ok(()) + } +} + +fn format_duration(duration: Duration) -> String { + let total_seconds = duration.as_secs(); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} + +fn print_runtime_start(options: &JazzerLibAflRuntimeOptions, loaded_inputs: usize) { + let runs = if options.runs == 0 { + "unlimited".to_string() + } else { + options.runs.to_string() + }; + let max_total_time = if options.max_total_time_seconds == 0 { + "unlimited".to_string() + } else { + format_duration(Duration::from_secs(options.max_total_time_seconds)) + }; + + eprintln!( + "[libafl::start] mode: fuzzing, seed: {}, loaded_inputs: {}, timeout: {} ms, max_len: {}, runs: {}, max_total_time: {}", + options.seed, loaded_inputs, options.timeout_millis, options.max_len, runs, max_total_time, + ); +} + +fn print_runtime_done( + started_at: Instant, + executions: u64, + corpus_size: usize, + objective_size: usize, +) { + 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 { + executions as f64 + }; + + eprintln!( + "[libafl::done] mode: fuzzing, run time: {}, corpus: {}, objectives: {}, executions: {}, exec/sec: {:.0}", + format_duration(elapsed), + corpus_size, + objective_size, + executions, + execs_per_sec, + ); +} + +fn clear_shared_map(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + + unsafe { + ptr::write_bytes(ptr, 0, len); + } +} + +unsafe fn parse_corpus_directories(options: &JazzerLibAflRuntimeOptions) -> Option> { + if options.corpus_directories.is_null() || options.corpus_directories_len == 0 { + return Some(Vec::new()); + } + + let mut result = Vec::with_capacity(options.corpus_directories_len); + let directories = + std::slice::from_raw_parts(options.corpus_directories, options.corpus_directories_len); + for directory in directories { + if directory.is_null() { + return None; + } + let path = CStr::from_ptr(*directory).to_string_lossy().to_string(); + result.push(PathBuf::from(path)); + } + Some(result) +} + +unsafe fn parse_dictionary_files(options: &JazzerLibAflRuntimeOptions) -> Option> { + if options.dictionary_files.is_null() || options.dictionary_files_len == 0 { + return Some(Vec::new()); + } + + let mut result = Vec::with_capacity(options.dictionary_files_len); + let files = std::slice::from_raw_parts(options.dictionary_files, options.dictionary_files_len); + for file in files { + if file.is_null() { + return None; + } + let path = CStr::from_ptr(*file).to_string_lossy().to_string(); + result.push(PathBuf::from(path)); + } + Some(result) +} + +fn resolve_main_corpus_directory( + corpus_dirs: &[PathBuf], + seed: u64, +) -> Result { + let directory = if let Some(first) = corpus_dirs.first() { + first.clone() + } else { + std::env::temp_dir().join(format!( + "jazzerjs-libafl-runtime-{}-{}", + std::process::id(), + seed, + )) + }; + fs::create_dir_all(&directory)?; + Ok(directory) +} + +fn load_dictionary_tokens(files: &[PathBuf]) -> Result { + if files.is_empty() { + return Ok(Tokens::new()); + } + + Tokens::new().add_from_files(files.iter()) +} + +#[no_mangle] +pub unsafe extern "C" fn jazzer_libafl_runtime_run( + options: *const JazzerLibAflRuntimeOptions, + maps: *const JazzerLibAflRuntimeSharedMaps, + execute_one: JazzerLibAflExecuteCallback, + user_data: *mut c_void, +) -> i32 { + if options.is_null() || maps.is_null() { + eprintln!("[libafl] fatal: null options or maps pointer"); + return RUNTIME_FATAL; + } + + let options = &*options; + let maps = &*maps; + if maps.edges.is_null() || maps.edges_len == 0 || maps.cmp.is_null() || maps.cmp_len == 0 { + eprintln!("[libafl] fatal: shared maps are missing"); + return RUNTIME_FATAL; + } + + let corpus_dirs = match parse_corpus_directories(options) { + Some(dirs) => dirs, + None => { + eprintln!("[libafl] fatal: invalid corpus directories"); + return RUNTIME_FATAL; + } + }; + let dictionary_files = match parse_dictionary_files(options) { + Some(files) => files, + None => { + eprintln!("[libafl] fatal: invalid dictionary files"); + return RUNTIME_FATAL; + } + }; + + let main_corpus_dir = match resolve_main_corpus_directory(&corpus_dirs, options.seed) { + Ok(directory) => directory, + Err(error) => { + eprintln!("[libafl] fatal: failed to prepare corpus directory: {error:?}"); + return RUNTIME_FATAL; + } + }; + + let monitor = LibAflMonitor; + let mut mgr = SimpleEventManager::new(monitor); + + let edges_observer = HitcountsMapObserver::new(StdMapObserver::from_mut_ptr( + "edges", + maps.edges, + maps.edges_len, + )) + .track_indices(); + let cmp_observer = + HitcountsMapObserver::new(StdMapObserver::from_mut_ptr("cmp", maps.cmp, maps.cmp_len)); + + let mut feedback = MaxMapFeedback::new(&edges_observer); + let mut objective = feedback_or_fast!(CrashFeedback::new(), TimeoutFeedback::new()); + let mut state = match StdState::new( + StdRand::with_seed(options.seed), + match CachedOnDiskCorpus::no_meta(&main_corpus_dir, 256) { + Ok(corpus) => corpus, + Err(error) => { + eprintln!("[libafl] fatal: failed to create on-disk corpus: {error:?}"); + return RUNTIME_FATAL; + } + }, + InMemoryCorpus::new(), + &mut feedback, + &mut objective, + ) { + Ok(state) => state, + Err(error) => { + eprintln!("[libafl] fatal: failed to create fuzzing state: {error:?}"); + return RUNTIME_FATAL; + } + }; + state.set_max_size(options.max_len); + + match load_dictionary_tokens(&dictionary_files) { + Ok(tokens) => { + if !tokens.is_empty() { + state.add_metadata(tokens); + } + } + Err(error) => { + eprintln!("[libafl] fatal: failed to load dictionary tokens: {error:?}"); + return RUNTIME_FATAL; + } + } + + let scheduler = IndexesLenTimeMinimizerScheduler::new(&edges_observer, QueueScheduler::new()); + let mut fuzzer = StdFuzzer::new(scheduler, feedback, objective); + let mutator = HavocScheduledMutator::new(havoc_mutations().merge(tokens_mutations())); + let mut stages = tuple_list!(StdMutationalStage::new(mutator),); + let stop_requested = Cell::new(false); + let fatal_error = Cell::new(false); + let timeout_found = Cell::new(false); + + let mut harness = |input: &BytesInput| { + clear_shared_map(maps.edges, maps.edges_len); + clear_shared_map(maps.cmp, maps.cmp_len); + + let bytes = input.target_bytes(); + let bytes = bytes.as_slice(); + let size = bytes.len().min(options.max_len); + let status = unsafe { execute_one(user_data, bytes.as_ptr(), size) }; + 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 mut 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; + } + }; + + if !corpus_dirs.is_empty() && state.must_load_initial_inputs() { + if state + .load_initial_inputs(&mut fuzzer, &mut executor, &mut mgr, &corpus_dirs) + .is_err() + { + eprintln!("[libafl] fatal: failed to load initial corpus inputs"); + return RUNTIME_FATAL; + } + } + + if state.corpus().count() == 0 + && fuzzer + .add_input(&mut state, &mut executor, &mut mgr, BytesInput::new(vec![])) + .is_err() + { + eprintln!("[libafl] fatal: failed to seed empty testcase"); + return RUNTIME_FATAL; + } + + print_runtime_start(options, state.corpus().count()); + + let started_at = Instant::now(); + let max_total_time = if options.max_total_time_seconds == 0 { + None + } else { + Some(Duration::from_secs(options.max_total_time_seconds)) + }; + + let initial_executions = *state.executions(); + let mut status = RUNTIME_OK; + loop { + if options.runs != 0 + && state.executions().saturating_sub(initial_executions) >= options.runs + { + break; + } + if let Some(max_total_time) = max_total_time { + if started_at.elapsed() >= max_total_time { + status = RUNTIME_STOPPED; + break; + } + } + + 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; + } + } + + print_runtime_done( + started_at, + state.executions().saturating_sub(initial_executions), + state.corpus().count(), + state.solutions().count(), + ); + + status +} diff --git a/packages/fuzzer/shared/callbacks.cpp b/packages/fuzzer/shared/callbacks.cpp index 30365436e..ddbabab6b 100644 --- a/packages/fuzzer/shared/callbacks.cpp +++ b/packages/fuzzer/shared/callbacks.cpp @@ -30,4 +30,8 @@ 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); } diff --git a/packages/fuzzer/shared/coverage.cpp b/packages/fuzzer/shared/coverage.cpp index d1cb3a682..ad66dfeb2 100644 --- a/packages/fuzzer/shared/coverage.cpp +++ b/packages/fuzzer/shared/coverage.cpp @@ -15,6 +15,7 @@ #include #include +#include extern "C" { void __sanitizer_cov_8bit_counters_init(uint8_t *start, uint8_t *end); @@ -26,6 +27,7 @@ namespace { // Shared coverage counter buffer populated from JavaScript using Buffer. // Individual slices are registered with libFuzzer by RegisterNewCounters. uint8_t *gCoverageCounters = nullptr; +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 @@ -102,6 +104,7 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { RegisterCounterRange(gCoverageCounters + old_num_counters, gCoverageCounters + new_num_counters); + gCoverageCountersSize = static_cast(new_num_counters); } // Register an independent coverage counter region for a single ES module. @@ -121,3 +124,15 @@ void RegisterModuleCounters(const Napi::CallbackInfo &info) { RegisterCounterRange(buf.Data(), buf.Data() + size); } + +uint8_t *CoverageCounters() { return gCoverageCounters; } + +std::size_t CoverageCountersSize() { return gCoverageCountersSize; } + +void ClearCoverageCounters() { + if (gCoverageCounters == nullptr || gCoverageCountersSize == 0) { + return; + } + + std::memset(gCoverageCounters, 0, gCoverageCountersSize); +} diff --git a/packages/fuzzer/shared/coverage.h b/packages/fuzzer/shared/coverage.h index ffbd7333a..42c126b09 100644 --- a/packages/fuzzer/shared/coverage.h +++ b/packages/fuzzer/shared/coverage.h @@ -13,8 +13,14 @@ // 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 CoverageCountersSize(); +void ClearCoverageCounters(); diff --git a/packages/fuzzer/shared/libfuzzer.h b/packages/fuzzer/shared/libfuzzer.h index d243d67c0..748b94c19 100644 --- a/packages/fuzzer/shared/libfuzzer.h +++ b/packages/fuzzer/shared/libfuzzer.h @@ -20,6 +20,7 @@ namespace libfuzzer { extern void (*PrintCrashingInput)(); const int EXIT_ERROR_CODE = 77; +const int EXIT_ERROR_TIMEOUT = 70; // Signals should exit with code 128+n, see // https://tldp.org/LDP/abs/html/exitcodes.html diff --git a/packages/fuzzer/shared/tracing.cpp b/packages/fuzzer/shared/tracing.cpp index ee68e55d0..ce98f6960 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,33 @@ 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 { +constexpr std::size_t kCompareFeedbackMapSize = 1 << 16; +std::array gCompareFeedbackMap{}; + +void RecordCompareFeedback(uint64_t value) { + auto index = static_cast(value % kCompareFeedbackMapSize); + auto &slot = gCompareFeedbackMap[index]; + slot = slot == 255 ? 1 : static_cast(slot + 1); +} + +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 +69,8 @@ void TraceUnequalStrings(const Napi::CallbackInfo &info) { auto s1 = info[1].As().Utf8Value(); auto s2 = info[2].As().Utf8Value(); + RecordStringFeedback(id, s1, s2); + // strcmp returns zero on equality, and libfuzzer doesn't care about the // result beyond whether it's zero or not. __sanitizer_weak_hook_strcmp((void *)id, s1.c_str(), s2.c_str(), 1); @@ -55,6 +88,8 @@ void TraceStringContainment(const Napi::CallbackInfo &info) { auto needle = info[1].As().Utf8Value(); auto haystack = info[2].As().Utf8Value(); + RecordStringFeedback(id, needle, haystack); + // libFuzzer currently ignores the result, which allows us to simply pass a // valid but arbitrary pointer here instead of performing an actual strstr // operation. @@ -72,6 +107,8 @@ 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)); __sanitizer_cov_trace_const_cmp8_with_pc(id, arg1, arg2); } @@ -83,5 +120,34 @@ 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); +} + +uint8_t *CompareFeedbackMap() { return gCompareFeedbackMap.data(); } + +std::size_t CompareFeedbackMapSize() { return gCompareFeedbackMap.size(); } + +void ClearCompareFeedbackMap() { + std::memset(gCompareFeedbackMap.data(), 0, gCompareFeedbackMap.size()); +} diff --git a/packages/fuzzer/shared/tracing.h b/packages/fuzzer/shared/tracing.h index d85c8e854..744c603c4 100644 --- a/packages/fuzzer/shared/tracing.h +++ b/packages/fuzzer/shared/tracing.h @@ -13,9 +13,18 @@ // limitations under the License. #pragma once +#include +#include #include 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); + +uint8_t *CompareFeedbackMap(); +std::size_t CompareFeedbackMapSize(); +void ClearCompareFeedbackMap(); diff --git a/packages/fuzzer/tsconfig.json b/packages/fuzzer/tsconfig.json index 8ef3f91ff..2d70535b6 100644 --- a/packages/fuzzer/tsconfig.json +++ b/packages/fuzzer/tsconfig.json @@ -4,5 +4,5 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["build", "dist", "cmake-build-*"] + "exclude": ["build", "dist", "runtime", "cmake-build-*"] } diff --git a/packages/jest-runner/fuzz.ts b/packages/jest-runner/fuzz.ts index 1e04cb1b9..6af6b7702 100644 --- a/packages/jest-runner/fuzz.ts +++ b/packages/jest-runner/fuzz.ts @@ -142,6 +142,7 @@ export function fuzz( const wrappedFn = asFindingAwareFuzzFn( fn, localConfig.get("mode") === "fuzzing", + localConfig.get("engine"), ); if (localConfig.get("mode") === "regression") { diff --git a/packages/options/index.ts b/packages/options/index.ts index 9717db4c8..e70ed2226 100644 --- a/packages/options/index.ts +++ b/packages/options/index.ts @@ -22,6 +22,8 @@ * options. */ export interface Options { + // Fuzzing backend engine. + engine: "libfuzzer" | "libafl"; // Enable source code coverage report generation. coverage: boolean; // Directory to write coverage reports to. @@ -77,6 +79,7 @@ export type OptionsWithPrintableSource = { // These options can be set from the Jest fuzz test. const allowedFuzzTestOptions = [ + "engine", "dictionaryEntries", "fuzzerOptions", "sync", @@ -85,6 +88,7 @@ const allowedFuzzTestOptions = [ export type AllowedFuzzTestOptions = (typeof allowedFuzzTestOptions)[number]; export const defaultCLIOptions: Options = Object.freeze({ + engine: "libafl", coverage: false, coverageDirectory: "coverage", coverageReporters: ["json", "text", "lcov", "clover"], @@ -107,6 +111,7 @@ export const defaultCLIOptions: Options = Object.freeze({ export const defaultJestOptions: Options = Object.freeze({ ...defaultCLIOptions, + engine: "libfuzzer", mode: "regression", }); @@ -155,6 +160,22 @@ export enum OptionSource { JestFuzzTestOptions, } +export type FuzzingEngine = Options["engine"]; + +export function resolveEngine(engine: string): FuzzingEngine { + switch (engine) { + case "libfuzzer": + return "libfuzzer"; + case "libafl": + case "afl": + return "libafl"; + default: + throw new Error( + `Unknown fuzzing engine '${engine}'. Supported engines are 'libfuzzer' and 'libafl' (alias 'afl').`, + ); + } +} + type DefaultSourceInfo = { name: string; transformKey: KeyFormatSource; @@ -287,6 +308,9 @@ export class OptionsManager { `Invalid type for Jazzer.js option '${key}', expected type '${keyType}', got '${typeof resultValue}'`, ); } + if (key === "engine") { + resultValue = resolveEngine(resultValue); + } resultValue = OptionsManager.copyOptionValue(resultValue); setProperty(this._options, key, { value: resultValue, source: source }); @@ -403,9 +427,6 @@ export function toOptionsWithPrintableSources( return result; } -// 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; diff --git a/tests/bug-detectors/general.test.js b/tests/bug-detectors/general.test.js index f94d5c79c..13fe74e29 100644 --- a/tests/bug-detectors/general.test.js +++ b/tests/bug-detectors/general.test.js @@ -176,6 +176,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalEvil") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(200) .forkMode(3) .build(); @@ -195,6 +196,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalFriendly") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(200) .forkMode(3) .build(); @@ -214,6 +216,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalEvilAsync") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(10) .forkMode(3) .build(); @@ -233,6 +236,7 @@ describe("General tests", () => { .sync(false) .fuzzEntryPoint("ForkModeCallOriginalFriendlyAsync") .dir(bugDetectorDirectory) + .engine("libfuzzer") .runs(200) .forkMode(3) .build(); diff --git a/tests/code_coverage/coverage.test.js b/tests/code_coverage/coverage.test.js index 0d18f1dba..8d8a473a8 100644 --- a/tests/code_coverage/coverage.test.js +++ b/tests/code_coverage/coverage.test.js @@ -222,7 +222,15 @@ function executeFuzzTest( verbose = false, ) { removeCoverageDir(coverageOutputDir); - let options = ["jazzer", "fuzz", "-e", excludePattern, "--corpus", "corpus"]; + let options = [ + "jazzer", + "fuzz", + "--engine=libfuzzer", + "-e", + excludePattern, + "--corpus", + "corpus", + ]; // add dry run option if (dryRun) options.push("-d"); if (includeLib) { diff --git a/tests/done_callback/package.json b/tests/done_callback/package.json index 0d54782fb..50daba017 100644 --- a/tests/done_callback/package.json +++ b/tests/done_callback/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles callback based fuzz targets", "scripts": { - "fuzz": "jazzer fuzz --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=2386907168", - "dryRun": "jazzer fuzz -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=2386907168", + "dryRun": "jazzer fuzz --engine=libfuzzer -- -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/fork_mode/package.json b/tests/fork_mode/package.json index f04ed1d4b..41b9cf5da 100644 --- a/tests/fork_mode/package.json +++ b/tests/fork_mode/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how to use libFuzzer's fork mode in Jazzer.js", "scripts": { - "fuzz": "jazzer fuzz --sync --disableBugDetectors='.*' -- -fork=3", - "dryRun": "jazzer fuzz --sync -- -fork=3 -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --sync --disableBugDetectors='.*' -- -fork=3", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync -- -fork=3 -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/helpers.js b/tests/helpers.js index 8ca16ac41..aa23ea8f4 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -49,6 +49,7 @@ class FuzzTest { expectedErrors, asJson, timeout, + engine, ) { this.logTestOutput = logTestOutput; this.includes = includes; @@ -74,6 +75,7 @@ class FuzzTest { this.expectedErrors = expectedErrors; this.asJson = asJson; this.timeout = timeout; + this.engine = engine; } // Runs the fuzz test in another process using `spawnSync`. @@ -104,6 +106,7 @@ class FuzzTest { if (this.verbose) options.push("--verbose"); if (this.dryRun !== undefined) options.push("--dry_run=" + this.dryRun); if (this.timeout !== undefined) options.push("--timeout=" + this.timeout); + if (this.engine !== undefined) options.push("--engine=" + this.engine); for (const include of this.includes) { options.push("-i=" + include); } @@ -177,6 +180,9 @@ class FuzzTest { if (this.verbose) { config.verbose = this.verbose; } + if (this.engine !== undefined) { + config.engine = this.engine; + } // Write jest config file even if it exists fs.writeFileSync( @@ -298,6 +304,7 @@ class FuzzTestBuilder { _expectedErrors = []; _asJson = false; _timeout = undefined; + _engine = "libfuzzer"; /** * @param {boolean} logTestOutput - whether to print the output of the fuzz test to the console. @@ -502,6 +509,11 @@ class FuzzTestBuilder { return this; } + engine(engine) { + this._engine = engine; + return this; + } + build() { if (this._jestTestFile === "" && this._fuzzEntryPoint === "") { throw new Error("fuzzEntryPoint or jestTestFile are not set."); @@ -536,6 +548,7 @@ class FuzzTestBuilder { this._expectedErrors, this._asJson, this._timeout, + this._engine, ); } } diff --git a/tests/promise/package.json b/tests/promise/package.json index 0ec55ccc3..31fdd35de 100644 --- a/tests/promise/package.json +++ b/tests/promise/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles promise based fuzz targets", "scripts": { - "fuzz": "jazzer fuzz --fuzz_function fuzz_promise --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=3088388356", - "dryRun": "jazzer fuzz --fuzz_function fuzz_promise -- -runs=1 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --fuzz_function fuzz_promise --disableBugDetectors='.*' -x Error -- -runs=5000 -seed=3088388356", + "dryRun": "jazzer fuzz --engine=libfuzzer --fuzz_function fuzz_promise -- -runs=1 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/string_compare/package.json b/tests/string_compare/package.json index 76b5fbd16..5e93c4cc4 100644 --- a/tests/string_compare/package.json +++ b/tests/string_compare/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles string comparisons in the code", "scripts": { - "fuzz": "jazzer fuzz --sync --disableBugDetectors='.*' -x Error -- -runs=5000000 -seed=111994470", - "dryRun": "jazzer fuzz --sync -- -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --sync --disableBugDetectors='.*' -x Error -- -runs=5000000 -seed=111994470", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync -- -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" diff --git a/tests/timeout/package.json b/tests/timeout/package.json index f35a9da32..00862b521 100644 --- a/tests/timeout/package.json +++ b/tests/timeout/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "Timeout test: checking that the handler for the SIGALRM signal does not return with error code.", "scripts": { - "timeout": "jazzer fuzz -f=timeout --timeout=1000 --disableBugDetectors='.*' -- -runs=5000 -seed=1234", - "fuzz": "jazzer fuzz --timeout=1000 -- -runs=5000 -seed=1234", + "timeout": "jazzer fuzz --engine=libfuzzer -f=timeout --timeout=1000 --disableBugDetectors='.*' -- -runs=5000 -seed=1234", + "fuzz": "jazzer fuzz --engine=libfuzzer --timeout=1000 -- -runs=5000 -seed=1234", "dryRun": "echo \"skipped\"" }, "devDependencies": { diff --git a/tests/value_profiling/package.json b/tests/value_profiling/package.json index 38b5e9ca9..fedb62155 100644 --- a/tests/value_profiling/package.json +++ b/tests/value_profiling/package.json @@ -3,8 +3,8 @@ "version": "1.0.0", "description": "An example showing how Jazzer.js handles integer comparisons in the code", "scripts": { - "fuzz": "jazzer fuzz --sync --disableBugDetectors='.*' -x Error -- -runs=4000000 -seed=1428686921 -use_value_profile=1", - "dryRun": "jazzer fuzz --sync -- -use_value_profile=1 -runs=100 -seed=123456789" + "fuzz": "jazzer fuzz --engine=libfuzzer --sync --disableBugDetectors='.*' -x Error -- -runs=4000000 -seed=1428686921 -use_value_profile=1", + "dryRun": "jazzer fuzz --engine=libfuzzer --sync -- -use_value_profile=1 -runs=100 -seed=123456789" }, "devDependencies": { "@jazzer.js/core": "file:../../packages/core" From 462a27377169a946fe8a5e72bb7d93fe9e09f8f6 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:33:22 +0200 Subject: [PATCH 03/26] test(engine): cover backend selection end to end Exercise CLI and Jest runs through both engines so backend selection breaks where users would notice it first. --- tests/engine/engine.test.js | 138 +++++++++++++++++++++++ tests/engine/fuzz.js | 33 ++++++ 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 ++++ 6 files changed, 247 insertions(+) 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 diff --git a/tests/engine/engine.test.js b/tests/engine/engine.test.js new file mode 100644 index 000000000..170ac966e --- /dev/null +++ b/tests/engine/engine.test.js @@ -0,0 +1,138 @@ +/* + * 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 path = require("path"); + +const { + cleanCrashFilesIn, + FuzzingExitCode, + FuzzTestBuilder, + JestRegressionExitCode, + TimeoutExitCode, +} = require("../helpers.js"); + +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"); + }); + + 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("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..63aab383e --- /dev/null +++ b/tests/engine/fuzz.js @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.fuzz = function (data) { + if (data.length > 1024 * 1024) { + throw new Error("Unexpectedly large input"); + } +}; + +module.exports.timeout_sync = function (_data) { + while (true) { + // Busy loop on purpose to exercise hard timeout handling. + } +}; + +module.exports.timeout_async = function (_data) { + return new Promise(() => { + // Never resolve on purpose to exercise cooperative timeout handling. + }); +}; 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 + } +} From da53022238bbcfa9827571657473f88eebd49647 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:33:35 +0200 Subject: [PATCH 04/26] feat(fuzzer): add compare-guided mutation for LibAFL Feed observed string and integer comparisons into LibAFL so it can mutate toward guarded code paths instead of treating those checks as opaque control flow. --- packages/fuzzer/addon.ts | 2 + packages/fuzzer/libafl_runtime.cpp | 14 +- packages/fuzzer/libafl_runtime.h | 3 + packages/fuzzer/libafl_runtime.test.ts | 2 + packages/fuzzer/rust/src/compare_log.rs | 190 ++++++++++++++++++++++++ packages/fuzzer/rust/src/lib.rs | 41 ++++- packages/fuzzer/shared/callbacks.cpp | 4 + packages/fuzzer/shared/tracing.cpp | 95 +++++++++++- packages/fuzzer/shared/tracing.h | 31 ++++ tests/engine/engine.test.js | 186 +++++++++++++++++++++++ tests/engine/fuzz.js | 43 ++++++ 11 files changed, 592 insertions(+), 19 deletions(-) create mode 100644 packages/fuzzer/rust/src/compare_log.rs diff --git a/packages/fuzzer/addon.ts b/packages/fuzzer/addon.ts index c2d9624cf..9f7ee6081 100644 --- a/packages/fuzzer/addon.ts +++ b/packages/fuzzer/addon.ts @@ -92,6 +92,8 @@ type NativeAddon = { startLibAflAsync?: StartLibAflAsyncFn; clearCompareFeedbackMap: () => void; countNonZeroCompareFeedbackSlots: () => number; + countCompareLogEntries: () => number; + countDroppedCompareLogEntries: () => number; }; type LoadedAddon = NativeAddon & { diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp index f4718bcca..db103adea 100644 --- a/packages/fuzzer/libafl_runtime.cpp +++ b/packages/fuzzer/libafl_runtime.cpp @@ -443,14 +443,16 @@ JazzerLibAflRuntimeSharedMaps SharedMapsForRuntime(Napi::Env env) { const auto edges_len = CoverageCountersSize(); auto *cmp = CompareFeedbackMap(); const auto cmp_len = CompareFeedbackMapSize(); + auto *compare_log = CompareLog(); - if (edges == nullptr || edges_len == 0 || cmp == nullptr || cmp_len == 0) { + if (edges == nullptr || edges_len == 0 || cmp == nullptr || cmp_len == 0 || + compare_log == nullptr) { throw Napi::Error::New( env, "Coverage maps were not initialized before the LibAFL backend started"); } - return {edges, edges_len, cmp, cmp_len}; + return {edges, edges_len, cmp, cmp_len, compare_log}; } bool CollectRegressionCorpusFiles( @@ -765,9 +767,11 @@ int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { } } } catch (const Napi::Error &error) { - WriteArtifact(context->options.artifact_prefix, "crash", data, size); - context->is_resolved = true; - context->deferred.Reject(error.Value()); + if (!context->is_resolved) { + WriteArtifact(context->options.artifact_prefix, "crash", data, size); + context->is_resolved = true; + context->deferred.Reject(error.Value()); + } return kExecutionFinding; } catch (const std::exception &exception) { ExitWithUnexpectedError(exception); diff --git a/packages/fuzzer/libafl_runtime.h b/packages/fuzzer/libafl_runtime.h index c4440b498..2fad26912 100644 --- a/packages/fuzzer/libafl_runtime.h +++ b/packages/fuzzer/libafl_runtime.h @@ -18,6 +18,8 @@ #include #include +#include "shared/tracing.h" + extern "C" { struct JazzerLibAflRuntimeOptions { uint64_t runs; @@ -36,6 +38,7 @@ struct JazzerLibAflRuntimeSharedMaps { size_t edges_len; uint8_t *cmp; size_t cmp_len; + JazzerLibAflCompareLog *compare_log; }; typedef int (*JazzerLibAflExecuteCallback)(void *user_data, const uint8_t *data, diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts index d276c3ec3..ef54f711b 100644 --- a/packages/fuzzer/libafl_runtime.test.ts +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -92,5 +92,7 @@ describe("LibAFL runtime", () => { ); expect(addon.countNonZeroCompareFeedbackSlots()).toBeGreaterThan(0); + expect(addon.countCompareLogEntries()).toBeGreaterThan(0); + expect(addon.countDroppedCompareLogEntries()).toBe(0); }); }); diff --git a/packages/fuzzer/rust/src/compare_log.rs b/packages/fuzzer/rust/src/compare_log.rs new file mode 100644 index 000000000..2305219c4 --- /dev/null +++ b/packages/fuzzer/rust/src/compare_log.rs @@ -0,0 +1,190 @@ +use std::borrow::Cow; + +use libafl::{ + executors::ExitKind, + mutators::Tokens, + observers::{ + cmp::{CmpValues, CmpValuesMetadata, CmplogBytes}, + Observer, + }, + Error, HasMetadata, +}; +use libafl_bolts::Named; + +pub const COMPARE_LOG_ENTRY_BYTES: usize = 32; +pub const COMPARE_LOG_MAX_ENTRIES: usize = 1024; + +const MAX_PROMOTED_TOKENS_PER_EXEC: usize = 64; +const MAX_PROMOTED_TOKENS_TOTAL: usize = 1024; +const COMPARE_LOG_SIGNED_FLAG: u8 = 1 << 0; + +const COMPARE_KIND_INTEGER: u8 = 1; +const COMPARE_KIND_STRING_EQUALITY: u8 = 2; +const COMPARE_KIND_STRING_CONTAINMENT: u8 = 3; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +pub struct JazzerLibAflCompareLogEntry { + pub kind: u8, + pub flags: u8, + pub left_len: u8, + pub right_len: u8, + pub left_value: u64, + pub right_value: u64, + pub left_bytes: [u8; COMPARE_LOG_ENTRY_BYTES], + pub right_bytes: [u8; COMPARE_LOG_ENTRY_BYTES], +} + +#[repr(C)] +#[derive(Debug)] +pub struct JazzerLibAflCompareLog { + pub used: u32, + pub dropped: u32, + pub entries: [JazzerLibAflCompareLogEntry; COMPARE_LOG_MAX_ENTRIES], +} + +#[derive(Clone, Debug)] +pub struct JazzerCompareLogObserver { + name: Cow<'static, str>, + compare_log: *mut JazzerLibAflCompareLog, +} + +impl JazzerCompareLogObserver { + pub fn new(compare_log: *mut JazzerLibAflCompareLog) -> Self { + Self { + name: Cow::Borrowed("jazzer-compare-log"), + compare_log, + } + } + + fn compare_log(&self) -> Option<&JazzerLibAflCompareLog> { + unsafe { self.compare_log.as_ref() } + } +} + +impl Named for JazzerCompareLogObserver { + fn name(&self) -> &Cow<'static, str> { + &self.name + } +} + +impl Observer for JazzerCompareLogObserver +where + S: HasMetadata, +{ + fn pre_exec(&mut self, state: &mut S, _input: &I) -> Result<(), Error> { + if let Some(metadata) = state.metadata_map_mut().get_mut::() { + metadata.list.clear(); + } + Ok(()) + } + + fn post_exec(&mut self, state: &mut S, _input: &I, _exit_kind: &ExitKind) -> Result<(), Error> { + let Some(compare_log) = self.compare_log() else { + return Ok(()); + }; + + let entry_count = usize::min(compare_log.used as usize, COMPARE_LOG_MAX_ENTRIES); + let mut cmp_values = Vec::with_capacity(entry_count); + let mut promoted_tokens = Vec::new(); + for entry in compare_log.entries.iter().take(entry_count) { + if let Some(value) = cmp_value_for_entry(entry) { + cmp_values.push(value); + } + if promoted_tokens.len() < MAX_PROMOTED_TOKENS_PER_EXEC { + if let Some(token) = promoted_token_for_entry(entry) { + promoted_tokens.push(token); + } + } + } + + let metadata = state.metadata_or_insert_with(CmpValuesMetadata::new); + metadata.list.clear(); + metadata.list.extend(cmp_values); + + if !promoted_tokens.is_empty() { + let tokens = state.metadata_or_insert_with(Tokens::new); + for token in promoted_tokens { + if tokens.len() >= MAX_PROMOTED_TOKENS_TOTAL { + break; + } + tokens.add_token(&token); + } + } + + Ok(()) + } +} + +fn cmp_value_for_entry(entry: &JazzerLibAflCompareLogEntry) -> Option { + match entry.kind { + COMPARE_KIND_INTEGER => Some(cmp_value_for_integer(entry)), + COMPARE_KIND_STRING_EQUALITY => cmp_value_for_string_equality(entry), + _ => None, + } +} + +fn promoted_token_for_entry(entry: &JazzerLibAflCompareLogEntry) -> Option> { + match entry.kind { + COMPARE_KIND_STRING_EQUALITY => token_from_entry(&entry.right_bytes, entry.right_len), + COMPARE_KIND_STRING_CONTAINMENT => token_from_entry(&entry.left_bytes, entry.left_len), + _ => None, + } +} + +fn token_from_entry(bytes: &[u8; COMPARE_LOG_ENTRY_BYTES], len: u8) -> Option> { + let len = usize::min(len as usize, COMPARE_LOG_ENTRY_BYTES); + if len == 0 { + return None; + } + Some(bytes[..len].to_vec()) +} + +fn cmp_value_for_string_equality(entry: &JazzerLibAflCompareLogEntry) -> Option { + let left_len = usize::min(entry.left_len as usize, COMPARE_LOG_ENTRY_BYTES); + let right_len = usize::min(entry.right_len as usize, COMPARE_LOG_ENTRY_BYTES); + if left_len == 0 || right_len == 0 { + return None; + } + + let mut left = [0; COMPARE_LOG_ENTRY_BYTES]; + left[..left_len].copy_from_slice(&entry.left_bytes[..left_len]); + let mut right = [0; COMPARE_LOG_ENTRY_BYTES]; + right[..right_len].copy_from_slice(&entry.right_bytes[..right_len]); + Some(CmpValues::Bytes(( + CmplogBytes::from_buf_and_len(left, left_len as u8), + CmplogBytes::from_buf_and_len(right, right_len as u8), + ))) +} + +fn cmp_value_for_integer(entry: &JazzerLibAflCompareLogEntry) -> CmpValues { + if entry.flags & COMPARE_LOG_SIGNED_FLAG != 0 { + cmp_value_for_signed_integer(entry.left_value as i64, entry.right_value as i64) + } else { + cmp_value_for_unsigned_integer(entry.left_value, entry.right_value) + } +} + +fn cmp_value_for_unsigned_integer(left: u64, right: u64) -> CmpValues { + if let (Ok(left), Ok(right)) = (u8::try_from(left), u8::try_from(right)) { + CmpValues::U8((left, right, false)) + } else if let (Ok(left), Ok(right)) = (u16::try_from(left), u16::try_from(right)) { + CmpValues::U16((left, right, false)) + } else if let (Ok(left), Ok(right)) = (u32::try_from(left), u32::try_from(right)) { + CmpValues::U32((left, right, false)) + } else { + CmpValues::U64((left, right, false)) + } +} + +fn cmp_value_for_signed_integer(left: i64, right: i64) -> CmpValues { + if let (Ok(left), Ok(right)) = (i8::try_from(left), i8::try_from(right)) { + CmpValues::U8((left as u8, right as u8, false)) + } else if let (Ok(left), Ok(right)) = (i16::try_from(left), i16::try_from(right)) { + CmpValues::U16((left as u16, right as u16, false)) + } else if let (Ok(left), Ok(right)) = (i32::try_from(left), i32::try_from(right)) { + CmpValues::U32((left as u32, right as u32, false)) + } else { + CmpValues::U64((left as u64, right as u64, false)) + } +} diff --git a/packages/fuzzer/rust/src/lib.rs b/packages/fuzzer/rust/src/lib.rs index c4e453ba0..6f2478a0e 100644 --- a/packages/fuzzer/rust/src/lib.rs +++ b/packages/fuzzer/rust/src/lib.rs @@ -1,3 +1,5 @@ +mod compare_log; + use core::ffi::{c_char, c_void}; use core::ptr; use std::cell::Cell; @@ -9,7 +11,7 @@ use std::time::{Duration, Instant}; use libafl::{ corpus::{CachedOnDiskCorpus, Corpus, InMemoryCorpus}, events::SimpleEventManager, - executors::{inprocess::InProcessExecutor, ExitKind}, + executors::{inprocess::InProcessExecutor, ExitKind, ShadowExecutor}, feedback_or_fast, feedbacks::{CrashFeedback, MaxMapFeedback, TimeoutFeedback}, fuzzer::{Evaluator, Fuzzer, StdFuzzer}, @@ -17,11 +19,11 @@ use libafl::{ monitors::{stats::ClientStatsManager, Monitor}, mutators::{ havoc_mutations::havoc_mutations, scheduled::HavocScheduledMutator, tokens_mutations, - Tokens, + I2SRandReplace, Tokens, }, observers::{CanTrack, HitcountsMapObserver, StdMapObserver}, schedulers::{IndexesLenTimeMinimizerScheduler, QueueScheduler}, - stages::mutational::StdMutationalStage, + stages::{mutational::StdMutationalStage, shadow::ShadowTracingStage}, state::{HasCorpus, HasExecutions, HasMaxSize, HasSolutions, StdState}, Error, HasMetadata, }; @@ -31,6 +33,8 @@ use libafl_bolts::{ AsSlice, ClientId, }; +use crate::compare_log::{JazzerCompareLogObserver, JazzerLibAflCompareLog}; + const EXECUTION_CONTINUE: i32 = 0; const EXECUTION_FINDING: i32 = 1; const EXECUTION_STOP: i32 = 2; @@ -62,6 +66,7 @@ pub struct JazzerLibAflRuntimeSharedMaps { pub edges_len: usize, pub cmp: *mut u8, pub cmp_len: usize, + pub compare_log: *mut JazzerLibAflCompareLog, } pub type JazzerLibAflExecuteCallback = @@ -188,6 +193,16 @@ fn clear_shared_map(ptr: *mut u8, len: usize) { } } +fn clear_compare_log(ptr: *mut JazzerLibAflCompareLog) { + if ptr.is_null() { + return; + } + + unsafe { + ptr::write_bytes(ptr, 0, 1); + } +} + unsafe fn parse_corpus_directories(options: &JazzerLibAflRuntimeOptions) -> Option> { if options.corpus_directories.is_null() || options.corpus_directories_len == 0 { return Some(Vec::new()); @@ -262,7 +277,12 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let options = &*options; let maps = &*maps; - if maps.edges.is_null() || maps.edges_len == 0 || maps.cmp.is_null() || maps.cmp_len == 0 { + if maps.edges.is_null() + || maps.edges_len == 0 + || maps.cmp.is_null() + || maps.cmp_len == 0 + || maps.compare_log.is_null() + { eprintln!("[libafl] fatal: shared maps are missing"); return RUNTIME_FATAL; } @@ -339,8 +359,12 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let scheduler = IndexesLenTimeMinimizerScheduler::new(&edges_observer, QueueScheduler::new()); let mut fuzzer = StdFuzzer::new(scheduler, feedback, objective); - let mutator = HavocScheduledMutator::new(havoc_mutations().merge(tokens_mutations())); - let mut stages = tuple_list!(StdMutationalStage::new(mutator),); + let mutator = HavocScheduledMutator::new( + havoc_mutations() + .merge(tokens_mutations()) + .merge(tuple_list!(I2SRandReplace::new())), + ); + let mut stages = tuple_list!(ShadowTracingStage::new(), StdMutationalStage::new(mutator),); let stop_requested = Cell::new(false); let fatal_error = Cell::new(false); let timeout_found = Cell::new(false); @@ -348,6 +372,7 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let mut harness = |input: &BytesInput| { clear_shared_map(maps.edges, maps.edges_len); clear_shared_map(maps.cmp, maps.cmp_len); + clear_compare_log(maps.compare_log); let bytes = input.target_bytes(); let bytes = bytes.as_slice(); @@ -375,7 +400,7 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( } }; - let mut executor = match InProcessExecutor::new( + let executor = match InProcessExecutor::new( &mut harness, tuple_list!(edges_observer, cmp_observer), &mut fuzzer, @@ -388,6 +413,8 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( return RUNTIME_FATAL; } }; + let shadow_observer = JazzerCompareLogObserver::new(maps.compare_log); + let mut executor = ShadowExecutor::new(executor, tuple_list!(shadow_observer)); if !corpus_dirs.is_empty() && state.must_load_initial_inputs() { if state diff --git a/packages/fuzzer/shared/callbacks.cpp b/packages/fuzzer/shared/callbacks.cpp index ddbabab6b..59220f6b2 100644 --- a/packages/fuzzer/shared/callbacks.cpp +++ b/packages/fuzzer/shared/callbacks.cpp @@ -34,4 +34,8 @@ void RegisterCallbackExports(Napi::Env env, Napi::Object exports) { 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/tracing.cpp b/packages/fuzzer/shared/tracing.cpp index ce98f6960..5172116ec 100644 --- a/packages/fuzzer/shared/tracing.cpp +++ b/packages/fuzzer/shared/tracing.cpp @@ -31,8 +31,10 @@ void __sanitizer_cov_trace_pc_indir_with_pc(void *caller_pc, uintptr_t callee); } namespace { -constexpr std::size_t kCompareFeedbackMapSize = 1 << 16; std::array gCompareFeedbackMap{}; +JazzerLibAflCompareLog gCompareLog{}; + +constexpr uint8_t kCompareLogSignedFlag = 1 << 0; void RecordCompareFeedback(uint64_t value) { auto index = static_cast(value % kCompareFeedbackMapSize); @@ -40,6 +42,55 @@ void RecordCompareFeedback(uint64_t value) { 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; @@ -70,6 +121,7 @@ void TraceUnequalStrings(const Napi::CallbackInfo &info) { 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. @@ -89,6 +141,8 @@ void TraceStringContainment(const Napi::CallbackInfo &info) { 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 @@ -107,8 +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) ^ + RecordCompareFeedback(static_cast(id) ^ + static_cast(arg1) ^ (static_cast(arg2) << 1)); + RecordIntegerCompareLog(arg1, arg2); __sanitizer_cov_trace_const_cmp8_with_pc(id, arg1, arg2); } @@ -127,7 +183,8 @@ void TracePcIndir(const Napi::CallbackInfo &info) { void ClearCompareFeedbackMap(const Napi::CallbackInfo &info) { if (info.Length() != 0) { - throw Napi::Error::New(info.Env(), "This function does not accept arguments"); + throw Napi::Error::New(info.Env(), + "This function does not accept arguments"); } ClearCompareFeedbackMap(); @@ -135,19 +192,43 @@ void ClearCompareFeedbackMap(const Napi::CallbackInfo &info) { Napi::Value CountNonZeroCompareFeedbackSlots(const Napi::CallbackInfo &info) { if (info.Length() != 0) { - throw Napi::Error::New(info.Env(), "This function does not accept arguments"); + 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; })); + 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 744c603c4..03bef091f 100644 --- a/packages/fuzzer/shared/tracing.h +++ b/packages/fuzzer/shared/tracing.h @@ -17,6 +17,33 @@ #include #include +constexpr std::size_t kCompareFeedbackMapSize = 1 << 16; +constexpr std::size_t kCompareLogEntryBytes = 32; +constexpr std::size_t kCompareLogMaxEntries = 1024; + +enum class JazzerLibAflCompareKind : uint8_t { + kInteger = 1, + kStringEquality = 2, + kStringContainment = 3, +}; + +struct JazzerLibAflCompareLogEntry { + uint8_t kind; + uint8_t flags; + uint8_t left_len; + uint8_t right_len; + uint64_t left_value; + uint64_t right_value; + uint8_t left_bytes[kCompareLogEntryBytes]; + uint8_t right_bytes[kCompareLogEntryBytes]; +}; + +struct JazzerLibAflCompareLog { + uint32_t used; + uint32_t dropped; + JazzerLibAflCompareLogEntry entries[kCompareLogMaxEntries]; +}; + void TraceUnequalStrings(const Napi::CallbackInfo &info); void TraceStringContainment(const Napi::CallbackInfo &info); void TraceIntegerCompare(const Napi::CallbackInfo &info); @@ -24,7 +51,11 @@ 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/tests/engine/engine.test.js b/tests/engine/engine.test.js index 170ac966e..ce1f4ea2d 100644 --- a/tests/engine/engine.test.js +++ b/tests/engine/engine.test.js @@ -14,6 +14,9 @@ * limitations under the License. */ +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); const path = require("path"); const { @@ -24,6 +27,45 @@ const { 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 = []) { + const proc = spawnSync( + "npx", + [ + "jazzer", + "fuzz.js", + "-f", + entryPoint, + "--engine=afl", + "--sync", + "--disable_bug_detectors=.*", + "--", + ...extraFuzzerOptions, + ], + { + cwd, + env: { ...process.env }, + shell: true, + stdio: "pipe", + windowsHide: true, + }, + ); + return { + status: proc.status, + output: proc.stdout.toString() + proc.stderr.toString(), + }; +} + describe("Engine selection", () => { const testDirectory = __dirname; const jestProjectDirectory = path.join(testDirectory, "jest_project"); @@ -46,6 +88,8 @@ describe("Engine selection", () => { .execute(); expect(fuzzTest.stderr).not.toContain("Unknown fuzzing engine"); + expect(fuzzTest.stderr).toContain("[libafl::start] mode: fuzzing"); + expect(fuzzTest.stderr).toContain("[libafl::done] mode: fuzzing"); }); it("rejects unsupported libFuzzer options in LibAFL mode", () => { @@ -61,6 +105,148 @@ describe("Engine selection", () => { 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("[libafl::start] mode: regression"); + 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"); + }); + }); + + 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) diff --git a/tests/engine/fuzz.js b/tests/engine/fuzz.js index 63aab383e..bcc4fe82d 100644 --- a/tests/engine/fuzz.js +++ b/tests/engine/fuzz.js @@ -14,6 +14,10 @@ * 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"); @@ -31,3 +35,42 @@ module.exports.timeout_async = function (_data) { // 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"); + } +}; From be90f36d51af9eb49f04337929fcfaec6e5b3fcb Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:35:44 +0200 Subject: [PATCH 05/26] fix(fuzzer): align LibAFL containment tracing Pass string-containment operands in the order the backend expects so tracing and mutation guidance agree on the same relationship. --- packages/fuzzer/shared/tracing.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/fuzzer/shared/tracing.cpp b/packages/fuzzer/shared/tracing.cpp index 5172116ec..b0c050699 100644 --- a/packages/fuzzer/shared/tracing.cpp +++ b/packages/fuzzer/shared/tracing.cpp @@ -147,7 +147,7 @@ void TraceStringContainment(const Napi::CallbackInfo &info) { // 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()); } From 935ed9533997d74423a13277e5ce82eb2d53082b Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:33:45 +0200 Subject: [PATCH 06/26] feat(fuzzer): tune LibAFL queue scheduling Switch LibAFL onto queue and mutation settings that work with the new guidance model and keep campaign progress moving. --- packages/fuzzer/rust/src/lib.rs | 35 +++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/fuzzer/rust/src/lib.rs b/packages/fuzzer/rust/src/lib.rs index 6f2478a0e..511d90b0d 100644 --- a/packages/fuzzer/rust/src/lib.rs +++ b/packages/fuzzer/rust/src/lib.rs @@ -22,8 +22,10 @@ use libafl::{ I2SRandReplace, Tokens, }, observers::{CanTrack, HitcountsMapObserver, StdMapObserver}, - schedulers::{IndexesLenTimeMinimizerScheduler, QueueScheduler}, - stages::{mutational::StdMutationalStage, shadow::ShadowTracingStage}, + schedulers::{ + powersched::PowerSchedule, IndexesLenTimeMinimizerScheduler, PowerQueueScheduler, + }, + stages::{calibrate::CalibrationStage, shadow::ShadowTracingStage, StdPowerMutationalStage}, state::{HasCorpus, HasExecutions, HasMaxSize, HasSolutions, StdState}, Error, HasMetadata, }; @@ -203,6 +205,22 @@ fn clear_compare_log(ptr: *mut JazzerLibAflCompareLog) { } } +fn ensure_non_empty_edge_map(ptr: *mut u8, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + + unsafe { + let map = std::slice::from_raw_parts_mut(ptr, len); + if map.iter().all(|slot| *slot == 0) { + // 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 the map untouched. + map[0] = 1; + } + } +} + unsafe fn parse_corpus_directories(options: &JazzerLibAflRuntimeOptions) -> Option> { if options.corpus_directories.is_null() || options.corpus_directories_len == 0 { return Some(Vec::new()); @@ -357,14 +375,22 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( } } - let scheduler = IndexesLenTimeMinimizerScheduler::new(&edges_observer, QueueScheduler::new()); + 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!(ShadowTracingStage::new(), StdMutationalStage::new(mutator),); + 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); @@ -378,6 +404,7 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let bytes = bytes.as_slice(); let size = bytes.len().min(options.max_len); let status = unsafe { execute_one(user_data, bytes.as_ptr(), size) }; + ensure_non_empty_edge_map(maps.edges, maps.edges_len); match status { EXECUTION_CONTINUE => ExitKind::Ok, EXECUTION_FINDING => ExitKind::Crash, From 873f764613c7f67b951affa370c267af81c47f47 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:33:55 +0200 Subject: [PATCH 07/26] feat(fuzzer): add structured LibAFL progress output Report startup, corpus loading, heartbeats, and shutdown in a stable format so users can understand a run without reading backend internals. --- packages/fuzzer/libafl_runtime.cpp | 211 ++++++++-- packages/fuzzer/libafl_runtime.h | 10 + packages/fuzzer/rust/src/lib.rs | 640 +++++++++++++++++++++++++---- tests/engine/engine.test.js | 123 +++++- tests/engine/fuzz.js | 24 ++ 5 files changed, 901 insertions(+), 107 deletions(-) diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp index db103adea..694b0f7c9 100644 --- a/packages/fuzzer/libafl_runtime.cpp +++ b/packages/fuzzer/libafl_runtime.cpp @@ -17,11 +17,14 @@ #include #include #include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -37,6 +40,7 @@ #include #ifdef _WIN32 +#include #include #define GetPID _getpid #else @@ -113,16 +117,61 @@ std::string FormatTotalTimeLimit(uint64_t max_total_time_seconds) { return FormatDuration(std::chrono::seconds(max_total_time_seconds)); } +bool ShouldColorizeOutput() { + if (std::getenv("NO_COLOR") != nullptr) { + return false; + } + + const auto *term = std::getenv("TERM"); + if (term != nullptr && std::string(term) == "dumb") { + return false; + } + +#ifdef _WIN32 + return _isatty(_fileno(stderr)) != 0; +#else + return isatty(fileno(stderr)) != 0; +#endif +} + +std::string StartMarker() { + if (!ShouldColorizeOutput()) { + return "[>]"; + } + + return "\x1b[34m[>]\x1b[0m"; +} + +std::string FormatInitedField(const std::string &label, + const std::string &value) { + const auto first = value.find_first_not_of(' '); + const auto trimmed = first == std::string::npos + ? std::string_view("") + : std::string_view(value).substr(first); + std::ostringstream stream; + stream << " " << std::left << std::setw(15) << label << ' ' << trimmed; + return stream.str(); +} + +std::string EmptyEdgesMetric() { return " -/ - ( -%)"; } + void PrintRegressionStart(const ParsedRuntimeOptions &options, size_t replay_inputs) { - std::cerr << "[libafl::start] mode: regression, seed: " << options.seed - << ", replay_inputs: " << replay_inputs - << ", timeout: " << options.timeout_millis - << " ms, max_len: " << options.max_len - << ", runs: " << FormatRunLimit(options.runs) - << ", max_total_time: " - << FormatTotalTimeLimit(options.max_total_time_seconds) - << std::endl; + 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, @@ -207,6 +256,107 @@ using AsyncTsfn = SyncFuzzTargetContext *gActiveSyncContext = nullptr; AsyncFuzzTargetContext *gActiveAsyncContext = nullptr; AsyncTsfn gAsyncTsfn; +JazzerLibAflFindingInfo gFindingInfo{}; + +void ClearFindingInfo() { std::memset(&gFindingInfo, 0, sizeof(gFindingInfo)); } + +void CopyFindingField(char *destination, size_t destination_size, + const std::string &value) { + if (destination == nullptr || destination_size == 0) { + return; + } + + std::memset(destination, 0, destination_size); + const auto copied = std::min(destination_size - 1, value.size()); + if (copied > 0) { + std::memcpy(destination, value.data(), copied); + } +} + +std::string CollapseWhitespace(const std::string &value) { + std::string collapsed; + collapsed.reserve(value.size()); + + bool previous_was_space = false; + for (const auto character : value) { + if (std::isspace(static_cast(character)) != 0) { + if (!collapsed.empty() && !previous_was_space) { + collapsed.push_back(' '); + } + previous_was_space = true; + continue; + } + + collapsed.push_back(character); + previous_was_space = false; + } + + if (!collapsed.empty() && collapsed.back() == ' ') { + collapsed.pop_back(); + } + + return collapsed; +} + +std::string TrimStackFrame(const std::string &frame) { + const auto first = frame.find_first_not_of(" \t"); + if (first == std::string::npos) { + return ""; + } + + auto trimmed = frame.substr(first); + constexpr char kAtPrefix[] = "at "; + if (trimmed.rfind(kAtPrefix, 0) == 0) { + trimmed.erase(0, sizeof(kAtPrefix) - 1); + } + + if (!trimmed.empty() && trimmed.back() == ')') { + const auto open_paren = trimmed.rfind('('); + if (open_paren != std::string::npos && open_paren + 1 < trimmed.size()) { + return trimmed.substr(open_paren + 1, trimmed.size() - open_paren - 2); + } + } + + return trimmed; +} + +std::string DescribeJsError(Napi::Env env, const Napi::Value &error) { + std::string summary = error.ToString().Utf8Value(); + if (!error.IsObject()) { + return CollapseWhitespace(summary); + } + + const auto stack_value = error.As().Get("stack"); + if (!stack_value.IsString()) { + return CollapseWhitespace(summary); + } + + std::istringstream stream(stack_value.As().Utf8Value()); + std::string line; + std::getline(stream, line); + while (std::getline(stream, line)) { + const auto frame = TrimStackFrame(line); + if (frame.empty()) { + continue; + } + summary.append(" in ").append(frame); + break; + } + + return CollapseWhitespace(summary); +} + +std::string DescribeTimeout(uint64_t timeout_millis) { + return "timeout after " + std::to_string(timeout_millis) + " ms"; +} + +void RecordFindingInfo(const std::string &artifact, + const std::string &summary) { + gFindingInfo.has_value = 1; + CopyFindingField(gFindingInfo.artifact, sizeof(gFindingInfo.artifact), + artifact); + CopyFindingField(gFindingInfo.summary, sizeof(gFindingInfo.summary), summary); +} std::string DigestInput(const uint8_t *data, size_t size) { uint64_t hash = 1469598103934665603ULL; @@ -253,10 +403,11 @@ std::filesystem::path ArtifactPath(const std::string &artifact_prefix, return std::filesystem::path(artifact_prefix + filename); } -void WriteArtifact(const std::string &artifact_prefix, const std::string &kind, - const uint8_t *data, size_t size) { +std::string WriteArtifact(const std::string &artifact_prefix, + const std::string &kind, const uint8_t *data, + size_t size, bool emit_info = true) { if (data == nullptr && size != 0) { - return; + return ""; } try { @@ -272,7 +423,7 @@ void WriteArtifact(const std::string &artifact_prefix, const std::string &kind, if (!output.is_open()) { std::cerr << "ERROR: Failed to open artifact file '" << artifact_path.string() << "'" << std::endl; - return; + return ""; } if (size > 0) { @@ -282,14 +433,18 @@ void WriteArtifact(const std::string &artifact_prefix, const std::string &kind, if (!output.good()) { std::cerr << "ERROR: Failed to write artifact file '" << artifact_path.string() << "'" << std::endl; - return; + return ""; } - std::cerr << "INFO: Wrote " << kind << " input to " - << artifact_path.string() << std::endl; + 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 ""; } } @@ -298,7 +453,9 @@ void WriteArtifact(const std::string &artifact_prefix, const std::string &kind, const std::vector &input) { std::cerr << "ERROR: Exceeded timeout of " << timeout_millis << " ms for one fuzz target execution." << std::endl; - WriteArtifact(artifact_prefix, "timeout", input.data(), input.size()); + const auto artifact = + WriteArtifact(artifact_prefix, "timeout", input.data(), input.size()); + RecordFindingInfo(artifact, DescribeTimeout(timeout_millis)); _Exit(libfuzzer::EXIT_ERROR_TIMEOUT); } @@ -336,8 +493,10 @@ void ReportAsyncFinding(AsyncFuzzTargetContext *context, Napi::Env env, const Napi::Value &error, const std::vector &input) { if (TrySetExecutionStatus(state, kExecutionFinding)) { - WriteArtifact(context->options.artifact_prefix, "crash", input.data(), - input.size()); + const auto artifact = + WriteArtifact(context->options.artifact_prefix, "crash", input.data(), + input.size(), false); + RecordFindingInfo(artifact, DescribeJsError(env, error)); } RejectDeferredIfNeeded(context, error); } @@ -440,19 +599,23 @@ ParsedRuntimeOptions ParseRuntimeOptions(Napi::Env env, JazzerLibAflRuntimeSharedMaps SharedMapsForRuntime(Napi::Env env) { auto *edges = CoverageCounters(); - const auto edges_len = CoverageCountersSize(); + const auto edges_capacity = CoverageCountersCapacity(); + auto *edges_size = CoverageCountersSizePointer(); auto *cmp = CompareFeedbackMap(); const auto cmp_len = CompareFeedbackMapSize(); auto *compare_log = CompareLog(); + auto *finding_info = &gFindingInfo; - if (edges == nullptr || edges_len == 0 || cmp == nullptr || cmp_len == 0 || - compare_log == nullptr) { + 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_len, cmp, cmp_len, compare_log}; + return {edges, edges_capacity, edges_size, cmp, + cmp_len, compare_log, finding_info}; } bool CollectRegressionCorpusFiles( @@ -768,7 +931,9 @@ int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { } } catch (const Napi::Error &error) { if (!context->is_resolved) { - WriteArtifact(context->options.artifact_prefix, "crash", data, size); + const auto artifact = WriteArtifact(context->options.artifact_prefix, + "crash", data, size, false); + RecordFindingInfo(artifact, DescribeJsError(context->env, error.Value())); context->is_resolved = true; context->deferred.Reject(error.Value()); } diff --git a/packages/fuzzer/libafl_runtime.h b/packages/fuzzer/libafl_runtime.h index 2fad26912..de1e25ed2 100644 --- a/packages/fuzzer/libafl_runtime.h +++ b/packages/fuzzer/libafl_runtime.h @@ -20,7 +20,16 @@ #include "shared/tracing.h" +constexpr std::size_t kFindingInfoArtifactBytes = 256; +constexpr std::size_t kFindingInfoSummaryBytes = 1024; + extern "C" { +struct JazzerLibAflFindingInfo { + uint8_t has_value; + char artifact[kFindingInfoArtifactBytes]; + char summary[kFindingInfoSummaryBytes]; +}; + struct JazzerLibAflRuntimeOptions { uint64_t runs; uint64_t seed; @@ -39,6 +48,7 @@ struct JazzerLibAflRuntimeSharedMaps { uint8_t *cmp; size_t cmp_len; JazzerLibAflCompareLog *compare_log; + JazzerLibAflFindingInfo *finding_info; }; typedef int (*JazzerLibAflExecuteCallback)(void *user_data, const uint8_t *data, diff --git a/packages/fuzzer/rust/src/lib.rs b/packages/fuzzer/rust/src/lib.rs index 511d90b0d..655c34c16 100644 --- a/packages/fuzzer/rust/src/lib.rs +++ b/packages/fuzzer/rust/src/lib.rs @@ -2,10 +2,13 @@ mod compare_log; use core::ffi::{c_char, c_void}; use core::ptr; -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::ffi::CStr; use std::fs; +use std::io::IsTerminal; use std::path::PathBuf; +use std::rc::Rc; +use std::slice; use std::time::{Duration, Instant}; use libafl::{ @@ -16,12 +19,17 @@ use libafl::{ feedbacks::{CrashFeedback, MaxMapFeedback, TimeoutFeedback}, fuzzer::{Evaluator, Fuzzer, StdFuzzer}, inputs::{BytesInput, HasTargetBytes}, - monitors::{stats::ClientStatsManager, Monitor}, + monitors::{ + stats::{ClientStatsManager, UserStats, UserStatsValue}, + Monitor, + }, mutators::{ havoc_mutations::havoc_mutations, scheduled::HavocScheduledMutator, tokens_mutations, I2SRandReplace, Tokens, }, - observers::{CanTrack, HitcountsMapObserver, StdMapObserver}, + observers::{ + CanTrack, HitcountsMapObserver, StdMapObserver, VariableMapObserver, + }, schedulers::{ powersched::PowerSchedule, IndexesLenTimeMinimizerScheduler, PowerQueueScheduler, }, @@ -49,6 +57,18 @@ const RUNTIME_STOPPED: i32 = 2; const RUNTIME_FATAL: i32 = 3; const RUNTIME_FOUND_TIMEOUT: i32 = 4; +const FINDING_INFO_ARTIFACT_BYTES: usize = 256; +const FINDING_INFO_SUMMARY_BYTES: usize = 1024; +const EXECUTION_FIELD_WIDTH: usize = 10; +const DEFAULT_MONITOR_TIMEOUT: Duration = Duration::from_secs(15); + +#[repr(C)] +pub struct JazzerLibAflFindingInfo { + pub has_value: u8, + pub artifact: [u8; FINDING_INFO_ARTIFACT_BYTES], + pub summary: [u8; FINDING_INFO_SUMMARY_BYTES], +} + #[repr(C)] pub struct JazzerLibAflRuntimeOptions { pub runs: u64, @@ -65,16 +85,75 @@ pub struct JazzerLibAflRuntimeOptions { #[repr(C)] pub struct JazzerLibAflRuntimeSharedMaps { pub edges: *mut u8, - pub edges_len: usize, + pub edges_capacity: usize, + pub edges_size: *mut usize, pub cmp: *mut u8, pub cmp_len: usize, pub compare_log: *mut JazzerLibAflCompareLog, + pub finding_info: *mut JazzerLibAflFindingInfo, } pub type JazzerLibAflExecuteCallback = unsafe extern "C" fn(user_data: *mut c_void, data: *const u8, size: usize) -> i32; -struct LibAflMonitor; +#[derive(Clone, Copy)] +struct RatioMetric { + numerator: u64, + denominator: u64, +} + +#[derive(Clone, Copy)] +struct ProgressSnapshot { + executions: u64, + edges: Option, + corpus_size: u64, + execs_per_sec: f64, + objective_size: u64, + stability: Option, + elapsed: Duration, +} + +struct MonitorState { + campaign_started: bool, + colors_enabled: bool, + last_edges_are_synthetic: bool, + last_status_output_at: Option, + last_progress: Option, +} + +#[derive(Clone, Copy)] +enum StatusEvent { + Testcase, + Heartbeat, + Objective, + Done, +} + +#[derive(Clone)] +struct LibAflMonitor { + state: Rc>, + finding_info: *mut JazzerLibAflFindingInfo, +} + +impl LibAflMonitor { + fn new(finding_info: *mut JazzerLibAflFindingInfo) -> (Self, Rc>) { + let state = Rc::new(RefCell::new(MonitorState { + campaign_started: false, + colors_enabled: should_colorize_output(), + last_edges_are_synthetic: false, + last_status_output_at: None, + last_progress: None, + })); + + ( + Self { + state: state.clone(), + finding_info, + }, + state, + ) + } +} impl Monitor for LibAflMonitor { fn display( @@ -83,47 +162,52 @@ impl Monitor for LibAflMonitor { event_msg: &str, sender_id: ClientId, ) -> Result<(), Error> { - let Some(event_name) = (match event_msg { - "Client Heartbeat" => Some("heartbeat"), - "Testcase" => Some("testcase"), - "Objective" => Some("objective"), - "Log" => Some("log"), + let Some(event) = (match event_msg { + "Testcase" => Some(StatusEvent::Testcase), + "Objective" => Some(StatusEvent::Objective), _ => None, }) else { return Ok(()); }; - let (run_time_pretty, corpus_size, objective_size, total_execs, execs_per_sec_pretty) = { - let global_stats = client_stats_manager.global_stats(); + let (campaign_started, colors_enabled, last_edges_are_synthetic) = { + let state = self.state.borrow(); ( - global_stats.run_time_pretty.clone(), - global_stats.corpus_size, - global_stats.objective_size, - global_stats.total_execs, - global_stats.execs_per_sec_pretty.clone(), + state.campaign_started, + state.colors_enabled, + state.last_edges_are_synthetic, ) }; - let mut user_stats = client_stats_manager - .client_stats_for(sender_id)? - .user_stats() - .iter() - .map(|(key, value)| format!("{key}: {value}")) - .collect::>(); - user_stats.sort(); - let extra = if user_stats.is_empty() { - String::new() - } else { - format!(", {}", user_stats.join(", ")) - }; + 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()); - eprintln!( - "[libafl::{event_name}] run time: {}, corpus: {}, objectives: {}, executions: {}, exec/sec: {}{extra}", - run_time_pretty, - corpus_size, - objective_size, - total_execs, - execs_per_sec_pretty, - ); Ok(()) } } @@ -135,53 +219,387 @@ fn format_duration(duration: Duration) -> String { let seconds = total_seconds % 60; if hours > 0 { - format!("{hours}h {minutes}m {seconds}s") + format!("{hours}h{minutes:02}m{seconds:02}s") } else if minutes > 0 { - format!("{minutes}m {seconds}s") + format!("{minutes}m{seconds:02}s") } else { format!("{seconds}s") } } -fn print_runtime_start(options: &JazzerLibAflRuntimeOptions, loaded_inputs: usize) { - let runs = if options.runs == 0 { - "unlimited".to_string() +fn should_colorize_output() -> bool { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + + if matches!(std::env::var("TERM"), Ok(term) if term == "dumb") { + return false; + } + + std::io::stderr().is_terminal() +} + +fn monitor_timeout() -> Duration { + match std::env::var("JAZZER_LIBAFL_MONITOR_TIMEOUT_MS") { + Ok(value) => value + .parse::() + .ok() + .filter(|timeout| *timeout > 0) + .map(Duration::from_millis) + .unwrap_or(DEFAULT_MONITOR_TIMEOUT), + Err(_) => DEFAULT_MONITOR_TIMEOUT, + } +} + +fn ratio_from_user_stat(user_stat: Option<&UserStats>) -> Option { + let UserStatsValue::Ratio(numerator, denominator) = user_stat?.value() else { + return None; + }; + Some(RatioMetric { + numerator: *numerator, + denominator: *denominator, + }) +} + +fn format_ratio_metric(metric: Option) -> String { + let Some(metric) = metric else { + return " -/ - ( -%)".to_string(); + }; + + if metric.denominator == 0 { + return format!("{:>4}/{:<4} ( -%)", metric.numerator, metric.denominator); + } + + let percentage = metric.numerator.saturating_mul(100) / metric.denominator; + format!( + "{:>4}/{:<4} ({:>3}%)", + metric.numerator, metric.denominator, percentage + ) +} + +fn colorize_marker(marker: &str, sgr_code: &str, colors_enabled: bool) -> String { + if colors_enabled { + format!("\x1b[{sgr_code}m{marker}\x1b[0m") } else { - options.runs.to_string() + 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 max_total_time = if options.max_total_time_seconds == 0 { - "unlimited".to_string() + 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 { - format_duration(Duration::from_secs(options.max_total_time_seconds)) + marker_text(event) + }; + + colorize_marker(marker, event_color_code(event), colors_enabled) +} + +fn format_progress_line( + event: StatusEvent, + snapshot: ProgressSnapshot, + colors_enabled: bool, + in_campaign: bool, +) -> String { + let marker = if colors_enabled && !in_campaign { + progress_marker(event, false, true) + } else { + progress_marker(event, in_campaign, false) + }; + let line = format!( + "{} #{:4} | exec/s: {:>8.1} | obj: {:>3} | stab: {} | t: {}", + marker, + snapshot.executions, + format_ratio_metric(snapshot.edges), + snapshot.corpus_size, + if snapshot.execs_per_sec.is_finite() { + snapshot.execs_per_sec + } else { + 0.0 + }, + snapshot.objective_size, + format_ratio_metric(snapshot.stability), + format_duration(snapshot.elapsed), + width = EXECUTION_FIELD_WIDTH, + ); + + if colors_enabled && in_campaign { + format!("\x1b[{}m{}\x1b[0m", event_color_code(event), line) + } else { + line + } +} + +fn maybe_print_final_init_testcase(state: &mut MonitorState, loaded_inputs: usize) { + let Some(snapshot) = state.last_progress else { + return; }; + if snapshot.corpus_size == 0 + || snapshot.corpus_size.is_power_of_two() + || snapshot.corpus_size != loaded_inputs as u64 + { + return; + } + + eprintln!( + "{}", + format_progress_line(StatusEvent::Testcase, snapshot, state.colors_enabled, false), + ); + state.last_status_output_at = Some(Instant::now()); +} + +fn build_idle_progress_snapshot( + state: &S, + started_at: Instant, + monitor_state: &MonitorState, +) -> ProgressSnapshot +where + S: HasCorpus + HasExecutions + HasSolutions, +{ + let executions = *state.executions(); + let elapsed = started_at.elapsed(); + let execs_per_sec = if elapsed.as_secs_f64() > 0.0 { + executions as f64 / elapsed.as_secs_f64() + } else { + 0.0 + }; + + ProgressSnapshot { + executions, + edges: monitor_state + .last_progress + .and_then(|snapshot| snapshot.edges), + corpus_size: state.corpus().count() as u64, + execs_per_sec, + objective_size: state.solutions().count() as u64, + stability: monitor_state + .last_progress + .and_then(|snapshot| snapshot.stability), + elapsed, + } +} + +fn maybe_emit_idle_heartbeat( + monitor_state: &mut MonitorState, + state: &S, + started_at: Instant, + monitor_timeout: Duration, +) where + S: HasCorpus + HasExecutions + HasSolutions, +{ + let Some(last_status_output_at) = monitor_state.last_status_output_at else { + return; + }; + + if last_status_output_at.elapsed() < monitor_timeout { + return; + } + + let snapshot = build_idle_progress_snapshot(state, started_at, monitor_state); eprintln!( - "[libafl::start] mode: fuzzing, seed: {}, loaded_inputs: {}, timeout: {} ms, max_len: {}, runs: {}, max_total_time: {}", - options.seed, loaded_inputs, options.timeout_millis, options.max_len, runs, max_total_time, + "{}", + 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 { - executions as f64 + 0.0 }; + let edges = last_progress.and_then(|snapshot| snapshot.edges); eprintln!( - "[libafl::done] mode: fuzzing, run time: {}, corpus: {}, objectives: {}, executions: {}, exec/sec: {:.0}", + "{} #{:, + colors_enabled: bool, +) { + let runs = if options.runs == 0 { + "unlimited".to_string() + } else { + options.runs.to_string() + }; + let max_total_time = if options.max_total_time_seconds == 0 { + "unlimited".to_string() + } else { + format_duration(Duration::from_secs(options.max_total_time_seconds)) + }; + + eprintln!( + "{} INITED\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}", + start_marker(colors_enabled), + format_inited_field("mode:", "fuzzing"), + format_inited_field("seed:", options.seed), + format_inited_field("loaded_inputs:", loaded_inputs), + format_inited_field("edges:", format_ratio_metric(edges)), + format_inited_field("timeout:", format!("{} ms", options.timeout_millis)), + format_inited_field("max_len:", options.max_len), + format_inited_field("runs:", runs), + format_inited_field("max_total_time:", max_total_time), ); } @@ -205,22 +623,52 @@ fn clear_compare_log(ptr: *mut JazzerLibAflCompareLog) { } } -fn ensure_non_empty_edge_map(ptr: *mut u8, len: usize) { - if ptr.is_null() || len == 0 { +fn clear_finding_info(ptr: *mut JazzerLibAflFindingInfo) { + if ptr.is_null() { return; } unsafe { - let map = std::slice::from_raw_parts_mut(ptr, len); - if map.iter().all(|slot| *slot == 0) { - // 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 the map untouched. - map[0] = 1; - } + ptr::write_bytes(ptr, 0, 1); + } +} + +fn edge_map_len(maps: &JazzerLibAflRuntimeSharedMaps) -> usize { + if maps.edges_size.is_null() { + 0 + } else { + unsafe { (*maps.edges_size).min(maps.edges_capacity) } } } +fn has_non_zero_coverage(ptr: *mut u8, len: usize) -> bool { + if ptr.is_null() || len == 0 { + return false; + } + + unsafe { slice::from_raw_parts(ptr, len).iter().any(|slot| *slot != 0) } +} + +fn ensure_non_empty_edge_map(ptr: *mut u8, len: usize) -> bool { + if has_non_zero_coverage(ptr, len) { + return false; + } + + if ptr.is_null() || len == 0 { + return false; + } + + unsafe { + let map = slice::from_raw_parts_mut(ptr, len); + // Power scheduling rejects corpus entries that never hit any edge. + // Preserve the old behavior for uninstrumented callbacks by marking + // one synthetic edge only when the target left every coverage region untouched. + map[0] = 1; + } + + true +} + unsafe fn parse_corpus_directories(options: &JazzerLibAflRuntimeOptions) -> Option> { if options.corpus_directories.is_null() || options.corpus_directories_len == 0 { return Some(Vec::new()); @@ -296,10 +744,12 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let options = &*options; let maps = &*maps; if maps.edges.is_null() - || maps.edges_len == 0 + || maps.edges_capacity == 0 + || maps.edges_size.is_null() || maps.cmp.is_null() || maps.cmp_len == 0 || maps.compare_log.is_null() + || maps.finding_info.is_null() { eprintln!("[libafl] fatal: shared maps are missing"); return RUNTIME_FATAL; @@ -328,14 +778,17 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( } }; - let monitor = LibAflMonitor; + let (monitor, monitor_state) = LibAflMonitor::new(maps.finding_info); let mut mgr = SimpleEventManager::new(monitor); - let edges_observer = HitcountsMapObserver::new(StdMapObserver::from_mut_ptr( - "edges", - maps.edges, - maps.edges_len, - )) + let edges_observer = HitcountsMapObserver::new( + VariableMapObserver::from_mut_ptr( + "edges", + maps.edges, + maps.edges_capacity, + maps.edges_size, + ), + ) .track_indices(); let cmp_observer = HitcountsMapObserver::new(StdMapObserver::from_mut_ptr("cmp", maps.cmp, maps.cmp_len)); @@ -396,15 +849,20 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let timeout_found = Cell::new(false); let mut harness = |input: &BytesInput| { - clear_shared_map(maps.edges, maps.edges_len); + clear_shared_map(maps.edges, edge_map_len(maps)); clear_shared_map(maps.cmp, maps.cmp_len); clear_compare_log(maps.compare_log); + clear_finding_info(maps.finding_info); let bytes = input.target_bytes(); let bytes = bytes.as_slice(); let size = bytes.len().min(options.max_len); let status = unsafe { execute_one(user_data, bytes.as_ptr(), size) }; - ensure_non_empty_edge_map(maps.edges, maps.edges_len); + let synthetic_edges = ensure_non_empty_edge_map( + maps.edges, + edge_map_len(maps), + ); + monitor_state.borrow_mut().last_edges_are_synthetic = synthetic_edges; match status { EXECUTION_CONTINUE => ExitKind::Ok, EXECUTION_FINDING => ExitKind::Crash, @@ -462,9 +920,23 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( return RUNTIME_FATAL; } - print_runtime_start(options, state.corpus().count()); + { + let mut monitor_state = monitor_state.borrow_mut(); + maybe_print_final_init_testcase(&mut monitor_state, state.corpus().count()); + print_runtime_start( + options, + state.corpus().count(), + monitor_state + .last_progress + .and_then(|snapshot| snapshot.edges), + monitor_state.colors_enabled, + ); + monitor_state.last_status_output_at = Some(Instant::now()); + monitor_state.campaign_started = true; + } let started_at = Instant::now(); + let monitor_timeout = monitor_timeout(); let max_total_time = if options.max_total_time_seconds == 0 { None } else { @@ -473,16 +945,16 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let initial_executions = *state.executions(); let mut status = RUNTIME_OK; - loop { + let done_reason = loop { if options.runs != 0 && state.executions().saturating_sub(initial_executions) >= options.runs { - break; + break "runs"; } if let Some(max_total_time) = max_total_time { if started_at.elapsed() >= max_total_time { status = RUNTIME_STOPPED; - break; + break "max_total_time"; } } @@ -504,15 +976,25 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( if stop_requested.get() { status = RUNTIME_STOPPED; - break; + 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().saturating_sub(initial_executions), - state.corpus().count(), + *state.executions(), state.solutions().count(), + monitor_state.last_progress, + monitor_state.colors_enabled, ); status diff --git a/tests/engine/engine.test.js b/tests/engine/engine.test.js index ce1f4ea2d..c2e15dad1 100644 --- a/tests/engine/engine.test.js +++ b/tests/engine/engine.test.js @@ -38,7 +38,7 @@ async function withTempGuidanceDirectory(callback) { } } -function runLibAflCli(cwd, entryPoint, extraFuzzerOptions = []) { +function runLibAflCli(cwd, entryPoint, extraFuzzerOptions = [], extraEnv = {}) { const proc = spawnSync( "npx", [ @@ -54,7 +54,7 @@ function runLibAflCli(cwd, entryPoint, extraFuzzerOptions = []) { ], { cwd, - env: { ...process.env }, + env: { ...process.env, ...extraEnv }, shell: true, stdio: "pipe", windowsHide: true, @@ -66,6 +66,10 @@ function runLibAflCli(cwd, entryPoint, extraFuzzerOptions = []) { }; } +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"); @@ -88,8 +92,106 @@ describe("Engine selection", () => { .execute(); expect(fuzzTest.stderr).not.toContain("Unknown fuzzing engine"); - expect(fuzzTest.stderr).toContain("[libafl::start] mode: fuzzing"); - expect(fuzzTest.stderr).toContain("[libafl::done] mode: fuzzing"); + 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", + [ + "-max_total_time=1", + "-seed=1337", + "-max_len=32", + `-artifact_prefix=${directory}${path.sep}`, + ], + { JAZZER_LIBAFL_MONITOR_TIMEOUT_MS: "50" }, + ); + + expect(status).toBe(0); + const testcaseLine = findOutputLine(output, "[i]"); + const heartbeatLine = findOutputLine(output, "[*]"); + + expect(testcaseLine).toBeDefined(); + expect(heartbeatLine).toBeDefined(); + expect(testcaseLine.indexOf("| edges:")).toBe( + heartbeatLine.indexOf("| edges:"), + ); + expect(testcaseLine.indexOf("| corp:")).toBe( + heartbeatLine.indexOf("| corp:"), + ); + expect(testcaseLine.indexOf("| exec/s:")).toBe( + heartbeatLine.indexOf("| exec/s:"), + ); + expect(testcaseLine.indexOf("| obj:")).toBe( + heartbeatLine.indexOf("| obj:"), + ); + expect(testcaseLine.indexOf("| stab:")).toBe( + heartbeatLine.indexOf("| stab:"), + ); + expect(testcaseLine.indexOf("| t:")).toBe( + heartbeatLine.indexOf("| t:"), + ); + + expect(output).toContain("[=] #"); + expect(output).toContain("| DONE"); + expect(output).toContain("reason: max_total_time"); + expect(output).toContain("time: "); + expect(output).toContain("edges: "); + expect(output).toContain("crashes: 0"); + expect(output).toContain("speed: "); + }); + }); + + it("only reports power-of-two testcase milestones while loading corpus", async () => { + await withTempGuidanceDirectory(async (directory) => { + const corpusDirectory = path.join(directory, "seed-corpus"); + await fs.promises.mkdir(corpusDirectory, { recursive: true }); + + for (let i = 1; i <= 6; i++) { + await fs.promises.writeFile( + path.join(corpusDirectory, `seed-${i}.txt`), + Buffer.from([i]), + ); + } + + const { status, output } = runLibAflCli( + testDirectory, + "seed_progress", + [ + corpusDirectory, + "-runs=1", + "-seed=1337", + "-max_len=32", + `-artifact_prefix=${directory}${path.sep}`, + ], + { JAZZER_LIBAFL_MONITOR_TIMEOUT_MS: "1" }, + ); + + expect(status).toBe(0); + const initOutput = output.split("[>] INITED", 1)[0]; + expect(initOutput).not.toContain("[*]"); + const testcaseLines = initOutput + .split(/\r?\n/) + .filter((line) => line.startsWith("[i]")); + + expect(testcaseLines).toHaveLength(4); + expect(testcaseLines[0]).toContain("| corp: 1 |"); + expect(testcaseLines[1]).toContain("| corp: 2 |"); + expect(testcaseLines[2]).toContain("| corp: 4 |"); + expect(testcaseLines[3]).toContain("| corp: 6 |"); + }); }); it("rejects unsupported libFuzzer options in LibAFL mode", () => { @@ -139,7 +241,15 @@ describe("Engine selection", () => { expect(proc.status).toBe(Number(FuzzingExitCode)); const output = proc.stdout.toString() + proc.stderr.toString(); - expect(output).toContain("[libafl::start] mode: regression"); + 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, { @@ -195,6 +305,9 @@ describe("Engine selection", () => { 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/, + ); }); }); diff --git a/tests/engine/fuzz.js b/tests/engine/fuzz.js index bcc4fe82d..217cf2049 100644 --- a/tests/engine/fuzz.js +++ b/tests/engine/fuzz.js @@ -74,3 +74,27 @@ module.exports.dictionary_target = function (data) { 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; + } +}; From 5c16b7b1c6e5e14453a3d7bc6b13a51c80a7ea4f Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:34:04 +0200 Subject: [PATCH 08/26] fix(instrumentor): preserve lazy ESM coverage in LibAFL Keep late-loaded ESM modules on the shared edge map so LibAFL sees the real coverage they produce instead of falling back to synthetic edges. --- packages/fuzzer/addon.ts | 1 - packages/fuzzer/coverage.ts | 37 ++-- packages/fuzzer/libafl_runtime.cpp | 8 +- packages/fuzzer/libafl_runtime.h | 3 +- packages/fuzzer/rust/src/lib.rs | 20 +- packages/fuzzer/shared/callbacks.cpp | 2 - packages/fuzzer/shared/coverage.cpp | 31 +-- packages/fuzzer/shared/coverage.h | 3 +- packages/instrumentor/edgeIdStrategy.ts | 207 +++++++++++------- packages/instrumentor/esm-loader.mts | 6 +- packages/instrumentor/esmSourceMaps.test.ts | 2 +- packages/instrumentor/instrument.ts | 8 + tests/esm_cjs_mixed/esm_cjs_mixed.test.js | 17 ++ .../esm_instrumentation.test.js | 17 ++ tests/esm_instrumentation/fuzz-lazy.mjs | 23 ++ 15 files changed, 240 insertions(+), 145 deletions(-) create mode 100644 tests/esm_instrumentation/fuzz-lazy.mjs diff --git a/packages/fuzzer/addon.ts b/packages/fuzzer/addon.ts index 9f7ee6081..8ce4db429 100644 --- a/packages/fuzzer/addon.ts +++ b/packages/fuzzer/addon.ts @@ -61,7 +61,6 @@ export type StartLibAflAsyncFn = ( type NativeAddon = { registerCoverageMap: (buffer: Buffer) => void; registerNewCounters: (oldNumCounters: number, newNumCounters: number) => void; - registerModuleCounters: (buffer: Buffer) => void; traceUnequalStrings: ( hookId: number, diff --git a/packages/fuzzer/coverage.ts b/packages/fuzzer/coverage.ts index acc807686..5efc3bc42 100644 --- a/packages/fuzzer/coverage.ts +++ b/packages/fuzzer/coverage.ts @@ -16,17 +16,23 @@ import { addon } from "./addon"; +type CoverageRangeAllocator = (filename: string, edgeCount: number) => number; + +function getCoverageRangeAllocator(): CoverageRangeAllocator { + const allocator = (globalThis as Record) + .__jazzer_reserveCoverageRange; + if (typeof allocator !== "function") { + throw new Error("Coverage range allocator was not initialized"); + } + return allocator as CoverageRangeAllocator; +} + export class CoverageTracker { private static readonly MAX_NUM_COUNTERS: number = 1 << 20; private static readonly INITIAL_NUM_COUNTERS: number = 1 << 9; private readonly coverageMap: Buffer; private currentNumCounters: number; - // Per-module counter buffers registered independently with libFuzzer. - // We must prevent GC from reclaiming these while libFuzzer still - // monitors the underlying memory. - private readonly moduleCounters: Buffer[] = []; - constructor() { this.coverageMap = Buffer.alloc(CoverageTracker.MAX_NUM_COUNTERS, 0); this.currentNumCounters = CoverageTracker.INITIAL_NUM_COUNTERS; @@ -71,16 +77,17 @@ export class CoverageTracker { return this.coverageMap.readUint8(edgeId); } - /** - * Allocate an independent counter buffer for a single module and - * register it with libFuzzer as a new coverage region. This lets - * each ESM module own its own counters without sharing global IDs. - */ - createModuleCounters(size: number): Buffer { - const buf = Buffer.alloc(size, 0); - this.moduleCounters.push(buf); - addon.registerModuleCounters(buf); - return buf; + createModuleCounters(filename: string, edgeCount: number): Buffer { + if (!Number.isInteger(edgeCount) || edgeCount < 0) { + throw new Error(`Invalid edge count: ${edgeCount}`); + } + if (edgeCount === 0) { + return Buffer.alloc(0); + } + + const firstEdgeId = getCoverageRangeAllocator()(filename, edgeCount); + this.enlargeCountersBufferIfNeeded(firstEdgeId + edgeCount - 1); + return this.coverageMap.subarray(firstEdgeId, firstEdgeId + edgeCount); } } diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp index 694b0f7c9..f2073ddb0 100644 --- a/packages/fuzzer/libafl_runtime.cpp +++ b/packages/fuzzer/libafl_runtime.cpp @@ -346,10 +346,6 @@ std::string DescribeJsError(Napi::Env env, const Napi::Value &error) { return CollapseWhitespace(summary); } -std::string DescribeTimeout(uint64_t timeout_millis) { - return "timeout after " + std::to_string(timeout_millis) + " ms"; -} - void RecordFindingInfo(const std::string &artifact, const std::string &summary) { gFindingInfo.has_value = 1; @@ -453,9 +449,7 @@ std::string WriteArtifact(const std::string &artifact_prefix, const std::vector &input) { std::cerr << "ERROR: Exceeded timeout of " << timeout_millis << " ms for one fuzz target execution." << std::endl; - const auto artifact = - WriteArtifact(artifact_prefix, "timeout", input.data(), input.size()); - RecordFindingInfo(artifact, DescribeTimeout(timeout_millis)); + WriteArtifact(artifact_prefix, "timeout", input.data(), input.size()); _Exit(libfuzzer::EXIT_ERROR_TIMEOUT); } diff --git a/packages/fuzzer/libafl_runtime.h b/packages/fuzzer/libafl_runtime.h index de1e25ed2..56f7bdc32 100644 --- a/packages/fuzzer/libafl_runtime.h +++ b/packages/fuzzer/libafl_runtime.h @@ -44,7 +44,8 @@ struct JazzerLibAflRuntimeOptions { struct JazzerLibAflRuntimeSharedMaps { uint8_t *edges; - size_t edges_len; + size_t edges_capacity; + size_t *edges_size; uint8_t *cmp; size_t cmp_len; JazzerLibAflCompareLog *compare_log; diff --git a/packages/fuzzer/rust/src/lib.rs b/packages/fuzzer/rust/src/lib.rs index 655c34c16..43c3d4b32 100644 --- a/packages/fuzzer/rust/src/lib.rs +++ b/packages/fuzzer/rust/src/lib.rs @@ -347,26 +347,16 @@ fn build_progress_snapshot( }) } -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, + highlight_full_line: bool, ) -> String { - let marker = if colors_enabled && !in_campaign { - progress_marker(event, false, true) + let marker = if colors_enabled && !highlight_full_line { + marker_for_event(event, true) } else { - progress_marker(event, in_campaign, false) + marker_text(event).to_string() }; let line = format!( "{} #{:4} | exec/s: {:>8.1} | obj: {:>3} | stab: {} | t: {}", @@ -385,7 +375,7 @@ fn format_progress_line( width = EXECUTION_FIELD_WIDTH, ); - if colors_enabled && in_campaign { + if colors_enabled && highlight_full_line { format!("\x1b[{}m{}\x1b[0m", event_color_code(event), line) } else { line diff --git a/packages/fuzzer/shared/callbacks.cpp b/packages/fuzzer/shared/callbacks.cpp index 59220f6b2..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"] = diff --git a/packages/fuzzer/shared/coverage.cpp b/packages/fuzzer/shared/coverage.cpp index ad66dfeb2..fc5b0876d 100644 --- a/packages/fuzzer/shared/coverage.cpp +++ b/packages/fuzzer/shared/coverage.cpp @@ -25,8 +25,10 @@ void __sanitizer_cov_pcs_init(const uintptr_t *pcs_beg, namespace { // Shared coverage counter buffer populated from JavaScript using Buffer. -// Individual slices are registered with libFuzzer by RegisterNewCounters. +// It is preallocated on the JavaScript side; registerNewCounters grows the +// active prefix that the fuzzing backends should observe. uint8_t *gCoverageCounters = nullptr; +std::size_t gCoverageCountersCapacity = 0; std::size_t gCoverageCountersSize = 0; // PC-Table is used by libFuzzer to keep track of program addresses @@ -78,6 +80,7 @@ void RegisterCoverageMap(const Napi::CallbackInfo &info) { auto buf = info[0].As>(); gCoverageCounters = reinterpret_cast(buf.Data()); + gCoverageCountersCapacity = buf.Length(); } void RegisterNewCounters(const Napi::CallbackInfo &info) { @@ -98,6 +101,10 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { info.Env(), "new_num_counters must not be smaller than old_num_counters"); } + if (static_cast(new_num_counters) > gCoverageCountersCapacity) { + throw Napi::Error::New(info.Env(), + "new_num_counters exceeds the coverage map size"); + } if (new_num_counters == old_num_counters) { return; } @@ -107,28 +114,14 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { gCoverageCountersSize = static_cast(new_num_counters); } -// Register an independent coverage counter region for a single ES module. -// libFuzzer supports multiple disjoint counter regions; each call here -// hands it a fresh one. -void RegisterModuleCounters(const Napi::CallbackInfo &info) { - if (info.Length() != 1 || !info[0].IsBuffer()) { - throw Napi::Error::New(info.Env(), - "Need one argument: a Buffer of 8-bit counters"); - } - - auto buf = info[0].As>(); - auto size = buf.Length(); - if (size == 0) { - return; - } - - RegisterCounterRange(buf.Data(), buf.Data() + size); -} - uint8_t *CoverageCounters() { return gCoverageCounters; } +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; diff --git a/packages/fuzzer/shared/coverage.h b/packages/fuzzer/shared/coverage.h index 42c126b09..ac84c7514 100644 --- a/packages/fuzzer/shared/coverage.h +++ b/packages/fuzzer/shared/coverage.h @@ -19,8 +19,9 @@ 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/instrumentor/edgeIdStrategy.ts b/packages/instrumentor/edgeIdStrategy.ts index dfc0c25e3..5ecac38ef 100644 --- a/packages/instrumentor/edgeIdStrategy.ts +++ b/packages/instrumentor/edgeIdStrategy.ts @@ -40,6 +40,7 @@ if (process.listeners) { export interface EdgeIdStrategy { nextEdgeId(): number; + reserveEdgeRange(filename: string, idCount: number): number; startForSourceFile(filename: string): void; commitIdCount(filename: string): void; } @@ -52,6 +53,15 @@ export abstract class IncrementingEdgeIdStrategy implements EdgeIdStrategy { return this._nextEdgeId++; } + reserveEdgeRange(_filename: string, idCount: number): number { + if (!Number.isInteger(idCount) || idCount < 0) { + throw new Error(`Invalid edge count: ${idCount}`); + } + const firstId = this._nextEdgeId; + this._nextEdgeId += idCount; + return firstId; + } + abstract startForSourceFile(filename: string): void; abstract commitIdCount(filename: string): void; } @@ -76,6 +86,29 @@ interface EdgeIdInfo { idCount: number; } +function parseIdInfoLine(line: string): EdgeIdInfo { + const parts = line.split(","); + if (parts.length !== 3) { + throw new Error( + `Expected ID file line to be ,,, got ` + + `"${line}"`, + ); + } + return { + filename: parts[0], + firstId: parseInt(parts[1], 10), + idCount: parseInt(parts[2], 10), + }; +} + +function nextFreeId(idInfo: EdgeIdInfo[]): number { + if (idInfo.length === 0) { + return 0; + } + const last = idInfo[idInfo.length - 1]; + return last.firstId + last.idCount; +} + /** * A strategy for edge ID generation that synchronizes the IDs assigned to a source file * with other processes via the specified `idSyncFile`. The edge information stored as a @@ -95,93 +128,69 @@ export class FileSyncIdStrategy extends IncrementingEdgeIdStrategy { } startForSourceFile(filename: string): void { - // We resort to busy waiting since the `Transformer` required by istanbul's `hookRequire` - // must be a synchronous function returning the transformed code. - for (;;) { - const isLocked = lock.checkSync(this.idSyncFile); - if (isLocked) { - // If the ID sync file is already locked, wait for a random period of time - // between 0 and 100 milliseconds. Waiting for different periods reduces - // the chance of all processes wanting to acquire the lock at the same time. - this.wait(this.randomIntFromInterval(0, 100)); - continue; - } - try { - // Acquire the lock for the ID sync file and look for the initial edge ID and - // corresponding number of inserted counters. - this.releaseLockOnSyncFile = lock.lockSync(this.idSyncFile); - const idInfo = fs - .readFileSync(this.idSyncFile, "utf8") - .toString() - .split(os.EOL) - .filter((line) => line.length !== 0) - .map((line): EdgeIdInfo => { - const parts = line.split(","); - if (parts.length !== 3) { - lock.unlockSync(this.idSyncFile); - throw Error( - `Expected ID file line to be of the form ,,", got "${line}"`, - ); - } - return { - filename: parts[0], - firstId: parseInt(parts[1], 10), - idCount: parseInt(parts[2], 10), - }; - }); - const idInfoForFile = idInfo.filter( - (info) => info.filename === filename, - ); + const idInfo = this.acquireLockAndReadIdInfo(); + const idInfoForFile = idInfo.filter((info) => info.filename === filename); - switch (idInfoForFile.length) { - case 0: - // We are the first to encounter this source file and thus need to hold the lock - // until the file has been instrumented and we know the required number of edge IDs. - // - // Compute the next free ID as the maximum over the sums of first ID and ID count, starting at 0 if - // this is the first ID to be assigned. Since this is the only way new lines are added to - // the file, the maximum is always attained by the last line. - this.firstEdgeId = - idInfo.length !== 0 - ? idInfo[idInfo.length - 1].firstId + - idInfo[idInfo.length - 1].idCount - : 0; - break; - case 1: - // This source file has already been instrumented elsewhere, so we just return the first ID and - // ID count reported from there and release the lock right away. The caller is still expected - // to call commitIdCount. - this.firstEdgeId = idInfoForFile[0].firstId; - this.cachedIdCount = idInfoForFile[0].idCount; - this.releaseLockOnSyncFile(); - break; - default: - this.releaseLockOnSyncFile(); - console.error( - `ERROR: Multiple entries for ${filename} in ID sync file`, - ); - process.exit(FileSyncIdStrategy.fatalExitCode); - } + switch (idInfoForFile.length) { + case 0: + // Keep the lock until commitIdCount() records the final range. + this.firstEdgeId = nextFreeId(idInfo); + this.cachedIdCount = undefined; break; - } catch (e) { - // Retry to wait for the lock to be release it is acquired by another process - // in the time window between last successful check and trying to acquire it. - if (this.isLockAlreadyHeldError(e)) { - continue; - } + case 1: + this.firstEdgeId = idInfoForFile[0].firstId; + this.cachedIdCount = idInfoForFile[0].idCount; + this.releaseLock(); + break; + default: + this.releaseLock(); + console.error( + `ERROR: Multiple entries for ${filename} in ID sync file`, + ); + process.exit(FileSyncIdStrategy.fatalExitCode); + } - // Before rethrowing the exception, release the lock if we have already acquired it. - if (this.releaseLockOnSyncFile !== undefined) { - this.releaseLockOnSyncFile(); - } + this._nextEdgeId = this.firstEdgeId; + } - // Stop waiting for the lock if we encounter other errors. Also, rethrow the error. - throw e; + reserveEdgeRange(filename: string, idCount: number): number { + const idInfo = this.acquireLockAndReadIdInfo(); + try { + const idInfoForFile = idInfo.filter((info) => info.filename === filename); + switch (idInfoForFile.length) { + case 0: { + const firstId = nextFreeId(idInfo); + fs.appendFileSync( + this.idSyncFile, + `${filename},${firstId},${idCount}${os.EOL}`, + ); + this._nextEdgeId = Math.max(this._nextEdgeId, firstId + idCount); + return firstId; + } + case 1: + if (idInfoForFile[0].idCount !== idCount) { + throw new Error( + `${filename} has ${idCount} edges, but ` + + `${idInfoForFile[0].idCount} edges reserved in ` + + "ID sync file", + ); + } + this._nextEdgeId = Math.max( + this._nextEdgeId, + idInfoForFile[0].firstId + idCount, + ); + return idInfoForFile[0].firstId; + default: + console.error( + `ERROR: Multiple entries for ${filename} in ID sync file`, + ); + process.exit(FileSyncIdStrategy.fatalExitCode); } + } finally { + this.releaseLock(); } - - this._nextEdgeId = this.firstEdgeId; } + commitIdCount(filename: string): void { if (this.firstEdgeId === undefined) { throw Error("commitIdCount() is called before startForSourceFile()"); @@ -210,13 +219,43 @@ export class FileSyncIdStrategy extends IncrementingEdgeIdStrategy { this.idSyncFile, `${filename},${this.firstEdgeId},${usedIdsCount}${os.EOL}`, ); - this.releaseLockOnSyncFile(); - this.releaseLockOnSyncFile = undefined; + this.releaseLock(); this.firstEdgeId = undefined; this.cachedIdCount = undefined; } } + private acquireLockAndReadIdInfo(): EdgeIdInfo[] { + for (;;) { + if (lock.checkSync(this.idSyncFile)) { + this.wait(this.randomIntFromInterval(0, 100)); + continue; + } + try { + this.releaseLockOnSyncFile = lock.lockSync(this.idSyncFile); + return fs + .readFileSync(this.idSyncFile, "utf8") + .toString() + .split(os.EOL) + .filter((line) => line.length !== 0) + .map(parseIdInfoLine); + } catch (e) { + if (this.isLockAlreadyHeldError(e)) { + continue; + } + this.releaseLock(); + throw e; + } + } + } + + private releaseLock() { + if (this.releaseLockOnSyncFile !== undefined) { + this.releaseLockOnSyncFile(); + this.releaseLockOnSyncFile = undefined; + } + } + private wait(timeout: number) { // This is a workaround to synchronously sleep for a `timout` milliseconds. // The static Atomics.wait() method verifies that a given position in an Int32Array @@ -241,6 +280,10 @@ export class ZeroEdgeIdStrategy implements EdgeIdStrategy { return 0; } + reserveEdgeRange(_filename: string, _idCount: number): number { + return 0; + } + startForSourceFile(filename: string): void { // Nothing to do here } diff --git a/packages/instrumentor/esm-loader.mts b/packages/instrumentor/esm-loader.mts index 72ec199c4..5c4864062 100644 --- a/packages/instrumentor/esm-loader.mts +++ b/packages/instrumentor/esm-loader.mts @@ -167,6 +167,10 @@ function instrumentModule(code: string, filename: string): string | null { filename, sourceFileName: filename, sourceMaps: true, + // Ignore host-project Babel config so ESM instrumentation keeps the + // module format intact and doesn't inherit target-specific transforms. + babelrc: false, + configFile: false, plugins, sourceType: "module", }); @@ -187,7 +191,7 @@ function instrumentModule(code: string, filename: string): string | null { // SourceMapRegistry so that source-map-support can remap stack // traces back to the original source. const preambleLines = [ - `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${edges});`, + `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${JSON.stringify(filename)}, ${edges});`, ]; if (transformed.map) { diff --git a/packages/instrumentor/esmSourceMaps.test.ts b/packages/instrumentor/esmSourceMaps.test.ts index 27900356a..5101cbe11 100644 --- a/packages/instrumentor/esmSourceMaps.test.ts +++ b/packages/instrumentor/esmSourceMaps.test.ts @@ -52,7 +52,7 @@ function instrumentModule( } const preambleLines = [ - `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${edges});`, + `const ${COUNTER_ARRAY} = Fuzzer.coverageTracker.createModuleCounters(${JSON.stringify(filename)}, ${edges});`, ]; let shiftedMap: SourceMap | null = null; diff --git a/packages/instrumentor/instrument.ts b/packages/instrumentor/instrument.ts index d1cd194f3..d7b66c879 100644 --- a/packages/instrumentor/instrument.ts +++ b/packages/instrumentor/instrument.ts @@ -104,6 +104,10 @@ export class Instrumentor { filename: string, map: SourceMap, ) => registry.registerSourceMap(filename, map); + (globalThis as Record).__jazzer_reserveCoverageRange = ( + filename: string, + idCount: number, + ) => this.idStrategy.reserveEdgeRange(filename, idCount); return this.sourceMapRegistry.installSourceMapSupport(); } @@ -187,6 +191,10 @@ export class Instrumentor { filename: filename, sourceFileName: filename, sourceMaps: true, + // Ignore host-project Babel config so Jazzer's runtime transforms stay + // deterministic and don't pick up polyfill injection from the fuzz target. + babelrc: false, + configFile: false, plugins: plugins, ...options, }); diff --git a/tests/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 85b8e961264a9ea9b3197cddbf1f1bc8c8ef5519 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:34:13 +0200 Subject: [PATCH 09/26] refactor(fuzzer): split LibAFL runtime helpers Pull findings, option parsing, regression replay, and ABI definitions out of the main runtime file so backend work can change in smaller pieces. Keep the Rust build wiring and shared ABI local to the LibAFL backend instead of spreading those details through the addon. --- .prettierignore | 1 + packages/fuzzer/.gitignore | 1 + packages/fuzzer/CMakeLists.txt | 14 +- packages/fuzzer/libafl_findings.cpp | 262 +++++++++ packages/fuzzer/libafl_findings.h | 41 ++ packages/fuzzer/libafl_options.cpp | 135 +++++ packages/fuzzer/libafl_options.h | 46 ++ packages/fuzzer/libafl_regression.cpp | 326 +++++++++++ packages/fuzzer/libafl_regression.h | 23 + packages/fuzzer/libafl_runtime.cpp | 765 +++----------------------- packages/fuzzer/libafl_runtime.h | 45 +- packages/fuzzer/rust/src/lib.rs | 134 ++++- packages/fuzzer/shared/libafl_abi.h | 138 +++++ packages/fuzzer/shared/tracing.h | 29 +- 14 files changed, 1166 insertions(+), 794 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/shared/libafl_abi.h 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/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 9dad180e0..478c060d1 100644 --- a/packages/fuzzer/CMakeLists.txt +++ b/packages/fuzzer/CMakeLists.txt @@ -105,23 +105,33 @@ else() 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_CRATE_DIR}/Cargo.toml + ${CARGO_EXECUTABLE} build --manifest-path ${RUST_CARGO_TOML} --target ${RUST_TARGET_TRIPLE} ${CARGO_PROFILE_FLAG} WORKING_DIRECTORY ${RUST_CRATE_DIR} - DEPENDS ${RUST_CRATE_DIR}/Cargo.toml ${RUST_CRATE_DIR}/src/lib.rs + 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 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..19126fb4d --- /dev/null +++ b/packages/fuzzer/libafl_options.cpp @@ -0,0 +1,135 @@ +// 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 seed = js_opts.Get("seed"); + const auto max_len = js_opts.Get("maxLen"); + const auto timeout_millis = js_opts.Get("timeoutMillis"); + const auto max_total_time_seconds = js_opts.Get("maxTotalTimeSeconds"); + const auto artifact_prefix = js_opts.Get("artifactPrefix"); + const auto corpus_directories = js_opts.Get("corpusDirectories"); + const auto dictionary_files = js_opts.Get("dictionaryFiles"); + + if (!mode.IsUndefined() && !mode.IsString()) { + throw Napi::Error::New( + env, "The LibAFL options object expects mode to be 'fuzzing' or " + "'regression'"); + } + + if (!runs.IsNumber() || !seed.IsNumber() || !max_len.IsNumber() || + !timeout_millis.IsNumber() || !max_total_time_seconds.IsNumber() || + !artifact_prefix.IsString() || !corpus_directories.IsArray() || + !dictionary_files.IsArray()) { + throw Napi::Error::New( + env, "The LibAFL backend expects an options object with mode, runs, " + "seed, maxLen, timeoutMillis, maxTotalTimeSeconds, " + "artifactPrefix, corpusDirectories, and dictionaryFiles"); + } + + if (mode.IsString()) { + const auto mode_value = mode.As().Utf8Value(); + if (mode_value == "regression") { + parsed.mode = 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.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..ead44666b --- /dev/null +++ b/packages/fuzzer/libafl_options.h @@ -0,0 +1,46 @@ +// 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; + 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 index f2073ddb0..f8eade886 100644 --- a/packages/fuzzer/libafl_runtime.cpp +++ b/packages/fuzzer/libafl_runtime.cpp @@ -14,6 +14,10 @@ #include "libafl_runtime.h" +#include "libafl_findings.h" +#include "libafl_options.h" +#include "libafl_regression.h" + #include #include #include @@ -54,140 +58,17 @@ #include "utils.h" namespace { -constexpr int kExecutionContinue = 0; -constexpr int kExecutionFinding = 1; -constexpr int kExecutionStop = 2; -constexpr int kExecutionFatal = 3; -constexpr int kExecutionTimeout = 4; - -constexpr int kRuntimeOk = 0; -constexpr int kRuntimeFoundFinding = 1; -constexpr int kRuntimeStopped = 2; -constexpr int kRuntimeFatal = 3; -constexpr int kRuntimeFoundTimeout = 4; - -struct ParsedRuntimeOptions { - enum class Mode { - kFuzzing, - kRegression, - }; - - Mode mode = Mode::kFuzzing; - uint64_t runs = 0; - uint64_t seed = 1; - size_t max_len = 4096; - uint64_t timeout_millis = 5000; - uint64_t max_total_time_seconds = 0; - std::string artifact_prefix; - std::vector corpus_directories; - std::vector dictionary_files; -}; - -std::string FormatDuration(std::chrono::steady_clock::duration duration) { - const auto total_seconds = - std::chrono::duration_cast(duration).count(); - const auto hours = total_seconds / 3600; - const auto minutes = (total_seconds % 3600) / 60; - const auto seconds = total_seconds % 60; - - std::ostringstream stream; - if (hours > 0) { - stream << hours << "h " << minutes << "m " << seconds << "s"; - } else if (minutes > 0) { - stream << minutes << "m " << seconds << "s"; - } else { - stream << seconds << "s"; - } - return stream.str(); -} - -std::string FormatRunLimit(uint64_t runs) { - if (runs == 0) { - return "unlimited"; - } - - return std::to_string(runs); -} - -std::string FormatTotalTimeLimit(uint64_t max_total_time_seconds) { - if (max_total_time_seconds == 0) { - return "unlimited"; - } - - return FormatDuration(std::chrono::seconds(max_total_time_seconds)); -} - -bool ShouldColorizeOutput() { - if (std::getenv("NO_COLOR") != nullptr) { - return false; - } - - const auto *term = std::getenv("TERM"); - if (term != nullptr && std::string(term) == "dumb") { - return false; - } - -#ifdef _WIN32 - return _isatty(_fileno(stderr)) != 0; -#else - return isatty(fileno(stderr)) != 0; -#endif -} - -std::string StartMarker() { - if (!ShouldColorizeOutput()) { - return "[>]"; - } - - return "\x1b[34m[>]\x1b[0m"; -} - -std::string FormatInitedField(const std::string &label, - const std::string &value) { - const auto first = value.find_first_not_of(' '); - const auto trimmed = first == std::string::npos - ? std::string_view("") - : std::string_view(value).substr(first); - std::ostringstream stream; - stream << " " << std::left << std::setw(15) << label << ' ' << trimmed; - return stream.str(); -} - -std::string EmptyEdgesMetric() { return " -/ - ( -%)"; } - -void PrintRegressionStart(const ParsedRuntimeOptions &options, - size_t replay_inputs) { - std::cerr - << StartMarker() << " INITED\n" - << FormatInitedField("mode:", "regression") << '\n' - << FormatInitedField("seed:", std::to_string(options.seed)) << '\n' - << FormatInitedField("loaded_inputs:", std::to_string(replay_inputs)) - << '\n' - << FormatInitedField("edges:", EmptyEdgesMetric()) << '\n' - << FormatInitedField("timeout:", - std::to_string(options.timeout_millis) + " ms") - << '\n' - << FormatInitedField("max_len:", std::to_string(options.max_len)) << '\n' - << FormatInitedField("runs:", FormatRunLimit(options.runs)) << '\n' - << FormatInitedField("max_total_time:", - FormatTotalTimeLimit(options.max_total_time_seconds)) - << std::endl; -} - -void PrintRegressionDone(std::chrono::steady_clock::time_point started_at, - uint64_t executions, size_t replay_inputs) { - const auto elapsed = std::chrono::steady_clock::now() - started_at; - const auto elapsed_seconds = std::chrono::duration(elapsed).count(); - const auto execs_per_sec = elapsed_seconds > 0.0 - ? executions / elapsed_seconds - : static_cast(executions); - - std::cerr << "[libafl::done] mode: regression, run time: " - << FormatDuration(elapsed) << ", replay_inputs: " << replay_inputs - << ", executions: " << executions - << ", exec/sec: " << static_cast(execs_per_sec) - << std::endl; -} +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; struct SyncWatchdogState { std::thread thread; @@ -201,8 +82,7 @@ struct SyncWatchdogState { struct SyncFuzzTargetContext { SyncFuzzTargetContext(Napi::Env env, Napi::Function target, - Napi::Function js_stop_callback, - ParsedRuntimeOptions options) + 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)) {} @@ -212,7 +92,7 @@ struct SyncFuzzTargetContext { bool is_resolved; Napi::Promise::Deferred deferred; Napi::Function js_stop_callback; - ParsedRuntimeOptions options; + LibAflOptions options; SyncWatchdogState watchdog; volatile std::sig_atomic_t signal_status = 0; volatile int sigints = 0; @@ -232,13 +112,13 @@ struct AsyncDataType { }; struct AsyncFuzzTargetContext { - explicit AsyncFuzzTargetContext(Napi::Env env, ParsedRuntimeOptions options) + explicit AsyncFuzzTargetContext(Napi::Env env, LibAflOptions options) : deferred(Napi::Promise::Deferred::New(env)), options(std::move(options)) {} std::thread native_thread; Napi::Promise::Deferred deferred; - ParsedRuntimeOptions options; + LibAflOptions options; bool is_resolved = false; bool is_done_called = false; int run_status = kRuntimeOk; @@ -258,209 +138,6 @@ AsyncFuzzTargetContext *gActiveAsyncContext = nullptr; AsyncTsfn gAsyncTsfn; JazzerLibAflFindingInfo gFindingInfo{}; -void ClearFindingInfo() { std::memset(&gFindingInfo, 0, sizeof(gFindingInfo)); } - -void CopyFindingField(char *destination, size_t destination_size, - const std::string &value) { - if (destination == nullptr || destination_size == 0) { - return; - } - - std::memset(destination, 0, destination_size); - const auto copied = std::min(destination_size - 1, value.size()); - if (copied > 0) { - std::memcpy(destination, value.data(), copied); - } -} - -std::string CollapseWhitespace(const std::string &value) { - std::string collapsed; - collapsed.reserve(value.size()); - - bool previous_was_space = false; - for (const auto character : value) { - if (std::isspace(static_cast(character)) != 0) { - if (!collapsed.empty() && !previous_was_space) { - collapsed.push_back(' '); - } - previous_was_space = true; - continue; - } - - collapsed.push_back(character); - previous_was_space = false; - } - - if (!collapsed.empty() && collapsed.back() == ' ') { - collapsed.pop_back(); - } - - return collapsed; -} - -std::string TrimStackFrame(const std::string &frame) { - const auto first = frame.find_first_not_of(" \t"); - if (first == std::string::npos) { - return ""; - } - - auto trimmed = frame.substr(first); - constexpr char kAtPrefix[] = "at "; - if (trimmed.rfind(kAtPrefix, 0) == 0) { - trimmed.erase(0, sizeof(kAtPrefix) - 1); - } - - if (!trimmed.empty() && trimmed.back() == ')') { - const auto open_paren = trimmed.rfind('('); - if (open_paren != std::string::npos && open_paren + 1 < trimmed.size()) { - return trimmed.substr(open_paren + 1, trimmed.size() - open_paren - 2); - } - } - - return trimmed; -} - -std::string DescribeJsError(Napi::Env env, const Napi::Value &error) { - std::string summary = error.ToString().Utf8Value(); - if (!error.IsObject()) { - return CollapseWhitespace(summary); - } - - const auto stack_value = error.As().Get("stack"); - if (!stack_value.IsString()) { - return CollapseWhitespace(summary); - } - - std::istringstream stream(stack_value.As().Utf8Value()); - std::string line; - std::getline(stream, line); - while (std::getline(stream, line)) { - const auto frame = TrimStackFrame(line); - if (frame.empty()) { - continue; - } - summary.append(" in ").append(frame); - break; - } - - return CollapseWhitespace(summary); -} - -void RecordFindingInfo(const std::string &artifact, - const std::string &summary) { - gFindingInfo.has_value = 1; - CopyFindingField(gFindingInfo.artifact, sizeof(gFindingInfo.artifact), - artifact); - CopyFindingField(gFindingInfo.summary, sizeof(gFindingInfo.summary), summary); -} - -std::string DigestInput(const uint8_t *data, size_t size) { - uint64_t hash = 1469598103934665603ULL; - for (size_t i = 0; i < size; ++i) { - hash ^= static_cast(data[i]); - hash *= 1099511628211ULL; - } - - std::array words{}; - for (auto &word : words) { - hash ^= hash >> 33; - hash *= 0xff51afd7ed558ccdULL; - hash ^= hash >> 33; - hash *= 0xc4ceb9fe1a85ec53ULL; - hash ^= hash >> 33; - word = static_cast(hash); - } - - std::ostringstream stream; - stream << std::hex << std::setfill('0'); - for (const auto word : words) { - stream << std::setw(8) << word; - } - return stream.str(); -} - -std::filesystem::path ArtifactPath(const std::string &artifact_prefix, - const std::string &kind, - const std::string &digest) { - const auto filename = kind + "-" + digest; - - if (artifact_prefix.empty()) { - return std::filesystem::current_path() / filename; - } - - const auto has_directory_semantics = - artifact_prefix.back() == '/' || artifact_prefix.back() == '\\'; - std::filesystem::path prefix_path(artifact_prefix); - if (has_directory_semantics || (std::filesystem::exists(prefix_path) && - std::filesystem::is_directory(prefix_path))) { - return prefix_path / filename; - } - - return std::filesystem::path(artifact_prefix + filename); -} - -std::string WriteArtifact(const std::string &artifact_prefix, - const std::string &kind, const uint8_t *data, - size_t size, bool emit_info = true) { - if (data == nullptr && size != 0) { - return ""; - } - - try { - const auto digest = DigestInput(data, size); - const auto artifact_path = ArtifactPath(artifact_prefix, kind, digest); - - if (!artifact_path.parent_path().empty()) { - std::filesystem::create_directories(artifact_path.parent_path()); - } - - std::ofstream output(artifact_path, - std::ios::binary | std::ios::out | std::ios::trunc); - if (!output.is_open()) { - std::cerr << "ERROR: Failed to open artifact file '" - << artifact_path.string() << "'" << std::endl; - return ""; - } - - if (size > 0) { - output.write(reinterpret_cast(data), - static_cast(size)); - } - if (!output.good()) { - std::cerr << "ERROR: Failed to write artifact file '" - << artifact_path.string() << "'" << std::endl; - return ""; - } - - if (emit_info) { - std::cerr << "INFO: Wrote " << kind << " input to " - << artifact_path.string() << std::endl; - } - return artifact_path.filename().string(); - } catch (const std::exception &exception) { - std::cerr << "ERROR: Failed to persist " << kind - << " artifact: " << exception.what() << std::endl; - return ""; - } -} - -[[noreturn]] void ExitOnTimeout(uint64_t timeout_millis, - const std::string &artifact_prefix, - const std::vector &input) { - std::cerr << "ERROR: Exceeded timeout of " << timeout_millis - << " ms for one fuzz target execution." << std::endl; - WriteArtifact(artifact_prefix, "timeout", input.data(), input.size()); - _Exit(libfuzzer::EXIT_ERROR_TIMEOUT); -} - -[[noreturn]] void ExitWithUnexpectedError(const std::exception &exception) { - std::cerr << "==" << static_cast(GetPID()) - << "== Jazzer.js: Unexpected Error: " << exception.what() - << std::endl; - libfuzzer::PrintCrashingInput(); - _Exit(libfuzzer::EXIT_ERROR_CODE); -} - void RejectDeferredIfNeeded(AsyncFuzzTargetContext *context, const Napi::Value &error) { if (context->is_resolved) { @@ -490,310 +167,11 @@ void ReportAsyncFinding(AsyncFuzzTargetContext *context, Napi::Env env, const auto artifact = WriteArtifact(context->options.artifact_prefix, "crash", input.data(), input.size(), false); - RecordFindingInfo(artifact, DescribeJsError(env, error)); + RecordFindingInfo(&gFindingInfo, artifact, DescribeJsError(env, error)); } RejectDeferredIfNeeded(context, error); } -ParsedRuntimeOptions ParseRuntimeOptions(Napi::Env env, - const Napi::Object &js_opts) { - ParsedRuntimeOptions parsed; - - const auto mode = js_opts.Get("mode"); - const auto runs = js_opts.Get("runs"); - const auto seed = js_opts.Get("seed"); - const auto max_len = js_opts.Get("maxLen"); - const auto timeout_millis = js_opts.Get("timeoutMillis"); - const auto max_total_time_seconds = js_opts.Get("maxTotalTimeSeconds"); - const auto artifact_prefix = js_opts.Get("artifactPrefix"); - const auto corpus_directories = js_opts.Get("corpusDirectories"); - const auto dictionary_files = js_opts.Get("dictionaryFiles"); - - if (!mode.IsUndefined() && !mode.IsString()) { - throw Napi::Error::New( - env, "The LibAFL options object expects mode to be 'fuzzing' or " - "'regression'"); - } - - if (!runs.IsNumber() || !seed.IsNumber() || !max_len.IsNumber() || - !timeout_millis.IsNumber() || !max_total_time_seconds.IsNumber() || - !artifact_prefix.IsString() || !corpus_directories.IsArray() || - !dictionary_files.IsArray()) { - throw Napi::Error::New( - env, "The LibAFL backend expects an options object with mode, runs, " - "seed, maxLen, timeoutMillis, maxTotalTimeSeconds, " - "artifactPrefix, corpusDirectories, and dictionaryFiles"); - } - - if (mode.IsString()) { - const auto mode_value = mode.As().Utf8Value(); - if (mode_value == "regression") { - parsed.mode = ParsedRuntimeOptions::Mode::kRegression; - } else if (mode_value == "fuzzing") { - parsed.mode = ParsedRuntimeOptions::Mode::kFuzzing; - } else { - throw Napi::Error::New( - env, "The LibAFL options object expects mode to be 'fuzzing' or " - "'regression'"); - } - } - - const auto runs_value = runs.As().Int64Value(); - const auto seed_value = seed.As().Int64Value(); - const auto max_len_value = max_len.As().Int64Value(); - const auto timeout_millis_value = - timeout_millis.As().Int64Value(); - const auto max_total_time_seconds_value = - max_total_time_seconds.As().Int64Value(); - - if (runs_value < 0 || seed_value < 0 || max_len_value < 0 || - timeout_millis_value < 0 || max_total_time_seconds_value < 0) { - throw Napi::Error::New( - env, "The LibAFL options object does not allow negative values"); - } - - parsed.runs = static_cast(runs_value); - parsed.seed = static_cast(seed_value); - parsed.max_len = static_cast(max_len_value); - parsed.timeout_millis = static_cast(timeout_millis_value); - parsed.max_total_time_seconds = - static_cast(max_total_time_seconds_value); - parsed.artifact_prefix = artifact_prefix.As().Utf8Value(); - - const auto dirs = corpus_directories.As(); - for (uint32_t i = 0; i < dirs.Length(); ++i) { - auto dir = dirs.Get(i); - if (!dir.IsString()) { - throw Napi::Error::New( - env, "LibAFL corpusDirectories entries must be strings"); - } - parsed.corpus_directories.push_back(dir.As().Utf8Value()); - } - - const auto dicts = dictionary_files.As(); - for (uint32_t i = 0; i < dicts.Length(); ++i) { - auto dict = dicts.Get(i); - if (!dict.IsString()) { - throw Napi::Error::New(env, - "LibAFL dictionaryFiles entries must be strings"); - } - parsed.dictionary_files.push_back(dict.As().Utf8Value()); - } - - if (parsed.max_len == 0) { - throw Napi::Error::New(env, "The LibAFL backend requires maxLen to be > 0"); - } - if (parsed.timeout_millis == 0) { - throw Napi::Error::New( - env, "The LibAFL backend requires timeoutMillis to be > 0"); - } - - return parsed; -} - -JazzerLibAflRuntimeSharedMaps SharedMapsForRuntime(Napi::Env env) { - auto *edges = CoverageCounters(); - const auto edges_capacity = CoverageCountersCapacity(); - auto *edges_size = CoverageCountersSizePointer(); - auto *cmp = CompareFeedbackMap(); - const auto cmp_len = CompareFeedbackMapSize(); - auto *compare_log = CompareLog(); - auto *finding_info = &gFindingInfo; - - if (edges == nullptr || edges_capacity == 0 || edges_size == nullptr || - cmp == nullptr || cmp_len == 0 || compare_log == nullptr || - finding_info == nullptr) { - throw Napi::Error::New( - env, - "Coverage maps were not initialized before the LibAFL backend started"); - } - - return {edges, edges_capacity, edges_size, cmp, - cmp_len, compare_log, finding_info}; -} - -bool CollectRegressionCorpusFiles( - const std::vector &corpus_directories, - std::vector *files) { - for (const auto &directory : corpus_directories) { - const std::filesystem::path directory_path(directory); - std::error_code error; - - if (!std::filesystem::exists(directory_path, error)) { - if (error) { - std::cerr << "[libafl] fatal: failed to access corpus directory '" - << directory_path.string() << "': " << error.message() - << std::endl; - } else { - std::cerr << "[libafl] fatal: corpus directory does not exist: '" - << directory_path.string() << "'" << std::endl; - } - return false; - } - - if (!std::filesystem::is_directory(directory_path, error)) { - if (error) { - std::cerr << "[libafl] fatal: failed to inspect corpus directory '" - << directory_path.string() << "': " << error.message() - << std::endl; - } else { - std::cerr << "[libafl] fatal: corpus path is not a directory: '" - << directory_path.string() << "'" << std::endl; - } - return false; - } - - std::filesystem::recursive_directory_iterator iterator( - directory_path, - std::filesystem::directory_options::skip_permission_denied, error); - const auto end = std::filesystem::recursive_directory_iterator(); - if (error) { - std::cerr << "[libafl] fatal: failed to iterate corpus directory '" - << directory_path.string() << "': " << error.message() - << std::endl; - return false; - } - - for (; iterator != end; iterator.increment(error)) { - if (error) { - std::cerr << "[libafl] fatal: failed to iterate corpus directory '" - << directory_path.string() << "': " << error.message() - << std::endl; - return false; - } - - const auto is_regular_file = iterator->is_regular_file(error); - if (error) { - std::cerr << "[libafl] fatal: failed to inspect corpus entry '" - << iterator->path().string() << "': " << error.message() - << std::endl; - return false; - } - if (is_regular_file) { - files->push_back(iterator->path()); - } - } - } - - std::sort(files->begin(), files->end()); - return true; -} - -bool ReadRegressionInput(const std::filesystem::path &file_path, size_t max_len, - std::vector *input) { - input->clear(); - std::ifstream stream(file_path, std::ios::binary); - if (!stream.is_open()) { - std::cerr << "[libafl] fatal: failed to open corpus input '" - << file_path.string() << "'" << std::endl; - return false; - } - - constexpr size_t kChunkSize = 4096; - std::array buffer{}; - while (stream.good() && input->size() < max_len) { - const auto remaining = max_len - input->size(); - const auto to_read = static_cast( - std::min(remaining, buffer.size())); - stream.read(buffer.data(), to_read); - const auto bytes_read = stream.gcount(); - if (bytes_read <= 0) { - break; - } - input->insert(input->end(), buffer.begin(), buffer.begin() + bytes_read); - } - - if (stream.bad()) { - std::cerr << "[libafl] fatal: failed to read corpus input '" - << file_path.string() << "'" << std::endl; - return false; - } - - return true; -} - -bool ReachedMaxTotalTime(const ParsedRuntimeOptions &options, - std::chrono::steady_clock::time_point started_at) { - if (options.max_total_time_seconds == 0) { - return false; - } - return std::chrono::steady_clock::now() - started_at >= - std::chrono::seconds(options.max_total_time_seconds); -} - -int ReplayRegressionInputs( - const ParsedRuntimeOptions &options, - const std::function &execute_one) { - std::vector corpus_files; - if (!CollectRegressionCorpusFiles(options.corpus_directories, - &corpus_files)) { - return kRuntimeFatal; - } - - const auto started_at = std::chrono::steady_clock::now(); - const auto replay_inputs = corpus_files.size() + 1; - uint64_t executions = 0; - static constexpr uint8_t kEmptyInputByte = 0; - std::vector current_input; - - PrintRegressionStart(options, replay_inputs); - - auto execute_input = [&](const uint8_t *data, size_t size) -> int { - if (options.runs != 0 && executions >= options.runs) { - return kRuntimeOk; - } - if (ReachedMaxTotalTime(options, started_at)) { - return kRuntimeStopped; - } - - const auto status = execute_one(data, size); - executions++; - switch (status) { - case kExecutionContinue: - return kRuntimeOk; - case kExecutionFinding: - return kRuntimeFoundFinding; - case kExecutionStop: - return kRuntimeStopped; - case kExecutionFatal: - return kRuntimeFatal; - case kExecutionTimeout: - return kRuntimeFoundTimeout; - default: - std::cerr << "[libafl] fatal: unknown execution status: " << status - << std::endl; - return kRuntimeFatal; - } - }; - - auto status = execute_input(&kEmptyInputByte, 0); - if (status != kRuntimeOk) { - if (status == kRuntimeStopped) { - PrintRegressionDone(started_at, executions, replay_inputs); - } - return status; - } - - for (const auto &file_path : corpus_files) { - if (!ReadRegressionInput(file_path, options.max_len, ¤t_input)) { - return kRuntimeFatal; - } - - const auto *data = - current_input.empty() ? &kEmptyInputByte : current_input.data(); - status = execute_input(data, current_input.size()); - if (status != kRuntimeOk) { - if (status == kRuntimeStopped) { - PrintRegressionDone(started_at, executions, replay_inputs); - } - return status; - } - } - - PrintRegressionDone(started_at, executions, replay_inputs); - return kRuntimeOk; -} - void StartSyncWatchdog(SyncFuzzTargetContext *context) { if (context->options.timeout_millis == 0) { return; @@ -826,7 +204,7 @@ void StartSyncWatchdog(SyncFuzzTargetContext *context) { auto timed_out_input = watchdog.current_input; lock.unlock(); - ExitOnTimeout(context->options.timeout_millis, + ExitOnTimeout(&gFindingInfo, context->options.timeout_millis, context->options.artifact_prefix, timed_out_input); } }); @@ -927,7 +305,8 @@ int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { if (!context->is_resolved) { const auto artifact = WriteArtifact(context->options.artifact_prefix, "crash", data, size, false); - RecordFindingInfo(artifact, DescribeJsError(context->env, error.Value())); + RecordFindingInfo(&gFindingInfo, artifact, + DescribeJsError(context->env, error.Value())); context->is_resolved = true; context->deferred.Reject(error.Value()); } @@ -1105,7 +484,7 @@ int ExecuteAsyncInput(void *user_data, const uint8_t *data, size_t size) { if (context->options.timeout_millis > 0) { auto timeout = std::chrono::milliseconds(context->options.timeout_millis); if (future.wait_for(timeout) == std::future_status::timeout) { - ExitOnTimeout(context->options.timeout_millis, + ExitOnTimeout(&gFindingInfo, context->options.timeout_millis, context->options.artifact_prefix, input->data); } } @@ -1119,6 +498,36 @@ int ExecuteAsyncInput(void *user_data, const uint8_t *data, size_t size) { 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, + 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) { @@ -1130,8 +539,9 @@ Napi::Value StartLibAfl(const Napi::CallbackInfo &info) { "LibAFL options object, and a stop callback"); } - auto options = ParseRuntimeOptions(info.Env(), info[1].As()); - auto maps = SharedMapsForRuntime(info.Env()); + auto options = ParseLibAflOptions(info.Env(), info[1].As()); + ClearFindingInfo(&gFindingInfo); + auto maps = SharedMapsForLibAflRuntime(info.Env(), &gFindingInfo); SyncFuzzTargetContext context(info.Env(), info[0].As(), info[2].As(), @@ -1143,36 +553,14 @@ Napi::Value StartLibAfl(const Napi::CallbackInfo &info) { signal(SIGSEGV, SyncErrorSignalHandler); auto status = kRuntimeOk; - if (context.options.mode == ParsedRuntimeOptions::Mode::kRegression) { + 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 { - std::vector corpus_directories; - corpus_directories.reserve(context.options.corpus_directories.size()); - for (const auto &directory : context.options.corpus_directories) { - corpus_directories.push_back(directory.c_str()); - } - std::vector dictionary_files; - dictionary_files.reserve(context.options.dictionary_files.size()); - for (const auto &dictionary : context.options.dictionary_files) { - dictionary_files.push_back(dictionary.c_str()); - } - - JazzerLibAflRuntimeOptions runtime_options{ - context.options.runs, - context.options.seed, - context.options.max_len, - context.options.timeout_millis, - context.options.max_total_time_seconds, - corpus_directories.empty() ? nullptr : corpus_directories.data(), - corpus_directories.size(), - dictionary_files.empty() ? nullptr : dictionary_files.data(), - dictionary_files.size(), - }; - status = jazzer_libafl_runtime_run(&runtime_options, &maps, - ExecuteSyncInput, &context); + status = + RunLibAflRuntime(context.options, maps, ExecuteSyncInput, &context); } signal(SIGINT, SIG_DFL); @@ -1213,8 +601,9 @@ Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info) { "function and a LibAFL options object"); } - auto options = ParseRuntimeOptions(info.Env(), info[1].As()); - auto maps = SharedMapsForRuntime(info.Env()); + auto options = ParseLibAflOptions(info.Env(), info[1].As()); + ClearFindingInfo(&gFindingInfo); + auto maps = SharedMapsForLibAflRuntime(info.Env(), &gFindingInfo); auto *context = new AsyncFuzzTargetContext(info.Env(), std::move(options)); gAsyncTsfn = AsyncTsfn::New( @@ -1249,36 +638,14 @@ Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info) { signal(SIGSEGV, AsyncErrorSignalHandler); signal(SIGINT, AsyncSigintHandler); - if (ctx->options.mode == ParsedRuntimeOptions::Mode::kRegression) { + 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 { - std::vector corpus_directories; - corpus_directories.reserve(ctx->options.corpus_directories.size()); - for (const auto &directory : ctx->options.corpus_directories) { - corpus_directories.push_back(directory.c_str()); - } - std::vector dictionary_files; - dictionary_files.reserve(ctx->options.dictionary_files.size()); - for (const auto &dictionary : ctx->options.dictionary_files) { - dictionary_files.push_back(dictionary.c_str()); - } - - JazzerLibAflRuntimeOptions runtime_options{ - ctx->options.runs, - ctx->options.seed, - ctx->options.max_len, - ctx->options.timeout_millis, - ctx->options.max_total_time_seconds, - corpus_directories.empty() ? nullptr : corpus_directories.data(), - corpus_directories.size(), - dictionary_files.empty() ? nullptr : dictionary_files.data(), - dictionary_files.size(), - }; - ctx->run_status = jazzer_libafl_runtime_run(&runtime_options, &maps, - ExecuteAsyncInput, ctx); + ctx->run_status = + RunLibAflRuntime(ctx->options, maps, ExecuteAsyncInput, ctx); } signal(SIGINT, SIG_DFL); signal(SIGSEGV, SIG_DFL); diff --git a/packages/fuzzer/libafl_runtime.h b/packages/fuzzer/libafl_runtime.h index 56f7bdc32..edd9b5e79 100644 --- a/packages/fuzzer/libafl_runtime.h +++ b/packages/fuzzer/libafl_runtime.h @@ -14,52 +14,9 @@ #pragma once -#include -#include #include -#include "shared/tracing.h" - -constexpr std::size_t kFindingInfoArtifactBytes = 256; -constexpr std::size_t kFindingInfoSummaryBytes = 1024; - -extern "C" { -struct JazzerLibAflFindingInfo { - uint8_t has_value; - char artifact[kFindingInfoArtifactBytes]; - char summary[kFindingInfoSummaryBytes]; -}; - -struct JazzerLibAflRuntimeOptions { - uint64_t runs; - uint64_t seed; - size_t max_len; - uint64_t timeout_millis; - uint64_t max_total_time_seconds; - const char **corpus_directories; - size_t corpus_directories_len; - const char **dictionary_files; - size_t dictionary_files_len; -}; - -struct JazzerLibAflRuntimeSharedMaps { - uint8_t *edges; - size_t edges_capacity; - size_t *edges_size; - uint8_t *cmp; - size_t cmp_len; - JazzerLibAflCompareLog *compare_log; - JazzerLibAflFindingInfo *finding_info; -}; - -typedef int (*JazzerLibAflExecuteCallback)(void *user_data, const uint8_t *data, - size_t size); - -int jazzer_libafl_runtime_run(const JazzerLibAflRuntimeOptions *options, - const JazzerLibAflRuntimeSharedMaps *maps, - JazzerLibAflExecuteCallback execute_one, - void *user_data); -} +#include "shared/libafl_abi.h" Napi::Value StartLibAfl(const Napi::CallbackInfo &info); Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info); diff --git a/packages/fuzzer/rust/src/lib.rs b/packages/fuzzer/rust/src/lib.rs index 43c3d4b32..24b3112b5 100644 --- a/packages/fuzzer/rust/src/lib.rs +++ b/packages/fuzzer/rust/src/lib.rs @@ -27,9 +27,7 @@ use libafl::{ havoc_mutations::havoc_mutations, scheduled::HavocScheduledMutator, tokens_mutations, I2SRandReplace, Tokens, }, - observers::{ - CanTrack, HitcountsMapObserver, StdMapObserver, VariableMapObserver, - }, + observers::{CanTrack, HitcountsMapObserver, StdMapObserver, VariableMapObserver}, schedulers::{ powersched::PowerSchedule, IndexesLenTimeMinimizerScheduler, PowerQueueScheduler, }, @@ -347,16 +345,26 @@ fn build_progress_snapshot( }) } +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, - highlight_full_line: bool, + in_campaign: bool, ) -> String { - let marker = if colors_enabled && !highlight_full_line { - marker_for_event(event, true) + let marker = if colors_enabled && !in_campaign { + progress_marker(event, false, true) } else { - marker_text(event).to_string() + progress_marker(event, in_campaign, false) }; let line = format!( "{} #{:4} | exec/s: {:>8.1} | obj: {:>3} | stab: {} | t: {}", @@ -375,7 +383,7 @@ fn format_progress_line( width = EXECUTION_FIELD_WIDTH, ); - if colors_enabled && highlight_full_line { + if colors_enabled && in_campaign { format!("\x1b[{}m{}\x1b[0m", event_color_code(event), line) } else { line @@ -636,7 +644,11 @@ fn has_non_zero_coverage(ptr: *mut u8, len: usize) -> bool { return false; } - unsafe { slice::from_raw_parts(ptr, len).iter().any(|slot| *slot != 0) } + unsafe { + slice::from_raw_parts(ptr, len) + .iter() + .any(|slot| *slot != 0) + } } fn ensure_non_empty_edge_map(ptr: *mut u8, len: usize) -> bool { @@ -771,14 +783,12 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let (monitor, monitor_state) = LibAflMonitor::new(maps.finding_info); let mut mgr = SimpleEventManager::new(monitor); - let edges_observer = HitcountsMapObserver::new( - VariableMapObserver::from_mut_ptr( - "edges", - maps.edges, - maps.edges_capacity, - maps.edges_size, - ), - ) + let edges_observer = HitcountsMapObserver::new(VariableMapObserver::from_mut_ptr( + "edges", + maps.edges, + maps.edges_capacity, + maps.edges_size, + )) .track_indices(); let cmp_observer = HitcountsMapObserver::new(StdMapObserver::from_mut_ptr("cmp", maps.cmp, maps.cmp_len)); @@ -848,10 +858,7 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( let bytes = bytes.as_slice(); let size = bytes.len().min(options.max_len); let status = unsafe { execute_one(user_data, bytes.as_ptr(), size) }; - let synthetic_edges = ensure_non_empty_edge_map( - maps.edges, - edge_map_len(maps), - ); + let synthetic_edges = ensure_non_empty_edge_map(maps.edges, edge_map_len(maps)); monitor_state.borrow_mut().last_edges_are_synthetic = synthetic_edges; match status { EXECUTION_CONTINUE => ExitKind::Ok, @@ -989,3 +996,88 @@ pub unsafe extern "C" fn jazzer_libafl_runtime_run( status } + +#[cfg(test)] +mod tests { + use super::*; + use crate::compare_log::{ + JazzerLibAflCompareLog, JazzerLibAflCompareLogEntry, COMPARE_LOG_ENTRY_BYTES, + COMPARE_LOG_MAX_ENTRIES, + }; + 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::(), 72); + assert_eq!(align_of::(), 8); + assert_eq!( + std::mem::offset_of!(JazzerLibAflRuntimeOptions, corpus_directories), + 40 + ); + assert_eq!( + std::mem::offset_of!(JazzerLibAflRuntimeOptions, dictionary_files), + 56 + ); + + 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/shared/libafl_abi.h b/packages/fuzzer/shared/libafl_abi.h new file mode 100644 index 000000000..e17885e9a --- /dev/null +++ b/packages/fuzzer/shared/libafl_abi.h @@ -0,0 +1,138 @@ +// 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; + 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) == 72, + "Unexpected JazzerLibAflRuntimeOptions layout"); +static_assert(offsetof(JazzerLibAflRuntimeOptions, corpus_directories) == 40, + "Unexpected corpus_directories offset"); +static_assert(offsetof(JazzerLibAflRuntimeOptions, dictionary_files) == 56, + "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/tracing.h b/packages/fuzzer/shared/tracing.h index 03bef091f..5bb5a7c57 100644 --- a/packages/fuzzer/shared/tracing.h +++ b/packages/fuzzer/shared/tracing.h @@ -13,36 +13,9 @@ // limitations under the License. #pragma once -#include -#include #include -constexpr std::size_t kCompareFeedbackMapSize = 1 << 16; -constexpr std::size_t kCompareLogEntryBytes = 32; -constexpr std::size_t kCompareLogMaxEntries = 1024; - -enum class JazzerLibAflCompareKind : uint8_t { - kInteger = 1, - kStringEquality = 2, - kStringContainment = 3, -}; - -struct JazzerLibAflCompareLogEntry { - uint8_t kind; - uint8_t flags; - uint8_t left_len; - uint8_t right_len; - uint64_t left_value; - uint64_t right_value; - uint8_t left_bytes[kCompareLogEntryBytes]; - uint8_t right_bytes[kCompareLogEntryBytes]; -}; - -struct JazzerLibAflCompareLog { - uint32_t used; - uint32_t dropped; - JazzerLibAflCompareLogEntry entries[kCompareLogMaxEntries]; -}; +#include "libafl_abi.h" void TraceUnequalStrings(const Napi::CallbackInfo &info); void TraceStringContainment(const Napi::CallbackInfo &info); From ee05aa0e982550e0094a648b5486a5d124e969ae Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:34:30 +0200 Subject: [PATCH 10/26] fix(fuzzer): serialize LibAFL async teardown Settle findings, signals, and promise results in one order so shutdown cannot race the native runtime guard or leave stale execution state behind. Skip the SIGINT restoration check on Windows, where Node turns process.kill(..., "SIGINT") into termination instead of recovery. --- packages/fuzzer/libafl_runtime.cpp | 400 +++++++++++++++++-------- packages/fuzzer/libafl_runtime.test.ts | 191 ++++++++++++ 2 files changed, 467 insertions(+), 124 deletions(-) diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp index f8eade886..27fe82a38 100644 --- a/packages/fuzzer/libafl_runtime.cpp +++ b/packages/fuzzer/libafl_runtime.cpp @@ -70,6 +70,55 @@ 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; @@ -95,13 +144,17 @@ struct SyncFuzzTargetContext { LibAflOptions options; SyncWatchdogState watchdog; volatile std::sig_atomic_t signal_status = 0; - volatile int sigints = 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 { @@ -112,17 +165,21 @@ struct AsyncDataType { }; struct AsyncFuzzTargetContext { - explicit AsyncFuzzTargetContext(Napi::Env env, LibAflOptions options) + explicit AsyncFuzzTargetContext( + Napi::Env env, LibAflOptions options, + std::unique_ptr runtime_guard) : deferred(Napi::Promise::Deferred::New(env)), - options(std::move(options)) {} + 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; - bool is_done_called = false; int run_status = kRuntimeOk; - volatile int sigints = 0; + volatile std::sig_atomic_t execution_active = 0; + volatile std::sig_atomic_t sigints = 0; std::jmp_buf execution_context; }; @@ -138,38 +195,129 @@ AsyncFuzzTargetContext *gActiveAsyncContext = nullptr; AsyncTsfn gAsyncTsfn; JazzerLibAflFindingInfo gFindingInfo{}; -void RejectDeferredIfNeeded(AsyncFuzzTargetContext *context, +void StoreDeferredRejection(AsyncFuzzTargetContext *context, const Napi::Value &error) { - if (context->is_resolved) { + if (!context->deferred_rejection.IsEmpty()) { return; } - context->deferred.Reject(error); - context->is_resolved = true; + context->deferred_rejection = Napi::Persistent(error); } -bool TrySetExecutionStatus(const std::shared_ptr &state, - int status) { +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 (TrySetExecutionStatus(state, kExecutionFinding)) { - const auto artifact = - WriteArtifact(context->options.artifact_prefix, "crash", input.data(), - input.size(), false); - RecordFindingInfo(&gFindingInfo, artifact, DescribeJsError(env, error)); + 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; } - RejectDeferredIfNeeded(context, error); + + SettleLibAflRun(env, context->deferred, context->is_resolved, + context->run_status); } void StartSyncWatchdog(SyncFuzzTargetContext *context) { @@ -270,17 +418,26 @@ class ScopedSyncWatchdog { }; void SyncSigintHandler(int signum) { - std::cerr << std::endl; - gActiveSyncContext->signal_status = signum; - if (gActiveSyncContext->sigints > 0) { + auto *context = gActiveSyncContext; + if (context == nullptr) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + + context->signal_status = signum; + if (context->sigints > 0) { _Exit(libfuzzer::RETURN_CONTINUE); } - gActiveSyncContext->sigints++; + context->sigints++; } void SyncErrorSignalHandler(int signum) { - gActiveSyncContext->signal_status = signum; - std::longjmp(gActiveSyncContext->execution_context, 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) { @@ -293,7 +450,11 @@ int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { try { auto buffer = Napi::Buffer::Copy(context->env, data, size); - if (setjmp(context->execution_context) == 0) { + // 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(); @@ -301,7 +462,9 @@ int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { 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); @@ -312,6 +475,7 @@ int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { } return kExecutionFinding; } catch (const std::exception &exception) { + context->execution_active = 0; ExitWithUnexpectedError(exception); } @@ -343,13 +507,16 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, try { if (context->sigints > 0) { - TrySetExecutionStatus(state, kExecutionStop); - context->deferred.Resolve(env.Undefined()); - context->is_resolved = true; + TryPublishExecutionStatus(state, kExecutionStop); return; } - if (setjmp(context->execution_context) == SIGSEGV) { + // 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(); @@ -357,7 +524,8 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, } if (env == nullptr) { - TrySetExecutionStatus(state, kExecutionFatal); + context->execution_active = 0; + TryPublishExecutionStatus(state, kExecutionFatal); return; } @@ -369,13 +537,12 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, .Int32Value(); if (parameter_count > 1) { - context->is_done_called = false; auto done = Napi::Function::New(env, [=](const Napi::CallbackInfo &info) { - if (context->is_resolved) { + if (IsExecutionSettled(state)) { return; } - if (context->is_done_called) { + if (state->done_called) { auto error = Napi::Error::New(env, "Expected done to be called once, but it " "was called multiple times.") @@ -384,21 +551,24 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, return; } - context->is_done_called = true; + state->done_called = true; const auto has_error = info.Length() > 0 && !(info[0].IsNull() || info[0].IsUndefined()); if (has_error) { - auto error = info[0]; - if (!error.IsObject()) { - error = Napi::Error::New(env, error.ToString()).Value(); - } - ReportAsyncFinding(context, env, state, error, current_input); + ReportAsyncFinding(context, env, state, info[0], current_input); + return; + } + + if (state->callback_invocation_completed) { + TryPublishExecutionStatus(state, kExecutionContinue); } else { - TrySetExecutionStatus(state, kExecutionContinue); + 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 = @@ -408,11 +578,15 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, 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(); @@ -420,7 +594,7 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, then.Call(js_promise, {Napi::Function::New(env, [=](const Napi::CallbackInfo &) { - TrySetExecutionStatus( + TryPublishExecutionStatus( state, kExecutionContinue); }), Napi::Function::New(env, [=](const Napi::CallbackInfo &info) { @@ -429,36 +603,43 @@ void CallJsFuzzCallback(Napi::Env env, Napi::Function js_fuzz_callback, ? info[0] : Napi::Error::New(env, "Unknown promise rejection") .Value(); - if (!error.IsObject()) { - error = Napi::Error::New(env, error.ToString()).Value(); - } ReportAsyncFinding(context, env, state, error, current_input); })}); } else { SyncReturnsHandler(); - TrySetExecutionStatus(state, kExecutionContinue); + 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) { - TrySetExecutionStatus(state, kExecutionFatal); - auto message = - std::string("Internal fuzzer error - ").append(exception.what()); - RejectDeferredIfNeeded(context, Napi::Error::New(env, message).Value()); + context->execution_active = 0; + ReportAsyncInternalError( + context, env, state, + std::string("Internal fuzzer error - ").append(exception.what())); } } void AsyncSigintHandler(int signum) { - std::cerr << std::endl; - if (gActiveAsyncContext->sigints > 0) { + auto *context = gActiveAsyncContext; + if (context == nullptr) { + _Exit(libfuzzer::RETURN_CONTINUE); + } + + if (context->sigints > 0) { _Exit(libfuzzer::RETURN_CONTINUE); } - gActiveAsyncContext->sigints = signum; + context->sigints = signum; } void AsyncErrorSignalHandler(int signum) { - std::longjmp(gActiveAsyncContext->execution_context, 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) { @@ -540,6 +721,7 @@ Napi::Value StartLibAfl(const Napi::CallbackInfo &info) { } auto options = ParseLibAflOptions(info.Env(), info[1].As()); + auto runtime_guard = AcquireLibAflRuntime(info.Env()); ClearFindingInfo(&gFindingInfo); auto maps = SharedMapsForLibAflRuntime(info.Env(), &gFindingInfo); @@ -549,47 +731,27 @@ Napi::Value StartLibAfl(const Napi::CallbackInfo &info) { gActiveSyncContext = &context; StartSyncWatchdog(&context); - signal(SIGINT, SyncSigintHandler); - signal(SIGSEGV, SyncErrorSignalHandler); auto status = kRuntimeOk; - 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); - } - - signal(SIGINT, SIG_DFL); - signal(SIGSEGV, SIG_DFL); + { + 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; - if (status == kRuntimeFatal && !context.is_resolved) { - context.is_resolved = true; - context.deferred.Reject( - Napi::Error::New(info.Env(), "The LibAFL backend failed internally") - .Value()); - } else if (status == kRuntimeFoundTimeout && !context.is_resolved) { - context.is_resolved = true; - context.deferred.Reject( - Napi::Error::New(info.Env(), - "Exceeded timeout while executing one fuzz input") - .Value()); - } else if (status == kRuntimeFoundFinding && !context.is_resolved) { - context.is_resolved = true; - context.deferred.Reject( - Napi::Error::New(info.Env(), - "The LibAFL backend found a crashing input") - .Value()); - } - - if (!context.is_resolved) { - context.deferred.Resolve(context.env.Undefined()); - } + SettleLibAflRun(info.Env(), context.deferred, context.is_resolved, status); return context.deferred.Promise(); } @@ -602,57 +764,47 @@ Napi::Value StartLibAflAsync(const Napi::CallbackInfo &info) { } auto options = ParseLibAflOptions(info.Env(), info[1].As()); + auto runtime_guard = AcquireLibAflRuntime(info.Env()); ClearFindingInfo(&gFindingInfo); auto maps = SharedMapsForLibAflRuntime(info.Env(), &gFindingInfo); - auto *context = new AsyncFuzzTargetContext(info.Env(), std::move(options)); + 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, + context.get(), [](Napi::Env env, AsyncFinalizerDataType *, AsyncFuzzTargetContext *ctx) { + Napi::HandleScope scope(env); ctx->native_thread.join(); - if (ctx->run_status == kRuntimeFatal && !ctx->is_resolved) { - ctx->deferred.Reject( - Napi::Error::New(env, "The LibAFL backend failed internally") - .Value()); - } else if (ctx->run_status == kRuntimeFoundTimeout && - !ctx->is_resolved) { - ctx->deferred.Reject( - Napi::Error::New( - env, "Exceeded timeout while executing one fuzz input") - .Value()); - } else if (ctx->run_status == kRuntimeFoundFinding && - !ctx->is_resolved) { - ctx->deferred.Reject( - Napi::Error::New(env, "The LibAFL backend found a crashing input") - .Value()); - } else if (!ctx->is_resolved) { - ctx->deferred.Resolve(env.Undefined()); - } + ctx->runtime_guard.reset(); + SettleAsyncLibAflRun(env, ctx); delete ctx; }); - context->native_thread = std::thread( + auto *context_ptr = context.get(); + context_ptr->native_thread = std::thread( [maps](AsyncFuzzTargetContext *ctx) { gActiveAsyncContext = ctx; - signal(SIGSEGV, AsyncErrorSignalHandler); - signal(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); + { + 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); + } } - signal(SIGINT, SIG_DFL); - signal(SIGSEGV, SIG_DFL); gActiveAsyncContext = nullptr; gAsyncTsfn.Release(); }, - context); + context_ptr); - return context->deferred.Promise(); + auto promise = context_ptr->deferred.Promise(); + context.release(); + return promise; } diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts index ef54f711b..763a65dae 100644 --- a/packages/fuzzer/libafl_runtime.test.ts +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -14,6 +14,11 @@ * 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 { addon } from "./addon"; import { fuzzer } from "./fuzzer"; @@ -29,6 +34,14 @@ const libAflOptions = { 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; @@ -67,6 +80,184 @@ describe("LibAFL runtime", () => { 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())}); + addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + 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())}); + addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + 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())}); + addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + 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}`, + ); + } + }, + ); + it("records compare feedback in the shared native map", async () => { addon.clearCompareFeedbackMap(); From 0bfe46bf067d729deb7c28c072a4c904fb7f65fb Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:34:43 +0200 Subject: [PATCH 11/26] test(bench): add LibAFL smoke and anomaly checks Benchmark both engines against the same target and keep a few anomaly checks close by so backend changes can be compared empirically. --- benchmarks/engine_smoke/.gitignore | 3 + benchmarks/engine_smoke/anomaly.js | 189 ++++++++++++++++++++++ benchmarks/engine_smoke/anomaly_fuzz.js | 32 ++++ benchmarks/engine_smoke/fuzz.js | 76 +++++++++ benchmarks/engine_smoke/package.json | 15 ++ benchmarks/engine_smoke/run.js | 182 +++++++++++++++++++++ benchmarks/engine_smoke/seeds/basic.txt | 1 + benchmarks/engine_smoke/seeds/encoded.txt | 1 + benchmarks/engine_smoke/seeds/nested.txt | 1 + 9 files changed, 500 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..0d46a6efd --- /dev/null +++ b/benchmarks/engine_smoke/anomaly.js @@ -0,0 +1,189 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const benchmarkDirectory = __dirname; +const workDirectory = path.join(benchmarkDirectory, "work", "anomalies"); +const engineTarget = path.join( + benchmarkDirectory, + "..", + "..", + "tests", + "engine", + "fuzz.js", +); +const asyncTarget = path.join(benchmarkDirectory, "anomaly_fuzz.js"); + +function removeIfExists(targetPath) { + fs.rmSync(targetPath, { force: true, recursive: true }); +} + +function ensureDirectory(targetPath) { + fs.mkdirSync(targetPath, { recursive: true }); +} + +function runCommand(label, args, cwd, outputDirectory, expectedStatus = 0) { + console.log(`\n[anomaly] ${label}`); + console.log(`[anomaly] command: npx ${args.join(" ")}`); + ensureDirectory(outputDirectory); + const sanitizedLabel = label.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + const stdoutPath = path.join(outputDirectory, `${sanitizedLabel}.stdout.log`); + const stderrPath = path.join(outputDirectory, `${sanitizedLabel}.stderr.log`); + const stdoutFd = fs.openSync(stdoutPath, "w"); + const stderrFd = fs.openSync(stderrPath, "w"); + const startedAt = Date.now(); + const proc = spawnSync("npx", args, { + cwd, + env: { ...process.env }, + shell: true, + stdio: ["ignore", stdoutFd, stderrFd], + windowsHide: true, + }); + const elapsedMs = Date.now() - startedAt; + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + + if (proc.status !== expectedStatus) { + throw new Error( + `${label} failed with exit code ${proc.status}\nSTDOUT (${stdoutPath}):\n${fs.readFileSync(stdoutPath, "utf8")}\nSTDERR (${stderrPath}):\n${fs.readFileSync(stderrPath, "utf8")}`, + ); + } + + return { + elapsedMs, + stderrPath, + stdoutPath, + }; +} + +function parseExecsPerSecond(stderrPath) { + const stderr = fs.readFileSync(stderrPath, "utf8"); + const match = stderr.match(/speed:\s+([0-9.]+) exec\/s/); + if (!match) { + throw new Error(`No LibAFL done line found in ${stderrPath}`); + } + return Number.parseFloat(match[1]); +} + +function runGuidedNumericSmoke() { + const outputDirectory = path.join(workDirectory, "guided-numeric"); + const corpusDirectory = path.join(outputDirectory, "corpus"); + removeIfExists(outputDirectory); + ensureDirectory(corpusDirectory); + fs.writeFileSync(path.join(corpusDirectory, "seed"), Buffer.alloc(4)); + + const result = runCommand( + "guided numeric solve", + [ + "jazzer", + engineTarget, + "-f", + "guided_numeric", + "--engine=afl", + "--sync", + "--disable_bug_detectors=.*", + "--", + corpusDirectory, + "-runs=4000", + "-seed=1337", + "-max_len=16", + `-artifact_prefix=${outputDirectory}${path.sep}`, + ], + benchmarkDirectory, + outputDirectory, + 77, + ); + + const output = + fs.readFileSync(result.stdoutPath, "utf8") + + fs.readFileSync(result.stderrPath, "utf8"); + if (!output.includes("AFL numeric guidance finding")) { + throw new Error("Guided numeric smoke did not report the expected finding"); + } + + return { + name: "guided-numeric", + elapsedMs: result.elapsedMs, + }; +} + +function runAsyncSmoke() { + const outputDirectory = path.join(workDirectory, "async-smoke"); + const corpusDirectory = path.join(outputDirectory, "corpus"); + removeIfExists(outputDirectory); + ensureDirectory(corpusDirectory); + fs.writeFileSync(path.join(corpusDirectory, "seed"), "async-seed"); + + const result = runCommand( + "async throughput smoke", + [ + "jazzer", + asyncTarget, + "-f", + "async_smoke", + "--engine=afl", + "--disable_bug_detectors=.*", + "--", + corpusDirectory, + "-runs=2000", + "-seed=9001", + "-max_len=128", + `-artifact_prefix=${outputDirectory}${path.sep}`, + ], + benchmarkDirectory, + outputDirectory, + ); + + const execsPerSecond = parseExecsPerSecond(result.stderrPath); + if (execsPerSecond <= 0) { + throw new Error("Async smoke reported a non-positive exec/sec rate"); + } + if (result.elapsedMs > 30000) { + throw new Error( + `Async smoke took unexpectedly long: ${result.elapsedMs} ms`, + ); + } + + return { + name: "async-smoke", + elapsedMs: result.elapsedMs, + execsPerSecond, + }; +} + +function main() { + ensureDirectory(workDirectory); + const results = [runGuidedNumericSmoke(), runAsyncSmoke()]; + for (const result of results) { + const stats = [`elapsed_ms=${result.elapsedMs}`]; + if (result.execsPerSecond !== undefined) { + stats.push(`execs_per_second=${result.execsPerSecond}`); + } + console.log(`[anomaly] ${result.name}: ${stats.join(" ")}`); + } + fs.writeFileSync( + path.join(workDirectory, "results.json"), + JSON.stringify(results, null, 2), + ); + console.log( + `\n[anomaly] wrote machine-readable results to ${path.join(workDirectory, "results.json")}`, + ); +} + +main(); diff --git a/benchmarks/engine_smoke/anomaly_fuzz.js b/benchmarks/engine_smoke/anomaly_fuzz.js new file mode 100644 index 000000000..fd31e8624 --- /dev/null +++ b/benchmarks/engine_smoke/anomaly_fuzz.js @@ -0,0 +1,32 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module.exports.async_smoke = function (data) { + let checksum = 0; + for (const byte of data) { + checksum = ((checksum * 33) ^ byte) & 0xffff; + } + + return new Promise((resolve) => { + setImmediate(() => { + if (checksum === 0x1337) { + // Exercise an extra branch without turning this into a finding target. + checksum ^= data.length; + } + resolve(checksum); + }); + }); +}; diff --git a/benchmarks/engine_smoke/fuzz.js b/benchmarks/engine_smoke/fuzz.js new file mode 100644 index 000000000..690274041 --- /dev/null +++ b/benchmarks/engine_smoke/fuzz.js @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const qs = require("qs"); + +const { FuzzedDataProvider } = require("@jazzer.js/core"); + +module.exports.fuzz = function (data) { + const provider = new FuzzedDataProvider(data); + const input = provider.consumeRemainingAsString(); + + const parseOptions = { + allowDots: provider.consumeBoolean(), + allowEmptyArrays: provider.consumeBoolean(), + allowPrototypes: provider.consumeBoolean(), + arrayLimit: provider.consumeIntegralInRange(0, 32), + charset: provider.pickValue(["utf-8", "iso-8859-1"]), + charsetSentinel: provider.consumeBoolean(), + comma: provider.consumeBoolean(), + decodeDotInKeys: provider.consumeBoolean(), + depth: provider.consumeIntegralInRange(0, 16), + duplicates: provider.pickValue(["combine", "first", "last"]), + ignoreQueryPrefix: provider.consumeBoolean(), + interpretNumericEntities: provider.consumeBoolean(), + parameterLimit: provider.consumeIntegralInRange(1, 256), + parseArrays: provider.consumeBoolean(), + plainObjects: provider.consumeBoolean(), + strictDepth: provider.consumeBoolean(), + strictNullHandling: provider.consumeBoolean(), + }; + + let parsed; + try { + parsed = qs.parse(input, parseOptions); + } catch { + return; + } + + try { + qs.stringify(parsed, { + addQueryPrefix: provider.consumeBoolean(), + allowDots: provider.consumeBoolean(), + allowEmptyArrays: provider.consumeBoolean(), + arrayFormat: provider.pickValue([ + "indices", + "brackets", + "repeat", + "comma", + ]), + charset: provider.pickValue(["utf-8", "iso-8859-1"]), + charsetSentinel: provider.consumeBoolean(), + commaRoundTrip: provider.consumeBoolean(), + delimiter: provider.pickValue(["&", ";"]), + encode: provider.consumeBoolean(), + encodeDotInKeys: provider.consumeBoolean(), + indices: provider.consumeBoolean(), + skipNulls: provider.consumeBoolean(), + strictNullHandling: provider.consumeBoolean(), + }); + } catch { + // Smoke target: ignore library-level parse/stringify failures. + } +}; diff --git a/benchmarks/engine_smoke/package.json b/benchmarks/engine_smoke/package.json new file mode 100644 index 000000000..aa448401c --- /dev/null +++ b/benchmarks/engine_smoke/package.json @@ -0,0 +1,15 @@ +{ + "name": "jazzerjs-engine-smoke", + "version": "1.0.0", + "private": true, + "description": "Manual 30-second smoke benchmark for libFuzzer vs LibAFL.", + "scripts": { + "smoke": "node run.js", + "smoke:anomalies": "node anomaly.js" + }, + "devDependencies": { + "@jazzer.js/core": "file:../../packages/core", + "istanbul-lib-coverage": "^3.2.2", + "qs": "^6.14.0" + } +} diff --git a/benchmarks/engine_smoke/run.js b/benchmarks/engine_smoke/run.js new file mode 100644 index 000000000..6e2539e13 --- /dev/null +++ b/benchmarks/engine_smoke/run.js @@ -0,0 +1,182 @@ +/* + * Copyright 2026 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const libCoverage = require("istanbul-lib-coverage"); + +const benchmarkDirectory = __dirname; +const workDirectory = path.join(benchmarkDirectory, "work"); +const fuzzTarget = path.join(benchmarkDirectory, "fuzz.js"); +const seedCorpusDirectory = path.join(benchmarkDirectory, "seeds"); +const seconds = Number.parseInt(process.argv[2] ?? "30", 10); + +function removeIfExists(targetPath) { + fs.rmSync(targetPath, { force: true, recursive: true }); +} + +function ensureDirectory(targetPath) { + fs.mkdirSync(targetPath, { recursive: true }); +} + +function runCommand(label, args, engineDirectory) { + console.log(`\n[smoke] ${label}`); + console.log(`[smoke] command: npx ${args.join(" ")}`); + ensureDirectory(engineDirectory); + const sanitizedLabel = label.replace(/[^a-z0-9]+/gi, "-").toLowerCase(); + const stdoutPath = path.join(engineDirectory, `${sanitizedLabel}.stdout.log`); + const stderrPath = path.join(engineDirectory, `${sanitizedLabel}.stderr.log`); + const stdoutFd = fs.openSync(stdoutPath, "w"); + const stderrFd = fs.openSync(stderrPath, "w"); + const proc = spawnSync("npx", args, { + cwd: benchmarkDirectory, + env: { ...process.env }, + shell: true, + stdio: ["ignore", stdoutFd, stderrFd], + windowsHide: true, + }); + fs.closeSync(stdoutFd); + fs.closeSync(stderrFd); + if (proc.status !== 0) { + throw new Error( + `${label} failed with exit code ${proc.status}\nSTDOUT (${stdoutPath}):\n${fs.readFileSync(stdoutPath, "utf8")}\nSTDERR (${stderrPath}):\n${fs.readFileSync(stderrPath, "utf8")}`, + ); + } + return { stdoutPath, stderrPath }; +} + +function countFiles(directory) { + if (!fs.existsSync(directory)) { + return 0; + } + return fs + .readdirSync(directory) + .filter((entry) => fs.lstatSync(path.join(directory, entry)).isFile()) + .length; +} + +function summarizeCoverage(coverageDirectory) { + const coverageFile = path.join(coverageDirectory, "coverage-final.json"); + const rawCoverage = JSON.parse(fs.readFileSync(coverageFile, "utf8")); + const coverageMap = libCoverage.createCoverageMap(rawCoverage); + const librarySummary = libCoverage.createCoverageSummary(); + const normalizedNeedle = `${path.sep}node_modules${path.sep}qs${path.sep}`; + + const files = coverageMap + .files() + .filter((filePath) => path.normalize(filePath).includes(normalizedNeedle)); + for (const filePath of files) { + librarySummary.merge(coverageMap.fileCoverageFor(filePath).toSummary()); + } + + return { + files: files.length, + lines: librarySummary.data.lines.pct, + branches: librarySummary.data.branches.pct, + functions: librarySummary.data.functions.pct, + statements: librarySummary.data.statements.pct, + }; +} + +function runSmoke(engine) { + const engineDirectory = path.join(workDirectory, engine); + const generatedCorpusDirectory = path.join( + engineDirectory, + "generated-corpus", + ); + const artifactDirectory = path.join(engineDirectory, "artifacts"); + const coverageDirectory = path.join(engineDirectory, "coverage"); + + removeIfExists(engineDirectory); + ensureDirectory(generatedCorpusDirectory); + ensureDirectory(artifactDirectory); + + runCommand( + `${engine} fuzzing`, + [ + "jazzer", + fuzzTarget, + generatedCorpusDirectory, + seedCorpusDirectory, + "--sync", + "--disable_bug_detectors=.*", + `--engine=${engine}`, + "-i=fuzz.js", + "-i=node_modules/qs/", + "--", + `-max_total_time=${seconds}`, + `-artifact_prefix=${artifactDirectory}${path.sep}`, + ], + engineDirectory, + ); + + removeIfExists(coverageDirectory); + runCommand( + `${engine} regression coverage`, + [ + "jazzer", + fuzzTarget, + generatedCorpusDirectory, + seedCorpusDirectory, + "--sync", + "--mode=regression", + "--coverage", + `--coverage_directory=${coverageDirectory}`, + "--coverage_reporters=json", + "--disable_bug_detectors=.*", + `--engine=${engine}`, + "-i=fuzz.js", + "-i=node_modules/qs/", + ], + engineDirectory, + ); + + return { + engine, + seconds, + generatedCorpusEntries: countFiles(generatedCorpusDirectory), + coverage: summarizeCoverage(coverageDirectory), + }; +} + +function printResult(result) { + console.log(`\n[smoke] ${result.engine}`); + console.log( + `[smoke] generated corpus entries: ${result.generatedCorpusEntries}`, + ); + console.log( + `[smoke] library coverage: lines=${result.coverage.lines}% branches=${result.coverage.branches}% functions=${result.coverage.functions}% statements=${result.coverage.statements}% across ${result.coverage.files} files`, + ); +} + +function main() { + ensureDirectory(workDirectory); + const results = [runSmoke("libfuzzer"), runSmoke("afl")]; + for (const result of results) { + printResult(result); + } + fs.writeFileSync( + path.join(workDirectory, "results.json"), + JSON.stringify(results, null, 2), + ); + console.log( + `\n[smoke] wrote machine-readable results to ${path.join(workDirectory, "results.json")}`, + ); +} + +main(); diff --git a/benchmarks/engine_smoke/seeds/basic.txt b/benchmarks/engine_smoke/seeds/basic.txt new file mode 100644 index 000000000..118274dd8 --- /dev/null +++ b/benchmarks/engine_smoke/seeds/basic.txt @@ -0,0 +1 @@ +a=b&c=d diff --git a/benchmarks/engine_smoke/seeds/encoded.txt b/benchmarks/engine_smoke/seeds/encoded.txt new file mode 100644 index 000000000..73bb8b819 --- /dev/null +++ b/benchmarks/engine_smoke/seeds/encoded.txt @@ -0,0 +1 @@ +utf8=%E2%9C%93&filters[color]=blue&filters[size]=xl&page=2 diff --git a/benchmarks/engine_smoke/seeds/nested.txt b/benchmarks/engine_smoke/seeds/nested.txt new file mode 100644 index 000000000..68cbd1817 --- /dev/null +++ b/benchmarks/engine_smoke/seeds/nested.txt @@ -0,0 +1 @@ +user[name]=alice&user[roles][]=admin&user[roles][]=author From 4222fa77cc5381bf1bfc5cdcf22d8a1ef21f5a25 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:34:50 +0200 Subject: [PATCH 12/26] test: cap root Jest worker usage Limit root Jest parallelism so native-heavy test runs stay usable on developer machines and shared CI hosts. --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d4e1fd6eb..5a8556f53 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "test:default": "npm run test:jest", "test:linux:darwin": "npm run test:jest && cd tests && sh ../scripts/run_all.sh fuzz", "test:win32": "npm run test:jest && cd tests && ..\\scripts\\run_all.bat fuzz", - "test:jest": "jest && npm run test --ws --if-present", - "test:jest:coverage": "jest --coverage", - "test:jest:watch": "jest --watch", + "test:jest": "jest --maxWorkers=25% && npm run test --ws --if-present -- --maxWorkers=25%", + "test:jest:coverage": "jest --coverage --maxWorkers=25%", + "test:jest:watch": "jest --watch --maxWorkers=25%", "example": "run-script-os", "example:linux:darwin": "cd examples && sh ../scripts/run_all.sh dryRun", "example:win32": "cd examples && ..\\scripts\\run_all.bat dryRun", From d57646cadd88987e6d2aef7388a61a3a3261896b Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:34:57 +0200 Subject: [PATCH 13/26] ci(fuzzer): build the Rust runtime in CI Teach release and test workflows about the Rust-backed backend so the native addon is built, cached, and exercised the way it is shipped. --- .github/workflows/release.yaml | 5 +++++ .github/workflows/run-all-tests-main.yaml | 13 ++++++++++--- .github/workflows/run-all-tests-pr.yaml | 17 ++++++++++++++--- 3 files changed, 29 insertions(+), 6 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..be1f8f6a8 100644 --- a/.github/workflows/run-all-tests-main.yaml +++ b/.github/workflows/run-all-tests-main.yaml @@ -21,18 +21,25 @@ 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 - name: install dependencies run: npm ci - name: build project run: npm run build + - 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..b926778c3 100644 --- a/.github/workflows/run-all-tests-pr.yaml +++ b/.github/workflows/run-all-tests-pr.yaml @@ -24,6 +24,8 @@ jobs: with: node-version: 22 cache: "npm" + - name: rust + uses: dtolnay/rust-toolchain@stable - name: install dependencies run: npm ci - name: install clang-tidy @@ -31,6 +33,8 @@ jobs: - name: build project # Build project so that imports can be checked during linting run: npm run build + - 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 @@ -59,14 +63,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 +104,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') From 00ff00789d5428fe682fbe79f2acc473b977c031 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 11:35:07 +0200 Subject: [PATCH 14/26] docs: document backend-specific engine usage Explain how to select LibAFL or libFuzzer and call out the places where their supported options still differ. --- README.md | 8 +++++-- docs/fuzz-settings.md | 44 +++++++++++++++++++++++++++++++++++++-- packages/fuzzer/README.md | 23 ++++++++++---------- 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 85873303e..c45407730 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 LibAFL backend by default. To run with libFuzzer + instead, add `--engine=libfuzzer`. + 4. Enjoy fuzzing! ## Usage diff --git a/docs/fuzz-settings.md b/docs/fuzz-settings.md index 43f3489a3..16b74a287 100644 --- a/docs/fuzz-settings.md +++ b/docs/fuzz-settings.md @@ -589,13 +589,53 @@ JAZZER_FUZZ_ENTRY_POINT=buzz npx jazzer my-fuzz-file _Note:_ In Jest mode, this option cannot be set via environment variable. Instead use the native Jest flag `--testNamePattern` as described above. +### `engine` : [string] + +Default: `"libafl"` in CLI mode, `"libfuzzer"` in Jest mode + +Select the native fuzzing backend. + +- `libfuzzer`: use the existing libFuzzer backend. +- `afl` (alias for `libafl`): use the LibAFL backend. + +**CLI:** Select the backend with `--engine`, for example: + +```bash +npx jazzer my-fuzz-file --engine=afl +``` + +**Jest:** Set it in `.jazzerjsrc.json`: + +```json +{ + "engine": "afl" +} +``` + +LibAFL supports both `fuzzing` and `regression` mode. + ### `fuzzerOptions` : [array\] Default: [] -Pass options to native fuzzing engine (Jazzer.js uses libFuzzer). +Pass options to the selected native fuzzing engine. + +For `engine=libfuzzer`, Jazzer.js supports the full libFuzzer-style argument +list. + +For `engine=afl`/`engine=libafl`, Jazzer.js currently supports these options: + +- `-runs=` +- `-seed=` +- `-max_len=` +- `-max_total_time=` +- `-artifact_prefix=` +- `-dict=` +- non-flag entries interpreted as corpus directories + +Unsupported engine-specific flags are rejected with an explicit error. -For a list of available options, see the +For the `libfuzzer` backend, see the [libFuzzer documentation](https://llvm.org/docs/LibFuzzer.html#options). To get a quick overview of all available options, call Jazzer.js with the libFuzzer argument `-help`. Here is an example for the CLI mode: diff --git a/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 From 56b3aa49be431a173df1b059d7155b3db79ab479 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 12:46:22 +0200 Subject: [PATCH 15/26] fix(fuzzer): reject invalid coverage counter ranges Guard the native coverage registration boundary so malformed JS numbers cannot become pointer arithmetic in C++. This turns a silent memory-safety footgun into an explicit addon error and locks the behavior in with regression tests. --- packages/fuzzer/fuzzer.test.ts | 16 +++++++++++++++ packages/fuzzer/shared/coverage.cpp | 32 +++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/fuzzer/fuzzer.test.ts b/packages/fuzzer/fuzzer.test.ts index c18580c4e..e75c2fb62 100644 --- a/packages/fuzzer/fuzzer.test.ts +++ b/packages/fuzzer/fuzzer.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { addon } from "./addon"; import { fuzzer } from "./fuzzer"; describe("compare hooks", () => { @@ -49,4 +50,19 @@ describe("incrementCounter", () => { } } }); + + it("rejects invalid counter ranges at the native boundary", () => { + addon.registerCoverageMap(Buffer.alloc(16)); + 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(); + } + }); }); diff --git a/packages/fuzzer/shared/coverage.cpp b/packages/fuzzer/shared/coverage.cpp index fc5b0876d..9f9c918d8 100644 --- a/packages/fuzzer/shared/coverage.cpp +++ b/packages/fuzzer/shared/coverage.cpp @@ -13,9 +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); @@ -24,6 +26,8 @@ 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. // It is preallocated on the JavaScript side; registerNewCounters grows the // active prefix that the fuzzing backends should observe. @@ -66,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) { @@ -89,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(), @@ -101,7 +125,7 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { info.Env(), "new_num_counters must not be smaller than old_num_counters"); } - if (static_cast(new_num_counters) > gCoverageCountersCapacity) { + if (new_num_counters > gCoverageCountersCapacity) { throw Napi::Error::New(info.Env(), "new_num_counters exceeds the coverage map size"); } @@ -111,7 +135,7 @@ void RegisterNewCounters(const Napi::CallbackInfo &info) { RegisterCounterRange(gCoverageCounters + old_num_counters, gCoverageCounters + new_num_counters); - gCoverageCountersSize = static_cast(new_num_counters); + gCoverageCountersSize = new_num_counters; } uint8_t *CoverageCounters() { return gCoverageCounters; } From c2e2bbf430c5574d22bbdb84bc9864eed7829667 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 12:51:03 +0200 Subject: [PATCH 16/26] fix(core): reject invalid fuzzing modes early Normalize the mode option at merge time so invalid values fail before they leak into backend-specific code paths. This keeps CLI, config, env, and Jest inputs consistent instead of silently treating junk as fuzzing. --- packages/options/index.ts | 15 +++++++++++ packages/options/options.test.ts | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/options/index.ts b/packages/options/index.ts index e70ed2226..de6f79eb4 100644 --- a/packages/options/index.ts +++ b/packages/options/index.ts @@ -161,6 +161,7 @@ export enum OptionSource { } export type FuzzingEngine = Options["engine"]; +export type FuzzingMode = Options["mode"]; export function resolveEngine(engine: string): FuzzingEngine { switch (engine) { @@ -176,6 +177,18 @@ export function resolveEngine(engine: string): FuzzingEngine { } } +export function resolveMode(mode: string): FuzzingMode { + switch (mode) { + case "fuzzing": + case "regression": + return mode; + default: + throw new Error( + `Unknown fuzzing mode '${mode}'. Supported modes are 'fuzzing' and 'regression'.`, + ); + } +} + type DefaultSourceInfo = { name: string; transformKey: KeyFormatSource; @@ -310,6 +323,8 @@ export class OptionsManager { } 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 }); diff --git a/packages/options/options.test.ts b/packages/options/options.test.ts index b68a78869..bdd5b1f45 100644 --- a/packages/options/options.test.ts +++ b/packages/options/options.test.ts @@ -16,11 +16,14 @@ import { defaultCLIOptions, + defaultJestOptions, fromSnakeCase, fromSnakeCaseWithPrefix, Options, OptionsManager, OptionSource, + resolveEngine, + resolveMode, validateKeySource, } from "./index"; @@ -68,6 +71,14 @@ describe("options", () => { }); describe("merge", () => { + it("uses LibAFL as default CLI engine", () => { + expect(defaultCLIOptions.engine).toBe("libafl"); + }); + + it("keeps libFuzzer as default Jest engine", () => { + expect(defaultJestOptions.engine).toBe("libfuzzer"); + }); + it("New options with lower priorities will not be added", () => { const baseOptions = OptionsManager.attachSource( defaultCLIOptions, @@ -215,6 +226,16 @@ describe("options", () => { ); }).toThrow("expected type 'string'"); }); + it("rejects invalid mode values during option merge", () => { + expect(() => { + withSource( + OptionSource.DefaultCLIOptions, + { mode: "nonsense" }, + OptionSource.CommandLineArguments, + () => undefined, + ); + }).toThrow("Unknown fuzzing mode"); + }); it("options are copied", () => { const input = { includes: ["foo"] }; withSource( @@ -252,6 +273,30 @@ describe("options", () => { }); }); +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("fuzzing"); + expect(resolveMode("regression")).toBe("regression"); + expect(() => resolveMode("unknown")).toThrow("Unknown fuzzing 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"); + }); +}); + describe("KeyFormatSource", () => { describe("fromSnakeCase", () => { it("converts to camelCase", () => { From a4eb8239b5d695c9b1dbbe3b0db1d322b097bf42 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 12:57:42 +0200 Subject: [PATCH 17/26] fix(fuzzer): contain sync stop callback errors Reject stop callback failures on the JS promise instead of letting them unwind through the Rust execution callback. The new child-process regression reproduces the pre-fix Rust panic triggered by a SIGINT stop callback that throws. --- packages/fuzzer/libafl_runtime.cpp | 36 +++++++++++++++++++++++- packages/fuzzer/libafl_runtime.test.ts | 39 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/fuzzer/libafl_runtime.cpp b/packages/fuzzer/libafl_runtime.cpp index 27fe82a38..9829d0547 100644 --- a/packages/fuzzer/libafl_runtime.cpp +++ b/packages/fuzzer/libafl_runtime.cpp @@ -203,6 +203,16 @@ void StoreDeferredRejection(AsyncFuzzTargetContext *context, 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) { @@ -492,7 +502,31 @@ int ExecuteSyncInput(void *user_data, const uint8_t *data, size_t size) { exit_code = Napi::Number::New(context->env, context->signal_status); } - context->js_stop_callback.Call({exit_code}); + 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; } diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts index 763a65dae..e20e8ac2e 100644 --- a/packages/fuzzer/libafl_runtime.test.ts +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -258,6 +258,45 @@ describe("LibAFL runtime", () => { }, ); + it("rejects thrown stop callbacks instead of panicking through Rust", () => { + const script = ` + const addon = require(${JSON.stringify(nativeAddonPath())}); + addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + 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(); From 2853b4627413cbac0ccc3ae2f15ed5c644491c50 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 13:05:09 +0200 Subject: [PATCH 18/26] fix(core): restore libFuzzer as the CLI default Make the standalone CLI keep its existing libFuzzer compatibility unless users explicitly opt into LibAFL. This avoids surprising failures for established libFuzzer flag combinations while the LibAFL backend continues to harden. --- docs/fuzz-settings.md | 2 +- packages/core/cli.ts | 2 +- packages/core/options.test.ts | 4 ++-- packages/options/index.ts | 2 +- packages/options/options.test.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/fuzz-settings.md b/docs/fuzz-settings.md index 16b74a287..755731a90 100644 --- a/docs/fuzz-settings.md +++ b/docs/fuzz-settings.md @@ -591,7 +591,7 @@ Instead use the native Jest flag `--testNamePattern` as described above. ### `engine` : [string] -Default: `"libafl"` in CLI mode, `"libfuzzer"` in Jest mode +Default: `"libfuzzer"` in CLI mode, `"libfuzzer"` in Jest mode Select the native fuzzing backend. diff --git a/packages/core/cli.ts b/packages/core/cli.ts index a471f6662..44114f03a 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -181,7 +181,7 @@ yargs(process.argv.slice(2)) alias: ["backend"], defaultDescription: `${JSON.stringify(defaultCLIOptions.engine)}`, describe: - "Fuzzing engine backend. Use 'afl' (alias 'libafl') for the default LibAFL backend or 'libfuzzer' to run the libFuzzer backend.", + "Fuzzing engine backend. Use 'libfuzzer' for the default backend or 'afl' (alias 'libafl') to opt into the LibAFL backend.", group: "Fuzzer:", type: "string", }) diff --git a/packages/core/options.test.ts b/packages/core/options.test.ts index d2a213230..e03576b0c 100644 --- a/packages/core/options.test.ts +++ b/packages/core/options.test.ts @@ -31,8 +31,8 @@ import { buildLibAflOptions, spawnsSubprocess } from "./options"; describe("options", () => { describe("merge", () => { - it("uses LibAFL as default CLI engine", () => { - expect(defaultCLIOptions.engine).toBe("libafl"); + it("keeps libFuzzer as default CLI engine", () => { + expect(defaultCLIOptions.engine).toBe("libfuzzer"); }); it("keeps libFuzzer as default Jest engine", () => { diff --git a/packages/options/index.ts b/packages/options/index.ts index de6f79eb4..55eaa6eaf 100644 --- a/packages/options/index.ts +++ b/packages/options/index.ts @@ -88,7 +88,7 @@ const allowedFuzzTestOptions = [ export type AllowedFuzzTestOptions = (typeof allowedFuzzTestOptions)[number]; export const defaultCLIOptions: Options = Object.freeze({ - engine: "libafl", + engine: "libfuzzer", coverage: false, coverageDirectory: "coverage", coverageReporters: ["json", "text", "lcov", "clover"], diff --git a/packages/options/options.test.ts b/packages/options/options.test.ts index bdd5b1f45..e517a08b3 100644 --- a/packages/options/options.test.ts +++ b/packages/options/options.test.ts @@ -71,8 +71,8 @@ describe("options", () => { }); describe("merge", () => { - it("uses LibAFL as default CLI engine", () => { - expect(defaultCLIOptions.engine).toBe("libafl"); + it("keeps libFuzzer as default CLI engine", () => { + expect(defaultCLIOptions.engine).toBe("libfuzzer"); }); it("keeps libFuzzer as default Jest engine", () => { From 4ae125c31a8563d8c399b5e598ff8ea99eae099c Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 16:06:21 +0200 Subject: [PATCH 19/26] refactor(options): align extracted options with LibAFL Finish the options split by moving engine and mode checks into the leaf package and sharing the LibAFL backend DTO with the fuzzer package. This leaves one generic extraction commit at the start of the branch and one LibAFL-specific follow-up later in the stack. --- package-lock.json | 1 + packages/core/options.test.ts | 37 +----------------------- packages/core/options.ts | 19 +++++-------- packages/fuzzer/addon.ts | 15 ++-------- packages/fuzzer/package.json | 1 + packages/fuzzer/tsconfig.json | 7 ++++- packages/options/index.ts | 13 +++++++++ packages/options/options.test.ts | 48 ++++++++++++++++---------------- 8 files changed, 56 insertions(+), 85 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0d26d776..3d0908f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7717,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" diff --git a/packages/core/options.test.ts b/packages/core/options.test.ts index e03576b0c..12f86ea60 100644 --- a/packages/core/options.test.ts +++ b/packages/core/options.test.ts @@ -18,29 +18,10 @@ import fs from "fs"; import os from "os"; import path from "path"; -import { - defaultCLIOptions, - defaultJestOptions, - Options, - OptionsManager, - OptionSource, - resolveEngine, -} from "@jazzer.js/options"; +import { OptionsManager, OptionSource } from "@jazzer.js/options"; import { buildLibAflOptions, spawnsSubprocess } from "./options"; -describe("options", () => { - 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"); - }); - }); -}); - describe("buildLibFuzzerOptions", () => { describe("spawnsSubprocess", () => { it("checks if subprocess libFuzzer flags are present", () => { @@ -57,22 +38,6 @@ describe("buildLibFuzzerOptions", () => { }); describe("libafl options", () => { - it("normalizes engine aliases", () => { - expect(resolveEngine("libfuzzer")).toBe("libfuzzer"); - expect(resolveEngine("afl")).toBe("libafl"); - expect(resolveEngine("libafl")).toBe("libafl"); - expect(() => resolveEngine("unknown")).toThrow("Unknown fuzzing engine"); - }); - - it("canonicalizes engine aliases during option merge", () => { - const manager = new OptionsManager(OptionSource.DefaultJestOptions).merge( - { engine: "afl" }, - OptionSource.ConfigurationFile, - ); - - expect(manager.get("engine")).toBe("libafl"); - }); - it("builds structured LibAFL options from fuzzer options", () => { const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( { diff --git a/packages/core/options.ts b/packages/core/options.ts index b8a3aa3c7..39f6db52a 100644 --- a/packages/core/options.ts +++ b/packages/core/options.ts @@ -20,6 +20,7 @@ import * as util from "util"; import * as tmp from "tmp"; import { + type LibAflOptions, type OptionsManager, resolveEngine, toOptionsWithPrintableSources, @@ -29,24 +30,14 @@ import { useDictionaryByParams } from "./dictionary"; export * from "@jazzer.js/options"; -export type LibAflOptions = { - mode: "fuzzing" | "regression"; - runs: number; - seed: number; - maxLen: number; - timeoutMillis: number; - maxTotalTimeSeconds: number; - artifactPrefix: string; - corpusDirectories: string[]; - dictionaryFiles: string[]; -}; - export function buildLibFuzzerOptions(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); @@ -54,6 +45,7 @@ export function buildLibFuzzerOptions(options: OptionsManager) { return params; } +// Backwards-compatible alias for existing call sites. export const buildFuzzerOption = buildLibFuzzerOptions; export function buildLibAflOptions(options: OptionsManager): LibAflOptions { @@ -114,6 +106,8 @@ export function buildLibAflOptions(options: OptionsManager): LibAflOptions { } if (options.get("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; } @@ -216,6 +210,7 @@ function optionDependentParams( let opts = options.get("fuzzerOptions"); if (options.get("mode") === "regression") { + // The last provided option takes precedence opts = opts.concat("-runs=0"); } diff --git a/packages/fuzzer/addon.ts b/packages/fuzzer/addon.ts index 8ce4db429..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,18 +28,7 @@ export type FuzzTargetCallback = ( ) => unknown; export type FuzzTarget = FuzzTargetAsyncOrValue | FuzzTargetCallback; export type FuzzOpts = string[]; - -export type LibAflOptions = { - mode: "fuzzing" | "regression"; - runs: number; - seed: number; - maxLen: number; - timeoutMillis: number; - maxTotalTimeSeconds: number; - artifactPrefix: string; - corpusDirectories: string[]; - dictionaryFiles: string[]; -}; +export type { LibAflOptions } from "@jazzer.js/options"; export type StartFuzzingSyncFn = ( fuzzFn: FuzzTarget, diff --git a/packages/fuzzer/package.json b/packages/fuzzer/package.json index 53ce9191a..8e0fef8f9 100644 --- a/packages/fuzzer/package.json +++ b/packages/fuzzer/package.json @@ -28,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/tsconfig.json b/packages/fuzzer/tsconfig.json index 2d70535b6..49855ce7d 100644 --- a/packages/fuzzer/tsconfig.json +++ b/packages/fuzzer/tsconfig.json @@ -4,5 +4,10 @@ "rootDir": ".", "outDir": "dist" }, - "exclude": ["build", "dist", "runtime", "cmake-build-*"] + "exclude": ["build", "dist", "runtime", "cmake-build-*"], + "references": [ + { + "path": "../options" + } + ] } diff --git a/packages/options/index.ts b/packages/options/index.ts index 55eaa6eaf..ea6e1e583 100644 --- a/packages/options/index.ts +++ b/packages/options/index.ts @@ -14,6 +14,18 @@ * limitations under the License. */ +export type LibAflOptions = { + mode: "fuzzing" | "regression"; + runs: number; + seed: number; + maxLen: number; + timeoutMillis: number; + maxTotalTimeSeconds: number; + artifactPrefix: string; + corpusDirectories: string[]; + dictionaryFiles: string[]; +}; + /** * Jazzer.js options structure expected by the fuzzer. * @@ -137,6 +149,7 @@ export const fromSnakeCase: KeyFormatSource = (key: string): string => { group.toUpperCase().replace("_", ""), ); }; + export const fromSnakeCaseWithPrefix: (prefix: string) => KeyFormatSource = ( prefix: string, ): KeyFormatSource => { diff --git a/packages/options/options.test.ts b/packages/options/options.test.ts index e517a08b3..b41e8e316 100644 --- a/packages/options/options.test.ts +++ b/packages/options/options.test.ts @@ -273,30 +273,6 @@ describe("options", () => { }); }); -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("fuzzing"); - expect(resolveMode("regression")).toBe("regression"); - expect(() => resolveMode("unknown")).toThrow("Unknown fuzzing 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"); - }); -}); - describe("KeyFormatSource", () => { describe("fromSnakeCase", () => { it("converts to camelCase", () => { @@ -334,6 +310,30 @@ describe("KeyFormatSource", () => { }); }); +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("fuzzing"); + expect(resolveMode("regression")).toBe("regression"); + expect(() => resolveMode("unknown")).toThrow("Unknown fuzzing 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, From 7919e2fd694f3905a42c1d322877ce974a1f33a3 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 18:22:31 +0200 Subject: [PATCH 20/26] refactor: fuzzer mode is unified --- packages/core/cli.ts | 7 ++- packages/core/core.ts | 9 +--- packages/core/options.ts | 22 ++------ packages/jest-runner/config.test.ts | 6 +-- packages/jest-runner/config.ts | 9 +++- packages/jest-runner/fuzz.test.ts | 4 +- packages/jest-runner/fuzz.ts | 9 ++-- .../jest-runner/testStateInterceptor.test.ts | 15 ++++-- packages/jest-runner/testStateInterceptor.ts | 8 +-- packages/options/index.ts | 52 ++++++++++++++----- packages/options/options.test.ts | 9 ++-- 11 files changed, 86 insertions(+), 64 deletions(-) diff --git a/packages/core/cli.ts b/packages/core/cli.ts index 44114f03a..0372ed802 100644 --- a/packages/core/cli.ts +++ b/packages/core/cli.ts @@ -17,8 +17,13 @@ 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"; // Use yargs to parse command line arguments and provide a nice CLI experience. diff --git a/packages/core/core.ts b/packages/core/core.ts index cfce39ef1..5d8502ca7 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -32,6 +32,7 @@ import { registerEsmLoaderHooks, registerInstrumentor, } from "@jazzer.js/instrumentor"; +import { OptionsManager, resolveEngine } from "@jazzer.js/options"; import { getCallbacks } from "./callback"; import { @@ -508,14 +509,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/options.ts b/packages/core/options.ts index 39f6db52a..7082a18d5 100644 --- a/packages/core/options.ts +++ b/packages/core/options.ts @@ -15,21 +15,19 @@ */ import fs from "fs"; -import * as util from "util"; import * as tmp from "tmp"; import { type LibAflOptions, + Mode, type OptionsManager, + printOptions, resolveEngine, - toOptionsWithPrintableSources, } from "@jazzer.js/options"; import { useDictionaryByParams } from "./dictionary"; -export * from "@jazzer.js/options"; - export function buildLibFuzzerOptions(options: OptionsManager) { let params: string[] = []; params = optionDependentParams(options, params); @@ -105,7 +103,7 @@ export function buildLibAflOptions(options: OptionsManager): LibAflOptions { ); } - if (options.get("mode") === "regression") { + 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; @@ -180,18 +178,6 @@ function parseDecimalInteger(name: string, value: string): number { return parsed; } -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), - ), - ); - } -} - function logInfoAboutFuzzerOptions(fuzzerOptions: string[]) { fuzzerOptions.slice(1).forEach((element) => { if (element.length > 0 && element[0] != "-") { @@ -209,7 +195,7 @@ function optionDependentParams( } let opts = options.get("fuzzerOptions"); - if (options.get("mode") === "regression") { + if (options.get("mode") === Mode.Regression) { // The last provided option takes precedence opts = opts.concat("-runs=0"); } diff --git a/packages/jest-runner/config.test.ts b/packages/jest-runner/config.test.ts index fdd6f4095..bf87e584b 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/options"; +import { Mode, OptionsManager, OptionSource } from "@jazzer.js/options"; import { loadConfig } from "./config"; @@ -46,12 +46,12 @@ describe("Config", () => { ); }); 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 a9fd9d7e0..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/options"; +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 9e186d631..a3c61132c 100644 --- a/packages/jest-runner/fuzz.test.ts +++ b/packages/jest-runner/fuzz.test.ts @@ -21,7 +21,7 @@ import * as tmp from "tmp"; import { FindingAwareFuzzTarget, startFuzzingNoInit } from "@jazzer.js/core"; import { FuzzTarget } from "@jazzer.js/fuzzer"; -import { OptionsManager, OptionSource } from "@jazzer.js/options"; +import { Mode, OptionsManager, OptionSource } from "@jazzer.js/options"; import { Corpus } from "./corpus"; import { @@ -72,7 +72,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 6af6b7702..f51cff462 100644 --- a/packages/jest-runner/fuzz.ts +++ b/packages/jest-runner/fuzz.ts @@ -24,14 +24,15 @@ import { FuzzTarget, FuzzTargetAsyncOrValue, FuzzTargetCallback, - printOptions, startFuzzingNoInit, } from "@jazzer.js/core"; import { AllowedFuzzTestOptions, + Mode, Options, OptionsManager, OptionSource, + printOptions, } from "@jazzer.js/options"; import { Corpus } from "./corpus"; @@ -141,13 +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")}`); diff --git a/packages/jest-runner/testStateInterceptor.test.ts b/packages/jest-runner/testStateInterceptor.test.ts index dd5b04120..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/options"; +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 1901bf77d..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/options"; +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/packages/options/index.ts b/packages/options/index.ts index ea6e1e583..f54d1a17f 100644 --- a/packages/options/index.ts +++ b/packages/options/index.ts @@ -13,6 +13,14 @@ * 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: "fuzzing" | "regression"; @@ -65,7 +73,7 @@ export interface Options { // Part of filepath names to include in the instrumentation. includes: string[]; // Fuzzing mode. - mode: "fuzzing" | "regression"; + mode: Mode; // Whether to run the fuzzer in sync mode or not. sync: boolean; // Timeout for one fuzzing iteration in milliseconds. @@ -115,7 +123,7 @@ export const defaultCLIOptions: Options = Object.freeze({ fuzzTarget: "", idSyncFile: "", includes: ["*"], - mode: "fuzzing", + mode: Mode.Fuzzing, sync: false, timeout: 5000, verbose: false, @@ -124,7 +132,7 @@ export const defaultCLIOptions: Options = Object.freeze({ export const defaultJestOptions: Options = Object.freeze({ ...defaultCLIOptions, engine: "libfuzzer", - mode: "regression", + mode: Mode.Regression, }); export type KeyFormatSource = (key: string) => string; @@ -174,7 +182,6 @@ export enum OptionSource { } export type FuzzingEngine = Options["engine"]; -export type FuzzingMode = Options["mode"]; export function resolveEngine(engine: string): FuzzingEngine { switch (engine) { @@ -190,16 +197,23 @@ export function resolveEngine(engine: string): FuzzingEngine { } } -export function resolveMode(mode: string): FuzzingMode { - switch (mode) { - case "fuzzing": - case "regression": - return mode; - default: - throw new Error( - `Unknown fuzzing mode '${mode}'. Supported modes are 'fuzzing' and 'regression'.`, - ); +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 = { @@ -455,6 +469,18 @@ export function toOptionsWithPrintableSources( 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; diff --git a/packages/options/options.test.ts b/packages/options/options.test.ts index b41e8e316..6f8c20587 100644 --- a/packages/options/options.test.ts +++ b/packages/options/options.test.ts @@ -19,6 +19,7 @@ import { defaultJestOptions, fromSnakeCase, fromSnakeCaseWithPrefix, + Mode, Options, OptionsManager, OptionSource, @@ -234,7 +235,7 @@ describe("options", () => { OptionSource.CommandLineArguments, () => undefined, ); - }).toThrow("Unknown fuzzing mode"); + }).toThrow("Unknown fuzzer mode"); }); it("options are copied", () => { const input = { includes: ["foo"] }; @@ -319,9 +320,9 @@ describe("engine and mode", () => { }); it("normalizes fuzzing modes", () => { - expect(resolveMode("fuzzing")).toBe("fuzzing"); - expect(resolveMode("regression")).toBe("regression"); - expect(() => resolveMode("unknown")).toThrow("Unknown fuzzing mode"); + 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", () => { From 311fa877a86006e065258a151693c98027c0ee2c Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Thu, 30 Apr 2026 18:23:18 +0200 Subject: [PATCH 21/26] afl: use unified fuzzer mode FIXUP --- packages/core/core.ts | 7 +------ packages/core/options.test.ts | 8 ++++---- packages/fuzzer/libafl_runtime.test.ts | 6 ++++-- packages/fuzzer/runtime/benchmark.js | 3 ++- packages/options/index.ts | 2 +- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/core/core.ts b/packages/core/core.ts index 5d8502ca7..639df05f0 100644 --- a/packages/core/core.ts +++ b/packages/core/core.ts @@ -44,12 +44,7 @@ import { reportFinding, } from "./finding"; import { getJazzerJsGlobal, jazzerJs } from "./globals"; -import { - buildLibAflOptions, - buildLibFuzzerOptions, - OptionsManager, - resolveEngine, -} from "./options"; +import { buildLibAflOptions, buildLibFuzzerOptions } from "./options"; import { ensureFilepath, importModule } from "./utils"; // Remove temporary files on exit diff --git a/packages/core/options.test.ts b/packages/core/options.test.ts index 12f86ea60..686d29d0c 100644 --- a/packages/core/options.test.ts +++ b/packages/core/options.test.ts @@ -18,7 +18,7 @@ import fs from "fs"; import os from "os"; import path from "path"; -import { OptionsManager, OptionSource } from "@jazzer.js/options"; +import { Mode, OptionsManager, OptionSource } from "@jazzer.js/options"; import { buildLibAflOptions, spawnsSubprocess } from "./options"; @@ -57,7 +57,7 @@ describe("libafl options", () => { ); expect(buildLibAflOptions(manager)).toEqual({ - mode: "fuzzing", + mode: Mode.Fuzzing, runs: 99, seed: 1337, maxLen: 1024, @@ -85,14 +85,14 @@ describe("libafl options", () => { const manager = new OptionsManager(OptionSource.DefaultCLIOptions).merge( { engine: "libafl", - mode: "regression", + mode: Mode.Regression, fuzzerOptions: ["corpus", "-runs=1"], }, OptionSource.CommandLineArguments, ); expect(buildLibAflOptions(manager)).toEqual({ - mode: "regression", + mode: Mode.Regression, runs: 0, seed: 0, maxLen: 4096, diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts index e20e8ac2e..bd20ce54a 100644 --- a/packages/fuzzer/libafl_runtime.test.ts +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -19,11 +19,13 @@ 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: "fuzzing" as const, + mode: Mode.Fuzzing, runs: 32, seed: 1234, maxLen: 64, @@ -308,7 +310,7 @@ describe("LibAFL runtime", () => { fuzzer.tracer.tracePcIndir(13, data.length); }, { - mode: "fuzzing", + mode: Mode.Fuzzing, runs: 1, seed: 9, maxLen: 16, diff --git a/packages/fuzzer/runtime/benchmark.js b/packages/fuzzer/runtime/benchmark.js index 8b08482ee..88c6fd393 100644 --- a/packages/fuzzer/runtime/benchmark.js +++ b/packages/fuzzer/runtime/benchmark.js @@ -14,6 +14,7 @@ * limitations under the License. */ +const { Mode } = require("../../options/dist/index.js"); const { addon } = require("../dist/addon.js"); const { fuzzer } = require("../dist/fuzzer.js"); @@ -28,7 +29,7 @@ const libFuzzerArgs = [ `-max_len=${maxLen}`, ]; const libAflOptions = { - mode: "fuzzing", + mode: Mode.Fuzzing, runs, seed, maxLen, diff --git a/packages/options/index.ts b/packages/options/index.ts index f54d1a17f..c804fa286 100644 --- a/packages/options/index.ts +++ b/packages/options/index.ts @@ -23,7 +23,7 @@ export const Mode = { export type Mode = (typeof Mode)[keyof typeof Mode]; export type LibAflOptions = { - mode: "fuzzing" | "regression"; + mode: Mode; runs: number; seed: number; maxLen: number; From 2b03ec9c77414623a34699b3da12cdb5515ff20f Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Mon, 4 May 2026 11:34:02 +0200 Subject: [PATCH 22/26] test(fuzzer): skip windows SIGINT stop test --- packages/fuzzer/libafl_runtime.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts index bd20ce54a..7a51ca512 100644 --- a/packages/fuzzer/libafl_runtime.test.ts +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -260,8 +260,10 @@ describe("LibAFL runtime", () => { }, ); - it("rejects thrown stop callbacks instead of panicking through Rust", () => { - const script = ` + (process.platform === "win32" ? it.skip : it)( + "rejects thrown stop callbacks instead of panicking through Rust", + () => { + const script = ` const addon = require(${JSON.stringify(nativeAddonPath())}); addon.registerCoverageMap(Buffer.alloc(${1 << 20})); addon.registerNewCounters(0, 512); @@ -291,13 +293,14 @@ describe("LibAFL runtime", () => { setTimeout(() => process.exit(4), 1000); `; - const result = spawnSync(process.execPath, ["-e", script], { - encoding: "utf8", - timeout: 5000, - }); + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf8", + timeout: 5000, + }); - expect(result.status).toBe(0); - }); + expect(result.status).toBe(0); + }, + ); it("records compare feedback in the shared native map", async () => { addon.clearCompareFeedbackMap(); From 36da2b5de958f0ecade88df5edf1e764d86215d7 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Mon, 4 May 2026 11:34:42 +0200 Subject: [PATCH 23/26] ci: cap unit test jobs at 30 minutes --- .github/workflows/run-all-tests-pr.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/run-all-tests-pr.yaml b/.github/workflows/run-all-tests-pr.yaml index b926778c3..b048ac46b 100644 --- a/.github/workflows/run-all-tests-pr.yaml +++ b/.github/workflows/run-all-tests-pr.yaml @@ -44,6 +44,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] From 88910e00e0d064eb3f9fa4f948e75ef94f5146cf Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Mon, 4 May 2026 12:58:10 +0200 Subject: [PATCH 24/26] chore: support building with clang with older glibc --- packages/fuzzer/CMakeLists.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/fuzzer/CMakeLists.txt b/packages/fuzzer/CMakeLists.txt index 478c060d1..0e27498ad 100644 --- a/packages/fuzzer/CMakeLists.txt +++ b/packages/fuzzer/CMakeLists.txt @@ -214,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}) From 00ec13d060913a454a96647a71da5666a7865544 Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Mon, 4 May 2026 13:25:56 +0200 Subject: [PATCH 25/26] test(fuzzer): keep direct coverage buffers alive --- packages/fuzzer/fuzzer.test.ts | 3 ++- packages/fuzzer/libafl_runtime.test.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/fuzzer/fuzzer.test.ts b/packages/fuzzer/fuzzer.test.ts index e75c2fb62..eb3d7c452 100644 --- a/packages/fuzzer/fuzzer.test.ts +++ b/packages/fuzzer/fuzzer.test.ts @@ -52,7 +52,8 @@ describe("incrementCounter", () => { }); it("rejects invalid counter ranges at the native boundary", () => { - addon.registerCoverageMap(Buffer.alloc(16)); + const coverageMap = Buffer.alloc(16); + addon.registerCoverageMap(coverageMap); for (const [oldNumCounters, newNumCounters] of [ [-1, 1], [1.5, 2], diff --git a/packages/fuzzer/libafl_runtime.test.ts b/packages/fuzzer/libafl_runtime.test.ts index 7a51ca512..2a923e415 100644 --- a/packages/fuzzer/libafl_runtime.test.ts +++ b/packages/fuzzer/libafl_runtime.test.ts @@ -145,7 +145,8 @@ describe("LibAFL runtime", () => { try { const script = ` const addon = require(${JSON.stringify(nativeAddonPath())}); - addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); addon.registerNewCounters(0, 512); addon.startLibAflAsync( @@ -189,7 +190,8 @@ describe("LibAFL runtime", () => { it("ignores late done callbacks after an input already settled", () => { const script = ` const addon = require(${JSON.stringify(nativeAddonPath())}); - addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); addon.registerNewCounters(0, 512); let invocations = 0; @@ -229,7 +231,8 @@ describe("LibAFL runtime", () => { const options = { ...libAflOptions, runs: 1 }; const script = ` const addon = require(${JSON.stringify(nativeAddonPath())}); - addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); addon.registerNewCounters(0, 512); let restored = false; @@ -265,7 +268,8 @@ describe("LibAFL runtime", () => { () => { const script = ` const addon = require(${JSON.stringify(nativeAddonPath())}); - addon.registerCoverageMap(Buffer.alloc(${1 << 20})); + const coverageMap = Buffer.alloc(${1 << 20}); + addon.registerCoverageMap(coverageMap); addon.registerNewCounters(0, 512); let invocations = 0; From 2387a64a6371b668cd3266db4b40cf5f92a7d19c Mon Sep 17 00:00:00 2001 From: Peter Samarin Date: Mon, 4 May 2026 13:29:29 +0200 Subject: [PATCH 26/26] fix(fuzzer): restore legacy libFuzzer handlers --- packages/fuzzer/fuzzer.test.ts | 53 +++++++++++++++++++++++++++++++ packages/fuzzer/fuzzing_async.cpp | 23 ++++++++++++-- packages/fuzzer/fuzzing_sync.cpp | 27 ++++++++++++++-- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/fuzzer/fuzzer.test.ts b/packages/fuzzer/fuzzer.test.ts index eb3d7c452..045d2b266 100644 --- a/packages/fuzzer/fuzzer.test.ts +++ b/packages/fuzzer/fuzzer.test.ts @@ -14,9 +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); @@ -66,4 +77,46 @@ describe("incrementCounter", () => { ).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/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) {