feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
5 changed files with 812 additions and 1 deletions
Showing only changes of commit f47f53c355 - Show all commits
+1 -1
View File
@@ -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`). | | 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. | | 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. | | 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 | — | — | | 8 — Connection state machine | not started | — | — |
| 9 — HPACK encoder + h2 response path | not started | — | — | | 9 — HPACK encoder + h2 response path | not started | — | — |
| 10 — Stream state machine + dispatch | not started | — | — | | 10 — Stream state machine + dispatch | not started | — | — |
@@ -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.
*
* <h3>Overflow safety ({@code HPACK bomb})</h3>
*
* 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);
}
}
@@ -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 <b>built from that one 257-row table at class-init time</b>, 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.
*
* <h3>Decoding: a nibble-driven FSM</h3>
*
* {@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.
*
* <h3>Padding (RFC 7541 §5.2)</h3>
*
* 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.
*
* <p><b>Why at most one symbol per nibble</b>: 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.
*
* <p>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 <em>as bytes
* are produced</em>, 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);
}
}
@@ -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));
}
}
@@ -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");
}
}
}