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 two target families:

  • Structured fuzzers use typed protobuf scenarios, generated target-specific mutation roots, 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 structured harness guide when changing a harness or adding protobuf seeds.

Online tools

The corpus decoder accepts structured protobuf Scenario files 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 annotated authoring schema. Typed SetOption fields own native curl-option identity and profile membership; structured-field annotations own the remainder of each generated profile’s input surface.
  • schemas/eligibility_options.proto defines the profile registry and eligibility metadata interpreted at build time. It does not define a second serialized Scenario format.
  • corpora/ contains checked-in 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.10 or newer;
  • the Protocol Buffers compiler (protoc) and well-known .proto files (protobuf-compiler plus libprotobuf-dev on Debian and Ubuntu);
  • 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_proto_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_proto_http \
  build/generated_corpora/curl_fuzzer_proto_http/basic_get.scenario
./build/curl_fuzzer_proto_http \
  build/generated_corpora/curl_fuzzer_proto_http/

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

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_proto_http
python3 -m zipfile -e \
  .oss-fuzz/build/out/curl/curl_fuzzer_proto_http_seed_corpus.zip \
  build/local-corpus/curl_fuzzer_proto_http
python3 .oss-fuzz/infra/helper.py run_fuzzer \
  --corpus-dir "$PWD/build/local-corpus/curl_fuzzer_proto_http" \
  curl curl_fuzzer_proto_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 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_proto*proto_fuzzer/, generated profile Traits, ordinary profile descriptors, and generated protobuf sourcesTarget-specific generated message lowered to an internal Scenario
fuzz_url, fuzz_bufq, fuzz_doh, fuzz_netrc, fuzz_privateDedicated source filesRaw target-specific bytes

The normal data flow is:

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

Each structured target asks libprotobuf-mutator to operate on its generated restricted message type, lowers that message into the common internal runtime Scenario, applies semantic normalization and bounds, then runs it against an appropriate bounded in-process peer. The compatibility target has a generated broad root and retains its mixed execution behavior. Direct targets skip the transfer harness and exercise a narrow parser or data structure.

fuzz_private remains input-driven despite sharing one executable across several small surfaces. Its first byte selects ptrarray, dynhds, uint-spbset, memdebug, or cf-ip-happy; the remaining bytes select bounded operations, arguments, and ordering. Each lane releases all per-input state before the input returns. In particular, the memdebug lane never enables global logging or allocation limits, and the Happy Eyeballs lane never opens a remote connection.

Build-time generation

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

  1. Host protoc emits a descriptor set containing the schema, eligibility metadata, imports, and source locations.
  2. The schema-eligibility generator validates the complete profile registry and every annotated field. For typed SetOption fields it also verifies the declared CURLOPT_* name and protobuf type against the selected curl headers.
  3. That one model emits a metadata-free full authoring view, the generic internal runtime schema and native option dispatch, one restricted schema per profile, direct lower/lift/pruning Traits, and deterministic reports.
  4. protoc generates the internal runtime C++ type and each target’s selected profile type. Only the selected profile object is linked into that target.
  5. Checked-in textprotos are encoded as the full authoring Scenario with ordinary protoc --encode, then packaged into one seed archive per target.

Restricted roots retain the authoring field numbers and wire types. They can therefore parse the ordinary authored seed bytes directly; unsupported fields become unknown fields and are discarded before lowering. Conditional eligibility is pruned after selector normalization and before shape bounds. This relationship applies to the new schema and its generated views, not to pre-cutover binary corpora.

Schema annotations own option identity, value kind, and static or conditional profile membership. Runtime C++ continues to own protocol mechanics, selector normalization, work limits, curl object lifetimes, and local transport safety.

Conditional targets

scripts/fuzz_targets is the canonical packaged target list. The base structured suite is included in OSS-Fuzz’s AddressSanitizer/i386 build. The GnuTLS, Mbed TLS, and HTTP/3 variants are omitted from i386 and MemorySanitizer builds because their complete dependency stacks have not been validated for those configurations.

Corpus management

Checked-in seeds

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 eligible corpora when present. Structured targets replay only their own corpus and exact same-profile aliases such as the three HTTPS backend variants. Pre-cutover generic-option bytes belong to the revision and target that produced them, and cross-profile reuse is not inferred from parse success.

Automatic structured replay additionally requires this marker at the public corpus root:

ossfuzz_corpus/.structured-schema-owned-eligibility-v1

The download script intentionally does not create it: the public ZIP format does not identify the schema that produced its entries. Only after the upstream structured corpora have been replaced for the typed-option cutover, move any existing local root aside, download a fresh root, and create the marker:

mv ossfuzz_corpus ossfuzz_corpus.pre-schema-owned-eligibility
./scripts/download_public_corpus.sh
touch ossfuzz_corpus/.structured-schema-owned-eligibility-v1

Coverage, benchmarking, profiling, and HTTP/2 default bootstrap discovery then accept structured directories from that root. Explicit corpus overrides remain explicit caller assertions. Do not mark a root that retains pre-cutover structured directories.

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 direct targets it archives the corresponding checked-in corpus directory. Per-target runtime settings live under ossconfig/.

Structured fuzzers

The structured fuzzer suite models a curl transfer with one annotated protobuf authoring schema. The build derives a restricted root for each target. libprotobuf-mutator mutates that target-specific type rather than an unstructured byte stream, and a generated adapter lowers it into the common runtime before the harness runs curl against a bounded local peer.

The targets still consume ordinary binary protobuf files. Textprotos under scenarios/curl_fuzzer_proto/ are readable full-authoring seed sources; CMake encodes them under build/generated_corpora/.

How an input reaches curl

flowchart TD
    subgraph build["Build time"]
        sources["Annotated authoring schema<br/>eligibility metadata<br/>selected curl.h"]
        descriptor["Descriptor set"]
        generator["Validate one eligibility model"]
        authoring["Metadata-free authoring schema"]
        runtimeSchema["Internal runtime schema<br/>and option manifest"]
        profileSchema["Restricted profile schema<br/>and direct Traits adapter"]
        seeds["Checked-in .textproto seeds"]
        encoder["ordinary protoc --encode<br/>authoring Scenario"]
        corpus["Per-target .scenario corpus"]
        target["Structured fuzzer executable<br/>linked to one generated profile"]

        sources --> descriptor --> generator
        generator --> authoring
        generator --> runtimeSchema
        generator --> profileSchema
        authoring --> encoder
        seeds --> encoder --> corpus
        runtimeSchema --> target
        profileSchema --> target
    end

    subgraph runtime["Each fuzz or replay iteration"]
        input["Binary profile-compatible input"]
        lpm["libprotobuf-mutator<br/>generated profile root"]
        lower["Direct lower to internal Scenario"]
        fixed{"Fixed profile?"}
        normalize["Normalize selectors<br/>generated prune<br/>bound surviving shape"]
        runner["Scenario runner"]
        curl["curl"]
        peer["Harness-owned local peer"]

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

    corpus --> input
    target --> input

SetOption is an ordered oneof of typed fields. Each field declares its stable protobuf tag, native CURLOPT_* identity, and profile membership beside the value. Structured fields carry the same kind of eligibility metadata. The generator checks those declarations against the selected curl checkout’s curl.h, then derives the internal CurlOptionId, value-kind manifest, profile cases, and direct transfers. There is no parallel handwritten C++ option allowlist.

The metadata-free authoring view is staged at build/schemas/curl_fuzzer.proto. Normal checked-in textprotos are encoded against that full view with ordinary protoc --encode. Generated restricted roots keep the same field numbers and wire types, so a target can parse those bytes directly and discard unknown fields. No separate per-profile seed encoder is required.

