Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/fuzz-nightly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Nightly fuzzing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR was opened I made a main.yml job which runs daily against main, I think this might make sense in there? But on the other hand it would then be rolled in to a top level status, your choice.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If fuzzing fails, it will be clear that it's the "Fuzzing" job that failed, not the "Main" job, that's why I think they should be separate. Also, I prefer to work with smaller files... :)


on:
schedule:
# 03:00 UTC daily.
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
durations:
description: 'Per-target fuzz durations, as <target>=<seconds> pairs'
required: false
default: 'FuzzQUICPackets=2400 FuzzTransportParameters=600'

permissions:
contents: read

jobs:
fuzz:
name: Fuzz
uses: ./.github/workflows/fuzz.yml
with:
durations: ${{ inputs.durations || 'FuzzQUICPackets=2400 FuzzTransportParameters=600' }}
persist_corpus: true
timeout_minutes: 120
crash_retention_days: 90
106 changes: 106 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
name: Fuzz

# Reusable fuzzing job, called by pull_request.yml (short smoke run) and
# fuzz-nightly.yml (long run with a persisted corpus). The inputs cover
# everything that differs between the two, so the build/run/upload steps only
# exist here.

on:
workflow_call:
inputs:
durations:
description: 'Per-target fuzz durations, as <target>=<seconds> pairs'
required: true
type: string
persist_corpus:
description: 'Carry the corpus across runs via actions/cache, so coverage accumulates'
required: false
default: false
type: boolean
timeout_minutes:
description: 'Job timeout in minutes; must comfortably exceed the sum of the durations'
required: false
default: 30
type: number
crash_retention_days:
description: 'How long to keep uploaded crash reproducers'
required: false
default: 14
type: number

permissions:
contents: read

