feat(core): add HPACK decoder

This commit is contained in:
Zakaria El Orche
2026-08-13 17:17:29 +00:00
parent f47f53c355
commit 95c33e7bf2
24 changed files with 1851 additions and 506 deletions
@@ -0,0 +1,40 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class ContinuationAssemblerTest {
@Test
void assemblesContiguousBlock() {
ContinuationAssembler assembler = new ContinuationAssembler(16);
assembler.begin(3, "abc".getBytes(StandardCharsets.US_ASCII), 0, 3, false);
assembler.continuation(3, "def".getBytes(StandardCharsets.US_ASCII), 0, 3, true);
assertTrue(assembler.isComplete());
assertFalse(assembler.isActive());
assertEquals(
"abcdef", new String(assembler.buffer(), 0, assembler.length(), StandardCharsets.US_ASCII));
}
@Test
void rejectsInterleavingWrongStreamAndOversizedBlocks() {
ContinuationAssembler assembler = new ContinuationAssembler(4);
assembler.begin(1, new byte[] {1}, 0, 1, false);
assertThrows(Http2Exception.class, () -> assembler.begin(3, new byte[0], 0, 0, true));
assertThrows(Http2Exception.class, () -> assembler.continuation(3, new byte[0], 0, 0, true));
assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[4], 0, 4, true));
}
@Test
void boundsContinuationCount() {
ContinuationAssembler assembler = new ContinuationAssembler(32);
assembler.begin(1, new byte[0], 0, 0, false);
for (int i = 0; i < Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK; i++) {
assembler.continuation(1, new byte[0], 0, 0, false);
}
assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[0], 0, 0, false));
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.fail;
import dev.relism.flash.http2.Http2Exception;
import org.junit.jupiter.api.Test;
class HpackDecoderFuzzTest {
private static final int CASES = 10_000_000;
private static final HeaderSink DISCARD = (name, value, never) -> {};
@Test
void tenMillionRandomBlocksOnlyProduceTypedRejections() {
HpackDecoder decoder = new HpackDecoder(256, 1024);
byte[] input = new byte[64];
long state = 0x7541_9113_C0DEL;
for (int iteration = 0; iteration < CASES; iteration++) {
state = next(state);
int length = (int) state & 63;
for (int i = 0; i < length; i++) {
state = next(state);
input[i] = (byte) state;
}
try {
decoder.decode(input, 0, length, DISCARD);
} catch (Http2Exception | HeaderListSizeException expected) {
// Typed protocol rejection.
} catch (Throwable unexpected) {
fail("unexpected failure at iteration " + iteration + ", length " + length, unexpected);
}
}
}
private static long next(long value) {
value ^= value << 13;
value ^= value >>> 7;
return value ^ (value << 17);
}
}
@@ -0,0 +1,68 @@
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 org.junit.jupiter.api.Test;
class HpackDecoderSecurityTest {
private static final HeaderSink DISCARD = (name, value, never) -> {};
@Test
void rejectsZeroAndOutOfRangeIndices() {
HpackDecoder decoder = new HpackDecoder();
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x80}, 0, 1, DISCARD));
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0xff, 0}, 0, 2, DISCARD));
}
@Test
void rejectsLateAndOversizedTableUpdates() {
HpackDecoder decoder = new HpackDecoder(128, 1024);
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x82, 0x20}, 0, 2, DISCARD));
ByteWriter update = new ByteWriter(8);
HpackIntegers.encode(update, 0x20, 5, 129);
assertThrows(
Http2Exception.class, () -> decoder.decode(update.array(), 0, update.length(), DISCARD));
}
@Test
void headerListLimitIsReportedOnlyAfterDynamicStateIsUpdated() {
HpackDecoder decoder = new HpackDecoder(256, 40);
byte[] block = java.util.HexFormat.of().parseHex("40016101624001630164");
HeaderListSizeException error =
assertThrows(
HeaderListSizeException.class, () -> decoder.decode(block, 0, block.length, DISCARD));
assertTrue(error.decodedSize() > 40);
assertEquals(2, decoder.dynamicTable().count());
}
@Test
void malformedStringsAndIntegerBombsAreCompressionErrors() {
HpackDecoder decoder = new HpackDecoder();
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {0x40, 0x01}, 0, 2, DISCARD));
byte[] bomb = {0x3f, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x80};
assertThrows(Http2Exception.class, () -> decoder.decode(bomb, 0, bomb.length, DISCARD));
}
@Test
void indexedDynamicNameSurvivesEvictionDuringInsertion() {
HpackDecoder decoder = new HpackDecoder(48, 1024);
byte[] first = java.util.HexFormat.of().parseHex("4001610d31323334353637383930313233");
decoder.decode(first, 0, first.length, DISCARD);
// Dynamic index 62 supplies the name "a". Adding the new value evicts the referenced entry.
byte[] second = java.util.HexFormat.of().parseHex("7e0d6162636465666768696a6b6c6d");
decoder.decode(second, 0, second.length, DISCARD);
dev.relism.flash.bytes.PooledSlice name = new dev.relism.flash.bytes.PooledSlice();
dev.relism.flash.bytes.PooledSlice value = new dev.relism.flash.bytes.PooledSlice();
decoder.dynamicTable().get(1, name, value);
assertEquals('a', name.byteAt(0));
}
}
@@ -0,0 +1,162 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import org.junit.jupiter.api.Test;
class HpackDecoderTest {
private static final HexFormat HEX = HexFormat.of();
private static final class CollectingSink implements HeaderSink {
final List<String> fields = new ArrayList<>();
final List<Boolean> neverIndexed = new ArrayList<>();
@Override
public void accept(ByteView name, ByteView value, boolean never) {
fields.add(text(name) + ": " + text(value));
neverIndexed.add(never);
}
}
@Test
void appendixC2IndependentRepresentations() {
HpackDecoder decoder = new HpackDecoder();
CollectingSink sink = decode(decoder, "400a637573746f6d2d6b65790d637573746f6d2d686561646572");
assertEquals(List.of("custom-key: custom-header"), sink.fields);
assertDynamic(decoder, 1, "custom-key", "custom-header", 55);
decoder = new HpackDecoder();
sink = decode(decoder, "040c2f73616d706c652f70617468");
assertEquals(List.of(":path: /sample/path"), sink.fields);
assertEquals(0, decoder.dynamicTable().count());
sink = decode(decoder, "100870617373776f726406736563726574");
assertEquals(List.of("password: secret"), sink.fields);
assertEquals(List.of(true), sink.neverIndexed);
assertEquals(0, decoder.dynamicTable().count());
sink = decode(decoder, "82");
assertEquals(List.of(":method: GET"), sink.fields);
}
@Test
void appendixC3RequestsWithoutHuffman() {
verifyRequestSequence(
"828684410f7777772e6578616d706c652e636f6d",
"828684be58086e6f2d6361636865",
"828785bf400a637573746f6d2d6b65790c637573746f6d2d76616c7565");
}
@Test
void appendixC4RequestsWithHuffman() {
verifyRequestSequence(
"828684418cf1e3c2e5f23a6ba0ab90f4ff",
"828684be5886a8eb10649cbf",
"828785bf408825a849e95ba97d7f8925a849e95bb8e8b4bf");
}
@Test
void appendixC5ResponsesWithoutHuffman() {
verifyResponseSequence(
"4803333032580770726976617465611d4d6f6e2c203231204f637420323031332032303a31333a323120474d546e1768747470733a2f2f7777772e6578616d706c652e636f6d",
"4803333037c1c0bf",
"88c1611d4d6f6e2c203231204f637420323031332032303a31333a323220474d54c05a04677a69707738666f6f3d4153444a4b48514b425a584f5157454f50495541585157454f49553b206d61782d6167653d333630303b2076657273696f6e3d31");
}
@Test
void appendixC6ResponsesWithHuffman() {
verifyResponseSequence(
"488264025885aec3771a4b6196d07abe941054d444a8200595040b8166e082a62d1bff6e919d29ad171863c78f0b97c8e9ae82ae43d3",
"4883640effc1c0bf",
"88c16196d07abe941054d444a8200595040b8166e084a62d1bffc05a839bd9ab77ad94e7821dd7f2e6c7b335dfdfcd5b3960d5af27087f3672c1ab270fb5291f9587316065c003ed4ee5b1063d5007");
}
private static void verifyRequestSequence(String first, String second, String third) {
HpackDecoder decoder = new HpackDecoder();
assertEquals(
List.of(":method: GET", ":scheme: http", ":path: /", ":authority: www.example.com"),
decode(decoder, first).fields);
assertDynamic(decoder, 1, ":authority", "www.example.com", 57);
assertEquals(
List.of(
":method: GET",
":scheme: http",
":path: /",
":authority: www.example.com",
"cache-control: no-cache"),
decode(decoder, second).fields);
assertDynamic(decoder, 1, "cache-control", "no-cache", 110);
assertDynamic(decoder, 2, ":authority", "www.example.com", 110);
assertEquals(
List.of(
":method: GET",
":scheme: https",
":path: /index.html",
":authority: www.example.com",
"custom-key: custom-value"),
decode(decoder, third).fields);
assertDynamic(decoder, 1, "custom-key", "custom-value", 164);
assertDynamic(decoder, 2, "cache-control", "no-cache", 164);
assertDynamic(decoder, 3, ":authority", "www.example.com", 164);
}
private static void verifyResponseSequence(String first, String second, String third) {
HpackDecoder decoder = new HpackDecoder(256, 32_768);
assertEquals(responseFields("302", "21"), decode(decoder, first).fields);
assertDynamic(decoder, 1, "location", "https://www.example.com", 222);
assertDynamic(decoder, 4, ":status", "302", 222);
assertEquals(responseFields("307", "21"), decode(decoder, second).fields);
assertDynamic(decoder, 1, ":status", "307", 222);
assertDynamic(decoder, 4, "cache-control", "private", 222);
List<String> expected = new ArrayList<>(responseFields("200", "22"));
expected.add("content-encoding: gzip");
expected.add("set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1");
assertEquals(expected, decode(decoder, third).fields);
assertEquals(3, decoder.dynamicTable().count());
assertDynamic(
decoder, 1, "set-cookie", "foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1", 215);
assertDynamic(decoder, 2, "content-encoding", "gzip", 215);
assertDynamic(decoder, 3, "date", "Mon, 21 Oct 2013 20:13:22 GMT", 215);
}
private static List<String> responseFields(String status, String second) {
return List.of(
":status: " + status,
"cache-control: private",
"date: Mon, 21 Oct 2013 20:13:" + second + " GMT",
"location: https://www.example.com");
}
private static CollectingSink decode(HpackDecoder decoder, String hex) {
CollectingSink sink = new CollectingSink();
byte[] block = HEX.parseHex(hex);
decoder.decode(block, 0, block.length, sink);
return sink;
}
private static void assertDynamic(
HpackDecoder decoder, int index, String expectedName, String expectedValue, int size) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
decoder.dynamicTable().get(index, name, value);
assertEquals(expectedName, text(name));
assertEquals(expectedValue, text(value));
assertEquals(size, decoder.dynamicTable().size());
}
private static String text(ByteView value) {
byte[] bytes = new byte[value.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i);
return new String(bytes, StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,75 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Exception;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HpackDynamicTableTest {
private static PooledSlice view(String text) {
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
PooledSlice result = new PooledSlice();
result.reset(bytes, 0, bytes.length);
return result;
}
private static String text(PooledSlice value) {
return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII);
}
@Test
void newestEntryHasLowestDynamicIndex() {
HpackDynamicTable table = new HpackDynamicTable(256);
table.add(view("a"), view("one"));
table.add(view("b"), view("two"));
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
table.get(1, name, value);
assertEquals("b", text(name));
assertEquals("two", text(value));
table.get(2, name, value);
assertEquals("a", text(name));
}
@Test
void evictsOldestEntriesByRfcSize() {
HpackDynamicTable table = new HpackDynamicTable(70);
table.add(view("a"), view("1")); // 34
table.add(view("b"), view("2")); // 34
table.add(view("c"), view("3")); // evicts a
assertEquals(2, table.count());
PooledSlice name = new PooledSlice();
table.get(2, name, new PooledSlice());
assertEquals("b", text(name));
}
@Test
void oversizedEntryClearsTableWithoutInsertion() {
HpackDynamicTable table = new HpackDynamicTable(40);
table.add(view("a"), view("1"));
table.add(view("long-name"), view("long-value"));
assertEquals(0, table.count());
assertEquals(0, table.size());
}
@Test
void sizeUpdateCannotExceedAdvertisedMaximum() {
HpackDynamicTable table = new HpackDynamicTable(128);
assertThrows(Http2Exception.class, () -> table.setMaximumSize(129));
table.setMaximumSize(0);
assertEquals(0, table.count());
}
@Test
void compactionPreservesLiveEntries() {
HpackDynamicTable table = new HpackDynamicTable(96);
for (int i = 0; i < 30; i++) table.add(view("name" + i), view("v" + i));
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
table.get(1, name, value);
assertEquals("name29", text(name));
assertEquals("v29", text(value));
}
}
@@ -0,0 +1,87 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
class HpackEvictionRaceTest {
@Test
void perStreamCopySurvivesConcurrentDynamicTableEviction() throws Exception {
HpackDecoder decoder = new HpackDecoder(64, 1024);
HpackHeaderBlock stream = new HpackHeaderBlock(1024, 16);
byte[] first = HexFormat.of().parseHex("40046e616d650b66697273742d76616c7565");
decoder.decode(first, 0, first.length, stream);
assertField(stream, 0, "name", "first-value");
byte[] replacement =
HexFormat.of().parseHex("400a6f746865722d6e616d650c7365636f6e642d76616c7565");
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
@SuppressWarnings("unchecked")
Future<Void>[] readers = new Future[8];
for (int reader = 0; reader < readers.length; reader++) {
readers[reader] =
executor.submit(
() -> {
start.await();
for (int i = 0; i < 10_000; i++) {
assertField(stream, 0, "name", "first-value");
}
return null;
});
}
start.countDown();
for (int i = 0; i < 10_000; i++) {
decoder.decode(replacement, 0, replacement.length, (n, v, x) -> {});
}
for (Future<Void> reader : readers) reader.get();
}
assertField(stream, 0, "name", "first-value");
}
@Test
void directDynamicTableViewDemonstratesTheEvictionHazard() {
HpackDynamicTable table = new HpackDynamicTable(64);
PooledSlice firstName = view("name");
PooledSlice firstValue = view("first-value");
table.add(firstName, firstValue);
PooledSlice borrowedName = new PooledSlice();
PooledSlice borrowedValue = new PooledSlice();
table.get(1, borrowedName, borrowedValue);
String before = text(borrowedValue);
table.add(view("other-name"), view("second-value"));
table.add(view("other-name"), view("second-value"));
table.add(view("other-name"), view("second-value"));
assertNotEquals(before, text(borrowedValue));
}
private static void assertField(
HpackHeaderBlock block, int index, String expectedName, String expectedValue) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
block.get(index, name, value);
assertEquals(expectedName, text(name));
assertEquals(expectedValue, text(value));
}
private static PooledSlice view(String text) {
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
PooledSlice view = new PooledSlice();
view.reset(bytes, 0, bytes.length);
return view;
}
private static String text(PooledSlice value) {
return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,42 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HpackStaticTableTest {
private static PooledSlice view(String value) {
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
PooledSlice view = new PooledSlice();
view.reset(bytes, 0, bytes.length);
return view;
}
@Test
void containsAllRfcEntriesAndUsesOneBasedIndices() {
assertEquals(61, HpackStaticTable.LENGTH);
assertArrayEquals(":authority".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(1));
assertArrayEquals(
"gzip, deflate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.value(16));
assertArrayEquals(
"www-authenticate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(61));
assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.name(0));
assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.value(62));
}
@Test
void findNameReturnsLowestIndexForRepeatedNames() {
assertEquals(2, HpackStaticTable.findName(view(":method")));
assertEquals(8, HpackStaticTable.findName(view(":status")));
assertEquals(0, HpackStaticTable.findName(view("missing")));
}
@Test
void findPairMatchesExactBytes() {
assertEquals(2, HpackStaticTable.findPair(view(":method"), view("GET")));
assertEquals(14, HpackStaticTable.findPair(view(":status"), view("500")));
assertEquals(0, HpackStaticTable.findPair(view(":method"), view("get")));
}
}