From fb44269a53b6472c666635c020d638b7e4df4c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Drwi=C4=99ga?= Date: Wed, 6 May 2026 20:28:29 +0200 Subject: [PATCH] Support fuzz configuration via env variables --- Dockerfile | 2 +- bin/jam/README.md | 31 ++++++ bin/jam/fuzz-env.test.ts | 205 +++++++++++++++++++++++++++++++++++++++ bin/jam/fuzz-env.ts | 87 +++++++++++++++++ bin/jam/index.ts | 23 ++++- 5 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 bin/jam/fuzz-env.test.ts create mode 100644 bin/jam/fuzz-env.ts diff --git a/Dockerfile b/Dockerfile index 9b226423f..63841f025 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:25-bookworm-slim +FROM --platform=linux/amd64 node:25-bookworm-slim RUN useradd -d /app -m typeberry diff --git a/bin/jam/README.md b/bin/jam/README.md index d0cec8d3b..a88695638 100644 --- a/bin/jam/README.md +++ b/bin/jam/README.md @@ -132,6 +132,37 @@ JAM_LOG=trace jam dev 1 JAM_LOG=networking:debug,state:trace jam ``` +### `JAM_FUZZ*` (Standard Target Packaging) + +When `JAM_FUZZ` is set, the node starts in fuzz-target mode regardless of any +command-line arguments (passing CLI args alongside `JAM_FUZZ` is rejected). +This is the entrypoint expected by the +[JAM conformance fuzz target packaging](https://github.com/davxy/jam-conformance/tree/main/fuzz-proto#standard-target-packaging) +contract. + +| Variable | Required | Purpose | +|----------|----------|---------| +| `JAM_FUZZ` | Yes (any non-empty value) | Activates fuzz mode. | +| `JAM_FUZZ_SPEC` | Yes | Chain spec: `tiny` or `full`. | +| `JAM_FUZZ_SOCK_PATH` | Yes | Unix domain socket path the target listens on. | +| `JAM_FUZZ_DATA_PATH` | Yes | Persistent data directory (currently unused; reserved). | +| `JAM_FUZZ_LOG_LEVEL` | No | Log verbosity: `error`, `warn`, `info`, `debug`, `trace`. Overrides `JAM_LOG` in fuzz mode. | + +The target stays up across multiple fuzzer sessions; on each `Initialize` +message it resets the in-memory state to the genesis sent by the fuzzer. + +**Docker example:** + +```bash +docker run --rm \ + -e JAM_FUZZ=1 \ + -e JAM_FUZZ_SPEC=tiny \ + -e JAM_FUZZ_SOCK_PATH=/tmp/jam.sock \ + -e JAM_FUZZ_DATA_PATH=/tmp/jam-data \ + -v /tmp:/tmp \ + typeberry:latest +``` + ### OpenTelemetry Variables | Variable | Description | Default | diff --git a/bin/jam/fuzz-env.test.ts b/bin/jam/fuzz-env.test.ts new file mode 100644 index 000000000..134deea0d --- /dev/null +++ b/bin/jam/fuzz-env.test.ts @@ -0,0 +1,205 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { KnownChainSpec, NODE_DEFAULTS } from "@typeberry/config-node"; +import { Level } from "@typeberry/logger"; +import { Command } from "./args.js"; +import { + JAM_FUZZ, + JAM_FUZZ_DATA_PATH, + JAM_FUZZ_LOG_LEVEL, + JAM_FUZZ_SOCK_PATH, + JAM_FUZZ_SPEC, + readFuzzEnv, + synthesizeFuzzArgs, +} from "./fuzz-env.js"; + +describe("readFuzzEnv", () => { + it("returns null when JAM_FUZZ is unset", () => { + assert.strictEqual(readFuzzEnv({}), null); + }); + + it("returns null when JAM_FUZZ is empty string", () => { + assert.strictEqual(readFuzzEnv({ [JAM_FUZZ]: "" }), null); + }); + + it("parses tiny spec happy path", () => { + const result = readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_SOCK_PATH]: "/tmp/jam.sock", + [JAM_FUZZ_DATA_PATH]: "/tmp/jam-data", + }); + + assert.deepStrictEqual(result, { + spec: KnownChainSpec.Tiny, + socketPath: "/tmp/jam.sock", + dataPath: "/tmp/jam-data", + logLevel: null, + }); + }); + + it("parses full spec happy path", () => { + const result = readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "full", + [JAM_FUZZ_SOCK_PATH]: "/tmp/jam.sock", + [JAM_FUZZ_DATA_PATH]: "/tmp/jam-data", + }); + + assert.strictEqual(result?.spec, KnownChainSpec.Full); + }); + + it("rejects missing JAM_FUZZ_SPEC", () => { + assert.throws( + () => + readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SOCK_PATH]: "/tmp/s", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + }), + new RegExp(`${JAM_FUZZ_SPEC} is required`), + ); + }); + + it("rejects missing JAM_FUZZ_SOCK_PATH", () => { + assert.throws( + () => + readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + }), + new RegExp(`${JAM_FUZZ_SOCK_PATH} is required`), + ); + }); + + it("rejects missing JAM_FUZZ_DATA_PATH", () => { + assert.throws( + () => + readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_SOCK_PATH]: "/tmp/s", + }), + new RegExp(`${JAM_FUZZ_DATA_PATH} is required`), + ); + }); + + it("rejects empty JAM_FUZZ_SOCK_PATH", () => { + assert.throws( + () => + readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_SOCK_PATH]: "", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + }), + new RegExp(`${JAM_FUZZ_SOCK_PATH} is required`), + ); + }); + + it("rejects bogus JAM_FUZZ_SPEC", () => { + assert.throws( + () => + readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "huge", + [JAM_FUZZ_SOCK_PATH]: "/tmp/s", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + }), + new RegExp(`${JAM_FUZZ_SPEC} must be one of: tiny, full`), + ); + }); + + it("parses JAM_FUZZ_LOG_LEVEL=debug as Level.LOG", () => { + const result = readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_SOCK_PATH]: "/tmp/s", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + [JAM_FUZZ_LOG_LEVEL]: "debug", + }); + assert.strictEqual(result?.logLevel, Level.LOG); + }); + + it("parses JAM_FUZZ_LOG_LEVEL=TRACE case-insensitively", () => { + const result = readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_SOCK_PATH]: "/tmp/s", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + [JAM_FUZZ_LOG_LEVEL]: "TRACE", + }); + assert.strictEqual(result?.logLevel, Level.TRACE); + }); + + it("parses each documented log level", () => { + const cases: [string, Level][] = [ + ["error", Level.ERROR], + ["warn", Level.WARN], + ["info", Level.INFO], + ["debug", Level.LOG], + ["trace", Level.TRACE], + ]; + for (const [raw, expected] of cases) { + const result = readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_SOCK_PATH]: "/tmp/s", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + [JAM_FUZZ_LOG_LEVEL]: raw, + }); + assert.strictEqual(result?.logLevel, expected, `level for '${raw}'`); + } + }); + + it("rejects bogus JAM_FUZZ_LOG_LEVEL", () => { + assert.throws( + () => + readFuzzEnv({ + [JAM_FUZZ]: "1", + [JAM_FUZZ_SPEC]: "tiny", + [JAM_FUZZ_SOCK_PATH]: "/tmp/s", + [JAM_FUZZ_DATA_PATH]: "/tmp/d", + [JAM_FUZZ_LOG_LEVEL]: "BOGUS", + }), + new RegExp(`${JAM_FUZZ_LOG_LEVEL} must be one of: error, warn, info, debug, trace`), + ); + }); +}); + +describe("synthesizeFuzzArgs", () => { + it("builds a FuzzTarget Arguments value with tiny flavor override", () => { + const args = synthesizeFuzzArgs({ + spec: KnownChainSpec.Tiny, + socketPath: "/tmp/jam.sock", + dataPath: "/tmp/jam-data", + logLevel: null, + }); + + assert.deepStrictEqual(args, { + command: Command.FuzzTarget, + args: { + nodeName: NODE_DEFAULTS.name, + config: [...NODE_DEFAULTS.config, '.flavor="tiny"'], + pvm: NODE_DEFAULTS.pvm, + socket: "/tmp/jam.sock", + version: 1, + initGenesisFromAncestry: false, + }, + }); + }); + + it("uses 'full' flavor when spec is full", () => { + const args = synthesizeFuzzArgs({ + spec: KnownChainSpec.Full, + socketPath: "/tmp/jam.sock", + dataPath: "/tmp/jam-data", + logLevel: null, + }); + if (args.command !== Command.FuzzTarget) { + throw new Error("expected FuzzTarget command"); + } + assert.deepStrictEqual(args.args.config, [...NODE_DEFAULTS.config, '.flavor="full"']); + }); +}); diff --git a/bin/jam/fuzz-env.ts b/bin/jam/fuzz-env.ts new file mode 100644 index 000000000..de08639da --- /dev/null +++ b/bin/jam/fuzz-env.ts @@ -0,0 +1,87 @@ +import { KnownChainSpec, NODE_DEFAULTS } from "@typeberry/config-node"; +import { Level } from "@typeberry/logger"; +import { type Arguments, Command } from "./args.js"; + +export type FuzzEnv = { + spec: KnownChainSpec; + socketPath: string; + dataPath: string; + logLevel: Level | null; +}; + +export const JAM_FUZZ = "JAM_FUZZ"; +export const JAM_FUZZ_SPEC = "JAM_FUZZ_SPEC"; +export const JAM_FUZZ_SOCK_PATH = "JAM_FUZZ_SOCK_PATH"; +export const JAM_FUZZ_DATA_PATH = "JAM_FUZZ_DATA_PATH"; +export const JAM_FUZZ_LOG_LEVEL = "JAM_FUZZ_LOG_LEVEL"; + +const REQUIRED_VARS = [JAM_FUZZ_SPEC, JAM_FUZZ_SOCK_PATH, JAM_FUZZ_DATA_PATH] as const; + +// Note the JAM-conformance vocabulary uses "debug" but the typeberry Level +// enum names the same level "LOG" (see packages/core/logger/options.ts). +const LOG_LEVELS: Record = { + error: Level.ERROR, + warn: Level.WARN, + info: Level.INFO, + debug: Level.LOG, + trace: Level.TRACE, +}; + +export function readFuzzEnv(env: NodeJS.ProcessEnv | Record): FuzzEnv | null { + const flag = env[JAM_FUZZ] ?? ""; + if (flag.trim().length === 0) { + return null; + } + + for (const name of REQUIRED_VARS) { + const value = env[name] ?? ""; + if (value.trim().length === 0) { + throw new Error(`${JAM_FUZZ} is set but ${name} is required.`); + } + } + + const specRaw = env[JAM_FUZZ_SPEC] ?? ""; + let spec: KnownChainSpec; + if (specRaw === KnownChainSpec.Tiny) { + spec = KnownChainSpec.Tiny; + } else if (specRaw === KnownChainSpec.Full) { + spec = KnownChainSpec.Full; + } else { + throw new Error( + `${JAM_FUZZ_SPEC} must be one of: ${KnownChainSpec.Tiny}, ${KnownChainSpec.Full}. Got: '${specRaw}'.`, + ); + } + + let logLevel: Level | null = null; + const rawLogLevel = env[JAM_FUZZ_LOG_LEVEL] ?? ""; + if (rawLogLevel !== "") { + const parsed = LOG_LEVELS[rawLogLevel.toLowerCase()]; + if (parsed === undefined) { + throw new Error( + `${JAM_FUZZ_LOG_LEVEL} must be one of: ${Object.keys(LOG_LEVELS).join(", ")}. Got: '${rawLogLevel}'.`, + ); + } + logLevel = parsed; + } + + return { + spec, + socketPath: env[JAM_FUZZ_SOCK_PATH] ?? "", + dataPath: env[JAM_FUZZ_DATA_PATH] ?? "", + logLevel, + }; +} + +export function synthesizeFuzzArgs(env: FuzzEnv): Arguments { + return { + command: Command.FuzzTarget, + args: { + nodeName: NODE_DEFAULTS.name, + config: [...NODE_DEFAULTS.config, `.flavor="${env.spec}"`], + pvm: NODE_DEFAULTS.pvm, + socket: env.socketPath, + version: 1, + initGenesisFromAncestry: false, + }, + }; +} diff --git a/bin/jam/index.ts b/bin/jam/index.ts index 2be8d8270..dea5cb6e2 100755 --- a/bin/jam/index.ts +++ b/bin/jam/index.ts @@ -11,6 +11,7 @@ import { exportBlocks, importBlocks, JamConfig, main, mainFuzz } from "@typeberr import { Telemetry } from "@typeberry/telemetry"; import { asOpaqueType, workspacePathFix } from "@typeberry/utils"; import { type Arguments, Command, HELP, parseArgs } from "./args.js"; +import { readFuzzEnv, synthesizeFuzzArgs } from "./fuzz-env.js"; export * from "./args.js"; @@ -20,12 +21,24 @@ let args: Arguments; const withRelPath = workspacePathFix(`${import.meta.dirname}/../..`); try { - const parsed = parseArgs(process.argv.slice(2), withRelPath); - if (parsed === null) { - console.info(HELP); - process.exit(0); + const fuzzEnv = readFuzzEnv(process.env); + if (fuzzEnv !== null) { + if (process.argv.length > 2) { + throw new Error("When JAM_FUZZ is set, command-line arguments are not accepted."); + } + // In fuzz mode, the logger config is determined by JAM_FUZZ_LOG_LEVEL alone; + // any JAM_LOG filters configured at module load are discarded so behavior is + // deterministic regardless of which env vars happen to be present. + Logger.configureAll("", fuzzEnv.logLevel ?? Level.LOG); + args = synthesizeFuzzArgs(fuzzEnv); + } else { + const parsed = parseArgs(process.argv.slice(2), withRelPath); + if (parsed === null) { + console.info(HELP); + process.exit(0); + } + args = parsed; } - args = parsed; } catch (e) { console.error(`\n${e}\n`); console.info(HELP);