diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ba2356..daf895e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,42 @@ jobs: # conformance-controls/.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 diff --git a/.github/workflows/nightly-fuzz.yml b/.github/workflows/nightly-fuzz.yml new file mode 100644 index 0000000..4293bb7 --- /dev/null +++ b/.github/workflows/nightly-fuzz.yml @@ -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 diff --git a/.gitignore b/.gitignore index c0c0ff4..e4267c5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ dist/ build/ *.class +corpus/ +fuzz-artifacts/ diff --git a/Makefile b/Makefile index 8658691..b01c260 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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 diff --git a/README.md b/README.md index 8cc8bf1..39bfd81 100644 --- a/README.md +++ b/README.md @@ -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 @@ -197,6 +199,28 @@ library, compiled and loaded on its own — must fail exactly the vectors [`conformance-controls/`](conformance-controls)`.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 diff --git a/fuzz/serialize/fuzz/FuzzCheck.java b/fuzz/serialize/fuzz/FuzzCheck.java new file mode 100644 index 0000000..219e610 --- /dev/null +++ b/fuzz/serialize/fuzz/FuzzCheck.java @@ -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 ); + } + } +} diff --git a/fuzz/serialize/fuzz/HostileReadFuzzer.java b/fuzz/serialize/fuzz/HostileReadFuzzer.java new file mode 100644 index 0000000..011df9a --- /dev/null +++ b/fuzz/serialize/fuzz/HostileReadFuzzer.java @@ -0,0 +1,375 @@ +package serialize.fuzz; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; + +import serialize.BoolRef; +import serialize.DoubleRef; +import serialize.FloatRef; +import serialize.Int128Value; +import serialize.IntRef; +import serialize.LongRef; +import serialize.ReadStream; +import serialize.Ref; +import serialize.Refusal; +import serialize.SerializeUtil; +import serialize.UInt128Value; + +/** + * Jazzer target one: the HOSTILE READ, the Java counterpart of the + * reference's FuzzRead (fuzz.cpp, passes 1 and 2). + * + * ReadStream is the trust boundary of this library: it must survive + * arbitrary hostile bytes, refusing a read by returning false and never by + * throwing. Any throwable escaping a read is a finding, Jazzer reports it. + * On top of that, every accepted value must be in contract (a ranged value in + * its range, a float finite, a string free of NUL and within its buffer), and + * every refusal must keep the refusal contract: the stream is latched, the + * reason is one of the closed list STANDARD.md defines ({@link Refusal}), a + * scalar destination is left unwritten, and the next read on the failed + * stream refuses too without consuming a bit or changing the reason. + * + * Input layout, as the reference: the first {@link #NUM_OPS} bytes are an op + * program (the low nibble selects the operation, the high nibble is its + * parameter), the rest is the hostile bitstream. + * + * The reference's second pass feeds the same bytes through InitializePadded + * from an allocation with no slack. A Java reader takes any array size, so + * the counterpart here is a differential of two readers over the same + * payload: one over an array sized exactly to the payload, one over an array + * whose 8 bytes of slack are all set bits. Bytes past the end must never be + * interpreted, so the two must agree on every value, on where they stop, and + * on why. + */ +public final class HostileReadFuzzer +{ + private HostileReadFuzzer() {} + + static final int NUM_OPS = 32; + + static final int MAX_PAYLOAD = 4096; + + private static final int SENTINEL = 0x5EED5EED; + + private static final long SENTINEL64 = 0x5EED5EED5EED5EEDL; + + /** Jazzer's entry point. */ + public static void fuzzerTestOneInput( byte[] data ) + { + if ( data.length <= NUM_OPS || data.length > NUM_OPS + MAX_PAYLOAD ) + { + return; + } + + final int payloadBytes = data.length - NUM_OPS; + + byte[] exact = new byte[payloadBytes]; + System.arraycopy( data, NUM_OPS, exact, 0, payloadBytes ); + + byte[] slack = new byte[payloadBytes + 8]; + System.arraycopy( data, NUM_OPS, slack, 0, payloadBytes ); + for ( int i = payloadBytes; i < slack.length; i++ ) + { + slack[i] = (byte) 0xFF; + } + + Trace exactTrace = new Trace(); + ReadStream exactStream = new ReadStream( exact, payloadBytes ); + boolean exactAccepted = fuzzRead( exactStream, data, exactTrace ); + + Trace slackTrace = new Trace(); + ReadStream slackStream = new ReadStream( slack, payloadBytes ); + boolean slackAccepted = fuzzRead( slackStream, data, slackTrace ); + + FuzzCheck.check( exactAccepted == slackAccepted, "slack past the end changed whether the reader refused" ); + FuzzCheck.check( exactStream.getRefusal() == slackStream.getRefusal(), "slack past the end changed the refusal reason" ); + FuzzCheck.check( exactStream.getBitsProcessed() == slackStream.getBitsProcessed(), "slack past the end changed where the reader stopped" ); + FuzzCheck.check( exactTrace.hash == slackTrace.hash && exactTrace.count == slackTrace.count, "slack past the end changed a decoded value" ); + } + + /** A running digest of every accepted value, so two readers can be compared without storing them. */ + static final class Trace + { + long hash = 0xCBF29CE484222325L; + int count; + + void add( long value ) + { + hash = ( hash ^ value ) * 0x100000001B3L; + count++; + } + } + + // a refusal: the stream is latched with a reason from the closed list, the + // scalar destination was left unwritten, and a further read refuses + // without consuming anything or changing the reason. Returns false, so a + // caller reads `return refused( ... );` like the reference's early return. + private static boolean refused( ReadStream stream, boolean unwritten ) + { + FuzzCheck.check( stream.isFailed(), "a refused read did not latch the stream" ); + Refusal reason = stream.getRefusal(); + FuzzCheck.check( reason != null && reason != Refusal.NONE, "a refused read reported no reason" ); + FuzzCheck.check( Refusal.fromSpelling( reason.toString() ) == reason, "a refusal reason outside the closed list" ); + FuzzCheck.check( unwritten, "a refused read wrote its scalar destination" ); + + long bits = stream.getBitsProcessed(); + IntRef echo = new IntRef( SENTINEL ); + FuzzCheck.check( !stream.serializeBits( echo, 1 ), "a read after a refusal was accepted" ); + FuzzCheck.check( echo.value == SENTINEL, "a read after a refusal wrote its destination" ); + FuzzCheck.check( stream.getBitsProcessed() == bits, "a read after a refusal consumed bits" ); + FuzzCheck.check( stream.getRefusal() == reason, "a read after a refusal changed the reason" ); + return false; + } + + private static void accepted( ReadStream stream ) + { + FuzzCheck.check( !stream.isFailed() && stream.getRefusal() == Refusal.NONE, "an accepted read left the stream failed" ); + } + + private static BigInteger big( Int128Value value ) + { + return BigInteger.valueOf( value.hi ).shiftLeft( 64 ).add( new BigInteger( Long.toUnsignedString( value.lo ) ) ); + } + + private static final BigInteger WIDE_BOUND = BigInteger.ONE.shiftLeft( 60 + 16 ); // ±2^60 whole units in Q112.16 + + static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) + { + byte[] bytes = new byte[256]; + + for ( int i = 0; i < NUM_OPS; i++ ) + { + final int select = ops[i] & 15; + final int param = ( ops[i] >> 4 ) & 15; // [0,15] + + switch ( select ) + { + case 0: + case 1: + { + final int bits = ( select == 0 ) ? param + 1 : param + 17; // [1,32] + IntRef value = new IntRef( SENTINEL ); + if ( !stream.serializeBits( value, bits ) ) return refused( stream, value.value == SENTINEL ); + accepted( stream ); + FuzzCheck.check( bits == 32 || ( value.value >>> bits ) == 0, "bits: value wider than its width" ); + trace.add( value.value ); + } + break; + + case 2: + { + IntRef value = new IntRef( SENTINEL ); + if ( !stream.serializeInt( value, -100, +100 ) ) return refused( stream, value.value == SENTINEL ); + accepted( stream ); + FuzzCheck.check( value.value >= -100 && value.value <= +100, "int: value outside [-100,+100]" ); + trace.add( value.value ); + } + break; + + case 3: + { + IntRef value = new IntRef( SENTINEL ); + if ( !stream.serializeInt( value, Integer.MIN_VALUE, Integer.MAX_VALUE ) ) return refused( stream, value.value == SENTINEL ); + trace.add( value.value ); + + LongRef value64 = new LongRef( SENTINEL64 ); + if ( !stream.serializeInt64( value64, Long.MIN_VALUE, Long.MAX_VALUE ) ) return refused( stream, value64.value == SENTINEL64 ); + trace.add( value64.value ); + + final long bound = (long) ( param + 1 ) << 35; // spans of varying width exercise bitsRequired64 + LongRef ranged64 = new LongRef( SENTINEL64 ); + if ( !stream.serializeInt64( ranged64, -bound, +bound ) ) return refused( stream, ranged64.value == SENTINEL64 ); + FuzzCheck.check( ranged64.value >= -bound && ranged64.value <= +bound, "int64: value outside its range" ); + trace.add( ranged64.value ); + + // fixed point: values read off hostile bytes must decode within the raw bounds or fail the read + LongRef fixed32 = new LongRef( SENTINEL64 ); + if ( !stream.serializeFixed( fixed32, 16, 16, -1000, +1000 ) ) return refused( stream, fixed32.value == SENTINEL64 ); + FuzzCheck.check( fixed32.value >= -1000L * 65536 && fixed32.value <= 1000L * 65536, "fixed Q16.16: raw value outside its bounds" ); + trace.add( fixed32.value ); + + LongRef fixed64 = new LongRef( SENTINEL64 ); + if ( !stream.serializeFixed( fixed64, 48, 16, -100000000000L, +100000000000L ) ) return refused( stream, fixed64.value == SENTINEL64 ); + FuzzCheck.check( fixed64.value >= -100000000000L * 65536 && fixed64.value <= 100000000000L * 65536, "fixed Q48.16: raw value outside its bounds" ); + trace.add( fixed64.value ); + + // ±2^60 units: a raw range past 64 bits + Ref wide = new Ref<>( null ); + if ( !stream.serializeFixed128( wide, 112, 16, -1152921504606846976L, +1152921504606846976L ) ) return refused( stream, wide.value == null ); + BigInteger wideValue = big( wide.value ); + FuzzCheck.check( wideValue.compareTo( WIDE_BOUND.negate() ) >= 0 && wideValue.compareTo( WIDE_BOUND ) <= 0, "fixed Q112.16: raw value outside its bounds" ); + trace.add( wide.value.lo ); + trace.add( wide.value.hi ); + + // ranged 128 bit integers, the bound width varying by param so the run walks one + // group through four (2^20 .. 2^110): hostile bytes decode inside the bounds or fail, + // never clamp or wrap + final BigInteger boundBig = BigInteger.ONE.shiftLeft( 20 + param * 6 ); + final Int128Value bound128 = new Int128Value( boundBig.shiftRight( 64 ).longValue(), boundBig.longValue() ); + Ref ranged128 = new Ref<>( null ); + if ( !stream.serializeInt128( ranged128, bound128.negate(), bound128 ) ) return refused( stream, ranged128.value == null ); + FuzzCheck.check( ranged128.value.compareTo( bound128.negate() ) >= 0 && ranged128.value.compareTo( bound128 ) <= 0, "int128: value outside its range" ); + trace.add( ranged128.value.lo ); + trace.add( ranged128.value.hi ); + + Ref full128 = new Ref<>( null ); + if ( !stream.serializeInt128( full128, Int128Value.MIN_VALUE, Int128Value.MAX_VALUE ) ) return refused( stream, full128.value == null ); + trace.add( full128.value.lo ); + trace.add( full128.value.hi ); + accepted( stream ); + } + break; + + case 4: + { + BoolRef value = new BoolRef(); + if ( !stream.serializeBool( value ) ) return refused( stream, true ); + accepted( stream ); + trace.add( value.value ? 1 : 0 ); + } + break; + + case 5: + { + // finite values only: whatever the bytes, an accepted float is never NaN or infinite + FloatRef value = new FloatRef( 1.5f ); + if ( !stream.serializeFloat( value ) ) return refused( stream, Float.floatToRawIntBits( value.value ) == Float.floatToRawIntBits( 1.5f ) ); + accepted( stream ); + FuzzCheck.check( SerializeUtil.floatBitsAreFinite( Float.floatToRawIntBits( value.value ) ), "float: accepted a non-finite value" ); + trace.add( Float.floatToRawIntBits( value.value ) ); + } + break; + + case 6: + { + DoubleRef value = new DoubleRef( 1.5 ); + if ( !stream.serializeDouble( value ) ) return refused( stream, Double.doubleToRawLongBits( value.value ) == Double.doubleToRawLongBits( 1.5 ) ); + accepted( stream ); + FuzzCheck.check( SerializeUtil.doubleBitsAreFinite( Double.doubleToRawLongBits( value.value ) ), "double: accepted a non-finite value" ); + trace.add( Double.doubleToRawLongBits( value.value ) ); + } + break; + + case 7: + { + FloatRef value = new FloatRef( 1.5f ); + if ( !stream.serializeCompressedFloat( value, -10.0f, +10.0f, 0.01f ) ) return refused( stream, Float.floatToRawIntBits( value.value ) == Float.floatToRawIntBits( 1.5f ) ); + accepted( stream ); + FuzzCheck.check( SerializeUtil.floatBitsAreFinite( Float.floatToRawIntBits( value.value ) ), "compressed_float: accepted a non-finite value" ); + trace.add( Float.floatToRawIntBits( value.value ) ); + } + break; + + case 8: + { + LongRef value = new LongRef( SENTINEL64 ); + if ( !stream.serializeUint64( value ) ) return refused( stream, value.value == SENTINEL64 ); + trace.add( value.value ); + + Ref value128 = new Ref<>( null ); + if ( !stream.serializeUint128( value128 ) ) return refused( stream, value128.value == null ); + trace.add( value128.value.lo ); + trace.add( value128.value.hi ); + + // the raw 64 bit width, the Java surface's own primitive: [33,63] + final int bits = 33 + param * 2; + LongRef raw = new LongRef( SENTINEL64 ); + if ( !stream.serializeBits64( raw, bits ) ) return refused( stream, raw.value == SENTINEL64 ); + FuzzCheck.check( ( raw.value >>> bits ) == 0, "bits64: value wider than its width" ); + trace.add( raw.value ); + accepted( stream ); + } + break; + + case 9: + { + final int numBytes = param * 16 + 1; // [1,241] + if ( !stream.serializeBytes( bytes, numBytes ) ) return refused( stream, true ); // a caller-owned buffer is unspecified after a refusal + accepted( stream ); + FuzzCheck.check( stream.getBitsProcessed() % 8 == 0, "bytes: ended off a byte boundary" ); + for ( int j = 0; j < numBytes; j++ ) + { + trace.add( bytes[j] ); + } + } + break; + + case 10: + { + Ref value = new Ref<>( null ); + if ( !stream.serializeString( value, 256 ) ) return refused( stream, value.value == null ); + accepted( stream ); + byte[] utf8 = value.value.getBytes( StandardCharsets.UTF_8 ); + FuzzCheck.check( utf8.length < 256, "string: longer than its buffer" ); + FuzzCheck.check( value.value.indexOf( '\0' ) < 0, "string: accepted an embedded NUL" ); + FuzzCheck.check( StandardCharsets.UTF_8.newEncoder().canEncode( value.value ), "string: decoded text holds an unpaired surrogate" ); + trace.add( value.value.hashCode() ); + trace.add( utf8.length ); + } + break; + + case 11: + { + // the end of the object: an accepted end leaves nothing in the stream + if ( !stream.serializeEnd() ) return refused( stream, true ); + accepted( stream ); + FuzzCheck.check( stream.getBitsProcessed() % 8 == 0, "end: accepted off a byte boundary" ); + IntRef after = new IntRef( SENTINEL ); + FuzzCheck.check( !stream.serializeBits( after, 1 ), "end: a bit remained after an accepted end" ); + FuzzCheck.check( stream.getRefusal() == Refusal.PAST_END, "end: the read past an accepted end was not past_end" ); + return false; + } + + case 12: + { + if ( !stream.serializeAlign() ) return refused( stream, true ); + accepted( stream ); + FuzzCheck.check( stream.getBitsProcessed() % 8 == 0, "align: ended off a byte boundary" ); + } + break; + + case 13: + { + final int previous = param * 1000; // int_relative's domain starts at 0: a negative previous is caller error + IntRef current = new IntRef( SENTINEL ); + if ( !stream.serializeIntRelative( previous, current ) ) return refused( stream, current.value == SENTINEL ); + accepted( stream ); + FuzzCheck.check( current.value > previous, "int_relative: accepted a value that does not increase" ); + trace.add( current.value ); + } + break; + + case 14: + { + final int max = ( param + 1 ) * 1000; // ranges of varying width exercise bitsRequired + IntRef value = new IntRef( SENTINEL ); + if ( !stream.serializeInt( value, 0, max ) ) return refused( stream, value.value == SENTINEL ); + accepted( stream ); + FuzzCheck.check( value.value >= 0 && value.value <= max, "int: value outside [0,max]" ); + trace.add( value.value ); + } + break; + + case 15: + default: + { + IntRef value8 = new IntRef( SENTINEL ); + if ( !stream.serializeUint8( value8 ) ) return refused( stream, value8.value == SENTINEL ); + FuzzCheck.check( ( value8.value >>> 8 ) == 0, "uint8: value wider than 8 bits" ); + IntRef value16 = new IntRef( SENTINEL ); + if ( !stream.serializeUint16( value16 ) ) return refused( stream, value16.value == SENTINEL ); + FuzzCheck.check( ( value16.value >>> 16 ) == 0, "uint16: value wider than 16 bits" ); + IntRef value32 = new IntRef( SENTINEL ); + if ( !stream.serializeUint32( value32 ) ) return refused( stream, value32.value == SENTINEL ); + accepted( stream ); + trace.add( value8.value ); + trace.add( value16.value ); + trace.add( value32.value ); + } + break; + } + } + + return true; + } +} diff --git a/fuzz/serialize/fuzz/RoundTripFuzzer.java b/fuzz/serialize/fuzz/RoundTripFuzzer.java new file mode 100644 index 0000000..0a77df2 --- /dev/null +++ b/fuzz/serialize/fuzz/RoundTripFuzzer.java @@ -0,0 +1,379 @@ +package serialize.fuzz; + +import java.math.BigInteger; + +import serialize.BitStream; +import serialize.BoolRef; +import serialize.DoubleRef; +import serialize.FloatRef; +import serialize.Int128Value; +import serialize.IntRef; +import serialize.LongRef; +import serialize.MeasureStream; +import serialize.ReadStream; +import serialize.Ref; +import serialize.Refusal; +import serialize.SerializeUtil; +import serialize.UInt128Value; +import serialize.WriteStream; + +/** + * Jazzer target two: the DIFFERENTIAL ROUND TRIP, the Java counterpart of the + * reference's FuzzRoundTrip (fuzz.cpp, pass 3). + * + * Values generated from the input are written with a {@link WriteStream}, + * measured with a {@link MeasureStream}, and read back with a + * {@link ReadStream}; one serialize body drives all three, as a message's + * would. It traps on any write/read asymmetry (a value that does not come + * back exactly, or a read of the writer's own bytes that refuses), on a + * measure that counts fewer bits than were written, on a read that does not + * end exactly where the writer stopped, and on a stream one byte longer than + * the writer reported whose end is not refused as trailing_data. + * + * Input layout, as the reference: the first {@link HostileReadFuzzer#NUM_OPS} + * bytes are the op program, the rest is the value pool. Every case consumes + * the same pool bytes writing and reading, so each pass regenerates the exact + * values the write pass produced. + * + * Run with assertions on (-ea): the write side contracts are asserts, and the + * generated values must satisfy every one of them. + */ +public final class RoundTripFuzzer +{ + private RoundTripFuzzer() {} + + /** Worst case is ~260 bytes per op (a 241 byte bytes field plus alignment), so 32 ops fit comfortably. */ + private static final int WRITE_BUFFER_SIZE = 16 * 1024; + + /** Jazzer's entry point. */ + public static void fuzzerTestOneInput( byte[] data ) + { + final int numOps = HostileReadFuzzer.NUM_OPS; + if ( data.length <= numOps || data.length > numOps + HostileReadFuzzer.MAX_PAYLOAD ) + { + return; + } + final int payloadBytes = data.length - numOps; + + byte[] writeBuffer = new byte[WRITE_BUFFER_SIZE]; + WriteStream writeStream = new WriteStream( writeBuffer, WRITE_BUFFER_SIZE ); + FuzzCheck.check( fuzzRoundTrip( writeStream, data, new ValuePool( data, numOps, payloadBytes ) ), "writing in-range values refused" ); + writeStream.flush(); + + MeasureStream measureStream = new MeasureStream(); + FuzzCheck.check( fuzzRoundTrip( measureStream, data, new ValuePool( data, numOps, payloadBytes ) ), "measuring in-range values refused" ); + FuzzCheck.check( measureStream.getBitsProcessed() >= writeStream.getBitsProcessed(), "the measure under-measured the write" ); + + final int written = (int) writeStream.getBytesProcessed(); + + // the write buffer extends well past the written bytes, and the writer only ever writes zeros there + ReadStream readStream = new ReadStream( writeBuffer, written ); + FuzzCheck.check( fuzzRoundTrip( readStream, data, new ValuePool( data, numOps, payloadBytes ) ), "reading back the writer's own bytes refused" ); + FuzzCheck.check( readStream.getBitsProcessed() == writeStream.getBitsProcessed(), "the reader and the writer disagree on the bits processed" ); + FuzzCheck.check( readStream.serializeEnd(), "the read did not end where the writer stopped" ); + FuzzCheck.check( readStream.getRefusal() == Refusal.NONE, "an accepted read reported a refusal" ); + + // one byte more than the writer reported is trailing data, refused at the end + ReadStream longerStream = new ReadStream( writeBuffer, written + 1 ); + FuzzCheck.check( fuzzRoundTrip( longerStream, data, new ValuePool( data, numOps, payloadBytes ) ), "reading the writer's bytes plus one refused before the end" ); + FuzzCheck.check( !longerStream.serializeEnd(), "a trailing byte was accepted at the end" ); + FuzzCheck.check( longerStream.getRefusal() == Refusal.TRAILING_DATA, "a trailing byte was refused for the wrong reason" ); + } + + private static final BigInteger TWO_64 = BigInteger.ONE.shiftLeft( 64 ); + + private static BigInteger unsignedBig( long value ) + { + return new BigInteger( Long.toUnsignedString( value ) ); + } + + private static Int128Value int128( BigInteger value ) + { + return new Int128Value( value.shiftRight( 64 ).longValue(), value.longValue() ); + } + + // wide fixed point: bounds ±2^60 whole units in Q112.16 give a raw range of 2^77, exercising + // the multi group path with values that cannot fit in 64 bits + private static final BigInteger WIDE_RAW_MIN = BigInteger.ONE.shiftLeft( 76 ).negate(); + private static final BigInteger WIDE_SPAN = BigInteger.ONE.shiftLeft( 77 ).add( BigInteger.ONE ); + + // builds the expected text of case 11: random Unicode scalar values, so every length of + // UTF-8 sequence and every plane reaches the writer. NUL and the surrogate block are not + // scalar text and are replaced. + private static String scalarText( ValuePool pool ) + { + StringBuilder text = new StringBuilder(); + final int characters = pool.nextByte() % 8; + for ( int j = 0; j < characters; j++ ) + { + int code = Integer.remainderUnsigned( pool.nextUint32(), 0x110000 ); + if ( code == 0 || ( code >= 0xD800 && code <= 0xDFFF ) ) + { + code = 0x7881; // 碁 + } + text.appendCodePoint( code ); + } + return text.toString(); + } + + static boolean fuzzRoundTrip( BitStream stream, byte[] ops, ValuePool pool ) + { + final boolean writing = stream.isWriting(); + final boolean reading = stream.isReading(); + + for ( int i = 0; i < HostileReadFuzzer.NUM_OPS; i++ ) + { + final int select = ops[i] & 15; + final int param = ( ops[i] >> 4 ) & 15; // [0,15] + + switch ( select ) + { + case 0: + case 1: + { + final int bits = ( select == 0 ) ? param + 1 : param + 17; // [1,32] + final int mask = ( bits == 32 ) ? 0xFFFFFFFF : ( ( 1 << bits ) - 1 ); + final int expected = pool.nextUint32() & mask; + IntRef value = new IntRef( writing ? expected : 0 ); + if ( !stream.serializeBits( value, bits ) ) return false; + if ( reading ) FuzzCheck.check( value.value == expected, "bits: asymmetric round trip" ); + } + break; + + case 2: + { + final int expected = -100 + Integer.remainderUnsigned( pool.nextUint32(), 201 ); + IntRef value = new IntRef( writing ? expected : 0 ); + if ( !stream.serializeInt( value, -100, +100 ) ) return false; + if ( reading ) FuzzCheck.check( value.value == expected, "int [-100,+100]: asymmetric round trip" ); + } + break; + + case 3: + { + final int expected = pool.nextUint32(); + IntRef value = new IntRef( writing ? expected : 0 ); + if ( !stream.serializeInt( value, Integer.MIN_VALUE, Integer.MAX_VALUE ) ) return false; + if ( reading ) FuzzCheck.check( value.value == expected, "int full range: asymmetric round trip" ); + + final long expected64 = pool.nextUint64(); + LongRef value64 = new LongRef( writing ? expected64 : 0 ); + if ( !stream.serializeInt64( value64, Long.MIN_VALUE, Long.MAX_VALUE ) ) return false; + if ( reading ) FuzzCheck.check( value64.value == expected64, "int64 full range: asymmetric round trip" ); + + final long bound = (long) ( param + 1 ) << 35; // spans of varying width exercise bitsRequired64 + final long span = bound * 2 + 1; + final long expectedRanged = -bound + Long.remainderUnsigned( pool.nextUint64(), span ); + LongRef ranged64 = new LongRef( writing ? expectedRanged : 0 ); + if ( !stream.serializeInt64( ranged64, -bound, +bound ) ) return false; + if ( reading ) FuzzCheck.check( ranged64.value == expectedRanged, "int64 ranged: asymmetric round trip" ); + + // fixed point: raw values generated uniformly inside the raw bounds must round trip exactly + final long fixed32Range = 2000L * 65536; // bounds [-1000,+1000] whole units in Q16.16 + final long expectedFixed32 = -1000L * 65536 + Long.remainderUnsigned( Integer.toUnsignedLong( pool.nextUint32() ), fixed32Range + 1 ); + LongRef fixed32 = new LongRef( writing ? expectedFixed32 : 0 ); + if ( !stream.serializeFixed( fixed32, 16, 16, -1000, +1000 ) ) return false; + if ( reading ) FuzzCheck.check( fixed32.value == expectedFixed32, "fixed Q16.16: asymmetric round trip" ); + + final long fixed64Range = 200000000000L * 65536; // bounds [-1e11,+1e11] whole units in Q48.16 + final long expectedFixed64 = -100000000000L * 65536 + Long.remainderUnsigned( pool.nextUint64(), fixed64Range + 1 ); + LongRef fixed64 = new LongRef( writing ? expectedFixed64 : 0 ); + if ( !stream.serializeFixed( fixed64, 48, 16, -100000000000L, +100000000000L ) ) return false; + if ( reading ) FuzzCheck.check( fixed64.value == expectedFixed64, "fixed Q48.16: asymmetric round trip" ); + + final long widePoolHigh = pool.nextUint64(); + final long widePoolLow = pool.nextUint64(); + final BigInteger widePool = unsignedBig( widePoolHigh ).multiply( TWO_64 ).add( unsignedBig( widePoolLow ) ); + final Int128Value expectedWide = int128( WIDE_RAW_MIN.add( widePool.mod( WIDE_SPAN ) ) ); + Ref wide = new Ref<>( writing ? expectedWide : Int128Value.ZERO ); + if ( !stream.serializeFixed128( wide, 112, 16, -1152921504606846976L, +1152921504606846976L ) ) return false; + if ( reading ) FuzzCheck.check( expectedWide.equals( wide.value ), "fixed Q112.16: asymmetric round trip" ); + + // ranged and full 128 bit integers, which the reference's fuzz pass reaches only through + // fixed point: the bound width varying by param walks the group structures + final BigInteger bound128 = BigInteger.ONE.shiftLeft( 20 + param * 6 ); // 2^20 .. 2^110 + final long rangedHigh = pool.nextUint64(); + final long rangedLow = pool.nextUint64(); + final BigInteger rangedPool = unsignedBig( rangedHigh ).multiply( TWO_64 ).add( unsignedBig( rangedLow ) ); + final Int128Value expectedRanged128 = int128( bound128.negate().add( rangedPool.mod( bound128.shiftLeft( 1 ).add( BigInteger.ONE ) ) ) ); + Ref ranged128 = new Ref<>( writing ? expectedRanged128 : Int128Value.ZERO ); + if ( !stream.serializeInt128( ranged128, int128( bound128.negate() ), int128( bound128 ) ) ) return false; + if ( reading ) FuzzCheck.check( expectedRanged128.equals( ranged128.value ), "int128 ranged: asymmetric round trip" ); + + final Int128Value expectedFull128 = new Int128Value( pool.nextUint64(), pool.nextUint64() ); + Ref full128 = new Ref<>( writing ? expectedFull128 : Int128Value.ZERO ); + if ( !stream.serializeInt128( full128, Int128Value.MIN_VALUE, Int128Value.MAX_VALUE ) ) return false; + if ( reading ) FuzzCheck.check( expectedFull128.equals( full128.value ), "int128 full range: asymmetric round trip" ); + } + break; + + case 4: + { + final boolean expected = ( pool.nextByte() & 1 ) != 0; + BoolRef value = new BoolRef( writing && expected ); + if ( !stream.serializeBool( value ) ) return false; + if ( reading ) FuzzCheck.check( value.value == expected, "bool: asymmetric round trip" ); + } + break; + + case 5: + { + // arbitrary FINITE bit patterns, -0 and subnormals included: floats round trip bit + // exact. A non-finite pattern has its exponent cleared, keeping the sign and the + // mantissa entropy as a subnormal: writing NaN or infinity is a contract violation, + // and hostile non-finite bytes reach the read path in the hostile read target + final int rawBits = pool.nextUint32(); + final int expectedBits = SerializeUtil.floatBitsAreFinite( rawBits ) ? rawBits : ( rawBits & 0x807FFFFF ); + FloatRef value = new FloatRef( writing ? Float.intBitsToFloat( expectedBits ) : 0.0f ); + if ( !stream.serializeFloat( value ) ) return false; + if ( reading ) FuzzCheck.check( Float.floatToRawIntBits( value.value ) == expectedBits, "float: asymmetric round trip" ); + } + break; + + case 6: + { + final long rawBits = pool.nextUint64(); + final long expectedBits = SerializeUtil.doubleBitsAreFinite( rawBits ) ? rawBits : ( rawBits & 0x800FFFFFFFFFFFFFL ); + DoubleRef value = new DoubleRef( writing ? Double.longBitsToDouble( expectedBits ) : 0.0 ); + if ( !stream.serializeDouble( value ) ) return false; + if ( reading ) FuzzCheck.check( Double.doubleToRawLongBits( value.value ) == expectedBits, "double: asymmetric round trip" ); + } + break; + + case 7: + { + // arbitrary FINITE bit patterns: out of range values clamp into [min,max] on write, + // and in range values round trip within the resolution + final int rawBits = pool.nextUint32(); + final int expectedBits = SerializeUtil.floatBitsAreFinite( rawBits ) ? rawBits : ( rawBits & 0x807FFFFF ); + final float expected = Float.intBitsToFloat( expectedBits ); + FloatRef value = new FloatRef( writing ? expected : 0.0f ); + if ( !stream.serializeCompressedFloat( value, -10.0f, +10.0f, 0.01f ) ) return false; + if ( reading ) + { + FuzzCheck.check( value.value >= -10.001f && value.value <= +10.001f, "compressed_float: decoded outside [min,max]" ); + if ( expected >= -10.0f && expected <= +10.0f ) + { + FuzzCheck.check( Math.abs( value.value - expected ) <= 0.011f, "compressed_float: decoded off by more than the resolution" ); + } + } + } + break; + + case 8: + { + final long expected = pool.nextUint64(); + LongRef value = new LongRef( writing ? expected : 0 ); + if ( !stream.serializeUint64( value ) ) return false; + if ( reading ) FuzzCheck.check( value.value == expected, "uint64: asymmetric round trip" ); + + final long expected128High = pool.nextUint64(); + final long expected128Low = pool.nextUint64(); + final UInt128Value expected128 = new UInt128Value( expected128High, expected128Low ); + Ref value128 = new Ref<>( writing ? expected128 : UInt128Value.ZERO ); + if ( !stream.serializeUint128( value128 ) ) return false; + if ( reading ) FuzzCheck.check( expected128.equals( value128.value ), "uint128: asymmetric round trip" ); + + // the raw 64 bit width, the Java surface's own primitive: [33,63] + final int bits = 33 + param * 2; + final long expectedRaw = pool.nextUint64() & ( ( 1L << bits ) - 1 ); + LongRef raw = new LongRef( writing ? expectedRaw : 0 ); + if ( !stream.serializeBits64( raw, bits ) ) return false; + if ( reading ) FuzzCheck.check( raw.value == expectedRaw, "bits64: asymmetric round trip" ); + } + break; + + case 9: + { + final int numBytes = param * 16 + 1; // [1,241] + byte[] expected = new byte[numBytes]; + for ( int j = 0; j < numBytes; j++ ) + { + expected[j] = (byte) pool.nextByte(); + } + byte[] value = writing ? expected.clone() : new byte[numBytes]; + if ( !stream.serializeBytes( value, numBytes ) ) return false; + if ( reading ) FuzzCheck.check( java.util.Arrays.equals( value, expected ), "bytes: asymmetric round trip" ); + } + break; + + case 10: + { + // masked to ASCII with NUL replaced: the writer's contract is well-formed text with + // no interior NUL (asserted), and arbitrary bytes reach the read path in the hostile target + final int length = pool.nextByte() % 31; + StringBuilder text = new StringBuilder(); + for ( int j = 0; j < length; j++ ) + { + final int c = pool.nextByte() & 0x7F; + text.append( c != 0 ? (char) c : ' ' ); + } + final String expected = text.toString(); + Ref value = new Ref<>( writing ? expected : null ); + if ( !stream.serializeString( value, 32 ) ) return false; + if ( reading ) FuzzCheck.check( expected.equals( value.value ), "string (ascii): asymmetric round trip" ); + } + break; + + case 11: + { + // seven four byte characters at most: 28 bytes, inside a 40 byte buffer + final String expected = scalarText( pool ); + Ref value = new Ref<>( writing ? expected : null ); + if ( !stream.serializeString( value, 40 ) ) return false; + if ( reading ) FuzzCheck.check( expected.equals( value.value ), "string (unicode): asymmetric round trip" ); + } + break; + + case 12: + { + if ( !stream.serializeAlign() ) return false; + } + break; + + case 13: + { + final int previous = param * 1000; // int_relative's domain starts at 0: a negative previous is caller error + final int expected = previous + 1 + Integer.remainderUnsigned( pool.nextUint32(), 1000000 ); // strictly greater than previous + IntRef value = new IntRef( writing ? expected : 0 ); + if ( !stream.serializeIntRelative( previous, value ) ) return false; + if ( reading ) FuzzCheck.check( value.value == expected, "int_relative: asymmetric round trip" ); + } + break; + + case 14: + { + final int max = ( param + 1 ) * 1000; // ranges of varying width exercise bitsRequired + final int expected = Integer.remainderUnsigned( pool.nextUint32(), max + 1 ); + IntRef value = new IntRef( writing ? expected : 0 ); + if ( !stream.serializeInt( value, 0, max ) ) return false; + if ( reading ) FuzzCheck.check( value.value == expected, "int [0,max]: asymmetric round trip" ); + } + break; + + case 15: + default: + { + final int expected8 = pool.nextByte(); + final int expected16 = pool.nextUint32() & 0xFFFF; + final int expected32 = pool.nextUint32(); + IntRef value8 = new IntRef( writing ? expected8 : 0 ); + IntRef value16 = new IntRef( writing ? expected16 : 0 ); + IntRef value32 = new IntRef( writing ? expected32 : 0 ); + if ( !stream.serializeUint8( value8 ) ) return false; + if ( !stream.serializeUint16( value16 ) ) return false; + if ( !stream.serializeUint32( value32 ) ) return false; + if ( reading ) + { + FuzzCheck.check( value8.value == expected8, "uint8: asymmetric round trip" ); + FuzzCheck.check( value16.value == expected16, "uint16: asymmetric round trip" ); + FuzzCheck.check( value32.value == expected32, "uint32: asymmetric round trip" ); + } + } + break; + } + } + + return true; + } +} diff --git a/fuzz/serialize/fuzz/Sabotage.java b/fuzz/serialize/fuzz/Sabotage.java new file mode 100644 index 0000000..d61217c --- /dev/null +++ b/fuzz/serialize/fuzz/Sabotage.java @@ -0,0 +1,79 @@ +package serialize.fuzz; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * The fuzz harness's NEGATIVE CONTROL: a fuzzer nobody has seen go red is a + * claim rather than a measurement. This copies the library source into a + * scratch directory and plants ONE write/read asymmetry in the copy; the + * Makefile builds that copy, runs the round trip target against it under the + * smoke budget, and is green only when the fuzzer traps on it. + * + * The asymmetry is in the writer of the ranged 64 bit integer: a value whose + * low 16 bits are 0x5A5A is written one higher than it is. Every other value + * round trips, so a fuzzer that only replays its seeds, or a harness that + * stopped comparing, stays green and fails the control. Finding it takes the + * fuzzer reaching the int64 operations through the op program and steering + * the value pool to the planted pattern. + * + * A needle that stops matching FAILS this tool rather than passing as a + * no-op, as Controls.java does for the conformance controls: if it fails, + * update the needle; never delete the control. + * + * Usage: {@code java serialize.fuzz.Sabotage } + */ +public final class Sabotage +{ + private Sabotage() {} + + static final String FILE = "WriteStream.java"; + + static final String NEEDLE = " long unsignedValue = value.value - min;\n"; + + static final String REPLACEMENT = " long unsignedValue = value.value - min + ( ( value.value & 0xFFFFL ) == 0x5A5AL ? 1 : 0 );" + + " // NEGATIVE CONTROL: a planted write/read asymmetry\n"; + + public static void main( String[] args ) throws IOException + { + if ( args.length != 2 ) + { + System.err.println( "usage: java serialize.fuzz.Sabotage " ); + System.exit( 2 ); + } + Path from = Paths.get( args[0] ); + Path to = Paths.get( args[1] ); + Files.createDirectories( to ); + + int patched = 0; + try ( DirectoryStream sources = Files.newDirectoryStream( from, "*.java" ) ) + { + for ( Path source : sources ) + { + String text = new String( Files.readAllBytes( source ), StandardCharsets.UTF_8 ); + if ( source.getFileName().toString().equals( FILE ) ) + { + int first = text.indexOf( NEEDLE ); + if ( first < 0 || text.indexOf( NEEDLE, first + 1 ) >= 0 ) + { + System.err.println( "Sabotage: the needle must match exactly once in " + FILE + "; update it, never delete the control" ); + System.exit( 1 ); + } + text = text.replace( NEEDLE, REPLACEMENT ); + patched++; + } + Files.write( to.resolve( source.getFileName() ), text.getBytes( StandardCharsets.UTF_8 ) ); + } + } + if ( patched != 1 ) + { + System.err.println( "Sabotage: " + FILE + " not found in " + from ); + System.exit( 1 ); + } + System.out.println( "Sabotage: planted the asymmetry in " + to.resolve( FILE ) ); + } +} diff --git a/fuzz/serialize/fuzz/ValuePool.java b/fuzz/serialize/fuzz/ValuePool.java new file mode 100644 index 0000000..797147c --- /dev/null +++ b/fuzz/serialize/fuzz/ValuePool.java @@ -0,0 +1,51 @@ +package serialize.fuzz; + +/** + * Hands out bytes from the fuzz input, wrapping around when exhausted: the + * reference's ValuePool. Every pass of the round trip builds its own pool + * over the same bytes and regenerates the identical value sequence, so + * nothing needs to be stored between passes. + */ +final class ValuePool +{ + private final byte[] data; + private final int offset; + private final int size; + private int index; + + ValuePool( byte[] data, int offset, int size ) + { + this.data = data; + this.offset = offset; + this.size = size; + this.index = 0; + } + + int nextByte() + { + if ( size == 0 ) + { + return 0; + } + if ( index >= size ) + { + index = 0; + } + return data[offset + index++] & 0xFF; + } + + int nextUint32() + { + int value = 0; + for ( int i = 0; i < 4; i++ ) + { + value = ( value << 8 ) | nextByte(); + } + return value; + } + + long nextUint64() + { + return ( Integer.toUnsignedLong( nextUint32() ) << 32 ) | Integer.toUnsignedLong( nextUint32() ); + } +} diff --git a/tending/PINS.md b/tending/PINS.md index d508c96..e9112ce 100644 --- a/tending/PINS.md +++ b/tending/PINS.md @@ -6,3 +6,12 @@ - unpacked at: dist/jdk-21.0.12.1/ (gitignored; re-fetch by url+sha) - invoke: dist/jdk-21.0.12.1/Contents/Home/bin/java (and javac) - library targets Java 17 language level (javac --release 17); built and tested on 21 + +- Jazzer 0.30.0 (Code Intelligence, Apache-2.0), the standalone release: the fuzzing engine of `make fuzz` + - fetched and checked by the Makefile on first use (the `$(JAZZER_JAR)` rule): curl, `shasum -a 256 -c`, then unpack + - url: https://github.com/CodeIntelligenceTesting/jazzer/releases/download/v0.30.0/ + - jazzer-linux-x86-64.tar.gz sha256: 6eeaf0026d75599b07527d93b576593ce847cfca8b337a579971a5a9cdf792d0 + - jazzer-linux-arm64.tar.gz sha256: 16636a4d3e98f1d3a7fcf59d4ab37f5be1d1ad6df9d94d5c3ca8de644838e369 + - jazzer-macos.tar.gz sha256: 6239e2b2b8d626c287fd8a50376315f00ef4310e3f29e96315cd6e60911563c8 + - unpacked at: dist/jazzer-0.30.0/jazzer_standalone.jar (gitignored); run on the pinned JDK + - the harness has no compile time dependency on it: targets are plain fuzzerTestOneInput( byte[] ) classes