Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,42 @@ jobs:
# conformance-controls/<rule>.expected names
- run: make controls JDK_HOME="$JAVA_HOME"

# The coverage guided fuzz smoke, the reference's libFuzzer job in Java:
# Jazzer (pinned, sha256 checked by the Makefile) drives the two targets in
# fuzz/, 60 s each, assertions on. The hostile read pushes arbitrary bytes
# through every ReadStream primitive and traps on a throw, an out of contract
# value, or a refusal that breaks the refusal contract; the round trip traps
# on any write/read asymmetry and on a measure that under-measures. The
# negative control runs first: the round trip target against a library with
# one asymmetry planted (fuzz/serialize/fuzz/Sabotage.java), from an empty
# corpus, must trap on it within the same 60 s budget, so a green smoke is a
# fuzzer seen to go red. .github/workflows/nightly-fuzz.yml runs the long
# version with a cumulative corpus.
fuzz:
name: fuzz (Jazzer)
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Negative control (a planted asymmetry must be caught within 60 s)
run: make fuzz-control JDK_HOME="$JAVA_HOME" FUZZ_TIME=60
- name: Fuzz hostile read
run: make fuzz-read JDK_HOME="$JAVA_HOME" FUZZ_TIME=60
- name: Fuzz round trip
run: make fuzz-roundtrip JDK_HOME="$JAVA_HOME" FUZZ_TIME=60
- name: Upload crash reproducers
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: |
fuzz-artifacts
build/fuzz-control
if-no-files-found: ignore

spec-sync:
name: STANDARD.md and the corpus match upstream
runs-on: ubuntu-latest
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/nightly-fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Nightly Fuzz

on:
schedule:
- cron: '0 8 * * *' # every day at 08:00 UTC
workflow_dispatch:
inputs:
duration:
description: 'Fuzz duration in seconds, per target'
default: '3600'
required: false

