diff --git a/.github/actions/collect-fuzz-stats/action.yml b/.github/actions/collect-fuzz-stats/action.yml new file mode 100644 index 0000000000..2548795902 --- /dev/null +++ b/.github/actions/collect-fuzz-stats/action.yml @@ -0,0 +1,62 @@ +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) + 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 + + echo "---- $OUT ----" + find "$OUT" -type f -printf '%p (%s bytes)\n' diff --git a/.github/actions/post-fuzz-report/__pycache__/render_comment.cpython-313.pyc b/.github/actions/post-fuzz-report/__pycache__/render_comment.cpython-313.pyc new file mode 100644 index 0000000000..badc84fe96 Binary files /dev/null and b/.github/actions/post-fuzz-report/__pycache__/render_comment.cpython-313.pyc differ diff --git a/.github/actions/post-fuzz-report/action.yml b/.github/actions/post-fuzz-report/action.yml new file mode 100644 index 0000000000..7952e882d7 --- /dev/null +++ b/.github/actions/post-fuzz-report/action.yml @@ -0,0 +1,65 @@ +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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: stats-baseline + path: stats/baseline + continue-on-error: true + + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: stats-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/render_comment.py b/.github/actions/post-fuzz-report/render_comment.py new file mode 100644 index 0000000000..a954f27c2c --- /dev/null +++ b/.github/actions/post-fuzz-report/render_comment.py @@ -0,0 +1,188 @@ +# 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 + +MARKER = "" + +FOOTER_GENERIC = ( + "Per-harness data from `report_target/<fuzzer>/linux/summary.json`. " + "Full HTML reports in the workflow artifacts.") +FOOTER_UPSTREAM = ("Same harness config applied to both sides " + "(baseline = base source + PR harness). " + + FOOTER_GENERIC[5:]) + + +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, + } + + +def _totals(summary): + if not summary: + return None + t = summary["data"][0]["totals"] + return { + "lines": + (t["lines"]["covered"], t["lines"]["count"], t["lines"]["percent"]), + "branches": ( + t["branches"]["covered"], + t["branches"]["count"], + t["branches"]["percent"], + ), + "functions": ( + t["functions"]["covered"], + t["functions"]["count"], + t["functions"]["percent"], + ), + } + + +def _fmt_cov(tot, key): + if not tot: + return "—" + cov, n, pct = tot[key] + return f"{pct:.1f}% ({cov}/{n})" + + +def _fmt_delta(b, a, key): + if not b and not a: + return "—" + if not b: + return "**new**" + if not a: + return "**removed**" + d = a[key][2] - b[key][2] + sign = "+" if d >= 0 else "" + return f"**{sign}{d:.1f} pp**" + + +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"]) + if bt or ct: + out += [ + "| Metric | Before | After | Delta |", + "|---|---|---|---|", + f"| Line coverage | {_fmt_cov(bt, 'lines')} | {_fmt_cov(ct, 'lines')} | {_fmt_delta(bt, ct, 'lines')} |", + f"| Branch coverage | {_fmt_cov(bt, 'branches')} | {_fmt_cov(ct, 'branches')} | {_fmt_delta(bt, ct, 'branches')} |", + f"| Function coverage | {_fmt_cov(bt, 'functions')} | {_fmt_cov(ct, 'functions')} | {_fmt_delta(bt, ct, 'functions')} |", + "", + ] + + 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')} |") + out.append("") + + if not (bt or ct or all_h): + 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/fuzz/Dockerfile b/.github/fuzz/Dockerfile new file mode 100755 index 0000000000..2e7580ba22 --- /dev/null +++ b/.github/fuzz/Dockerfile @@ -0,0 +1,23 @@ +# Copyright 2020 Google Inc. +# +# 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. +# +################################################################################ + +FROM gcr.io/oss-fuzz-base/base-builder +RUN apt-get update && apt-get install -y make autoconf automake libtool wget gettext automake libxml2-dev m4 pkg-config bison flex python3.8-venv libssl-dev zlib1g-dev libtool-bin +RUN git clone --depth 1 --single-branch --branch main https://github.com/dovecot/core dovecot +RUN git clone --depth 1 --single-branch --branch main https://github.com/dovecot/pigeonhole pigeonhole +COPY build.sh $SRC/ +COPY seeds/ $SRC/seeds/ +#COPY fuzz-* $SRC/ diff --git a/.github/fuzz/build.sh b/.github/fuzz/build.sh new file mode 100755 index 0000000000..e80a846361 --- /dev/null +++ b/.github/fuzz/build.sh @@ -0,0 +1,39 @@ +#!/bin/bash -eu +# Copyright 2020 Google Inc. +# +# 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. +# +################################################################################ +cd dovecot +# Patch ldflags +find . -name "Makefile.am" -exec sed -i -e 's,(FUZZER_LDFLAGS),(FUZZER_LDFLAGS) -static-libtool-libs,' {} \; +./autogen.sh +./configure PANDOC=false --with-fuzzer=clang --prefix=$OUT +make -j$(nproc) +# Copy over the fuzzers +find . -name "fuzz-*" -executable -exec libtool install install -m0755 {} $OUT/ \; +cd ../pigeonhole +# Fix typo in upstream: fuzz_suite_LDFLAG -> fuzz_suite_LDFLAGS +find . -name "Makefile.am" -exec sed -i -e 's,fuzz_suite_LDFLAG ,fuzz_suite_LDFLAGS ,' {} \; +find . -name "Makefile.am" -exec sed -i -e 's,(FUZZER_LDFLAGS),(FUZZER_LDFLAGS) -static-libtool-libs,' {} \; +./autogen.sh +./configure --with-dovecot=../dovecot --with-fuzzer=clang --prefix=$OUT +make -j$(nproc) +# Copy over the fuzzers +find . -name "fuzz-*" -executable -exec libtool install install -m0755 {} $OUT/ \; +# Package seed corpora +for harness in fuzz-message-address fuzz-imap-parser fuzz-imap-url; do + if [ -d "$SRC/seeds/$harness" ]; then + zip -j "$OUT/${harness}_seed_corpus.zip" "$SRC/seeds/$harness/"* + fi +done diff --git a/.github/fuzz/project.yaml b/.github/fuzz/project.yaml new file mode 100755 index 0000000000..3c71d55472 --- /dev/null +++ b/.github/fuzz/project.yaml @@ -0,0 +1,16 @@ +homepage: "https://www.dovecot.org/" +language: c +primary_contact: "oss-fuzz@open-xchange.com" +auto_ccs: + - "david@adalogics.com" + - "p.antoine@catenacyber.fr" + - "cmousefi@gmail.com" + - "boschstephan@gmail.com" + - "timo.sirainen@gmail.com" +main_repo: 'https://github.com/dovecot/core' + +fuzzing_engines: + - afl + - honggfuzz + - libfuzzer + diff --git a/.github/fuzz/seeds/fuzz-imap-parser/append b/.github/fuzz/seeds/fuzz-imap-parser/append new file mode 100644 index 0000000000..e7e21dc13f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/append @@ -0,0 +1,4 @@ +A001 APPEND INBOX (\Seen) {10} +Hello + + diff --git a/.github/fuzz/seeds/fuzz-imap-parser/fetch b/.github/fuzz/seeds/fuzz-imap-parser/fetch new file mode 100644 index 0000000000..cbd8128b0f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/fetch @@ -0,0 +1 @@ +A001 FETCH 1:10 (FLAGS BODY[HEADER.FIELDS (FROM TO SUBJECT)]) diff --git a/.github/fuzz/seeds/fuzz-imap-parser/login b/.github/fuzz/seeds/fuzz-imap-parser/login new file mode 100644 index 0000000000..030f275e46 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/login @@ -0,0 +1 @@ +A001 LOGIN user password diff --git a/.github/fuzz/seeds/fuzz-imap-parser/search b/.github/fuzz/seeds/fuzz-imap-parser/search new file mode 100644 index 0000000000..b86e269b5f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/search @@ -0,0 +1 @@ +A001 SEARCH HEADER FROM user@example.com SINCE 1-Jan-2020 diff --git a/.github/fuzz/seeds/fuzz-imap-parser/select b/.github/fuzz/seeds/fuzz-imap-parser/select new file mode 100644 index 0000000000..747649f1e8 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/select @@ -0,0 +1 @@ +A001 SELECT INBOX diff --git a/.github/fuzz/seeds/fuzz-imap-parser/uid_fetch b/.github/fuzz/seeds/fuzz-imap-parser/uid_fetch new file mode 100644 index 0000000000..f1d6256975 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-parser/uid_fetch @@ -0,0 +1 @@ +A001 UID FETCH 1234 (BODY.PEEK[]) diff --git a/.github/fuzz/seeds/fuzz-imap-url/absolute b/.github/fuzz/seeds/fuzz-imap-url/absolute new file mode 100644 index 0000000000..1e68bd2389 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/absolute @@ -0,0 +1 @@ +imap://user@example.com/INBOX \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/auth b/.github/fuzz/seeds/fuzz-imap-url/auth new file mode 100644 index 0000000000..de5b3058f8 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/auth @@ -0,0 +1 @@ +imap://user;AUTH=GSSAPI@example.com/ \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/relative b/.github/fuzz/seeds/fuzz-imap-url/relative new file mode 100644 index 0000000000..69de78fa04 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/relative @@ -0,0 +1 @@ +/INBOX/;UID=1/;SECTION=TEXT \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/section b/.github/fuzz/seeds/fuzz-imap-url/section new file mode 100644 index 0000000000..d0c4ae934a --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/section @@ -0,0 +1 @@ +imap://user@example.com/INBOX;UIDVALIDITY=12345/;UID=5/;SECTION=1.2 \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-imap-url/uid b/.github/fuzz/seeds/fuzz-imap-url/uid new file mode 100644 index 0000000000..eaf7626fc9 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-imap-url/uid @@ -0,0 +1 @@ +imap://example.com/INBOX/;UID=1 \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/display_name b/.github/fuzz/seeds/fuzz-message-address/display_name new file mode 100644 index 0000000000..aba6e25598 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/display_name @@ -0,0 +1 @@ +"User Name" \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/group b/.github/fuzz/seeds/fuzz-message-address/group new file mode 100644 index 0000000000..90faedb278 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/group @@ -0,0 +1 @@ +Group: user1@a.com, user2@b.com; \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/list b/.github/fuzz/seeds/fuzz-message-address/list new file mode 100644 index 0000000000..cbc9ee907f --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/list @@ -0,0 +1 @@ +user1@a.com, user2@b.com, user3@c.com \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/quoted b/.github/fuzz/seeds/fuzz-message-address/quoted new file mode 100644 index 0000000000..378bc577eb --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/quoted @@ -0,0 +1 @@ +"User "Quoted" Name" \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/route b/.github/fuzz/seeds/fuzz-message-address/route new file mode 100644 index 0000000000..c86fcba620 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/route @@ -0,0 +1 @@ +<@route:user@example.com> \ No newline at end of file diff --git a/.github/fuzz/seeds/fuzz-message-address/simple b/.github/fuzz/seeds/fuzz-message-address/simple new file mode 100644 index 0000000000..7046a5bbe8 --- /dev/null +++ b/.github/fuzz/seeds/fuzz-message-address/simple @@ -0,0 +1 @@ +user@example.com \ No newline at end of file diff --git a/.github/workflows/fuzz-verify.yml b/.github/workflows/fuzz-verify.yml new file mode 100644 index 0000000000..2bf7b1f4fc --- /dev/null +++ b/.github/workflows/fuzz-verify.yml @@ -0,0 +1,186 @@ +# Template — injected into upstream repo fork branches by pr_server.py at PR submission time. +# Not used directly in this repo. Injected alongside .github/actions/{collect-fuzz-stats,post-fuzz-report}/. +name: Fuzz Verify +on: [push] + +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 + +env: + PROJECT: dovecot + FUZZ_SECONDS: "300" + +jobs: + measure: + name: Measure (${{ matrix.variant }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + continue-on-error: ${{ matrix.variant == 'baseline' }} + strategy: + fail-fast: false + matrix: + variant: [baseline, current] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + persist-credentials: false + submodules: recursive + + - name: Resolve variant + id: variant + env: + VARIANT: ${{ matrix.variant }} + run: | + if [ "$VARIANT" = "baseline" ]; then + git remote set-head origin -a >/dev/null 2>&1 || true + DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|origin/||') + [ -z "$DEFAULT_BRANCH" ] && DEFAULT_BRANCH=main + if ! git rev-parse --verify "origin/$DEFAULT_BRANCH" >/dev/null 2>&1; then + DEFAULT_BRANCH=master + fi + BASE_SHA=$(git merge-base "origin/$DEFAULT_BRANCH" HEAD) + echo "sha=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "Baseline: $BASE_SHA (merge-base with origin/$DEFAULT_BRANCH)" + + if [ ! -d .github/fuzz ]; then + echo "::error::.github/fuzz not present on PR — cannot overlay onto baseline" + echo "has_project=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + FUZZ_TMP=$(mktemp -d) + cp -r .github/fuzz "$FUZZ_TMP/" + ACTIONS_TMP=$(mktemp -d) + cp -r .github/actions "$ACTIONS_TMP/" + git reset --hard "$BASE_SHA" + git submodule update --init --recursive + rm -rf .github/fuzz .github/actions + mkdir -p .github + cp -r "$FUZZ_TMP/fuzz" .github/ + cp -r "$ACTIONS_TMP/actions" .github/ + echo "has_project=true" >> "$GITHUB_OUTPUT" + else + echo "sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "has_project=true" >> "$GITHUB_OUTPUT" + echo "Current: $GITHUB_SHA" + fi + + - name: Clone oss-fuzz + if: steps.variant.outputs.has_project == 'true' + run: git clone --depth 1 https://github.com/google/oss-fuzz.git /tmp/oss-fuzz + + - name: Install oss-fuzz deps + if: steps.variant.outputs.has_project == 'true' + run: pip install -r /tmp/oss-fuzz/infra/ci/requirements.txt 2>/dev/null || pip install docker + + - name: Set up project + if: steps.variant.outputs.has_project == 'true' + run: | + mkdir -p "/tmp/oss-fuzz/projects/$PROJECT" + cp -r .github/fuzz/* "/tmp/oss-fuzz/projects/$PROJECT/" + + - name: Build image + if: steps.variant.outputs.has_project == 'true' + run: echo n | python3 /tmp/oss-fuzz/infra/helper.py build_image "$PROJECT" + + - name: Build fuzzers + if: steps.variant.outputs.has_project == 'true' + run: echo n | python3 /tmp/oss-fuzz/infra/helper.py build_fuzzers --sanitizer address "$PROJECT" "$GITHUB_WORKSPACE" + + - name: Fuzz + if: steps.variant.outputs.has_project == 'true' + run: | + OUT="/tmp/oss-fuzz/build/out/$PROJECT" + + HARNESSES="" + for f in "$OUT"/*; do + [ -f "$f" ] && [ -x "$f" ] || continue + case "$(basename "$f")" in + *.options|*.dict|*_seed_corpus.zip|llvm-symbolizer) continue ;; + esac + HARNESSES="$HARNESSES $(basename "$f")" + done + HARNESSES=$(echo $HARNESSES | xargs) + + if [ -z "$HARNESSES" ]; then + echo "::error::No harness binaries found in $OUT" + exit 1 + fi + + N=$(echo $HARNESSES | wc -w) + TIME_EACH=$(( FUZZ_SECONDS / N )) + [ "$TIME_EACH" -lt 30 ] && TIME_EACH=30 + echo "Harnesses ($N): $HARNESSES — ${TIME_EACH}s each" + + for h in $HARNESSES; do + echo "::group::$h" + mkdir -p "/tmp/oss-fuzz/corpus/$PROJECT/$h" + timeout $((TIME_EACH + 60)) python3 /tmp/oss-fuzz/infra/helper.py run_fuzzer \ + --corpus-dir "/tmp/oss-fuzz/corpus/$PROJECT/$h" \ + "$PROJECT" "$h" -- \ + "-max_total_time=$TIME_EACH" 2>&1 || true + echo "Corpus: $(find "/tmp/oss-fuzz/corpus/$PROJECT/$h" -type f | wc -l) files" + echo "::endgroup::" + done + + - name: Coverage + if: steps.variant.outputs.has_project == 'true' + run: | + echo n | python3 /tmp/oss-fuzz/infra/helper.py build_fuzzers --sanitizer coverage "$PROJECT" "$GITHUB_WORKSPACE" + + mkdir -p "/tmp/oss-fuzz/build/corpus" + ln -sfn "/tmp/oss-fuzz/corpus/$PROJECT" "/tmp/oss-fuzz/build/corpus/$PROJECT" + + python3 /tmp/oss-fuzz/infra/helper.py coverage \ + --no-corpus-download --no-serve \ + "$PROJECT" 2>&1 || true + + - name: Collect stats + if: always() + uses: ./.github/actions/collect-fuzz-stats + with: + project: ${{ env.PROJECT }} + variant: ${{ matrix.variant }} + sha: ${{ steps.variant.outputs.sha }} + has_project: ${{ steps.variant.outputs.has_project }} + out_base: /tmp/oss-fuzz/build/out + + - name: Upload stats + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: stats-${{ matrix.variant }} + path: stats-${{ matrix.variant }}/ + if-no-files-found: error + + - name: Upload coverage report + if: always() && steps.variant.outputs.has_project == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-report-${{ matrix.variant }} + path: /tmp/oss-fuzz/build/out/*coverage*/report/ + if-no-files-found: ignore + + report: + name: Report + needs: [measure] + if: always() + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write # post sticky fuzz-verify comment + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + sparse-checkout: .github/actions/post-fuzz-report + - uses: ./.github/actions/post-fuzz-report + with: + footer: upstream + fuzz_seconds: ${{ env.FUZZ_SECONDS }} + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/lib-imap/Makefile.am b/src/lib-imap/Makefile.am index 0aee66785c..151396ba43 100644 --- a/src/lib-imap/Makefile.am +++ b/src/lib-imap/Makefile.am @@ -109,7 +109,9 @@ test_imap_util_DEPENDENCIES = $(test_deps) if USE_FUZZER noinst_PROGRAMS += \ fuzz-imap-utf7 \ - fuzz-imap-bodystructure + fuzz-imap-bodystructure \ + fuzz-imap-parser \ + fuzz-imap-url nodist_EXTRA_fuzz_imap_utf7_SOURCES = force-cxx-linking.cxx fuzz_imap_utf7_SOURCES = fuzz-imap-utf7.c @@ -125,5 +127,18 @@ fuzz_imap_bodystructure_LDFLAGS = $(FUZZER_LDFLAGS) fuzz_imap_bodystructure_LDADD = libimap.la ../lib-mail/libmail.la $(test_libs) fuzz_imap_bodystructure_DEPENDENCIES = libimap.la $(test_deps) ../lib-mail/libmail.la +nodist_EXTRA_fuzz_imap_parser_SOURCES = force-cxx-linking.cxx +fuzz_imap_parser_SOURCES = fuzz-imap-parser.c +fuzz_imap_parser_CPPFLAGS = $(FUZZER_CPPFLAGS) +fuzz_imap_parser_LDFLAGS = $(FUZZER_LDFLAGS) +fuzz_imap_parser_LDADD = libimap.la $(test_libs) +fuzz_imap_parser_DEPENDENCIES = libimap.la $(test_deps) + +nodist_EXTRA_fuzz_imap_url_SOURCES = force-cxx-linking.cxx +fuzz_imap_url_SOURCES = fuzz-imap-url.c +fuzz_imap_url_CPPFLAGS = $(FUZZER_CPPFLAGS) +fuzz_imap_url_LDFLAGS = $(FUZZER_LDFLAGS) +fuzz_imap_url_LDADD = libimap.la $(test_libs) +fuzz_imap_url_DEPENDENCIES = libimap.la $(test_deps) endif diff --git a/src/lib-imap/fuzz-imap-parser.c b/src/lib-imap/fuzz-imap-parser.c new file mode 100644 index 0000000000..5705952c2c --- /dev/null +++ b/src/lib-imap/fuzz-imap-parser.c @@ -0,0 +1,38 @@ +/* Copyright (c) 2026 Dovecot authors, see the included COPYING file */ + +#include "lib.h" +#include "istream.h" +#include "test-common.h" +#include "fuzzer.h" +#include "imap-parser.h" + +FUZZ_BEGIN_DATA(const unsigned char *data, size_t size) +{ + struct istream *input = test_istream_create_data(data, size); + struct imap_parser *parser = + imap_parser_create(input, NULL, 65536, NULL); + const struct imap_arg *args; + const char *word; + int ret; + + i_stream_read(input); + + /* Try to parse as a full IMAP client command: tag SP cmd [args] CRLF */ + ret = imap_parser_read_tag(parser, &word); + if (ret == 1) { + ret = imap_parser_read_command_name(parser, &word); + if (ret == 1) + (void)imap_parser_finish_line(parser, 0, 0, &args); + } + + /* Also parse the raw data as a flat argument list with permissive flags */ + imap_parser_reset(parser); + i_stream_seek(input, 0); + i_stream_read(input); + (void)imap_parser_finish_line(parser, 0, + IMAP_PARSE_FLAG_ATOM_ALLCHARS, &args); + + imap_parser_unref(&parser); + i_stream_unref(&input); +} +FUZZ_END diff --git a/src/lib-imap/fuzz-imap-url.c b/src/lib-imap/fuzz-imap-url.c new file mode 100644 index 0000000000..02bb57cf32 --- /dev/null +++ b/src/lib-imap/fuzz-imap-url.c @@ -0,0 +1,25 @@ +/* Copyright (c) 2026 Dovecot authors, see the included COPYING file */ + +#include "lib.h" +#include "fuzzer.h" +#include "imap-url.h" + +FUZZ_BEGIN_STR(const char *input) +{ + struct imap_url *url; + const char *error; + + /* Parse as absolute IMAP URL; if valid, regenerate and check round-trip */ + if (imap_url_parse(input, NULL, IMAP_URL_PARSE_ALLOW_URLAUTH, + &url, &error) == 0) + (void)imap_url_create(url); + + /* Parse as relative URL (mailbox path, uid, section etc.) */ + const struct imap_url base = { + .host = { .name = "example.com" }, + .mailbox = "INBOX", + }; + (void)imap_url_parse(input, &base, + IMAP_URL_PARSE_REQUIRE_RELATIVE, &url, &error); +} +FUZZ_END diff --git a/src/lib-mail/Makefile.am b/src/lib-mail/Makefile.am index c02b338817..830d8c4f32 100644 --- a/src/lib-mail/Makefile.am +++ b/src/lib-mail/Makefile.am @@ -127,7 +127,8 @@ if USE_FUZZER fuzz_programs += fuzz-message-parser \ fuzz-qp-decoder \ fuzz-message-date \ - fuzz-message-decoder + fuzz-message-decoder \ + fuzz-message-address nodist_EXTRA_fuzz_message_parser_SOURCES = force-cxx-linking.cxx @@ -161,6 +162,14 @@ fuzz_message_decoder_SOURCES = fuzz-message-decoder.c fuzz_message_decoder_LDADD = $(test_libs) fuzz_message_decoder_DEPENDENCIES = $(test_deps) +nodist_EXTRA_fuzz_message_address_SOURCES = force-cxx-linking.cxx + +fuzz_message_address_CPPFLAGS = $(FUZZER_CPPFLAGS) +fuzz_message_address_LDFLAGS = $(FUZZER_LDFLAGS) +fuzz_message_address_SOURCES = fuzz-message-address.c +fuzz_message_address_LDADD = $(test_libs) +fuzz_message_address_DEPENDENCIES = $(test_deps) + endif noinst_PROGRAMS += $(fuzz_programs) diff --git a/src/lib-mail/fuzz-message-address.c b/src/lib-mail/fuzz-message-address.c new file mode 100644 index 0000000000..e518901e79 --- /dev/null +++ b/src/lib-mail/fuzz-message-address.c @@ -0,0 +1,29 @@ +/* Copyright (c) 2026 Dovecot authors, see the included COPYING file */ + +#include "lib.h" +#include "str.h" +#include "fuzzer.h" +#include "message-address.h" + +FUZZ_BEGIN_DATA(const unsigned char *data, size_t size) +{ + struct message_address *addr; + string_t *str; + + /* Parse as address list and roundtrip through write */ + addr = message_address_parse(pool_datastack_create(), data, size, + UINT_MAX, 0); + if (addr != NULL) { + str = t_str_new(128); + message_address_write(str, addr); + /* Re-parse the written output: must not crash */ + (void)message_address_parse(pool_datastack_create(), + (const unsigned char *)str_c(str), str_len(str), + UINT_MAX, 0); + } + + /* Also exercise Return-Path / path parsing */ + (void)message_address_parse_path(pool_datastack_create(), data, size, + &addr); +} +FUZZ_END