From 0fdd12dca2bf81ae8243c1c34638829e4a05a74e Mon Sep 17 00:00:00 2001 From: Tomasz Leman Date: Thu, 16 Jul 2026 15:10:20 +0200 Subject: [PATCH 1/4] scripts: fuzz: add IPC4 libFuzzer dictionary generator The native_sim libFuzzer harness consults an external dictionary (`-dict=`) for known byte sequences to splice into mutated inputs. Until now the only dictionary content shipped for the IPC4 target was two placeholder entries, leaving libFuzzer to discover the whole IPC4 dispatch surface (global / module message types, pipeline states, large-config param IDs) on its own through random mutation plus CMP intercepts. Most random 4-byte prefixes are rejected by the type-field switch in the IPC4 front end, so the deeper handlers were reached only rarely. Add a generator that harvests integer-literal enums from src/include/ipc4/*.h and emits a libFuzzer dictionary mapping each constant to a 4-byte little-endian token, with two specialised encodings for dispatch headers: * global_pri - (type << 24) (msg_tgt=0, rsp=0) * module_pri - (1 << 30) | (type << 24) (msg_tgt=1) so that every well-known global or module entry point appears as a ready-to-paste 4-byte pri value at any 4-byte-aligned offset chosen by the mutator. Raw u32 encodings are emitted for the remaining enums (pipeline state values, pipeline extension object IDs, base-fw / hw-config / memory-type / clock-src params, notification and resource types, error/status codes). The `type` field of the primary header is only 5 bits wide, so pri tokens are emitted only for enum values that fit (0..31); MAX / COUNT sentinels are dropped by value as well as by name, so a header typo fix cannot leak a bogus dispatch token. The parser is intentionally narrow: it only follows enums whose entries are plain integer literals (with optional auto-increment), stopping at the first non-literal initialiser. That keeps the script free of preprocessor logic while capturing the great majority of dispatch-affecting constants; enums that use cross-references or bit-field expressions can be hand-added to the ENUM_SOURCES table when worthwhile. A missing header or an empty harvest is a hard error, so a broken regeneration can never silently overwrite a good dictionary with an empty one. Usage: python3 scripts/gen_fuzz_ipc4_dict.py -o Coverage impact, measured on the coverage-instrumented harness (IPC4, empty corpus, 5 seeds x 60 s, paired dict-minus-nodict means): libFuzzer edges (cov) +69 (high variance, seed dependent) libFuzzer features (ft) +141 IPC subsystem lines +23 (+0.9 pp of src/ipc) The dictionary's main effect is to raise the coverage ceiling: it occasionally unlocks deep module-dispatch paths that random mutation rarely reaches (best-seed firmware line coverage 2122 -> 3268), so the per-run gain is positive on average but noisy at 60 s. The output is not committed; it is regenerated from the headers on demand (for example by CI into a temporary file) and deployments may extend it with site-specific tokens. Signed-off-by: Tomasz Leman --- scripts/gen_fuzz_ipc4_dict.py | 268 ++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100755 scripts/gen_fuzz_ipc4_dict.py diff --git a/scripts/gen_fuzz_ipc4_dict.py b/scripts/gen_fuzz_ipc4_dict.py new file mode 100755 index 000000000000..cecb52c3e6d9 --- /dev/null +++ b/scripts/gen_fuzz_ipc4_dict.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright(c) 2026 Intel Corporation. All rights reserved. +# +# Generate dictionary/ipc4.dict for the SOF IPC4 libFuzzer harness. +# +# libFuzzer's dictionary is consulted by mutation strategies that +# inject known byte sequences into the candidate inputs. For SOF +# IPC4 the most valuable seed values are: +# +# * The 4-byte "primary" message header dat for each well-known +# dispatch path: msg_tgt|rsp|type encoded into bits 24-30 with +# the rest left zero so libFuzzer can mutate the lower 24 bits +# (which carry module_id / instance_id / global type parameters). +# * Small enum constants that appear inline in payloads +# (pipeline state values, pipeline extension object IDs, +# large_config param IDs). +# +# The constants are harvested directly from sof/src/include/ipc4/*.h +# so the dictionary stays in sync with firmware code; only enums +# whose entries are all integer literals (decimal or hex) are +# parsed, which keeps the harvester simple and robust. +# +# Usage: +# python3 scripts/gen_fuzz_ipc4_dict.py [-o dictionary/ipc4.dict] +# +# The output is regenerated on demand from the headers (for example by +# CI, into a temporary file) and is intentionally not committed to the +# tree; regenerate it whenever the harvested headers change. + +import argparse +import re +import sys +from pathlib import Path + +# Path to the IPC4 public include directory, relative to the SOF repo +# root (the checkout that contains this script's `scripts/` directory). +IPC4_INC_REL = Path("src/include/ipc4") + +# Enums we want to harvest, keyed by the source header (relative to +# IPC4_INC_REL) and the C enum tag. Each entry also records what +# kind of dictionary token to emit: +# "global_pri" - 4-byte LE pri value with msg_tgt=0, rsp=0, type=N +# "module_pri" - 4-byte LE pri value with msg_tgt=1, rsp=0, type=N +# "u32" - raw 4-byte LE value of N +ENUM_SOURCES = [ + ("header.h", "ipc4_message_type", "global_pri"), + ("module.h", "sof_ipc4_module_type", "module_pri"), + ("module.h", "ipc4_mod_init_data_glb_id", "u32"), + ("pipeline.h", "ipc4_pipeline_state", "u32"), + ("pipeline.h", "ipc4_pipeline_ext_obj_id", "u32"), + ("pipeline.h", "ipc4_pipeline_priority", "u32"), + ("base_fw.h", "ipc4_basefw_params", "u32"), + ("base_fw.h", "ipc4_fw_config_params", "u32"), + ("base_fw.h", "ipc4_hw_config_params", "u32"), + ("base_fw.h", "ipc4_memory_type", "u32"), + ("base_fw.h", "ipc4_clock_src", "u32"), + ("notification.h", "sof_ipc4_notification_type", "u32"), + ("notification.h", "sof_ipc4_resource_event_type", "u32"), + ("notification.h", "sof_ipc4_resource_type", "u32"), + ("error_status.h", "ipc4_status", "u32"), +] + +# Bit layout of struct ipc4_message_request::primary (see +# src/include/ipc4/header.h): +# rsvd0 : 24 bits 0..23 +# type : 5 bits 24..28 +# rsp : 1 bit 29 +# msg_tgt : 1 bit 30 +# reserved: 1 bit 31 +PRI_TYPE_SHIFT = 24 +PRI_RSP_SHIFT = 29 +PRI_MSG_TGT_SHIFT = 30 + +ENUM_BLOCK_RE = re.compile( + r"enum\s+(?P[A-Za-z_][A-Za-z0-9_]*)\s*\{(?P.*?)\}\s*;", + re.DOTALL, +) + +# Match `NAME` or `NAME = VALUE` inside an enum body. VALUE may be +# decimal or hex. Any non-numeric initialiser causes us to skip the +# whole enum (we don't resolve macros / arithmetic). +ENUM_ENTRY_RE = re.compile( + r""" + ^\s* + (?P[A-Za-z_][A-Za-z0-9_]*) + (?:\s*=\s*(?P0x[0-9a-fA-F]+|\d+))? + \s*,?\s*(?://.*|/\*.*?\*/)?\s*$ + """, + re.VERBOSE, +) + + +def find_enum(header_text, enum_tag): + """Return the enum body text for `enum enum_tag { ... }`, else None.""" + for m in ENUM_BLOCK_RE.finditer(header_text): + if m.group("name") == enum_tag: + return m.group("body") + return None + + +def parse_enum(body): + """Parse a C enum body of integer-literal entries. + + Returns a list of (name, value) pairs. If an entry uses a + non-literal initialiser (macro, arithmetic, cross-reference to + another enum name) we stop parsing at that point and return + everything harvested so far -- typically the trailing sentinels + (``_MAX``, ``_COUNT``) of an otherwise integer-literal enum. + Returning a truncated list is preferable to silently emitting + wrong values for auto-incremented entries that follow.""" + out = [] + next_value = 0 + body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL) + body = re.sub(r"//[^\n]*", "", body) + for raw_line in body.splitlines(): + line = raw_line.strip() + if not line: + continue + m = ENUM_ENTRY_RE.match(line) + if not m: + # First non-literal entry: stop here. Anything that + # follows would need correct auto-increment tracking + # which we cannot guarantee once the count is broken. + break + name = m.group("name") + if m.group("value") is not None: + value = int(m.group("value"), 0) + else: + value = next_value + out.append((name, value)) + next_value = value + 1 + return out + + +def u32_le(value): + """Encode an unsigned 32-bit value as 4 little-endian bytes.""" + value &= 0xFFFFFFFF + return bytes([ + value & 0xFF, + (value >> 8) & 0xFF, + (value >> 16) & 0xFF, + (value >> 24) & 0xFF, + ]) + + +def fmt_dict_entry(name, raw_bytes): + """Format a libFuzzer dictionary line: name="\\xNN\\xNN...". + + libFuzzer accepts ASCII printables unescaped; we escape + everything except space..~ excluding the quote and backslash, + which keeps the file readable while remaining unambiguous. + """ + pieces = [] + for b in raw_bytes: + if 0x20 <= b <= 0x7E and b not in (0x22, 0x5C): + pieces.append(chr(b)) + else: + pieces.append(f"\\x{b:02x}") + return f'{name}="{"".join(pieces)}"' + + +def encode(kind, value): + if kind == "global_pri": + return u32_le(value << PRI_TYPE_SHIFT) + if kind == "module_pri": + return u32_le((1 << PRI_MSG_TGT_SHIFT) | (value << PRI_TYPE_SHIFT)) + if kind == "u32": + return u32_le(value) + raise ValueError(f"unknown kind: {kind}") + + +def harvest(repo_root): + """Return list of (section_label, [(name, raw_bytes), ...]).""" + sections = [] + for fname, enum_tag, kind in ENUM_SOURCES: + path = repo_root / IPC4_INC_REL / fname + if not path.is_file(): + print(f"error: {path} not found", file=sys.stderr) + sys.exit(1) + text = path.read_text() + body = find_enum(text, enum_tag) + if body is None: + print(f"warning: enum {enum_tag} not found in {fname}", + file=sys.stderr) + continue + entries = parse_enum(body) + if not entries: + print(f"warning: enum {enum_tag} in {fname} yielded no " + "literal entries, skipping", file=sys.stderr) + continue + encoded = [] + for name, value in entries: + # Skip MAX / COUNT sentinels which are not real dispatch + # values; keep everything else even if the value collides + # with another entry (libFuzzer dedups automatically). + if (name.endswith("_MAX") + or name.endswith("_MAX_IXC_MESSAGE_TYPE") + or name.endswith("_PARAMS_COUNT")): + continue + # The primary-header `type` field is only 5 bits wide; a + # value that does not fit cannot be a real dispatch type and + # would corrupt the rsp / msg_tgt bits, so drop it regardless + # of the enumerator's name (robust to header typo fixes). + if kind in ("global_pri", "module_pri") and not 0 <= value < 32: + continue + encoded.append((name, encode(kind, value))) + sections.append((f"{fname}::{enum_tag} ({kind})", encoded)) + return sections + + +def render(sections): + out_lines = [ + "# SOF IPC4 libFuzzer dictionary", + "#", + "# Generated by scripts/gen_fuzz_ipc4_dict.py from the IPC4", + "# public headers under src/include/ipc4/. Do not edit by", + "# hand; regenerate instead.", + "#", + "# Each entry is a 4-byte little-endian value drawn from an IPC4", + "# enum, encoded so libFuzzer's CMP / dictionary mutators can", + "# splice it into candidate inputs at any 4-byte-aligned offset.", + "", + ] + for label, entries in sections: + out_lines.append(f"# --- {label} ---") + for name, raw in entries: + out_lines.append(fmt_dict_entry(name, raw)) + out_lines.append("") + return "\n".join(out_lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate the SOF IPC4 libFuzzer dictionary from the " + "in-tree IPC4 headers.") + parser.add_argument( + "-o", "--output", + help="Output path (default: stdout)", + ) + parser.add_argument( + "--repo-root", + default=None, + help="SOF repository root (default: auto-detected from script " + "location)", + ) + args = parser.parse_args() + + if args.repo_root: + repo_root = Path(args.repo_root).resolve() + else: + # Script lives at /scripts/gen_fuzz_ipc4_dict.py. + repo_root = Path(__file__).resolve().parents[1] + + sections = harvest(repo_root) + if not any(entries for _, entries in sections): + print("error: no dictionary entries harvested; refusing to write " + "an empty dictionary", file=sys.stderr) + sys.exit(1) + text = render(sections) + if args.output: + Path(args.output).write_text(text) + else: + sys.stdout.write(text) + + +if __name__ == "__main__": + main() From 659a4bef90e681821bfe88921d0ab81ceb6bd84b Mon Sep 17 00:00:00 2001 From: Tomasz Leman Date: Thu, 16 Jul 2026 15:11:16 +0200 Subject: [PATCH 2/4] scripts: fuzz: add IPC3 libFuzzer dictionary generator The native_sim libFuzzer harness consults an external dictionary (`-dict=`) for known byte sequences to splice into mutated inputs. The IPC3 dictionary shipped until now was hand-written: thirteen opaque `kwN=` entries, two of which were corpus-derived byte blobs rather than real dispatch constants, covering only a subset of the topology / PM / stream command space. It had no documented provenance and silently drifted out of sync as header.h changed. Add a generator that harvests the SOF_GLB_TYPE() / SOF_CMD_TYPE() combines the global type with its command type: cmd = (glb_type << 28) | (cmd_type << 16) so each token lands directly in a leaf handler when the mutator splices it into the cmd field at offset 4 of a message (the size dword at offset 0 is rewritten by the harness, so cmd is the only dispatch-relevant header field). The bare global types are emitted too, as a 4-byte splice for messages the fuzzer builds from scratch. The parser is intentionally narrow: it only matches the two SOF_*_TYPE() macros, which keeps the script free of preprocessor logic while covering the full IPC3 dispatch surface (topology, PM, component, DAI, stream, trace, probe, debug and test groups). The cmd-group-to-global-type mapping is an explicit table so a renamed or newly added group is an obvious one-line edit. A missing header is a hard error. Usage: python3 scripts/gen_fuzz_ipc3_dict.py -o Coverage impact, measured on the coverage-instrumented harness (IPC3, empty corpus, 5 seeds x 60 s, paired dict-minus-nodict means): libFuzzer edges (cov) +7 libFuzzer features (ft) +266 (positive on every seed) IPC subsystem lines +9 (+0.4 pp of src/ipc) IPC3's dispatch surface is small enough that line coverage saturates either way at 60 s; the dictionary's consistent benefit is in feature (edge x counter) combinations. The change is primarily about completeness and maintainability, replacing opaque hand-tuned tokens with a documented, header-synchronised superset (64 entries). The output is not committed; it is regenerated from the header on demand (for example by CI into a temporary file) and deployments may extend it with site-specific tokens. Signed-off-by: Tomasz Leman --- scripts/gen_fuzz_ipc3_dict.py | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100755 scripts/gen_fuzz_ipc3_dict.py diff --git a/scripts/gen_fuzz_ipc3_dict.py b/scripts/gen_fuzz_ipc3_dict.py new file mode 100755 index 000000000000..a5e80411454e --- /dev/null +++ b/scripts/gen_fuzz_ipc3_dict.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright(c) 2026 Intel Corporation. All rights reserved. +# +# Generate dictionary/ipc3.dict for the SOF IPC3 libFuzzer harness. +# +# libFuzzer's dictionary is consulted by mutation strategies that +# inject known byte sequences into the candidate inputs. For SOF +# IPC3 the single most valuable token is the 32-bit "cmd" dword that +# lives at offset 4 of every message (struct sof_ipc_cmd_hdr: the +# size dword is rewritten by the harness, so only cmd matters for +# dispatch). That dword packs two fields: +# +# glb_type : bits 28..31 (SOF_GLB_TYPE) +# cmd_type : bits 16..27 (SOF_CMD_TYPE) +# +# The firmware switches on glb_type first and then on cmd_type, so a +# token that is only a bare glb_type reaches a handler but stalls at +# its inner switch. We therefore emit, for every command group, the +# fully-combined "glb_type | cmd_type" dword that lands directly in a +# leaf handler, plus the bare glb_type values on their own (useful as +# a 4-byte splice when the fuzzer is building a message from scratch). +# +# The constants are harvested directly from src/include/ipc/header.h +# so the dictionary stays in sync with firmware code; only the +# SOF_GLB_TYPE() / SOF_CMD_TYPE() #defines are parsed, which keeps the +# harvester simple and robust. +# +# Usage: +# python3 scripts/gen_fuzz_ipc3_dict.py [-o dictionary/ipc3.dict] +# +# The output is regenerated on demand from the header (for example by +# CI, into a temporary file) and is intentionally not committed to the +# tree; regenerate it whenever the harvested header changes. + +import argparse +import re +import sys +from pathlib import Path + +# Path to the IPC3 header, relative to the SOF repo root (the checkout +# that contains this script's `scripts/` directory). +HEADER_REL = Path("src/include/ipc/header.h") + +# Bit layout of the command dword (see SOF_GLB_TYPE / SOF_CMD_TYPE in +# src/include/ipc/header.h). +GLB_TYPE_SHIFT = 28 +CMD_TYPE_SHIFT = 16 + +# Command groups: each SOF_CMD_TYPE() value is only meaningful when +# OR-ed with the global type that selects its handler. This table +# maps a cmd #define name prefix to the SOF_GLB_TYPE() #define that +# dispatches it. Order controls the layout of the emitted file. +CMD_GROUPS = [ + ("SOF_IPC_TPLG_", "SOF_IPC_GLB_TPLG_MSG"), + ("SOF_IPC_PM_", "SOF_IPC_GLB_PM_MSG"), + ("SOF_IPC_COMP_", "SOF_IPC_GLB_COMP_MSG"), + ("SOF_IPC_DAI_", "SOF_IPC_GLB_DAI_MSG"), + ("SOF_IPC_STREAM_", "SOF_IPC_GLB_STREAM_MSG"), + ("SOF_IPC_TRACE_", "SOF_IPC_GLB_TRACE_MSG"), + ("SOF_IPC_PROBE_", "SOF_IPC_GLB_PROBE"), + ("SOF_IPC_DEBUG_", "SOF_IPC_GLB_DEBUG"), + ("SOF_IPC_TEST_", "SOF_IPC_GLB_TEST"), +] + +GLB_RE = re.compile( + r"#define\s+(?PSOF_IPC_\w+)\s+SOF_GLB_TYPE\(\s*" + r"(?P0x[0-9a-fA-F]+|\d+)U?L?\s*\)" +) +CMD_RE = re.compile( + r"#define\s+(?PSOF_IPC_\w+)\s+SOF_CMD_TYPE\(\s*" + r"(?P0x[0-9a-fA-F]+|\d+)U?L?\s*\)" +) + + +def u32_le(value): + """Encode an unsigned 32-bit value as 4 little-endian bytes.""" + value &= 0xFFFFFFFF + return bytes([ + value & 0xFF, + (value >> 8) & 0xFF, + (value >> 16) & 0xFF, + (value >> 24) & 0xFF, + ]) + + +def fmt_dict_entry(name, raw_bytes): + """Format a libFuzzer dictionary line: name="\\xNN\\xNN...". + + libFuzzer accepts ASCII printables unescaped; we escape everything + except space..~ (excluding the quote and backslash) which keeps the + file readable while remaining unambiguous. + """ + pieces = [] + for b in raw_bytes: + if 0x20 <= b <= 0x7E and b not in (0x22, 0x5C): + pieces.append(chr(b)) + else: + pieces.append(f"\\x{b:02x}") + return f'{name}="{"".join(pieces)}"' + + +def harvest(repo_root): + """Parse header.h, returning (glb_defs, cmd_defs) name->value dicts.""" + path = repo_root / HEADER_REL + if not path.is_file(): + print(f"error: {path} not found", file=sys.stderr) + sys.exit(1) + text = path.read_text() + + glb_defs = {} + for m in GLB_RE.finditer(text): + glb_defs[m.group("name")] = int(m.group("value"), 0) << GLB_TYPE_SHIFT + + cmd_defs = {} + for m in CMD_RE.finditer(text): + cmd_defs[m.group("name")] = int(m.group("value"), 0) << CMD_TYPE_SHIFT + + return glb_defs, cmd_defs + + +def build_sections(glb_defs, cmd_defs): + """Return list of (section_label, [(name, raw_bytes), ...]).""" + sections = [] + + # Bare global message types. + glb_entries = [(name, u32_le(val)) for name, val in glb_defs.items()] + if glb_entries: + sections.append(("Global message types (glb_type only)", glb_entries)) + + # Combined glb_type | cmd_type dwords, one section per group. + for prefix, glb_name in CMD_GROUPS: + glb_val = glb_defs.get(glb_name) + if glb_val is None: + print(f"warning: {glb_name} not found in header, " + f"skipping group {prefix}*", file=sys.stderr) + continue + entries = [] + for name, cmd_val in cmd_defs.items(): + if not name.startswith(prefix): + continue + entries.append((name, u32_le(glb_val | cmd_val))) + if entries: + sections.append((f"{glb_name} commands", entries)) + + return sections + + +def render(sections): + out_lines = [ + "# SOF IPC3 libFuzzer dictionary", + "#", + "# Generated by scripts/gen_fuzz_ipc3_dict.py from the IPC3", + "# header src/include/ipc/header.h. Do not edit by hand;", + "# regenerate instead.", + "#", + "# Each entry is a 4-byte little-endian command dword. Command", + "# entries combine the global type and the command type so they", + "# land directly in a leaf handler when libFuzzer's CMP /", + "# dictionary mutators splice them into the cmd field at offset 4", + "# of a message.", + "", + ] + for label, entries in sections: + out_lines.append(f"# --- {label} ---") + for name, raw in entries: + out_lines.append(fmt_dict_entry(name, raw)) + out_lines.append("") + return "\n".join(out_lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate the SOF IPC3 libFuzzer dictionary from the " + "in-tree IPC3 header.") + parser.add_argument( + "-o", "--output", + help="Output path (default: stdout)", + ) + parser.add_argument( + "--repo-root", + default=None, + help="SOF repository root (default: auto-detected from script " + "location)", + ) + args = parser.parse_args() + + if args.repo_root: + repo_root = Path(args.repo_root).resolve() + else: + # Script lives at /scripts/gen_fuzz_ipc3_dict.py. + repo_root = Path(__file__).resolve().parents[1] + + glb_defs, cmd_defs = harvest(repo_root) + sections = build_sections(glb_defs, cmd_defs) + if not sections: + print("error: no dictionary entries harvested; refusing to write " + "an empty dictionary", file=sys.stderr) + sys.exit(1) + text = render(sections) + if args.output: + Path(args.output).write_text(text) + else: + sys.stdout.write(text) + + +if __name__ == "__main__": + main() From 1160194c28741a752379739309b9ea8107cab480 Mon Sep 17 00:00:00 2001 From: Tomasz Leman Date: Thu, 16 Jul 2026 14:54:50 +0200 Subject: [PATCH 3/4] scripts: fuzz: add optional libFuzzer dictionary (-d) option Add a -d option to fuzz.sh that forwards -dict= to the libFuzzer executable. A dictionary of known IPC command dwords and enum constants helps the mutator reach deeper dispatch paths that random mutation rarely hits. The option is deliberately non-fatal: a missing or empty dictionary file only emits a warning and the fuzzer runs without it. The dictionary is a pure enhancement and must never block a fuzzing run, for example when its generator fails after an unrelated header change in CI. fuzz.sh stays agnostic of how the dictionary is produced. The caller is responsible for generating and passing the file. Signed-off-by: Tomasz Leman --- scripts/fuzz.sh | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh index b8b69b5fd1d2..27ff96dc637f 100755 --- a/scripts/fuzz.sh +++ b/scripts/fuzz.sh @@ -21,6 +21,8 @@ Usage: -j n Number of concurrent -jobs=n. Defaults to 1. The value 0 uses the output of the 'nproc' command. -a arch The architecture to build against (i386, x86_64) + -d dfile Pass -dict=dfile to libFuzzer. A missing or empty dfile + is a non-fatal warning: the fuzzer then runs without it. Arguments after -- are passed as is to CMake (through west). When passing conflicting -DVAR='VAL UE1' -DVAR='VAL UE2' to CMake, @@ -93,9 +95,10 @@ main() local ARCH=i386 local IPC local BOARD + local DICT # Parse "$@". getopts stops after '--' - while getopts "i:hj:ps:a:o:t:b" opt; do + while getopts "i:hj:ps:a:o:t:bd:" opt; do case "$opt" in i) IPC="$OPTARG";; h) print_help; exit 0;; @@ -105,6 +108,7 @@ main() o) FUZZER_STDOUT="$OPTARG";; t) TEST_DURATION="$OPTARG";; a) ARCH="$OPTARG";; + d) DICT="$OPTARG";; b) BUILD_ONLY=true;; *) print_help; exit 1;; esac @@ -169,11 +173,23 @@ main() jobs_opts+=( -jobs="$JOBS" -close_fd_mask=1 ) fi + # Optional libFuzzer dictionary (-d). An absent or empty file is a + # non-fatal warning: the dictionary is a pure enhancement and must + # never block a fuzzing run (e.g. when its generator fails in CI). + local dict_opts=( ) + if [ -n "$DICT" ]; then + if [ -s "$DICT" ]; then + dict_opts+=( -dict="$DICT" ) + else + >&2 printf 'WARN: dictionary %s missing or empty; running without it\n' "$DICT" + fi + fi + date # Help is at: -help=1 ( set -x >"$FUZZER_STDOUT" build-fuzz/zephyr/zephyr.exe -max_total_time="$TEST_DURATION" \ - -verbosity=0 "${jobs_opts[@]}" ./fuzz_corpus ) || { + -verbosity=0 "${jobs_opts[@]}" "${dict_opts[@]}" ./fuzz_corpus ) || { ret=$? >&2 printf 'zephyr.exe returned: %d\n' $ret date From 6915cd13275d47a7b28b9df7f1a6c6ce4a4422d9 Mon Sep 17 00:00:00 2001 From: Tomasz Leman Date: Thu, 16 Jul 2026 14:55:45 +0200 Subject: [PATCH 4/4] workflows: fuzzer: generate and use the IPC dictionary Generate the IPC3 / IPC4 libFuzzer dictionary on the fly from the in-tree headers and pass it to fuzz.sh through the new -d option, so the CI fuzzing job benefits from dictionary-guided mutation without committing a generated file to the tree. The dictionary is written to $RUNNER_TEMP, which is provisioned and cleaned up by the runner and lives outside the checkout, so nothing is committed. The generation step is best-effort (continue-on-error): if the generator ever breaks, the job still fuzzes without the dictionary rather than failing, and fuzz.sh -d tolerates the missing file. Signed-off-by: Tomasz Leman --- .github/workflows/ipc_fuzzer.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ipc_fuzzer.yml b/.github/workflows/ipc_fuzzer.yml index 755dbf4b5d9b..f52291839111 100644 --- a/.github/workflows/ipc_fuzzer.yml +++ b/.github/workflows/ipc_fuzzer.yml @@ -75,6 +75,16 @@ jobs: west init -l west update --narrow --fetch-opt=--filter=tree:0 + - name: generate IPC dictionary + # A generator failure (e.g. after a header rename) must not break + # fuzzing: the step is best-effort and fuzz.sh runs without the + # dictionary when the file is missing or empty (see -d below). + continue-on-error: true + run: | + cd workspace + python3 sof/scripts/gen_fuzz_ipc${{ matrix.IPC }}_dict.py \ + -o "$RUNNER_TEMP/ipc${{ matrix.IPC }}.dict" + - name: build and run fuzzer for a few minutes run: | cd workspace @@ -83,7 +93,8 @@ jobs: duration="${{inputs.fuzzing_duration_s}}" duration="${duration:-301}" # pull_request has not 'inputs.' :-( # Note libFuzzer makes a difference between -jobs and -workers (capped at nproc/2) - sof/scripts/fuzz.sh -i '${{ matrix.IPC }}' -o fuzz-stdout.txt -t "$duration" -j"$(nproc)" + sof/scripts/fuzz.sh -i '${{ matrix.IPC }}' -o fuzz-stdout.txt -t "$duration" \ + -j"$(nproc)" -d "$RUNNER_TEMP/ipc${{ matrix.IPC }}.dict" - name: Upload stdout uses: actions/upload-artifact@v4