Each thin entrypoint binds its generated Traits once into an ordinary kProfile descriptor and passes that descriptor to non-template entrypoint helpers. Mutation, crossover, and loading therefore use the same concrete message type without exposing template specializations to Fuzz Introspector. Static profile projection prevents unsupported fields from entering the mutation graph. Generated conditional pruning runs after selector normalization and before limits, while C++ continues to own scheme fixing, selector canonicalization, work bounds, resource lifetimes, and peer behavior. The compatibility target uses a generated broad root and keeps its historical mixed execution mode; the schema format itself still changed in place.

Pre-cutover binaries are not automatically detected or loaded. Reproduce them with the revision and target that created them.

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. The C++ reference documents the implementation’s classes, functions, and ownership contracts.

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 is an ordered oneof whose field names and protobuf types identify the curl options directly. At most one option can be selected in each entry, and oneof presence preserves explicit false, zero, and empty values:

options {
  nobody: true
}

The typed field carries build-time metadata naming CURLOPT_NOBODY and listing the profiles allowed to retain it. There is no separate option ID or value-kind field in authored input. Repeating options preserves setopt order and duplicates.

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 and its closed state-probe profile, resolve_entries, happy_eyeballs, doh, TLS certificate selection, HTTPS/H2 key-exchange selection, bounded HTTPS client-credential and handshake profiles, pop3_starttls_filter_allocation_failure, ftp_auth_tls_filter_allocation_failure, gopher_probe_mode, mqtt_probe_mode, smtp_probe_mode, socks_proxy_mode, haproxy_protocol, http_transport_mode, proxy_socket_option_mode, trace_ids, probe_multi_pollset, resolver_mode, filename-backed parser inputs, and fail_allocations.

Read the comments in schemas/curl_fuzzer.proto for the complete field contract. It is the annotated authoring schema: typed option fields declare their native curl identity and profile membership, and structured fields declare their own eligibility. During the build, the schema-eligibility generator validates those declarations against the selected curl checkout’s curl.h, emits restricted target roots, and stages a metadata-free authoring view at build/schemas/curl_fuzzer.proto for ordinary protoc and browser tools.

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.

The deep HTTP lane can instead select a fixed plaintext forward proxy, a fixed HTTP/1.1 CONNECT proxy, or a fixed abstract Unix-domain origin with http_transport_mode. Proxy traffic is always directed to a numeric loopback endpoint and every socket is replaced by the in-process peer; Unix mode uses a harness-owned abstract name and creates no filesystem entry. Use request-triggered on_readable chunks for these modes. A CONNECT script puts the tunnel response first and the origin response second. The correlated haproxy_protocol CONNECT profile instead sends the bounded request headers to both the proxy and origin and applies fixed bounded send pressure. The long valid CONNECT request, small peer drains, and repeated pollset probes make the pending fixed IPv6 HAProxy v1 setup state reachable without allowing the scenario to select either endpoint.

The deep HTTP lane’s proxy_socket_option_mode selects a closed set of option paths. Fixed hostname, IPv4-CIDR, and IPv6-CIDR lists exercise CURLOPT_NOPROXY; a scoped http_proxy override points only at a fixed numeric loopback endpoint intercepted by the socketpair callback and restores the previous environment afterwards. The interface/keepalive mode returns an unconnected INET socket only for its owned ephemeral loopback listener, binds it with host!127.0.0.1, and applies fixed TCP keepalive values. Mutations cannot supply an environment value, interface, bind address, proxy endpoint, or destination port.

