fuzz

package
v0.16.6 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 23, 2026 License: Apache-2.0 Imports: 0 Imported by: 0

README

Differential fuzzing: Go ClassAd vs. C++ libclassad

This directory contains a differential fuzzing harness that finds behavioral differences between the two ClassAd evaluation engines available in this container:

  • Go — the native implementation in ../classad.
  • C++ — HTCondor's reference libclassad (the same library the classad2 Python bindings and the classad_eval CLI wrap).

Both engines parse the same ClassAd, evaluate every top-level attribute, and encode the results into a common canonical form. The harness then compares the two and reports any divergence.

Why in-process (cgo), not a subprocess

The C++ engine is reached in-process via cgo (oracle/cgo), not by shelling out to classad_eval or a Python worker. libclassad is a C++ library, so a small extern "C" shim (shim.cc) bridges the C ABI that cgo speaks to the C++ ClassAd API; cgo compiles and links it automatically. This means one Go process parses and evaluates in both engines with no IPC or process-startup overhead, which is what makes coverage-guided fuzzing run at thousands of execs/second.

Trade-off: because libclassad shares the process, a hard crash there (segfault/abort) takes the fuzzer down. The shim catches all C++ exceptions, and cafuzz -journal records the current input before each evaluation so a crash leaves a reproducer. A C++ crash is itself a finding.

