# Copyright (c) ONNX Project Contributors # SPDX-License-Identifier: Apache-2.0 """Atheris fuzz harness for onnx.compose. Two input paths are exercised per iteration, selected by a fuzzer-controlled toggle byte read from the *tail* of the input: * Raw -> a 4-byte big-endian length prefix splits the remaining bytes into m1 | m2, each parsed via onnx.load_model_from_string. Catches protobuf parser bugs and bugs reachable only through crafted serialized models the structured builder will not produce. * Structured -> helper.make_model from FuzzedDataProvider builds two small linear graphs with predictable input/output names, so merge_models reaches its connect_io/add_prefix logic on most iterations rather than only when the parser happens to accept a random byte string. Further bits of the toggle byte select prefix1/prefix2 (add_prefix collision-resolution path) and a randomized (vs. derived) io_map, so a single harness covers merge_models, merge_graphs, check_overlapping_names, the recursive connect_io subgraph rewrite, and add_prefix. """ from __future__ import annotations import random import struct import sys import atheris with atheris.instrument_imports(): from onnx import ( TensorProto, checker, compose, defs, helper, load_model_from_string, ) _UNARY = ("Relu", "Sigmoid", "Tanh", "Abs", "Neg", "Identity") def _value_info(name): return helper.make_tensor_value_info(name, TensorProto.FLOAT, ["N"]) def _build_model(fdp, tag, opset): """Build a small linear graph with predictable input/output names. Naming the sole input/output `In`/`Out` lets the derived io_map (m1's outputs -> m2's inputs) connect on most iterations, so merge_models' rewrite logic is reached without relying on the raw protobuf parser to produce compatible names. `opset` is shared between both models: merge_models rejects mismatched opset_import up front, so picking it independently per model would make the structured path rarely reach the merge/connect logic it exists to exercise. """ n_ops = fdp.ConsumeIntInRange(0, 4) last = f"In{tag}" nodes = [] for i in range(n_ops): op = _UNARY[fdp.ConsumeIntInRange(0, len(_UNARY) - 1)] nxt = f"v{tag}_{i}" nodes.append(helper.make_node(op, [last], [nxt])) last = nxt out_name = f"Out{tag}" nodes.append(helper.make_node("Identity", [last], [out_name])) graph = helper.make_graph( nodes, f"g{tag}", [_value_info(f"In{tag}")], [_value_info(out_name)] ) return helper.make_model(graph, opset_imports=[helper.make_opsetid("", opset)]) def _derived_io_map(m1, m2): outs = [o.name for o in m1.graph.output] ins = [i.name for i in m2.graph.input] return list(zip(outs, ins, strict=False)) def _random_io_map(data, m1, m2): outs = [o.name for o in m1.graph.output] ins = [i.name for i in m2.graph.input] if not outs or not ins: return [] rng = random.Random(data) rng.shuffle(outs) rng.shuffle(ins) n = rng.randint(0, min(len(outs), len(ins))) return list(zip(outs[:n], ins[:n], strict=True)) def TestOneInput(data): # Toggles live in the trailing byte; see module docstring and # onnx/fuzz/README.md's "fuzz_compose.py toggle byte" table. if len(data) < 2: return toggles = data[-1] body = data[:-1] use_prefix = bool(toggles & 0x01) use_structured = bool(toggles & 0x04) use_random_io_map = bool(toggles & 0x08) # bits 0x02, 0x10..0x80 are reserved for future toggles; mutations # against them are harmless until claimed. try: if use_structured: fdp = atheris.FuzzedDataProvider(body) opset = fdp.ConsumeIntInRange(7, defs.onnx_opset_version()) m1 = _build_model(fdp, 1, opset) m2 = _build_model(fdp, 2, opset) else: if len(body) < 4: return (n1,) = struct.unpack(">I", body[:4]) rest = body[4:] if n1 > len(rest): return m1 = load_model_from_string(rest[:n1]) m2 = load_model_from_string(rest[n1:]) io_map = ( _random_io_map(body, m1, m2) if use_random_io_map else _derived_io_map(m1, m2) ) prefix1 = "g1_" if use_prefix else None prefix2 = "g2_" if use_prefix else None merged = compose.merge_models(m1, m2, io_map, prefix1=prefix1, prefix2=prefix2) checker.check_model(merged, full_check=True) except Exception: # Malformed fuzz inputs raise a broad set of expected exceptions # (ValidationError, TypeError, ValueError, DecodeError, ...). # Real bugs surface as crashes, hangs, or sanitizer reports. return def main(): atheris.instrument_all() atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) atheris.Fuzz() if __name__ == "__main__": main()