Other peers intentionally give those fields narrower meanings:

  • WebSocket scenarios use a peer that generates the 101 handshake and accepts either raw chunks or structured server_frames. The WSS lane runs that same Upgrade, framing, callback, and manual-drive behavior after a verified in-process TLS handshake. For CURLOPT_CONNECT_ONLY=2, the peer coalesces the first bounded frame with the 101 response so libcurl must preserve already-read payload for curl_ws_recv; later chunks are delivered by the manual-drive tail.
  • The dedicated HTTPS peer encrypts connection-script bytes as HTTP application data. Its optional tls_client_credential_profile selects only harness-owned malformed blobs or fixed /dev/null engine/provider probes; protobuf bytes never become a filename, engine name, or provider name. pinned_public_key_file instead exposes bounded protobuf bytes through a session-owned anonymous descriptor and installs only its /proc/self/fd path, allowing curl’s filename-backed PEM pin parser to run without host-file access. The separate tls_handshake_profile can replace the OpenSSL peer with a fixed pre-handshake close_notify or empty HelloRequest record. It covers curl’s terminal error translation and message classification without accepting fuzz-selected TLS bytes; GnuTLS and mbedTLS clients treat those profiles as the ordinary verified peer. The compatibility target preserves its older behavior and treats HTTPS script bytes as raw TLS records. These controls deliberately leave Curl_ssl_cfilter_add to a protocol peer capable of an in-place STARTTLS upgrade. Process-lifecycle probes for ossl_cleanup, Curl_ssl_cleanup, Curl_ssl_version, and Curl_tls_keylog_close belong in the one-shot API lane instead of a per-input HTTPS path.
  • 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. An SCHEME_FTPS scenario runs the identical command and passive-data exchange over implicit TLS: the control connection and each EPSV/PASV data connection complete a verified handshake, and the script must answer curl’s PBSZ 0 and PROT P commands after login. Its fixed authority is tls.test, matching the checked-in trust anchor, and the handshakes carry no ALPN extension. Active mode is not available in this lane because it would require wrapping a real TCP listener rather than a socketpair; CURLOPT_FTPPORT is removed from FTPS inputs. The plaintext-only ftp_auth_tls_filter_allocation_failure selector accepts AUTH SSL and fails the adjacent TLS-filter allocation without relying on a build-specific allocation ordinal.
  • TFTP preserves packet boundaries: the initial response and each readable chunk are separate datagrams.
  • SMTP uses the primary connection as command-aligned replies. The initial response is the 220 greeting, each readable chunk answers the next command, and after a 354 DATA reply the peer drains the dot-stuffed message body up to its \r\n.\r\n end marker before releasing the final 250. Envelope recipients come from the schema-native mail_rcpt list because CURLOPT_MAIL_RCPT is a curl_slist; the URL path supplies curl’s EHLO domain rather than the recipient in current libcurl. An SCHEME_SMTPS scenario runs the identical exchange over implicit TLS; its fixed authority is tls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. Plaintext SMTP may select a closed smtp_probe_mode for optional or required STARTTLS, the adjacent TLS-filter allocation failure, or one fixed command-send backpressure shape. Accepted STARTTLS probes end the plaintext peer with bounded EOF; implicit SMTPS remains the lane’s real successful TLS carrier.
  • IMAP uses the primary connection as command-aligned replies. The initial response is the * OK greeting, each readable chunk answers the next command, and a status line beginning with OK, NO, BAD, or PREAUTH receives curl’s generated command tag. A {N} literal copies the next N bytes verbatim, so FETCH bodies and their tagged completion can share one chunk. An accepted APPEND continuation drains exactly the announced literal size. An SCHEME_IMAPS scenario runs the identical exchange over implicit TLS; its fixed authority is tls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. IMAP may retain a bounded receive-buffer size to exercise long-command short writes; drain throttling is always cleared.
  • POP3 also uses command-aligned replies, but its +OK/-ERR status lines need no rewriting. Multi-line CAPA, LIST, and RETR replies include their terminating dot line and are written unchanged. An SCHEME_POP3S scenario runs the identical exchange over implicit TLS against the same tls.test authority and also negotiates no ALPN protocol. POP3 may retain the same recv-buffer-only pressure. Its semantic STLS selector arms one failure immediately after the peer accepts STLS, so it targets filter installation without depending on earlier allocation ordinals.
  • Gopher normally writes one bounded selector through the stream peer. Its closed probe selector can split that write with no operation deadline, fill the harness-owned socket before a five-millisecond timeout, or select a fixed Gophers authority that reaches the TLS session-key size limit. No protobuf bytes control the environment value, timeout, or oversized host.
  • The proxy-transport lane uses request-triggered chunks for each SOCKS reply and then for tunneled HTTP. SOCKS5 advertises deterministic no-auth and username/password methods; the scripted method-selection byte chooses the path. haproxy_protocol inserts a protocol-v1 prefix after tunnel setup with fixed loopback metadata, never a fuzz-selected endpoint.
  • SMTP, IMAP, POP3, FTP, FTPS, MQTT, MQTTS, the proxy-transport lane, and deep HTTP CONNECT retain the opt-in probe_multi_pollset field. When it is true, their drivers call zero-timeout curl_multi_poll after successful perform turns while both the transfer and protocol connection are live. This reaches protocol/filter pollset callbacks without delaying the default false path. Other fixed target policies, including direct, forward-proxy, and Unix-domain deep HTTP, clear the field until their loops have a useful intermediate state to probe.
  • MQTT treats the primary connection as a binary packet stream. The initial response is the CONNACK, while each readable chunk is an opaque server-initiated packet (for example PUBLISH followed by a server DISCONNECT) released after the peer acknowledges curl’s SUBSCRIBE. Client packets are delimited by MQTT’s fixed header and base-128 remaining length, not by newlines; the URL path becomes the publish/subscribe topic and CURLOPT_POSTFIELDS supplies the publish payload. An SCHEME_MQTTS scenario runs the identical packet exchange over implicit TLS; its fixed authority is tls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. Its closed probe selector can publish a fixed 16 KiB body through a small, incrementally drained socket or install a fixed 65,536-byte username or password. This reaches queued-send and 16-bit credential-limit handling without placing oversized values in the protobuf corpus.
  • DICT uses the primary connection for one request/response exchange. curl writes a CLIENT identification line, a command derived from the URL path (DEFINE from /d:word, MATCH from /match:word:db, SHOW from /show:db, or a raw command with colons replaced by spaces), and QUIT in a single send. It then reads the response as an unframed text stream, so the first non-CLIENT/non-QUIT on_readable entry is the complete body and the peer half-closes after writing it. Include the RFC 2229 .\r\n terminator in the seed when a well-formed body is desired; curl does not parse it and treats EOF as end-of-transfer. initial_response is normally empty because DICT has no server greeting.
  • FILE has no peer. Its generated schema omits host_path and retains bounded file_source bytes plus upload scripts and their scalar controls. The runtime places the source in a session-owned anonymous regular file and constructs the complete file:///proc/self/fd/N URL. Mutations therefore exercise curl’s local read, write, range, resume, time-condition, metadata, and upload rate-limit paths without reading or writing an arbitrary host path.
  • LDAP is BER/ASN.1, not line-oriented. initial_response, when present, is the scripted bind reply and is released only after curl’s bindRequest with its envelope messageID rewritten to the request id; an empty value asks the peer to synthesize a successful bind. Each readable chunk is one complete server LDAPMessage (for example a searchResEntry) forwarded after the searchRequest and likewise renumbered. A scripted searchResDone ends the search; otherwise the peer synthesizes one. When login options request SASL, the peer recognizes OpenLDAP’s root-DSE supportedSASLMechanisms search, advertises bounded PLAIN support, and preserves the readable script for the later URL-derived search. The URL path carries the base DN, attributes, scope, and filter, because this curl revision’s OpenLDAP handler does not consult CURLOPT_CUSTOMREQUEST. An SCHEME_LDAPS scenario runs the identical BER exchange over implicit TLS. In this curl revision OpenLDAP itself is built without TLS: lib/openldap.c drives curl’s SSL connection filter and routes OpenLDAP’s sockbuf I/O through curl’s send/receive callbacks, so the in-process peer terminates ordinary TLS records and then parses decrypted LDAPMessages. The fixed authority is tls.test, matching the checked-in trust anchor, and the handshake carries no ALPN extension. A plaintext SCHEME_LDAP scenario may instead set ldap_starttls: the peer recognizes OpenLDAP’s ExtendedRequest and returns a successful correlated ExtendedResponse before curl installs its TLS filter. The peer intentionally closes the unimplemented upgraded carrier, keeping this transition bounded.
  • RTSP is request-aligned and has no server greeting. The peer consumes each request start line and header block, releases the next on_readable entry only after the terminating empty line, and drains any announced Content-Length body exactly. A response chunk may carry the status line, headers, an SDP/parameter body, and RFC 2326 interleaved RTP frames ($, channel, 16-bit length, payload). CURLOPT_RTSP_REQUEST selects the method, and the response’s own CSeq stays mutation-controlled so mismatches reach curl’s CURLE_RTSP_CSEQ_ERROR path. Closed probe modes repeat OPTIONS first at the same origin and then at a fixed second origin to exercise cached-connection and learned-Session handling, or split an invalid RTP channel at a fixed receive boundary and fail only the resulting one-byte body write.
  • IPFS and IPNS are gateway schemes rather than wire protocols. libcurl has no ipfs:// handler; curl’s command-line tool rewrites ipfs://<cid> and ipns://<name> to <gateway>/ipfs/<cid> or <gateway>/ipns/<name> before issuing an ordinary HTTP request. The structured lane mirrors that rewrite against a fixed loopback gateway and the in-process HTTP peer. host_path holds a pinned CID/name followed by the mutation-controlled gateway path, query, and fragment; the runner prepends the gateway authority.
  • The resolver lane’s doh settings bind a loopback DoH responder and point CURLOPT_DOH_URL at it. transport_mode preserves the historical HTTP endpoint by default or selects an HTTPS endpoint backed by a dedicated trust-anchor-verified HTTP/1.1 TLS peer. Normal response mode answers address and HTTPS queries; its HTTPS record carries alpn=h2,h3, so the separate outer HTTPS transfer completes through the in-process TLS/ALPN h2 peer using http2_plan. Malformed-DNS mode returns HTTP 200 with a one-byte DNS body, HTTP-error mode returns a non-success status before curl can decode DNS bytes, and transport-error mode closes only after receiving a complete DoH request so curl records the internal transfer failure. Non-default option_probe_mode values force HTTPS and install one closed, harness-owned parent-option fixture: a non-global diagnostic stream, procfs-backed CAINFO, CAPATH, and CRLFILE pathnames, or a no-op SSL_CTX callback and data pointer. Separate modes keep the deliberately invalid pathname fixture from masking the callback’s successful end-to-end propagation. CURLOPT_RESOLVE is cleared because it cannot carry SVCB/HTTPS records. A paired coverage seed repeats that exact exchange with trace_config: "all"; tracing is reset at the input boundary and does not become a resolver-lane default. An ECH seed releases the address answers first and delays the HTTPS answer by one deterministic drive turn, covering TLS setup while the record is pending without relying on scheduling or wall-clock timing.
  • The resolver lane’s non-default resolver_mode values select fixed, target-owned states. Positive and negative threaded lookups, plus a scope-less link-local result and AF_UNSPEC retry, use only the resolver binary’s local getaddrinfo backend. The threaded modes receive bounded poll/wait turns; the partial backend intentionally staggers A behind AAAA so curl consumes a live partial result instead of relying on scheduler luck. Queued-cancellation mode limits the public resolver pool to one worker, holds its first lookup while a duplicated easy queues later work, cancels that duplicate, then observes the worker’s public THREADS idle trace after releasing the gate. The observation proves entry to the configured timed condition wait without using elapsed time as the coverage contract. Unix-socket mode passes /curl-fuzzer-resolver.sock to curl’s address builder, but the open-socket callback replaces it with an anonymous peer and never opens that filesystem path. DoH error settings enable silent typed diagnostics, distinguishing DNS decode failures from HTTP failures without writing per-input logs. The global-lifecycle mode finishes a localhost transfer, calls the public global cleanup entrypoint only after all resolver workers are gone, and reinstalls the harness allocator callbacks before another input runs.
  • HTTP/2 origin lanes treat connection bytes as raw HTTP/2 frames after a fixed prior-knowledge or TLS/ALPN setup. The HTTPS CONNECT proxy does the same when connection.proxy_protocol is zero; its explicit HTTP/1 profile instead treats the script as a bounded CONNECT response and always uses a tunnel. The H2 profile can select either CONNECT tunnelling or direct absolute-form forwarding with h2_forward_proxy. The fast HTTP lane retains the HTTP/1.1 h2c Upgrade request and 101 transition instead.
  • Direct HTTP/3 uses ordered http3_plan actions after a valid QUIC/TLS handshake and discards the ordinary connection script. Setting http3_plan.use_h1_connect_udp_proxy instead uses connection for a local HTTP/1.1 CONNECT-UDP upgrade and capsule bytes; direct HTTP/3 actions are cleared.

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.

