Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

curl-fuzzer

curl-fuzzer contains the fuzz targets, seed corpora, build integration, and testcase tools used by curl’s OSS-Fuzz project. It can also build standalone binaries for local regression testing and crash reproduction.

The repository has three target families:

  • Legacy fuzzers (curl_fuzzer and its protocol variants) drive libcurl from a stable Type-Length-Value (TLV) input.
  • Structured fuzzers use a protobuf Scenario, target-specific mutation policies, and bounded in-process protocol peers.
  • Direct fuzzers feed raw bytes to focused parsers such as URL, DoH, netrc, and buffer-queue code.

Start with Getting started, then use the dedicated legacy or structured guide when changing a harness or adding seeds.

Online tools

The corpus decoder accepts legacy TLV and structured protobuf Scenario files. It normally detects the format, allows manual selection for ambiguous inputs, and runs entirely in the browser. Selected testcase data never leaves the device. For command-line protobuf decoding, see Scenarios and corpora.

Sources of truth

Avoid copying lists that must be kept synchronized:

  • scripts/fuzz_targets defines the targets packaged for each architecture and sanitizer.
  • schemas/curl_fuzzer.proto is the complete structured scenario schema, including the stable CurlOptionId values used by serialized corpora. Its marker-delimited enum block also defines the active SetOption surface.
  • corpora/ contains checked-in legacy and direct-fuzzer inputs.
  • scenarios/curl_fuzzer_proto/ contains the reviewable structured seed sources.

Getting started

Prerequisites

The primary local workflow targets 64-bit Linux. It requires:

  • Bash and common build tools;
  • Clang and Clang++;
  • CMake 3.24 or newer;
  • Ninja or Make;
  • Python 3;
  • Git and network access for the first dependency build.

The build downloads and compiles curl and its static dependencies. It can take several minutes and use substantial disk space on its first run; later runs reuse the build tree.

Build all fuzzers

From the repository root, run:

./mainline.sh

This builds curl master and the fuzz targets with AddressSanitizer. Outputs are placed under build/. The local build links a standalone replay engine and, on the default non-MemorySanitizer path, runs the CTest suite.

To use an existing curl checkout:

./mainline.sh -c /path/to/curl

To build one CMake target instead of the aggregate fuzz target:

./mainline.sh -t curl_fuzzer_http

MemorySanitizer builds use a separate directory and are compile/link checks:

SANITIZER=memory ./mainline.sh

Replay inputs

Standalone binaries accept files and directories. A file produces a detailed per-input trace; a directory is walked recursively:

FUZZ_VERBOSE=1 ./build/curl_fuzzer_http \
  corpora/curl_fuzzer_http/test_url_http

./build/curl_fuzzer_http corpora/curl_fuzzer_http/

Set FUZZ_VERBOSE to any value to enable libcurl’s verbose output. Structured targets use generated binary seeds under build/generated_corpora/:

./build/curl_fuzzer_proto_http \
  build/generated_corpora/curl_fuzzer_proto_http/

Run mutation fuzzing

mainline.sh produces replay binaries by default; it does not begin a mutation fuzzing campaign. The most production-like local route uses the OSS-Fuzz helper:

git clone --depth 1 https://github.com/google/oss-fuzz.git .oss-fuzz
python3 .oss-fuzz/infra/helper.py build_image curl
python3 .oss-fuzz/infra/helper.py build_fuzzers \
  --sanitizer address --engine libfuzzer curl "$PWD"

