From f47f53c3557a2a0fc07937645d771fc9e891ed5f Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 13 Aug 2026 16:48:47 +0000 Subject: [PATCH] feat(core): add HPACK coding primitives --- flash/docs/http2/IMPLEMENTATION-PLAN.md | 2 +- .../flash/http2/hpack/HpackIntegers.java | 96 +++++ .../dev/relism/flash/http2/hpack/Huffman.java | 345 ++++++++++++++++++ .../flash/http2/hpack/HpackIntegersTest.java | 130 +++++++ .../relism/flash/http2/hpack/HuffmanTest.java | 240 ++++++++++++ 5 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HpackIntegers.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/Huffman.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackIntegersTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanTest.java 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. + * + *

Overflow safety ({@code HPACK bomb})

+ * + * RFC 7541 places no upper bound on the number of continuation octets — a hostile peer can encode + * an arbitrarily large integer (conceptually up to 2^64 and beyond) in a handful of bytes. {@link + * #decode} rejects any integer needing more than {@link #MAX_CONTINUATION_OCTETS} continuation + * octets, and independently rejects one that would exceed {@link Integer#MAX_VALUE} even within + * that octet budget — belt-and-suspenders, since with the octet cap in place the second check is + * not expected to ever fire on a real input. Both throw the preallocated {@link + * Http2Exception#COMPRESSION_ERROR} singleton — zero allocation on this hot rejection path (see + * that exception's own Javadoc for why reusing a singleton here is safe). + */ +public final class HpackIntegers { + + private HpackIntegers() {} + + /** + * Maximum number of continuation octets {@link #decode} accepts. 4 octets contribute {@code 4 * 7 + * = 28} bits beyond the prefix — comfortably enough for any legitimate HPACK integer (table + * indices, string lengths, table size updates are all far smaller in practice) while keeping a + * hostile peer's worst case bounded to a handful of wasted bytes per rejected block, not an + * unbounded read loop. + */ + private static final int MAX_CONTINUATION_OCTETS = 4; + + /** + * Decodes a prefix-coded integer starting at {@code buf[pos]}, where the low {@code prefixBits} + * bits of {@code buf[pos]} carry the prefix (any higher bits — e.g. a representation's leading + * flag bits — are the caller's concern and are masked off here). {@code limit} is the exclusive + * end of the region this integer may read from (typically the end of the current HPACK block) — + * reading past it means a truncated/malformed encoding, not "need more input", since by the time + * this runs the whole block is already contiguous in memory (RFC 9113 §6.10: CONTINUATION frames + * are never interleaved with other frames). + * + * @return {@link Pairs#pack}({@code value}, {@code newPos}) — the decoded value in the high 32 + * bits, the position just past the last consumed byte in the low 32 bits + * @throws Http2Exception {@code COMPRESSION_ERROR} on truncation, on exceeding {@link + * #MAX_CONTINUATION_OCTETS}, or on a value that would exceed {@link Integer#MAX_VALUE} + */ + public static long decode(byte[] buf, int pos, int limit, int prefixBits) { + if (pos >= limit) throw Http2Exception.COMPRESSION_ERROR; + int prefixMask = (1 << prefixBits) - 1; + int first = buf[pos] & 0xFF; + int value = first & prefixMask; + int p = pos + 1; + if (value < prefixMask) { + return Pairs.pack(value, p); + } + + long accumulated = prefixMask; + int shift = 0; + int continuationOctets = 0; + while (true) { + if (p >= limit) throw Http2Exception.COMPRESSION_ERROR; + if (++continuationOctets > MAX_CONTINUATION_OCTETS) throw Http2Exception.COMPRESSION_ERROR; + int b = buf[p++] & 0xFF; + accumulated += (long) (b & 0x7F) << shift; + if (accumulated > Integer.MAX_VALUE) throw Http2Exception.COMPRESSION_ERROR; + if ((b & 0x80) == 0) break; + shift += 7; + } + return Pairs.pack((int) accumulated, p); + } + + /** + * Encodes {@code value} as a prefix-coded integer into {@code prefixByteFlags | encoded value}, + * writing into {@code out}. {@code prefixByteFlags} carries whatever high bits the representation + * needs (e.g. {@code 0x80} for an Indexed Header Field) already shifted into position — this + * method only ever sets the low {@code prefixBits} bits of the first byte. + */ + public static void encode(ByteWriter out, int prefixByteFlags, int prefixBits, int value) { + int prefixMask = (1 << prefixBits) - 1; + if (value < prefixMask) { + out.writeByte((byte) (prefixByteFlags | value)); + return; + } + out.writeByte((byte) (prefixByteFlags | prefixMask)); + int remaining = value - prefixMask; + while (remaining >= 0x80) { + out.writeByte((byte) ((remaining & 0x7F) | 0x80)); + remaining >>>= 7; + } + out.writeByte((byte) remaining); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/Huffman.java b/flash/src/main/java/dev/relism/flash/http2/hpack/Huffman.java new file mode 100644 index 0000000..1c3a1bd --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/Huffman.java @@ -0,0 +1,345 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.Http2Exception; + +/** + * RFC 7541 §5.2 / Appendix B: the fixed canonical Huffman code used to compress HPACK string + * literals. {@link #CODES}/{@link #LENGTHS} are transcribed verbatim from Appendix B (each pair + * cross-checked against the RFC's own "code as hex" / "code as bits" columns, which the RFC gives + * redundantly for exactly this reason — a transcription error in either column disagrees with the + * other). Every other structure in this class — the decode trie, the nibble-driven FSM, the + * padding-validity table — is built from that one 257-row table at class-init time, not + * hand-derived, so a mistake in this class's own logic (as opposed to the RFC table itself) shows + * up as a decode/round-trip test failure rather than a silently wrong hand-written FSM. + * + *

Decoding: a nibble-driven FSM

+ * + * {@link #decode} processes each input byte as two 4-bit nibbles (high nibble, then low), doing one + * array lookup per nibble instead of one branch per bit. Each {@link #TRANSITIONS} entry packs: the + * next trie state, whether a symbol was completed while consuming this nibble's 4 bits (at most one + * — the shortest real code is 5 bits, longer than a nibble, so two symbols can never complete + * within a single nibble transition, see {@link #buildTransitionTable} for the proof this relies + * on), and that symbol's byte value if so. A "dead" transition (this nibble's bits cannot be a + * prefix of any valid code, at this position) is a distinct packed flag the decode loop checks + * first. + * + *

Padding (RFC 7541 §5.2)

+ * + * A Huffman-coded string is padded to a byte boundary with the high-order bits of the EOS code (all + * 1s), strictly fewer than 8 of them. Inserting the EOS code itself into the trie (as a real, if + * never-emittable, leaf) means every prefix of the all-1s path already exists as a trie node from + * ordinary trie construction — {@link #buildPaddingValidity} marks exactly those nodes (reachable + * only via 1-bits from the root, depth 1..7) as valid end-of-input states. Anything else left over + * when the input ends — an incomplete real code, or 8+ bits of trailing 1s — is {@code + * COMPRESSION_ERROR}, and so is the EOS symbol appearing anywhere in the input (RFC 7541 §5.2: "a + * Huffman-encoded string literal containing the EOS symbol MUST be treated as a decoding error"). + */ +public final class Huffman { + + private Huffman() {} + + /** + * Symbol id used internally for the EOS code (RFC 7541 Appendix B, row 256) — one past the last + * real byte value; never a legal decode output. + */ + private static final int EOS_SYMBOL = 256; + + // RFC 7541 Appendix B, verbatim: CODES[s]/LENGTHS[s] is symbol s's code (LSB-aligned, per the + // RFC's own "code as hex" column) and its bit length, for s in [0, 255] plus EOS at s = 256. + private static final int[] CODES = { + 0x1ff8, 0x7fffd8, 0xfffffe2, 0xfffffe3, 0xfffffe4, 0xfffffe5, 0xfffffe6, 0xfffffe7, 0xfffffe8, + 0xffffea, + 0x3ffffffc, 0xfffffe9, 0xfffffea, 0x3ffffffd, 0xfffffeb, 0xfffffec, 0xfffffed, 0xfffffee, + 0xfffffef, 0xffffff0, + 0xffffff1, 0xffffff2, 0x3ffffffe, 0xffffff3, 0xffffff4, 0xffffff5, 0xffffff6, 0xffffff7, + 0xffffff8, 0xffffff9, + 0xffffffa, 0xffffffb, 0x14, 0x3f8, 0x3f9, 0xffa, 0x1ff9, 0x15, 0xf8, 0x7fa, + 0x3fa, 0x3fb, 0xf9, 0x7fb, 0xfa, 0x16, 0x17, 0x18, 0x0, 0x1, + 0x2, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x5c, 0xfb, + 0x7ffc, 0x20, 0xffb, 0x3fc, 0x1ffa, 0x21, 0x5d, 0x5e, 0x5f, 0x60, + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, + 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0xfc, 0x73, + 0xfd, 0x1ffb, 0x7fff0, 0x1ffc, 0x3ffc, 0x22, 0x7ffd, 0x3, 0x23, 0x4, + 0x24, 0x5, 0x25, 0x26, 0x27, 0x6, 0x74, 0x75, 0x28, 0x29, + 0x2a, 0x7, 0x2b, 0x76, 0x2c, 0x8, 0x9, 0x2d, 0x77, 0x78, + 0x79, 0x7a, 0x7b, 0x7ffe, 0x7fc, 0x3ffd, 0x1ffd, 0xffffffc, 0xfffe6, 0x3fffd2, + 0xfffe7, 0xfffe8, 0x3fffd3, 0x3fffd4, 0x3fffd5, 0x7fffd9, 0x3fffd6, 0x7fffda, 0x7fffdb, + 0x7fffdc, + 0x7fffdd, 0x7fffde, 0xffffeb, 0x7fffdf, 0xffffec, 0xffffed, 0x3fffd7, 0x7fffe0, 0xffffee, + 0x7fffe1, + 0x7fffe2, 0x7fffe3, 0x7fffe4, 0x1fffdc, 0x3fffd8, 0x7fffe5, 0x3fffd9, 0x7fffe6, 0x7fffe7, + 0xffffef, + 0x3fffda, 0x1fffdd, 0xfffe9, 0x3fffdb, 0x3fffdc, 0x7fffe8, 0x7fffe9, 0x1fffde, 0x7fffea, + 0x3fffdd, + 0x3fffde, 0xfffff0, 0x1fffdf, 0x3fffdf, 0x7fffeb, 0x7fffec, 0x1fffe0, 0x1fffe1, 0x3fffe0, + 0x1fffe2, + 0x7fffed, 0x3fffe1, 0x7fffee, 0x7fffef, 0xfffea, 0x3fffe2, 0x3fffe3, 0x3fffe4, 0x7ffff0, + 0x3fffe5, + 0x3fffe6, 0x7ffff1, 0x3ffffe0, 0x3ffffe1, 0xfffeb, 0x7fff1, 0x3fffe7, 0x7ffff2, 0x3fffe8, + 0x1ffffec, + 0x3ffffe2, 0x3ffffe3, 0x3ffffe4, 0x7ffffde, 0x7ffffdf, 0x3ffffe5, 0xfffff1, 0x1ffffed, 0x7fff2, + 0x1fffe3, + 0x3ffffe6, 0x7ffffe0, 0x7ffffe1, 0x3ffffe7, 0x7ffffe2, 0xfffff2, 0x1fffe4, 0x1fffe5, 0x3ffffe8, + 0x3ffffe9, + 0xffffffd, 0x7ffffe3, 0x7ffffe4, 0x7ffffe5, 0xfffec, 0xfffff3, 0xfffed, 0x1fffe6, 0x3fffe9, + 0x1fffe7, + 0x1fffe8, 0x7ffff3, 0x3fffea, 0x3fffeb, 0x1ffffee, 0x1ffffef, 0xfffff4, 0xfffff5, 0x3ffffea, + 0x7ffff4, + 0x3ffffeb, 0x7ffffe6, 0x3ffffec, 0x3ffffed, 0x7ffffe7, 0x7ffffe8, 0x7ffffe9, 0x7ffffea, + 0x7ffffeb, 0xffffffe, + 0x7ffffec, 0x7ffffed, 0x7ffffee, 0x7ffffef, 0x7fffff0, 0x3ffffee, 0x3fffffff, + }; + + private static final int[] LENGTHS = { + 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28, 28, 28, 28, 28, + 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28, 6, 10, 10, 12, 13, 6, 8, 11, + 10, 10, 8, 11, 8, 6, 6, 6, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, + 15, 6, 12, 10, 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6, 15, 5, 6, 5, + 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5, 6, 7, 6, 5, 5, 6, 7, 7, + 7, 7, 7, 15, 11, 14, 13, 28, 20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, + 23, 23, 24, 23, 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24, + 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23, 21, 21, 22, 21, + 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23, 26, 26, 20, 19, 22, 23, 22, 25, + 26, 26, 26, 27, 27, 26, 24, 25, 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, + 28, 27, 27, 27, 20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23, + 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26, 30, + }; + + // ── Trie, built once from CODES/LENGTHS ───────────────────────────────────── + + private static final int ROOT = 0; + private static final int NO_CHILD = -1; + private static final int NO_SYMBOL = -1; + + private static final int[] child0; + private static final int[] child1; + private static final int[] symbolAt; + private static final int[] depthOf; + private static final boolean[] onesSpine; + private static final int nodeCount; + + // ── Nibble-driven FSM, built from the trie above ──────────────────────────── + + private static final int STATE_BITS = 16; + private static final int STATE_MASK = (1 << STATE_BITS) - 1; + private static final int FLAG_SYMBOL = 1 << STATE_BITS; + private static final int FLAG_INVALID = 1 << (STATE_BITS + 1); + private static final int SYMBOL_SHIFT = 24; + + private static final int[] TRANSITIONS; + private static final boolean[] validEndState; + + static { + // Build the trie: one node per distinct bit-prefix any of the 257 codes passes through. + // Sized generously (sum of code lengths bounds the true worst case; the actual codes + // share far more prefix structure than that bound suggests). + int capacity = 4096; + int[] c0 = new int[capacity]; + int[] c1 = new int[capacity]; + int[] sym = new int[capacity]; + int[] depth = new int[capacity]; + boolean[] spine = new boolean[capacity]; + java.util.Arrays.fill(c0, NO_CHILD); + java.util.Arrays.fill(c1, NO_CHILD); + java.util.Arrays.fill(sym, NO_SYMBOL); + spine[ROOT] = true; + int[] count = {1}; // node 0 = root, already allocated + + for (int s = 0; s <= EOS_SYMBOL; s++) { + insert(c0, c1, sym, depth, spine, count, CODES[s], LENGTHS[s], s); + } + + nodeCount = count[0]; + child0 = java.util.Arrays.copyOf(c0, nodeCount); + child1 = java.util.Arrays.copyOf(c1, nodeCount); + symbolAt = java.util.Arrays.copyOf(sym, nodeCount); + depthOf = java.util.Arrays.copyOf(depth, nodeCount); + onesSpine = java.util.Arrays.copyOf(spine, nodeCount); + + if (nodeCount > (1 << STATE_BITS)) { + // Defensive: would only trip if a future edit changed the table shape drastically. + throw new ExceptionInInitializerError( + "Huffman trie grew to " + nodeCount + " nodes, exceeding STATE_BITS budget"); + } + + TRANSITIONS = buildTransitionTable(); + validEndState = buildPaddingValidity(); + } + + private static void insert( + int[] c0, + int[] c1, + int[] sym, + int[] depth, + boolean[] spine, + int[] count, + int code, + int length, + int symbolValue) { + int node = ROOT; + for (int i = length - 1; i >= 0; i--) { + int bit = (code >>> i) & 1; + int[] children = bit == 0 ? c0 : c1; + int next = children[node]; + if (next == NO_CHILD) { + next = count[0]++; + depth[next] = depth[node] + 1; + spine[next] = spine[node] && bit == 1; + children[node] = next; + } + node = next; + } + sym[node] = symbolValue; + } + + /** + * Builds the {@code state * 16 + nibble} transition table. For each state and each possible 4-bit + * nibble value, walks up to 4 trie edges from that state, MSB-first within the nibble. + * + *

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"); + } + } +}