Select allocation failures

All structured targets can reject selected libcurl allocation attempts while driving a transfer:

fail_allocations: 5
fail_allocations: 9

Ordinals are one-based and count positive-size calls on the fuzzer thread through the callbacks installed by curl_global_init_mem. Scenario parsing stays outside the sequence; libcurl setup, transfer driving, result probes, and cleanup inside the shared scenario runner are included except for narrow harness ownership scopes. The COPYPOSTFIELDS ownership commit and pushed-handle cleanup enumeration neither fail nor advance the ordinal counter, so failures remain pending for subsequent injectable allocations. Fixed target policies retain the four smallest distinct values after folding them into 1..4096; the compatibility target applies that bound at execution time without rewriting the message. State-dependent probes still require the preceding transfer entrypoint to establish their documented runtime state.

In the API target, a scenario that combines API_DRIVE_EASY_EVENTS with an allocation plan is normalized to API_DRIVE_EASY_PERFORM. After its heap-backed socket-monitor allocation fails, curl’s current debug event adapter returns a status outside the socket-callback contract; event-based driving remains available when no allocation failure is selected.

An active plan can combine a MIME body with CURLOPT_CRLF or CURLOPT_TRANSFERTEXT, allowing allocation injection across curl’s composed MIME and line-conversion reader stack. curl clears partially installed readers when that setup fails.

Multiple failures describe one evolving execution. In the example, 9 means the ninth allocation attempt on the path produced after attempt 5 failed, not the ninth attempt from a failure-free baseline. Keep failure seeds with the target that produced them: allocation sequences can change with the target, curl revision, build configuration, and TLS backend. Resolver and peer worker threads deliberately do not inherit the calling thread’s plan.

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 full authoring Scenario with ordinary protoc --encode and creates <name>.scenario in the corpus assigned to that target. The target’s generated root preserves retained field tags and discards fields outside its profile when it parses the file.
  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. The decoder embeds the metadata-free full authoring view prepared from the annotated schema, so it can inspect any current profile payload without loading eligibility annotations.

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. The typed option cutover changed the existing format in place: pre-cutover generic-option binaries can parse misleadingly under a current schema and are not detected automatically. Reproduce them with their original revision and the target that produced them. Current checked-in seeds are ordinary authoring textprotos encoded directly by protoc; there is no separate per-profile seed encoder.

Target profiles

All structured targets are generated from one authoring schema and share most of the runtime, but each target gives libprotobuf-mutator its own restricted message type. Retained fields keep the authoring field numbers and wire types, so ordinary authored seed bytes remain profile-compatible. A profile defines the mutation surface, peer, semantic normalization, and work budget.

TargetProfile and peer focus
curl_fuzzer_protoBroad generated compatibility profile for mixed HTTP, HTTPS, WebSocket, and TELNET behavior. It preserves the historical execution mode, but uses the new typed-option schema like every other target.
curl_fuzzer_proto_httpHigh-throughput plaintext HTTP/1 and h2c upgrade negotiation. Retains cheap request options and raw response parsing; removes MIME, uploads, follow-on sockets, prior-knowledge HTTP/2, HTTP/3 selection, and timing controls.
curl_fuzzer_proto_http_deepStateful HTTP coverage, including redirects, dead connection-pool pruning, authentication, MIME construction and redirected body rewinds, uploads, result APIs, bounded cookie, Alt-Svc, HSTS, and netrc files; harness-owned forward-proxy, HTTP CONNECT (including a bounded pending HAProxy-prefix pollset profile), and abstract Unix-domain transports; plus fixed no-proxy/environment-proxy parsing and owned-loopback interface/keepalive setup.
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, bounded CRL and public-key-pin files, closed harness-owned client-credential profiles, and fixed OpenSSL close-notify and HelloRequest probes.
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, input-selectable X25519 or provider-default key exchange, plus push, live stream-weight, fatal-session, and upkeep probes.
curl_fuzzer_proto_http3Structured or raw HTTP/3/QPACK work after a real local QUIC/TLS handshake, or capsule traffic through a local HTTP/1.1 CONNECT-UDP proxy.
curl_fuzzer_proto_h2_proxyAn HTTP/1.1 origin request through a fixed trust-anchor-verified HTTPS CONNECT proxy. connection.proxy_protocol selects the existing HTTP/2 frame peer or a bounded HTTP/1.1 CONNECT response profile; proxy authentication options remain owned by this lane.
curl_fuzzer_proto_socks4HTTP through an in-process SOCKS4, SOCKS4A, SOCKS5, or SOCKS5-hostname proxy, including scripted SOCKS5 username/password authentication and an optional fixed HAProxy protocol-v1 prefix. The historical basename is retained as the target identity.
curl_fuzzer_proto_resolverLocalhost and bounded CURLOPT_RESOLVE cache operations; deterministic positive, negative, link-local retry, queued-cancellation/idle-wait, and Unix-socket resolver states; a real loopback happy-eyeballs race; and loopback HTTP/HTTPS DoH normal, malformed-DNS, HTTP-error, or transport-error responses.
curl_fuzzer_proto_wsPlaintext WebSocket handshake, framing, callbacks, and manual-drive paths.
curl_fuzzer_proto_wssVerified in-process TLS followed by the WebSocket handshake, framing, callbacks, and manual-drive paths.
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. The same target runs implicit-TLS ftps:// scenarios through a verified in-process TLS peer, with both control and passive data channels wrapped and active mode deliberately unavailable.
curl_fuzzer_proto_tftpPacket-preserving TFTP exchanges over private loopback UDP endpoints.
curl_fuzzer_proto_smtpSingle-connection SMTP greeting, EHLO/HELO, mail envelope, DATA body upload and EOB drain, graceful QUIT, and opt-in active pollset probes. The same target also runs implicit-TLS smtps:// scenarios through a verified in-process TLS peer.
curl_fuzzer_proto_imapSingle-connection IMAP greeting, CAPABILITY, LOGIN/AUTHENTICATE (including SASL LOGIN, DIGEST-MD5 challenge handling, and XOAUTH2 bearer construction), SELECT, SEARCH, FETCH literals and custom listings, APPEND uploads, tagged completion, and opt-in active pollset probes. The same target also runs implicit-TLS imaps:// scenarios through a verified in-process TLS peer.
curl_fuzzer_proto_pop3Single-connection POP3 greeting, CAPA, USER/PASS or APOP, LIST, RETR multi-line bodies, recv-buffer pressure, semantic STLS filter-allocation failure, QUIT, and opt-in active pollset probes. The same target also runs implicit-TLS pop3s:// scenarios through a verified in-process TLS peer.
curl_fuzzer_proto_mqttSingle-connection binary MQTT CONNECT/CONNACK, PUBLISH or SUBSCRIBE/SUBACK, server-initiated PUBLISH packets, DISCONNECT, and closed queued-send and oversized-credential probes. The same target also runs implicit-TLS mqtts:// scenarios through a verified in-process TLS peer.
curl_fuzzer_proto_dictSingle-connection DICT lookups. curl writes CLIENT, one URL-derived command, and QUIT together, then reads the multi-line text response until the peer half-closes.
curl_fuzzer_proto_gopherBounded Gopher or Gophers selectors through a stream peer, including closed partial-write, send-timeout, and TLS session-key size probes.
curl_fuzzer_proto_filePeerless local-file reads, ranges, resumes, metadata, and bounded uploads through a harness-owned anonymous regular file. The runtime constructs the complete file:///proc/self/fd/N URL and never accepts a protobuf-controlled pathname.
curl_fuzzer_proto_ldapSingle-connection BER/ASN.1 LDAP simple or SASL PLAIN bind and search through OpenLDAP, including bounded root-DSE mechanism discovery, a scripted bind reply, scripted search-result entries, and synthesized search completion. ldap_starttls drives a correlated plaintext ExtendedRequest/ExtendedResponse before curl installs its TLS filter; SCHEME_LDAPS runs the ordinary exchange over implicit TLS against the in-process OpenSSL peer.
curl_fuzzer_proto_rtspRequest-aligned RTSP method/CSeq exchanges, SDP response bodies, PLAY responses with interleaved RTP packets, and closed cached-connection/Session and split-invalid-RTP write-failure probes.
curl_fuzzer_proto_ipfsipfs:// and ipns:// gateway rewrites as ordinary HTTP GETs against the in-process HTTP peer, with a pinned CID/name and a mutation-controlled gateway path.
curl_fuzzer_proto_apiEasy, share, multi, URL, connect-only, pause/resume, callback-MIME same-handle reuse, 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, an opt-in closed progress/rate-limit probe, 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. The base structured targets are included in OSS-Fuzz’s AddressSanitizer/i386 build. The GnuTLS, mbedTLS, and HTTP/3 variants are excluded from i386 and 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 generated message, its postprocessor lowers it into the internal runtime representation and:

  • fixes or narrows the scheme and, where required, canonicalizes the authority;
  • evaluates generated conditional eligibility after selector normalization;
  • removes variant-specific options and fields that the selected mode 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.

