feat(core): HTTP/2 Phase 0 — groundwork (limits, error model, decision log)
Establishes the package layout, limits/error model and decision-log convention that every later HTTP/2 phase depends on, per flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 0. - dev.relism.flash.h2: package-info (architecture overview), Http2ErrorCode (the 14 RFC 9113 §7 codes with precomputed 4-byte wire encodings), Http2Exception (connection error -> GOAWAY) and Http2StreamException (stream error -> RST_STREAM), neither extending IOException, both with stack-trace capture disabled on the hot rejection path. - Http2Limits: every bound Phase 0 requires (concurrent streams, frame size, header list size, CONTINUATION/reset/settings/ping rate bounds, flow-control windows, HPACK table size/string length, assembly and idle timeouts), each documented with the attack or RFC clause it addresses. - dev.relism.flash.http.Http1Limits: the h1 bounds needed by EX-03 (strict Content-Length) and EX-08 (header count/size limits). - flash/docs/http2/DECISIONS.md seeded with DEC-01..DEC-11 (the ten decisions implied by the plan itself, plus DEC-11 recording that commits keep scope `core` rather than adding `h2` to AGENTS.md). - flash/docs/http2/IMPLEMENTATION-PLAN.md: added the Progress Ledger (tracks phase status across sessions) and checked off Phase 0's DoD. 19 new tests, full flash module suite green (226/226). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8f1f30b973
commit
db6e4a4d0c
@@ -0,0 +1,59 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Http2ErrorCodeTest {
|
||||
|
||||
@Test
|
||||
void everyCodeRoundTripsThroughFromCode() {
|
||||
for (Http2ErrorCode code : Http2ErrorCode.values()) {
|
||||
assertSame(code, Http2ErrorCode.fromCode(code.code()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void bytesAreFourByteBigEndian() {
|
||||
for (Http2ErrorCode code : Http2ErrorCode.values()) {
|
||||
assertEquals(4, code.bytes().length, code.name());
|
||||
int decoded = ((code.bytes()[0] & 0xFF) << 24)
|
||||
| ((code.bytes()[1] & 0xFF) << 16)
|
||||
| ((code.bytes()[2] & 0xFF) << 8)
|
||||
| (code.bytes()[3] & 0xFF);
|
||||
assertEquals(code.code(), decoded, code.name());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void allFourteenRfc9113CodesArePresent() {
|
||||
assertEquals(14, Http2ErrorCode.values().length);
|
||||
assertEquals(0x00, Http2ErrorCode.NO_ERROR.code());
|
||||
assertEquals(0x01, Http2ErrorCode.PROTOCOL_ERROR.code());
|
||||
assertEquals(0x02, Http2ErrorCode.INTERNAL_ERROR.code());
|
||||
assertEquals(0x03, Http2ErrorCode.FLOW_CONTROL_ERROR.code());
|
||||
assertEquals(0x04, Http2ErrorCode.SETTINGS_TIMEOUT.code());
|
||||
assertEquals(0x05, Http2ErrorCode.STREAM_CLOSED.code());
|
||||
assertEquals(0x06, Http2ErrorCode.FRAME_SIZE_ERROR.code());
|
||||
assertEquals(0x07, Http2ErrorCode.REFUSED_STREAM.code());
|
||||
assertEquals(0x08, Http2ErrorCode.CANCEL.code());
|
||||
assertEquals(0x09, Http2ErrorCode.COMPRESSION_ERROR.code());
|
||||
assertEquals(0x0a, Http2ErrorCode.CONNECT_ERROR.code());
|
||||
assertEquals(0x0b, Http2ErrorCode.ENHANCE_YOUR_CALM.code());
|
||||
assertEquals(0x0c, Http2ErrorCode.INADEQUATE_SECURITY.code());
|
||||
assertEquals(0x0d, Http2ErrorCode.HTTP_1_1_REQUIRED.code());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownCodeReturnsNull() {
|
||||
assertNull(Http2ErrorCode.fromCode(0x0e));
|
||||
assertNull(Http2ErrorCode.fromCode(-1));
|
||||
assertNull(Http2ErrorCode.fromCode(Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bytesInstanceIsStablePerConstant() {
|
||||
// Precomputed at class init (R4) — must not be rebuilt per call.
|
||||
assertSame(Http2ErrorCode.PROTOCOL_ERROR.bytes(), Http2ErrorCode.PROTOCOL_ERROR.bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Http2ExceptionTest {
|
||||
|
||||
@Test
|
||||
void ofCarriesTheGivenCodeAndMessage() {
|
||||
Http2Exception e = Http2Exception.of(Http2ErrorCode.FLOW_CONTROL_ERROR, "window exceeded");
|
||||
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, e.errorCode());
|
||||
assertEquals("window exceeded", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stackTraceCaptureIsDisabled() {
|
||||
Http2Exception e = Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, "bad frame");
|
||||
assertEquals(0, e.getStackTrace().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void singletonsCarryTheAdvertisedCode() {
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, Http2Exception.PROTOCOL_ERROR.errorCode());
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, Http2Exception.FRAME_SIZE_ERROR.errorCode());
|
||||
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, Http2Exception.FLOW_CONTROL_ERROR.errorCode());
|
||||
assertEquals(Http2ErrorCode.COMPRESSION_ERROR, Http2Exception.COMPRESSION_ERROR.errorCode());
|
||||
assertEquals(Http2ErrorCode.INTERNAL_ERROR, Http2Exception.INTERNAL_ERROR.errorCode());
|
||||
assertEquals(Http2ErrorCode.SETTINGS_TIMEOUT, Http2Exception.SETTINGS_TIMEOUT.errorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotExtendIoException() {
|
||||
assertFalse(java.io.IOException.class.isAssignableFrom(Http2Exception.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Http2LimitsTest {
|
||||
|
||||
@Test
|
||||
void maxFrameSizeLocalWithinRfcBounds() {
|
||||
// RFC 9113 §6.5.2 — SETTINGS_MAX_FRAME_SIZE must be within 16384..16777215.
|
||||
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL >= 16_384);
|
||||
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL <= 16_777_215);
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyLimitIsPositive() {
|
||||
assertTrue(Http2Limits.MAX_CONCURRENT_STREAMS > 0);
|
||||
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL > 0);
|
||||
assertTrue(Http2Limits.MAX_HEADER_LIST_SIZE > 0);
|
||||
assertTrue(Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK > 0);
|
||||
assertTrue(Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL > 0);
|
||||
assertTrue(Http2Limits.RESET_RATE_INTERVAL_MS > 0);
|
||||
assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL > 0);
|
||||
assertTrue(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME > 0);
|
||||
assertTrue(Http2Limits.MAX_PING_QUEUE_DEPTH > 0);
|
||||
assertTrue(Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM > 0);
|
||||
assertTrue(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL > 0);
|
||||
assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL > 0);
|
||||
assertTrue(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL > 0);
|
||||
assertTrue(Http2Limits.MAX_HPACK_STRING_LENGTH > 0);
|
||||
assertTrue(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS > 0);
|
||||
assertTrue(Http2Limits.STREAM_IDLE_TIMEOUT_MS > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void streamCreationBoundIsAtLeastTheResetBound() {
|
||||
// A Rapid Reset defence that only counts resets can be bypassed by a peer that creates
|
||||
// streams fast enough that the reset counter never saturates within a window boundary;
|
||||
// the creation bound must be at least as tight.
|
||||
assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL >= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionWindowIsAtLeastAsLargeAsAStreamWindow() {
|
||||
// Otherwise a single active stream would be bottlenecked by the connection window
|
||||
// before it ever reaches its own (larger) per-stream window.
|
||||
assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL >= Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void hpackDynamicTableSizeMatchesRfcDefault() {
|
||||
// RFC 7541 §4.1 default is 4096; nothing in this codebase should silently diverge.
|
||||
assertEquals(4_096, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Http2StreamExceptionTest {
|
||||
|
||||
@Test
|
||||
void carriesStreamIdAndErrorCode() {
|
||||
Http2StreamException e = new Http2StreamException(7, Http2ErrorCode.STREAM_CLOSED, "closed");
|
||||
assertEquals(7, e.streamId());
|
||||
assertEquals(Http2ErrorCode.STREAM_CLOSED, e.errorCode());
|
||||
assertEquals("closed", e.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stackTraceCaptureIsDisabled() {
|
||||
Http2StreamException e = new Http2StreamException(3, Http2ErrorCode.CANCEL, "cancelled");
|
||||
assertEquals(0, e.getStackTrace().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotExtendIoException() {
|
||||
assertFalse(java.io.IOException.class.isAssignableFrom(Http2StreamException.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.http;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Http1LimitsTest {
|
||||
|
||||
@Test
|
||||
void everyLimitIsPositive() {
|
||||
assertTrue(Http1Limits.MAX_CONTENT_LENGTH > 0);
|
||||
assertTrue(Http1Limits.MAX_HEADER_COUNT > 0);
|
||||
assertTrue(Http1Limits.MAX_HEADER_NAME_LENGTH > 0);
|
||||
assertTrue(Http1Limits.MAX_HEADER_VALUE_LENGTH > 0);
|
||||
assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestLineFitsInsideMaxHeaderValueOrderOfMagnitude() {
|
||||
// Sanity: the request-line bound should not dwarf the total per-header bound to the
|
||||
// point of being meaningless as a distinct limit.
|
||||
assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH <= Http1Limits.MAX_CONTENT_LENGTH);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user