jobs:
fuzz:
name: Fuzzing
runs-on: ubuntu-latest
container:
image: swift:6.3
timeout-minutes: ${{ inputs.timeout_minutes }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false

- name: Swift version
run: swift --version

# No exact-key hit on a scheduled run (the key includes github.run_id,
# always new), so this falls through to restore-keys and picks up the most
# recent previous corpus. First run, or after eviction, is simply a no-op
# and the fuzzers start from empty.
- name: Restore fuzz corpus cache
if: inputs.persist_corpus
uses: actions/cache/restore@v4
with:
path: fuzz-corpus
key: fuzz-corpus-${{ github.run_id }}
restore-keys: |
fuzz-corpus-

- name: Build fuzz targets (release)
# Build *only* the two fuzz products. The Fuzzing trait applies the
# sanitizer flags package-wide, and linking the instrumented
# command-line tools trips an ld.gold bug ("internal error in
# format_file_lineno, at ../../gold/dwarf_reader.cc"). The fuzzers
# themselves link fine, and the tools aren't needed here anyway.
#
# DisableDebugLogging/DisableErrorLogging keep the library quiet: every
# rejected input otherwise logs, which buries the libFuzzer output and
# costs throughput. Crash diagnosis doesn't need it - the stack trace
# comes from libFuzzer/ASan, and a saved reproducer can be replayed
# locally with logging left on.
run: |
for product in FuzzQUICPackets FuzzTransportParameters; do
swift build --configuration release --traits Fuzzing,DisableDebugLogging,DisableErrorLogging --product "$product"
done

- name: Fuzz
# Unquoted on purpose: the spec is several space-separated arguments.
run: bash .github/workflows/scripts/ci-fuzz.sh ${{ inputs.durations }}

# Runs even though the Fuzz step failed (i.e. found a crash/oom/timeout),
# so the failing input can be downloaded and replayed locally.
- name: Upload crash artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes-${{ github.run_id }}
path: |
fuzz-corpus/*/crash-*
fuzz-corpus/*/oom-*
fuzz-corpus/*/timeout-*
if-no-files-found: ignore
retention-days: ${{ inputs.crash_retention_days }}

# Always re-save, even on failure, so the next run keeps this run's
# coverage gains and the crashing input stays in the corpus as a
# regression check once it's fixed.
- name: Save fuzz corpus cache
if: always() && inputs.persist_corpus
uses: actions/cache/save@v4
with:
path: fuzz-corpus
key: fuzz-corpus-${{ github.run_id }}
12 changes: 12 additions & 0 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,15 @@ jobs:
with:
license_header_check_project_name: "Swift"
api_breakage_check_container_image: "swiftlang/swift:nightly-6.3-jammy"
fuzz-smoke:
# Quick smoke check that the fuzz targets still build and don't crash
# immediately - not a substitute for the much longer, corpus-persisting
# nightly fuzzing run (see fuzz-nightly.yml).
#
# Named "Test" so the check reads "Pull request / Test / Fuzzing": GitHub
# composes the name from the workflow name, this caller job's name, and the
# job name inside the called workflow.
name: Test
uses: ./.github/workflows/fuzz.yml
with:
durations: 'FuzzQUICPackets=200 FuzzTransportParameters=10'
101 changes: 101 additions & 0 deletions .github/workflows/scripts/ci-fuzz.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/bin/bash
##===----------------------------------------------------------------------===##
##
## This source file is part of the Swift open source project
##
## Copyright (c) 2026 Apple Inc. and the Swift project authors
## Licensed under Apache License v2.0
##
## See LICENSE.txt for license information
## See CONTRIBUTORS.txt for the list of Swift project authors
##
## SPDX-License-Identifier: Apache-2.0
##
##===----------------------------------------------------------------------===##

# Runs the fuzz targets, each for its own duration. Shared by two workflows:
# fuzz-nightly.yml calls this with long durations and persists the corpus across
# runs via actions/cache, for deep, cumulative fuzzing; pull_request.yml's
# fuzz-smoke job calls this with short durations and no persisted corpus, as a
# quick per-PR smoke check that the fuzz targets still build and don't crash
# immediately.
#
# Durations are per target rather than shared because the targets cover very
# different amounts of code: FuzzQUICPackets reaches the whole connection and
# frame-processing machinery and keeps finding coverage for a long time, while
# FuzzTransportParameters exercises one deserializer and saturates quickly, so
# extra time there buys little.
#
# Each target gets its own corpus subdirectory under FUZZ_CORPUS_DIR, which the
# calling workflow persists across runs (e.g. via actions/cache) so coverage
# accumulates day over day instead of restarting from empty every time.
# libFuzzer writes any crash-*/oom-*/timeout-* artifact directly into that same
# subdirectory (via -artifact_prefix), so the workflow can find and upload them
# after this script exits, regardless of whether it exits 0 or non-zero.
#
# Every target always runs, even if an earlier one finds a crash - this script
# only reports the overall failure (via ci_finish's exit code) once they have
# all had a chance to run, matching ci-linux.sh's "run every step" philosophy.
#
# Usage: ci-fuzz.sh <target>=<seconds> [<target>=<seconds> ...]
#
# e.g. ci-fuzz.sh FuzzQUICPackets=120 FuzzTransportParameters=30
#
# Env:
# FUZZ_CORPUS_DIR - where the persisted corpus/crash artifacts live
# (default: fuzz-corpus)

set -u

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=ci-support.sh
. "${script_dir}/ci-support.sh"

corpus_root="${FUZZ_CORPUS_DIR:-fuzz-corpus}"

usage() {
echo "usage: $(basename "$0") <target>=<seconds> [<target>=<seconds> ...]" >&2
echo " e.g. $(basename "$0") FuzzQUICPackets=120 FuzzTransportParameters=30" >&2
exit 2
}

fuzz_target() {
local binary="$1"
local seconds="$2"
local corpus_dir="${corpus_root}/${binary}"
mkdir -p "${corpus_dir}"
ci_run "Fuzz ${binary} (${seconds}s)" \
".build/release/${binary}" \
-max_total_time="${seconds}" \
-artifact_prefix="${corpus_dir}/" \
"${corpus_dir}"
}

[ "$#" -gt 0 ] || usage

# Validate everything up front, so a typo fails immediately instead of after
# the first target has already fuzzed for several minutes.
for spec in "$@"; do
case "${spec}" in
*=*) ;;
*) echo "error: expected <target>=<seconds>, got '${spec}'" >&2; usage ;;
esac
binary="${spec%%=*}"
seconds="${spec##*=}"
case "${seconds}" in
'' | *[!0-9]*)
echo "error: '${seconds}' is not a whole number of seconds, in '${spec}'" >&2
usage
;;
esac
if [ ! -x ".build/release/${binary}" ]; then
echo "error: .build/release/${binary} is missing or not executable" >&2
exit 2
fi
done