Static option and field selection is declared beside fields in schemas/curl_fuzzer.proto and compiled into each generated profile root. Conditional membership is also declared there and emitted as a generated pruner. The shared limits and semantic selector normalization remain in proto_fuzzer/scenario_limits.h and 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 normally clear backpressure because a single mutated value would otherwise move an ordinary case into a timed loop. IMAP and POP3 retain only a bounded receive-buffer size and clear drain throttling, so long commands can short-write without adding waits. The timing lane retains both controls and forces bounded pressure. Direct HTTP/3 discards the stream Connection; CONNECT-UDP mode retains it and clears direct HTTP/3 actions. FTP, TFTP, SMTP, IMAP, POP3, MQTT, DICT, LDAP, LDAPS, FTPS, MQTTS, IMAPS, POP3S, RTSP, IPFS/IPNS, TELNET, Gopher, and FILE prune fields that their specialized peers (or, for FILE, the peerless local handler) cannot observe. The IPFS/IPNS lane rewrites both schemes to a /ipfs/<cid> or /ipns/<name> HTTP path on a fixed loopback gateway before the request reaches the ordinary HTTP peer. The FILE lane additionally removes host_path from its generated schema and exposes bounded file_source bytes through a session-owned anonymous descriptor. It retains bounded upload state and can read or write only that descriptor; the runtime constructs the complete file:///proc/self/fd/N URL without granting access to host files.

The resolver profile also clears arbitrary backpressure. Its successful HTTPS-RR exchange has a trace-enabled coverage twin with the same peer transcript; trace state remains scoped to that input. A second HTTPS-RR seed requests ECH and defers that answer for one drive turn, so TLS setup observes the record as pending before the transfer completes. Its five threaded resolver_mode values install one fixed timed-drive marker instead. Their authorities are pinned to names handled by the resolver binary’s target-local getaddrinfo backend: no ambient name service is consulted, returned address lists are owned and bounded, and a fixed family delay makes the partial-result path intentional. The queued-cancellation mode holds one worker-owned lookup, cancels only later queue-owned work, then requires the public THREADS trace to report the worker idle after release; acquiring that trace state proves the worker entered its configured timed wait. The Unix mode gives curl a fixed nonexistent pathname, but the open-socket callback still substitutes its anonymous socketpair. The DoH settings retain the historical HTTP listener or select a trust-anchor-verified HTTPS listener, independently of normal HTTPS-RR answers, one-byte malformed DNS messages, a non-success HTTP status, or a close after a complete request. Three closed HTTPS option probes own a custom diagnostic stream, bounded TLS pathnames, or SSL_CTX callback state long enough for curl-created DoH handles to inherit them. A dedicated localhost seed balances the process-wide curl cleanup and allocator-preserving reinitialization lifecycle between inputs, after all per-transfer resolver workers have been joined.

The fixed SMTP, IMAP, POP3, FTP/FTPS, MQTT/MQTTS, and proxy-transport profiles retain probe_multi_pollset; deep HTTP retains it only for its CONNECT proxy mode. Every other fixed profile clears it. The flag adds zero-timeout public multi-poll calls only while those peers’ perform loop has a live transfer and protocol socket, so ordinary false/default corpus entries do not pay for an unconditional polling syscall. The proxy-transport lane may probe several bounded perform turns because its HAProxy filter is installed only after SOCKS negotiation completes.

The multi profile’s MULTI_STATE_PROBE_PROGRESS_RATE_LIMIT value is a closed state-machine probe. The runtime redirects the internal progress meter to an owned anonymous temporary stream, enables it, and applies a fixed one-byte per second receive limit plus a 20-millisecond transfer timeout to every easy handle. The authored seed releases each two-byte response body over two peer turns so curl must observe the rate-limited state before cleanup. None of the sink, rate, or timeout values is mutation-controlled, and failure to create the anonymous stream leaves the probe disabled.

This probe deliberately does not manufacture the error-only Curl_creader_free path: that function needs a request-body reader to be created and then rejected, which would couple this generic state-machine case to an unrelated allocation-failure index. It also does not claim Curl_multi_wakeup_internal, whose live call site belongs to the threaded resolver lifecycle rather than the generic multi runner. The identity content decoder used by the seed does safely exercise Curl_cwriter_def_write as part of the response path.

