Skip to content

Commit 2918412

Browse files
tmlemanlgirdwood
authored andcommitted
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 <output.dict> 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 <tomasz.m.leman@intel.com>
1 parent 23df7c6 commit 2918412

1 file changed

Lines changed: 268 additions & 0 deletions

File tree

scripts/gen_fuzz_ipc4_dict.py

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
#!/usr/bin/env python3
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
# Copyright(c) 2026 Intel Corporation. All rights reserved.
4+
#
5+
# Generate dictionary/ipc4.dict for the SOF IPC4 libFuzzer harness.
6+
#
7+
# libFuzzer's dictionary is consulted by mutation strategies that
8+
# inject known byte sequences into the candidate inputs. For SOF
9+
# IPC4 the most valuable seed values are:
10+
#
11+
# * The 4-byte "primary" message header dat for each well-known
12+
# dispatch path: msg_tgt|rsp|type encoded into bits 24-30 with
13+
# the rest left zero so libFuzzer can mutate the lower 24 bits
14+
# (which carry module_id / instance_id / global type parameters).
15+
# * Small enum constants that appear inline in payloads
16+
# (pipeline state values, pipeline extension object IDs,
17+
# large_config param IDs).
18+
#
19+
# The constants are harvested directly from sof/src/include/ipc4/*.h
20+
# so the dictionary stays in sync with firmware code; only enums
21+
# whose entries are all integer literals (decimal or hex) are
22+
# parsed, which keeps the harvester simple and robust.
23+
#
24+
# Usage:
25+
# python3 scripts/gen_fuzz_ipc4_dict.py [-o dictionary/ipc4.dict]
26+
#
27+
# The output is regenerated on demand from the headers (for example by
28+
# CI, into a temporary file) and is intentionally not committed to the
29+
# tree; regenerate it whenever the harvested headers change.
30+
31+
import argparse
32+
import re
33+
import sys
34+
from pathlib import Path
35+
36+
# Path to the IPC4 public include directory, relative to the SOF repo
37+
# root (the checkout that contains this script's `scripts/` directory).
38+
IPC4_INC_REL = Path("src/include/ipc4")
39+
40+
# Enums we want to harvest, keyed by the source header (relative to
41+
# IPC4_INC_REL) and the C enum tag. Each entry also records what
42+
# kind of dictionary token to emit:
43+
# "global_pri" - 4-byte LE pri value with msg_tgt=0, rsp=0, type=N
44+
# "module_pri" - 4-byte LE pri value with msg_tgt=1, rsp=0, type=N
45+
# "u32" - raw 4-byte LE value of N
46+
ENUM_SOURCES = [
47+
("header.h", "ipc4_message_type", "global_pri"),
48+
("module.h", "sof_ipc4_module_type", "module_pri"),
49+
("module.h", "ipc4_mod_init_data_glb_id", "u32"),
50+
("pipeline.h", "ipc4_pipeline_state", "u32"),
51+
("pipeline.h", "ipc4_pipeline_ext_obj_id", "u32"),
52+
("pipeline.h", "ipc4_pipeline_priority", "u32"),
53+
("base_fw.h", "ipc4_basefw_params", "u32"),
54+
("base_fw.h", "ipc4_fw_config_params", "u32"),
55+
("base_fw.h", "ipc4_hw_config_params", "u32"),
56+
("base_fw.h", "ipc4_memory_type", "u32"),
57+
("base_fw.h", "ipc4_clock_src", "u32"),
58+
("notification.h", "sof_ipc4_notification_type", "u32"),
59+
("notification.h", "sof_ipc4_resource_event_type", "u32"),
60+
("notification.h", "sof_ipc4_resource_type", "u32"),
61+
("error_status.h", "ipc4_status", "u32"),
62+
]
63+
64+
# Bit layout of struct ipc4_message_request::primary (see
65+
# src/include/ipc4/header.h):
66+
# rsvd0 : 24 bits 0..23
67+
# type : 5 bits 24..28
68+
# rsp : 1 bit 29
69+
# msg_tgt : 1 bit 30
70+
# reserved: 1 bit 31
71+
PRI_TYPE_SHIFT = 24
72+
PRI_RSP_SHIFT = 29
73+
PRI_MSG_TGT_SHIFT = 30
74+
75+
ENUM_BLOCK_RE = re.compile(
76+
r"enum\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*\{(?P<body>.*?)\}\s*;",
77+
re.DOTALL,
78+
)
79+
80+
# Match `NAME` or `NAME = VALUE` inside an enum body. VALUE may be
81+
# decimal or hex. Any non-numeric initialiser causes us to skip the
82+
# whole enum (we don't resolve macros / arithmetic).
83+
ENUM_ENTRY_RE = re.compile(
84+
r"""
85+
^\s*
86+
(?P<name>[A-Za-z_][A-Za-z0-9_]*)
87+
(?:\s*=\s*(?P<value>0x[0-9a-fA-F]+|\d+))?
88+
\s*,?\s*(?://.*|/\*.*?\*/)?\s*$
89+
""",
90+
re.VERBOSE,
91+
)
92+
93+
94+
def find_enum(header_text, enum_tag):
95+
"""Return the enum body text for `enum enum_tag { ... }`, else None."""
96+
for m in ENUM_BLOCK_RE.finditer(header_text):
97+
if m.group("name") == enum_tag:
98+
return m.group("body")
99+
return None
100+
101+
102+
def parse_enum(body):
103+
"""Parse a C enum body of integer-literal entries.
104+
105+
Returns a list of (name, value) pairs. If an entry uses a
106+
non-literal initialiser (macro, arithmetic, cross-reference to
107+
another enum name) we stop parsing at that point and return
108+
everything harvested so far -- typically the trailing sentinels
109+
(``_MAX``, ``_COUNT``) of an otherwise integer-literal enum.
110+
Returning a truncated list is preferable to silently emitting
111+
wrong values for auto-incremented entries that follow."""
112+
out = []
113+
next_value = 0
114+
body = re.sub(r"/\*.*?\*/", "", body, flags=re.DOTALL)
115+
body = re.sub(r"//[^\n]*", "", body)
116+
for raw_line in body.splitlines():
117+
line = raw_line.strip()
118+
if not line:
119+
continue
120+
m = ENUM_ENTRY_RE.match(line)
121+
if not m:
122+
# First non-literal entry: stop here. Anything that
123+
# follows would need correct auto-increment tracking
124+
# which we cannot guarantee once the count is broken.
125+
break
126+
name = m.group("name")
127+
if m.group("value") is not None:
128+
value = int(m.group("value"), 0)
129+
else:
130+
value = next_value
131+
out.append((name, value))
132+
next_value = value + 1
133+
return out
134+
135+
136+
def u32_le(value):
137+
"""Encode an unsigned 32-bit value as 4 little-endian bytes."""
138+
value &= 0xFFFFFFFF
139+
return bytes([
140+
value & 0xFF,
141+
(value >> 8) & 0xFF,
142+
(value >> 16) & 0xFF,
143+
(value >> 24) & 0xFF,
144+
])
145+
146+
147+
def fmt_dict_entry(name, raw_bytes):
148+
"""Format a libFuzzer dictionary line: name="\\xNN\\xNN...".
149+
150+
libFuzzer accepts ASCII printables unescaped; we escape
151+
everything except space..~ excluding the quote and backslash,
152+
which keeps the file readable while remaining unambiguous.
153+
"""
154+
pieces = []
155+
for b in raw_bytes:
156+
if 0x20 <= b <= 0x7E and b not in (0x22, 0x5C):
157+
pieces.append(chr(b))
158+
else:
159+
pieces.append(f"\\x{b:02x}")
160+
return f'{name}="{"".join(pieces)}"'
161+
162+
163+
def encode(kind, value):
164+
if kind == "global_pri":
165+
return u32_le(value << PRI_TYPE_SHIFT)
166+
if kind == "module_pri":
167+
return u32_le((1 << PRI_MSG_TGT_SHIFT) | (value << PRI_TYPE_SHIFT))
168+
if kind == "u32":
169+
return u32_le(value)
170+
raise ValueError(f"unknown kind: {kind}")
171+
172+
173+
def harvest(repo_root):
174+
"""Return list of (section_label, [(name, raw_bytes), ...])."""
175+
sections = []
176+
for fname, enum_tag, kind in ENUM_SOURCES:
177+
path = repo_root / IPC4_INC_REL / fname
178+
if not path.is_file():
179+
print(f"error: {path} not found", file=sys.stderr)
180+
sys.exit(1)
181+
text = path.read_text()
182+
body = find_enum(text, enum_tag)
183+
if body is None:
184+
print(f"warning: enum {enum_tag} not found in {fname}",
185+
file=sys.stderr)
186+
continue
187+
entries = parse_enum(body)
188+
if not entries:
189+
print(f"warning: enum {enum_tag} in {fname} yielded no "
190+
"literal entries, skipping", file=sys.stderr)
191+
continue
192+
encoded = []
193+
for name, value in entries:
194+
# Skip MAX / COUNT sentinels which are not real dispatch
195+
# values; keep everything else even if the value collides
196+
# with another entry (libFuzzer dedups automatically).
197+
if (name.endswith("_MAX")
198+
or name.endswith("_MAX_IXC_MESSAGE_TYPE")
199+
or name.endswith("_PARAMS_COUNT")):
200+
continue
201+
# The primary-header `type` field is only 5 bits wide; a
202+
# value that does not fit cannot be a real dispatch type and
203+
# would corrupt the rsp / msg_tgt bits, so drop it regardless
204+
# of the enumerator's name (robust to header typo fixes).
205+
if kind in ("global_pri", "module_pri") and not 0 <= value < 32:
206+
continue
207+
encoded.append((name, encode(kind, value)))
208+
sections.append((f"{fname}::{enum_tag} ({kind})", encoded))
209+
return sections
210+
211+
212+
def render(sections):
213+
out_lines = [
214+
"# SOF IPC4 libFuzzer dictionary",
215+
"#",
216+
"# Generated by scripts/gen_fuzz_ipc4_dict.py from the IPC4",
217+
"# public headers under src/include/ipc4/. Do not edit by",
218+
"# hand; regenerate instead.",
219+
"#",
220+
"# Each entry is a 4-byte little-endian value drawn from an IPC4",
221+
"# enum, encoded so libFuzzer's CMP / dictionary mutators can",
222+
"# splice it into candidate inputs at any 4-byte-aligned offset.",
223+
"",
224+
]
225+
for label, entries in sections:
226+
out_lines.append(f"# --- {label} ---")
227+
for name, raw in entries:
228+
out_lines.append(fmt_dict_entry(name, raw))
229+
out_lines.append("")
230+
return "\n".join(out_lines)
231+
232+
233+
def main():
234+
parser = argparse.ArgumentParser(
235+
description="Generate the SOF IPC4 libFuzzer dictionary from the "
236+
"in-tree IPC4 headers.")
237+
parser.add_argument(
238+
"-o", "--output",
239+
help="Output path (default: stdout)",
240+
)
241+
parser.add_argument(
242+
"--repo-root",
243+
default=None,
244+
help="SOF repository root (default: auto-detected from script "
245+
"location)",
246+
)
247+
args = parser.parse_args()
248+
249+
if args.repo_root:
250+
repo_root = Path(args.repo_root).resolve()
251+
else:
252+
# Script lives at <repo>/scripts/gen_fuzz_ipc4_dict.py.
253+
repo_root = Path(__file__).resolve().parents[1]
254+
255+
sections = harvest(repo_root)
256+
if not any(entries for _, entries in sections):
257+
print("error: no dictionary entries harvested; refusing to write "
258+
"an empty dictionary", file=sys.stderr)
259+
sys.exit(1)
260+
text = render(sections)
261+
if args.output:
262+
Path(args.output).write_text(text)
263+
else:
264+
sys.stdout.write(text)
265+
266+
267+
if __name__ == "__main__":
268+
main()

0 commit comments

Comments
 (0)