feat(core): add HPACK coding primitives
This commit is contained in:
@@ -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