The compatibility profile is intentionally broad. Its generated root preserves empty option slots and its runner keeps the mixed execution mode rather than applying one fixed lane’s restrictions. The typed-option cutover was still a breaking in-place format change: pre-cutover OSS-Fuzz corpus and crash files must be replayed with their original revision and target. New fields and enum values must continue to avoid reinterpreting older wire tags.

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 and curl-fuzzer revision named by OSS-Fuzz. Profile roots preserve common field tags, but different profiles can discard or normalize the same bytes differently, and pre-cutover bytes use a different option layout.

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. The complete checklist for an ordinary addition is:

  1. Confirm that the option fits this path. Callbacks, slists, blobs, filenames, and other pointer-bearing state usually need a schema-native field and explicit runtime ownership instead.
  2. Add one field to SetOption.setting in schemas/curl_fuzzer.proto. Assign a new, never-reused tag and use bool, uint64, or bytes according to the value curl consumes.
  3. Add the field’s (curl.fuzzer.schema.curl_option) annotation with the exact native CURLOPT_* name and its complete profile eligibility. Use a closed conditional guard only when normalized selector state really changes whether the option is meaningful.
  4. Add or update a target-relevant textproto seed when useful reachability requires correlated options, request data, or peer bytes.
  5. Run the schema tests, inspect the affected generated profile report, build that target, decode its generated seed, and replay it. Add runtime tests if the option needs value normalization, new selector semantics, ownership, or peer behavior.

The schema generator checks the native name and declared protobuf type against the selected checkout’s curl.h. From that one field it derives the internal CurlOptionId, value-kind/native manifest, lookup switch, allowed profile cases, and direct lower/lift transfers. Do not add a handwritten C++ option ID, kind override, profile allowlist, or adapter case. If none of the existing closed guard facts describes a new conditional rule, add and test a new fact deliberately; do not encode an arbitrary expression in the schema.

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 typed option oneof exposes only their in-memory controls. CURLOPT_FTPPORT and CURLOPT_FTP_USE_EPRT are safe because the plaintext FTP peer confines active listeners to loopback; the implicit-TLS FTPS lane removes them and supports passive data channels only. The slist-backed QUOTE options still need protocol-specific 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 complete schema-owned eligibility, including contextual-path metadata when applicable. If a closed guard fact reads the new field, add its path to that fact’s generator-owned input list; schema generation verifies that every guarded profile retains it through selector normalization.
  • Add an appropriate complexity limit in proto_fuzzer/scenario_limits.h and enforce the runtime-visible bound in proto_fuzzer/target_policy.cc.
  • Implement ownership and execution in the request-data, runner, or peer layer.
  • Decide deliberately whether the generated compatibility root should expose the field; a newly assigned tag must not accidentally reinterpret historical bytes.
  • 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 profile projection must exclude ineligible shapes, and target normalization must prune conditional fields before applying bounds so libprotobuf-mutator does not keep allocating and mutating data that cannot add coverage. Runtime bounds remain necessary for direct callers and for the broad compatibility lane.

Add a target profile

A new lane normally requires all of the following:

  1. A file-level profile declaration in schemas/curl_fuzzer.proto, including its exact C++ TargetProfile name, generated root, and empty-option policy; add complete membership to every field and option it can reach.
  2. A TargetProfile and complete RunModeFor mapping in proto_fuzzer/target_profile.h. Schema generation checks that this C++ enum and the registry are complete and unique.
  3. A policy branch in proto_fuzzer/target_policy.cc that fixes routing, normalizes selectors, and applies semantic bounds. Static field and option membership belongs in the schema, not this branch.
  4. 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.
  5. A thin fuzzer_entrypoints/curl_fuzzer_proto_<name>.cc binding its generated Traits once into a kProfile descriptor and passing that descriptor to all three ordinary libFuzzer entrypoint helpers.
  6. A CMake executable and profile-generation declaration, generated corpus declaration (or an explicit reuse mapping in scripts/fuzz_corpus_helpers.sh), and dependency from the executable to that corpus.
  7. An ossconfig/<target>.options file and an entry in scripts/fuzz_targets, with architecture or sanitizer gates when required.
  8. Schema-generation, entrypoint, target-policy, peer/runtime, packaging, and representative scenario tests.
  9. 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

Keep function descriptions, parameter contracts, and return documentation beside their definitions in .cc files. Do not repeat them on header declarations. Class, type, and field documentation stays in headers, as does documentation for functions defined there and pure virtual contracts. Run the placement and coverage checks with cmake --build build --target doxygen-check.

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_schema_eligibility.py \
  tests/test_active_schema_cutover.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.

C++ reference

The structured harness C++ reference documents the classes and functions under proto_fuzzer/ for contributors. It includes ownership and lifetime contracts, searchable symbols, and links to implementation source. Generated protobuf types and dependency internals are outside its scope.

Doxygen renders the reference directly from the source. Function descriptions live beside their definitions in .cc files; headers retain type and member documentation. See Development to build it locally.

Python tools

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

Install with uv

uv sync
uv run read_proto_corpus /path/to/testcase

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_proto_corpusDecode one binary protobuf scenario using protoc
generate_decoder_htmlBuild the standalone browser decoder for protobuf Scenario inputs
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_schema_eligibilityValidate annotated schema eligibility and emit authoring, runtime, profile, adapter, report, and machine-contract artifacts
compare_fuzzersCompare repeatable throughput and source coverage measurements between fuzzer builds
profile_fuzzerCollect a reproducible Linux perf profile from one production libFuzzer target
bootstrap_http2_corpusSelect compatible HTTP/2 inputs into a dedicated corpus
check_python_protobufVerify that the installed Python protobuf runtime is compatible
coverage_historyFetch aggregate or per-target OSS-Fuzz curl/lib history as a table, JSON, CSV, or SVG

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

OSS-Fuzz coverage history

coverage_history reads the public daily summary.json artifacts and sums the files under curl/lib. Aggregate reports use https://storage.googleapis.com/oss-fuzz-coverage/curl/reports/{YYYYMMDD}/linux/summary.json; passing --target selects https://storage.googleapis.com/oss-fuzz-coverage/curl/reports-by-target/{YYYYMMDD}/{TARGET}/linux/summary.json. Each JSON or CSV observation retains the exact URL used.

uv run coverage_history \
  --start 2026-10-01 --end 2026-10-28 \
  --format svg --metric functions > curl-lib-functions.svg

uv run coverage_history \
  --start 2026-10-01 --end 2026-10-28 \
  --target curl_fuzzer_proto_ldap --format json

Schema generation and seed encoding

CMake invokes generate_schema_eligibility after host protoc has compiled the annotated schema to a descriptor set. The command validates the profile registry, contextual field shapes, native option names and types, and the mapping to the C++ TargetProfile enum. Its generated files live under build/schema_eligibility/; the metadata-free full authoring schema is staged separately at build/schemas/curl_fuzzer.proto.

Normal checked-in textprotos need no projection command. CMake encodes the full authoring Scenario with ordinary protoc --encode, and each restricted target root preserves retained field tags while discarding unknown fields. Generated conditional pruning and C++ target normalization run after the target parses those bytes.

The typed-option edit was a breaking in-place schema change. Historical binary inputs are not detected or converted by current tooling; reproduce them with the curl-fuzzer revision and target that produced them.

Browser decoder

The published decoder accepts protobuf Scenario inputs and does not upload the selected file. The generator bundles the locked protobuf.js runtime and a metadata-free full authoring schema into the HTML, so the resulting page does not need the generator-only eligibility import and does not load a decoder from a CDN. It reuses build/schemas/curl_fuzzer.proto when available; otherwise it invokes protoc on the annotated checked-in schema and strips the metadata in memory. 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 reports libcurl and curl-fuzzer separately. The libcurl report includes curl’s lib/, public include/curl/ headers, and any mapped src/ files. The harness report covers checked-in C++ sources and headers, excluding tests, generated protobuf code, dependency build trees, and the standalone replay engine.

./codecoverage.sh

