feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user