From 97b5d61bfe3781935bf3f4b4ea334c5f4fa3c63f Mon Sep 17 00:00:00 2001 From: tc-agent <270634894+tc-agent@users.noreply.github.com> Date: Sat, 9 May 2026 08:46:16 +0000 Subject: [PATCH 1/2] openvpn: fix harness aborts and stalls in fuzz_route/fuzz_buffer --- projects/openvpn/build.sh | 5 +++++ projects/openvpn/fuzz_buffer.c | 6 ++++-- projects/openvpn/fuzz_route.c | 5 ++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/projects/openvpn/build.sh b/projects/openvpn/build.sh index 2f3d8a10b..58ac8f936 100755 --- a/projects/openvpn/build.sh +++ b/projects/openvpn/build.sh @@ -40,6 +40,11 @@ apply_sed_changes() { sed -i 's/msg(M_FATAL/msg(M_WARN/g' ${BASE}/crypto.c sed -i 's/= write/= fuzz_write/g' ${BASE}/packet_id.c + + # Don't let openvpn_getaddrinfo's retry loop clear AI_NUMERICHOST. Random + # fuzz hostnames otherwise hit the glibc resolver path (~10s per call), + # which starves fuzz_route to single-digit exec/s. + sed -i 's|hints.ai_flags &= ~AI_NUMERICHOST;|/* fuzz: keep AI_NUMERICHOST to avoid DNS */|' ${BASE}/socket_util.c } # Changes in the code so we can fuzz it. diff --git a/projects/openvpn/fuzz_buffer.c b/projects/openvpn/fuzz_buffer.c index 04caf4600..f0ed55f71 100644 --- a/projects/openvpn/fuzz_buffer.c +++ b/projects/openvpn/fuzz_buffer.c @@ -43,8 +43,10 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { bufp = &buf; } else { tmp = get_random_string(); - buf = string_alloc_buf(tmp, &gc); - bufp = &buf; + if (strlen(tmp) + 1 < BUF_SIZE_MAX) { + buf = string_alloc_buf(tmp, &gc); + bufp = &buf; + } free(tmp); tmp = NULL; } diff --git a/projects/openvpn/fuzz_route.c b/projects/openvpn/fuzz_route.c index ea59776c4..55c913200 100644 --- a/projects/openvpn/fuzz_route.c +++ b/projects/openvpn/fuzz_route.c @@ -151,7 +151,7 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { tt.actual_name = gb_get_random_string(); r6.iface = gb_get_random_string(); r6.flags = fuzz_randomizer_get_int(0, 0xfffff); - r6.netbits = fuzz_randomizer_get_int(0, 0xfffff); + r6.netbits = fuzz_randomizer_get_int(0, 128); r6.metric = fuzz_randomizer_get_int(0, 0xfffff); r6.next = NULL; @@ -196,6 +196,9 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { if (route_list_inited) { gc_free(&rl.gc); } + if (route_list_ipv6_inited) { + gc_free(&rl6.gc); + } env_set_destroy(c.es); context_gc_free(&c); From 602e97757ddd882d1ac90af0a50c732dd690df9b Mon Sep 17 00:00:00 2001 From: tc-agent <270634894+tc-agent@users.noreply.github.com> Date: Wed, 27 May 2026 16:44:56 +0000 Subject: [PATCH 2/2] [CI] Add fuzzing verification (drop before forwarding upstream) --- .github/actions/check-new-seeds/action.yml | 27 ++ .../check-new-seeds/check_new_seeds.py | 330 ++++++++++++++++++ .github/actions/collect-fuzz-stats/action.yml | 69 ++++ .github/actions/fuzz-all/action.yml | 48 +++ .github/actions/fuzz-all/run_fuzzers.py | 159 +++++++++ .github/actions/post-fuzz-report/action.yml | 82 +++++ .github/actions/post-fuzz-report/cov.py | 87 +++++ .../post-fuzz-report/render_comment.py | 252 +++++++++++++ .github/actions/prepare/action.yml | 45 +++ .github/actions/prepare/prepare.py | 228 ++++++++++++ .github/workflows/_fuzz-verify.yml | 274 +++++++++++++++ .github/workflows/fuzz-verify.yml | 15 + 12 files changed, 1616 insertions(+) create mode 100644 .github/actions/check-new-seeds/action.yml create mode 100644 .github/actions/check-new-seeds/check_new_seeds.py create mode 100644 .github/actions/collect-fuzz-stats/action.yml create mode 100644 .github/actions/fuzz-all/action.yml create mode 100644 .github/actions/fuzz-all/run_fuzzers.py create mode 100644 .github/actions/post-fuzz-report/action.yml create mode 100644 .github/actions/post-fuzz-report/cov.py create mode 100644 .github/actions/post-fuzz-report/render_comment.py create mode 100644 .github/actions/prepare/action.yml create mode 100644 .github/actions/prepare/prepare.py create mode 100644 .github/workflows/_fuzz-verify.yml create mode 100644 .github/workflows/fuzz-verify.yml diff --git a/.github/actions/check-new-seeds/action.yml b/.github/actions/check-new-seeds/action.yml new file mode 100644 index 000000000..1c15fdbe0 --- /dev/null +++ b/.github/actions/check-new-seeds/action.yml @@ -0,0 +1,27 @@ +name: Verify new seeds +description: Run libfuzzer -merge=1 over (baseline + new) seeds for each harness with newly-added seeds in the PR. Hard-fail if any new seed is redundant. + +inputs: + project: + description: Project name. + required: true + base_sha: + description: Git base SHA to diff against (typically merge-base with the default branch). + required: true + out_base: + description: Directory containing the per-project oss-fuzz out tree (e.g. build/out, /tmp/oss-fuzz/build/out). + required: true + seeds_dir: + description: Workspace-relative seeds root grouped by harness (e.g. projects//seeds, .github/fuzz/seeds). + required: true + +runs: + using: composite + steps: + - shell: bash + env: + PROJECT: ${{ inputs.project }} + BASE_SHA: ${{ inputs.base_sha }} + OUT_BASE: ${{ inputs.out_base }} + SEEDS_DIR: ${{ inputs.seeds_dir }} + run: python3 "$GITHUB_ACTION_PATH/check_new_seeds.py" diff --git a/.github/actions/check-new-seeds/check_new_seeds.py b/.github/actions/check-new-seeds/check_new_seeds.py new file mode 100644 index 000000000..3e7d5bfbf --- /dev/null +++ b/.github/actions/check-new-seeds/check_new_seeds.py @@ -0,0 +1,330 @@ +# This file ships into oss-fuzz review-repo PRs via the check-new-seeds +# composite action; oss-fuzz's `infra/presubmit.py` check_license greps every +# Python file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, +# so we carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Verify newly-added fuzz seeds aren't redundant given the baseline corpus. + +For each harness whose seed dir got new files in this PR, run libfuzzer's +`-merge=1` over (baseline + new) and check every new seed survived. A seed +is redundant if any of: + - its content already exists in baseline (exact duplicate of preexisting), + - its content matches another newly-added seed (dup within the PR), + - libfuzzer's merge dropped it (covers no new edges beyond what's loaded). + +Hard-fails (exit 1) with a per-seed reason and a copy-paste reproducer. + +Inputs (env vars): + PROJECT project name (used for OUT_BASE//) + BASE_SHA git SHA to diff against (typically merge-base with main/master) + OUT_BASE dir containing built harness binaries + (e.g. build/out for oss-fuzz, /tmp/oss-fuzz/build/out for upstream) + SEEDS_DIR workspace-relative seeds root, grouped by harness + (e.g. projects//seeds for oss-fuzz, .github/fuzz/seeds for upstream) + BASE_RUNNER (optional) override base-runner image, default + gcr.io/oss-fuzz-base/base-runner +""" + +import hashlib +import os +import pathlib +import shutil +import subprocess +import sys +import tempfile +from collections import defaultdict + +DEFAULT_BASE_RUNNER = "gcr.io/oss-fuzz-base/base-runner" + + +def file_sha(p: pathlib.Path) -> str: + return hashlib.sha1(p.read_bytes()).hexdigest() + + +def hashes_in(d: pathlib.Path) -> set[str]: + return {file_sha(p) for p in d.iterdir() if p.is_file()} + + +def added_paths(base_sha: str, prefix: str) -> list[str]: + """Files added in commits between base_sha and HEAD, restricted to prefix/.""" + result = subprocess.run( + [ + "git", + "diff", + "--name-only", + "--diff-filter=A", + f"{base_sha}...HEAD", + "--", + prefix, + ], + capture_output=True, + text=True, + check=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +def group_by_harness(paths: list[str], seeds_dir: str) -> dict[str, list[str]]: + """Group seed paths by their harness subdir. + + e.g. projects/foo/seeds/parser/a.in -> {"parser": ["projects/foo/seeds/parser/a.in"]} + """ + prefix = seeds_dir.rstrip("/") + "/" + groups: dict[str, list[str]] = defaultdict(list) + for p in paths: + if not p.startswith(prefix): + continue + rest = p[len(prefix):] + parts = rest.split("/", 1) + if len(parts) != 2: # seed file directly under seeds/, no harness subdir + continue + groups[parts[0]].append(p) + return groups + + +def docker_merge( + image: str, + out_dir: pathlib.Path, + harness: str, + minimized: pathlib.Path, + baseline: pathlib.Path, + new: pathlib.Path, +) -> subprocess.CompletedProcess: + """Run ` -merge=1 /minimized /baseline /new` inside the base-runner.""" + cmd = [ + "docker", + "run", + "--rm", + "--privileged", + "-e", + "FUZZING_ENGINE=libfuzzer", + "-e", + "SANITIZER=address", + "-e", + "ARCHITECTURE=x86_64", + "-e", + "OUT=/out", + "-v", + f"{out_dir.resolve()}:/out:ro", + "-v", + f"{baseline.resolve()}:/baseline:ro", + "-v", + f"{new.resolve()}:/new:ro", + "-v", + f"{minimized.resolve()}:/minimized", + "--entrypoint", + "/out/" + harness, + image, + "-merge=1", + "/minimized", + "/baseline", + "/new", + ] + return subprocess.run(cmd, capture_output=True, text=True) + + +def check_harness( + harness: str, + out_dir: pathlib.Path, + seeds_root: pathlib.Path, + new_names: set[str], + image: str, + work_root: pathlib.Path, +) -> tuple[list[tuple[str, str]], str]: + """Returns (redundant_seeds, debug_log). + + redundant_seeds: list of (filename, reason) for each redundant new seed. + debug_log: stdout+stderr of the merge run (always returned for diagnostics). + """ + harness_seeds = seeds_root / harness + if not harness_seeds.is_dir(): + return [], f"{harness}: seeds dir {harness_seeds} missing — skipping" + + binary = out_dir / harness + if not binary.is_file(): + return [], ( + f"{harness}: harness binary not built ({binary} missing) — skipping. " + "If this is unexpected, check the build_fuzzers step.") + + work = work_root / harness + baseline = work / "baseline" + new = work / "new" + minimized = work / "minimized" + for d in (baseline, new, minimized): + d.mkdir(parents=True, exist_ok=True) + + for f in harness_seeds.iterdir(): + if not f.is_file(): + continue + dst = (new if f.name in new_names else baseline) / f.name + shutil.copyfile(f, dst) + + baseline_hashes = hashes_in(baseline) + + proc = docker_merge(image, out_dir, harness, minimized, baseline, new) + log = ( + f"$ {' '.join(['', '-merge=1', '/minimized', '/baseline', '/new'])}\n" + f"--- exit {proc.returncode} ---\n" + f"--- stdout ---\n{proc.stdout}\n" + f"--- stderr ---\n{proc.stderr}") + if proc.returncode != 0: + return [(harness, f"libfuzzer merge failed (exit {proc.returncode})")], log + + minimized_hashes = hashes_in(minimized) + + # Detect merge-incompatible harnesses. libFuzzer's -merge=1 keeps a minimal + # covering set; with an empty baseline, *any* input that executes and records + # features yields a non-empty `minimized`. If the merge succeeds but keeps + # ZERO files even though there was ≥1 input, libFuzzer never recorded + # per-input features — i.e. the harness doesn't return cleanly to libFuzzer + # between inputs so the merge control file gets no FT lines. (u-boot's + # sandbox fuzzer is the canonical case: it runs the target on a separate + # thread via a coroutine handoff and os_abort()s when sandbox_main returns, + # killing the process before libFuzzer's merge bookkeeping completes.) + # Flagging every seed "redundant" here would be a false positive, so only + # report true byte-duplicates and skip the edge-coverage verdict. + inputs_present = any(baseline.iterdir()) or any(new.iterdir()) + merge_recorded_nothing = not minimized_hashes + incompatible = inputs_present and merge_recorded_nothing + + redundant: list[tuple[str, str]] = [] + seen_in_new: set[str] = set() + for src in sorted(new.iterdir()): + h = file_sha(src) + if h in baseline_hashes: + redundant.append((src.name, "duplicate of an existing baseline seed")) + elif h in seen_in_new: + redundant.append((src.name, "duplicate of another newly-added seed")) + elif incompatible: + # Not byte-duplicate; can't judge edge-coverage on a merge-incompatible + # harness — accept it. + seen_in_new.add(h) + elif h not in minimized_hashes: + redundant.append( + (src.name, "covers no new edges beyond baseline + earlier new seeds")) + else: + seen_in_new.add(h) + + if incompatible: + log += ( + "\n--- note ---\n" + "libFuzzer -merge=1 kept 0 files despite ≥1 input seed; the harness " + "does not return cleanly to libFuzzer between inputs (merge control " + "file has no FT lines), so per-seed edge-coverage cannot be verified. " + "Skipping the redundancy verdict for this harness; only exact " + "byte-duplicate seeds are reported.") + + return redundant, log + + +def _emit(line: str = ""): + print(line, flush=True) + + +def report_failure( + findings: list[tuple[str, list[tuple[str, str]], str]], + seeds_dir: str, + out_base: str, + project: str, +): + """Print a GH-Actions error block + per-harness fix instructions.""" + total = sum(len(rs) for _, rs, _ in findings) + _emit(f"::error::{total} redundant new seed(s) — see details below.") + _emit() + _emit("=" * 70) + _emit("Redundant new seeds detected") + _emit("=" * 70) + _emit() + _emit("libfuzzer's -merge=1 keeps only inputs that add new code coverage. " + "Each seed below either duplicated an existing seed or covered nothing " + "new on top of the corpus loaded before it.") + _emit() + for harness, redundant, log in findings: + _emit(f"### {harness}") + for name, reason in redundant: + _emit(f" - {seeds_dir}/{harness}/{name}") + _emit(f" -> {reason}") + _emit() + _emit(" merge log (last 20 lines of stderr):") + tail = "\n".join(log.splitlines()[-20:]) + for line in tail.splitlines(): + _emit(f" | {line}") + _emit() + _emit("=" * 70) + _emit("How to make CI go green") + _emit("=" * 70) + _emit() + _emit("Pick one per redundant file:") + _emit(" (a) Delete the file from the PR.") + _emit(" (b) Replace its content with input that exercises a new code path") + _emit(" (different parser branch, new field, edge value, etc.).") + _emit() + _emit("To reproduce locally on a built project:") + _emit(f" cd {out_base}/{project}") + _emit(" mkdir -p baseline new minimized") + _emit( + " # populate baseline/ with preexisting seeds, new/ with the added ones") + _emit(" ./ -merge=1 minimized/ baseline/ new/") + _emit( + " # any seed in new/ whose content-hash is not in minimized/ is redundant." + ) + + +def main() -> int: + project = os.environ["PROJECT"] + base_sha = os.environ["BASE_SHA"] + out_base = os.environ["OUT_BASE"] + seeds_dir = os.environ["SEEDS_DIR"] + image = os.environ.get("BASE_RUNNER", DEFAULT_BASE_RUNNER) + + added = added_paths(base_sha, seeds_dir) + if not added: + _emit(f"No new seed files under {seeds_dir} in this PR — skipping check.") + return 0 + + groups = group_by_harness(added, seeds_dir) + if not groups: + _emit(f"Seeds added under {seeds_dir} but none in a per-harness subdir — " + "skipping. (Expected layout: //.)") + return 0 + + out_dir = pathlib.Path(out_base) / project + seeds_root = pathlib.Path(seeds_dir) + + findings: list[tuple[str, list[tuple[str, str]], str]] = [] + with tempfile.TemporaryDirectory() as tmp: + tmp_root = pathlib.Path(tmp) + for harness, paths in sorted(groups.items()): + new_names = {pathlib.Path(p).name for p in paths} + _emit(f"::group::Verify new seeds: {harness} ({len(new_names)} new)") + redundant, log = check_harness(harness, out_dir, seeds_root, new_names, + image, tmp_root) + if redundant: + findings.append((harness, redundant, log)) + _emit(f" -> {len(redundant)} redundant") + else: + _emit(" -> all new seeds non-redundant") + _emit("::endgroup::") + + if findings: + report_failure(findings, seeds_dir, out_base, project) + return 1 + + _emit("All new seeds are non-redundant.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/collect-fuzz-stats/action.yml b/.github/actions/collect-fuzz-stats/action.yml new file mode 100644 index 000000000..cc2af2c8c --- /dev/null +++ b/.github/actions/collect-fuzz-stats/action.yml @@ -0,0 +1,69 @@ +name: Collect fuzz stats +description: Dump coverage summaries (project-wide + per-harness) and corpus file counts into stats-/ for the report job. + +inputs: + project: + description: Project name. + required: true + variant: + description: Variant label — "baseline" or "current". + required: true + sha: + description: Commit SHA measured in this variant. + required: true + has_project: + description: String "true" if the project was present and built for this variant; otherwise only meta.json is written. + required: true + out_base: + description: Directory containing the oss-fuzz out tree (e.g. build/out for oss-fuzz template, /tmp/oss-fuzz/build/out for upstream). + required: true + +runs: + using: composite + steps: + - shell: bash + env: + PROJECT: ${{ inputs.project }} + VARIANT: ${{ inputs.variant }} + SHA: ${{ inputs.sha }} + HAS_PROJECT: ${{ inputs.has_project }} + OUT_BASE: ${{ inputs.out_base }} + run: | + OUT="stats-$VARIANT" + mkdir -p "$OUT/harness" + + jq -n \ + --arg variant "$VARIANT" \ + --arg sha "${SHA:-}" \ + --arg project "$PROJECT" \ + --arg has_project "${HAS_PROJECT:-false}" \ + '{variant: $variant, sha: $sha, project: $project, has_project: ($has_project == "true")}' \ + > "$OUT/meta.json" + + if [ "$HAS_PROJECT" != "true" ]; then + echo "No project at this variant; stats collection skipped" + exit 0 + fi + + PROJ_SUM=$(find "$OUT_BASE/$PROJECT/report/linux" -maxdepth 1 -name summary.json 2>/dev/null | head -1 || true) + if [ -n "$PROJ_SUM" ]; then + cp "$PROJ_SUM" "$OUT/project.summary.json" + fi + + if [ -d "$OUT_BASE/$PROJECT/report_target" ]; then + for d in "$OUT_BASE/$PROJECT/report_target"/*/linux; do + [ -d "$d" ] || continue + HNAME=$(basename "$(dirname "$d")") + [ -f "$d/summary.json" ] && cp "$d/summary.json" "$OUT/harness/$HNAME.summary.json" + done + fi + + # summary.json carries only the harness-inclusive scalar; the per-function + # list is what lets the report exclude harness functions, so ship that. + INSPECTOR_FUNCS="$OUT_BASE/$PROJECT/inspector/all-fuzz-introspector-functions.json" + if [ -f "$INSPECTOR_FUNCS" ]; then + cp "$INSPECTOR_FUNCS" "$OUT/reachability.json" + fi + + echo "---- $OUT ----" + find "$OUT" -type f -printf '%p (%s bytes)\n' diff --git a/.github/actions/fuzz-all/action.yml b/.github/actions/fuzz-all/action.yml new file mode 100644 index 000000000..f43779ddf --- /dev/null +++ b/.github/actions/fuzz-all/action.yml @@ -0,0 +1,48 @@ +name: Fuzz all harnesses +description: Run every libFuzzer harness in $OUT concurrently for fuzz_seconds wall-time, capturing per-harness logs. + +inputs: + project: + description: oss-fuzz project name (or path, in external mode). + required: true + helper_py: + description: Path to oss-fuzz infra/helper.py. + required: true + out_dir: + description: Path to build/out/ with the harness binaries. + required: true + corpus_root: + description: Parent directory for per-harness corpus dirs. + required: true + logs_dir: + description: Directory for per-harness raw fuzz logs. + required: true + fuzz_seconds: + description: Wall-time budget per harness (passed as -max_total_time). + required: true + external: + description: Pass --external to helper.py run_fuzzer (for ClusterFuzzLite-style projects). + required: false + default: 'false' + +runs: + using: composite + steps: + - shell: bash + env: + PROJECT: ${{ inputs.project }} + HELPER_PY: ${{ inputs.helper_py }} + OUT_DIR: ${{ inputs.out_dir }} + CORPUS_ROOT: ${{ inputs.corpus_root }} + LOGS_DIR: ${{ inputs.logs_dir }} + FUZZ_SECONDS: ${{ inputs.fuzz_seconds }} + EXTERNAL_FLAG: ${{ inputs.external == 'true' && '--external' || '' }} + run: | + python3 "$GITHUB_ACTION_PATH/run_fuzzers.py" \ + --helper-py "$HELPER_PY" \ + --project "$PROJECT" \ + --out-dir "$OUT_DIR" \ + --corpus-root "$CORPUS_ROOT" \ + --logs-dir "$LOGS_DIR" \ + --max-total-time "$FUZZ_SECONDS" \ + $EXTERNAL_FLAG diff --git a/.github/actions/fuzz-all/run_fuzzers.py b/.github/actions/fuzz-all/run_fuzzers.py new file mode 100644 index 000000000..8864739d2 --- /dev/null +++ b/.github/actions/fuzz-all/run_fuzzers.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# This file ships into oss-fuzz review-repo PRs via the fuzz-all composite +# action; oss-fuzz's `infra/presubmit.py` check_license greps every Python +# file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, so we +# carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Run all libFuzzer harnesses for an oss-fuzz project concurrently. + +Shared by CI workflows (the fuzz-verify reusable workflow) and the agent +CLI. stdlib-only so it can run in a stock oss-fuzz CI environment without the +fuzz_for_me package installed. +""" + +import argparse +import os +import shlex +import stat +import subprocess +import sys +from pathlib import Path + +SKIP_SUFFIXES = (".options", ".dict", "_seed_corpus.zip") +SKIP_NAMES = {"llvm-symbolizer", "jazzer_agent_deploy.jar", "jazzer_driver"} + + +def discover_harnesses(out_dir): + out = [] + for entry in sorted(out_dir.iterdir()): + if not entry.is_file(): + continue + if entry.name in SKIP_NAMES: + continue + if any(entry.name.endswith(s) for s in SKIP_SUFFIXES): + continue + if not (entry.stat().st_mode & stat.S_IXUSR): + continue + out.append(entry.name) + return out + + +def build_cmd(args, harness, corpus_dir): + # --out-dir is intentionally NOT passed to helper.py run_fuzzer: not all + # versions of upstream helper.py accept it. The default location + # (oss-fuzz/build/out/) is what build_fuzzers writes to. + cmd = [sys.executable, str(args.helper_py), "run_fuzzer"] + if args.external: + cmd.append("--external") + cmd += ["--corpus-dir", str(corpus_dir), args.project, harness] + if args.max_total_time > 0: + cmd += ["--", f"-max_total_time={args.max_total_time}"] + return cmd + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--helper-py", required=True, type=Path) + ap.add_argument("--project", + required=True, + help="oss-fuzz project name, or path in --external mode") + ap.add_argument("--out-dir", + required=True, + type=Path, + help="build/out/ — where the harness binaries are") + ap.add_argument("--corpus-root", required=True, type=Path) + ap.add_argument("--logs-dir", required=True, type=Path) + ap.add_argument("--max-total-time", + type=int, + default=0, + help="seconds per harness; 0 = unbounded (use --detach)") + ap.add_argument("--external", + action="store_true", + help="pass --external to helper.py") + ap.add_argument("--detach", + action="store_true", + help="launch and exit immediately, printing PIDs") + args = ap.parse_args() + + args.out_dir = args.out_dir.resolve() + args.corpus_root = args.corpus_root.resolve() + args.logs_dir = args.logs_dir.resolve() + args.helper_py = args.helper_py.resolve() + + if not args.detach and args.max_total_time <= 0: + ap.error("--max-total-time must be > 0 unless --detach is given") + + harnesses = discover_harnesses(args.out_dir) + if not harnesses: + print(f"::error::no harness binaries found in {args.out_dir}", + file=sys.stderr) + return 1 + + args.corpus_root.mkdir(parents=True, exist_ok=True) + args.logs_dir.mkdir(parents=True, exist_ok=True) + + print( + f"Harnesses ({len(harnesses)}): {' '.join(harnesses)} — " + f"max_total_time={args.max_total_time}s, detach={args.detach}", + flush=True) + + procs = [] + for h in harnesses: + corpus_dir = args.corpus_root / h + corpus_dir.mkdir(parents=True, exist_ok=True) + log_path = args.logs_dir / f"{h}.log" + log_fp = open(log_path, "w") + cmd = build_cmd(args, h, corpus_dir) + proc = subprocess.Popen(cmd, + stdout=log_fp, + stderr=subprocess.STDOUT, + start_new_session=True) + procs.append((h, proc, log_fp, log_path, corpus_dir)) + + if args.detach: + for h, proc, log_fp, log_path, _ in procs: + log_fp.close() + print(f"{h} pid={proc.pid} log={log_path}") + return 0 + + rc = 0 + timeout = args.max_total_time + 60 + for h, proc, log_fp, _, _ in procs: + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + print(f"::warning::{h} exceeded timeout, killing", file=sys.stderr) + proc.kill() + proc.wait() + rc = max(rc, 1) + finally: + log_fp.close() + + for h, proc, _, log_path, corpus_dir in procs: + n_corpus = sum( + 1 for _ in corpus_dir.iterdir()) if corpus_dir.is_dir() else 0 + print(f"::group::{h}", flush=True) + try: + sys.stdout.write(log_path.read_text(errors="replace")) + except OSError as e: + print(f"(failed to read log: {e})") + print(f"\nCorpus: {n_corpus} files (exit={proc.returncode})", flush=True) + print("::endgroup::", flush=True) + + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/post-fuzz-report/action.yml b/.github/actions/post-fuzz-report/action.yml new file mode 100644 index 000000000..2fa513615 --- /dev/null +++ b/.github/actions/post-fuzz-report/action.yml @@ -0,0 +1,82 @@ +name: Post fuzz coverage report +description: Download baseline/current stats artifacts, render markdown, post or update a sticky PR comment. + +inputs: + footer: + description: Footer flavor — "generic" for oss-fuzz, "upstream" for upstream source PRs. + required: false + default: generic + fuzz_seconds: + description: Total fuzz budget used (for display only). + required: true + github_token: + description: Token with pull-requests:write to post the comment. + required: true + +runs: + using: composite + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: stats-baseline + path: stats/baseline + continue-on-error: true + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: stats-current + path: stats/current + continue-on-error: true + + # Reachability is produced by a separate parallel job (see the + # `reachability` job). Land its reachability.json next to the other + # stats so render_comment.py finds it at the same path as before — + # no renderer change. Soft: a missing artifact (introspector failed) + # just renders the reachability row as unavailable. + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reachability-baseline + path: stats/baseline + continue-on-error: true + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reachability-current + path: stats/current + continue-on-error: true + + - name: Render comment + shell: bash + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + FUZZ_SECONDS: ${{ inputs.fuzz_seconds }} + FOOTER: ${{ inputs.footer }} + STATS_ROOT: stats + run: | + python3 "$GITHUB_ACTION_PATH/render_comment.py" > comment.md + echo "---- comment.md ----" + cat comment.md + + - name: Post or update PR comment + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + REPO: ${{ github.repository }} + BRANCH: ${{ github.ref_name }} + run: | + PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -z "$PR" ]; then + echo "No open PR for branch $BRANCH — skipping comment" + exit 0 + fi + MARKER='' + EXISTING=$(gh api "repos/$REPO/issues/$PR/comments" --paginate \ + --jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -1) + jq -Rs '{body: .}' < comment.md > payload.json + if [ -n "$EXISTING" ]; then + gh api --method PATCH "repos/$REPO/issues/comments/$EXISTING" --input payload.json > /dev/null + echo "Updated existing comment $EXISTING on PR #$PR" + else + gh api --method POST "repos/$REPO/issues/$PR/comments" --input payload.json > /dev/null + echo "Created new comment on PR #$PR" + fi diff --git a/.github/actions/post-fuzz-report/cov.py b/.github/actions/post-fuzz-report/cov.py new file mode 100644 index 000000000..2920e01bb --- /dev/null +++ b/.github/actions/post-fuzz-report/cov.py @@ -0,0 +1,87 @@ +# This file ships into oss-fuzz review-repo PRs via the post-fuzz-report +# composite action (next to render_comment.py); oss-fuzz's +# `infra/presubmit.py` check_license greps every Python file under +# non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, so we carry the +# standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Single source of truth for "project coverage". + +Project coverage = the aggregate over in-scope source files, *excluding* the +fuzz harnesses. Harness bodies run on every input, so they're ~100% covered by +construction; counting them inflates the headline and is pure noise. llvm-cov's +precomputed ``data[0]["totals"]`` and Fuzz Introspector's +``MergedProjectProfile.stats`` are both harness-inclusive, so the headline must +be re-derived from the per-file / per-function lists instead of trusting those +free aggregates. + +stdlib-only and dependency-free: this module ships standalone next to +``render_comment.py`` into bare CI, and is also imported in-tree by +``manager.py``. +""" + +HARNESS_PREFIX = "/src/harnesses/" + +_METRICS = ("lines", "branches", "functions") + + +def _triple(covered, count): + return (covered, count, (100.0 * covered / count) if count else 0.0) + + +def coverage_totals(summary): + """Harness-excluded totals from an llvm-cov export ``summary.json``. + + Sums the per-file ``summary`` blocks (``data[0]["files"]``) for files not + under ``HARNESS_PREFIX``. Returns ``{metric: (covered, count, pct)}`` for + lines/branches/functions, or ``None`` when no usable data is present. + """ + if not summary: + return None + try: + files = summary["data"][0]["files"] + except (KeyError, IndexError, TypeError): + return None + acc = {m: [0, 0] for m in _METRICS} + for f in files: + if f["filename"].startswith(HARNESS_PREFIX): + continue + s = f["summary"] + for m in _METRICS: + sm = s.get(m) + if sm: + acc[m][0] += sm["covered"] + acc[m][1] += sm["count"] + return {m: _triple(*acc[m]) for m in _METRICS} + + +def reach_totals(functions): + """Harness-excluded static reachability from Fuzz Introspector. + + ``functions`` is the parsed ``all-fuzz-introspector-functions.json`` list + (``summary.json`` itself carries only the harness-inclusive scalar). Counts + entries whose ``Functions filename`` is not under ``HARNESS_PREFIX``; an + entry is "reached" when ``Combined reached by Fuzzers`` is non-empty. + Returns ``{"reach": (reached, total, pct)}`` or ``None``. + """ + if not functions: + return None + reached = total = 0 + for fn in functions: + if fn["Functions filename"].startswith(HARNESS_PREFIX): + continue + total += 1 + if fn["Combined reached by Fuzzers"]: + reached += 1 + return {"reach": _triple(reached, total)} diff --git a/.github/actions/post-fuzz-report/render_comment.py b/.github/actions/post-fuzz-report/render_comment.py new file mode 100644 index 000000000..4b2eace9e --- /dev/null +++ b/.github/actions/post-fuzz-report/render_comment.py @@ -0,0 +1,252 @@ +# This file ships into oss-fuzz review-repo PRs via the post-fuzz-report +# composite action; oss-fuzz's `infra/presubmit.py` check_license greps every +# Python file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, +# so we carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Render before/after coverage comparison as a sticky PR comment body. + +Reads artifacts downloaded by the calling workflow: + stats/baseline/meta.json, project.summary.json, corpus.json, harness/*.summary.json + stats/current/ ... (same layout) + +Writes the markdown body to stdout. +""" + +import datetime +import json +import os +import pathlib +import sys + +try: + # In-tree (tests, host package). + from fuzz_for_me.ci import cov +except ImportError: # pragma: no cover - only the shipped bare-CI layout + # Shipped standalone next to this file in the composite action; exercised + # end-to-end by TestShippedStandalone via a subprocess. + import cov # ty: ignore[unresolved-import] + +# Must match the `reachability` job's `timeout-minutes` in +# ci/fuzz-verify.yml. Pinned by test_reach_timeout_label_matches_workflow_cap +# so the rendered cell can't advertise a cap that differs from the one +# GitHub actually enforces. +_REACH_TIMEOUT_MIN = 45 + +MARKER = "" + +# Δ is computed on covered counts (not percent points) so the same metric is +# meaningful when the denominator changes — e.g. when new code lands the +# instrumented line count grows, so comparing raw percentages understates real +# coverage gains. "new" / "deleted" cover the divide-by-zero edges. +FOOTER_DELTA_NOTE = ( + "Δ = (after − before) / before, to accommodate that denominators " + 'may change. "new" when before is 0; "deleted" when after is 0.') +FOOTER_GENERIC = FOOTER_DELTA_NOTE +FOOTER_UPSTREAM = ("Same harness config applied to both sides " + "(baseline = base source + PR harness).\n" + + FOOTER_DELTA_NOTE) + + +def _load_json(path: pathlib.Path): + if not path.exists(): + return None + try: + return json.loads(path.read_text()) + except json.JSONDecodeError: + return None + + +def _load_variant(base: pathlib.Path) -> dict: + harness: dict = {} + hd = base / "harness" + if hd.is_dir(): + for f in sorted(hd.glob("*.summary.json")): + data = _load_json(f) + if data is not None: + harness[f.name.removesuffix(".summary.json")] = data + return { + "meta": _load_json(base / "meta.json"), + "project": _load_json(base / "project.summary.json"), + "harness": harness, + "reachability": _load_json(base / "reachability.json"), + } + + +def _totals(summary): + """Harness-excluded coverage totals from an llvm-cov ``summary.json``.""" + return cov.coverage_totals(summary) + + +def _reach_totals(functions): + """Harness-excluded static reachability from the introspector per-function + list (``all-fuzz-introspector-functions.json``, carried by the + reachability.json artifact). ``None`` when no data is available.""" + return cov.reach_totals(functions) + + +def _fmt_cov(tot, key): + if not tot: + return "0%" + cov, n, pct = tot[key] + return f"{pct:.1f}% ({cov}/{n})" + + +def _fmt_delta(b, a, key, c_has=True): + if not b and not a: + return "—" + if not a: + # Baseline has data, current doesn't. + return "**removed**" if c_has else "**build failed**" + cov_a = a[key][0] + cov_b = b[key][0] if b else 0 + if cov_b == 0 and cov_a == 0: + return "—" + if cov_b == 0: + return "**new**" + if cov_a == 0: + return "**deleted**" + d = (cov_a - cov_b) / cov_b * 100 + sign = "+" if d >= 0 else "" + return f"**{sign}{d:.1f}%**" + + +def _fmt_reach_cell(reach, variant_has, run_url): + """Format a reachability value cell. + + ``>{_REACH_TIMEOUT_MIN}m`` (linked to the workflow run) when the variant + ran but the introspector build produced no summary — overwhelmingly + because it didn't finish within the soft job's guard timeout (an unbounded + Fuzz Introspector analysis; upstream OSS-Fuzz hits the same wall even with + a 20h budget). ``0%`` when the variant didn't run at all (matching the + coverage-row convention). + """ + if reach: + return _fmt_cov(reach, "reach") + if variant_has: + return f"[>{_REACH_TIMEOUT_MIN}m]({run_url})" + return "0%" + + +def _fmt_reach_delta(br, cr): + """Delta for the reachability row. Like ``_fmt_delta`` but treats a missing + variant as a build failure (``continue-on-error: true``) rather than a + removed metric — there is no "remove static reachability" intent.""" + if not br and not cr: + return "—" + if not br: + d = cr["reach"][2] + return f"**+{d:.1f}%**" + if not cr: + return "**build failed**" + d = cr["reach"][2] - br["reach"][2] + sign = "+" if d >= 0 else "" + return f"**{sign}{d:.1f}%**" + + +def render( + stats_root: pathlib.Path, + run_url: str, + fuzz_seconds: str, + now_utc: str, + footer: str, +) -> str: + b = _load_variant(stats_root / "baseline") + c = _load_variant(stats_root / "current") + + b_meta = b["meta"] or {} + c_meta = c["meta"] or {} + b_sha_full = b_meta.get("sha") or "" + c_sha_full = c_meta.get("sha") or "" + b_sha = b_sha_full[:7] if b_sha_full else "unknown" + c_sha = c_sha_full[:7] if c_sha_full else "unknown" + project = c_meta.get("project") or b_meta.get("project") or "?" + b_has = bool(b_meta.get("has_project")) + c_has = bool(c_meta.get("has_project")) + + out = [MARKER, "", "## Fuzzing Coverage Report", ""] + + tested = f"**Tested:** project `{project}` · base `{b_sha}`" + if not b_has: + tested += ( + " _(no baseline — project not present at base or baseline build failed)_" + ) + tested += f" → head `{c_sha}`" + if not c_has: + tested += " _(current measurement failed)_" + tested += (f" · {fuzz_seconds}s total fuzz budget" + f" · updated {now_utc}" + f" · [workflow run]({run_url})") + out += [tested, ""] + + bt = _totals(b["project"]) + ct = _totals(c["project"]) + br = _reach_totals(b["reachability"]) + cr = _reach_totals(c["reachability"]) + if b_has or c_has or bt or ct or br or cr: + out += [ + "| Metric | Before | After | Delta |", + "|---|---|---|---|", + f"| Static reachability | {_fmt_reach_cell(br, b_has, run_url)} | " + f"{_fmt_reach_cell(cr, c_has, run_url)} | " + f"{_fmt_reach_delta(br, cr)} |", + ] + if bt or ct: + out += [ + f"| Line coverage | {_fmt_cov(bt, 'lines')} | {_fmt_cov(ct, 'lines')} | {_fmt_delta(bt, ct, 'lines', c_has)} |", + f"| Branch coverage | {_fmt_cov(bt, 'branches')} | {_fmt_cov(ct, 'branches')} | {_fmt_delta(bt, ct, 'branches', c_has)} |", + f"| Function coverage | {_fmt_cov(bt, 'functions')} | {_fmt_cov(ct, 'functions')} | {_fmt_delta(bt, ct, 'functions', c_has)} |", + ] + out.append("") + + all_h = sorted(set(b["harness"].keys()) | set(c["harness"].keys())) + if all_h: + out += [ + "### Per-harness", + "", + "| Harness | Lines before | Lines after | Δ |", + "|---|---|---|---|", + ] + for h in all_h: + bh = _totals(b["harness"].get(h)) + ch = _totals(c["harness"].get(h)) + out.append( + f"| `{h}` | {_fmt_cov(bh, 'lines')} | {_fmt_cov(ch, 'lines')} | " + f"{_fmt_delta(bh, ch, 'lines', c_has)} |") + out.append("") + + if not (b_has or c_has or bt or ct or all_h or br or cr): + out += [ + "_No coverage data collected. Check the workflow run for build errors._", + "", + ] + + out.append(footer) + return "\n".join(out) + + +def main(): + stats_root = pathlib.Path(os.environ.get("STATS_ROOT", "stats")) + run_url = os.environ["RUN_URL"] + fuzz_seconds = os.environ.get("FUZZ_SECONDS", "300") + footer_kind = os.environ.get("FOOTER", "generic") + now_utc = datetime.datetime.now( + datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + footer = FOOTER_UPSTREAM if footer_kind == "upstream" else FOOTER_GENERIC + sys.stdout.write(render(stats_root, run_url, fuzz_seconds, now_utc, footer)) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main() diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml new file mode 100644 index 000000000..777d344f6 --- /dev/null +++ b/.github/actions/prepare/action.yml @@ -0,0 +1,45 @@ +name: Prepare fuzz-verify +description: Resolve project/variant, baseline checkout/overlay, oss-fuzz clone, pristine snapshot. Emits normalized outputs. + +inputs: + mode: + description: upstream | oss-fuzz + required: true + variant: + description: baseline | current + required: true + project_name: + description: Project name (upstream); empty for oss-fuzz (auto-detected). + required: false + default: '' + +outputs: + project: + value: ${{ steps.run.outputs.project }} + sha: + value: ${{ steps.run.outputs.sha }} + merge_base_sha: + value: ${{ steps.run.outputs.merge_base_sha }} + has_project: + value: ${{ steps.run.outputs.has_project }} + helper_dir: + value: ${{ steps.run.outputs.helper_dir }} + out_base: + value: ${{ steps.run.outputs.out_base }} + corpus_root: + value: ${{ steps.run.outputs.corpus_root }} + seeds_dir: + value: ${{ steps.run.outputs.seeds_dir }} + pristine_dir: + value: ${{ steps.run.outputs.pristine_dir }} + +runs: + using: composite + steps: + - id: run + shell: bash + env: + MODE: ${{ inputs.mode }} + VARIANT: ${{ inputs.variant }} + PROJECT_NAME: ${{ inputs.project_name }} + run: python3 "$GITHUB_ACTION_PATH/prepare.py" diff --git a/.github/actions/prepare/prepare.py b/.github/actions/prepare/prepare.py new file mode 100644 index 000000000..13bd6bc0e --- /dev/null +++ b/.github/actions/prepare/prepare.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +# This file ships into oss-fuzz review-repo PRs via the prepare composite +# action; oss-fuzz's `infra/presubmit.py` check_license greps every Python +# file under non-projects/ paths for the Apache 2.0 LICENSE-2.0 URL, so we +# carry the standard header here even though it's our own infra code. +# Copyright 2026 fuzz-for-me contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Mode-specific prep for the fuzz-verify reusable workflow. + +Resolves the project + variant SHA, performs baseline checkout/overlay, +clones oss-fuzz (upstream mode), snapshots a pristine source tree +(upstream mode, bug #2 fix), and emits normalized outputs the +mode-agnostic reusable workflow consumes. +""" + +import os +import pathlib +import re +import shutil +import subprocess +import sys + +_OSS_FUZZ_DIR = "/tmp/oss-fuzz" +_PRISTINE_DIR = "/tmp/fuzz-verify-pristine" + + +def _run(args, cwd=None, check=True): + return subprocess.run(args, + cwd=cwd, + check=check, + capture_output=True, + text=True) + + +def detect_oss_fuzz_project(repo: pathlib.Path) -> str: + out = _run( + ["git", "-C", + str(repo), "diff", "--name-only", "origin/master...HEAD"], + check=False, + ).stdout + for line in out.splitlines(): + if line.startswith("projects/"): + return line.split("/")[1] + # Fallback for local test repos without an `origin` remote. + out = _run( + ["git", "-C", + str(repo), "diff", "--name-only", "master...HEAD"], + check=False, + ).stdout + for line in out.splitlines(): + if line.startswith("projects/"): + return line.split("/")[1] + return "" + + +def compute_outputs(mode: str, project: str) -> dict[str, str]: + if mode == "oss-fuzz": + return { + "helper_dir": ".", + "out_base": "build/out", + "corpus_root": f"corpus/{project}", + "seeds_dir": f"projects/{project}/seeds", + } + if mode == "upstream": + return { + "helper_dir": _OSS_FUZZ_DIR, + "out_base": f"{_OSS_FUZZ_DIR}/build/out", + "corpus_root": f"{_OSS_FUZZ_DIR}/corpus/{project}", + "seeds_dir": ".github/fuzz/seeds", + } + raise ValueError(f"unknown mode: {mode}") + + +def _merge_base(repo: pathlib.Path, ref_a: str, ref_b: str) -> str: + return _run(["git", "-C", str(repo), "merge-base", ref_a, + ref_b]).stdout.strip() + + +def _default_remote_branch(repo: pathlib.Path) -> str: + r = _run( + [ + "git", "-C", + str(repo), "symbolic-ref", "--short", "refs/remotes/origin/HEAD" + ], + check=False, + ).stdout.strip() + return r.split("origin/")[-1] if r else "" + + +def _compute_merge_base(repo: pathlib.Path) -> str: + base_ref = "origin/master" + if _run(["git", "-C", + str(repo), "rev-parse", "--verify", "-q", base_ref], + check=False).returncode != 0: + base_ref = "master" + sha = _merge_base(repo, base_ref, "HEAD") + if not re.fullmatch(r"[0-9a-f]{7,40}", sha): + raise RuntimeError( + f"merge-base produced no usable SHA (base_ref={base_ref})") + return sha + + +def resolve_variant(mode: str, variant: str, project: str, + repo: pathlib.Path) -> dict[str, str]: + head = _run(["git", "-C", str(repo), "rev-parse", "HEAD"]).stdout.strip() + if variant == "current": + return {"sha": os.environ.get("GITHUB_SHA", head), "has_project": "true"} + + sha = _compute_merge_base(repo) + + if mode == "oss-fuzz": + present = _run( + [ + "git", "-C", + str(repo), "cat-file", "-e", f"{sha}:projects/{project}/Dockerfile" + ], + check=False, + ).returncode == 0 + if not present: + return {"sha": sha, "has_project": "false"} + shutil.rmtree(repo / "projects" / project, ignore_errors=True) + _run(["git", "-C", str(repo), "checkout", sha, "--", f"projects/{project}"]) + return {"sha": sha, "has_project": "true"} + + # upstream baseline: stash overlay, hard-reset to merge-base, restore. + fuzz = repo / ".github" / "fuzz" + if not fuzz.is_dir(): + print("::error::.github/fuzz not present on PR — " + "cannot overlay onto baseline") + return {"sha": sha, "has_project": "false"} + actions = repo / ".github" / "actions" + tmp = pathlib.Path(_run(["mktemp", "-d"]).stdout.strip()) + shutil.copytree(fuzz, tmp / "fuzz") + if actions.is_dir(): + shutil.copytree(actions, tmp / "actions") + _run(["git", "-C", str(repo), "reset", "--hard", sha]) + _run( + ["git", "-C", + str(repo), "submodule", "update", "--init", "--recursive"], + check=False, + ) + shutil.rmtree(repo / ".github" / "fuzz", ignore_errors=True) + shutil.rmtree(repo / ".github" / "actions", ignore_errors=True) + (repo / ".github").mkdir(exist_ok=True) + shutil.copytree(tmp / "fuzz", repo / ".github" / "fuzz") + if (tmp / "actions").is_dir(): + shutil.copytree(tmp / "actions", repo / ".github" / "actions") + return {"sha": sha, "has_project": "true"} + + +def _copy_tree(src: pathlib.Path, dst: pathlib.Path) -> str: + if shutil.which("rsync"): + dst.mkdir(parents=True, exist_ok=True) + _run(["rsync", "-a", "--delete", f"{src}/", f"{dst}/"]) + else: + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst, symlinks=True) + return str(dst) + + +def snapshot_pristine(workspace: pathlib.Path | str, + dest_root: pathlib.Path | str) -> str: + return _copy_tree(pathlib.Path(workspace), pathlib.Path(dest_root)) + + +def materialize_working_copy(pristine: pathlib.Path | str, + dest: pathlib.Path | str) -> str: + return _copy_tree(pathlib.Path(pristine), pathlib.Path(dest)) + + +def _emit(outputs: dict[str, str]): + path = os.environ["GITHUB_OUTPUT"] + with open(path, "a") as fh: + for k, v in outputs.items(): + fh.write(f"{k}={v}\n") + + +def main(): + mode = os.environ["MODE"] + variant = os.environ["VARIANT"] + ws = pathlib.Path(os.environ["GITHUB_WORKSPACE"]) + project = os.environ.get("PROJECT_NAME", "") or detect_oss_fuzz_project(ws) + + var = resolve_variant(mode, variant, project, ws) + out = {"project": project, **var, "merge_base_sha": _compute_merge_base(ws)} + is_upstream_project = var["has_project"] == "true" and mode == "upstream" + + if is_upstream_project: + fuzz = ws / ".github" / "fuzz" + if not fuzz.is_dir(): + print("::error::.github/fuzz not present — " + "cannot overlay onto baseline") + return 1 + _run([ + "git", "clone", "--depth", "1", + "https://github.com/google/oss-fuzz.git", _OSS_FUZZ_DIR + ], + check=False) + dest = pathlib.Path(f"{_OSS_FUZZ_DIR}/projects/{project}") + dest.mkdir(parents=True, exist_ok=True) + for item in fuzz.iterdir(): + tgt = dest / item.name + (shutil.copytree if item.is_dir() else shutil.copy)(item, tgt) + + paths = compute_outputs(mode, project) + if is_upstream_project: + out["pristine_dir"] = snapshot_pristine(ws, _PRISTINE_DIR) + else: + out["pristine_dir"] = "" + out.update(paths) + _emit(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/_fuzz-verify.yml b/.github/workflows/_fuzz-verify.yml new file mode 100644 index 000000000..6937f74c1 --- /dev/null +++ b/.github/workflows/_fuzz-verify.yml @@ -0,0 +1,274 @@ +# Reusable workflow injected as .github/workflows/_fuzz-verify.yml by pr_server. +# Mode-agnostic: every path/arg comes from the `prepare` action's outputs. +name: Fuzz Verify +on: + workflow_call: + inputs: + mode: { required: true, type: string } + project_name: { required: false, type: string, default: '' } + footer: { required: true, type: string } + fuzz_seconds: { required: true, type: string } + +permissions: + # Default to read-only at the workflow level; the report job elevates to pull-requests:write. + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + measure: + name: Measure (${{ matrix.variant }}) + runs-on: ubuntu-latest + timeout-minutes: 120 + continue-on-error: ${{ matrix.variant == 'baseline' }} + strategy: + fail-fast: false + matrix: + variant: [baseline, current] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: { fetch-depth: 0, persist-credentials: false, submodules: recursive } + + - id: prep + uses: ./.github/actions/prepare + with: + mode: ${{ inputs.mode }} + variant: ${{ matrix.variant }} + project_name: ${{ inputs.project_name }} + + - name: Install oss-fuzz deps + if: steps.prep.outputs.has_project == 'true' + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + run: pip install -r "$HELPER_DIR/infra/ci/requirements.txt" 2>/dev/null || pip install docker + + - name: Working copy (address) + id: wc-addr + if: steps.prep.outputs.has_project == 'true' && steps.prep.outputs.pristine_dir != '' + env: + PRISTINE_DIR: ${{ steps.prep.outputs.pristine_dir }} + run: | + rsync -a --delete "$PRISTINE_DIR/" /tmp/ws-address/ + echo "src=/tmp/ws-address" >> "$GITHUB_OUTPUT" + + - name: Build image + id: bi + if: steps.prep.outputs.has_project == 'true' + continue-on-error: ${{ matrix.variant == 'baseline' }} + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + run: echo n | python3 "$HELPER_DIR/infra/helper.py" build_image "$PROJECT" + + - name: Build fuzzers + id: bf + if: steps.prep.outputs.has_project == 'true' && steps.bi.outcome == 'success' + continue-on-error: ${{ matrix.variant == 'baseline' }} + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + WC_SRC: ${{ steps.wc-addr.outputs.src }} + run: | + if [ -n "$WC_SRC" ]; then + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer address "$PROJECT" "$WC_SRC" + else + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer address "$PROJECT" + fi + + - name: Verify new seeds + if: matrix.variant == 'current' && steps.prep.outputs.has_project == 'true' && steps.bf.outcome == 'success' + uses: ./.github/actions/check-new-seeds + with: + project: ${{ steps.prep.outputs.project }} + base_sha: ${{ steps.prep.outputs.merge_base_sha }} + out_base: ${{ steps.prep.outputs.out_base }} + seeds_dir: ${{ steps.prep.outputs.seeds_dir }} + + - name: Fuzz + if: steps.prep.outputs.has_project == 'true' && steps.bf.outcome == 'success' + uses: ./.github/actions/fuzz-all + with: + project: ${{ steps.prep.outputs.project }} + helper_py: ${{ steps.prep.outputs.helper_dir }}/infra/helper.py + out_dir: ${{ steps.prep.outputs.out_base }}/${{ steps.prep.outputs.project }} + corpus_root: ${{ steps.prep.outputs.corpus_root }} + logs_dir: /tmp/fuzz-logs + fuzz_seconds: ${{ inputs.fuzz_seconds }} + + - name: Coverage + if: steps.prep.outputs.has_project == 'true' && steps.bf.outcome == 'success' + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + PRISTINE_DIR: ${{ steps.prep.outputs.pristine_dir }} + CORPUS_ROOT: ${{ steps.prep.outputs.corpus_root }} + run: | + if [ -n "$PRISTINE_DIR" ]; then + rsync -a --delete "$PRISTINE_DIR/" /tmp/ws-cov/ + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer coverage "$PROJECT" /tmp/ws-cov + else + echo n | python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer coverage "$PROJECT" + fi + CORPUS_LINK_DIR="$HELPER_DIR/build/corpus" + mkdir -p "$CORPUS_LINK_DIR" + case "$CORPUS_ROOT" in + /*) CORPUS_TGT="$CORPUS_ROOT" ;; + *) CORPUS_TGT="$(pwd)/$CORPUS_ROOT" ;; + esac + ln -sfn "$CORPUS_TGT" "$CORPUS_LINK_DIR/$PROJECT" + python3 "$HELPER_DIR/infra/helper.py" coverage --no-corpus-download --no-serve "$PROJECT" 2>&1 || true + + - name: Collect stats + if: always() + continue-on-error: ${{ matrix.variant == 'baseline' }} + uses: ./.github/actions/collect-fuzz-stats + with: + project: ${{ steps.prep.outputs.project }} + variant: ${{ matrix.variant }} + sha: ${{ steps.prep.outputs.sha }} + has_project: ${{ steps.prep.outputs.has_project }} + out_base: ${{ steps.prep.outputs.out_base }} + + - name: Upload stats + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: stats-${{ matrix.variant }} + path: stats-${{ matrix.variant }}/ + if-no-files-found: error + + - name: Upload coverage report + if: always() && steps.prep.outputs.has_project == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-report-${{ matrix.variant }} + path: ${{ steps.prep.outputs.out_base }}/*coverage*/report/ + if-no-files-found: ignore + + # 45-min guard: Fuzz Introspector's analysis is unbounded over /src, so + # projects vendoring large trees (libprotobuf-mutator, fuzzer-test-suite) + # never finish — upstream OSS-Fuzz grants its own introspector build 20h + # and nginx/capnproto STILL time out. More time is futile; ~68% of + # successful C/C++ projects finish under 45 min, so cap there and + # fast-fail the doomed long tail instead of burning 2h × 2 variants. + # Soft signal: continue-on-error, so a cancel never reds the PR. + reachability: + name: Reachability (${{ matrix.variant }}) + runs-on: ubuntu-latest + # No job-level timeout-minutes — a timeout-cancelled job propagates to + # the workflow conclusion, reds the PR even though continue-on-error is + # set. The 45-min cap is enforced at the step level instead (see + # `timeout 45m` in "Build introspector") and the step absorbs the exit. + continue-on-error: true + strategy: + fail-fast: false + matrix: + variant: [baseline, current] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: { fetch-depth: 0, persist-credentials: false, submodules: recursive } + + - id: prep + uses: ./.github/actions/prepare + with: + mode: ${{ inputs.mode }} + variant: ${{ matrix.variant }} + project_name: ${{ inputs.project_name }} + + - name: Install oss-fuzz deps + if: steps.prep.outputs.has_project == 'true' + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + run: pip install -r "$HELPER_DIR/infra/ci/requirements.txt" 2>/dev/null || pip install docker + + - name: Build image + id: bi + if: steps.prep.outputs.has_project == 'true' + continue-on-error: true + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + run: echo n | python3 "$HELPER_DIR/infra/helper.py" build_image "$PROJECT" + + - name: Working copy (introspector) + id: wc-int + if: steps.prep.outputs.has_project == 'true' && steps.prep.outputs.pristine_dir != '' && steps.bi.outcome == 'success' + env: + PRISTINE_DIR: ${{ steps.prep.outputs.pristine_dir }} + run: | + rsync -a --delete "$PRISTINE_DIR/" /tmp/ws-introspector/ + echo "src=/tmp/ws-introspector" >> "$GITHUB_OUTPUT" + + - name: Build introspector + if: steps.prep.outputs.has_project == 'true' && steps.bi.outcome == 'success' + env: + HELPER_DIR: ${{ steps.prep.outputs.helper_dir }} + PROJECT: ${{ steps.prep.outputs.project }} + WC_SRC: ${{ steps.wc-int.outputs.src }} + # 45-min cap on the unbounded introspector analysis. We absorb the + # exit code so a timeout (124) or build failure doesn't red the + # step — Extract reachability handles missing output gracefully and + # render_comment renders ">45m" for a missing variant. Keeping the + # job-level timeout-minutes here would cancel the job on expiry, + # which propagates to the workflow conclusion despite + # continue-on-error and reds the PR. + run: | + set +e + if [ -n "$WC_SRC" ]; then + echo n | timeout 45m python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer introspector "$PROJECT" "$WC_SRC" + else + echo n | timeout 45m python3 "$HELPER_DIR/infra/helper.py" build_fuzzers \ + --sanitizer introspector "$PROJECT" + fi + rc=$? + echo "::notice::introspector build exited rc=$rc (124=timeout)" + exit 0 + + - name: Extract reachability + if: always() + env: + VARIANT: ${{ matrix.variant }} + OUT_BASE: ${{ steps.prep.outputs.out_base }} + PROJECT: ${{ steps.prep.outputs.project }} + run: | + mkdir -p "reachability-$VARIANT" + SRC="$OUT_BASE/$PROJECT/inspector/all-fuzz-introspector-functions.json" + if [ -f "$SRC" ]; then + cp "$SRC" "reachability-$VARIANT/reachability.json" + else + echo "::notice::No introspector output for $VARIANT — reachability unavailable" + fi + + - name: Upload reachability + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reachability-${{ matrix.variant }} + path: reachability-${{ matrix.variant }}/ + if-no-files-found: ignore + + report: + name: Report + needs: [measure, reachability] + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # post sticky fuzz-verify comment + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: .github/actions/post-fuzz-report + - uses: ./.github/actions/post-fuzz-report + with: + footer: ${{ inputs.footer }} + fuzz_seconds: ${{ inputs.fuzz_seconds }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/fuzz-verify.yml b/.github/workflows/fuzz-verify.yml new file mode 100644 index 000000000..3032670b2 --- /dev/null +++ b/.github/workflows/fuzz-verify.yml @@ -0,0 +1,15 @@ +# Injected by pr_server. Drop before forwarding upstream. +name: Fuzz Verify +on: [push] +jobs: + fuzz-verify: + uses: ./.github/workflows/_fuzz-verify.yml + permissions: + contents: read + pull-requests: write + with: + mode: oss-fuzz + project_name: "" + footer: generic + fuzz_seconds: "300" + secrets: inherit