test(core): add HTTP/2 compliance suite
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
# HTTP/2 compliance
|
||||
|
||||
This document records the repeatable protocol gate for Flash's HTTP/2 server. The automated
|
||||
matrix runs from Maven; external tools are selected through system properties so local builds
|
||||
without them skip only the corresponding interoperability adapter. CI installs and enables every
|
||||
command-line client listed below.
|
||||
|
||||
## h2spec
|
||||
|
||||
Validated on 2026-08-13 with h2spec 2.6.0.
|
||||
|
||||
| Listener | Cases | Failures | Skips |
|
||||
|---|---:|---:|---:|
|
||||
| TLS with ALPN `h2` | 146 | 0 | 0 |
|
||||
| Cleartext prior knowledge on the mixed HTTP/1.1 + HTTP/2 port | 145 | 0 | 0 |
|
||||
|
||||
`H2SpecComplianceTest` parses h2spec's JUnit XML and fails on a failure, error, or skipped case.
|
||||
The cleartext selection omits only `http2/3.5/2`, which sends a complete invalid HTTP/2 preface.
|
||||
That case assumes a dedicated HTTP/2 endpoint. Flash deliberately has one cleartext port that
|
||||
selects HTTP/2 only when the 24-byte prior-knowledge preface matches; any other initial bytes are
|
||||
HTTP/1.1 input. RFC 9113 section 3.3 defines the exact preface as the cleartext protocol selector,
|
||||
while section 3.4's `PROTOCOL_ERROR` applies after an endpoint is operating as HTTP/2. The HTTP/2
|
||||
state machine itself does return `GOAWAY(PROTOCOL_ERROR)` for a complete invalid preface, covered
|
||||
byte-for-byte by `invalid-preface.hex`. Excluding the mixed-port negotiation case therefore does
|
||||
not waive an HTTP/2 state-machine requirement.
|
||||
|
||||
## Interoperability
|
||||
|
||||
Automated results recorded on 2026-08-13:
|
||||
|
||||
| Client | Version | Mode and coverage | Result |
|
||||
|---|---|---|---|
|
||||
| curl | 8.5.0, libnghttp2 1.59.0 | TLS and h2c; GET, POST, 2 MiB upload/download | pass |
|
||||
| Java `HttpClient` | Temurin 21.0.11+10 | TLS; GET, POST, large bodies and multiplexing | pass |
|
||||
| nghttp | nghttp2 1.59.0 | TLS and h2c; verbose SETTINGS/HEADERS/DATA trace, POST and 2 MiB download | pass |
|
||||
| grpcurl | 1.9.3 | h2c; unary, server-streaming, client-streaming, bidi and error trailers | pass |
|
||||
|
||||
The 1,000-stream test uses one TCP connection and admits at most the advertised 64 live streams
|
||||
at once. This tests 1,000 multiplexed stream lifecycles without contradicting
|
||||
`SETTINGS_MAX_CONCURRENT_STREAMS` or weakening the production memory bound.
|
||||
|
||||
Chrome and Firefox are a release smoke test rather than a CI dependency. For each release, record
|
||||
the exact stable browser versions and date in the release evidence, then verify:
|
||||
|
||||
1. Load a TLS route and confirm `h2` in the browser network protocol column.
|
||||
2. Exercise GET, POST, a large upload and a large streamed download.
|
||||
3. Open the same registered WebSocket route over HTTP/1.1 and RFC 8441, exchange a fragmented
|
||||
message larger than one flow-control window, and close from each side once.
|
||||
4. Confirm no certificate, console, failed-request, or retry-to-HTTP/1.1 warnings.
|
||||
|
||||
This manual row is intentionally not represented as an automated pass: browser release testing
|
||||
must record the browsers actually shipped at release time rather than a stale development image.
|
||||
|
||||
## Fuzzing and regression corpus
|
||||
|
||||
All fuzz targets use deterministic xorshift or `Random` seeds, fixed maximum input lengths, an
|
||||
absolute JUnit time budget, and a post-GC retained-heap assertion. Untyped runtime failures fail
|
||||
the test immediately. The permanent targets cover:
|
||||
|
||||
| Target | Cases | Seed |
|
||||
|---|---:|---|
|
||||
| frame reader | 10,000,000 | `0x485532445f465a32` |
|
||||
| HPACK decoder | 10,000,000 | `0x75419113c0de` |
|
||||
| Huffman decoder | 1,000,000 | `0x7541485546464d4e` |
|
||||
| pseudo-header validator | 250,000 | `0x911350534555444f` |
|
||||
| HTTP/1 request parser | 25,000 | `0x911248545450314c` |
|
||||
|
||||
Exact wire inputs for implementation defects live under
|
||||
`src/test/resources/http2/regressions/`; `Http2RegressionCorpusTest` executes every file and
|
||||
asserts the terminal frame and error code. The nightly `Http2SoakTest` defaults to ten minutes of
|
||||
GET, POST, streaming DATA, reset and PING traffic, with retained-heap assertions. A short run can
|
||||
be requested with `-Dflash.http2.soak=true -Dflash.http2.soak.seconds=10`.
|
||||
|
||||
## Deliberately absent features
|
||||
|
||||
- HTTP/2 server push is not exposed. A client cannot send `PUSH_PROMISE` to a server (RFC 9113
|
||||
section 6.6); receiving one is a connection error. Flash does not originate push.
|
||||
- RFC 7540 dependency-tree priority scheduling is not implemented. RFC 9113 section 5.3.2
|
||||
deprecates the scheme; PRIORITY frames are validated and ignored as required.
|
||||
- `Upgrade: h2c` is not implemented. RFC 9113 section 3.1 removed the HTTP/1.1 upgrade mechanism;
|
||||
cleartext support uses section 3.3 prior knowledge.
|
||||
|
||||
These omissions do not create alternate request/response APIs: HTTP/1.1 and HTTP/2 remain peers
|
||||
behind the transport protocol boundary.
|
||||
@@ -1064,3 +1064,39 @@ usable by other streaming adapters.
|
||||
stream pair without losing protocol semantics.
|
||||
|
||||
---
|
||||
|
||||
## DEC-33 — Retain bounded closed-stream provenance
|
||||
|
||||
**Context.** RFC 9113 assigns different outcomes to a frame on an idle lower-numbered stream, a
|
||||
normally closed stream, and a reset stream. Removing a stream from the live table discarded the
|
||||
only information that distinguished those cases.
|
||||
|
||||
**Decision.** Keep a primitive circular tombstone table sized to twice the maximum live-stream
|
||||
count. Each entry stores only a stream id and whether it closed normally or by reset.
|
||||
|
||||
**Consequence.** The demultiplexer produces the required connection- or stream-scoped error
|
||||
without an unbounded set, boxed keys, or hot-path allocation. Very old tombstones expire, which is
|
||||
safe because a peer cannot require unbounded historical state from a bounded connection.
|
||||
|
||||
**Revisit when.** Only if a conformance case demonstrates that the bounded history is too short;
|
||||
change the fixed ratio from evidence rather than introducing an unbounded map.
|
||||
|
||||
---
|
||||
|
||||
## DEC-34 — Test cleartext conformance at the protocol-selection boundary
|
||||
|
||||
**Context.** h2spec's invalid-preface case assumes a dedicated HTTP/2 socket. Flash intentionally
|
||||
multiplexes HTTP/1.1 and HTTP/2 prior knowledge on one cleartext port, so non-matching initial
|
||||
bytes select the HTTP/1 parser before an HTTP/2 state machine exists.
|
||||
|
||||
**Decision.** Run every h2spec case applicable after prior-knowledge selection on the mixed port,
|
||||
and separately feed a complete invalid preface directly to the HTTP/2 state-machine regression
|
||||
test, where it must produce `GOAWAY(PROTOCOL_ERROR)`.
|
||||
|
||||
**Consequence.** The suite tests both layers according to their actual ownership and does not add
|
||||
a second h2-only cleartext listener solely to satisfy a tool assumption.
|
||||
|
||||
**Revisit when.** If Flash introduces a dedicated cleartext HTTP/2 listener, run the omitted case
|
||||
against that listener too.
|
||||
|
||||
---
|
||||
|
||||
@@ -77,7 +77,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
|
||||
| 13 — Security hardening & abuse resistance | done | `feature/core/http2` | Two-bucket Rapid Reset/stream/settings/ping/aggregate counters, control/write queue bounds, optional stream/byte/lifetime budgets, absolute header and idle-stream deadlines, and hostile-peer suite complete. Security review found+fixed EX-50/51. JMH counter: 38.083 ns/op, ~10^-4 B/op, no GC. 100k-CONTINUATION attack terminates in under 2 s with bounded retained heap. 663/663 tests green from a clean `-Pjmh` build. |
|
||||
| 14 — h2c prior knowledge + proxy support | done | `feature/core/http2` | Independent TLS/h2c gates, pooled proxy-oriented h2 client with TLS ALPN and h2c, bidirectional h1/h2 trailer relay, shared four-direction hop-by-hop policy and certificate-backed 421 handling complete. Real grpcurl h2c interop passes. 670/670 tests green from a clean `-Pjmh` build. |
|
||||
| 15 — RFC 8441 extended CONNECT (WS over h2) | done | `feature/core/http2` | SETTINGS_ENABLE_CONNECT_PROTOCOL, shared WS router/session, DATA flow control, >1 MiB message, h1/h2 parity and lifecycle hardening complete. EX-52/53 fixed; DEC-32 recorded. 675/675 tests green from a clean `-Pjmh` build; real grpcurl interop remains green. |
|
||||
| 16 — Compliance test suite | not started | — | — |
|
||||
| 16 — Compliance test suite | done | `feature/core/http2` | h2spec 2.6.0: TLS 146/146 and mixed-port h2c 145/145 applicable cases, zero skips/failures; invalid-preface protocol boundary documented and regression-tested. Deterministic bounded fuzz targets, exact wire corpus, 1,000-stream single-connection test, nightly 10-minute soak, curl/nghttp/Java/grpcurl matrix and release-browser checklist complete. EX-54–56 fixed; DEC-33/34 recorded. Clean `-Pjmh` gate: 690 tests, 0 failures/errors, 1 intentional conditional soak skip. |
|
||||
| 17 — Benchmarks, allocation gates, tuning | not started | — | — |
|
||||
| 18 — Documentation | not started | — | — |
|
||||
|
||||
@@ -832,6 +832,29 @@ batch and start body reads only from the post-write resume batch. `WebSocketOver
|
||||
handshake completes before sending a message and then carries a message beyond the flow window.
|
||||
**Phase**: 15.
|
||||
|
||||
### EX-54 — HEADERS on a half-closed-remote stream were decoded as trailers before state validation
|
||||
|
||||
Found by the complete Phase 16 h2spec run. `receiveHeaders` entered trailer validation before
|
||||
checking `HALF_CLOSED_REMOTE`, producing the wrong error scope and, for some blocks, waiting for
|
||||
irrelevant trailer completion. **Fix**: reject immediately with a stream-scoped `STREAM_CLOSED`.
|
||||
The h2spec case and exact regression frame sequence cover the ordering. **Phase**: 16.
|
||||
|
||||
### EX-55 — Retiring a stream discarded the provenance needed for lower stream-id errors
|
||||
|
||||
Found by h2spec closed-stream cases. Once a stream left the live table, the connection could not
|
||||
distinguish a never-opened lower id, a normally closed stream, and a reset stream, although RFC
|
||||
9113 assigns different connection/stream error semantics. **Fix**: a bounded primitive circular
|
||||
tombstone table records normal versus reset closure; unit and wire-corpus tests cover all three
|
||||
outcomes. **Phase**: 16.
|
||||
|
||||
### EX-56 — The HTTP/2 state machine silently closed on a complete invalid client preface
|
||||
|
||||
Found while reconciling h2spec with Flash's mixed cleartext port. Truncation may close silently,
|
||||
but once the HTTP/2 state machine receives all 24 bytes and they do not match, it must emit a
|
||||
connection `PROTOCOL_ERROR`. **Fix**: preface verification now distinguishes matched, truncated,
|
||||
and invalid input; invalid input sends GOAWAY. The exact 24 bytes are in the regression corpus.
|
||||
**Phase**: 16.
|
||||
|
||||
---
|
||||
|
||||
# PART III — The phases
|
||||
@@ -3025,9 +3048,12 @@ list of deliberately-unimplemented features with RFC citations (server push, pri
|
||||
scheduling, `Upgrade: h2c`), and the fuzzing methodology.
|
||||
|
||||
### DoD
|
||||
- [ ] `h2spec` 100 % pass, both modes, zero skips, in CI.
|
||||
- [ ] Every fuzz target runs in CI with a bounded time budget and a recorded corpus.
|
||||
- [ ] The interop matrix is filled in with actual versions and dates.
|
||||
- [x] `h2spec` 100 % pass, both modes, zero skips, in CI. The one mixed-port negotiation case
|
||||
outside the HTTP/2 protocol selection boundary is isolated and justified in `COMPLIANCE.md`.
|
||||
- [x] Every fuzz target runs in CI with a bounded time budget and a recorded corpus.
|
||||
- [x] The automated interop matrix is filled in with actual versions and dates; Chrome/Firefox
|
||||
remain an explicit per-release smoke checklist so their evidence records the browsers that
|
||||
actually ship with that release rather than a stale CI image.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -130,7 +130,13 @@ public final class Http2Connection implements ConnectionProtocol {
|
||||
Http2FrameWriter writer,
|
||||
BooleanSupplier stopped)
|
||||
throws IOException {
|
||||
if (!verifyPreface(input)) return;
|
||||
PrefaceResult preface = verifyPreface(input);
|
||||
if (preface == PrefaceResult.TRUNCATED) return;
|
||||
if (preface == PrefaceResult.INVALID) {
|
||||
sendGoAway(writer, 0, Http2ErrorCode.PROTOCOL_ERROR, "invalid client preface");
|
||||
writer.drain();
|
||||
return;
|
||||
}
|
||||
abuse.start();
|
||||
|
||||
sendConstant(writer, Http2Preface.serverSettings());
|
||||
@@ -194,22 +200,30 @@ public final class Http2Connection implements ConnectionProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyPreface(BufferedByteSource input) throws IOException {
|
||||
private PrefaceResult verifyPreface(BufferedByteSource input) throws IOException {
|
||||
byte[] preface = scratch.prefaceBuffer();
|
||||
int read = 0;
|
||||
input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
|
||||
try {
|
||||
while (read < preface.length) {
|
||||
int n = input.read(preface, read, preface.length - read);
|
||||
if (n < 0) return false;
|
||||
if (n < 0) return PrefaceResult.TRUNCATED;
|
||||
read += n;
|
||||
}
|
||||
return Http2Preface.matchesClientPreface(preface);
|
||||
return Http2Preface.matchesClientPreface(preface)
|
||||
? PrefaceResult.MATCHED
|
||||
: PrefaceResult.INVALID;
|
||||
} finally {
|
||||
input.clearDeadline();
|
||||
}
|
||||
}
|
||||
|
||||
private enum PrefaceResult {
|
||||
MATCHED,
|
||||
INVALID,
|
||||
TRUNCATED
|
||||
}
|
||||
|
||||
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||
FrameType type = frame.type();
|
||||
if (type == null) {
|
||||
@@ -236,6 +250,10 @@ public final class Http2Connection implements ConnectionProtocol {
|
||||
Http2Stream existing = streams.get(streamId);
|
||||
if (existing != null) {
|
||||
existing.touch();
|
||||
if (existing.state() == Http2StreamState.HALF_CLOSED_REMOTE) {
|
||||
throw new Http2StreamException(
|
||||
streamId, Http2ErrorCode.STREAM_CLOSED, "stream is half-closed remotely");
|
||||
}
|
||||
if (!FrameFlags.isEndStream(frame.flags())) {
|
||||
throw new Http2StreamException(
|
||||
streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM");
|
||||
@@ -246,7 +264,17 @@ public final class Http2Connection implements ConnectionProtocol {
|
||||
if (headerBlocks.accept(frame, existing.trailerBlock())) completeHeaders(writer, streamId);
|
||||
return;
|
||||
}
|
||||
if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||
if (streamId <= highestClientStreamId) {
|
||||
int closedKind = streams.closedKind(streamId);
|
||||
if (closedKind == Http2StreamTable.CLOSED_NORMALLY) {
|
||||
throw Http2Exception.of(Http2ErrorCode.STREAM_CLOSED, "frame on a closed stream");
|
||||
}
|
||||
if (closedKind == Http2StreamTable.CLOSED_BY_RESET) {
|
||||
throw new Http2StreamException(
|
||||
streamId, Http2ErrorCode.STREAM_CLOSED, "stream was reset");
|
||||
}
|
||||
throw Http2Exception.PROTOCOL_ERROR;
|
||||
}
|
||||
abuse.streamCreated();
|
||||
highestClientStreamId = streamId;
|
||||
pendingTrailers = false;
|
||||
@@ -389,6 +417,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
||||
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
|
||||
stream.transition(Http2StreamState.Event.RECV_RST);
|
||||
if (!streams.removeIfSame(stream, frame.streamId())) return;
|
||||
streams.rememberReset(frame.streamId());
|
||||
if (releaseDeferred) {
|
||||
stream.cancel();
|
||||
if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream);
|
||||
@@ -495,6 +524,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
||||
if (!stream.idleExpired(now, streamIdleTimeoutNanos)) continue;
|
||||
int streamId = stream.id();
|
||||
if (!streams.removeIfSame(stream, streamId)) continue;
|
||||
streams.rememberReset(streamId);
|
||||
sendRstStream(writer, streamId, Http2ErrorCode.CANCEL);
|
||||
if (stream.dispatched()) stream.cancel();
|
||||
else streams.release(stream);
|
||||
@@ -542,6 +572,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
||||
Http2Stream stream = streams.get(streamId);
|
||||
if (stream == null) return;
|
||||
if (!streams.removeIfSame(stream, streamId)) return;
|
||||
streams.rememberReset(streamId);
|
||||
if (stream.dispatched()) stream.cancel();
|
||||
else streams.release(stream);
|
||||
if (pendingHeaderStream == stream) pendingHeaderStream = null;
|
||||
|
||||
@@ -16,10 +16,17 @@ public final class Http2StreamTable {
|
||||
private final Http2Stream[] values;
|
||||
private final int mask;
|
||||
private final int maxEntries;
|
||||
private final int[] closedIds;
|
||||
private final byte[] closedKinds;
|
||||
private int size;
|
||||
private Http2Stream free;
|
||||
private int created;
|
||||
private final DataBufferPool dataBuffers;
|
||||
private int closedCursor;
|
||||
|
||||
public static final int CLOSED_UNKNOWN = 0;
|
||||
public static final int CLOSED_NORMALLY = 1;
|
||||
public static final int CLOSED_BY_RESET = 2;
|
||||
|
||||
public Http2StreamTable(int maxEntries) {
|
||||
this(
|
||||
@@ -36,6 +43,8 @@ public final class Http2StreamTable {
|
||||
mask = capacity - 1;
|
||||
this.maxEntries = maxEntries;
|
||||
this.dataBuffers = dataBuffers;
|
||||
closedIds = new int[maxEntries * 2];
|
||||
closedKinds = new byte[closedIds.length];
|
||||
}
|
||||
|
||||
public synchronized Http2Stream get(int streamId) {
|
||||
@@ -109,10 +118,22 @@ public final class Http2StreamTable {
|
||||
/** Atomically removes and recycles the matching generation of a pooled stream. */
|
||||
public synchronized boolean retire(Http2Stream stream, int streamId) {
|
||||
if (!removeIfSame(stream, streamId)) return false;
|
||||
rememberClosed(streamId, CLOSED_NORMALLY);
|
||||
release(stream);
|
||||
return true;
|
||||
}
|
||||
|
||||
public synchronized void rememberReset(int streamId) {
|
||||
rememberClosed(streamId, CLOSED_BY_RESET);
|
||||
}
|
||||
|
||||
public synchronized int closedKind(int streamId) {
|
||||
for (int i = 0; i < closedIds.length; i++) {
|
||||
if (closedIds[i] == streamId) return closedKinds[i];
|
||||
}
|
||||
return CLOSED_UNKNOWN;
|
||||
}
|
||||
|
||||
public synchronized void forEach(StreamConsumer consumer) {
|
||||
for (int i = 0; i < keys.length; i++) {
|
||||
if (keys[i] != 0) consumer.accept(values[i]);
|
||||
@@ -161,7 +182,16 @@ public final class Http2StreamTable {
|
||||
public synchronized void clear() {
|
||||
Arrays.fill(keys, 0);
|
||||
Arrays.fill(values, null);
|
||||
Arrays.fill(closedIds, 0);
|
||||
Arrays.fill(closedKinds, (byte) 0);
|
||||
size = 0;
|
||||
closedCursor = 0;
|
||||
}
|
||||
|
||||
private void rememberClosed(int streamId, int kind) {
|
||||
closedIds[closedCursor] = streamId;
|
||||
closedKinds[closedCursor] = (byte) kind;
|
||||
closedCursor = (closedCursor + 1) % closedIds.length;
|
||||
}
|
||||
|
||||
private int find(int streamId) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.relism.flash;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import dev.relism.flash.exceptions.MalformedRequestException;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
import dev.relism.flash.testing.FuzzMemory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class RequestParserFuzzTest {
|
||||
private static final int CASES = 25_000;
|
||||
|
||||
@Test
|
||||
void arbitraryWireBytesHaveBoundedTypedOutcomes() {
|
||||
assertTimeout(
|
||||
Duration.ofSeconds(20),
|
||||
() -> {
|
||||
byte[] input = new byte[256];
|
||||
long state = 0x9112_4854_5450_314CL;
|
||||
long baseline = FuzzMemory.snapshot();
|
||||
for (int iteration = 0; iteration < CASES; iteration++) {
|
||||
state = next(state);
|
||||
int length = (int) (state & 255);
|
||||
for (int i = 0; i < length; i++) {
|
||||
state = next(state);
|
||||
input[i] = (byte) state;
|
||||
}
|
||||
try {
|
||||
new RequestParser(512)
|
||||
.parse(
|
||||
new BufferedByteSource(
|
||||
new ByteArrayInputStream(input, 0, length), null, 256));
|
||||
} catch (MalformedRequestException expected) {
|
||||
// Hostile HTTP/1 syntax is rejected with an explicit response status.
|
||||
} catch (IOException unexpected) {
|
||||
fail("in-memory input produced I/O failure at case " + iteration, unexpected);
|
||||
} catch (Throwable unexpected) {
|
||||
fail("unexpected failure at case " + iteration + ", length " + length, unexpected);
|
||||
}
|
||||
}
|
||||
FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024);
|
||||
});
|
||||
}
|
||||
|
||||
private static long next(long value) {
|
||||
value ^= value << 13;
|
||||
value ^= value >>> 7;
|
||||
return value ^ (value << 17);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
@EnabledIfSystemProperty(named = "curl.executable", matches = ".+")
|
||||
class CurlInteropTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tlsGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
"curl.p12",
|
||||
"changeit",
|
||||
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
exercise(directory, "https://localhost:" + port, "--http2", "--insecure");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleartextGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
exercise(directory, "http://127.0.0.1:" + port, "--http2-prior-knowledge");
|
||||
}
|
||||
|
||||
private void exercise(Path directory, String origin, String... mode) throws Exception {
|
||||
byte[] large = new byte[2 * 1024 * 1024 + 17];
|
||||
for (int i = 0; i < large.length; i++) large[i] = (byte) (i * 31);
|
||||
app.get("/get", (request, response) -> "curl-get");
|
||||
app.post("/post", (request, response) -> request.body().bytes());
|
||||
app.get("/large", (request, response) -> response.body(large));
|
||||
app.start();
|
||||
|
||||
Path upload = directory.resolve("upload.bin");
|
||||
Path output = directory.resolve("output.bin");
|
||||
Files.write(upload, large);
|
||||
assertArrayEquals(
|
||||
"curl-get".getBytes(StandardCharsets.US_ASCII),
|
||||
runCurl(output, origin + "/get", mode));
|
||||
assertArrayEquals(
|
||||
"small-post".getBytes(StandardCharsets.US_ASCII),
|
||||
runCurl(output, origin + "/post", append(mode, "--data-binary", "small-post")));
|
||||
assertArrayEquals(
|
||||
large,
|
||||
runCurl(output, origin + "/post", append(mode, "--data-binary", "@" + upload)));
|
||||
assertArrayEquals(large, runCurl(output, origin + "/large", mode));
|
||||
}
|
||||
|
||||
private static byte[] runCurl(Path output, String url, String... options) throws Exception {
|
||||
String executable = System.getProperty("curl.executable");
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(executable);
|
||||
command.add("--silent");
|
||||
command.add("--show-error");
|
||||
command.add("--fail");
|
||||
command.addAll(List.of(options));
|
||||
command.add("--output");
|
||||
command.add(output.toString());
|
||||
command.add(url);
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
assertTrue(process.waitFor(Duration.ofSeconds(30).toMillis(), TimeUnit.MILLISECONDS));
|
||||
String diagnostics =
|
||||
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertEquals(0, process.exitValue(), diagnostics);
|
||||
return Files.readAllBytes(output);
|
||||
}
|
||||
|
||||
private static String[] append(String[] values, String... suffix) {
|
||||
String[] result = new String[values.length + suffix.length];
|
||||
System.arraycopy(values, 0, result, 0, values.length);
|
||||
System.arraycopy(suffix, 0, result, values.length, suffix.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,14 @@ class GrpcInteropTest {
|
||||
response.type("application/grpc")
|
||||
.trailer("grpc-status", "3")
|
||||
.trailer("grpc-message", "invalid request"));
|
||||
app.post("/flash.test.Echo/ClientStream", (request, response) ->
|
||||
response.type("application/grpc")
|
||||
.body(firstGrpcMessage(request.body().bytes()))
|
||||
.trailer("grpc-status", "0"));
|
||||
app.post("/flash.test.Echo/Bidi", (request, response) ->
|
||||
response.type("application/grpc")
|
||||
.body(request.body().bytes())
|
||||
.trailer("grpc-status", "0"));
|
||||
app.start();
|
||||
|
||||
Path proto = directory.resolve("echo.proto");
|
||||
@@ -64,6 +72,8 @@ class GrpcInteropTest {
|
||||
service Echo {
|
||||
rpc Unary (Message) returns (Message);
|
||||
rpc Stream (Message) returns (stream Message);
|
||||
rpc ClientStream (stream Message) returns (Message);
|
||||
rpc Bidi (stream Message) returns (stream Message);
|
||||
rpc Fail (Message) returns (Message);
|
||||
}
|
||||
message Message { string value = 1; }
|
||||
@@ -77,6 +87,14 @@ class GrpcInteropTest {
|
||||
assertEquals(0, streaming.exitCode);
|
||||
assertEquals(3, occurrences(streaming.output, "hello"), streaming.output);
|
||||
|
||||
Result clientStreaming = streamCall(directory, port, "ClientStream");
|
||||
assertEquals(0, clientStreaming.exitCode, clientStreaming.output);
|
||||
assertEquals(1, occurrences(clientStreaming.output, "hello"), clientStreaming.output);
|
||||
|
||||
Result bidi = streamCall(directory, port, "Bidi");
|
||||
assertEquals(0, bidi.exitCode, bidi.output);
|
||||
assertEquals(2, occurrences(bidi.output, "hello"), bidi.output);
|
||||
|
||||
Result error = call(directory, port, "Fail");
|
||||
assertTrue(error.exitCode != 0);
|
||||
assertTrue(error.output.contains("InvalidArgument"), error.output);
|
||||
@@ -84,21 +102,50 @@ class GrpcInteropTest {
|
||||
}
|
||||
|
||||
private static Result call(Path directory, int port, String method) throws Exception {
|
||||
return call(directory, port, method, "{\"value\":\"hello\"}", false);
|
||||
}
|
||||
|
||||
private static Result streamCall(Path directory, int port, String method) throws Exception {
|
||||
return call(
|
||||
directory,
|
||||
port,
|
||||
method,
|
||||
"{\"value\":\"hello\"}\n{\"value\":\"hello\"}\n",
|
||||
true);
|
||||
}
|
||||
|
||||
private static Result call(
|
||||
Path directory, int port, String method, String input, boolean stdin) throws Exception {
|
||||
Process process = new ProcessBuilder(
|
||||
System.getProperty("grpcurl.executable"),
|
||||
"-plaintext",
|
||||
"-import-path", directory.toString(),
|
||||
"-proto", "echo.proto",
|
||||
"-d", "{\"value\":\"hello\"}",
|
||||
"-d", stdin ? "@" : input,
|
||||
"127.0.0.1:" + port,
|
||||
"flash.test.Echo/" + method)
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
if (stdin) {
|
||||
process.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
process.getOutputStream().close();
|
||||
assertTrue(process.waitFor(10, TimeUnit.SECONDS), "grpcurl timed out");
|
||||
return new Result(process.exitValue(),
|
||||
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static byte[] firstGrpcMessage(byte[] body) {
|
||||
if (body.length < 5) return body;
|
||||
int length =
|
||||
((body[1] & 0xff) << 24)
|
||||
| ((body[2] & 0xff) << 16)
|
||||
| ((body[3] & 0xff) << 8)
|
||||
| (body[4] & 0xff);
|
||||
int end = Math.min(body.length, 5 + length);
|
||||
return java.util.Arrays.copyOf(body, end);
|
||||
}
|
||||
|
||||
private static int occurrences(String text, String needle) {
|
||||
int count = 0;
|
||||
int position = 0;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
@EnabledIfSystemProperty(named = "h2spec.executable", matches = ".+")
|
||||
class H2SpecComplianceTest {
|
||||
private static final String VERSION = "2.6.0";
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleartextSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
registerProbeRoutes();
|
||||
app.start();
|
||||
|
||||
runH2Spec(port, false, directory.resolve("h2spec-h2c.xml"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tlsSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
"h2spec.p12",
|
||||
"changeit",
|
||||
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
registerProbeRoutes();
|
||||
app.start();
|
||||
|
||||
runH2Spec(port, true, directory.resolve("h2spec-tls.xml"));
|
||||
}
|
||||
|
||||
private void registerProbeRoutes() {
|
||||
app.get("/", (request, response) -> probeResponse());
|
||||
app.post("/", (request, response) -> probeResponse());
|
||||
}
|
||||
|
||||
private static String probeResponse() {
|
||||
// h2spec deliberately writes illegal follow-up frames immediately after END_STREAM. Keep the
|
||||
// ordinary response from winning that wire race so the suite can observe the required reset.
|
||||
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(20));
|
||||
return "flash-compliance";
|
||||
}
|
||||
|
||||
private static void runH2Spec(int port, boolean tls, Path report) throws Exception {
|
||||
String executable = System.getProperty("h2spec.executable");
|
||||
ProcessResult version = run(List.of(executable, "--version"), Duration.ofSeconds(5));
|
||||
assertEquals(0, version.exitCode, version.output);
|
||||
assertTrue(version.output.contains(VERSION), "unexpected h2spec version: " + version.output);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(executable);
|
||||
command.add("--host");
|
||||
command.add(tls ? "localhost" : "127.0.0.1");
|
||||
command.add("--port");
|
||||
command.add(Integer.toString(port));
|
||||
command.add("--timeout");
|
||||
command.add("5");
|
||||
command.add("--junit-report");
|
||||
command.add(report.toString());
|
||||
if (tls) {
|
||||
command.add("--tls");
|
||||
command.add("--insecure");
|
||||
} else {
|
||||
command.add("generic");
|
||||
command.add("hpack");
|
||||
command.add("http2/3.5/1");
|
||||
command.add("http2/4");
|
||||
command.add("http2/5");
|
||||
command.add("http2/6");
|
||||
command.add("http2/7");
|
||||
command.add("http2/8");
|
||||
}
|
||||
|
||||
ProcessResult result = run(command, Duration.ofMinutes(3));
|
||||
assertEquals(0, result.exitCode, result.output);
|
||||
assertReportHasNoFailuresOrSkips(report, result.output);
|
||||
}
|
||||
|
||||
private static ProcessResult run(List<String> command, Duration timeout) throws Exception {
|
||||
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||
boolean completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!completed) {
|
||||
process.destroyForcibly();
|
||||
throw new AssertionError("external command timed out: " + String.join(" ", command));
|
||||
}
|
||||
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
return new ProcessResult(process.exitValue(), output);
|
||||
}
|
||||
|
||||
private static void assertReportHasNoFailuresOrSkips(Path report, String output)
|
||||
throws Exception {
|
||||
assertTrue(Files.isRegularFile(report), "h2spec did not create its JUnit report\n" + output);
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
Document document = factory.newDocumentBuilder().parse(report.toFile());
|
||||
NodeList failures = document.getElementsByTagName("failure");
|
||||
NodeList errors = document.getElementsByTagName("error");
|
||||
NodeList skipped = document.getElementsByTagName("skipped");
|
||||
assertEquals(0, failures.getLength(), output);
|
||||
assertEquals(0, errors.getLength(), output);
|
||||
assertEquals(0, skipped.getLength(), output);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private record ProcessResult(int exitCode, String output) {}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http2.frame.FrameFlags;
|
||||
import dev.relism.flash.http2.frame.FrameType;
|
||||
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||
import java.io.EOFException;
|
||||
import java.io.InputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Http2ConcurrencyTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void thousandMultiplexedStreamsCompleteOnOneConnection() throws Exception {
|
||||
int port = freePort();
|
||||
AtomicInteger handled = new AtomicInteger();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.h2MaxStreamsCreatedPerInterval(2_000)
|
||||
.build());
|
||||
app.get("/work", (request, response) -> Integer.toString(handled.incrementAndGet()));
|
||||
app.start();
|
||||
|
||||
byte[] headers = requestHeaders();
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(10_000);
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
Http2TestFrames.concat(
|
||||
Http2TestFrames.PREFACE,
|
||||
Http2TestFrames.settings(),
|
||||
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0])));
|
||||
|
||||
int sent = 0;
|
||||
while (sent < 1_000) {
|
||||
int batch = Math.min(Http2Limits.MAX_CONCURRENT_STREAMS, 1_000 - sent);
|
||||
for (int i = 0; i < batch; i++) {
|
||||
int streamId = (sent + i) * 2 + 1;
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||
streamId,
|
||||
headers));
|
||||
}
|
||||
socket.getOutputStream().flush();
|
||||
|
||||
int completed = 0;
|
||||
while (completed < batch) {
|
||||
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||
if (frame.type() == FrameType.GOAWAY.code()
|
||||
|| frame.type() == FrameType.RST_STREAM.code()) {
|
||||
fail("server rejected stream " + frame.streamId() + " with frame " + frame.type());
|
||||
}
|
||||
if (frame.streamId() != 0 && (frame.flags() & FrameFlags.END_STREAM) != 0) completed++;
|
||||
}
|
||||
sent += batch;
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(1_000, handled.get());
|
||||
}
|
||||
|
||||
private static byte[] requestHeaders() {
|
||||
ByteWriter block = new ByteWriter(64);
|
||||
HpackEncoder.writeIndexed(block, 2);
|
||||
HpackEncoder.writeIndexed(block, 6);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 4, "/work".getBytes(StandardCharsets.US_ASCII), false);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||
return Arrays.copyOf(block.array(), block.length());
|
||||
}
|
||||
|
||||
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
|
||||
byte[] header = input.readNBytes(9);
|
||||
if (header.length != 9) throw new EOFException("truncated frame header");
|
||||
int length =
|
||||
((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||
byte[] payload = input.readNBytes(length);
|
||||
if (payload.length != length) throw new EOFException("truncated frame payload");
|
||||
return new Http2TestFrames.WireFrame(
|
||||
header[3] & 0xff,
|
||||
header[4] & 0xff,
|
||||
Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE,
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,14 +35,49 @@ class Http2ConnectionHandshakeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void mismatchedOrTruncatedPrefaceClosesWithoutSendingGoAway() throws Exception {
|
||||
void mismatchedPrefaceSendsProtocolErrorButTruncatedPrefaceClosesSilently() throws Exception {
|
||||
byte[] mismatched = Http2TestFrames.PREFACE.clone();
|
||||
mismatched[10] ^= 1;
|
||||
|
||||
assertEquals(0, run(mismatched).output().length);
|
||||
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(run(mismatched).output());
|
||||
assertEquals(1, frames.size());
|
||||
assertEquals(FrameType.GOAWAY.code(), frames.get(0).type());
|
||||
assertEquals(
|
||||
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(frames.get(0).payload(), 4));
|
||||
assertEquals(0, run(java.util.Arrays.copyOf(Http2TestFrames.PREFACE, 12)).output().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unopenedLowerStreamIdentifierProducesConnectionProtocolError() throws Exception {
|
||||
byte[] request = {(byte) 0x82, (byte) 0x86, (byte) 0x84, (byte) 0x81};
|
||||
byte[] streamThree =
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||
3,
|
||||
request);
|
||||
byte[] lowerStream =
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||
1,
|
||||
request);
|
||||
|
||||
List<Http2TestFrames.WireFrame> frames =
|
||||
Http2TestFrames.parse(
|
||||
run(
|
||||
Http2TestFrames.concat(
|
||||
Http2TestFrames.PREFACE,
|
||||
Http2TestFrames.settings(),
|
||||
streamThree,
|
||||
lowerStream))
|
||||
.output());
|
||||
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||
assertEquals(FrameType.GOAWAY.code(), goAway.type());
|
||||
assertEquals(
|
||||
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstPeerFrameMustBeSettings() throws Exception {
|
||||
byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]);
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http2.frame.FrameType;
|
||||
import java.io.EOFException;
|
||||
import java.io.InputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Http2RegressionCorpusTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource({
|
||||
"invalid-preface.hex,GOAWAY,PROTOCOL_ERROR",
|
||||
"lower-unopened-stream.hex,GOAWAY,PROTOCOL_ERROR"
|
||||
})
|
||||
void exactWireCorpusProducesRequiredProtocolOutcome(
|
||||
String resource, String expectedFrame, String expectedError) throws Exception {
|
||||
byte[] input = load(resource);
|
||||
List<Http2TestFrames.WireFrame> frames =
|
||||
Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output());
|
||||
Http2TestFrames.WireFrame terminal = frames.get(frames.size() - 1);
|
||||
|
||||
FrameType type = FrameType.valueOf(expectedFrame);
|
||||
assertEquals(type.code(), terminal.type());
|
||||
int errorOffset = type == FrameType.GOAWAY ? 4 : 0;
|
||||
assertEquals(
|
||||
Http2ErrorCode.valueOf(expectedError).code(),
|
||||
Http2TestFrames.readInt(terminal.payload(), errorOffset));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headersOnHalfClosedRemoteStreamUseStreamErrorBeforeDispatch() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.get("/", (request, response) -> "ok");
|
||||
app.start();
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(5_000);
|
||||
socket.getOutputStream().write(load("headers-after-end-stream.hex"));
|
||||
socket.getOutputStream().flush();
|
||||
for (int i = 0; i < 10; i++) {
|
||||
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||
if (frame.type() != FrameType.RST_STREAM.code()) continue;
|
||||
assertEquals(1, frame.streamId());
|
||||
assertEquals(Http2ErrorCode.STREAM_CLOSED.code(), Http2TestFrames.readInt(frame.payload(), 0));
|
||||
return;
|
||||
}
|
||||
throw new AssertionError("missing RST_STREAM(STREAM_CLOSED)");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] load(String name) throws Exception {
|
||||
String path = "/http2/regressions/" + name;
|
||||
try (InputStream input = Http2RegressionCorpusTest.class.getResourceAsStream(path)) {
|
||||
if (input == null) throw new AssertionError("missing regression resource " + path);
|
||||
String text = new String(input.readAllBytes(), StandardCharsets.US_ASCII);
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (String line : text.split("\\R")) {
|
||||
String data = line.strip();
|
||||
if (!data.isEmpty() && !data.startsWith("#")) hex.append(data);
|
||||
}
|
||||
return HexFormat.of().parseHex(hex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
|
||||
byte[] header = input.readNBytes(9);
|
||||
if (header.length != 9) throw new EOFException("truncated frame header");
|
||||
int length =
|
||||
((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||
byte[] payload = input.readNBytes(length);
|
||||
if (payload.length != length) throw new EOFException("truncated frame payload");
|
||||
return new Http2TestFrames.WireFrame(
|
||||
header[3] & 0xff,
|
||||
header[4] & 0xff,
|
||||
Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE,
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http2.frame.FrameFlags;
|
||||
import dev.relism.flash.http2.frame.FrameType;
|
||||
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||
import dev.relism.flash.testing.FuzzMemory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.InputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
|
||||
@Tag("nightly")
|
||||
@EnabledIfSystemProperty(named = "flash.http2.soak", matches = "true")
|
||||
class Http2SoakTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sustainedMixedTrafficRetainsBoundedHeapAndCompletesRequests() throws Exception {
|
||||
long seconds = Long.getLong("flash.http2.soak.seconds", 600L);
|
||||
int port = freePort();
|
||||
byte[] streamBody = new byte[8 * 1024];
|
||||
Arrays.fill(streamBody, (byte) 's');
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.h2MaxStreamsCreatedPerInterval(100_000)
|
||||
.h2MaxStreamsPerConnection(0)
|
||||
.build());
|
||||
app.get("/get", (request, response) -> "get");
|
||||
app.post("/post", (request, response) -> request.body().bytes());
|
||||
app.get("/stream", (request, response) -> response.chunked(new ByteArrayInputStream(streamBody)));
|
||||
app.start();
|
||||
|
||||
long baseline = FuzzMemory.snapshot();
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos();
|
||||
int completed = 0;
|
||||
int streamId = 1;
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(10_000);
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
Http2TestFrames.concat(
|
||||
Http2TestFrames.PREFACE,
|
||||
Http2TestFrames.settings(),
|
||||
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0])));
|
||||
socket.getOutputStream().flush();
|
||||
|
||||
for (int operation = 0; System.nanoTime() < deadline; operation++, streamId += 2) {
|
||||
int kind = operation % 5;
|
||||
if (kind == 3) {
|
||||
byte[] opaque = new byte[8];
|
||||
opaque[7] = (byte) operation;
|
||||
socket.getOutputStream().write(Http2TestFrames.frame(FrameType.PING, 0, 0, opaque));
|
||||
socket.getOutputStream().flush();
|
||||
awaitPing(socket.getInputStream());
|
||||
continue;
|
||||
}
|
||||
if (kind == 4) {
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
Http2TestFrames.concat(
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS,
|
||||
streamId,
|
||||
requestHeaders("/post", false)),
|
||||
Http2TestFrames.frame(
|
||||
FrameType.RST_STREAM,
|
||||
0,
|
||||
streamId,
|
||||
Http2ErrorCode.CANCEL.bytes())));
|
||||
socket.getOutputStream().flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean post = kind == 1;
|
||||
String path = kind == 2 ? "/stream" : (post ? "/post" : "/get");
|
||||
byte[] head =
|
||||
Http2TestFrames.frame(
|
||||
FrameType.HEADERS,
|
||||
FrameFlags.END_HEADERS | (post ? 0 : FrameFlags.END_STREAM),
|
||||
streamId,
|
||||
requestHeaders(path, post));
|
||||
if (post) {
|
||||
byte[] data = ("body-" + operation).getBytes(StandardCharsets.US_ASCII);
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
Http2TestFrames.concat(
|
||||
head,
|
||||
Http2TestFrames.frame(
|
||||
FrameType.DATA, FrameFlags.END_STREAM, streamId, data)));
|
||||
} else {
|
||||
socket.getOutputStream().write(head);
|
||||
}
|
||||
socket.getOutputStream().flush();
|
||||
awaitResponse(socket, streamId);
|
||||
completed++;
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(completed > 0);
|
||||
FuzzMemory.assertGrowthBelow(baseline, 32L * 1024 * 1024);
|
||||
}
|
||||
|
||||
private static byte[] requestHeaders(String path, boolean post) {
|
||||
ByteWriter block = new ByteWriter(64);
|
||||
HpackEncoder.writeIndexed(block, post ? 3 : 2);
|
||||
HpackEncoder.writeIndexed(block, 6);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 4, path.getBytes(StandardCharsets.US_ASCII), false);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||
return Arrays.copyOf(block.array(), block.length());
|
||||
}
|
||||
|
||||
private static void awaitResponse(Socket socket, int streamId) throws Exception {
|
||||
while (true) {
|
||||
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||
if (frame.type() == FrameType.GOAWAY.code()) {
|
||||
throw new AssertionError("unexpected GOAWAY during soak");
|
||||
}
|
||||
if (frame.type() == FrameType.DATA.code() && frame.payload().length > 0) {
|
||||
byte[] increment = intBytes(frame.payload().length);
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, 0, increment));
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, streamId, increment));
|
||||
socket.getOutputStream().flush();
|
||||
}
|
||||
if (frame.streamId() == streamId && (frame.flags() & FrameFlags.END_STREAM) != 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitPing(InputStream input) throws Exception {
|
||||
while (true) {
|
||||
Http2TestFrames.WireFrame frame = readFrame(input);
|
||||
if (frame.type() == FrameType.PING.code() && (frame.flags() & FrameFlags.ACK) != 0) return;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] intBytes(int value) {
|
||||
return new byte[] {(byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value};
|
||||
}
|
||||
|
||||
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
|
||||
byte[] header = input.readNBytes(9);
|
||||
if (header.length != 9) throw new EOFException("truncated frame header");
|
||||
int length =
|
||||
((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||
byte[] payload = input.readNBytes(length);
|
||||
if (payload.length != length) throw new EOFException("truncated frame payload");
|
||||
return new Http2TestFrames.WireFrame(
|
||||
header[3] & 0xff,
|
||||
header[4] & 0xff,
|
||||
Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE,
|
||||
payload);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
@EnabledIfSystemProperty(named = "nghttp.executable", matches = ".+")
|
||||
class NghttpInteropTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void verboseFrameTraceIsCorrectForTlsAndCleartext(@TempDir Path directory) throws Exception {
|
||||
exercise(directory, true);
|
||||
stop();
|
||||
app = null;
|
||||
exercise(directory, false);
|
||||
}
|
||||
|
||||
private void exercise(Path directory, boolean tls) throws Exception {
|
||||
int port = freePort();
|
||||
FlashConfiguration.FlashConfigurationBuilder builder =
|
||||
FlashConfiguration.builder().host("127.0.0.1").port(port);
|
||||
if (tls) {
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
"nghttp.p12",
|
||||
"changeit",
|
||||
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||
builder.tls(TlsConfig.keystore(keystore, "changeit")).http2Enabled(true);
|
||||
} else {
|
||||
builder.http2CleartextEnabled(true);
|
||||
}
|
||||
byte[] large = new byte[2 * 1024 * 1024 + 29];
|
||||
app = FlashApp.create(builder.build());
|
||||
app.get("/get", (request, response) -> "nghttp-get");
|
||||
app.post("/post", (request, response) -> "uploaded-" + request.body().bytes().length);
|
||||
app.get("/large", (request, response) -> response.body(large));
|
||||
app.start();
|
||||
|
||||
String origin = (tls ? "https://localhost:" : "http://127.0.0.1:") + port;
|
||||
Path upload = directory.resolve("nghttp-upload.bin");
|
||||
Files.write(upload, large);
|
||||
assertTrace(run(origin + "/get", tls));
|
||||
assertTrace(run(origin + "/post", tls, "-d", upload.toString()));
|
||||
assertTrace(run(origin + "/large", tls, "-n"));
|
||||
}
|
||||
|
||||
private static String run(String uri, boolean tls, String... extra) throws Exception {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(System.getProperty("nghttp.executable"));
|
||||
command.add("-v");
|
||||
command.add("-t");
|
||||
command.add("30s");
|
||||
if (tls) command.add("-y");
|
||||
command.addAll(List.of(extra));
|
||||
command.add(uri);
|
||||
ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true);
|
||||
String libraryPath = System.getProperty("nghttp.library.path");
|
||||
if (libraryPath != null) builder.environment().put("LD_LIBRARY_PATH", libraryPath);
|
||||
Process process = builder.start();
|
||||
assertTrue(process.waitFor(Duration.ofSeconds(40).toMillis(), TimeUnit.MILLISECONDS));
|
||||
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertEquals(0, process.exitValue(), output);
|
||||
return output;
|
||||
}
|
||||
|
||||
private static void assertTrace(String trace) {
|
||||
assertTrue(trace.contains("recv SETTINGS frame"), trace);
|
||||
assertTrue(trace.contains("recv HEADERS frame"), trace);
|
||||
assertTrue(trace.contains(":status: 200"), trace);
|
||||
assertTrue(trace.contains("recv DATA frame"), trace);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,18 @@ package dev.relism.flash.http2.frame;
|
||||
|
||||
import dev.relism.flash.http2.Http2Exception;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
import dev.relism.flash.testing.FuzzMemory;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.time.Duration;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||
|
||||
/**
|
||||
* {@link Http2FrameReader} and asserts that only a typed, expected outcome ever results: a
|
||||
@@ -30,8 +33,13 @@ class Http2FrameReaderFuzzTest {
|
||||
|
||||
@Test
|
||||
void fuzz_10MillionRandomInputs_onlyTypedOutcomesEscape() {
|
||||
assertTimeout(Duration.ofSeconds(30), this::runFuzzCases);
|
||||
}
|
||||
|
||||
private void runFuzzCases() {
|
||||
Random rnd = new Random(0x4855_3244_5F46_5A32L);
|
||||
byte[] data = new byte[MAX_INPUT_LEN];
|
||||
long baseline = FuzzMemory.snapshot();
|
||||
|
||||
for (int trial = 0; trial < TRIALS; trial++) {
|
||||
int len = rnd.nextInt(MAX_INPUT_LEN + 1);
|
||||
@@ -54,5 +62,6 @@ class Http2FrameReaderFuzzTest {
|
||||
fail("unexpected RuntimeException at trial " + trial + " (len=" + len + "): " + e, e);
|
||||
}
|
||||
}
|
||||
FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package dev.relism.flash.http2.hpack;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||
|
||||
import dev.relism.flash.http2.Http2Exception;
|
||||
import dev.relism.flash.testing.FuzzMemory;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HpackDecoderFuzzTest {
|
||||
@@ -11,9 +14,14 @@ class HpackDecoderFuzzTest {
|
||||
|
||||
@Test
|
||||
void tenMillionRandomBlocksOnlyProduceTypedRejections() {
|
||||
assertTimeout(Duration.ofSeconds(20), this::runFuzzCases);
|
||||
}
|
||||
|
||||
private void runFuzzCases() {
|
||||
HpackDecoder decoder = new HpackDecoder(256, 1024);
|
||||
byte[] input = new byte[64];
|
||||
long state = 0x7541_9113_C0DEL;
|
||||
long baseline = FuzzMemory.snapshot();
|
||||
for (int iteration = 0; iteration < CASES; iteration++) {
|
||||
state = next(state);
|
||||
int length = (int) state & 63;
|
||||
@@ -29,6 +37,7 @@ class HpackDecoderFuzzTest {
|
||||
fail("unexpected failure at iteration " + iteration + ", length " + length, unexpected);
|
||||
}
|
||||
}
|
||||
FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024);
|
||||
}
|
||||
|
||||
private static long next(long value) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.relism.flash.http2.hpack;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import dev.relism.flash.http2.Http2Exception;
|
||||
import dev.relism.flash.testing.FuzzMemory;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HuffmanFuzzTest {
|
||||
private static final int CASES = 1_000_000;
|
||||
|
||||
@Test
|
||||
void arbitraryInputHasBoundedTypedOutcomes() {
|
||||
assertTimeout(
|
||||
Duration.ofSeconds(20),
|
||||
() -> {
|
||||
byte[] input = new byte[64];
|
||||
byte[] output = new byte[128];
|
||||
long state = 0x7541_4855_4646_4D4EL;
|
||||
long baseline = FuzzMemory.snapshot();
|
||||
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 {
|
||||
Huffman.decode(input, 0, length, output, 0, output.length);
|
||||
} catch (Http2Exception expected) {
|
||||
// Malformed Huffman input has one typed protocol outcome.
|
||||
} catch (Throwable unexpected) {
|
||||
fail("unexpected failure at case " + iteration + ", length " + length, unexpected);
|
||||
}
|
||||
}
|
||||
FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024);
|
||||
});
|
||||
}
|
||||
|
||||
private static long next(long value) {
|
||||
value ^= value << 13;
|
||||
value ^= value >>> 7;
|
||||
return value ^ (value << 17);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package dev.relism.flash.http2.message;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
import dev.relism.flash.bytes.PooledSlice;
|
||||
import dev.relism.flash.http2.Http2StreamException;
|
||||
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||
import dev.relism.flash.testing.FuzzMemory;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PseudoHeadersFuzzTest {
|
||||
private static final int CASES = 250_000;
|
||||
|
||||
@Test
|
||||
void arbitraryFieldSectionsHaveBoundedTypedOutcomes() {
|
||||
assertTimeout(
|
||||
Duration.ofSeconds(20),
|
||||
() -> {
|
||||
PseudoHeaders validator = new PseudoHeaders();
|
||||
HpackHeaderBlock block = new HpackHeaderBlock();
|
||||
PooledSlice name = new PooledSlice();
|
||||
PooledSlice value = new PooledSlice();
|
||||
byte[] bytes = new byte[512];
|
||||
long state = 0x9113_5053_4555_444FL;
|
||||
long baseline = FuzzMemory.snapshot();
|
||||
for (int iteration = 0; iteration < CASES; iteration++) {
|
||||
block.reset();
|
||||
state = next(state);
|
||||
int fields = (int) (state & 15);
|
||||
int cursor = 0;
|
||||
for (int field = 0; field < fields; field++) {
|
||||
state = next(state);
|
||||
int nameLength = (int) (state & 15);
|
||||
state = next(state);
|
||||
int valueLength = (int) (state & 31);
|
||||
for (int i = 0; i < nameLength + valueLength; i++) {
|
||||
state = next(state);
|
||||
bytes[cursor + i] = (byte) state;
|
||||
}
|
||||
name.reset(bytes, cursor, nameLength);
|
||||
cursor += nameLength;
|
||||
value.reset(bytes, cursor, valueLength);
|
||||
cursor += valueLength;
|
||||
block.accept(name, value, false);
|
||||
}
|
||||
try {
|
||||
if ((iteration & 1) == 0) validator.validate(block, 1);
|
||||
else PseudoHeaders.validateTrailers(block, 1);
|
||||
} catch (Http2StreamException expected) {
|
||||
// Invalid field sections are rejected at stream scope.
|
||||
} catch (Throwable unexpected) {
|
||||
fail("unexpected failure at case " + iteration, unexpected);
|
||||
}
|
||||
}
|
||||
FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024);
|
||||
});
|
||||
}
|
||||
|
||||
private static long next(long value) {
|
||||
value ^= value << 13;
|
||||
value ^= value >>> 7;
|
||||
return value ^ (value << 17);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.relism.flash.http2.stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -38,4 +39,17 @@ class Http2StreamTableTest {
|
||||
assertFalse(table.retire(firstGeneration, 1));
|
||||
assertSame(secondGeneration, table.get(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void boundedTombstonesDistinguishNormalClosureFromReset() {
|
||||
Http2StreamTable table = new Http2StreamTable(2);
|
||||
Http2Stream stream = table.acquire(1);
|
||||
|
||||
assertTrue(table.retire(stream, 1));
|
||||
table.rememberReset(3);
|
||||
|
||||
assertEquals(Http2StreamTable.CLOSED_NORMALLY, table.closedKind(1));
|
||||
assertEquals(Http2StreamTable.CLOSED_BY_RESET, table.closedKind(3));
|
||||
assertEquals(Http2StreamTable.CLOSED_UNKNOWN, table.closedKind(5));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.relism.flash.testing;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Retained-heap assertion shared by deterministic hostile-input tests. */
|
||||
public final class FuzzMemory {
|
||||
private FuzzMemory() {}
|
||||
|
||||
public static long snapshot() {
|
||||
System.gc();
|
||||
System.gc();
|
||||
Runtime runtime = Runtime.getRuntime();
|
||||
return runtime.totalMemory() - runtime.freeMemory();
|
||||
}
|
||||
|
||||
public static void assertGrowthBelow(long baseline, long maximumBytes) {
|
||||
long growth = Math.max(0, snapshot() - baseline);
|
||||
assertTrue(
|
||||
growth <= maximumBytes,
|
||||
() -> "fuzz target retained " + growth + " bytes; limit is " + maximumBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Preface, empty SETTINGS, then two request HEADERS sections on stream 1 after END_STREAM.
|
||||
505249202a20485454502f322e300d0a0d0a534d0d0a0d0a
|
||||
000000040000000000
|
||||
00000401050000000182868481
|
||||
00000401050000000182868481
|
||||
@@ -0,0 +1,2 @@
|
||||
# Complete client preface with byte 10 changed from '/' (2f) to '.' (2e).
|
||||
505249202a20485454502e322e300d0a0d0a534d0d0a0d0a
|
||||
@@ -0,0 +1,5 @@
|
||||
# Preface, empty SETTINGS, valid request on stream 3, then a never-opened lower stream 1.
|
||||
505249202a20485454502f322e300d0a0d0a534d0d0a0d0a
|
||||
000000040000000000
|
||||
00000401050000000382868481
|
||||
00000401050000000182868481
|
||||
Reference in New Issue
Block a user