|
| 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