diff --git a/.github/workflows/fuzz-nightly.yml b/.github/workflows/fuzz-nightly.yml new file mode 100644 index 00000000..7a4c2336 --- /dev/null +++ b/.github/workflows/fuzz-nightly.yml @@ -0,0 +1,25 @@ +name: Nightly fuzzing + +on: + schedule: + # 03:00 UTC daily. + - cron: '0 3 * * *' + workflow_dispatch: + inputs: + durations: + description: 'Per-target fuzz durations, as = 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 diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..6176a934 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -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 = 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 }} diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 86fbbe84..40e5d7ac 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -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' diff --git a/.github/workflows/scripts/ci-fuzz.sh b/.github/workflows/scripts/ci-fuzz.sh new file mode 100644 index 00000000..474fe2bf --- /dev/null +++ b/.github/workflows/scripts/ci-fuzz.sh @@ -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 = [= ...] +# +# 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") = [= ...]" >&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 =, 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 diff --git a/Package.swift b/Package.swift index c886ceed..3bfe1b30 100644 --- a/Package.swift +++ b/Package.swift @@ -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 @@ -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] = [ @@ -50,6 +52,7 @@ let settings: [SwiftSetting] = [ .enableExperimentalFeature("Lifetimes"), .enableExperimentalFeature("AnyAppleOSAvailability"), .enableUpcomingFeature("ExistentialAny"), + .unsafeFlags(swiftFuzzFlags, fuzzBuildCondition), ] let package = Package( @@ -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: [ @@ -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", @@ -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)] ), ] ) diff --git a/Sources/SwiftNetwork/QUIC/Protector.swift b/Sources/SwiftNetwork/QUIC/Protector.swift index 649f5cbe..922479b7 100644 --- a/Sources/SwiftNetwork/QUIC/Protector.swift +++ b/Sources/SwiftNetwork/QUIC/Protector.swift @@ -157,9 +157,12 @@ enum TLSCipherSuite: CaseIterable { @available(Network 0.1.0, *) struct SecFramerKeys: ~Copyable { - enum KeyType { + enum KeyType: Equatable { case aesGCM case chaChaPoly + #if Fuzzing + case null + #endif } let key: SymmetricKey let iv: ProtectorIV @@ -240,6 +243,56 @@ protocol SecFramerProtocol: ~Copyable { ) throws(QUICError) } +#if Fuzzing +// A no-op packet protector used only by fuzz targets. +@available(Network 0.1.0, *) +struct SecFramerNull: ~Copyable, SecFramerProtocol { + static func createKeyStorage( + key: SymmetricKey, + iv: ProtectorIV, + headerProtectionKey: SymmetricKey, + savedWriteSecret: SymmetricKey? = nil, + savedReadSecret: SymmetricKey? = nil, + log: LogPrefixer + ) -> SecFramerKeys { + SecFramerKeys( + key: key, + iv: iv, + headerProtectionKey: headerProtectionKey, + savedWriteSecret: savedWriteSecret, + savedReadSecret: savedReadSecret, + type: .null, + log: log + ) + } + + static func seal( + keys: borrowing SecFramerKeys, + nonce: ProtectorNonce, + packet: inout Packet, + frame: inout Frame + ) throws(QUICError) { + } + + static func open( + keys: borrowing SecFramerKeys, + nonce: ProtectorNonce, + packet: inout Packet, + frame: inout Frame + ) throws(QUICError) { + } + + static func headerProtection( + keys: borrowing SecFramerKeys, + packet: inout Packet, + frame: inout Frame, + mask: inout MutableRawSpan, + loggingOperation: StaticString + ) throws(QUICError) { + } +} +#endif + @available(Network 0.1.0, *) struct SecFramerAESGCM: ~Copyable, SecFramerProtocol { @@ -916,6 +969,10 @@ struct Protector: ~Copyable, PrefixedLoggable { #else throw (.protector(.unsupportedAlgorithm)) #endif + #if Fuzzing + case .null: + break + #endif } } @@ -967,6 +1024,21 @@ struct Protector: ~Copyable, PrefixedLoggable { #else throw (.protector(.unsupportedAlgorithm)) #endif + #if Fuzzing + case .null: + var maskSpan = mask.mutableSpan + var maskRawSpan = maskSpan.mutableBytes + try SecFramerNull.headerProtection( + keys: keys, + packet: &packet, + frame: &frame, + mask: &maskRawSpan, + loggingOperation: "open" + ) + // The mask stays zeroed, so this leaves the header untouched: + // fuzzer input is treated as already-plaintext. + Protector.processHeaderProtection(packet: &packet, frame: &frame, mask: mask.span.bytes) + #endif } } @@ -1000,6 +1072,11 @@ struct Protector: ~Copyable, PrefixedLoggable { #else throw (.protector(.unsupportedAlgorithm)) #endif + #if Fuzzing + case .null: + let nonce = prepareNonce(iv: keys.iv, packetNumber: packet.number) + try SecFramerNull.open(keys: keys, nonce: nonce, packet: &packet, frame: &frame) + #endif } } @@ -1141,6 +1218,12 @@ struct Protector: ~Copyable, PrefixedLoggable { isWrite ? savedWriteSecret! : savedReadSecret! keySize = key.bitCount / 8 ivSize = iv.count + #if Fuzzing + case .null: + // Null protector keys are never phase0/phase1 traffic secrets, + // so key-phase rotation should never be triggered for them. + fatalError("trafficUpdate is unreachable for a null protector") + #endif } let keyUpdateSecret: SymmetricKey let key: SymmetricKey @@ -1221,6 +1304,10 @@ struct Protector: ~Copyable, PrefixedLoggable { nextFramer.savedReadSecret = keyUpdateSecret readFramer[nextKeyState.rawValue] = nextFramer } + #if Fuzzing + case .null: + fatalError("trafficUpdate is unreachable for a null protector") + #endif } } @@ -1281,6 +1368,22 @@ struct Protector: ~Copyable, PrefixedLoggable { keys.type } + #if Fuzzing + // Installs a no-op protector for the given key state so incoming packets + // are treated as already-plaintext instead of being rejected/queued for + // missing keys. Only usable by fuzz targets; never available in + // production builds. Only the read side is installed since fuzzing only + // exercises the receive path. + mutating func installNullProtector(for keyState: PacketKeyState) { + readFramer[keyState.rawValue] = SecFramerNull.createKeyStorage( + key: SymmetricKey(data: []), + iv: ProtectorIV(repeating: 0), + headerProtectionKey: SymmetricKey(data: []), + log: log + ) + } + #endif + mutating func drop(keyState: PacketKeyState) { log.info("Dropping keys for state: \(keyState.description)") let readType: SecFramerKeys.KeyType = keyType(keys: readFramer[keyState.rawValue]) @@ -1312,7 +1415,12 @@ struct Protector: ~Copyable, PrefixedLoggable { @inline(always) func getTagSize(for keyState: PacketKeyState?) -> UInt8 { - 16 + #if Fuzzing + if let keyState, readFramer[keyState.rawValue].type == .null { + return 0 + } + #endif + return 16 } static func openRetry(retryPseudo: RawSpan, retryTag: RawSpan) throws(QUICError) { diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 8fe63f23..e135f82e 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -329,7 +329,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, private(set) var knownFlows = [QUICStreamID: MultiplexedFlowIdentifier]() - private(set) var localCIDLength: Int = 0 + var localCIDLength: Int = 0 // would be private(set) private var initialSourceConnectionID: QUICConnectionID? private var initialStatelessResetToken: QUICStatelessResetToken? private var disableAutomaticNewConnectionIDs = false diff --git a/Tests/Fuzzers/QUICPackets/FuzzQUICPackets.swift b/Tests/Fuzzers/QUICPackets/FuzzQUICPackets.swift new file mode 100644 index 00000000..6f07b283 --- /dev/null +++ b/Tests/Fuzzers/QUICPackets/FuzzQUICPackets.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if !NETWORK_NO_SWIFT_QUIC + +import Foundation +#if canImport(SwiftNetwork) +@_spi(Essentials) @_spi(ProtocolProvider) @testable import SwiftNetwork +#elseif canImport(Network) +@_spi(Essentials) @_spi(ProtocolProvider) @testable import Network +#endif + +#if !Fuzzing +@main +struct RefuseToFuzz { + static func main() { + print("Refusing to fuzz. Rebuild with Fuzzing trait.") + exit(1) + } +} +#else +#if os(Linux) +// On Linux SwiftPM renames an executable target's entry point to +// `_main` and links with `--defsym main=_main`, so the module +// can also be imported. Under the Fuzzing trait this file deliberately has no +// Swift entry point (libFuzzer's runtime supplies `main`), so that symbol does +// not exist and the link fails with: +// +// ld.gold: error: undefined symbol 'FuzzQUICPackets_main' referenced in expression +// +// Supply it, and drive libFuzzer explicitly. `main` then resolves here via the +// `--defsym`, which also means libFuzzer's own `main` - a member of the static +// libclang_rt.fuzzer archive - is never extracted, so there is no duplicate +// `main`. macOS does not do this renaming, so it is left untouched. +@_silgen_name("LLVMFuzzerRunDriver") +private func LLVMFuzzerRunDriver( + _ argc: UnsafeMutablePointer, + _ argv: UnsafeMutablePointer?>?>, + _ userCallback: @convention(c) (UnsafePointer?, Int) -> Int32 +) -> Int32 + +@available(Network 0.1.0, *) +@_cdecl("FuzzQUICPackets_main") +func fuzzQUICPacketsMain( + _ argc: Int32, + _ argv: UnsafeMutablePointer?>? +) -> Int32 { + var argc = argc + var argv = argv + return withUnsafeMutablePointer(to: &argc) { argcPointer in + withUnsafeMutablePointer(to: &argv) { argvPointer in + LLVMFuzzerRunDriver(argcPointer, argvPointer) { start, count in + guard let start else { return 0 } + return fuzzPacketParser(start, count) + } + } + } +} +#endif + +@available(Network 0.1.0, *) +@_cdecl("LLVMFuzzerTestOneInput") +public func fuzzPacketParser(_ start: UnsafePointer, _ count: Int) -> Int32 { + guard count > 0 else { + return 0 + } + var start = start + var count = count + let cidLength = Int(start[0]) % (QUICConnectionID.maximumSize + 1) + start += 1 + count -= 1 + + guard count > cidLength else { + return 0 + } + + let context = NetworkContext(identifier: "FuzzingContext") + let connection = QUICConnection(context: context) + + context.queue.sync { + let path = QUICPath.makeFromExternalTest(parent: connection) + connection.state = .connected + for keyState in PacketKeyState.allCases { + connection.protector.installNullProtector(for: keyState) + } + // TLS is never started (the null protector stands in for it), so leave + // `crypto.parentConnection` unset: every `crypto.stop(in:)`, whether from + // `close()` mid-packet or from the teardown below, then takes its + // never-started path, which also hands back the TLS instance's event state. + if cidLength != 0 { + let cidBytes = Array(UnsafeBufferPointer(start: start, count: cidLength)) + if let cid = QUICConnectionID(cidBytes) { + // Store the CID on the connection and path to avoid CID mismatches. + connection.localCIDLength = cid.length + path.setSCID(cid) + } + start += cidLength + count -= cidLength + } + + let data = Data(bytes: start, count: count) + let frame = Frame(copyBuffer: data.span) + connection.fromExternal(frame) { eventContext, frame in + var frame = frame + var packetParser = PacketParser(logPrefixer: connection.logPrefixer) + connection.handleInbound( + frame: &frame, + from: path, + inConnectedState: true, + isServerConnection: false, + packetParser: &packetParser, + in: &eventContext + ) + } + + connection.fromExternal { eventContext in + connection.crypto.stop(in: &eventContext) + for (_, stream) in connection.multiplexedFlows { + stream.upperSendQueue.finalizeAllFramesAsFailed() + stream.upperReceiveQueue.finalizeAllFramesAsFailed() + stream.reassemblyQueue.dequeueAll() + stream.identifier.discardPendingEventsForUpperProtocol(in: &eventContext) + } + for (_, secondaryFlow) in connection.multiplexedSecondaryFlows { + secondaryFlow.upperSendQueue.finalizeAllFramesAsFailed() + secondaryFlow.upperReceiveQueue.finalizeAllFramesAsFailed() + secondaryFlow.identifier.discardPendingEventsForUpperProtocol(in: &eventContext) + } + connection.multiplexedFlows.removeAll() + connection.multiplexedSecondaryFlows.removeAll() + connection.identifier.discardPendingEventsForUpperProtocol(in: &eventContext) + } + path.destroyFromExternalTest() + connection.fromExternal { eventContext in + connection.unregisterEventManager(in: &eventContext) + } + } + + return 0 +} +#endif +#endif diff --git a/Tests/Fuzzers/TransportParameters/FuzzTransportParameters.swift b/Tests/Fuzzers/TransportParameters/FuzzTransportParameters.swift new file mode 100644 index 00000000..bfca10cf --- /dev/null +++ b/Tests/Fuzzers/TransportParameters/FuzzTransportParameters.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if !NETWORK_NO_SWIFT_QUIC + +#if canImport(SwiftNetwork) +@_spi(Essentials) @_spi(ProtocolProvider) @testable import SwiftNetwork +#elseif canImport(Network) +@_spi(Essentials) @_spi(ProtocolProvider) @testable import Network +#endif + +import Foundation + +#if !Fuzzing +@main +struct RefuseToFuzz { + static func main() { + print("Refusing to fuzz. Rebuild with Fuzzing trait.") + exit(1) + } +} +#else +#if os(Linux) +// See the equivalent shim in FuzzQUICPackets.swift for why this exists. +@_silgen_name("LLVMFuzzerRunDriver") +private func LLVMFuzzerRunDriver( + _ argc: UnsafeMutablePointer, + _ argv: UnsafeMutablePointer?>?>, + _ userCallback: @convention(c) (UnsafePointer?, Int) -> Int32 +) -> Int32 + +@available(Network 0.1.0, *) +@_cdecl("FuzzTransportParameters_main") +func fuzzTransportParametersMain( + _ argc: Int32, + _ argv: UnsafeMutablePointer?>? +) -> Int32 { + var argc = argc + var argv = argv + return withUnsafeMutablePointer(to: &argc) { argcPointer in + withUnsafeMutablePointer(to: &argv) { argvPointer in + LLVMFuzzerRunDriver(argcPointer, argvPointer) { start, count in + guard let start else { return 0 } + return fuzzTransportParameters(start, count) + } + } + } +} +#endif + +@available(Network 0.1.0, *) +@_cdecl("LLVMFuzzerTestOneInput") +public func fuzzTransportParameters(_ start: UnsafePointer, _ count: Int) -> Int32 { + let data = Data(bytes: start, count: count) + _ = try? TransportParameters.deserialize(data.span, logPrefixer: LogPrefixer()) + return 0 +} +#endif +#endif