test(core): add HTTP/2 compliance suite
This commit is contained in:
@@ -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