for spec in "$@"; do
fuzz_target "${spec%%=*}" "${spec##*=}"
done

ci_finish
53 changes: 43 additions & 10 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription
import Foundation

// Availability Macros

Expand Down Expand Up @@ -38,6 +37,9 @@ let allApplePlatforms: [Platform] = [
.driverKit, .iOS, .macCatalyst, .macOS, .tvOS, .visionOS, .watchOS,
]

let swiftFuzzFlags: [String] = ["-sanitize=fuzzer,address"]
let fuzzBuildCondition: BuildSettingCondition = .when(traits: ["Fuzzing"])

// Logging levels, qlog output, and QUIC signposts are configured via package
// traits. See the `traits:` list on the `Package(...)` initializer below.
let settings: [SwiftSetting] = [
Expand All @@ -50,6 +52,7 @@ let settings: [SwiftSetting] = [
.enableExperimentalFeature("Lifetimes"),
.enableExperimentalFeature("AnyAppleOSAvailability"),
.enableUpcomingFeature("ExistentialAny"),
.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition),
]

let package = Package(
Expand Down Expand Up @@ -93,6 +96,11 @@ let package = Package(
name: "DISABLE_SHIM_CRYPTO_SPAN_APIS",
description: "Disable backwards compatible crypto shim for performance sensitive cases"
),
.trait(
name: "Fuzzing",
description:
"Builds with fuzzer and address sanitizer instrumentation, for use with fuzzing"
),
.default(enabledTraits: []),
],
dependencies: [
Expand All @@ -113,7 +121,10 @@ let package = Package(
.product(name: "CryptoExtras", package: "swift-crypto"),
.product(name: "SwiftTLS", package: "swift-tls"),
],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings + [
// Fuzzers need testable imports.
.unsafeFlags(["-enable-testing"], fuzzBuildCondition)
]
),
.target(
name: "SwiftNetworkLinuxShim",
Expand Down Expand Up @@ -147,53 +158,75 @@ let package = Package(
.testTarget(
name: "SwiftNetworkTests",
dependencies: ["SwiftNetwork", "SwiftNetworkTestHarness"],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.testTarget(
name: "QUICTests",
dependencies: ["SwiftNetwork", "SwiftNetworkTestHarness"],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "QUICHandshake",
dependencies: ["SwiftNetwork", "SwiftNetworkBenchmarks", "SwiftNetworkTestHarness"],
path: "Sources/Tools/QUICHandshake",
exclude: ["README.md"],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "IPUDPTransfer",
dependencies: ["SwiftNetwork", "SwiftNetworkBenchmarks", "SwiftNetworkTestHarness"],
path: "Sources/Tools/IPUDPTransfer",
exclude: ["README.md"],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "QUICTransfer",
dependencies: ["SwiftNetwork", "SwiftNetworkBenchmarks", "SwiftNetworkTestHarness"],
path: "Sources/Tools/QUICTransfer",
exclude: ["README.md"],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "QUICStreamLoad",
dependencies: ["SwiftNetwork", "SwiftNetworkBenchmarks", "SwiftNetworkTestHarness"],
path: "Sources/Tools/QUICStreamLoad",
exclude: ["README.md"],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "SocketTransfer",
dependencies: ["SwiftNetwork", "SwiftNetworkBenchmarks"],
path: "Sources/Tools/SocketTransfer",
exclude: ["README.md"],
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "DeserializerBenchmark",
dependencies: ["SwiftNetwork", "SwiftNetworkBenchmarks"],
path: "Sources/Tools/DeserializerBenchmark",
swiftSettings: availabilityMacros + settings
swiftSettings: availabilityMacros + settings,
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "FuzzTransportParameters",
dependencies: ["SwiftNetwork"],
path: "Tests/Fuzzers/TransportParameters",
swiftSettings: availabilityMacros + settings + [.unsafeFlags(["-parse-as-library"])],
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
.executableTarget(
name: "FuzzQUICPackets",
dependencies: ["SwiftNetwork"],
path: "Tests/Fuzzers/QUICPackets",
swiftSettings: availabilityMacros + settings + [.unsafeFlags(["-parse-as-library"])],
linkerSettings: [.unsafeFlags(swiftFuzzFlags, fuzzBuildCondition)]
),
]
)
Loading
Loading