diff --git a/flash/docs/http2/IMPLEMENTATION-PLAN.md b/flash/docs/http2/IMPLEMENTATION-PLAN.md index a0bf75d..75d71a0 100644 --- a/flash/docs/http2/IMPLEMENTATION-PLAN.md +++ b/flash/docs/http2/IMPLEMENTATION-PLAN.md @@ -68,7 +68,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`. | 4 — Byte-layer foundations | done | `feature/core/http2` | `dev.relism.flash.bytes` package (`ByteScan`+SWAR, `ArrayBackedByteView`, `SegmentedByteView`, `PooledSlice`/`SlicePool`, `ByteWriter`, `Pairs`) built. `EX-04`/`EX-05`/`EX-09`/`EX-19`/`EX-25`/`EX-26`/`EX-33` done, plus `EX-06`'s router half (plan correction, `DEC-19`) removing `FastPathRouterImpl`/`FastPathWsRouterImpl`'s `ThreadLocal`s via an opaque per-connection scratch (`AbstractRouter#newScratch`) instead of extending `ConnectionScratch` (would have created a `routing`→`transport` package cycle). `AbstractRouter`/`AbstractWsRouter.route()` gained a `scratch` param — all call sites updated. Measured (`DEC-20`): SWAR scan 35.4% faster (kept), `EX-04`'s word-path 32.1% faster at the mechanism level (kept; today's router doesn't route through it — `MethodPathByteView` stays non-array-backed by design). Router matching itself is ≈0 B/op including parametric routes. Full h1 pipeline is 120.008 B/op, 100% attributable to `Request`/`RequestBody`/`RequestLine` construction — explicitly Phase 6 scope, not a Phase 4 regression. Two documented (non-hot-path) anonymous-`ByteView` fallbacks remain in `QueryParams`/`PathParams.view`. `BYTES.md` written. 395/395 tests green (both with and without `-Pjmh`). | | 5 — Frame layer | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. | | 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20`–`EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models` — `DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38`–`EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. | -| 7 — HPACK decoder | not started | — | — | +| 7 — HPACK decoder | in progress | `feature/core/http2` | `HpackIntegers` and `Huffman` are implemented and tested; next: RFC 7541 static table. | | 8 — Connection state machine | not started | — | — | | 9 — HPACK encoder + h2 response path | not started | — | — | | 10 — Stream state machine + dispatch | not started | — | — | diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackIntegers.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackIntegers.java new file mode 100644 index 0000000..ac70eed --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackIntegers.java @@ -0,0 +1,96 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.http2.Http2Exception; + +/** + * RFC 7541 §5.1 prefix-coded integer encode/decode. An {@code N}-bit prefix holds values {@code + * 0..2^N-2} directly; the sentinel {@code 2^N-1} means "the real value is at least this large, keep + * reading continuation octets" — each contributes 7 bits, low-to-high, with the high bit as a + * continue flag. + * + *
Why at most one symbol per nibble: the shortest real code in {@link #LENGTHS} is 5 + * bits (verified by {@code HuffmanTest.everyRealCodeIsAtLeastFiveBitsLong} — the invariant this + * method's design depends on). Completing a symbol mid-nibble consumes at least 1 of the nibble's + * 4 bits; whatever remains (at most 3) is too short to complete a second code from a fresh root. + * So this method never needs to track more than one emission per entry. + * + *
A missing child edge (a bit sequence that is not a prefix of any of the 257 codes) is always + * {@code FLAG_INVALID}, unconditionally — including at what turns out to be the last nibble of + * the input. This is intentional, not an approximation: valid padding never causes a missing-edge + * walk to begin with (see {@link #buildPaddingValidity}) — it only ever causes the input to run + * out while sitting at a legitimate partial state, which this method's caller ({@link #decode}) + * checks separately once the whole input has been consumed. + */ + private static int[] buildTransitionTable() { + int[] table = new int[nodeCount * 16]; + for (int state = 0; state < nodeCount; state++) { + for (int nibble = 0; nibble < 16; nibble++) { + table[state * 16 + nibble] = simulateNibble(state, nibble); + } + } + return table; + } + + private static int simulateNibble(int startState, int nibble) { + int state = startState; + boolean emitted = false; + int emittedSymbol = -1; + for (int bitIndex = 3; bitIndex >= 0; bitIndex--) { + int bit = (nibble >>> bitIndex) & 1; + int next = bit == 0 ? child0[state] : child1[state]; + if (next == NO_CHILD) { + return FLAG_INVALID; + } + state = next; + if (symbolAt[state] != NO_SYMBOL) { + if (symbolAt[state] == EOS_SYMBOL) { + return FLAG_INVALID; // RFC 7541 5.2: EOS in the input is always an error + } + emitted = true; + emittedSymbol = symbolAt[state]; + state = ROOT; // remaining bits of this nibble (if any) start a fresh code + } + } + int packed = state; + if (emitted) { + packed |= FLAG_SYMBOL | (emittedSymbol << SYMBOL_SHIFT); + } + return packed; + } + + /** + * {@code validEndState[s]}: {@code true} if the decoder may legally have consumed all input while + * sitting at trie state {@code s} — root (nothing pending) or 1..7 bits into the all-1s + * (EOS-prefix) spine. + */ + private static boolean[] buildPaddingValidity() { + boolean[] valid = new boolean[nodeCount]; + valid[ROOT] = true; + for (int s = 1; s < nodeCount; s++) { + valid[s] = onesSpine[s] && depthOf[s] <= 7; + } + return valid; + } + + // ── Public API ─────────────────────────────────────────────────────────── + + /** + * Decodes the Huffman-coded string {@code src[srcOff, srcOff + srcLen)} into {@code dst[dstOff, + * dstLimit)}, returning the number of bytes written. The output bound is enforced as bytes + * are produced, not after accumulating into an unbounded buffer — callers pass a {@code + * dst}/{@code dstLimit} sized to their own maximum (typically {@code + * Http2Limits.MAX_HPACK_STRING_LENGTH}), and a string that would decode past it is rejected + * mid-decode. + * + * @throws Http2Exception {@code COMPRESSION_ERROR} — on any bit sequence that is not a prefix of + * a real code, on the EOS symbol appearing in the input, on invalid trailing padding (not + * all-1s, or 8+ bits), or on exceeding {@code dstLimit} + */ + public static int decode( + byte[] src, int srcOff, int srcLen, byte[] dst, int dstOff, int dstLimit) { + int state = ROOT; + int dstPos = dstOff; + int end = srcOff + srcLen; + for (int i = srcOff; i < end; i++) { + int b = src[i] & 0xFF; + + int t = TRANSITIONS[state * 16 + (b >>> 4)]; + if ((t & FLAG_INVALID) != 0) throw Http2Exception.COMPRESSION_ERROR; + if ((t & FLAG_SYMBOL) != 0) { + if (dstPos >= dstLimit) throw Http2Exception.COMPRESSION_ERROR; + dst[dstPos++] = (byte) (t >>> SYMBOL_SHIFT); + } + state = t & STATE_MASK; + + t = TRANSITIONS[state * 16 + (b & 0xF)]; + if ((t & FLAG_INVALID) != 0) throw Http2Exception.COMPRESSION_ERROR; + if ((t & FLAG_SYMBOL) != 0) { + if (dstPos >= dstLimit) throw Http2Exception.COMPRESSION_ERROR; + dst[dstPos++] = (byte) (t >>> SYMBOL_SHIFT); + } + state = t & STATE_MASK; + } + if (!validEndState[state]) throw Http2Exception.COMPRESSION_ERROR; + return dstPos - dstOff; + } + + /** + * Huffman-encodes {@code src[off, off + len)}, writing directly into {@code out}. Pads the final + * byte with the high-order bits of the EOS code (all 1s), per RFC 7541 §5.2. Built now ({@code + * EX} task 3) for use by the HPACK encoder. + */ + public static void encode(ByteWriter out, byte[] src, int off, int len) { + long accumulator = 0; + int bitCount = 0; + int end = off + len; + for (int i = off; i < end; i++) { + int v = src[i] & 0xFF; + int codeLen = LENGTHS[v]; + accumulator = (accumulator << codeLen) | (CODES[v] & ((1L << codeLen) - 1)); + bitCount += codeLen; + while (bitCount >= 8) { + bitCount -= 8; + out.writeByte((byte) (accumulator >>> bitCount)); + } + } + if (bitCount > 0) { + int padBits = 8 - bitCount; + long lastByte = ((accumulator << padBits) | ((1L << padBits) - 1)) & 0xFF; + out.writeByte((byte) lastByte); + } + } + + /** + * The number of bytes {@link #encode} would produce for {@code src[off, off + len)} — the ceiling + * of the total bit length over 8. + */ + public static int encodedLength(byte[] src, int off, int len) { + long bits = 0; + int end = off + len; + for (int i = off; i < end; i++) { + bits += LENGTHS[src[i] & 0xFF]; + } + return (int) ((bits + 7) / 8); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackIntegersTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackIntegersTest.java new file mode 100644 index 0000000..39b3fc4 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackIntegersTest.java @@ -0,0 +1,130 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.http2.Http2Exception; +import org.junit.jupiter.api.Test; + +class HpackIntegersTest { + + // --- RFC 7541 Appendix C.1: official vectors --- + + @Test + void appendixC11_10With5BitPrefix() { + byte[] buf = {0x0a}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertEquals(10, Pairs.hi(packed)); + assertEquals(1, Pairs.lo(packed)); + } + + @Test + void appendixC12_1337With5BitPrefix() { + byte[] buf = {(byte) 0x1f, (byte) 0x9a, 0x0a}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertEquals(1337, Pairs.hi(packed)); + assertEquals(3, Pairs.lo(packed)); + } + + @Test + void appendixC13_42With8BitPrefix() { + byte[] buf = {0x2a}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 8); + assertEquals(42, Pairs.hi(packed)); + assertEquals(1, Pairs.lo(packed)); + } + + // --- position handling --- + + @Test + void decode_startsAtNonZeroPosition_leavesPrecedingBytesUntouched() { + byte[] buf = {(byte) 0xFF, 0x0a}; // garbage, then "10" with 5-bit prefix + long packed = HpackIntegers.decode(buf, 1, buf.length, 5); + assertEquals(10, Pairs.hi(packed)); + assertEquals(2, Pairs.lo(packed)); + } + + @Test + void decode_ignoresHighBitsAboveThePrefix() { + // High 3 bits simulate a representation's leading flag bits (e.g. 0xA0 = 101xxxxx); + // only the low 5 bits are the integer's prefix. + byte[] buf = {(byte) 0b101_01010}; // flags=101, prefix value=01010=10 + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertEquals(10, Pairs.hi(packed)); + } + + // --- round-trip via encode() --- + + @Test + void encode_thenDecode_roundTrips_acrossBoundaryValues() { + int[] values = {0, 1, 30, 31, 32, 1337, 268_435_455}; + for (int prefixBits : new int[] {4, 5, 7, 8}) { + for (int value : values) { + ByteWriter out = new ByteWriter(16); + HpackIntegers.encode(out, 0, prefixBits, value); + long packed = HpackIntegers.decode(out.array(), 0, out.length(), prefixBits); + assertEquals(value, Pairs.hi(packed), "prefixBits=" + prefixBits + " value=" + value); + assertEquals(out.length(), Pairs.lo(packed)); + } + } + } + + @Test + void encode_matchesRfcVector_1337With5BitPrefix() { + ByteWriter out = new ByteWriter(16); + HpackIntegers.encode(out, 0, 5, 1337); + assertArrayEquals( + new byte[] {(byte) 0x1f, (byte) 0x9a, 0x0a}, + java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void encode_preservesPrefixByteFlags() { + ByteWriter out = new ByteWriter(16); + HpackIntegers.encode(out, 0x80, 7, 5); // Indexed Header Field, index 5 + assertEquals((byte) 0x85, out.array()[0]); + } + + // --- overflow / hostile-input safety (HPACK bomb) --- + + @Test + void decode_exceedingMaxContinuationOctets_throwsCompressionError() { + // 5-bit prefix all-ones (31), then 5 continuation octets all with the continue bit set + // (0xFF) -- one more than MAX_CONTINUATION_OCTETS(4) tolerates. + byte[] buf = {0x1f, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00}; + Http2Exception ex = + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, buf.length, 5)); + assertSame(dev.relism.flash.http2.Http2ErrorCode.COMPRESSION_ERROR, ex.errorCode()); + } + + @Test + void decode_exactlyMaxContinuationOctets_succeeds() { + // 4 continuation octets is the tolerated boundary -- must not throw. + byte[] buf = {0x1f, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertTrue(Pairs.hi(packed) > 0); + } + + @Test + void decode_truncatedAtPrefixByte_throws() { + byte[] buf = {}; + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, buf.length, 5)); + } + + @Test + void decode_truncatedMidContinuation_throws() { + // prefix says "keep reading" but the buffer ends immediately after. + byte[] buf = {0x1f}; + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, buf.length, 5)); + } + + @Test + void decode_truncatedByLimit_notByBufferLength_throws() { + // The buffer itself has more bytes, but `limit` (the current block's end) cuts it off -- + // decode must respect limit, not buf.length, since HPACK scratch buffers are reused and + // may contain trailing bytes from a previous, larger block. + byte[] buf = {0x1f, (byte) 0x9a, 0x0a, 0x00, 0x00}; + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, 2, 5)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanTest.java new file mode 100644 index 0000000..b86473b --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanTest.java @@ -0,0 +1,240 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.Http2Exception; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.Random; +import org.junit.jupiter.api.Test; + +class HuffmanTest { + + private static byte[] hex(String s) { + return HexFormat.of().parseHex(s); + } + + private static byte[] decode(byte[] encoded, int maxOut) { + byte[] dst = new byte[maxOut]; + int n = Huffman.decode(encoded, 0, encoded.length, dst, 0, dst.length); + byte[] result = new byte[n]; + System.arraycopy(dst, 0, result, 0, n); + return result; + } + + // --- RFC 7541 Appendix C.4 / C.6: official Huffman vectors --- + + @Test + void appendixC41_wwwExampleCom() { + byte[] encoded = hex("f1e3c2e5f23a6ba0ab90f4ff"); + assertEquals("www.example.com", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC42_noCache() { + byte[] encoded = hex("a8eb10649cbf"); + assertEquals("no-cache", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC43_customKeyAndValue() { + assertEquals( + "custom-key", new String(decode(hex("25a849e95ba97d7f"), 64), StandardCharsets.UTF_8)); + assertEquals( + "custom-value", new String(decode(hex("25a849e95bb8e8b4bf"), 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_status302() { + assertEquals("302", new String(decode(hex("6402"), 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_private() { + assertEquals("private", new String(decode(hex("aec3771a4b"), 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_dateHeader() { + byte[] encoded = hex("d07abe941054d444a8200595040b8166e082a62d1bff"); + assertEquals( + "Mon, 21 Oct 2013 20:13:21 GMT", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_locationHeader() { + byte[] encoded = hex("9d29ad171863c78f0b97c8e9ae82ae43d3"); + assertEquals( + "https://www.example.com", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC62_status307() { + assertEquals("307", new String(decode(hex("640eff"), 64), StandardCharsets.UTF_8)); + } + + // --- encode() matches the RFC's own bytes --- + + @Test + void encode_matchesRfcVector_wwwExampleCom() { + ByteWriter out = new ByteWriter(32); + byte[] src = "www.example.com".getBytes(StandardCharsets.UTF_8); + Huffman.encode(out, src, 0, src.length); + assertArrayEquals( + hex("f1e3c2e5f23a6ba0ab90f4ff"), java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void encode_matchesRfcVector_noCache() { + ByteWriter out = new ByteWriter(32); + byte[] src = "no-cache".getBytes(StandardCharsets.UTF_8); + Huffman.encode(out, src, 0, src.length); + assertArrayEquals(hex("a8eb10649cbf"), java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void encodedLength_matchesActualEncodedSize() { + byte[] src = "www.example.com".getBytes(StandardCharsets.UTF_8); + assertEquals(12, Huffman.encodedLength(src, 0, src.length)); + } + + // --- round trip: every byte value individually --- + + @Test + void everyByteValue_roundTrips() { + for (int v = 0; v <= 255; v++) { + byte[] src = {(byte) v}; + ByteWriter out = new ByteWriter(8); + Huffman.encode(out, src, 0, 1); + byte[] decoded = decode(java.util.Arrays.copyOf(out.array(), out.length()), 4); + assertArrayEquals(src, decoded, "byte value " + v); + } + } + + // --- round trip: random strings --- + + @Test + void randomStrings_roundTrip() { + Random rnd = new Random(42); + for (int trial = 0; trial < 500; trial++) { + int len = rnd.nextInt(200); + byte[] src = new byte[len]; + rnd.nextBytes(src); + ByteWriter out = new ByteWriter(64); + Huffman.encode(out, src, 0, len); + byte[] encoded = java.util.Arrays.copyOf(out.array(), out.length()); + byte[] decoded = decode(encoded, len + 8); + assertArrayEquals(src, decoded, "trial " + trial + " len " + len); + } + } + + @Test + void asciiHeaderLikeStrings_roundTrip() { + String[] samples = { + "", + "a", + "GET", + "POST", + "application/json", + "text/html; charset=utf-8", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + "0", + "12345", + "!!!???...", + }; + for (String s : samples) { + byte[] src = s.getBytes(StandardCharsets.UTF_8); + ByteWriter out = new ByteWriter(64); + Huffman.encode(out, src, 0, src.length); + byte[] encoded = java.util.Arrays.copyOf(out.array(), out.length()); + byte[] decoded = decode(encoded, src.length + 8); + assertArrayEquals(src, decoded, "sample: " + s); + } + } + + // --- invalid padding --- + + @Test + void decode_paddingLongerThanSevenBits_throws() { + // "no-cache" is 6 bytes Huffman-encoded (a8eb10649cbf); appending a full extra byte of + // all-1s padding (8+ bits of padding total) must be rejected. + byte[] encoded = hex("a8eb10649cbfff"); + assertThrows(Http2Exception.class, () -> decode(encoded, 64)); + } + + @Test + void decode_paddingNotAllOnes_throws() { + // '0' (symbol 48) is the 5-bit code 00000; the correct padding to fill the remaining 3 + // bits of the byte is 111 (0x07), decoding cleanly to "0". Replacing that padding with + // 000 (0x00) leaves the walk 3 bits into the "000..." region of the trie (shared by + // '0'/'1'/'2'/'a', none of which complete in exactly 3 bits) -- not the root, and not on + // the all-1s padding spine, so it must be rejected. + byte[] validPadding = {0x07}; + assertEquals("0", new String(decode(validPadding, 8), StandardCharsets.UTF_8)); + + byte[] invalidPadding = {0x00}; + assertThrows(Http2Exception.class, () -> decode(invalidPadding, 8)); + } + + @Test + void decode_incompleteCodeAtEnd_throws() { + // Truncate "no-cache"'s encoding mid-code (not a valid prefix of the ones-spine). + byte[] full = hex("a8eb10649cbf"); + byte[] truncated = java.util.Arrays.copyOf(full, full.length - 1); + assertThrows(Http2Exception.class, () -> decode(truncated, 64)); + } + + // --- EOS symbol in input --- + + @Test + void decode_eosSymbolInInput_throws() { + // EOS is 30 ones: 0x3fffffff -- encode it directly as 4 bytes, left-aligned to a byte boundary. + // 30 ones followed by 2 padding ones = 0xFF 0xFF 0xFF 0xFF. + byte[] encoded = {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF}; + assertThrows(Http2Exception.class, () -> decode(encoded, 64)); + } + + // --- output bound enforced during decode --- + + @Test + void decode_outputExceedingDstLimit_throws() { + byte[] encoded = hex("f1e3c2e5f23a6ba0ab90f4ff"); // "www.example.com", 16 bytes decoded + assertThrows(Http2Exception.class, () -> decode(encoded, 10)); + } + + @Test + void decode_outputExactlyAtDstLimit_succeeds() { + byte[] encoded = hex("f1e3c2e5f23a6ba0ab90f4ff"); + byte[] result = decode(encoded, 16); + assertEquals("www.example.com", new String(result, StandardCharsets.UTF_8)); + } + + // --- empty string --- + + @Test + void decode_emptyInput_producesEmptyOutput() { + byte[] result = decode(new byte[0], 8); + assertEquals(0, result.length); + } + + @Test + void encode_emptyInput_producesEmptyOutput() { + ByteWriter out = new ByteWriter(8); + Huffman.encode(out, new byte[0], 0, 0); + assertEquals(0, out.length()); + } + + // --- structural invariant the nibble-FSM's "at most one symbol per nibble" design relies on --- + + @Test + void everyRealCodeIsAtLeastFiveBitsLong() throws Exception { + var lengthsField = Huffman.class.getDeclaredField("LENGTHS"); + lengthsField.setAccessible(true); + int[] lengths = (int[]) lengthsField.get(null); + for (int i = 0; i < 256; i++) { + assertTrue(lengths[i] >= 5, "symbol " + i + " has length " + lengths[i] + " < 5"); + } + } +}