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,95 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
/**
|
||||
* The 14 HTTP/2 error codes defined by RFC 9113 §7.
|
||||
*
|
||||
* <p>Each constant carries its 4-byte big-endian wire encoding, precomputed once at class
|
||||
* load (RFC 9113 §6.4 {@code RST_STREAM} and §6.8 {@code GOAWAY} both carry the error code as
|
||||
* a raw 32-bit field — there is no framing around it to build). Callers write
|
||||
* {@link #bytes()} directly into a frame payload; nothing is formatted at request time (R4).
|
||||
*
|
||||
* <p>{@code Http2ErrorCode} is used to reject a peer <em>and</em> to interpret what a peer
|
||||
* sends us: {@link #fromCode(int)} decodes a received 32-bit value. RFC 9113 does not reserve
|
||||
* unknown codes for future use in a way that requires us to accept them silently as one of the
|
||||
* 14 — an endpoint that receives an error code it does not recognise treats it as
|
||||
* {@code INTERNAL_ERROR}-equivalent for logging purposes; {@link #fromCode(int)} returns
|
||||
* {@code null} for that case and callers log the raw integer rather than guessing a mapping.
|
||||
*/
|
||||
public enum Http2ErrorCode {
|
||||
|
||||
/** Graceful shutdown or successful completion; not an error. RFC 9113 §7. */
|
||||
NO_ERROR(0x00),
|
||||
/** The peer violated the protocol in a way not covered by a more specific code. */
|
||||
PROTOCOL_ERROR(0x01),
|
||||
/** Unexpected internal condition on our side (e.g. an uncaught exception on the demux loop). */
|
||||
INTERNAL_ERROR(0x02),
|
||||
/** A flow-control window was violated: overflow past 2^31-1, or a peer exceeded its window. */
|
||||
FLOW_CONTROL_ERROR(0x03),
|
||||
/** The peer did not acknowledge our SETTINGS within {@code SETTINGS_ACK_TIMEOUT_MS}. */
|
||||
SETTINGS_TIMEOUT(0x04),
|
||||
/** A frame was received for a stream that is already closed. */
|
||||
STREAM_CLOSED(0x05),
|
||||
/** A frame's length did not match what its type requires (RFC 9113 §4.2, per-type rules). */
|
||||
FRAME_SIZE_ERROR(0x06),
|
||||
/** The stream was refused before any processing; safe for the client to retry elsewhere. */
|
||||
REFUSED_STREAM(0x07),
|
||||
/** Used by clients to cancel a stream; Flash never sends it, only receives it. */
|
||||
CANCEL(0x08),
|
||||
/** An HPACK decoding failure. Terminates the connection because the dynamic table state is lost. */
|
||||
COMPRESSION_ERROR(0x09),
|
||||
/** A CONNECT-tunnelled stream failed. */
|
||||
CONNECT_ERROR(0x0a),
|
||||
/** The peer is generating excessive load (rate-limit rejection: Rapid Reset, PING/SETTINGS floods). */
|
||||
ENHANCE_YOUR_CALM(0x0b),
|
||||
/** The negotiated TLS parameters fall below RFC 9113 §9.2's minimum security requirements. */
|
||||
INADEQUATE_SECURITY(0x0c),
|
||||
/** Defined by RFC 9113 for HTTP/1.1-only resources; Flash serves everything over h2, so unused. */
|
||||
HTTP_1_1_REQUIRED(0x0d);
|
||||
|
||||
private static final Http2ErrorCode[] BY_CODE = new Http2ErrorCode[values().length];
|
||||
|
||||
static {
|
||||
for (Http2ErrorCode c : values()) {
|
||||
BY_CODE[c.code] = c;
|
||||
}
|
||||
}
|
||||
|
||||
private final int code;
|
||||
private final byte[] bytes;
|
||||
|
||||
Http2ErrorCode(int code) {
|
||||
this.code = code;
|
||||
this.bytes = new byte[]{
|
||||
(byte) (code >>> 24),
|
||||
(byte) (code >>> 16),
|
||||
(byte) (code >>> 8),
|
||||
(byte) code
|
||||
};
|
||||
}
|
||||
|
||||
/** The numeric error code as it appears on the wire. */
|
||||
public int code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-encoded 4-byte big-endian wire form. Safe to write directly into a
|
||||
* {@code RST_STREAM} or {@code GOAWAY} payload with a single {@code System.arraycopy} —
|
||||
* never allocated or formatted per use.
|
||||
*/
|
||||
public byte[] bytes() {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a 32-bit error code received from a peer. Returns {@code null} for a value
|
||||
* outside the 14 defined codes; the caller should log the raw integer rather than assume
|
||||
* a mapping, since RFC 9113 permits future extension codes we do not yet know about.
|
||||
*/
|
||||
public static Http2ErrorCode fromCode(int code) {
|
||||
if (code >= 0 && code < BY_CODE.length) {
|
||||
return BY_CODE[code];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
/**
|
||||
* A <b>connection-level</b> HTTP/2 error. Thrown anywhere a peer's frame, HPACK block, or
|
||||
* SETTINGS value violates the protocol in a way that leaves the connection's state (the HPACK
|
||||
* dynamic table, a flow-control window, the stream table) unrecoverable.
|
||||
*
|
||||
* <p>The connection demux loop ({@code Http2Connection}, Phase 8) catches this exception at a
|
||||
* single site: it sends {@code GOAWAY} with {@link #errorCode()} and closes the connection.
|
||||
* Compare {@link Http2StreamException}, whose scope is one stream and which results in
|
||||
* {@code RST_STREAM} while the connection survives.
|
||||
*
|
||||
* <p>Deliberately does <b>not</b> extend {@link java.io.IOException}: the connection loop
|
||||
* distinguishes a protocol violation (a decision Flash made about the peer's bytes) from a
|
||||
* socket failure (the peer went away) by catching these as unrelated types. Conflating them
|
||||
* would make it possible to accidentally treat a hostile peer's malformed frame as a harmless
|
||||
* disconnect, or vice versa.
|
||||
*
|
||||
* <h3>Why stack traces are disabled</h3>
|
||||
* This exception is thrown on the connection's hot rejection path — a single malformed byte
|
||||
* from a hostile or buggy peer can trigger it, and under a scripted attack that can happen many
|
||||
* times per second across many connections. JVM stack trace capture ({@code fillInStackTrace})
|
||||
* is by far the most expensive part of constructing a {@code Throwable}, and it buys nothing
|
||||
* here: the call site is exactly where {@code errorCode()} says it is, and the debug message
|
||||
* already names the specific violation. The 4-argument {@link RuntimeException} constructor
|
||||
* disables both suppression and stack-trace writing.
|
||||
*
|
||||
* <h3>Preallocated singletons</h3>
|
||||
* For the common, message-less rejections (frame validation failures, HPACK structural errors)
|
||||
* this class exposes shared singleton instances. Reusing one instance across threads and across
|
||||
* many throws is safe <em>only because</em> the instance carries no per-throw mutable state and
|
||||
* writable-stack-trace is disabled — nothing about a throw mutates the exception object.
|
||||
*/
|
||||
public final class Http2Exception extends RuntimeException {
|
||||
|
||||
private final Http2ErrorCode errorCode;
|
||||
|
||||
private Http2Exception(Http2ErrorCode errorCode, String message) {
|
||||
super(message, null, false, false);
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
/** The RFC 9113 §7 error code to send in the {@code GOAWAY} frame. */
|
||||
public Http2ErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a connection error carrying a caller-supplied debug message. Allocates a new
|
||||
* instance — acceptable per R2, since this exception always terminates the connection and
|
||||
* R2 exempts error paths that terminate the connection. Use this overload whenever the
|
||||
* message carries information specific to this occurrence (e.g. the offending stream id or
|
||||
* a decoded value); use one of the preallocated singletons below when it does not.
|
||||
*/
|
||||
public static Http2Exception of(Http2ErrorCode code, String message) {
|
||||
return new Http2Exception(code, message);
|
||||
}
|
||||
|
||||
// ── Preallocated, message-less singletons for the hot rejection paths ──────────────────
|
||||
|
||||
public static final Http2Exception PROTOCOL_ERROR =
|
||||
new Http2Exception(Http2ErrorCode.PROTOCOL_ERROR, "protocol error");
|
||||
public static final Http2Exception FRAME_SIZE_ERROR =
|
||||
new Http2Exception(Http2ErrorCode.FRAME_SIZE_ERROR, "frame size error");
|
||||
public static final Http2Exception FLOW_CONTROL_ERROR =
|
||||
new Http2Exception(Http2ErrorCode.FLOW_CONTROL_ERROR, "flow control error");
|
||||
public static final Http2Exception COMPRESSION_ERROR =
|
||||
new Http2Exception(Http2ErrorCode.COMPRESSION_ERROR, "compression error");
|
||||
public static final Http2Exception INTERNAL_ERROR =
|
||||
new Http2Exception(Http2ErrorCode.INTERNAL_ERROR, "internal error");
|
||||
public static final Http2Exception SETTINGS_TIMEOUT =
|
||||
new Http2Exception(Http2ErrorCode.SETTINGS_TIMEOUT, "settings ack timeout");
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
/**
|
||||
* Every bound the HTTP/2 implementation enforces against a peer's input, in one place.
|
||||
*
|
||||
* <p>Per R8, any code that reads a length, an index, a count, or a size off the wire checks it
|
||||
* against a named constant here — never against an ad-hoc literal, and never by letting the
|
||||
* underlying array or buffer throw on overrun. Each field's Javadoc names the specific attack
|
||||
* or resource it bounds and, where one exists, the CVE.
|
||||
*
|
||||
* <p>These are compile-time defaults, not runtime configuration. The operationally-relevant
|
||||
* subset is promoted to {@code FlashConfiguration} in Phase 13 task 10, once the whole surface
|
||||
* has been exercised and it is clear which knobs operators actually need. Until then, changing
|
||||
* a limit means changing this file.
|
||||
*
|
||||
* <p>This class is added to incrementally: later phases add fields as the feature that needs
|
||||
* them lands (e.g. {@code WRITE_TIMEOUT_MS} in Phase 3, {@code FRAME_READ_TIMEOUT_MS} in
|
||||
* Phase 5). Phase 0 seeds the set called out explicitly by its task list; nothing here is a
|
||||
* forward-declared placeholder — every field is already used by the phase that introduces it.
|
||||
*/
|
||||
public final class Http2Limits {
|
||||
|
||||
private Http2Limits() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum number of streams a single connection may have open concurrently. Advertised to
|
||||
* the peer as {@code SETTINGS_MAX_CONCURRENT_STREAMS}. Bounds per-connection memory (each
|
||||
* open stream owns a per-stream HPACK arena and request/response state) against a peer that
|
||||
* simply opens streams and never closes them.
|
||||
*/
|
||||
public static final int MAX_CONCURRENT_STREAMS = 100;
|
||||
|
||||
/**
|
||||
* The largest frame payload we accept without the peer first raising it via our own
|
||||
* {@code SETTINGS_MAX_FRAME_SIZE}. RFC 9113 §4.2 fixes the protocol default at 16384 and
|
||||
* requires any advertised value to stay within {@code 16384..16777215}. Bounds the memory a
|
||||
* single frame read can force us to hold.
|
||||
*/
|
||||
public static final int MAX_FRAME_SIZE_LOCAL = 16_384;
|
||||
|
||||
/**
|
||||
* Maximum total size (name + value + 32 per RFC 7541 §4.1's accounting, summed over every
|
||||
* header) of a decoded header list. Advertised as {@code SETTINGS_MAX_HEADER_LIST_SIZE}
|
||||
* (RFC 9113 §6.5.2). This is the primary defence against an HPACK bomb: a small compressed
|
||||
* block that references dynamic-table entries to expand into an enormous header list.
|
||||
*/
|
||||
public static final int MAX_HEADER_LIST_SIZE = 32_768;
|
||||
|
||||
/**
|
||||
* Maximum number of CONTINUATION frames accepted for a single header block before the
|
||||
* connection is torn down. Defence against CVE-2024-27316 (the "HTTP/2 CONTINUATION
|
||||
* Flood"): a peer that never sets {@code END_HEADERS} can otherwise force unbounded
|
||||
* decode/reassembly work per header block.
|
||||
*/
|
||||
public static final int MAX_CONTINUATION_FRAMES_PER_BLOCK = 8;
|
||||
|
||||
/**
|
||||
* Maximum number of {@code RST_STREAM} frames accepted from the peer within
|
||||
* {@link #RESET_RATE_INTERVAL_MS}. Defence against CVE-2023-44487 ("HTTP/2 Rapid Reset"):
|
||||
* opening a stream and immediately resetting it does not count against
|
||||
* {@link #MAX_CONCURRENT_STREAMS}, so without a rate bound a peer can force unbounded
|
||||
* per-stream setup/teardown work at effectively unlimited concurrency.
|
||||
*/
|
||||
public static final int MAX_RESET_STREAMS_PER_INTERVAL = 200;
|
||||
|
||||
/** The rolling window (milliseconds) over which {@link #MAX_RESET_STREAMS_PER_INTERVAL} is measured. */
|
||||
public static final long RESET_RATE_INTERVAL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Maximum number of new streams accepted from the peer within
|
||||
* {@link #RESET_RATE_INTERVAL_MS}. A companion bound to
|
||||
* {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only count resets can
|
||||
* still be bypassed by a peer that creates streams fast enough that the reset counter never
|
||||
* saturates within any single window boundary.
|
||||
*/
|
||||
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
|
||||
|
||||
/**
|
||||
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A
|
||||
* SETTINGS frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each
|
||||
* entry is 6 bytes), but an explicit entry-count bound keeps the per-entry validation loop
|
||||
* itself cheap to reason about and gives a distinct, loud rejection reason.
|
||||
*/
|
||||
public static final int MAX_SETTINGS_ENTRIES_PER_FRAME = 64;
|
||||
|
||||
/**
|
||||
* Maximum number of outstanding (unanswered) PING responses queued for the writer. A PING
|
||||
* flood forces a PONG per PING; without a bound, a peer that reads its own responses slowly
|
||||
* can make us buffer unbounded PONG frames.
|
||||
*/
|
||||
public static final int MAX_PING_QUEUE_DEPTH = 64;
|
||||
|
||||
/**
|
||||
* Maximum number of zero-length DATA frames accepted per stream. Zero-length DATA consumes
|
||||
* no flow-control window, so window accounting does not bound it — without this limit a
|
||||
* peer can force unbounded per-frame dispatch/validation CPU work at zero cost to itself.
|
||||
*/
|
||||
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
|
||||
|
||||
/**
|
||||
* The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream:
|
||||
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
|
||||
* request or response body never blocks on a WINDOW_UPDATE round trip. See Phase 11 task 1.
|
||||
*/
|
||||
public static final int INITIAL_WINDOW_SIZE_LOCAL = 1_048_576;
|
||||
|
||||
/**
|
||||
* The connection-level flow-control window Flash advertises. Sized above
|
||||
* {@link #INITIAL_WINDOW_SIZE_LOCAL} so a single active stream is never bottlenecked by the
|
||||
* connection window before its own stream window, but well below
|
||||
* {@code MAX_CONCURRENT_STREAMS * INITIAL_WINDOW_SIZE_LOCAL} — real traffic is never all
|
||||
* streams simultaneously saturating their windows, and sizing for that worst case would
|
||||
* commit 100 MiB of receive window to every connection regardless of load.
|
||||
*/
|
||||
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 16 * 1_048_576;
|
||||
|
||||
/**
|
||||
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1
|
||||
* accounting. RFC 7541's protocol default. The encoder never uses a dynamic table at all
|
||||
* (DEC-04), so this bound applies only to headers <em>we receive</em>.
|
||||
*/
|
||||
public static final int HPACK_DYNAMIC_TABLE_SIZE_LOCAL = 4_096;
|
||||
|
||||
/**
|
||||
* Maximum length, in decoded bytes, of a single HPACK string literal. Applied during
|
||||
* Huffman decode as bytes are produced, not to the encoded length — a Huffman string can
|
||||
* expand by roughly 8/5, so bounding only the encoded length would let a compact input
|
||||
* still decode past this limit.
|
||||
*/
|
||||
public static final int MAX_HPACK_STRING_LENGTH = 8_192;
|
||||
|
||||
/**
|
||||
* Maximum time, in milliseconds, allowed between a HEADERS frame's arrival and the header
|
||||
* block's completion (its {@code END_HEADERS} flag, possibly after CONTINUATION frames). A
|
||||
* peer that starts a header block and then stalls indefinitely would otherwise hold the
|
||||
* per-stream arena and the connection's HPACK assembly buffer forever.
|
||||
*/
|
||||
public static final long HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Maximum time, in milliseconds, a stream may remain open with no frame activity in either
|
||||
* direction. Bounds resource pinning by a peer that opens a stream and then goes silent
|
||||
* without closing it — the h2 equivalent of the h1 slowloris defence in
|
||||
* {@code FlashConfiguration.idleKeepAliveTimeoutMs}.
|
||||
*/
|
||||
public static final long STREAM_IDLE_TIMEOUT_MS = 60_000;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dev.relism.flash.h2;
|
||||
|
||||
/**
|
||||
* A <b>stream-level</b> HTTP/2 error, scoped to one stream id. Results in an {@code RST_STREAM}
|
||||
* frame for {@link #streamId()} with {@link #errorCode()}; the connection and every other
|
||||
* stream on it are unaffected. Compare {@link Http2Exception}, whose scope is the whole
|
||||
* connection.
|
||||
*
|
||||
* <p>Deliberately does <b>not</b> extend {@link java.io.IOException}, for the same reason as
|
||||
* {@link Http2Exception}: the connection loop must be able to distinguish "we decided to reject
|
||||
* this stream" from "the socket failed" by catching unrelated exception types.
|
||||
*
|
||||
* <h3>Why this allocates, unlike {@code Http2Exception}'s singletons</h3>
|
||||
* Every instance carries a distinct {@link #streamId()}, so it cannot be a shared singleton the
|
||||
* way {@code Http2Exception}'s message-less constants are. This is still acceptable under R2:
|
||||
* {@code RST_STREAM} generation is an error path, not the steady-state request path, and R2
|
||||
* exempts error paths. The scenario where this matters most — a peer opening and resetting
|
||||
* thousands of streams per second (the Rapid Reset pattern, CVE-2023-44487) — is bounded by
|
||||
* rate limits (Phase 13), not by making the rejection itself allocation-free; a hostile peer
|
||||
* that can force RST_STREAM generation fast enough for GC pressure to matter has already
|
||||
* tripped {@code Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL} and the connection is being torn
|
||||
* down anyway.
|
||||
*
|
||||
* <p>Stack trace capture is disabled for the same cost reason as {@link Http2Exception}.
|
||||
*/
|
||||
public final class Http2StreamException extends RuntimeException {
|
||||
|
||||
private final Http2ErrorCode errorCode;
|
||||
private final int streamId;
|
||||
|
||||
public Http2StreamException(int streamId, Http2ErrorCode errorCode, String message) {
|
||||
super(message, null, false, false);
|
||||
this.streamId = streamId;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
/** The id of the stream this error terminates. */
|
||||
public int streamId() {
|
||||
return streamId;
|
||||
}
|
||||
|
||||
/** The RFC 9113 §7 error code to send in the {@code RST_STREAM} frame. */
|
||||
public Http2ErrorCode errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* HTTP/2 (RFC 9113) and HPACK (RFC 7541), as a peer transport to HTTP/1.1 — not a special case
|
||||
* bolted onto it. This package lives in {@code flash} core, not an extension, because the
|
||||
* protocol decision is made at the transport layer, where {@code HttpServer}'s replacement
|
||||
* lives (see {@code DEC-01} in {@code flash/docs/http2/DECISIONS.md}).
|
||||
*
|
||||
* <h2>Architecture in one page</h2>
|
||||
*
|
||||
* <h3>The demux loop</h3>
|
||||
* One virtual thread per connection reads and dispatches frames
|
||||
* ({@code Http2Connection}, Phase 8): read a 9-byte frame header, validate it against the
|
||||
* per-type table ({@code FrameValidator}, Phase 5), dispatch by type. This loop <b>never blocks
|
||||
* on application work</b> — a slow handler must never stall frame processing for other streams
|
||||
* on the same connection, which is the entire point of multiplexing. The only things the demux
|
||||
* thread itself does synchronously are protocol bookkeeping: SETTINGS/PING/WINDOW_UPDATE
|
||||
* accounting, HPACK decode, and stream-table updates.
|
||||
*
|
||||
* <h3>Virtual-thread-per-stream dispatch</h3>
|
||||
* Once a request's headers (and, for small bodies, its body) are fully assembled, the demux
|
||||
* thread submits a task to the shared virtual-thread executor and returns immediately to
|
||||
* reading frames. Routing, middleware, and the user's handler run on that stream's own virtual
|
||||
* thread — identical to the HTTP/1.1 dispatch model, so a handler written for h1 works
|
||||
* unmodified over h2 (verified in Phase 10).
|
||||
*
|
||||
* <h3>The writer discipline</h3>
|
||||
* N stream threads share one socket. {@code Http2FrameWriter} (Phase 3) is the single
|
||||
* serialization point: a stream serializes its complete frame (header + HPACK block + payload)
|
||||
* into a reusable per-stream scratch buffer, then takes a connection-wide {@link
|
||||
* java.util.concurrent.locks.ReentrantLock} — never {@code synchronized}, which pins a virtual
|
||||
* thread's carrier on Java 21 (see {@code DEC-03}) — and issues one bulk write. The uncontended
|
||||
* path costs one CAS ({@code tryLock()}); contention falls back to an intrusive, allocation-free
|
||||
* MPSC queue rather than blocking every writer on the lock. This is the project's single
|
||||
* largest architectural risk and is proven or falsified by Phase 3's benchmark gate before any
|
||||
* frame-layer code is written.
|
||||
*
|
||||
* <h3>The arena strategy</h3>
|
||||
* HPACK is stateful compression: header bytes that enter the dynamic table must outlive the
|
||||
* connection read buffer, and Huffman-coded values must be decoded somewhere. Flash copies each
|
||||
* decoded header into a <b>per-stream arena</b>, not a shared one ({@code DEC-06}). This is not
|
||||
* the minimal-copy design — a refcounted shared dynamic table would copy less — but it is the
|
||||
* only design that is correct by construction under concurrent multiplexing: the demux thread
|
||||
* can decode another stream's HEADERS, evicting dynamic-table entries, while a handler on a
|
||||
* different virtual thread is still reading a view into a previous decode. A per-stream arena
|
||||
* makes that race impossible without any cross-thread coordination on the hot path. See
|
||||
* {@code flash/docs/http2/HPACK.md} (Phase 7) for the worked example.
|
||||
*
|
||||
* <h2>What this package deliberately does not implement</h2>
|
||||
* <ul>
|
||||
* <li><b>Server push ({@code PUSH_PROMISE}).</b> Flash never sends it and rejects any
|
||||
* {@code PUSH_PROMISE} received from a client as a connection error, since only servers may
|
||||
* send it (RFC 9113 §8.4). Flash advertises {@code SETTINGS_ENABLE_PUSH = 0}. Justification:
|
||||
* push is widely disabled by browsers and its cache-coherency benefits are better served by
|
||||
* {@code 103 Early Hints} or resource hints, which do not require protocol-level state.</li>
|
||||
* <li><b>Priority scheduling ({@code PRIORITY} frames, and the deprecated priority fields on
|
||||
* {@code HEADERS}).</b> RFC 9113 §5.3.2 itself says endpoints "SHOULD ignore" priority
|
||||
* signalling — it was deprecated in the same RFC that (re)defined HTTP/2. Flash parses and
|
||||
* discards {@code PRIORITY} frames (they must still be consumed, not rejected) and never acts
|
||||
* on the priority fields.</li>
|
||||
* <li><b>{@code Upgrade: h2c}.</b> RFC 9113 §3.1 removed the HTTP/1.1 upgrade mechanism that
|
||||
* RFC 7540 §3.2 defined. Cleartext HTTP/2 is reached only via prior knowledge (RFC 9113 §3.4),
|
||||
* which is what every modern h2c client (notably gRPC) actually uses. See {@code DEC-10}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Package layout</h2>
|
||||
* This package is filled in incrementally, phase by phase — see
|
||||
* {@code flash/docs/http2/IMPLEMENTATION-PLAN.md} Part III for the full schedule. As of Phase 0
|
||||
* it contains only the error model ({@link dev.relism.flash.h2.Http2ErrorCode},
|
||||
* {@link dev.relism.flash.h2.Http2Exception}, {@link dev.relism.flash.h2.Http2StreamException})
|
||||
* and the limits registry ({@link dev.relism.flash.h2.Http2Limits}). Subpackages
|
||||
* {@code frame}, {@code hpack}, {@code stream}, {@code message}, and {@code upgrade} are added
|
||||
* by Phases 3, 5, 7–9, and 14–15 respectively.
|
||||
*/
|
||||
package dev.relism.flash.h2;
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.relism.flash.http;
|
||||
|
||||
/**
|
||||
* Bounds the HTTP/1.1 parser ({@code RequestParser}, {@code ChunkedInputStream}) enforces
|
||||
* against a peer's input, in one place.
|
||||
*
|
||||
* <p>Per R8, any code that reads a length, an index, a count, or a size off the wire checks it
|
||||
* against a named constant here — never against an ad-hoc literal, and never by letting the
|
||||
* underlying buffer throw on overrun. Each field's Javadoc names the specific attack it bounds.
|
||||
*
|
||||
* <p>Seeded in Phase 0 with the bounds required by {@code EX-03} (strict {@code Content-Length}
|
||||
* parsing) and {@code EX-08} (header count/size limits); extended in Phase 1 with the chunked-
|
||||
* transfer bounds ({@code EX-10}) and again in later phases as new h1 surfaces need a limit.
|
||||
* Compare {@code dev.relism.flash.h2.Http2Limits}, the HTTP/2 equivalent.
|
||||
*/
|
||||
public final class Http1Limits {
|
||||
|
||||
private Http1Limits() {
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest {@code Content-Length} value accepted, in bytes. RFC 9112 places no upper
|
||||
* bound on the header's numeric value, but an unbounded value from a hostile peer is a
|
||||
* resource-exhaustion vector for any code path that pre-sizes a buffer from it. Requests
|
||||
* declaring a length above this are rejected with {@code 413 Payload Too Large} before any
|
||||
* body byte is read.
|
||||
*/
|
||||
public static final long MAX_CONTENT_LENGTH = 100L * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Maximum number of header lines accepted in a single request. Without this bound, a
|
||||
* request with tens of thousands of one-byte headers passes the total header-block size
|
||||
* check ({@code maxHeaderBufferSize}) while still forcing every subsequent
|
||||
* {@code HeaderMap} lookup to scan all of them — turning a small request into quadratic CPU
|
||||
* work per middleware that reads a header ({@code EX-08}, {@code EX-09}).
|
||||
*/
|
||||
public static final int MAX_HEADER_COUNT = 100;
|
||||
|
||||
/**
|
||||
* Maximum length, in bytes, of a single header field name. RFC 9110 §5.1 places no formal
|
||||
* limit; this bound exists purely to cap per-header memory and scan cost.
|
||||
*/
|
||||
public static final int MAX_HEADER_NAME_LENGTH = 256;
|
||||
|
||||
/**
|
||||
* Maximum length, in bytes, of a single header field value. Bounds per-header memory and
|
||||
* scan cost the same way {@link #MAX_HEADER_NAME_LENGTH} bounds the name.
|
||||
*/
|
||||
public static final int MAX_HEADER_VALUE_LENGTH = 8_192;
|
||||
|
||||
/**
|
||||
* Maximum length, in bytes, of the request line ({@code METHOD SP target SP version}).
|
||||
* Tracked separately from the overall header-buffer size so an oversized request line is
|
||||
* rejected with a specific, correct status ({@code 414 URI Too Long}) rather than folded
|
||||
* into the generic header-block-too-large case.
|
||||
*/
|
||||
public static final int MAX_REQUEST_LINE_LENGTH = 8_192;
|
||||
}
|
||||
@@ -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