jobs:
fuzz:
name: fuzz (Jazzer, extended)
runs-on: ubuntu-24.04
timeout-minutes: 150
env:
FUZZ_TIME: ${{ github.event.inputs.duration || '3600' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21

# the corpus is cached across runs so fuzzing is cumulative: each night
# starts from the most recent corpus and saves the evolved one
- name: Restore corpus
uses: actions/cache/restore@v4
with:
path: corpus
key: fuzz-corpus-${{ github.run_id }}
restore-keys: |
fuzz-corpus-

# the two targets of the per-push smoke (the fuzz job in ci.yml), each
# for the full duration, from and into the cached corpus
- name: Fuzz hostile read
run: make fuzz-read JDK_HOME="$JAVA_HOME" FUZZ_TIME="$FUZZ_TIME"

- name: Fuzz round trip
if: success() || failure()
run: make fuzz-roundtrip JDK_HOME="$JAVA_HOME" FUZZ_TIME="$FUZZ_TIME"

- name: Save corpus
if: always()
uses: actions/cache/save@v4
with:
path: corpus
key: fuzz-corpus-${{ github.run_id }}

- name: Upload crash reproducers
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crashes
path: fuzz-artifacts
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
dist/
build/
*.class
corpus/
fuzz-artifacts/
103 changes: 102 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ CLASSES := build/classes
TEST_CLASSES := build/test-classes
INTEROP_CLASSES := build/interop-classes

.PHONY: all test test-release conformance controls interop clean
.PHONY: all test test-release conformance controls interop clean fuzz fuzz-read fuzz-roundtrip fuzz-control

all: test test-release controls

Expand Down Expand Up @@ -64,5 +64,106 @@ $(INTEROP_CLASSES)/.stamp: $(INTEROP_SRC) $(CLASSES)/.stamp
interop: $(INTEROP_CLASSES)/.stamp
$(JAVA) -ea -cp $(CLASSES):$(INTEROP_CLASSES) serialize.interop.Interop $(MODE) $(FILE)

# the fuzz harness: coverage guided, on Jazzer. The functional tier, never part
# of `all`: it runs for real time and drives a native fuzzing engine. Two
# targets mirror the reference's fuzz.cpp: a hostile read of arbitrary bytes
# through every ReadStream primitive, and a differential write -> read round
# trip. Jazzer is the standalone release, pinned and sha256 checked like the
# JDK (tending/PINS.md), fetched into dist/ on first use. The harness has no
# Jazzer dependency at compile time: a target is a class with a static
# fuzzerTestOneInput( byte[] ), compiled against the library classes only.
# make fuzz # both targets, FUZZ_TIME seconds each (default 60)
# make fuzz-read FUZZ_TIME=3600
# make fuzz-roundtrip SABOTAGE=1 # against the sabotaged library (see fuzz-control)
JAZZER_VERSION := 0.30.0
ifeq ($(shell uname -s),Darwin)
JAZZER_ASSET := jazzer-macos.tar.gz
JAZZER_SHA256 := 6239e2b2b8d626c287fd8a50376315f00ef4310e3f29e96315cd6e60911563c8
else ifeq ($(shell uname -m),x86_64)
JAZZER_ASSET := jazzer-linux-x86-64.tar.gz
JAZZER_SHA256 := 6eeaf0026d75599b07527d93b576593ce847cfca8b337a579971a5a9cdf792d0
else
JAZZER_ASSET := jazzer-linux-arm64.tar.gz
JAZZER_SHA256 := 16636a4d3e98f1d3a7fcf59d4ab37f5be1d1ad6df9d94d5c3ca8de644838e369
endif
JAZZER_URL := https://github.com/CodeIntelligenceTesting/jazzer/releases/download/v$(JAZZER_VERSION)/$(JAZZER_ASSET)
JAZZER_DIR := $(CURDIR)/dist/jazzer-$(JAZZER_VERSION)
JAZZER_JAR := $(JAZZER_DIR)/jazzer_standalone.jar

FUZZ_SRC := $(wildcard fuzz/serialize/fuzz/*.java)
FUZZ_CLASSES := build/fuzz-classes
SABOTAGE_SRC := build/fuzz-sabotage-src
SABOTAGE_CLASSES := build/fuzz-sabotage-classes

FUZZ_TIME ?= 60
FUZZ_CORPUS ?= corpus
FUZZ_ARTIFACTS ?= fuzz-artifacts
FUZZ_LIBRARY := $(if $(SABOTAGE),$(SABOTAGE_CLASSES),$(CLASSES))

# assertions on, as the reference builds its fuzzer in Debug: the write side
# contracts are asserts, and the round trip's generated values must keep them.
# Only this repository's classes are instrumented for coverage.
JAZZER = $(JAVA) -ea:serialize... -cp $(JAZZER_JAR):$(1) com.code_intelligence.jazzer.Jazzer --cp=$(1) --instrumentation_includes='serialize.**'
FUZZ_FLAGS = -max_total_time=$(FUZZ_TIME) -timeout=10 -print_final_stats=1

# the download is verified before anything is unpacked from it
$(JAZZER_JAR):
@mkdir -p $(JAZZER_DIR)
curl -fsSL -o $(JAZZER_DIR)/$(JAZZER_ASSET) $(JAZZER_URL)
echo "$(JAZZER_SHA256) $(JAZZER_DIR)/$(JAZZER_ASSET)" | shasum -a 256 -c -
tar -xzf $(JAZZER_DIR)/$(JAZZER_ASSET) -C $(JAZZER_DIR) jazzer_standalone.jar
@touch $@

$(FUZZ_CLASSES)/.stamp: $(FUZZ_SRC) $(CLASSES)/.stamp
$(JAVAC) --release 17 -Xlint:all -Werror -cp $(CLASSES) -d $(FUZZ_CLASSES) $(FUZZ_SRC)
@touch $@

# the sabotaged library: a scratch copy of src/ with one write/read asymmetry
# planted by serialize.fuzz.Sabotage, built with the same flags as the real one
$(SABOTAGE_CLASSES)/.stamp: $(SRC) $(FUZZ_CLASSES)/.stamp
rm -rf $(SABOTAGE_SRC) $(SABOTAGE_CLASSES)
$(JAVA) -cp $(FUZZ_CLASSES) serialize.fuzz.Sabotage src/serialize $(SABOTAGE_SRC)
$(JAVAC) --release 17 -Xlint:all -Werror -d $(SABOTAGE_CLASSES) $(SABOTAGE_SRC)/*.java
@touch $@

FUZZ_DEPS := $(JAZZER_JAR) $(FUZZ_CLASSES)/.stamp $(if $(SABOTAGE),$(SABOTAGE_CLASSES)/.stamp)

fuzz: fuzz-read fuzz-roundtrip

fuzz-read: $(FUZZ_DEPS)
@mkdir -p $(FUZZ_CORPUS)/hostile-read $(FUZZ_ARTIFACTS)
$(call JAZZER,$(FUZZ_LIBRARY):$(FUZZ_CLASSES)) --target_class=serialize.fuzz.HostileReadFuzzer \
--reproducer_path=$(FUZZ_ARTIFACTS) -artifact_prefix=$(FUZZ_ARTIFACTS)/hostile-read- \
$(FUZZ_FLAGS) $(FUZZ_CORPUS)/hostile-read

fuzz-roundtrip: $(FUZZ_DEPS)
@mkdir -p $(FUZZ_CORPUS)/round-trip $(FUZZ_ARTIFACTS)
$(call JAZZER,$(FUZZ_LIBRARY):$(FUZZ_CLASSES)) --target_class=serialize.fuzz.RoundTripFuzzer \
--reproducer_path=$(FUZZ_ARTIFACTS) -artifact_prefix=$(FUZZ_ARTIFACTS)/round-trip- \
$(FUZZ_FLAGS) $(FUZZ_CORPUS)/round-trip

# the fuzzer's NEGATIVE CONTROL: the round trip target, from an EMPTY corpus,
# against the sabotaged library, within the smoke budget. Green only when the
# fuzzer traps, and traps on the planted asymmetry (an int64 that does not come
# back) rather than on anything else; a fuzzer that runs out the clock against
# a broken writer is a fuzzer that measures nothing.
fuzz-control: $(JAZZER_JAR) $(FUZZ_CLASSES)/.stamp $(SABOTAGE_CLASSES)/.stamp
rm -rf build/fuzz-control
mkdir -p build/fuzz-control/corpus build/fuzz-control/artifacts
@start=$$(date +%s); \
if $(call JAZZER,$(SABOTAGE_CLASSES):$(FUZZ_CLASSES)) --target_class=serialize.fuzz.RoundTripFuzzer \
--reproducer_path=build/fuzz-control/artifacts -artifact_prefix=build/fuzz-control/artifacts/ \
$(FUZZ_FLAGS) build/fuzz-control/corpus > build/fuzz-control/log 2>&1; then \
tail -n 20 build/fuzz-control/log; \
echo "fuzz-control: RED: the planted asymmetry survived $(FUZZ_TIME) s of fuzzing"; exit 1; \
fi; \
elapsed=$$(( $$(date +%s) - start )); \
if ! grep -E 'serialize\.fuzz\.FuzzCheck: int64 [a-z ]+: asymmetric round trip' build/fuzz-control/log; then \
tail -n 40 build/fuzz-control/log; \
echo "fuzz-control: RED: the fuzzer stopped, but not on the planted asymmetry"; exit 1; \
fi; \
grep -E '^#[0-9]+' build/fuzz-control/log | tail -n 1; \
echo "fuzz-control: green: the planted asymmetry was caught in $${elapsed} s (budget $(FUZZ_TIME) s)"

clean:
rm -rf build
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,12 @@ language level (`javac --release 17`), built and tested on the pinned
JDK 21. A plain Makefile drives everything — no Maven, no Gradle:

```
make # all three below
make # the first three below
make test # the suite with assertions on, the checked shape
make test-release # the same suite with assertions off, the release shape
make controls # the negative controls: sabotaged readers must fail exactly their rule's vectors
make fuzz # the coverage guided fuzz targets, FUZZ_TIME seconds each (default 60)
make fuzz-control # the fuzzer's negative control: a planted asymmetry must be caught
```

## Testing
Expand Down Expand Up @@ -197,6 +199,28 @@ library, compiled and loaded on its own — must fail exactly the vectors
[`conformance-controls/`](conformance-controls)`<rule>.expected` names,
the same lists the reference holds its own sabotaged readers to.

`make fuzz` is the functional tier's fuzzer, the counterpart of the
reference's libFuzzer harness, on [Jazzer](https://github.com/CodeIntelligenceTesting/jazzer)
(pinned in [tending/PINS.md](tending/PINS.md), fetched and sha256 checked
by the Makefile). [`fuzz/`](fuzz) holds two coverage guided targets, run
with assertions on. `make fuzz-read` is the hostile read: an op program
from the input drives arbitrary bytes through every `ReadStream`
primitive, and it traps on a throw, an accepted value outside its
contract, or a refusal that breaks the refusal contract (no reason, a
reason outside the closed list, a written destination, a stream that
does not stay failed); the same bytes are read twice, from an exact array
and from one with set bits past the end, and the two must agree.
`make fuzz-roundtrip` is the differential round trip: values generated
from the input are written, measured and read back by one serialize body,
and it traps on any asymmetry, a measure that under-measures, a read that
does not end where the writer stopped, and a trailing byte not refused as
`trailing_data`. `make fuzz-control` is its negative control: the round
trip, from an empty corpus, against a scratch copy of the library with
one asymmetry planted, must trap on it within the 60 second smoke budget.
CI runs the control and a 60 second smoke of each target on every push;
`.github/workflows/nightly-fuzz.yml` runs each for an hour nightly with a
cumulative corpus, and uploads crash reproducers on failure.

[`interop/`](interop) takes it further: the CI `interop` job builds the
C++ reference at a pinned release and runs it head to head with this
port. Both halves write the same boundary message — every operation the
Expand Down
31 changes: 31 additions & 0 deletions fuzz/serialize/fuzz/FuzzCheck.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package serialize.fuzz;

/**
* The harness's trap: the Java spelling of the reference's {@code fuzz_check},
* which calls {@code __builtin_trap()}. A failed check throws this, Jazzer
* reports the uncaught throwable as a finding and writes the input that
* produced it as a crash reproducer.
*
* It is unchecked so it escapes every serialize body untouched, and it has
* its own type so a negative control can tell a check that fired from any
* other throwable: a ReadStream that throws on hostile bytes is a finding
* too, but a different one.
*/
public final class FuzzCheck extends RuntimeException
{
private static final long serialVersionUID = 1L;

private FuzzCheck( String message )
{
super( message );
}

/** Traps when the condition is false. */
public static void check( boolean condition, String what )
{
if ( !condition )
{
throw new FuzzCheck( what );
}
}
}
Loading
Loading