diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9b4c7b..93cbdbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: # reader with one refusal rule deleted fails exactly the vectors # conformance-controls/.expected names - run: make controls JDK_HOME="$JAVA_HOME" + - run: make fuzz-sentinel-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 diff --git a/Makefile b/Makefile index e3725fa..4ba0778 100644 --- a/Makefile +++ b/Makefile @@ -210,3 +210,28 @@ fuzz-read-control: $(JAZZER_JAR) $(FUZZ_CLASSES)/.stamp $(READ_SABOTAGE_CLASSES) clean: rm -rf build + +# Deterministic hostile qualification is independent of Jazzer discovery. Both +# destination patterns must reach every first refusal, including grouped reads. +.PHONY: fuzz-replay fuzz-sentinel-controls +fuzz-replay: $(FUZZ_CLASSES)/.stamp + $(JAVA) -ea -cp $(CLASSES):$(FUZZ_CLASSES) serialize.fuzz.HostileReadReplay + $(JAVA) -da -cp $(CLASSES):$(FUZZ_CLASSES) serialize.fuzz.HostileReadReplay + +# Planted destination writes preserve the first sentinel (the bits controls also +# preserve the low half). Separate latch controls test each zero-bit API. +fuzz-sentinel-controls: fuzz-replay + @set -eu; for ctl in bool bits bits64 float double int128 uint128 terminal-int terminal-int64 terminal-int128 terminal-fixed terminal-fixed128 terminal-align terminal-bytes terminal-end terminal-object; do \ + dir=build/fuzz-sentinel-controls/$$ctl; mkdir -p $$dir/src $$dir/classes; \ + $(JAVA) -cp $(FUZZ_CLASSES) serialize.fuzz.SentinelSabotage src/serialize/ReadStream.java $$dir/src/ReadStream.java $$ctl; \ + $(JAVAC) --release 17 -Xlint:all -Werror -cp $(CLASSES) -d $$dir/classes $$dir/src/ReadStream.java; \ + expected="a refused read wrote its scalar destination"; \ + case $$ctl in terminal-*) expected="$${ctl#terminal-}: accepted after refusal" ;; esac; \ + for mode in -ea -da; do \ + if $(JAVA) $$mode -cp $$dir/classes:$(CLASSES):$(FUZZ_CLASSES) serialize.fuzz.HostileReadReplay > $$dir/$$mode.log 2>&1; then \ + echo "sentinel control $$ctl $$mode survived"; exit 1; \ + fi; \ + grep -q "$$expected" $$dir/$$mode.log || { cat $$dir/$$mode.log; exit 1; }; \ + echo "sentinel control $$ctl $$mode caught the planted defect"; \ + done; \ + done diff --git a/README.md b/README.md index 78d9418..e7da15d 100644 --- a/README.md +++ b/README.md @@ -164,10 +164,23 @@ 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 +make fuzz-sentinel-controls # deterministic first-refusal replay and source controls ``` ## Testing +`make fuzz-sentinel-controls` replays 7,777 hostile inputs with assertions on +and off. Both complementary destination patterns must reach the first refusal +of all 28 named read cases, including later members of grouped reads. +Seven copied-reader controls plant destination writes that preserve the first +pattern; the same oracle must catch each under the second pattern. The integer +controls include partial destination writes in the high and low halves. +Nine further source controls prove independent post-refusal checks for +degenerate integer/fixed ranges, align, zero bytes, end, and object composition. +Each probe checks the result, position, reason, and scalar destination (or +that the object callback was never invoked). + + `make test` runs the suite with assertions enabled (`-ea`): writer contracts are `assert` statements, so this is the checked shape. `make test-release` runs the same suite with assertions disabled — the diff --git a/fuzz/serialize/fuzz/HostileReadFuzzer.java b/fuzz/serialize/fuzz/HostileReadFuzzer.java index 1371d0c..fc4ce73 100644 --- a/fuzz/serialize/fuzz/HostileReadFuzzer.java +++ b/fuzz/serialize/fuzz/HostileReadFuzzer.java @@ -27,8 +27,9 @@ * 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. + * scalar destination is left unwritten under full-width complementary + * sentinels, and the next read on the failed stream refuses too without + * consuming a bit or changing the reason. * * The string operation's buffer size is chosen by the op parameter from a * table that includes caller misuse (sizes below 1), which must refuse as @@ -54,7 +55,7 @@ private HostileReadFuzzer() {} static final int MAX_PAYLOAD = 4096; - private static final int SENTINEL = 0x5EED5EED; + private static final int SENTINEL = 0x5A5A5A5A; // the string buffer sizes the op parameter selects: the caller misuse // values a reader must refuse (0, -1, -7, MIN_VALUE), the smallest legal @@ -63,14 +64,14 @@ private HostileReadFuzzer() {} 0, -1, -7, Integer.MIN_VALUE, 1, 2, 3, 16, 255, 256, 257, 65536, 1 << 20, 1 << 30, Integer.MAX_VALUE - 1, Integer.MAX_VALUE, }; - private static final long SENTINEL64 = 0x5EED5EED5EED5EEDL; + private static final long SENTINEL64 = 0x5A5A5A5A5A5A5A5AL; private static final CompressedFloatParams CF_PARAMS = SerializeUtil.compressedFloatParams( -10.0f, +10.0f, 0.01f ); /** Jazzer's entry point. */ public static void fuzzerTestOneInput( byte[] data ) { - if ( data.length <= NUM_OPS || data.length > NUM_OPS + MAX_PAYLOAD ) + if ( data.length < NUM_OPS || data.length > NUM_OPS + MAX_PAYLOAD ) { return; } @@ -89,11 +90,19 @@ public static void fuzzerTestOneInput( byte[] data ) Trace exactTrace = new Trace(); ReadStream exactStream = new ReadStream( exact, payloadBytes ); - boolean exactAccepted = fuzzRead( exactStream, data, exactTrace ); + boolean exactAccepted = fuzzRead( exactStream, data, exactTrace, false ); + + Trace oppositeTrace = new Trace(); + ReadStream oppositeStream = new ReadStream( exact, payloadBytes ); + boolean oppositeAccepted = fuzzRead( oppositeStream, data, oppositeTrace, true ); + FuzzCheck.check( exactAccepted == oppositeAccepted, "sentinels changed whether the reader refused" ); + FuzzCheck.check( exactStream.getRefusal() == oppositeStream.getRefusal(), "sentinels changed the refusal reason" ); + FuzzCheck.check( exactStream.getBitsProcessed() == oppositeStream.getBitsProcessed(), "sentinels changed where the reader stopped" ); + FuzzCheck.check( exactTrace.hash == oppositeTrace.hash && exactTrace.count == oppositeTrace.count, "sentinels changed a decoded value" ); Trace slackTrace = new Trace(); ReadStream slackStream = new ReadStream( slack, payloadBytes ); - boolean slackAccepted = fuzzRead( slackStream, data, slackTrace ); + boolean slackAccepted = fuzzRead( slackStream, data, slackTrace, false ); 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" ); @@ -106,6 +115,8 @@ static final class Trace { long hash = 0xCBF29CE484222325L; int count; + String operation; + String firstRefusal; void add( long value ) { @@ -118,23 +129,53 @@ void add( long value ) // 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 ) + private static boolean refused( ReadStream stream, boolean unwritten, Trace trace, int sentinel ) { + trace.firstRefusal = trace.operation; 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" ); + FuzzCheck.check( unwritten, trace.operation + ": a refused read wrote its scalar destination" ); long bits = stream.getBitsProcessed(); - IntRef echo = new IntRef( SENTINEL ); + 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( 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" ); + + // These APIs have independent zero-bit success paths. A refused one-bit + // read alone cannot prove that any of them respects the failure latch. + IntRef integer = new IntRef( sentinel ); + terminal( stream, stream.serializeInt( integer, 0, 0 ), integer.value == sentinel, reason, bits, "int" ); + long sentinel64 = Integer.toUnsignedLong( sentinel ) * 0x100000001L; + LongRef wide = new LongRef( sentinel64 ); + terminal( stream, stream.serializeInt64( wide, 0, 0 ), wide.value == sentinel64, reason, bits, "int64" ); + Int128Value pattern = new Int128Value( sentinel64, sentinel64 ); + Ref widest = new Ref<>( pattern ); + terminal( stream, stream.serializeInt128( widest, Int128Value.ZERO, Int128Value.ZERO ), widest.value == pattern, reason, bits, "int128" ); + terminal( stream, stream.serializeFixed( wide, 16, 16, 0, 0 ), wide.value == sentinel64, reason, bits, "fixed" ); + terminal( stream, stream.serializeFixed128( widest, 112, 16, 0, 0 ), widest.value == pattern, reason, bits, "fixed128" ); + terminal( stream, stream.serializeAlign(), true, reason, bits, "align" ); + terminal( stream, stream.serializeBytes( EMPTY, 0 ), true, reason, bits, "bytes" ); + terminal( stream, stream.serializeEnd(), true, reason, bits, "end" ); + IntRef calls = new IntRef(); + boolean object = stream.serializeObject( ignored -> { calls.value++; return true; } ); + terminal( stream, object, calls.value == 0, reason, bits, "object" ); return false; } + private static final byte[] EMPTY = new byte[0]; + + private static void terminal( ReadStream stream, boolean ok, boolean unwritten, Refusal reason, long bits, String operation ) + { + FuzzCheck.check( !ok, operation + ": accepted after refusal" ); + FuzzCheck.check( unwritten, operation + ": wrote a destination or invoked a callback after refusal" ); + FuzzCheck.check( stream.getBitsProcessed() == bits, operation + ": consumed bits after refusal" ); + FuzzCheck.check( stream.isFailed() && stream.getRefusal() == reason, operation + ": changed reason after refusal" ); + } + private static void accepted( ReadStream stream ) { FuzzCheck.check( !stream.isFailed() && stream.getRefusal() == Refusal.NONE, "an accepted read left the stream failed" ); @@ -147,8 +188,13 @@ private static BigInteger big( Int128Value value ) 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 ) + static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace, boolean opposite ) { + final int sentinel = opposite ? ~SENTINEL : SENTINEL; + final long sentinel64 = opposite ? ~SENTINEL64 : SENTINEL64; + final Int128Value sentinel128 = new Int128Value( sentinel64, sentinel64 ); + final UInt128Value sentinelU128 = new UInt128Value( sentinel64, sentinel64 ); + final String sentinelString = opposite ? "sentinel A5" : "sentinel 5A"; byte[] bytes = new byte[256]; for ( int i = 0; i < NUM_OPS; i++ ) @@ -162,8 +208,9 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) 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 ); + IntRef value = new IntRef( sentinel ); + trace.operation = "Bits"; + if ( !stream.serializeBits( value, bits ) ) return refused( stream, value.value == sentinel, trace, sentinel ); accepted( stream ); FuzzCheck.check( bits == 32 || ( value.value >>> bits ) == 0, "bits: value wider than its width" ); trace.add( value.value ); @@ -172,8 +219,9 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) case 2: { - IntRef value = new IntRef( SENTINEL ); - if ( !stream.serializeInt( value, -100, +100 ) ) return refused( stream, value.value == SENTINEL ); + IntRef value = new IntRef( sentinel ); + trace.operation = "Int"; + if ( !stream.serializeInt( value, -100, +100 ) ) return refused( stream, value.value == sentinel, trace, sentinel ); accepted( stream ); FuzzCheck.check( value.value >= -100 && value.value <= +100, "int: value outside [-100,+100]" ); trace.add( value.value ); @@ -182,34 +230,40 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) case 3: { - IntRef value = new IntRef( SENTINEL ); - if ( !stream.serializeInt( value, Integer.MIN_VALUE, Integer.MAX_VALUE ) ) return refused( stream, value.value == SENTINEL ); + IntRef value = new IntRef( sentinel ); + trace.operation = "IntFull"; + if ( !stream.serializeInt( value, Integer.MIN_VALUE, Integer.MAX_VALUE ) ) return refused( stream, value.value == sentinel, trace, 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 ); + LongRef value64 = new LongRef( sentinel64 ); + trace.operation = "Int64-value64"; + if ( !stream.serializeInt64( value64, Long.MIN_VALUE, Long.MAX_VALUE ) ) return refused( stream, value64.value == sentinel64, trace, sentinel ); 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 ); + LongRef ranged64 = new LongRef( sentinel64 ); + trace.operation = "Int64-ranged64"; + if ( !stream.serializeInt64( ranged64, -bound, +bound ) ) return refused( stream, ranged64.value == sentinel64, trace, sentinel ); 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 ); + LongRef fixed32 = new LongRef( sentinel64 ); + trace.operation = "Fixed-fixed32"; + if ( !stream.serializeFixed( fixed32, 16, 16, -1000, +1000 ) ) return refused( stream, fixed32.value == sentinel64, trace, sentinel ); 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 ); + LongRef fixed64 = new LongRef( sentinel64 ); + trace.operation = "Fixed-fixed64"; + if ( !stream.serializeFixed( fixed64, 48, 16, -100000000000L, +100000000000L ) ) return refused( stream, fixed64.value == sentinel64, trace, sentinel ); 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 ); + Ref wide = new Ref<>( sentinel128 ); + trace.operation = "Fixed128-wide"; + if ( !stream.serializeFixed128( wide, 112, 16, -1152921504606846976L, +1152921504606846976L ) ) return refused( stream, wide.value != null && wide.value.hi == sentinel64 && wide.value.lo == sentinel64, trace, sentinel ); 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 ); @@ -220,14 +274,16 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) // 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 ); + Ref ranged128 = new Ref<>( sentinel128 ); + trace.operation = "Int128-ranged128"; + if ( !stream.serializeInt128( ranged128, bound128.negate(), bound128 ) ) return refused( stream, ranged128.value != null && ranged128.value.hi == sentinel64 && ranged128.value.lo == sentinel64, trace, sentinel ); 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 ); + Ref full128 = new Ref<>( sentinel128 ); + trace.operation = "Int128-full128"; + if ( !stream.serializeInt128( full128, Int128Value.MIN_VALUE, Int128Value.MAX_VALUE ) ) return refused( stream, full128.value != null && full128.value.hi == sentinel64 && full128.value.lo == sentinel64, trace, sentinel ); trace.add( full128.value.lo ); trace.add( full128.value.hi ); accepted( stream ); @@ -236,8 +292,9 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) case 4: { - BoolRef value = new BoolRef(); - if ( !stream.serializeBool( value ) ) return refused( stream, true ); + BoolRef value = new BoolRef( opposite ); + trace.operation = "Bool"; + if ( !stream.serializeBool( value ) ) return refused( stream, value.value == opposite, trace, sentinel ); accepted( stream ); trace.add( value.value ? 1 : 0 ); } @@ -246,8 +303,9 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) 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 ) ); + FloatRef value = new FloatRef( Float.intBitsToFloat( sentinel ) ); + trace.operation = "Float"; + if ( !stream.serializeFloat( value ) ) return refused( stream, Float.floatToRawIntBits( value.value ) == sentinel, trace, sentinel ); accepted( stream ); FuzzCheck.check( SerializeUtil.floatBitsAreFinite( Float.floatToRawIntBits( value.value ) ), "float: accepted a non-finite value" ); trace.add( Float.floatToRawIntBits( value.value ) ); @@ -256,8 +314,9 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) case 6: { - DoubleRef value = new DoubleRef( 1.5 ); - if ( !stream.serializeDouble( value ) ) return refused( stream, Double.doubleToRawLongBits( value.value ) == Double.doubleToRawLongBits( 1.5 ) ); + DoubleRef value = new DoubleRef( Double.longBitsToDouble( sentinel64 ) ); + trace.operation = "Double"; + if ( !stream.serializeDouble( value ) ) return refused( stream, Double.doubleToRawLongBits( value.value ) == sentinel64, trace, sentinel ); accepted( stream ); FuzzCheck.check( SerializeUtil.doubleBitsAreFinite( Double.doubleToRawLongBits( value.value ) ), "double: accepted a non-finite value" ); trace.add( Double.doubleToRawLongBits( value.value ) ); @@ -268,11 +327,12 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) { // odd parameters drive the precomputed form with the same constants: // the two are wire identical, so they refuse and accept alike - FloatRef value = new FloatRef( 1.5f ); + FloatRef value = new FloatRef( Float.intBitsToFloat( sentinel ) ); + trace.operation = ( param & 1 ) == 0 ? "CompressedFloat" : "CompressedFloatPrecomputed"; final boolean ok = ( param & 1 ) == 0 ? stream.serializeCompressedFloat( value, -10.0f, +10.0f, 0.01f ) : stream.serializeCompressedFloatPrecomputed( value, CF_PARAMS.maxIntegerValue, CF_PARAMS.bits, CF_PARAMS.delta, -10.0f ); - if ( !ok ) return refused( stream, Float.floatToRawIntBits( value.value ) == Float.floatToRawIntBits( 1.5f ) ); + if ( !ok ) return refused( stream, Float.floatToRawIntBits( value.value ) == sentinel, trace, sentinel ); accepted( stream ); FuzzCheck.check( SerializeUtil.floatBitsAreFinite( Float.floatToRawIntBits( value.value ) ), "compressed_float: accepted a non-finite value" ); trace.add( Float.floatToRawIntBits( value.value ) ); @@ -281,19 +341,22 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) case 8: { - LongRef value = new LongRef( SENTINEL64 ); - if ( !stream.serializeUint64( value ) ) return refused( stream, value.value == SENTINEL64 ); + LongRef value = new LongRef( sentinel64 ); + trace.operation = "Uint64"; + if ( !stream.serializeUint64( value ) ) return refused( stream, value.value == sentinel64, trace, sentinel ); trace.add( value.value ); - Ref value128 = new Ref<>( null ); - if ( !stream.serializeUint128( value128 ) ) return refused( stream, value128.value == null ); + Ref value128 = new Ref<>( sentinelU128 ); + trace.operation = "Uint128-value128"; + if ( !stream.serializeUint128( value128 ) ) return refused( stream, value128.value != null && value128.value.hi == sentinel64 && value128.value.lo == sentinel64, trace, sentinel ); 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 ); + LongRef raw = new LongRef( sentinel64 ); + trace.operation = "Bits64-raw"; + if ( !stream.serializeBits64( raw, bits ) ) return refused( stream, raw.value == sentinel64, trace, sentinel ); FuzzCheck.check( ( raw.value >>> bits ) == 0, "bits64: value wider than its width" ); trace.add( raw.value ); accepted( stream ); @@ -303,7 +366,8 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) 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 + trace.operation = "Bytes"; + if ( !stream.serializeBytes( bytes, numBytes ) ) return refused( stream, true, trace, sentinel ); // 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++ ) @@ -320,7 +384,8 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) // whatever the wire holds (IMPLEMENTING.md U7) final int bufferSize = STRING_BUFFER_SIZES[param]; final long before = stream.getBitsProcessed(); - Ref value = new Ref<>( null ); + Ref value = new Ref<>( sentinelString ); + trace.operation = "String"; if ( !stream.serializeString( value, bufferSize ) ) { if ( bufferSize < 1 ) @@ -328,7 +393,7 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) FuzzCheck.check( stream.getRefusal() == Refusal.VALUE_OUT_OF_RANGE, "string: a buffer below 1 was not value_out_of_range" ); FuzzCheck.check( stream.getBitsProcessed() == before, "string: a buffer below 1 moved bits" ); } - return refused( stream, value.value == null ); + return refused( stream, value.value == sentinelString, trace, sentinel ); } FuzzCheck.check( bufferSize >= 1, "string: accepted with a buffer below 1" ); accepted( stream ); @@ -344,18 +409,21 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) case 11: { // the end of the object: an accepted end leaves nothing in the stream - if ( !stream.serializeEnd() ) return refused( stream, true ); + trace.operation = "End-stream"; + if ( !stream.serializeEnd() ) return refused( stream, true, trace, sentinel ); accepted( stream ); FuzzCheck.check( stream.getBitsProcessed() % 8 == 0, "end: accepted off a byte boundary" ); - IntRef after = new IntRef( SENTINEL ); + 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; + trace.operation = "BitsAfterEnd"; + return refused( stream, after.value == sentinel, trace, sentinel ); } case 12: { - if ( !stream.serializeAlign() ) return refused( stream, true ); + trace.operation = "Align-stream"; + if ( !stream.serializeAlign() ) return refused( stream, true, trace, sentinel ); accepted( stream ); FuzzCheck.check( stream.getBitsProcessed() % 8 == 0, "align: ended off a byte boundary" ); } @@ -364,8 +432,9 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) 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 ); + IntRef current = new IntRef( sentinel ); + trace.operation = "IntRelative-previous"; + if ( !stream.serializeIntRelative( previous, current ) ) return refused( stream, current.value == sentinel, trace, sentinel ); accepted( stream ); FuzzCheck.check( current.value > previous, "int_relative: accepted a value that does not increase" ); trace.add( current.value ); @@ -375,8 +444,9 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) 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 ); + IntRef value = new IntRef( sentinel ); + trace.operation = "IntBounded"; + if ( !stream.serializeInt( value, 0, max ) ) return refused( stream, value.value == sentinel, trace, sentinel ); accepted( stream ); FuzzCheck.check( value.value >= 0 && value.value <= max, "int: value outside [0,max]" ); trace.add( value.value ); @@ -386,14 +456,17 @@ static boolean fuzzRead( ReadStream stream, byte[] ops, Trace trace ) case 15: default: { - IntRef value8 = new IntRef( SENTINEL ); - if ( !stream.serializeUint8( value8 ) ) return refused( stream, value8.value == SENTINEL ); + IntRef value8 = new IntRef( sentinel ); + trace.operation = "Uint8-value8"; + if ( !stream.serializeUint8( value8 ) ) return refused( stream, value8.value == sentinel, trace, 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 ); + IntRef value16 = new IntRef( sentinel ); + trace.operation = "Uint16-value16"; + if ( !stream.serializeUint16( value16 ) ) return refused( stream, value16.value == sentinel, trace, 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 ); + IntRef value32 = new IntRef( sentinel ); + trace.operation = "Uint32-value32"; + if ( !stream.serializeUint32( value32 ) ) return refused( stream, value32.value == sentinel, trace, sentinel ); accepted( stream ); trace.add( value8.value ); trace.add( value16.value ); diff --git a/fuzz/serialize/fuzz/HostileReadReplay.java b/fuzz/serialize/fuzz/HostileReadReplay.java new file mode 100644 index 0000000..ad21beb --- /dev/null +++ b/fuzz/serialize/fuzz/HostileReadReplay.java @@ -0,0 +1,62 @@ +package serialize.fuzz; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import serialize.ReadStream; + +/** Deterministic hostile replay. Coverage counts FIRST refusals, never calls on a latched stream. */ +public final class HostileReadReplay +{ + private HostileReadReplay() {} + + private static final Set EXPECTED = Set.of( + "Bits", "BitsAfterEnd", "Int", "IntFull", "IntBounded", "Int64-value64", "Int64-ranged64", + "Fixed-fixed32", "Fixed-fixed64", "Fixed128-wide", "Int128-ranged128", "Int128-full128", + "Bool", "Float", "Double", "CompressedFloat", "CompressedFloatPrecomputed", + "Uint64", "Uint128-value128", "Bits64-raw", "Bytes", "String", "End-stream", "Align-stream", + "IntRelative-previous", "Uint8-value8", "Uint16-value16", "Uint32-value32" ); + + private static void replay( byte[] data, Set first, Set opposite ) + { + HostileReadFuzzer.fuzzerTestOneInput( data ); + byte[] payload = Arrays.copyOfRange( data, HostileReadFuzzer.NUM_OPS, data.length ); + for ( int pass = 0; pass < 2; pass++ ) + { + HostileReadFuzzer.Trace trace = new HostileReadFuzzer.Trace(); + HostileReadFuzzer.fuzzRead( new ReadStream( payload, payload.length ), data, trace, pass != 0 ); + if ( trace.firstRefusal != null ) ( pass == 0 ? first : opposite ).add( trace.firstRefusal ); + } + } + + public static void main( String[] args ) + { + Set first = new HashSet<>(); + Set opposite = new HashSet<>(); + int count = 0; + // Zero payloads keep each preceding ranged field valid. Every byte truncation + // reaches later members of grouped operations, rather than an earlier range error. + for ( int select = 0; select < 16; select++ ) + for ( int param : new int[] { 0, 1, 15 } ) + for ( int bytes = 0; bytes <= 80; bytes++ ) + for ( int fill : new int[] { 0, 255 } ) + { + byte[] data = new byte[HostileReadFuzzer.NUM_OPS + bytes]; + Arrays.fill( data, 0, HostileReadFuzzer.NUM_OPS, (byte) ( param * 16 + select ) ); + Arrays.fill( data, HostileReadFuzzer.NUM_OPS, data.length, (byte) fill ); + replay( data, first, opposite ); + count++; + } + byte[] badPadding = new byte[HostileReadFuzzer.NUM_OPS + 1]; + Arrays.fill( badPadding, (byte) 12 ); + badPadding[0] = 0; // consume one zero bit, then refuse the nonzero alignment padding + badPadding[HostileReadFuzzer.NUM_OPS] = (byte) 254; + replay( badPadding, first, opposite ); + count++; + Set missing = new HashSet<>( EXPECTED ); missing.removeAll( first ); + FuzzCheck.check( missing.isEmpty(), "missing first-refusal coverage for sentinel 5A: " + missing ); + missing = new HashSet<>( EXPECTED ); missing.removeAll( opposite ); + FuzzCheck.check( missing.isEmpty(), "missing first-refusal coverage for sentinel A5: " + missing ); + System.out.println( "hostile replay: " + count + " inputs, " + EXPECTED.size() + " first-refusal operations under both sentinels" ); + } +} diff --git a/fuzz/serialize/fuzz/SentinelSabotage.java b/fuzz/serialize/fuzz/SentinelSabotage.java new file mode 100644 index 0000000..8ddd9d8 --- /dev/null +++ b/fuzz/serialize/fuzz/SentinelSabotage.java @@ -0,0 +1,55 @@ +package serialize.fuzz; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** Plants a destination write or a broken failure latch in a private reader copy. */ +public final class SentinelSabotage +{ + private SentinelSabotage() {} + + public static void main( String[] args ) throws IOException + { + if ( args.length != 3 ) throw new IllegalArgumentException( "source output control" ); + String method; + String write; + boolean terminal = args[2].startsWith( "terminal-" ); + switch ( args[2] ) + { + case "terminal-int": method = "serializeInt"; write = ""; break; + case "terminal-int64": method = "serializeInt64"; write = ""; break; + case "terminal-int128": method = "serializeInt128"; write = ""; break; + case "terminal-fixed": method = "serializeFixed"; write = ""; break; + case "terminal-fixed128": method = "serializeFixed128"; write = ""; break; + case "terminal-align": method = "serializeAlign"; write = ""; break; + case "terminal-bytes": method = "serializeBytes"; write = ""; break; + case "terminal-end": method = "serializeEnd"; write = ""; break; + case "terminal-object": method = "serializeObject"; write = ""; break; + case "bool": method = "serializeBool"; write = "value.value = false;"; break; + case "bits": method = "serializeBits"; write = "value.value = ( value.value & 0xFFFF ) | 0x5A5A0000;"; break; + case "bits64": method = "serializeBits64"; write = "value.value = ( value.value & 0xFFFFFFFFL ) | 0x5A5A5A5A00000000L;"; break; + case "float": method = "serializeFloat"; write = "value.value = Float.intBitsToFloat( 0x5A5A5A5A );"; break; + case "double": method = "serializeDouble"; write = "value.value = Double.longBitsToDouble( 0x5A5A5A5A5A5A5A5AL );"; break; + case "int128": method = "serializeInt128"; write = "value.value = new Int128Value( 0x5A5A5A5A5A5A5A5AL, value.value.lo );"; break; + case "uint128": method = "serializeUint128"; write = "value.value = new UInt128Value( value.value.hi, 0x5A5A5A5A5A5A5A5AL );"; break; + default: throw new IllegalArgumentException( "unknown control " + args[2] ); + } + String text = Files.readString( Path.of( args[0] ), StandardCharsets.UTF_8 ); + String signature = "public boolean " + method + "("; + int start = text.indexOf( signature ); + FuzzCheck.check( start >= 0 && text.indexOf( signature, start + 1 ) < 0, "ambiguous sabotage signature " + method ); + int end = text.indexOf( " @Override", start + signature.length() ); + if ( end < 0 ) end = text.length(); + String body = text.substring( start, end ); + String needle = terminal ? "if ( failed ) return false;" : "return fail( Refusal.PAST_END );"; + int at = body.indexOf( needle ); + FuzzCheck.check( at >= 0, "missing fresh-refusal sabotage site " + method ); + // Braces preserve single-line if semantics in the wide reader. + body = body.replace( needle, terminal ? "if ( failed ) return true;" : "{ " + write + " " + needle + " }" ); + Path out = Path.of( args[1] ); + Files.createDirectories( out.getParent() ); + Files.writeString( out, text.substring( 0, start ) + body + text.substring( end ), StandardCharsets.UTF_8 ); + } +}