mkdir -p build/local-corpus/curl_fuzzer_http
cp corpora/curl_fuzzer_http/* build/local-corpus/curl_fuzzer_http/
python3 .oss-fuzz/infra/helper.py run_fuzzer \
  --corpus-dir "$PWD/build/local-corpus/curl_fuzzer_http" \
  curl curl_fuzzer_http

Pass libFuzzer arguments after the target name when needed. The helper keeps the compiler, sanitizer, engine, and packaging behavior aligned with OSS-Fuzz.

Architecture

Every packaged legacy or structured target has a same-named source file under fuzzer_entrypoints/. These thin files give OSS-Fuzz and Fuzz Introspector an unambiguous target identity while sharing the substantial harness implementation. Direct targets define their entrypoints in their top-level implementation files instead.

Target familyShared implementationInput model
curl_fuzzer and protocol variantslegacy_fuzzer.cc, curl_fuzzer_tlv.cc, and callback/socket helpersStable binary TLV stream
curl_fuzzer_proto*proto_fuzzer/ and generated protobuf sourcescurl.fuzzer.proto.Scenario
fuzz_url, fuzz_bufq, fuzz_doh, fuzz_netrcDedicated source filesRaw target-specific bytes

The normal data flow is:

seed corpus -> fuzzer engine -> target entrypoint -> shared harness -> libcurl

Legacy targets configure transfers and scripted responses from TLVs. Fixed structured targets first constrain a protobuf scenario to the compiled target profile, then run it against an appropriate bounded in-process peer. The original compatibility target retains its historical mixed behavior. Direct targets skip the transfer harness and exercise a narrow parser or data structure.

Build-time generation

The structured build derives several artifacts from the checked-in schema:

  1. The option-manifest generator reads the active CurlOptionId names and values from the schema, checks them against the selected curl headers, stages a copy of the schema in the build tree, and writes the C++ option dispatch manifest.
  2. protoc generates the C++ message implementation.
  3. Textproto files under scenarios/ are encoded into binary entries under build/generated_corpora/.
  4. OSS-Fuzz packaging creates one seed archive per target.

The checked-in enum values are part of the corpus wire format. Build-time validation catches drift between that format and the curl revision being built. The entries between the schema’s CURL-OPTIONS markers are the single source of truth for options exposed through SetOption.

Conditional targets

scripts/fuzz_targets is the canonical packaged target list. The structured suite is 64-bit only. The GnuTLS, Mbed TLS, and HTTP/3 variants are omitted under MemorySanitizer because their complete dependency stacks cannot currently be instrumented.

Corpus management

Checked-in seeds

Legacy and direct-fuzzer seeds are binary files under corpora/<target>/. Structured seeds are authored as textproto under scenarios/curl_fuzzer_proto/<lane>/; their generated binary form belongs in the build tree and must not be checked in.

When the aggregate build runs, CMake writes a manifest for each generated structured corpus. Packaging and coverage use that manifest so removed scenarios cannot survive as stale build artifacts.

Public OSS-Fuzz corpora

Download the current public corpus for every supported target with:

./scripts/download_public_corpus.sh

The script writes to ossfuzz_corpus/<target>/, skips targets whose public archive is not available yet, and leaves an existing non-empty directory alone. Force a refresh with:

./scripts/download_public_corpus.sh -f

OSS-Fuzz prefixes target names that do not already begin with curl_. The download script owns this mapping; documentation should not maintain a parallel list of storage URLs.

Downloaded corpora are ignored by Git. Coverage automatically includes them when present. Some fixed structured lanes also replay compatible historical curl_fuzzer_proto inputs, with their target policy normalizing the scenario before execution.

Seed archives

scripts/create_zip.sh is used by the OSS-Fuzz build to package seed corpora. For structured targets it reads the generated manifest; for legacy and direct targets it archives the corresponding checked-in corpus directory. Per-target runtime settings and dictionaries live under ossconfig/.

Legacy TLV fuzzers

The legacy fuzzers are the curl_fuzzer executable and the protocol-specific curl_fuzzer_* executables declared with curl_add_fuzzer() in CMakeLists.txt. They consume a compact binary type-length-value stream. The name legacy distinguishes this input model from the structured protobuf scenario fuzzers; these targets are still maintained and run by OSS-Fuzz.

The current protocol-specific targets cover DICT, file, FTP, Gopher, HTTP, HTTPS, IMAP, LDAP, MQTT, POP3, RTSP, SMTP, TFTP, and WebSocket. The unsuffixed curl_fuzzer target enables the reviewed subset of protocols supported by the libcurl build. Direct byte-oriented targets such as fuzz_url, fuzz_doh, fuzz_netrc, and fuzz_bufq do not use the TLV harness.

Execution model

The format can describe both sides of one transfer: curl options and upload data configure the client, while response TLVs provide bytes for the simulated peer. A particular testcase need not contain every kind of record.

  1. A same-named source under fuzzer_entrypoints/ exports LLVMFuzzerTestOneInput() and delegates to LegacyFuzzerTestOneInput(). Keeping a distinct entrypoint per executable lets Fuzz Introspector attribute coverage to the correct target.
  2. legacy_fuzzer.cc initializes one CURL easy handle and asks curl_fuzzer_tlv.cc to parse every record. Known records configure curl, uploads, MIME data, or one of the two simulated connections.
  3. The target applies its compile-time protocol allow-list and standard safety options. The generic target intersects its reviewed allow-list with the protocols advertised by the linked libcurl.
  4. curl_fuzzer_callback.cc supplies non-blocking Unix socketpair() sockets in place of network sockets. fuzz_handle_transfer() drives the easy handle through the multi API and releases successive response records as curl writes requests.
  5. All per-input handles, lists, MIME objects, and sockets are released before the next testcase.

The harness also directs connections to loopback, uses short transfer timeouts, limits output, and rejects protocols outside the target’s allow-list. Resolver-sensitive proxy, interface, FTP active-mode, and pre-proxy values are canonicalized before a transfer so an old or newly mutated corpus entry cannot introduce an external hostname lookup.

Structure-aware mutation

legacy_tlv_mutator.cc exports libFuzzer’s custom mutator and crossover callbacks. Most mutations operate on complete records. They preserve known value shapes, keep numeric values four bytes long, avoid duplicate scalar options, and preserve existing URL and, except for the file target, initial-response records. When either prerequisite is missing, successive structured mutations add it before making other edits, provided the output buffer has enough capacity.

When both parents are structurally valid and non-empty, crossover normally combines whole records while reserving space for those prerequisites. It falls back to bounded byte crossover for invalid or empty parents, or when the output buffer cannot hold the required records.

When the mutator seed is divisible by 16, mutation instead takes a byte-level lane (LLVMFuzzerMutate when available). This continues to explore corrupt lengths, unknown types, and other parser failures that structure-preserving edits cannot create. The ordinary lane truncates malformed inputs to their valid prefix and converges toward the transfer scaffolding needed to exercise curl.

See Working with corpora for decoding, creating, and replaying inputs, or Extending the legacy fuzzers when adding coverage.

Source map

PathResponsibility
fuzzer_entrypoints/curl_fuzzer*.ccPer-binary libFuzzer entrypoints
legacy_fuzzer.ccPer-input lifecycle, curl setup, and transfer loop
curl_fuzzer_tlv.ccTLV parsing and mapping to curl operations
curl_fuzzer_callback.ccSimulated sockets and read/write callbacks
curl_fuzzer.hWire IDs, shared state, and parser helpers
legacy_protocol_allowlist.ccGeneric target’s reviewed protocol policy
legacy_tlv_mutator.ccStructure-aware mutation and crossover
src/curl_fuzzer_tools/corpus.pyPython encoder, decoder, and ID map

TLV wire format

A legacy testcase is a sequence of binary type-length-value (TLV) records. There is no file header or record count. Each record starts with this six-byte header:

OffsetSizeFieldEncoding
02 bytesTypeUnsigned 16-bit integer, network byte order
24 bytesLengthUnsigned 32-bit integer, network byte order
6Length bytesValueMeaning depends on the type

For example, the CURLOPT_URL value http://127.0.0.1 is encoded as:

00 01  00 00 00 10  68 74 74 70 3a 2f 2f 31
                        32 37 2e 30 2e 30 2e 31
type   length = 16      value

The C parser uses to_u16() and to_u32() to decode the fields. The Python encoder and decoder use the equivalent !H and !L formats in src/curl_fuzzer_tools/corpus.py.

Value forms

The type number determines how the value is interpreted:

  • The Python encoder writes string option values as UTF-8 without a terminating NUL. The harness copies arbitrary value bytes and appends a terminator before calling libcurl, so a mutated value may still contain embedded NUL bytes.
  • Integer option records contain exactly one four-byte unsigned integer in network byte order. FU32TLV passes it as a long; FU32TLV_OFF_T passes it as a curl_off_t.
  • Response and upload records contain uninterpreted bytes. Response 0 is sent when the simulated socket opens; later response records are sent as curl produces requests. A small second set of response types serves protocols such as FTP that can open another connection.
  • A MIME-part record (type 13) contains a nested TLV stream. Only the MIME name and data records (types 14 and 15) are valid in that nested stream.
  • Header, mail-recipient, and MIME-part records may be repeated. Most curl option records are singletons.

The authoritative C type IDs are the TLV_TYPE_* definitions in curl_fuzzer.h. BaseType and BaseType.TYPEMAP in src/curl_fuzzer_tools/corpus.py provide the corresponding Python IDs and human-readable decoder names. Avoid copying the full list into documentation. tests/test_tlv_constants_sync.py checks that the C and Python numeric ID sets remain unique and synchronized.

Parser behavior

An input shorter than six bytes cannot contain a record and returns without a transfer. For each record, a declared value that extends beyond the input is a size error, and an unknown top-level type is rejected. Integer cases also reject values whose length is not four.

HSTS type 51 remains declared for corpus compatibility but has no enabled parser case. POSTFIELDSIZE (212) and POSTFIELDSIZE_LARGE (322) are likewise declared but deliberately disabled to avoid easy API misuse. The structure-aware mutation lane excludes all three, although its periodic raw byte mutation can still produce any bit pattern. MIME name and data (14 and 15) are valid only inside a MIME-part record, not at the top level.

The harness treats fewer than six bytes left after a complete record as the end of the stream. The custom mutator deliberately matches that historical behavior, although generated corpora should end exactly on a record boundary.

The structure-aware mutator applies stricter rules than the byte parser before performing record-level edits: it accepts eligible top-level IDs, valid four-byte integers, valid nested MIME framing, and no duplicate scalar records. This distinction lets the raw mutation lane continue to test parser behavior while ordinary mutations spend more executions inside libcurl.

Continue with Working with corpora to inspect or create this format.

Working with legacy corpora

Checked-in seeds live in corpora/<target>/. The active target list is maintained in scripts/fuzz_targets; do not infer it from old corpus directories that may remain for historical targets.

The commands below assume the repository’s Python package is installed in an active Python 3.10 or newer environment:

python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e .

With uv, the same tools can be invoked as uv run read_corpus, uv run generate_corpus, and uv run generate_decoder_html without manually activating the environment.

Inspect a testcase

read_corpus prints one decoded record per line:

$ read_corpus corpora/curl_fuzzer_http/test_url_http
TLVContents(type='CURLOPT_URL' (1), length=16, data=b'http://127.0.0.1')

For interactive inspection, use the published corpus decoder. It recognizes legacy TLV inputs and reads the selected file entirely in the browser. To generate a standalone copy locally:

generate_decoder_html --output /tmp/curl-corpus-decoder.html

Generate a seed

generate_corpus always requires an output path and URL. Other flags add responses, uploads, authentication, headers, MIME parts, and selected curl options. Run generate_corpus --help for the implemented set.

The historical --hsts argument emits disabled TLV type 51 and should not be used for a new seed; see Parser behavior.

This Bash example creates an HTTP seed with an initial server response and then checks its contents:

generate_corpus \
  --output /tmp/http-seed \
  --url http://127.0.0.1/ \
  --rsp0 $'HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n'
read_corpus /tmp/http-seed

Responses can instead come from a binary file (--rsp0file) or the <reply><data> section of a curl test (--rsp0test together with --curl_test_dir). Repeated options such as --header, --mailrecipient, and --mimepart name:value may be supplied more than once.

Before checking in a seed, put it under the matching active target directory, decode it, and replay it with that target. A protocol-specific URL is important: an HTTP-only executable will reject an FTP URL before reaching FTP code.

Build and replay

Build one target with the repository’s standalone replay engine:

./mainline.sh -t curl_fuzzer_http

The resulting runner accepts files and directories. A directory is walked recursively:

./build/curl_fuzzer_http corpora/curl_fuzzer_http/test_url_http
./build/curl_fuzzer_http corpora/curl_fuzzer_http/

Set FUZZ_VERBOSE=1 when replaying one testcase to show curl’s verbose trace and the simulated peer traffic:

FUZZ_VERBOSE=1 ./build/curl_fuzzer_http /path/to/testcase

The default local build replays inputs; it does not perform mutation. Follow the local mutation workflow to build and run the target through OSS-Fuzz’s libFuzzer environment. LibFuzzer then discovers LLVMFuzzerCustomMutator and LLVMFuzzerCustomCrossOver from legacy_tlv_mutator.cc; see Legacy TLV fuzzers for their policy.

Public OSS-Fuzz corpora

Download all currently published corpora with:

./scripts/download_public_corpus.sh

The script writes each archive beneath ossfuzz_corpus/<target>/, skips targets without a published archive, and accepts -f to refresh existing downloads. codecoverage.sh replays these directories alongside the checked-in seeds when they are present.

Extending the legacy fuzzers

Legacy inputs are a persistent binary format. Treat an assigned type number as part of the corpus compatibility contract: allocate a previously unused ID and never reuse or renumber an existing one. The grouped ranges in legacy_tlv_mutator.cc also make the value representation part of that choice.

Add a TLV type

  1. Add the TLV_TYPE_* value to curl_fuzzer.h.
  2. Implement its behavior in fuzz_parse_tlv() in curl_fuzzer_tlv.cc. FSINGLETONTLV handles a string-valued curl option, FU32TLV handles a four-byte option declared by libcurl as long, and FU32TLV_OFF_T handles a four-byte option declared as curl_off_t. The numeric macros enforce these option families at compile time. List-like, raw-byte, or nested data needs an explicit switch case and cleanup in FUZZ_DATA where applicable.
  3. Add the same numeric ID to BaseType and a decoder label to TYPEMAP in src/curl_fuzzer_tools/corpus.py. If contributors should be able to seed it from the command line, add the corresponding argument and encoder call in src/curl_fuzzer_tools/generate_corpus.py.
  4. Audit GetTypeInfo() and the type-selection bounds in legacy_tlv_mutator.cc. The mutator must know whether the payload is bytes, a string, a four-byte integer, or nested MIME, and whether it may repeat. Extending an ID range can also require updating the maximum known ID, the dense ordinal mapping, and its compile-time synchronization assertions.
  5. If the option can trigger name resolution or another external side effect, extend the mutator’s canonicalization/safety policy and its tests before making that type eligible for structured insertion.
  6. Add focused parser, generator, and mutator tests, then generate or update a seed that reaches the new behavior.

FUZZ_CURLOPT_TRACKER_SPACE is not a TLV-ID limit. FSET_OPTION and FCHECK_OPTION_UNSET index the tracker with CURLOPTNAME % 1000; when adding a tracked curl option, verify that this remainder is smaller than the allocated tracker space. The tracker enforces singleton curl options; this size check avoids introducing an out-of-bounds index.

tests/test_tlv_constants_sync.py rejects duplicate numeric IDs and missing or extra values between curl_fuzzer.h and Python’s BaseType. It does not replace the behavioral tests needed for the parser and mutator.

Add a protocol target

When a new target can use the existing TLV execution model:

  1. Add fuzzer_entrypoints/<target>.cc following an existing legacy wrapper. Its basename must match the executable so coverage and Fuzz Introspector attribution remain distinct.
  2. Add curl_add_fuzzer(<target> <TOKEN>) and the target to the aggregate fuzz target in CMakeLists.txt. This defines FUZZ_PROTOCOLS_<TOKEN> for the shared sources.
  3. Add or reuse the matching branch in fuzz_set_allowed_protocols() in legacy_fuzzer.cc. If this expands the generic target too, update the reviewed list in legacy_protocol_allowlist.cc. Protocols that can bypass the fake-socket model require a safety review rather than automatic inclusion.
  4. Teach DefaultUrl(), DefaultResponse(), and, where appropriate, TargetNeedsResponse() in legacy_tlv_mutator.cc how to create useful transfer scaffolding for the compile-time token.
  5. Add the executable to scripts/fuzz_targets so packaging, public-corpus downloads, and replay tooling agree with CMake.
  6. Create corpora/<target>/ with at least one decoded and replayed seed.

The generic allow-list deliberately excludes protocols such as TELNET when the legacy harness cannot safely isolate their I/O. A new protocol-specific target must preserve the harness’s isolation and must not introduce an unintended network or blocking-standard-input path.

Relevant checks

Run the focused Python checks after changing IDs, generation, entrypoints, or packaging:

python -m pip install -e '.[python-tests]'
python -m pytest \
  tests/test_tlv_constants_sync.py \
  tests/test_generate_corpus.py \
  tests/test_fuzzer_entrypoints.py

After configuring a normal build, compile and run the C++ policy tests:

cmake --build build --target \
  legacy_tlv_mutator_test legacy_protocol_allowlist_test
ctest --test-dir build --output-on-failure \
  -R 'legacy_(tlv_mutator|protocol_allowlist)_test'

tests/legacy_tlv_mutator_test.cc covers framing, bounds, transfer-scaffold repair, routing canonicalization, raw mutation, and record crossover. tests/legacy_protocol_allowlist_test.cc verifies that the generic allow-list is both supported by the linked libcurl and limited to reviewed protocols. A full AddressSanitizer ./mainline.sh build also builds fuzzer_unit_tests and runs CTest.

Structured fuzzers

The structured fuzzer suite models a curl transfer as a protobuf Scenario. libprotobuf-mutator mutates the message rather than an unstructured byte stream, then a target-specific policy normalizes it before the harness runs curl against a bounded local peer.

The targets still consume binary files. The textproto files under scenarios/curl_fuzzer_proto/ are readable seed sources; CMake encodes them as binary protobuf corpus entries under build/generated_corpora/.

How an input reaches curl

flowchart TD
    subgraph build["Build time"]
        sources["Checked-in schema<br/>and selected curl.h"]
        generator["Validate and stage the schema;<br/>generate the C++ option manifest"]
        schema["build/schemas/curl_fuzzer.proto"]
        messages["Generated C++ Scenario type"]
        manifest["C++ option dispatch manifest"]
        seeds["Checked-in .textproto seeds"]
        encoder["protoc --encode"]
        corpus["Per-target .scenario corpus"]
        target["Structured fuzzer executable<br/>bound to a TargetProfile"]

        sources --> generator
        generator --> schema
        generator --> manifest
        schema --> messages
        schema --> encoder
        seeds --> encoder --> corpus
        messages --> target
        manifest --> target
    end

    subgraph runtime["Each fuzz or replay iteration"]
        input["Binary Scenario input"]
        lpm["libprotobuf-mutator<br/>decode; mutate and cross over when fuzzing"]
        profile{"Fixed-profile target?"}
        normalize["Normalize, prune, and bound"]
        compatibility["Compatibility behavior"]
        runner["Scenario runner"]
        curl["curl"]
        peer["Harness-owned local peer"]

        input --> lpm --> profile
        profile -->|yes| normalize --> runner
        profile -->|curl_fuzzer_proto| compatibility --> runner
        runner --> curl
        runner --> peer
        curl <-->|bounded protocol exchange| peer
    end

    corpus --> input
    target --> input

The CurlOptionId values are checked in because they are part of the serialized corpus format. During the build, the option-manifest generator reads the active options between the schema’s CURL-OPTIONS markers, checks their values against the selected curl checkout’s curl.h, stages the schema under build/schemas/, and generates the C++ dispatch manifest. The remaining message types describe request data, peer responses, and focused API-lifecycle work.

Each thin entrypoint binds the shared runtime to one TargetProfile. Fixed profiles select a protocol, remove fields and options that their peer cannot use, and cap repeated fields and byte budgets. This prevents mutations from spending most of an iteration on inert or unbounded data. The original curl_fuzzer_proto target is the exception: it preserves its historical mixed semantics and corpus without registering a profile postprocessor.

The peers are owned by the harness. Stream protocols generally use local socket pairs; TFTP and HTTP/3 use private loopback UDP endpoints. Baseline curl configuration disables ambient proxies, restricts protocols, redirects direct connections to the harness, and uses short timeouts. A new target or option must preserve those isolation guarantees.

Build and replay a scenario

Build one structured target and its generated seed corpus:

./mainline.sh -t curl_fuzzer_proto_http

Replay one seed with the standalone runner:

./build/curl_fuzzer_proto_http \
  build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario

The standalone executable accepts files and directories, so an entire target-specific seed set can be replayed too:

./build/curl_fuzzer_proto_http \
  build/generated_corpora/curl_fuzzer_proto_http/

Set FUZZ_VERBOSE=1 when a reproduction needs curl’s protocol trace. These locally built executables replay inputs; OSS-Fuzz builds provide the active libFuzzer mutation engine.

Continue with Writing and inspecting scenarios, then consult Target profiles before choosing a corpus or adding a field.

Writing and inspecting scenarios

The checked-in scenario sources live below scenarios/curl_fuzzer_proto/ and use protobuf’s text format. They are the source of truth for seed inputs. Do not check generated .scenario files into corpora/; CMake writes those derived binary files to build/generated_corpora/<corpus-name>/. The corpus name normally matches the target. The GnuTLS and mbedTLS HTTPS variants reuse build/generated_corpora/curl_fuzzer_proto_https/; the canonical mapping is in scripts/fuzz_corpus_helpers.sh.

A minimal scenario

scheme: SCHEME_HTTP
host_path: "127.0.0.1/basic"
connection {
  initial_response: "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"
}

scheme and host_path form the request URL. Most byte-bearing fields use protobuf bytes, so textproto escape sequences can represent arbitrary wire data. SetOption entries use a checked-in CURLOPT_* identifier and a oneof typed value. At most one value member can be present; authored seeds should use the kind expected by that option:

options {
  option_id: CURLOPT_NOBODY
  bool_value: true
}

The main groups of fields are:

  • Request configuration: options, request_headers, mime_post, upload, and telnet_options.
  • Peer work: connection, subsequent_connections, structured WebSocket frames, and http3_plan.
  • Focused lanes: api_plan, multi_plan, resolve_entries, TLS certificate selection, socks_proxy_mode, trace_ids, and filename-backed parser inputs.

Read the comments in schemas/curl_fuzzer.proto for the complete field contract. Its checked-in CurlOptionId numbers are part of the serialized corpus format. During the build, the option-manifest generator validates them against the selected curl checkout’s curl.h, then stages an identical schema at build/schemas/curl_fuzzer.proto. The entries between the schema’s CURL-OPTIONS markers are the active options available to SetOption.

Peer scripts depend on the target

For the ordinary HTTP peer, connection.initial_response is sent when curl opens the socket and each on_readable value is released on a later readable turn. subsequent_connections supplies bounded scripts for redirects, authentication retries, and other fresh HTTP sockets.

Other peers intentionally give those fields narrower meanings:

  • Plain WebSocket scenarios use a peer that generates the 101 handshake and accepts either raw chunks or structured server_frames. The WSS lane covers secure setup under a fixed scheme; it does not promise post-handshake frame coverage.
  • The dedicated HTTPS peer encrypts connection-script bytes as HTTP application data. The compatibility target preserves its older behavior and treats HTTPS script bytes as raw TLS records.
  • TELNET preloads a bounded response before entering curl’s blocking protocol loop.
  • FTP treats the primary connection as command-aligned control replies and follow-on connections as data streams.
  • TFTP preserves packet boundaries: the initial response and each readable chunk are separate datagrams.
  • HTTP/2 origin and proxy lanes treat connection bytes as raw HTTP/2 frames after a fixed TLS/ALPN setup.
  • HTTP/3 uses ordered http3_plan actions after a valid QUIC/TLS handshake; the ordinary connection script is discarded.

Use a seed already assigned to the intended target as the closest example. A valid protobuf message can still be irrelevant to a lane if that lane’s policy removes the field or replaces its scheme.

Add a seed

  1. Put a descriptively named .textproto file in the closest protocol directory under scenarios/curl_fuzzer_proto/. Its filename stem must be unique across every source directory combined into the destination corpus, because generated corpora are flat.
  2. Check the corpus declarations in CMakeLists.txt. Directory membership is not the complete assignment rule: the fast HTTP set is explicit, the deep and compatibility sets combine selected directories, and the timing set is selected by a backpressure filename pattern.
  3. Build the destination target. CMake encodes the source with the staged schema and creates <name>.scenario in the corpus assigned to that target.
  4. Decode the generated file and replay it through the matching executable.

For example:

./mainline.sh -t curl_fuzzer_proto_http
uv run read_proto_corpus \
  build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario
FUZZ_VERBOSE=1 ./build/curl_fuzzer_proto_http \
  build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario

Adding a new fast-HTTP seed also requires updating its explicit list and count check in CMakeLists.txt.

Decode a binary input

For interactive inspection, use the published corpus decoder. It recognizes protobuf Scenario files and reads the selected file entirely in the browser.

read_proto_corpus wraps protoc and prints named textproto fields using the checked-in schema. It falls back to the staged build copy when the source schema is unavailable:

uv run read_proto_corpus path/to/crash-input
uv run read_proto_corpus \
  --proto-file build/schemas/curl_fuzzer.proto path/to/crash-input

The command requires protoc on PATH. It checks --proto-file, then CURL_FUZZER_PROTO, the checkout’s schemas/curl_fuzzer.proto, and finally build/schemas/curl_fuzzer.proto. Without a schema it falls back to wire-level field numbers; request that form explicitly with --raw:

uv run read_proto_corpus --raw path/to/crash-input

Use the schema from the curl-fuzzer revision that produced the input when investigating CURLOPT_* values.

Target profiles

All structured targets share the binary Scenario wire format and most of the runtime. A target profile defines which part of that format is meaningful, which peer is used, and how much work one mutation may create.

TargetProfile and peer focus
curl_fuzzer_protoCompatibility target for the historical mixed HTTP, HTTPS, WebSocket, and TELNET corpus. It deliberately has no profile postprocessor.
curl_fuzzer_proto_httpHigh-throughput plaintext HTTP. Retains cheap request options and raw response parsing; removes MIME, uploads, follow-on sockets, and timing controls.
curl_fuzzer_proto_http_deepStateful HTTP coverage, including redirects, authentication, MIME, uploads, result APIs, and bounded cookie, Alt-Svc, HSTS, and netrc files.
curl_fuzzer_proto_http2High-throughput HTTP/2 origin frames over plaintext prior knowledge, using the same structured and malformed frame grammar as the TLS lane.
curl_fuzzer_proto_httpsHTTP/1.1 through a real in-process TLS peer, including certificate, session, TLS result-state, and bounded CRL-input coverage.
curl_fuzzer_proto_https_gnutls / curl_fuzzer_proto_https_mbedtlsThe HTTPS profile with a GnuTLS or mbedTLS curl client; the local server side remains the harness TLS peer and both variants reuse the HTTPS generated seed corpus.
curl_fuzzer_proto_https_h2Structured and malformed HTTP/2 origin frames after a verified TLS/ALPN handshake, plus push and upkeep probes.
curl_fuzzer_proto_http3Structured or raw HTTP/3/QPACK work after a real local QUIC/TLS handshake.
curl_fuzzer_proto_h2_proxyAn HTTP/1.1 origin request through a fixed trust-anchor-verified HTTPS/HTTP/2 CONNECT proxy; mutations control bounded raw proxy frames and origin request settings.
curl_fuzzer_proto_socks4HTTP through an in-process SOCKS4 or SOCKS4A proxy.
curl_fuzzer_proto_resolverLocalhost resolution and bounded CURLOPT_RESOLVE host-cache operations while harness callbacks retain transport control.
curl_fuzzer_proto_wsPlaintext WebSocket handshake, framing, callbacks, and manual-drive paths.
curl_fuzzer_proto_wssSecure-WebSocket setup under a fixed WSS scheme, with backpressure removed from this fast lane.
curl_fuzzer_proto_telnetBounded TELNET negotiation and callback-backed input against a preloaded local peer.
curl_fuzzer_proto_ftpPlain FTP control plus passive or loopback-confined active data connections.
curl_fuzzer_proto_tftpPacket-preserving TFTP exchanges over private loopback UDP endpoints.
curl_fuzzer_proto_gopherBounded Gopher or Gophers selectors through a stream peer, using the TLS peer for Gophers when available.
curl_fuzzer_proto_apiEasy, share, multi, URL, connect-only, pause/resume, and typed result API lifecycles described by api_plan.
curl_fuzzer_proto_multiTwo to four easy handles on one shared multi handle, with bounded scheduling actions, connection-cache controls, and a five-second packaged timeout.
curl_fuzzer_proto_timingPlain HTTP or WebSocket backpressure and timed-wait behavior; it guarantees a non-default bounded pressure configuration.

The exact target inventory and platform gates are maintained in scripts/fuzz_targets. Structured targets are excluded from i386 builds. The GnuTLS, mbedTLS, and HTTP/3 variants are also excluded from MemorySanitizer builds, and HTTP/3 is created only when its dependency variant is enabled. MemorySanitizer omits OpenSSL and the TLS mock peer. The plaintext curl_fuzzer_proto_http2 lane remains active in that build. In contrast, curl_fuzzer_proto_https_h2 and curl_fuzzer_proto_h2_proxy return without driving their peer-dependent scenarios, while curl_fuzzer_proto_https cannot complete its ordinary verified-TLS path.

What policy application changes

Before a fixed target executes a loaded or newly mutated message, its postprocessor:

  • fixes or narrows the scheme and, where required, canonicalizes the authority;
  • retains only options that can affect that peer and transfer mode;
  • removes protocol-specific plans, file inputs, and connection shapes that the lane cannot consume;
  • caps options, headers, response chunks, connection count, upload data, MIME parts, API actions, and protocol-specific byte budgets; and
  • canonicalizes small enums and transport settings onto useful, safe ranges.

The shared limits are in proto_fuzzer/scenario_limits.h; profile-specific selection is in proto_fuzzer/target_policy.cc. Runtime code repeats important bounds so callers that bypass mutation policy cannot create unchecked work. The packaged libFuzzer configuration also caps each serialized structured input at 32 KiB.

Fast lanes clear backpressure because a single mutated value would otherwise move an ordinary case into a timed loop. The timing lane does the inverse and forces bounded pressure. HTTP/3 discards the stream Connection entirely, while FTP, TFTP, TELNET, and Gopher prune fields that their specialized peers cannot observe.

The compatibility profile is intentionally different. Existing OSS-Fuzz inputs are keyed to curl_fuzzer_proto, so changing its normalization would change the meaning of accumulated corpus and crash files. New protocol enum values and fields must therefore default to the old behavior or be gated so the compatibility runner does not reinterpret historical wire data.

Choosing a target for a seed or crash

Use the narrowest target that owns the behavior:

  • Prefer http for cheap request/response parsing, http_deep for stateful or file-backed work, and timing for intentional backpressure.
  • Use https, http2, https_h2, or http3 according to the actual transport rather than putting protocol frame setup into an HTTP seed.
  • Put lifecycle-only work in api or multi, keeping it out of protocol-hot loops.
  • Replay a crash with the binary named by OSS-Fuzz. Although fixed lanes share a wire format, their policies can transform the same bytes differently.

See Writing and inspecting scenarios for corpus generation and Extending the structured suite for the invariants a new lane must implement.

Extending the structured suite

Structured inputs work best when mutations reliably reach curl without making each iteration expensive. Extend the smallest existing surface that owns the behavior. Consider serialization compatibility, object lifetimes, target policy, and local transport isolation together.

Add a supported curl option

SetOption is for scalar values and copied or scenario-owned strings. To add one:

  1. Add its stable numeric value in alphabetical order between the CURL-OPTIONS markers in the CurlOptionId enum in schemas/curl_fuzzer.proto. This block is the active option list. Existing values are part of the corpus wire format and must not be changed or reused.
  2. If its curl.h type does not determine the intended protobuf value kind, update the overrides in src/curl_fuzzer_tools/generate_option_manifest.py.
  3. Decide which fixed profiles may retain it. The fast HTTP, HTTP/2 proxy, HTTP/3, TELNET, FTP, and TFTP lanes use explicit option allowlists in proto_fuzzer/target_policy.cc.
  4. Add a correlated textproto seed when reaching useful code requires other options or particular peer bytes.

At build time the generator reads the selected checkout’s curl.h, validates the marker-delimited names and numbers against it, stages the schema in the build tree, and emits the C++ value-kind dispatch table.

Callbacks, slists, files, and other pointer-bearing options usually need a schema-native field plus explicit harness-owned storage. Their backing objects must remain alive through transfer completion and easy-handle cleanup. Existing examples include request headers, MIME, upload state, TELNET options, resolver entries, and bounded anonymous parser files.

Keep filename-backed cookie, Alt-Svc, and HSTS options out of SetOption; the baseline owns their fixed /dev/null sinks, while the enum exposes only their in-memory controls. CURLOPT_FTPPORT and CURLOPT_FTP_USE_EPRT are safe because the FTP peer confines active listeners to loopback. FTPS and the slist-backed QUOTE options need protocol-specific transport and ownership before they can join this surface. The TFTP controls reuse the schema’s upload body and size fields to exercise option negotiation and raw RRQ/WRQ creation.

Some scalar options still need correlated seeds. The HTTP Message Signature key, key ID, header list, and algorithm options are one example: keep their values together in the deep HTTP corpus so mutations can reach both parsing and rejection paths.

Add or change a schema field

The binary corpus is persistent. Add new fields with unused field numbers and append new enum values; never renumber or reuse an existing tag. Choose a default that preserves the behavior of older serialized inputs.

Then update all parts of the contract:

  • Add an appropriate complexity limit in proto_fuzzer/scenario_limits.h.
  • Bound the runtime-visible value and remove it from profiles that cannot use it in proto_fuzzer/target_policy.cc.
  • Implement ownership and execution in the request-data, runner, or peer layer.
  • Ensure the compatibility target does not silently acquire new semantics from bytes that older schemas treated as unknown.
  • Add policy tests for retention, removal, canonicalization, and boundary values, plus runtime tests when the field owns resources or changes I/O.

Protobuf repeated and bytes fields have no intrinsic work bound. In fixed lanes, a runtime that merely ignores an oversized suffix is not sufficient: the target postprocessor must remove it too, or libprotobuf-mutator will continue allocating and mutating data that cannot add coverage. The historical compatibility lane cannot acquire a new postprocessor, so its runtime boundary must remain safe without changing existing input semantics.

Add a target profile

A new lane normally requires all of the following:

  1. A TargetProfile and a complete RunModeFor mapping in proto_fuzzer/target_profile.h.
  2. A policy branch in proto_fuzzer/target_policy.cc that fixes routing, retains only meaningful fields, and applies shared bounds.
  3. A bounded peer or runner path. It must not consult ambient DNS, proxies, trust stores, or remote endpoints, and malformed scripts must terminate by operation or idle budgets rather than long wall-clock waits.
  4. A thin fuzzer_entrypoints/curl_fuzzer_proto_<name>.cc binding all three libFuzzer entrypoints to the same profile.
  5. A CMake executable declaration, generated corpus declaration (or an explicit reuse mapping in scripts/fuzz_corpus_helpers.sh), and dependency from the executable to that corpus.
  6. An ossconfig/<target>.options file and an entry in scripts/fuzz_targets, with architecture or sanitizer gates when required.
  7. Entrypoint, target-policy, peer/runtime, packaging, and representative scenario tests.
  8. A row in Target profiles.

Keep throughput-sensitive parsing separate from expensive lifecycle, TLS, file parser, or timed work. Existing http, http_deep, and timing profiles show how one protocol surface can be split without making its fast lane pay for every coverage feature.

Verify a change

Run the focused Python tests for schema generation and target wiring, then build the affected binary and the C++ unit-test aggregate:

uv run --extra python-tests pytest \
  tests/test_generate_option_manifest.py \
  tests/test_fuzzer_entrypoints.py
./mainline.sh -t curl_fuzzer_proto_http
cmake --build build --target fuzzer_unit_tests
ctest --test-dir build --output-on-failure

Replace the example target with the lane being changed. Finally, decode and replay each new generated seed as described in Writing and inspecting scenarios. For performance-sensitive policy changes, use the benchmarking guide; source coverage and executions per second answer different questions and should be checked together.

Python tools

The curl-fuzzer-tools package requires Python 3.10 or newer.

Install with uv

uv sync
uv run read_corpus corpora/curl_fuzzer_http/test_url_http

Add the Python test extra when developing:

uv sync --extra python-tests

Install with pip

python3 -m venv .venv
. .venv/bin/activate
python -m pip install -e .

Commands

CommandPurpose
read_corpusDecode one legacy TLV input
read_proto_corpusDecode one binary protobuf scenario using protoc
generate_corpusGenerate a legacy TLV testcase
tlv_to_protoConvert a directory of legacy HTTP inputs to textproto
generate_decoder_htmlBuild the standalone browser decoder for legacy TLV and protobuf Scenario inputs
corpus_to_pcapConvert response TLVs to a packet capture; requires Scapy from the development dependency group
generate_matrixPackage built fuzzers into balanced artifact shards and produce the CI matrix
prepare_fuzzerExtract one fuzzer and its supporting files from a CI artifact shard
generate_option_manifestValidate and stage the protobuf schema, then generate its C++ curl-option manifest

Run any command with --help for its complete interface.

Browser decoder

The published decoder accepts legacy TLV and protobuf Scenario inputs. It detects the format automatically, allows manual selection, and does not upload the selected file. Empty, unknown-only, or damaged inputs may require selecting a format manually. The generator bundles the locked protobuf.js runtime and the checked-in schema into the HTML, so the resulting page does not load a decoder from a CDN. Build the complete documentation site and decoder locally with:

npm ci --ignore-scripts --no-audit --no-fund
mdbook-mermaid install .
mdbook build
uv run generate_decoder_html --output _site/corpus-decoder/index.html

Then open _site/index.html for the book or _site/corpus-decoder/index.html for the decoder. Build mdBook first because it recreates the output directory.

Coverage

codecoverage.sh builds curl and the fuzzers with LLVM source-based coverage, replays the available corpora, and limits the report to curl’s lib/ and src/ trees.

./codecoverage.sh

The generated reports are:

  • build-coverage/coverage/summary.txt, an llvm-cov report summary;
  • build-coverage/coverage/html/index.html, a browsable annotated report.

Measure a local curl checkout with:

./codecoverage.sh -c /path/to/curl

If ossfuzz_corpus/ exists, its downloaded inputs are replayed alongside the checked-in or generated seeds. To rerun one target after an instrumented build:

BUILD_DIR="$PWD/build-coverage" \
TARGETS=curl_fuzzer_proto_http \
./scripts/run_coverage.sh

The GitHub Actions Coverage workflow is manual. It uploads both reports, writes the text summary to the job summary, and reuses a public-corpus cache whose key changes each ISO week.

For controlled A/B performance and source-coverage comparisons, see Benchmarking.

Reproducing fuzzer findings

OSS-Fuzz reports identify the target binary, fuzzing engine, sanitizer, and platform that found a failure. Reproduce with the closest available configuration: a crash found under UndefinedBehaviorSanitizer, for example, may not be visible in the default AddressSanitizer build.

These instructions assume that the testcase has been downloaded from the OSS-Fuzz report.

Inspect the testcase

Legacy TLV targets

Decode a legacy input with the Python tool:

read_corpus clusterfuzz-testcase-minimized-curl_fuzzer_http-<id>

For example, a URL-only input is displayed as:

TLVContents(type='CURLOPT_URL' (1), length=16, data=b'http://127.0.0.1')

The hosted corpus decoder provides the same kind of inspection in a browser without uploading the file. It recognizes legacy TLV inputs automatically.

Structured protobuf targets

The hosted corpus decoder also decodes protobuf Scenario files entirely in the browser. It normally detects the format, or you can select it manually for ambiguous inputs.

From a source checkout, read_proto_corpus uses the checked-in schema to print field names. It can also use a staged copy under build/schemas/ when the source schema is unavailable:

read_proto_corpus \
  clusterfuzz-testcase-minimized-curl_fuzzer_proto_http-<id>

The command requires protoc. If it cannot find either schema, it falls back to protoc --decode_raw; pass --proto-file to select one explicitly.

Published reproduction images also include the staged schema and a decode-scenario command:

docker run --rm -i \
  -v "$PWD/clusterfuzz-testcase-minimized-curl_fuzzer_proto_multi-<id>:/testcase:ro" \
  curlfuzzer.azurecr.io/address-libfuzzer \
  decode-scenario /testcase

It also accepts the testcase on standard input:

docker run --rm -i curlfuzzer.azurecr.io/address-libfuzzer decode-scenario \
  < clusterfuzz-testcase-minimized-curl_fuzzer_proto_multi-<id>

Direct parser targets consume target-specific raw bytes and generally have no separate decoder.

Reproduce with a local standalone build

Build the target, optionally against a local curl checkout:

./mainline.sh -t curl_fuzzer_http
./mainline.sh -c /path/to/curl -t curl_fuzzer_http

The resulting binary is under build/. Set FUZZ_VERBOSE to enable detailed libcurl logging:

FUZZ_VERBOSE=1 ./build/curl_fuzzer_http \
  clusterfuzz-testcase-minimized-curl_fuzzer_http-<id>

The default build uses AddressSanitizer. It is suitable for many libfuzzer_asan findings and provides a fast edit-build-replay loop. A matching OSS-Fuzz container is preferable when the report uses another sanitizer, engine, architecture, or dependency configuration.

Reproduce in OSS-Fuzz

Follow the upstream OSS-Fuzz reproduction guide for complete instructions. From an OSS-Fuzz checkout, an UndefinedBehaviorSanitizer HTTP reproduction is:

python3 infra/helper.py build_image curl
python3 infra/helper.py build_fuzzers --sanitizer undefined curl
python3 infra/helper.py reproduce \
  curl curl_fuzzer_http \
  /path/to/clusterfuzz-testcase-minimized-curl_fuzzer_http-<id>

Once reproduced, use the normal sanitizer stack trace, debugger, and targeted logging to narrow the fault. For AddressSanitizer investigations in GDB, a breakpoint on __asan::ReportGenericError can stop at the point where the runtime reports the invalid access.

Comparing fuzzer coverage and speed

scripts/compare_fuzzers.py runs repeatable A/B measurements against production-style libFuzzer binaries. It records libFuzzer coverage (cov), features (ft), executions per second, corpus growth, peak RSS, and wall/user/ system time. Complete logs and crash artifacts are retained, and any failed or unparseable run makes the command fail.

Build the baseline and candidate with the same curl revision, compiler, sanitizer, and OSS-Fuzz settings. For example, use two OSS-Fuzz output directories produced with the address sanitizer and libFuzzer. Then run:

scripts/compare_fuzzers.py \
  --baseline-dir /path/to/baseline/out/curl \
  --candidate-dir /path/to/candidate/out/curl \
  --seconds 30 \
  --repeats 5 \
  --cpu 0 \
  --output build/measurements/comparison.json

The defaults cover the legacy FTP/HTTP/HTTPS/TFTP/WS targets and the fixed structured fast HTTP, deep HTTP, HTTPS, WS, WSS, TELNET, FTP, TFTP, API, and timing lanes. The deep HTTP target retains stateful authentication, redirect, upload, MIME, and result-probe work so the ordinary HTTP lane can concentrate its CPU budget on single-request URL and response parsing. The original mixed-semantics curl_fuzzer_proto target is retained for OSS-Fuzz corpus and testcase compatibility, but is not benchmarked by default because its variable scheme and timing policy obscures lane-level throughput. Repeat --target to choose a different same-name A/B set:

scripts/compare_fuzzers.py \
  --baseline-dir /path/to/baseline/out/curl \
  --candidate-dir /path/to/candidate/out/curl \
  --target curl_fuzzer_proto_http \
  --target curl_fuzzer_proto_http_deep \
  --target curl_fuzzer_proto \
  --target fuzz_url

To compare a legacy target with a differently named proto replacement, use a target pair:

scripts/compare_fuzzers.py \
  --baseline-dir /path/to/legacy/out/curl \
  --candidate-dir /path/to/proto/out/curl \
  --target-pair curl_fuzzer_http=curl_fuzzer_proto_http \
  --target-pair curl_fuzzer_https=curl_fuzzer_proto_https \
  --target-pair curl_fuzzer_ftp=curl_fuzzer_proto_ftp \
  --target-pair curl_fuzzer_tftp=curl_fuzzer_proto_tftp \
  --target-pair curl_fuzzer_ws=curl_fuzzer_proto_ws

The label in the report is, for example, curl_fuzzer_http=>curl_fuzzer_proto_http. Each side of a mapped pair receives its own native corpus and seed archive; legacy TLV bytes are never passed to the protobuf target. Same-name --target comparisons retain the stricter behavior of sharing one content-identical snapshot. --corpus TARGET=PATH overrides are keyed by the actual binary target name, so a mapped pair may supply one override for each side.

For a same-name target, the input snapshot combines the checked-in corpus with the baseline’s <target>_seed_corpus.zip, deduplicating by SHA-256. A mapped pair resolves the checked-in corpus and seed archive independently from its baseline and candidate layouts. Add a downloaded fleet corpus with --public-corpus-root ossfuzz_corpus. Override a target’s inputs completely with one or more --corpus TARGET=PATH arguments; PATH may be a directory, zip archive, or individual input.

For every fixed proto lane, --public-corpus-root also includes the historical curl_fuzzer_proto public corpus. The protobuf wire format is shared, and the lane postprocessor normalizes scheme and timing controls before replay, so this preserves accumulated fleet coverage while the new target corpora grow.

Every run gets a fresh copy of one content-addressed snapshot. Repeat N uses the deterministic seed --seed-base + N - 1; baseline/candidate order alternates between repeats to reduce ordering bias. The JSON records corpus and binary hashes, exact managed arguments, host details, individual results, and medians. Raw cov and ft values are only comparable when compiler instrumentation and the curl revision are held constant. They remain recorded for mapped targets, but their cross-harness deltas are deliberately reported as null; use the source coverage comparison below for legacy-to-proto parity.

Compare curl source coverage

Raw libFuzzer cov and ft counters include each harness and cannot prove coverage parity between a legacy target and a proto target. The comparison helper can additionally replay the fixed native corpus through standalone LLVM coverage builds and compare curl-relative function and code-region identities.

Create an instrumented build in each source worktree with codecoverage.sh. The default build layout puts binaries under build-coverage/ and curl sources under build-coverage/curl/src/curl_external, which the helper detects:

(cd /path/to/baseline-worktree && ./codecoverage.sh)
(cd /path/to/candidate-worktree && ./codecoverage.sh)

scripts/compare_fuzzers.py \
  --baseline-dir /path/to/baseline-libfuzzer-out \
  --candidate-dir /path/to/candidate-libfuzzer-out \
  --target-pair curl_fuzzer_http=curl_fuzzer_proto_http \
  --target-pair curl_fuzzer_http=curl_fuzzer_proto_http_deep \
  --target-pair curl_fuzzer_http=curl_fuzzer_proto_timing \
  --coverage-baseline-dir /path/to/baseline-worktree/build-coverage \
  --coverage-candidate-dir /path/to/candidate-worktree/build-coverage \
  --seconds 60 \
  --repeats 5

The aggregate source-coverage comparison unions the three candidate lanes, so the fast target can intentionally omit stateful behavior without making the replacement-parity number look worse. Benchmark the fast lane separately when judging whether it can take over the legacy HTTP CPU allocation; the deep and timing targets exist to retain coverage, not to match its throughput.

When curl was supplied with codecoverage.sh -c, pass the source roots explicitly:

scripts/compare_fuzzers.py \
  ... \
  --baseline-curl-source-dir /path/to/baseline-curl \
  --candidate-curl-source-dir /path/to/candidate-curl

Use --llvm-profdata and --llvm-cov for versioned executable names, and --coverage-wall-timeout to change the per-target replay limit. The helper validates all binaries and tools before fuzzing, requires byte-identical curl lib/ and src/ source trees, and rejects a coverage binary that produces no profile. This prevents line-number comparisons across different curl revisions from looking meaningful.

The main JSON gains a source_coverage object with per-target and aggregate summaries. Its comparison records baseline-retention percentages plus complete baseline_only and candidate_only function/region lists. Raw profiles, merged profiles, exports, and replay logs are preserved below <log-dir>/source-coverage/. The source metric deliberately replays a fixed input snapshot; discoveries from the timed fuzzing runs do not leak into it. Timer and event-loop scheduling can still move an occasional region between runs, so repeat the replay before treating a marginal one-region gap as real.

For a conventional combined HTML report, a single target can still be replayed after an instrumented build exists:

BUILD_DIR="$PWD/build-coverage" \
TARGETS=curl_fuzzer_proto_http \
scripts/run_coverage.sh

Use source coverage and libFuzzer features together when evaluating corpus minimization or harness changes: line coverage alone does not preserve value, counter, and indirect-call features used by the mutation scheduler.

Development

Run checks

Install the Python testing and development dependencies:

uv sync --extra python-tests

Run the Python tests and repository lint entrypoint:

uv run pytest tests/test_*.py
./lint.sh

mainline.sh builds the C++ tests and runs CTest for the normal AddressSanitizer aggregate build. The MemorySanitizer lane compiles and links them but does not execute against the host’s uninstrumented C++ runtime.

Documentation

Install the pinned mdBook and Mermaid preprocessor versions:

# renovate: datasource=crate depName=mdbook
MDBOOK_VERSION=0.5.4
# renovate: datasource=crate depName=mdbook-mermaid
MDBOOK_MERMAID_VERSION=0.17.1
cargo install mdbook --version "=${MDBOOK_VERSION}" --locked
cargo install mdbook-mermaid --version "=${MDBOOK_MERMAID_VERSION}" --locked

With Node.js 18 or newer, install the locked JavaScript dependencies used by the browser decoder:

npm ci --ignore-scripts --no-audit --no-fund

Then generate Mermaid’s JavaScript assets and build the documentation from the repository root:

mdbook-mermaid install .
mdbook build
uv run generate_decoder_html --output _site/corpus-decoder/index.html

The Mermaid assets are generated and ignored by Git. Delete them before rerunning the installer after an mdbook-mermaid upgrade because the installer does not overwrite existing assets. For live editing, run mdbook serve --open. The browser decoder is generated separately and is therefore not refreshed by the mdBook development server.

Run its optional browser tests for both legacy TLV and protobuf Scenario inputs with:

uv sync --extra browser-tests
uv run playwright install chromium
uv run pytest tests/browser/test_corpus_decoder.py

The documentation CI builds the book and decoder together. The Pages workflow publishes _site/ from master for https://fuzz.curl.se/. book.toml records the intended hostname, but the custom domain and HTTPS enforcement must also be configured in the repository’s Pages settings.

Generated files

Do not commit _site/, build/generated_corpora/, staged schema copies under build/, or downloaded public corpora. Commit the schema under schemas/, authored Markdown under docs/, legacy binary seeds under corpora/, and structured textproto seeds under scenarios/curl_fuzzer_proto/.