The generated reports under build-coverage/coverage/ are:

  • summary.txt and html/index.html, libcurl text and annotated HTML reports;
  • harness-summary.txt and harness-html/index.html, harness text and annotated HTML reports;
  • curl-coverage.json and harness-coverage.json, scoped LLVM exports for further analysis;
  • report.md and gaps.json, separate line, function, region, and branch counts plus ranked remaining gaps;
  • replay-status.json, per-target input counts, exit codes, runner counters, and fresh profile evidence;
  • replay-logs/ and replay-counters/, retained target diagnostics and numeric input outcomes.

Percentages use exact covered/total counters. An unavailable metric or empty denominator is not reported as 100%. Counts cover sources mapped into the selected binaries; disabled features and other platforms require separate builds. Compare changes using the same curl revision, toolchain, features, and corpora, and investigate failed or incomplete replay before interpreting a result as complete. report.md labels the selected replay as complete or partial before presenting coverage percentages. A complete replay requires every selected target to finish every counted input without detected errors and to write a fresh nonempty profile; it does not mean 100% source coverage. Failed, missing, or incomplete targets remain explicit while later targets continue to contribute partial coverage.

Coverage and Fuzz Introspector builds configure curl with CURL_DISABLE_TYPECHECK=ON. curl’s public C type-checking macros provide compile-time diagnostics, but internal curl_easy_setopt calls can expand them into thousands of regions whose type-error arms cannot execute. Removing that compiler-only surface makes region coverage describe runtime behavior. Runtime error strings and verbose tracing remain enabled and fuzzable. The same setting is applied to local coverage (ENABLE_COVERAGE=ON or SANITIZER=coverage) and Fuzz Introspector (SANITIZER=introspector) builds.

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

Each invocation replaces the previous replay profiles and derived reports, so a single-target rerun reports only that selection. Failed merges retain replay diagnostics without leaving old coverage reports available.

The GitHub Actions Coverage workflow is manual. It uploads both source reports and their gap summaries and replay diagnostics, writes report.md 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.

curl_fuzzer_proto_api supplements corpus replay with deterministic public compatibility API probes, such as formatting and deprecated form serialization. The probes execute once per process in coverage builds (ENABLE_COVERAGE=ON or SANITIZER=coverage) and Fuzz Introspector builds (SANITIZER=introspector). They reuse the proto runtime’s global curl initialization and never perform global cleanup from an input callback. The runtime releases its global resolver and TLS state only during process exit. An initialization hook covers empty-corpus merge jobs, while the first input callback is a fallback for standalone replay engines that omit that hook.

Utility probes cover the version string, documented date formats, and deprecated escape/unescape aliases with short fixed strings and explicit binary lengths.

Dynamic formatting probes use curl’s real private buffer type and small explicit limits to exercise exact fits, staging boundaries, and size-limit errors. They allocate at most 1 KiB per case rather than depending on allocation failures or large formatted output.

Every structured target supports allocation fault injection through the scenario’s fail_allocations field. The shared runtime installs libcurl’s curl_global_init_mem callbacks once, before its ordinary global initialization, then arms them only for the shared scenario runner. Protobuf parsing remains outside the selected sequence; libcurl easy-handle setup, transfer driving, result probes, and teardown are all part of it except for narrowly scoped harness ownership operations. The COPYPOSTFIELDS ownership commit and pushed-handle cleanup enumeration suppress injected failures; allocations in those scopes neither count nor consume ordinals, leaving failures pending for subsequent injectable allocations. Result probes still honor their public state preconditions: in particular, upkeep runs only after an easy drive has progressed far enough to attach its private multi.

Values are one-based positive-size allocation attempt numbers. Fixed target policies sort, deduplicate, and retain at most four ordinals in the range 1..4096; the compatibility runtime applies the same safety bound without rewriting its persistent input. A plan such as 5, 9 rejects the fifth attempt, then interprets 9 as the ninth attempt on the execution path already changed by that first failure. Ordinals reproduce within the same target, build, TLS backend, and input; they are not source locations or portable identifiers.

The API target keeps its event-based easy drive for inputs without a failure plan. When a plan is present, the target normalizes that drive to ordinary curl_easy_perform: after its socket-monitor allocation fails, curl’s current debug event adapter returns a status outside the socket-callback contract and later removes a record it never inserted. This keeps the ordinal plan focused on libcurl transfer and parser state without call-stack-dependent exclusions.

MIME bodies retain CURLOPT_CRLF and CURLOPT_TRANSFERTEXT under active plans, so allocation injection covers failures while curl composes its MIME and line-conversion readers. curl clears the partially installed reader stack if that setup fails.

The active state is thread-local. Worker-thread allocations pass through, and only allocations routed through libcurl’s configured callbacks are counted. This includes most libcurl storage and some dependency allocations made through curl-provided adapters, but not general C++ harness, protobuf, or TLS library heaps. Free is never rejected, a rejected realloc preserves its input, and zero-size calls do not consume an ordinal. Each fuzzer entry resets the controller before parsing so the standalone runner’s timeout jump cannot carry an armed plan into the next input.

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

Structured protobuf targets

The hosted corpus decoder decodes protobuf Scenario files entirely in the browser without uploading them.

From a source checkout, read_proto_corpus uses the full authoring schema to print field names. Current generated profile roots preserve its retained field numbers and wire types, so the authoring view can inspect target payloads even though libprotobuf-mutator uses a restricted target-specific type. The command can also use the metadata-free 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.

The typed-option cutover changed schemas/curl_fuzzer.proto in place. Always pair a structured testcase with the curl-fuzzer revision and target that produced it. A pre-cutover generic-option input can parse without error under the current schema but mean something different; the runtime deliberately does not auto-detect or fall back to the old format. Reproduce such an input at the original revision and with the target that produced it.

Published reproduction images also include the metadata-free staged authoring 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_proto_http
./mainline.sh -c /path/to/curl -t curl_fuzzer_proto_http

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

FUZZ_VERBOSE=1 ./build/curl_fuzzer_proto_http \
  clusterfuzz-testcase-minimized-curl_fuzzer_proto_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_proto_http \
  /path/to/clusterfuzz-testcase-minimized-curl_fuzzer_proto_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

compare_fuzzers 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.

The examples use uv run to invoke the entry point from the managed project environment. Omit uv run after activating another environment where the project is installed.

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:

uv run compare_fuzzers \
  --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 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:

uv run compare_fuzzers \
  --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 differently named targets, use a target pair:

uv run compare_fuzzers \
  --baseline-dir /path/to/baseline/out/curl \
  --candidate-dir /path/to/candidate/out/curl \
  --target-pair curl_fuzzer_proto=curl_fuzzer_proto_http

The label in the report is, for example, curl_fuzzer_proto=>curl_fuzzer_proto_http. Each side of a mapped pair receives its own native corpus and seed archive. 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.

Structured targets accept --public-corpus-root only when the root contains .structured-schema-owned-eligibility-v1. Create that marker only after the upstream structured corpora have been replaced and the local root has been refreshed as described in Corpus management. Explicit --corpus overrides remain the caller’s schema/revision assertion.

Proto corpus inheritance is limited to binaries that use the exact same generated profile, currently the GnuTLS and mbedTLS HTTPS variants reusing the OpenSSL HTTPS profile. The typed-option cutover does not treat compatibility or other profile corpora as interchangeable merely because retained protobuf tags can parse. Cross-profile corpora are not reused, and pre-cutover public corpora must be benchmarked with the revision and target that produced them.

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 instrumentation-specific coverage and feature counts. They remain recorded for each run, but their cross-build deltas default to null: matching target names, compiler versions, and curl revisions do not prove that two binaries have the same coverage sites and feature semantics. Pass --compare-libfuzzer-counters only when those are known to be identical. Mapped targets always report these deltas as null. Use the source coverage comparison below for coverage parity across different binaries.

Compare curl source coverage