Build requirements
  • condor-devel (provides /usr/include/classad/*.h) — already in the devcontainer Dockerfile.
  • Unversioned libclassad.so and libcondor_utils.so symlinks for the linker (the Dockerfile creates both). The cgo link uses these unversioned names, so it does not hard-code an HTCondor version; the versioned SONAME is still what the binary loads at runtime.
  • CGO_ENABLED=1 and a C++20 compiler (the headers use operator<=>).

Because those requirements are not present on a stock CI runner, everything that links libclassad — the oracle/cgo package, the differ, cmd/cafuzz, and the FuzzDifferential / TestCppQuirks tests — is behind the libclassad build tag. A plain go build ./... / go test ./... (as CI runs) simply skips those packages; the pure-Go pieces (canon, gen, cmd/genseeds) still build and test everywhere. To include the C++ engine, pass -tags libclassad (as the commands below do); the devcontainer has everything the tag needs.

Components

Path Role
canon/ Canonical, engine-independent value encoding + tolerant compare. The lingua franca both engines emit.
oracle/cgo/ The C++ engine: extern "C" shim over libclassad + cgo bindings.
differ/ Runs one ClassAd through both engines and classifies the outcome.
gen/ Grammar-directed random ClassAd generator (deterministic per seed).
cmd/cafuzz/ Standalone driver: generate/replay ads, bucket & report all divergences.
cmd/genseeds/ Regenerates corpus/seeds.txt.
differential_test.go FuzzDifferential, the Go-native coverage-guided target.

The canonical encoding

Direct string comparison of engine output is hopeless — Go prints reals as 0.5 and lists as [1 2 3], while libclassad prints 5.0E-01 and { 1,2,3 }. Instead both engines encode each evaluated value into a compact, self-delimiting line (see the doc comment in canon/canon.go) that captures type and value but not formatting. Comparison is:

  1. exact string match (the common case), then
  2. a structural compare with a tight float tolerance (1e-12) to absorb last-ULP differences from libm.

The int vs real distinction is preserved and significant — int(1) and real(1.0) are not equal, because conflating them would hide a whole class of ClassAd bugs.

Usage

Bulk survey — cafuzz

The primary discovery tool. Generates random ads, evaluates in both engines, and prints divergences bucketed by signature (so one bug isn't reported a thousand times):

# 100k generated ads from seed 1
CGO_ENABLED=1 go run -tags libclassad ./fuzz/cmd/cafuzz -n 100000

# focus on evaluation semantics, ignore known parser grammar differences
CGO_ENABLED=1 go run -tags libclassad ./fuzz/cmd/cafuzz -n 100000 -ignore-parse

# replay a corpus file, one ad per line
CGO_ENABLED=1 go run -tags libclassad ./fuzz/cmd/cafuzz -corpus fuzz/corpus/seeds.txt

# inspect a single ad in both engines
CGO_ENABLED=1 go run -tags libclassad ./fuzz/cmd/cafuzz -ad '[ a = 1 / 2 ]'

# crash-reproducer journaling (for hunting libclassad crashes)
CGO_ENABLED=1 go run -tags libclassad ./fuzz/cmd/cafuzz -n 1000000 -journal /tmp/last.ad

cafuzz exits non-zero if any divergence is found.

Coverage-guided — go test -fuzz
# explore from a matching baseline; saves any divergence to testdata/fuzz/
CGO_ENABLED=1 go test -tags libclassad ./fuzz -run='xxx' -fuzz='FuzzDifferential' -fuzztime=60s

# re-run a saved finding
CGO_ENABLED=1 go test -tags libclassad ./fuzz -run='FuzzDifferential/<hash>'

The fuzz target seeds itself only with inputs the engines currently agree on (Go's fuzzer aborts on a failing seed before it mutates), so mutation explores from green and reports regressions. It fails on ValueDivergence and on a Go engine panic. Parse-only divergences are left to cafuzz.

Regenerating the seed corpus
CGO_ENABLED=1 go run ./fuzz/cmd/genseeds > fuzz/corpus/seeds.txt

Divergence categories

  • value-divergence — both parsed; an attribute evaluated differently. The prize.
  • parse-divergence — exactly one engine accepted the input. Often reflects known grammar differences; -ignore-parse suppresses these.
  • go-panic — the Go engine panicked. Always a Go bug (it should return error/undefined, never panic).
  • encoding-error — an engine emitted something the canonical decoder rejected (harness/engine bug).

Findings from the first runs

These were found within seconds and confirmed independently against the classad_eval reference CLI — they are real engine differences, not harness artifacts. All of the rows below have since been fixed in the Go engine (see the classad: / parser: commits and classad/cpp_parity_test.go):

Input Go (before) C++ Nature
1 / 2 real(0.5) int(0) Integer division for integer operands.
"B" < "a" true false Case-insensitive string ordering. Silent wrong boolean.
true < false error false Booleans are orderable numbers (1/0).
6/3, 1.0/2.0 parse error parses Lexer dropped a char after / adjacent to a digit.
false && error error false Three-valued short-circuit logic.
{}[0] undefined error Out-of-range list subscript is an error.
1 ? a : b error a Ternary condition coerces a number's truthiness.
0.1 + 0.2 == 0.3 true false Reals compare with exact (not epsilon) equality.
round(2.5) 3 2 Round half to even.
string(1.5) "1.5" "1.50…E+00" Reals stringify as %.15E.

…and many more found by driving the fuzzer iteratively (see the classad: commits and classad/cpp_parity_test.go): boolean numeric coercion, per-function undefined handling, ifThenElse/?: condition coercion, exact real equality, mismatched-type == → error, quantize/pow typing, integer division/modulo overflow, int()/real()/floor()/pow()/… string parsing, member() ==-semantics, =?=/=!= on lists → error, undefined-dominates-error argument precedence (substr/strcmp/member), real division by zero (+Inf→error, -Inf/NaN→value), large-integer round/floor precision, list coercion via the sink unparse format, and removing the non-reference length().

The bucketed cafuzz survey, plus a harness-bug fix and the list refactor (below), drove the semantic-divergence rate over random generated ads (-ignore-parse) to 0 in 240,000 for the generator as it stood then. Coverage-guided mutation (go test -fuzz) then found further operator/function gaps the generator does not emit, which were also fixed (bitwise operators, case-insensitive function names, substr negative/out-of-range offsets, duplicate attribute names, and cyclic references — see the classad: commits).

A second, deeper round of coverage-guided mutation drove out a family of lazy-evaluation and parsing divergences (all with regression tests in cpp_parity_test.go):

  • Lazy list values. A list value is its unevaluated element expressions (like the reference's ExprList): a subscript evaluates only the indexed element, size() counts without evaluating, and a self-referential list is a value rather than a construction-time error.
  • Short-circuit / lazy operators. &&/|| evaluate the right operand only when the left does not decide the result; ifThenElse and ?: evaluate only the taken branch — except a ?: with an undefined condition evaluates both branches; strcat stops at its first undefined/error argument.
  • Unknown / wrong-arity calls are an error without evaluating arguments (an functionArity table checks arity before evaluation); sum()/avg()/min()/max() with no argument are error.
  • List projection. list.attr maps the select over each element ({[A=1],[A=2]}.A is {1,2}).
  • Parsing. Adjacent ?: is a high-precedence postfix elvis operator (so 10 ?: 2 + 3 is 13); single-quoted attribute names ('a b') are supported; a =!/=? that is not =!=/=?= no longer drops the following character; explicit parentheses are preserved through an ast.ParenExpr node when unparsing.
  • Cyclic-reference handling matched where libclassad is self-consistent: a cyclic function argument propagates (the call errors), a cyclic list-literal element is localized to that element's error, and a cyclic projection propagates. Where libclassad is not self-consistent (it hangs or behaves differently by syntactic path), the cases are recorded in CPP_QUIRKS.md rather than mirrored.

Coverage-guided mutation can still surface non-reproducing flaky failures whose saved input is a parse error in both engines or a libclassad cyclic hang; these are a consequence of fuzzing an in-process engine that can infinite-loop, not Go bugs (see the cyclic-hang note below and CPP_QUIRKS.md). The single-process cafuzz survey is the reliable divergence counter.

A harness bug, not a Go bug

Much of what first looked like an "architectural list" divergence (~35/5000) was actually a bug in this fuzzer's C++ oracle: it encoded a list value by evaluating each element with the static ClassAd::EvaluateExpr(ad, tree, val), which cannot set the parent scope, so a bare attribute reference inside a list element (result = {{a0}[0], 99}) wrongly evaluated to undefined instead of resolving against the ad. The Go engine was correct. Fixed by reconnecting the list's parent scope before evaluating elements (shim.cc). Lesson: when a differential harness reaches into one engine's internals, it can manufacture divergences — verify surprising ones against the real CLI (classad_eval).

The list refactor

String coercion of a list/nested-ad with non-literal elements once differed (string({1, 1+1}) is "{ 1,1 + 1 }" in the reference — the source expression — but Go had only the evaluated value). This is now matched: list values carry their source element expressions (classad/evaluator.go), the parser preserves explicit parentheses, and a reference-faithful expression unparser (classad/unparse.go, a port of sink.cpp) renders them. Value semantics still use the eagerly-evaluated elements.

The extension-function round

The earlier "0 in 240,000" was reached with a generator that did not exercise HTCondor's extension functions (split, stringList*, join, versioncmp/version_in_range, interval, …) or hex/inf/nan string literals. Two changes opened that surface:

  • The shim now calls ClassAdReconfig() (linking libcondor_utils) so libclassad registers HTCondor's extension functions; without it the reference lacked them and produced false divergences (oracle/cgo/shim.cc).
  • The generator's alphabet and builtin lists were widened (hex/inf/nan strings, ~57 builtins) (gen/gen.go).

That surfaced ~1,900 real divergences, all since fixed and regression-tested in cpp_parity_test.go:

  • split splits on commas and whitespace, with hard (non-whitespace) delimiters producing empty fields (split("a,,b") is {"a","","b"}).
  • stringList* tokenize on comma + space (not comma alone), drop empty tokens, and take a delimiter set as their optional trailing argument; stringListMember/stringListIMember's third argument is that delimiter, not a case option (case is fixed by the function name).
  • join coerces its separator/items to strings, skips undefined items, and is undefined (not "") when no items contribute and the separator is undefined.
  • versioncmp is a faithful port of natural_cmp.cpp (strverscmp-style, including its leading/trailing-zero handling).
  • interval truncates to a signed 32-bit second count and formats a negative magnitude with a leading -.
  • sum/avg/min/max skip undefined elements, coerce booleans to numbers, preserve a lone boolean element's type, and accumulate integer sums in int64 so they stay exact past 2^53.

This drove the rate back to 0 divergences across 360,000 generated ads (12 seeds × 30k, -ignore-parse). The one residual — a whitespace-only string treated as a non-empty list by stringListSubsetMatch — is an internal libclassad inconsistency that the Go engine now mirrors for parity; its reference value is pinned by TestCppQuirks in oracle/cgo/quirks_test.go so a future libclassad change is caught (see CPP_QUIRKS.md).

Known remaining divergences
  • Nested-ad parent-scope resolution. A nested ad literal that selects an attribute resolving up its parent scope chain into a cycle — [A = [].A] — is error in the reference (it resolves .A to the parent's cyclic A) but undefined in Go (select only looks in the immediate ad). Matching it needs nested-ad scope-chain resolution plus cross-scope cycle detection; the case is pathological (cyclic) and left for a focused follow-up. ([B = [].A] and other non-cyclic selects already match.)

  • Non-UTF-8 string literals. The Go lexer decodes string literals as UTF-8 (an invalid byte becomes the 3-byte U+FFFD) while libclassad keeps raw bytes. FuzzDifferential skips non-UTF-8 inputs; ClassAd source is text.

  • length(...) was a Go alias for size(); the reference engine has no such function, so it has been removed (now an error), matching the reference.

  • Integer-literal overflow (9223372036854775808): the reference engine silently evaluates it to 0 (a libclassad bug, reported upstream); Go rejects it at parse time. Not mirrored. Surprising or likely-buggy libclassad behaviors found along the way are collected in CPP_QUIRKS.md for upstream reporting.

  • libclassad cyclic-evaluation hang ([A0 = 0 ? e : A0]): libclassad infinite-loops on a cyclic self-reference reached through a lazy operand (a ternary/elvis branch, an unknown call, …) where its cycle guard never fires. The Go engine detects the cycle and returns error. Since a cgo call cannot be interrupted, the harness caps each libclassad evaluation (cppEvalTimeout); a timeout is reported as the cpp-timeout category and treated as a non-divergence (the result is uncomparable). A C++ bug, not a Go one.

Extending

  • More operators/functions: extend the generator in gen/gen.go (binops, fns1/2/3). Keep additions deterministic — no time/random.
  • A target attribute / matchmaking: the differ currently evaluates a single ad's attributes. To fuzz TARGET.x matchmaking, add a second ad to the shim (MatchClassAd is already linked) and to canon.FromGoClassAd.
  • New value kinds: add a Kind in canon/canon.go and emit it from both shim.cc and canon/govalue.go.

Documentation

Overview

Package fuzz hosts the differential fuzz target comparing the native Go ClassAd evaluation engine against the reference C++ libclassad. See README.md in this directory for design and usage. The fuzz target itself lives in differential_test.go; this file exists so the package has a non-test Go source file (some tooling requires one).

Directories

Path Synopsis
Package canon defines a canonical, engine-independent encoding for the result of evaluating a ClassAd attribute.
Package canon defines a canonical, engine-independent encoding for the result of evaluating a ClassAd attribute.
cmd
cafuzz command
The real cafuzz driver (main.go) links libclassad and is behind `//go:build libclassad`.
The real cafuzz driver (main.go) links libclassad and is behind `//go:build libclassad`.
genseeds command
Command genseeds writes a deterministic seed corpus of ClassAds (one per line) to stdout, for use with `cafuzz -corpus` and as FuzzDifferential seeds.
Command genseeds writes a deterministic seed corpus of ClassAds (one per line) to stdout, for use with `cafuzz -corpus` and as FuzzDifferential seeds.
Package differ is empty unless built with the `libclassad` build tag.
Package differ is empty unless built with the `libclassad` build tag.
Package gen produces random but valid ClassAd source text for differential fuzzing.
Package gen produces random but valid ClassAd source text for differential fuzzing.
oracle
cgo
Package cgo is empty unless built with the `libclassad` build tag.
Package cgo is empty unless built with the `libclassad` build tag.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL