Four complete runs on real targets:
- libxml2 + a libFuzzer harness: the C/C++ path, with static-library expansion, an error callback, and a comparison with LTO.
- The
urlcrate + a ziggy harness: the Rust path, starting from the Rustmain. - cpp_demangle + a cargo-afl harness: a feature-gated harness that only parses, so the unused render half is never compiled.
- rustyknife + a cargo-afl harness:
a harness whose old
aflcrate needs build workarounds.
All runs used reachability 1.2-dev (commit f5adeab) with the analyzer on
LLVM 23. The Rust runs used rustc 1.99.0-nightly (2026-07-12, LLVM 22.1.8) and
cargo-afl 0.18.2. Your counts will differ with other library versions, flags
and LLVM releases.
Take libxml2-2.9.2 and a small C++ libFuzzer harness, and compute which libxml2 functions the harness can reach. This shows three features:
- C/C++ builds: building with the gllvm wrappers to get bitcode.
- Static-library expansion: analyzing all of
libxml2.a, not only the parts the harness pulls in (--static-libs). - Indirect calls: the harness installs an error callback, and the analyzer reaches it through a function pointer.
The harness (harness.cc):
#include "libxml/parser.h"
void ignore(void *ctx, const char *msg, ...) {
// Error handler to silence libxml2 parser messages.
}
extern "C" int LLVMFuzzerTestOneInput(const unsigned char *data, size_t size) {
xmlSetGenericErrorFunc(NULL, &ignore);
auto doc = xmlReadMemory(reinterpret_cast<const char *>(data), size,
"noname.xml", NULL, 0);
if (doc) {
xmlFreeDoc(doc);
xmlCleanupParser();
}
return 0;
}A built analyzer and gllvm on PATH, see Install:
export REACHABILITY_ANALYZER=$PWD/analyzer/build/reachability-analyzer
export PATH="$(go env GOPATH)/bin:$PATH" # gclang, gclang++, get-bc
source .venv/bin/activate
reachability check-toolchaincurl -fsSL -O https://raw.githubusercontent.com/vanhauser-thc/fuzzing-targets/refs/heads/master/libxml2-2.9.2.tar.gz
tar xf libxml2-2.9.2.tar.gz
cd libxml2-2.9.2Save the harness above as harness.cc in libxml2-2.9.2/. The public
headers are under include/, so it is compiled with -I include.
One command builds the library, compiles the harness, and analyzes the result:
reachability run \
--lang cpp \
--project . \
--build-cmd './configure --without-python --disable-shared && make -j"$(nproc)" && gclang++ -I include -c harness.cc -o harness.o' \
--artifact harness.o \
--static-libs all \
--entry LLVMFuzzerTestOneInput \
-vWhat each part does:
--build-cmdruns with the gllvm wrappers asCCandCXX, so./configure && makebuildslibxml2.awith bitcode, andgclang++ -ccompiles the harness to an object with bitcode.--without-python --disable-sharedkeeps the build small and static. An auto-detectedconfigurebuild adds--disable-shareditself; it is spelled out here because the command is explicit.--artifact harness.opicks the harness object. Without it, the search would prefer one of libxml2's many test programs.--static-libs allmerges every bitcode archive in the tree with the harness object. This is what makes the report cover all of libxml2, including functions the harness never calls.--entry LLVMFuzzerTestOneInputis already a default for--lang cpp; it is only spelled out here.
Why
alland notauto?autoreads the linked archives from a linked binary, and a lone object has no such list. libxml2 builds two bitcode archives,libxml2.aand a smalltestdso.atest helper, andalltakes both; they link without conflict. If you link a real fuzzer binary (harness,libxml2.aand libFuzzer), point--artifactat that binary and keep the defaultauto; see Static libraries.
The summary line:
reachable 1901 / defined 3035 (329 indirect-only, 14 low-confidence, 1134 unreachable) [backend=type-based]
- defined (3035): every libxml2 function and the harness. With
--static-libs allthis is the whole library. - reachable (1901): the functions reachable from
LLVMFuzzerTestOneInput. - unreachable (1134): everything the harness can never reach, listed in
not_reached.txt. - indirect-only (329): reachable functions that only indirect calls reach. This is where the over-approximation lives.
- low-confidence (14): indirect-only functions that only a type match supports. They stay reachable.
The run also prints
note: added 40 root(s) referenced by name (dlsym/dlopen-by-name reachability): xmlSAX2InternalSubset ….
libxml2 calls dlsym and contains the names of 40 of its own functions as
strings, so they become roots (why). With
--no-name-roots the run reaches 1,867 functions instead of 1,901.
Reachable: the parser and everything it pulls in, since xmlReadMemory
runs a full parse: LLVMFuzzerTestOneInput, xmlReadMemory,
xmlParseDocument, the lexer and SAX callbacks, xmlFreeDoc,
xmlCleanupParser.
The harness's ignore handler is reachable through an indirect edge. The
harness passes &ignore to xmlSetGenericErrorFunc, and libxml2 later calls
the handler through a function pointer. It rates medium: there is value-flow
evidence that its address is called, not only a type match. Being a C++
function, it appears under its mangled name. The harness was compiled without
-g, so file and line are empty:
{
"mangled": "_Z6ignorePvPKcz",
"demangled": "ignore(void*, char const*, ...)",
"key": "_Z6ignorePvPKcz",
"file": null,
"line": null,
"via": "indirect",
"indirect_only": true,
"confidence": "medium",
"depth": 1,
"basic_blocks": 1,
"dangerous_calls": 0,
"local_vars": 2,
"cyclomatic": 1,
"loops": 0,
"interesting": true,
"bottleneck": false,
"dead_end": true
}A directly reached function, with its source location:
{
"mangled": "xmlReadMemory",
"demangled": "xmlReadMemory",
"key": "xmlReadMemory",
"file": "parser.c",
"line": 15376,
"via": "direct",
"indirect_only": false,
"confidence": "high",
"depth": 1,
"basic_blocks": 4,
"dangerous_calls": 0,
"local_vars": 1,
"cyclomatic": 2,
"loops": 0,
"interesting": true,
"bottleneck": false,
"dead_end": false
}All fields are described in output.md.
Unreachable (not_reached.txt): the large parts of libxml2 the harness
never enters, such as the XPath evaluator (xmlXPathEval,
xmlXPathCompile), schema and RelaxNG validation (xmlSchemaValidateDoc,
xmlRelaxNGValidateDoc), and the serialization and writer API
(xmlSaveFile, xmlTextWriterStartDocument). With --static-libs none most of
these would be missing from the report instead of unreachable.
Over-approximation at work.
xmlXPathEvalis unreachable, yet XPath internals such as the axis functionxmlXPathNextAncestorare reachable: 59 reachable indirect call sites have its type,ptr (ptr, ptr), which many callbacks share. XPointer helpers such asxmlXPtrRangeToFunctionare reached the same way and callxmlXPathEvalExprdirectly. The analysis accepts such false edges so that it does not miss real ones.
Pass the ignorelist to the coverage build, so that SanitizerCoverage skips the code the harness cannot reach:
clang -fsanitize-coverage=trace-pc-guard -fsanitize-coverage-ignorelist=not_reached.txt ...-fsanitize-coverage-allowlist=reached.txt is the alternative; see
Allowlist or ignorelist.
LTO already removes functions that nothing references. To see what that means for the lists, build the same library and harness as a libFuzzer binary with full LTO, in a fresh copy of the sources next to the analyzed one:
mkdir lto && tar xf libxml2-2.9.2.tar.gz -C lto && cd lto/libxml2-2.9.2
cp ../../libxml2-2.9.2/harness.cc .
CC=clang-23 CXX=clang++-23 AR=llvm-ar-23 RANLIB=llvm-ranlib-23 NM=llvm-nm-23 \
CFLAGS='-O2 -flto=full' LDFLAGS='-flto=full -fuse-ld=lld' \
./configure --without-python --disable-shared
make -j"$(nproc)" libxml2.la
clang++-23 -O2 -flto=full -fuse-ld=lld -fsanitize=fuzzer -I include harness.cc \
.libs/libxml2.a -lz -llzma -lm -o harness
llvm-nm-23 --defined-only harness | awk '$2 ~ /^[tTwW]$/ {print $3}' > funcs.txt
grep '^fun:' ../../libxml2-2.9.2/not_reached.txt | cut -c5- | grep -Fxc -f funcs.txtThe last command prints how many functions from not_reached.txt are still in
the binary. The same with -flto=thin, and with no LTO (-O2 and
-fuse-ld=lld only):
| Build | Defined text symbols | Left from not_reached.txt (1,134) |
Left from reached.txt (1,901) |
|---|---|---|---|
-O2 -flto=full |
1,871 | 0 | 560 |
-O2 -flto=thin |
2,100 | 0 | 719 |
-O2 |
3,590 | 797 | 1,477 |
With LTO no unreachable function is left, so on this target the ignorelist
changes nothing for an LTO build. Without LTO, 797 unreachable functions stay in
the binary and are instrumented unless the ignorelist excludes them. The
reached counts drop under LTO because of inlining. The reached.txt column
counts exact names only; thin LTO renames some functions to ….llvm.N.
Build problems with this example, such as gclang: not found or overlapping
archives, are covered in troubleshooting.md.
The ziggy repository has an example harness
that fuzzes the url crate in several ways (examples/url/src/main.rs, at
ziggy commit 7af4467). Its fuzz loop is inside fn main():
fn main() {
ziggy::fuzz!(|data: &[u8]| {
if let Ok(string) = std::str::from_utf8(data) {
invariant_fuzz(string); // url::Url::parse, then assert an invariant
differential_fuzz(string); // parse two ways, compare
correctness_fuzz(string);
consistency_fuzz(string);
idempotency_fuzz(string);
}
});
}A ziggy harness has no LLVMFuzzerTestOneInput; the entry is the Rust main.
--lang ziggy starts there automatically.
A built analyzer, a nightly rustc whose LLVM is not newer than the analyzer's
(here LLVM 22.1.8 against 23), and cargo-ziggy. --lang ziggy builds through
cargo-afl, so the AFL++ plugins must be built for the current rustc:
LLVM_CONFIG=llvm-config-22 cargo afl config --build --plugins --force.
git clone https://github.com/srlabs/ziggy
cd ziggyreachability run --lang ziggy --project examples/url --clean -v--lang ziggybuilds withcargo ziggy build --no-honggfuzz, with aRUSTC_WRAPPERthat adds--emit=llvm-bcand collects each crate's bitcode. It reads the bitcode from rustc's output directories, so it does not matter thatexamples/urlis a workspace member whose target directory is at the workspace root.- The bitcode matches the fuzz binary, because it comes from ziggy's own
build with the same
cfg(fuzzing), features and instrumentation. --cleanrunscargo cleanfirst, so a cached build cannot leave the run without bitcode.
See rust.md for the details.
reachable 9073 / defined 19717 (756 indirect-only, 128 low-confidence, 10644 unreachable) [backend=type-based]
Starting from the Rust main. The token main resolves to two symbols,
shown in the JSON entries:
"entries": ["main", "_RNvCscjF3EmfBC9s_8url_fuzz4main"]The first is the C main that starts the Rust runtime. The second is the real
url_fuzz::main with the ziggy::fuzz! body. The names are v0-mangled
("mangling": "v0"), the default of rustc 1.99.
Reachable: the harness and the url parser it drives: url_fuzz::main,
all five strategies (invariant_fuzz, differential_fuzz, correctness_fuzz,
consistency_fuzz, idempotency_fuzz), and url::ParseOptions::parse with
the parser below it.
Unreachable (not_reached.txt): API the harness never calls. The harness
only parses URLs, so the mutation API is unreachable: url::Url::set_scheme,
set_host, set_port, set_path, set_query, set_fragment,
set_username, set_password, and their internal helpers. Most of the
Unicode and IDNA code is unreachable too, for example 540 of the 562
icu_properties functions.
Over-approximation is heavier in Rust. 9,073 of 19,717 functions are reachable, because Rust calls through trait-object vtables everywhere. Every address-taken function whose type matches a reachable indirect call is a candidate, so once any
Debug::fmt-shaped call is reachable,fmtimplementations across the dependencies come along; for example 2,533 functions ofsynare reachable.indirect-only(756) counts the functions reached only this way.
cpp_demangle is a Rust demangler
for C++ (Itanium) symbols. At commit 076632f it has a cargo-afl harness in
src/bin/afl_runner.rs that feeds the fuzz input to the parser:
#[macro_use]
extern crate afl;
extern crate cpp_demangle;
fn main() {
afl::fuzz!(|bytes| {
let _ = cpp_demangle::Symbol::new(bytes);
});
}Like ziggy, a cargo-afl harness keeps its fuzz loop in main. The binary is
feature-gated (required-features = ["afl"]), so cargo afl build alone skips
it. Pass the feature and the binary with --build-cmd.
git clone https://github.com/gimli-rs/cpp_demangle
cd cpp_demanglereachability run --lang afl --project . \
--build-cmd 'cargo afl build --features afl --bin afl_runner' --clean -vThis needs the AFL++ plugins, as in §2.
reachable 1388 / defined 2436 (189 indirect-only, 46 low-confidence, 1048 unreachable) [backend=type-based]
main again resolves to the C main and the Rust main:
"entries": ["main", "_RNvCshnZqb9Nwllb_10afl_runner4main"].
Reachable: afl_runner::main, the afl::fuzz! closure,
cpp_demangle::Symbol::new, and the recursive-descent grammar below it,
including the SubstitutionTable inserts that record back-reference
candidates during the parse. 815 of the 1,388 come from the Rust runtime
(core, alloc, std), instantiated for the parser.
{
"mangled": "_RNvMs0_Cs6Asx3OJCf2Q_12cpp_demangleINtB5_6SymbolRShE3newCshnZqb9Nwllb_10afl_runner",
"demangled": "<cpp_demangle::Symbol<&[u8]>>::new",
"key": "_RNvMs0_Cs6Asx3OJCf2Q_12cpp_demangleINtB5_6SymbolRShE3new",
"file": "src/lib.rs",
"line": 179,
"via": "direct",
"indirect_only": false,
"confidence": "high",
"depth": 8,
"basic_blocks": 9,
"dangerous_calls": 1,
"local_vars": 0,
"cyclomatic": 3,
"loops": 0,
"interesting": true,
"bottleneck": true,
"dead_end": false
}Symbol::new is generic, and this instance was compiled in afl_runner. The
key drops that trailing crate (…Cshn…_10afl_runner), so it also matches the
instance another build compiles elsewhere; see
Rust names.
Unreachable (not_reached.txt): the code that only runs when a symbol is
printed, because the harness parses but never demangles. This includes the
substitution lookups (SubstitutionTable::get_type, pop,
non_substitution), the back-reference resolvers (TypeHandle::back_reference,
TemplateParam::resolve, …), accessors only used while printing
(BareFunctionType::args and ret), and the options builder
(DemangleOptions::new, no_params, recursion_limit, …).
Unused generic code is absent, not unreachable.
Symbol::demangle, theDemangleimplementations and theDisplaycode appear in neither list. They are generic and nothing in this build instantiates them, so rustc never compiles them. The analysis can only classify functions that exist. cpp_demangle's cargo-fuzz harnessfuzz/fuzzers/parse_and_stringify.rsdoes callsym.demangle(); analyze that one and the printing code appears.
The indirect-only functions are mostly formatting. 131 of the 189 indirect-only functions are
fmtimplementations, reached because a formatting call of the same type is reachable.
rustyknife is an email-parsing
library. At commit 1f652c4 it has a cargo-afl harness in
src/bin/fuzz_mailbox.rs:
#[macro_use]
extern crate afl;
fn main() {
fuzz!(|data: &[u8]| {
let _ = rustyknife::rfc5321::mailbox::<rustyknife::behaviour::Intl>(data);
});
}Three things need care:
- The
fuzz_mailboxbinary is feature-gated (required-features = ["fuzz"]). - rustyknife's
rust-toolchainfile selectsstable. Here that is rustc 1.99.0 with LLVM 23.1.1, newer than the analyzer's LLVM 23.0.0, so the run would stop withrustc's bundled LLVM (23.1.1) is newer than the analyzer toolchain's LLVM (23.0.0).RUSTUP_TOOLCHAIN=nightlypicks the nightly instead. - rustyknife uses the old
afl0.8 crate, whose bundled AFL fails a modern compiler's x86 self-test and stops on warnings. Only the bitcode matters, so skip the self-test withAFL_NO_X86=1and add-Wno-errorwith small compiler wrappers.
git clone https://github.com/zerospam/rustyknife
cd rustyknifemkdir -p /tmp/nowerror
for t in cc clang clang++; do
printf '#!/bin/sh\nexec /usr/bin/%s "$@" -Wno-error\n' "$t" > /tmp/nowerror/$t
chmod +x /tmp/nowerror/$t
done
export PATH="/tmp/nowerror:$PATH" CC=/tmp/nowerror/cc AFL_NO_X86=1 RUSTUP_TOOLCHAIN=nightly
reachability run --lang afl --project . \
--build-cmd 'cargo afl build --features fuzz --bin fuzz_mailbox' --clean -vreachable 2933 / defined 17819 (438 indirect-only, 172 low-confidence, 14886 unreachable) [backend=type-based]
"entries": ["main", "_RNvCs5n6KfqIeUf6_12fuzz_mailbox4main"], as before.
Reachable: fuzz_mailbox::main, the fuzz! closure,
rustyknife::rfc5321::mailbox::<rustyknife::behaviour::Intl>, the RFC 5321
grammar rules such as dot_string, and the nom parser combinators below
them (194 nom functions).
Unreachable: rustyknife's other parsers, which this harness never calls:
the RFC 2047 decoder (rustyknife::rfc2047::decode_text) and most of the RFC
5322 message grammar (48 rustyknife::rfc5322 functions). Five small
rfc5322 helpers that the RFC 5321 grammar shares are reachable.
A focused harness gives a focused report. Only 2,933 of 17,819 functions (16%) are reachable. The harness goes through a single parser entry, so the over-approximation stays small, unlike the
urlexample (46%), where trait-object calls pull in much more.