Raw libFuzzer cov and ft counters include each harness and cannot prove source coverage parity between different binaries. 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)

uv run compare_fuzzers \
  --baseline-dir /path/to/baseline-libfuzzer-out \
  --candidate-dir /path/to/candidate-libfuzzer-out \
  --target-pair curl_fuzzer_proto=curl_fuzzer_proto_http \
  --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 all selected target pairs. Benchmark each candidate lane separately when judging whether a change should shift its CPU allocation; supporting lanes may retain coverage without needing to match the primary lane’s throughput.

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

uv run compare_fuzzers \
  ... \
  --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.

Profiling fuzzer CPU use

profile_fuzzer records an on-CPU sampling profile for one production libFuzzer binary. It is intended to find functions and call paths that consume a significant part of the fuzzing budget. Use compare_fuzzers afterwards to measure whether a change actually improves executions per second.

The profiler requires Linux perf and Python 3.10 or newer. The fuzzer should be an optimized, symbolized AddressSanitizer/libFuzzer build with frame pointers. The default binaries produced by mainline.sh use the standalone replay engine and are deliberately rejected.

Build a profiling binary

An OSS-Fuzz libFuzzer build supplies the same compiler, sanitizer, and runtime shape used by the fleet. Given an OSS-Fuzz checkout at /path/to/oss-fuzz:

python3 /path/to/oss-fuzz/infra/helper.py build_fuzzers \
  --sanitizer address \
  --engine libfuzzer \
  curl "$PWD"

The binaries, seed archives, dictionaries, and options are written beneath /path/to/oss-fuzz/build/out/curl/. A target’s packaged .options file is part of the workload: for example, the structured targets set max_len, and the multi target sets a shorter per-input timeout.

Downloaded public corpora provide a more representative input population:

./scripts/download_public_corpus.sh

Record a profile

Profile one target for 60 seconds:

uv run profile_fuzzer \
  --binary-dir /path/to/oss-fuzz/build/out/curl \
  --target curl_fuzzer_proto_http \
  --public-corpus-root ossfuzz_corpus \
  --seconds 60 \
  --output-dir build/profiles/proto-http

The profiler combines the checked-in corpus when a source checkout is available, the target’s packaged seed ZIP, and compatible public corpora. It deduplicates inputs by SHA-256 and runs with a fixed libFuzzer seed. Repeat --corpus PATH to replace these default sources. Each path may be a directory, ZIP archive, or individual input.

For structured targets, the public root must contain .structured-schema-owned-eligibility-v1; see Corpus management. An explicit --corpus path remains the caller’s schema/revision assertion.

By default, the profiler loads <target>.options beside the binary, falling back to ossconfig/<target>.options when a source checkout is available. Use --options-file to select another file and --fuzzer-arg=-name=value for additional non-managed libFuzzer settings. The profiler owns duration, seed, timeouts, artifacts, final stats, and single-process execution so the metadata cannot disagree with the sampled workload.

The default event is the user-space software clock at 99 Hz. It works without a virtualized hardware performance counter and measures time actually executing on a CPU:

uv run profile_fuzzer ... \
  --event cpu-clock:u \
  --frequency 99

The default fp call graph requires frame pointers. Use --call-graph dwarf for an existing binary that omitted them; DWARF stack samples are larger and cost more to collect. --cpu N optionally pins the workload to one CPU.

The output directory must not already exist. A successful run contains:

  • perf.data, the original sampling data;
  • perf-report.txt, a symbolized flat report with all sampled symbols;
  • fuzzer.log, including the exact perf and libFuzzer diagnostics;
  • metadata.json, with binary and corpus hashes, commands, effective options, host settings, execution metrics, and supplied provenance;
  • corpus.zip, the exact deduplicated inputs present before fuzzing starts;
  • summary.md, including the highest-overhead symbols; and
  • artifacts/, for any testcase emitted by libFuzzer.

Pass corpus.zip back through --corpus to repeat the workload after its original sources have changed or disappeared.

The text report remains useful after the build host is gone. Interactive use of perf.data requires the exact profiled binary and its build ID; its SHA-256 and original path are recorded in metadata.json. Preserve that binary alongside the profile, then add it to perf’s build-ID cache before opening the data if the original path is unavailable.

CI or build wrappers can attach revision information without exposing the complete environment:

uv run profile_fuzzer ... \
  --provenance curl_revision=0123456789abcdef \
  --provenance oss_fuzz_revision=fedcba9876543210

Run a profile in GitHub Actions

The Profile fuzzer workflow can be dispatched manually from the Actions tab. Leave the target filter blank to create one matrix job for every target offered by the workflow, or enter a case-sensitive substring such as http2 to profile only matching targets. Then select a 30, 60, or 120 second duration. Each job builds its target with OSS-Fuzz’s AddressSanitizer/libFuzzer configuration, downloads the public corpus, and records a user-space cpu-clock profile.

Each uploaded profile-... artifact contains the report, raw perf.data, folded stacks in perf-folded.txt, an interactive flamegraph.svg, the log, metadata, exact corpus.zip, build-toolchain manifest, and any testcase emitted by the fuzzer. It also includes oss-fuzz-build.tar with the exact binary, options, seed archive, and llvm-symbolizer needed to inspect the raw profile after the hosted runner has gone away.

Interpreting results

Start with the widest stacks in flamegraph.svg and the highest-overhead symbols in the flat report. The flame graph shows inclusive call paths, making it easier to distinguish work owned by the harness from libcurl, TLS, parsing, compression, protobuf mutation, and sanitizer checks. A hot function can be called frequently because it is valuable coverage, so treat the profile as a place to investigate rather than a reason to remove behavior.

Sampling overhead makes the run unsuitable as a throughput benchmark. Compare the candidate against an unchanged baseline with the same corpus and repeated runs as described in Benchmarking.

CPU samples deliberately exclude time spent asleep or blocked. Targets whose purpose includes timing, backpressure, or scheduler behavior need separate off-CPU analysis.

If perf record reports a permission error, inspect /proc/sys/kernel/perf_event_paranoid and the host’s perf policy. The profiler does not invoke sudo; changing that policy belongs to the local machine or CI runner configuration.

Development

Run checks

The Python tests require the host protoc compiler on PATH and the well-known .proto files (protobuf-compiler plus libprotobuf-dev on Debian and Ubuntu). Install them, then install the Python testing and development dependencies:

sudo apt-get install libprotobuf-dev protobuf-compiler
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

Install CMake and Doxygen for the C++ reference, plus protoc for preparing the browser’s metadata-free authoring schema. With Node.js 18 or newer, install the locked JavaScript dependencies used by the protobuf corpus 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
cmake -B build .
cmake --build build --target doxygen-docs
mkdir -p _site/api
cp -R build/proto_fuzzer_reference/html _site/api/proto_fuzzer
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 derives a metadata-free authoring view from the annotated schema with protoc. Only when protoc is unavailable does it reuse a staged build copy. The decoder and C++ reference are generated separately and are therefore not refreshed by the mdBook development server. Run their generation commands again after rebuilding the book. The doxygen-docs target checks the source documentation before rendering HTML; it does not compile curl or the fuzzers.

Run the optional browser tests for the guides, C++ reference, and decoder with:

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

The documentation CI builds the book, decoder, and C++ reference together. The Pages workflow publishes _site/ from master for https://fuzz.curl.se/, with the reference at /api/proto_fuzzer/. 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/, generated schema/profile artifacts under build/schemas/ or build/schema_eligibility/, or downloaded public corpora. Commit the annotated schemas under schemas/, authored Markdown under docs/, direct-fuzzer seeds under corpora/, and full authoring textproto seeds under scenarios/curl_fuzzer_proto/.