feat(core): HTTP/2 Phase 5 — frame layer
Implements HTTP/2 frame reading, validation, and writing: FrameType (the 10 RFC 9113 types + per-type validation descriptor), FrameFlags (with the deliberate END_STREAM/ACK bit collision documented), FrameHeader (a flyweight, never allocated per frame), Http2FrameReader (length-prefixed reader over BufferedByteSource, mirroring RequestParser's buffer/ compaction discipline), FrameValidator (table-driven, specific RFC error code per violation -- not a uniform code per type), Padding (RFC 9113 6.1/6.2), and FrameWriteBuffer (beginFrame/endFrame length back-patching over Phase 4's ByteWriter). All 10 frame types round-trip correctly; every RFC-mandated rejection has its own test asserting the specific error code; the reader is fuzz-tested against 10,000,000 random inputs (~14s). The zero-alloc contract is measured, not asserted: reading + validating + consuming a frame is 0.002 B/op, writing one is ~10^-4 B/op -- both indistinguishable from zero (DEC-21). Found and fixed EX-37 while writing Http2FrameReaderTest: BufferedByteSource's deadline mechanism (EX-07's actual fix) NPE'd against a null socket, which every isolated unit test in this codebase uses -- it had zero dedicated test coverage of its own. Fixed to treat a null socket as "no OS-level timeout to bound" rather than a misuse, and given BufferedByteSourceTest, which did not exist before. 449/449 tests green, both with and without -Pjmh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
704a00a551
commit
0e1bbed42c
@@ -0,0 +1,181 @@
|
||||
package dev.relism.flash.h2.frame;
|
||||
|
||||
import dev.relism.flash.h2.Http2ErrorCode;
|
||||
import dev.relism.flash.h2.Http2Exception;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** One test per RFC-mandated rejection, asserting the specific {@link Http2ErrorCode} — not merely that {@link Http2Exception} was thrown. */
|
||||
class FrameValidatorTest {
|
||||
|
||||
private static byte[] rawFrame(int length, int typeCode, int flags, int streamId) {
|
||||
byte[] buf = new byte[9];
|
||||
buf[0] = (byte) (length >>> 16);
|
||||
buf[1] = (byte) (length >>> 8);
|
||||
buf[2] = (byte) length;
|
||||
buf[3] = (byte) typeCode;
|
||||
buf[4] = (byte) flags;
|
||||
buf[5] = (byte) (streamId >>> 24);
|
||||
buf[6] = (byte) (streamId >>> 16);
|
||||
buf[7] = (byte) (streamId >>> 8);
|
||||
buf[8] = (byte) streamId;
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static FrameHeader headerOf(int length, FrameType type, int flags, int streamId) {
|
||||
byte[] buf = rawFrame(length, type.code(), flags, streamId);
|
||||
FrameHeader header = new FrameHeader();
|
||||
// reset() is package-private; same package as this test.
|
||||
header.reset(buf, 0);
|
||||
return header;
|
||||
}
|
||||
|
||||
private static Http2ErrorCode codeOf(FrameHeader header, boolean insideHeaderBlock) {
|
||||
Http2Exception ex = assertThrows(Http2Exception.class, () -> FrameValidator.validate(header, insideHeaderBlock));
|
||||
return ex.errorCode();
|
||||
}
|
||||
|
||||
// ── Length bounds, per type ──────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void ping_wrongLength_isFrameSizeError() {
|
||||
FrameHeader h = headerOf(7, FrameType.PING, 0, 0);
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rstStream_wrongLength_isFrameSizeError() {
|
||||
FrameHeader h = headerOf(3, FrameType.RST_STREAM, 0, 1);
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void windowUpdate_wrongLength_isFrameSizeError() {
|
||||
FrameHeader h = headerOf(5, FrameType.WINDOW_UPDATE, 0, 1);
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void priority_wrongLength_isFrameSizeError() {
|
||||
FrameHeader h = headerOf(4, FrameType.PRIORITY, 0, 1);
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void goaway_tooShort_isFrameSizeError() {
|
||||
FrameHeader h = headerOf(7, FrameType.GOAWAY, 0, 0);
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void goaway_exactlyEightBytes_isValid() {
|
||||
FrameHeader h = headerOf(8, FrameType.GOAWAY, 0, 0);
|
||||
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settings_notMultipleOfSix_isFrameSizeError() {
|
||||
FrameHeader h = headerOf(7, FrameType.SETTINGS, 0, 0);
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settings_multipleOfSix_isValid() {
|
||||
FrameHeader h = headerOf(12, FrameType.SETTINGS, 0, 0);
|
||||
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void settings_zeroLength_isValid() {
|
||||
// An empty SETTINGS frame (0 entries) is legal -- e.g. the initial connection SETTINGS
|
||||
// with no non-default values, or a SETTINGS ACK.
|
||||
FrameHeader h = headerOf(0, FrameType.SETTINGS, FrameFlags.ACK, 0);
|
||||
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
|
||||
}
|
||||
|
||||
// ── Stream id rules ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void settings_nonZeroStreamId_isProtocolError() {
|
||||
FrameHeader h = headerOf(0, FrameType.SETTINGS, 0, 1);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ping_nonZeroStreamId_isProtocolError() {
|
||||
FrameHeader h = headerOf(8, FrameType.PING, 0, 3);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void goaway_nonZeroStreamId_isProtocolError() {
|
||||
FrameHeader h = headerOf(8, FrameType.GOAWAY, 0, 5);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void data_zeroStreamId_isProtocolError() {
|
||||
FrameHeader h = headerOf(0, FrameType.DATA, 0, 0);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headers_zeroStreamId_isProtocolError() {
|
||||
FrameHeader h = headerOf(0, FrameType.HEADERS, 0, 0);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rstStream_zeroStreamId_isProtocolError() {
|
||||
FrameHeader h = headerOf(4, FrameType.RST_STREAM, 0, 0);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void windowUpdate_zeroStreamId_isValid_connectionWindow() {
|
||||
FrameHeader h = headerOf(4, FrameType.WINDOW_UPDATE, 0, 0);
|
||||
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void windowUpdate_nonZeroStreamId_isValid_streamWindow() {
|
||||
FrameHeader h = headerOf(4, FrameType.WINDOW_UPDATE, 0, 9);
|
||||
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
|
||||
}
|
||||
|
||||
// ── PUSH_PROMISE from a client ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void pushPromise_fromClient_isAlwaysProtocolError() {
|
||||
FrameHeader h = headerOf(4, FrameType.PUSH_PROMISE, 0, 1);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false));
|
||||
}
|
||||
|
||||
// ── Unknown frame types ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void unknownType_outsideHeaderBlock_isIgnoredNotRejected() {
|
||||
byte[] buf = rawFrame(3, 0x20, 0, 1); // 0x20 is not a recognised type
|
||||
FrameHeader h = new FrameHeader();
|
||||
h.reset(buf, 0);
|
||||
assertNull(h.type());
|
||||
assertDoesNotThrow(() -> FrameValidator.validate(h, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownType_insideHeaderBlock_isProtocolError() {
|
||||
byte[] buf = rawFrame(3, 0x20, 0, 1);
|
||||
FrameHeader h = new FrameHeader();
|
||||
h.reset(buf, 0);
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, true));
|
||||
}
|
||||
|
||||
// ── Frame-size ceiling ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void declaredLengthAboveMaxFrameSize_isFrameSizeError() {
|
||||
FrameHeader h = headerOf(dev.relism.flash.h2.Http2Limits.MAX_FRAME_SIZE_LOCAL + 1, FrameType.DATA, 0, 1);
|
||||
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package dev.relism.flash.h2.frame;
|
||||
|
||||
import dev.relism.flash.h2.Http2Exception;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* Phase 5's DoD: "Fuzz test green for 10 million random inputs." Throws fully random bytes at
|
||||
* {@link Http2FrameReader} and asserts that only a typed, expected outcome ever results: a
|
||||
* {@link Http2Exception} (a declared length exceeding {@code MAX_FRAME_SIZE_LOCAL} — the
|
||||
* overwhelmingly common outcome, since a random 24-bit length is astronomically likely to
|
||||
* exceed 16384), an {@link EOFException} (the random input ran out before a full frame arrived
|
||||
* — the second most common outcome, since fuzz inputs are deliberately small), or a
|
||||
* {@link SocketTimeoutException} (never actually expected here — no deadline is short enough to
|
||||
* trip against an in-memory stream — but a legal outcome of the API's own contract). Anything
|
||||
* else escaping — {@code ArrayIndexOutOfBoundsException}, {@code NegativeArraySizeException},
|
||||
* {@code OutOfMemoryError}, or simply never returning — fails the test.
|
||||
*/
|
||||
class Http2FrameReaderFuzzTest {
|
||||
|
||||
private static final int TRIALS = 10_000_000;
|
||||
private static final int MAX_INPUT_LEN = 64;
|
||||
|
||||
@Test
|
||||
void fuzz_10MillionRandomInputs_onlyTypedOutcomesEscape() {
|
||||
Random rnd = new Random(0x4855_3244_5F46_5A32L);
|
||||
byte[] data = new byte[MAX_INPUT_LEN];
|
||||
|
||||
for (int trial = 0; trial < TRIALS; trial++) {
|
||||
int len = rnd.nextInt(MAX_INPUT_LEN + 1);
|
||||
for (int i = 0; i < len; i++) data[i] = (byte) rnd.nextInt(256);
|
||||
|
||||
BufferedByteSource src = new BufferedByteSource(
|
||||
new ByteArrayInputStream(data, 0, len), null, 128);
|
||||
Http2FrameReader reader = new Http2FrameReader(src, 128);
|
||||
|
||||
try {
|
||||
FrameHeader header = reader.readFrame();
|
||||
if (header != null) {
|
||||
reader.consumeFrame();
|
||||
}
|
||||
} catch (Http2Exception | EOFException | SocketTimeoutException expected) {
|
||||
// any of these three is a correctly-typed rejection of malformed/truncated input
|
||||
} catch (IOException e) {
|
||||
fail("unexpected IOException at trial " + trial + " (len=" + len + "): " + e, e);
|
||||
} catch (RuntimeException e) {
|
||||
fail("unexpected RuntimeException at trial " + trial + " (len=" + len + "): " + e, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package dev.relism.flash.h2.frame;
|
||||
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
import dev.relism.flash.h2.Http2Exception;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class Http2FrameReaderTest {
|
||||
|
||||
private static BufferedByteSource sourceOf(byte[] bytes) {
|
||||
return new BufferedByteSource(new ByteArrayInputStream(bytes), null);
|
||||
}
|
||||
|
||||
private static byte[] buildFrame(FrameType type, int flags, int streamId, byte[] payload) {
|
||||
FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(32));
|
||||
out.beginFrame(type, flags, streamId);
|
||||
out.writer().writeBytes(payload);
|
||||
out.endFrame();
|
||||
byte[] result = new byte[out.writer().length()];
|
||||
System.arraycopy(out.writer().array(), 0, result, 0, result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Round trip every frame type ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void roundTrip_everyFrameType() throws IOException {
|
||||
for (FrameType type : FrameType.values()) {
|
||||
int payloadLen = switch (type) {
|
||||
case PING -> 8;
|
||||
case RST_STREAM, WINDOW_UPDATE -> 4;
|
||||
case PRIORITY -> 5;
|
||||
case GOAWAY -> 8;
|
||||
default -> 10;
|
||||
};
|
||||
byte[] payload = new byte[payloadLen];
|
||||
for (int i = 0; i < payloadLen; i++) payload[i] = (byte) (i + 1);
|
||||
int streamId = type.streamIdRule() == FrameType.StreamIdRule.FORBIDDEN ? 0 : 7;
|
||||
|
||||
byte[] wire = buildFrame(type, 0x1, streamId, payload);
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
|
||||
FrameHeader header = reader.readFrame();
|
||||
|
||||
assertNotNull(header, "type=" + type);
|
||||
assertEquals(type, header.type());
|
||||
assertEquals(type.code(), header.typeCode());
|
||||
assertEquals(payloadLen, header.length());
|
||||
assertEquals(streamId, header.streamId());
|
||||
assertEquals(0x1, header.flags());
|
||||
for (int i = 0; i < payloadLen; i++) {
|
||||
assertEquals(payload[i], header.buffer()[header.payloadOffset() + i], "byte " + i + " of type " + type);
|
||||
}
|
||||
reader.consumeFrame();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boundary lengths ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void boundaryLengths_0_1_16383_16384_16385() throws IOException {
|
||||
int[] lengths = {0, 1, 16383, 16384, 16385};
|
||||
for (int len : lengths) {
|
||||
byte[] payload = new byte[len];
|
||||
byte[] wire = buildFrame(FrameType.DATA, 0, 1, payload);
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
|
||||
if (len > dev.relism.flash.h2.Http2Limits.MAX_FRAME_SIZE_LOCAL) {
|
||||
Http2Exception ex = assertThrows(Http2Exception.class, reader::readFrame);
|
||||
assertEquals(dev.relism.flash.h2.Http2ErrorCode.FRAME_SIZE_ERROR, ex.errorCode());
|
||||
} else {
|
||||
FrameHeader header = reader.readFrame();
|
||||
assertNotNull(header);
|
||||
assertEquals(len, header.length());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── A frame split across multiple socket reads ─────────────────────────
|
||||
|
||||
private static final class DribblingInputStream extends InputStream {
|
||||
private final byte[] data;
|
||||
private int pos;
|
||||
private final int chunkSize;
|
||||
|
||||
DribblingInputStream(byte[] data, int chunkSize) {
|
||||
this.data = data;
|
||||
this.chunkSize = chunkSize;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
return pos < data.length ? (data[pos++] & 0xFF) : -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] dst, int off, int len) {
|
||||
if (pos >= data.length) return -1;
|
||||
int n = Math.min(chunkSize, Math.min(len, data.length - pos));
|
||||
System.arraycopy(data, pos, dst, off, n);
|
||||
pos += n;
|
||||
return n;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void frameSplitAcrossThreeSocketReads() throws IOException {
|
||||
byte[] payload = new byte[300];
|
||||
for (int i = 0; i < payload.length; i++) payload[i] = (byte) i;
|
||||
byte[] wire = buildFrame(FrameType.DATA, 0, 3, payload);
|
||||
|
||||
// 9(header) + 300(payload) = 309 bytes, dribbled in chunks of 103 -> 3 reads.
|
||||
int chunk = (wire.length + 2) / 3;
|
||||
BufferedByteSource src = new BufferedByteSource(new DribblingInputStream(wire, chunk), null);
|
||||
Http2FrameReader reader = new Http2FrameReader(src);
|
||||
FrameHeader header = reader.readFrame();
|
||||
|
||||
assertNotNull(header);
|
||||
assertEquals(300, header.length());
|
||||
for (int i = 0; i < 300; i++) {
|
||||
assertEquals(payload[i], header.buffer()[header.payloadOffset() + i]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── A frame exactly filling the initial buffer ──────────────────────────
|
||||
|
||||
@Test
|
||||
void frameExactlyFillingInitialBuffer() throws IOException {
|
||||
int bufSize = 64;
|
||||
byte[] payload = new byte[bufSize - 9]; // header + payload == bufSize exactly
|
||||
byte[] wire = buildFrame(FrameType.DATA, 0, 1, payload);
|
||||
assertEquals(bufSize, wire.length);
|
||||
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire), bufSize);
|
||||
FrameHeader header = reader.readFrame();
|
||||
assertNotNull(header);
|
||||
assertEquals(payload.length, header.length());
|
||||
}
|
||||
|
||||
// ── Multiple frames on one connection, sequential reads ─────────────────
|
||||
|
||||
@Test
|
||||
void multipleFramesSequentially() throws IOException {
|
||||
ByteWriter w = new ByteWriter(64);
|
||||
FrameWriteBuffer out = new FrameWriteBuffer(w);
|
||||
out.beginFrame(FrameType.PING, 0, 0);
|
||||
out.writer().writeBytes(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
|
||||
out.endFrame();
|
||||
out.beginFrame(FrameType.PING, dev.relism.flash.h2.frame.FrameFlags.ACK, 0);
|
||||
out.writer().writeBytes(new byte[]{8, 7, 6, 5, 4, 3, 2, 1});
|
||||
out.endFrame();
|
||||
byte[] wire = new byte[w.length()];
|
||||
System.arraycopy(w.array(), 0, wire, 0, wire.length);
|
||||
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
|
||||
FrameHeader first = reader.readFrame();
|
||||
assertEquals(1, first.buffer()[first.payloadOffset()]);
|
||||
assertEquals(0, first.flags());
|
||||
reader.consumeFrame();
|
||||
|
||||
FrameHeader second = reader.readFrame();
|
||||
assertEquals(8, second.buffer()[second.payloadOffset()]);
|
||||
assertEquals(FrameFlags.ACK, second.flags());
|
||||
reader.consumeFrame();
|
||||
|
||||
assertNull(reader.readFrame()); // clean EOF after both frames consumed
|
||||
}
|
||||
|
||||
// ── EOF handling ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void cleanEofBetweenFrames_returnsNull() throws IOException {
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(new byte[0]));
|
||||
assertNull(reader.readFrame());
|
||||
}
|
||||
|
||||
@Test
|
||||
void eofMidFrame_throwsEOFException() {
|
||||
byte[] wire = buildFrame(FrameType.DATA, 0, 1, new byte[100]);
|
||||
byte[] truncated = new byte[50]; // header + partial payload
|
||||
System.arraycopy(wire, 0, truncated, 0, 50);
|
||||
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(truncated));
|
||||
assertThrows(EOFException.class, reader::readFrame);
|
||||
}
|
||||
|
||||
@Test
|
||||
void eofMidHeader_throwsEOFException() {
|
||||
byte[] truncated = new byte[5]; // fewer than the 9 header bytes
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(truncated));
|
||||
assertThrows(EOFException.class, reader::readFrame);
|
||||
}
|
||||
|
||||
// ── Reserved bit masking ─────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void reservedBitInStreamId_isMaskedNotRejected() throws IOException {
|
||||
byte[] wire = buildFrame(FrameType.DATA, 0, 5, new byte[]{1, 2, 3});
|
||||
wire[5] |= (byte) 0x80; // set the reserved high bit of the stream-id field
|
||||
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
|
||||
FrameHeader header = reader.readFrame();
|
||||
assertEquals(5, header.streamId(), "reserved bit must be masked, not folded into the stream id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package dev.relism.flash.h2.frame;
|
||||
|
||||
import dev.relism.flash.h2.Http2ErrorCode;
|
||||
import dev.relism.flash.h2.Http2Exception;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PaddingTest {
|
||||
|
||||
@Test
|
||||
void notPadded_returnsWholePayloadUnchanged() {
|
||||
byte[] buf = {1, 2, 3, 4, 5};
|
||||
long r = Padding.unpad(buf, 1, 4, false);
|
||||
assertEquals(1, Padding.dataOffset(r));
|
||||
assertEquals(4, Padding.dataLength(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_zeroPadLength_allBytesAreData() {
|
||||
// [padLength=0][data...]
|
||||
byte[] buf = {0, 10, 20, 30};
|
||||
long r = Padding.unpad(buf, 0, 4, true);
|
||||
assertEquals(1, Padding.dataOffset(r));
|
||||
assertEquals(3, Padding.dataLength(r));
|
||||
assertEquals(10, buf[Padding.dataOffset(r)]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_someData_somePadding() {
|
||||
// [padLength=2][data: 3 bytes][padding: 2 bytes] -> payload length 6
|
||||
byte[] buf = {2, 7, 8, 9, 0, 0};
|
||||
long r = Padding.unpad(buf, 0, 6, true);
|
||||
assertEquals(1, Padding.dataOffset(r));
|
||||
assertEquals(3, Padding.dataLength(r));
|
||||
assertEquals(7, buf[Padding.dataOffset(r)]);
|
||||
assertEquals(9, buf[Padding.dataOffset(r) + 2]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_allPaddingNoData() {
|
||||
// [padLength=3][padding x3] -> payload length 4, dataLength 0
|
||||
byte[] buf = {3, 0, 0, 0};
|
||||
long r = Padding.unpad(buf, 0, 4, true);
|
||||
assertEquals(0, Padding.dataLength(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_atNonZeroOffset_withinLargerBuffer() {
|
||||
byte[] buf = {(byte) 0xFF, (byte) 0xFF, 1, 5, 6, 0, (byte) 0xFF};
|
||||
// payload starts at index 2, length 4: [padLength=1][data:5,6][padding:1]
|
||||
long r = Padding.unpad(buf, 2, 4, true);
|
||||
assertEquals(3, Padding.dataOffset(r));
|
||||
assertEquals(2, Padding.dataLength(r));
|
||||
assertEquals(5, buf[Padding.dataOffset(r)]);
|
||||
assertEquals(6, buf[Padding.dataOffset(r) + 1]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_zeroPayloadLength_isProtocolError() {
|
||||
byte[] buf = {};
|
||||
Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 0, true));
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_padLengthEqualsPayloadLength_isProtocolError() {
|
||||
// payloadLength=3, claimed padLength=3 -- leaves -1 bytes for data, invalid.
|
||||
byte[] buf = {3, 0, 0};
|
||||
Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 3, true));
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_padLengthGreaterThanPayloadLength_isProtocolError() {
|
||||
byte[] buf = {(byte) 255, 0, 0};
|
||||
Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 3, true));
|
||||
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void padded_maxValidPadLength_leavesZeroData() {
|
||||
// payloadLength=5: [padLength=4][padding x4] -- valid, dataLength 0.
|
||||
byte[] buf = {4, 0, 0, 0, 0};
|
||||
long r = Padding.unpad(buf, 0, 5, true);
|
||||
assertEquals(0, Padding.dataLength(r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package dev.relism.flash.transport;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-37}: this class previously had zero dedicated tests — its deadline mechanism (the
|
||||
* actual {@code EX-07} slowloris fix) was exercised only indirectly through real-socket,
|
||||
* end-to-end tests, which never hit the {@code null}-socket path every isolated unit test in
|
||||
* this codebase actually uses. Found and fixed while building {@code Http2FrameReaderTest}
|
||||
* (Phase 5); this class closes the gap.
|
||||
*/
|
||||
class BufferedByteSourceTest {
|
||||
|
||||
private static BufferedByteSource sourceOf(String s) {
|
||||
return new BufferedByteSource(new ByteArrayInputStream(s.getBytes(StandardCharsets.US_ASCII)), null);
|
||||
}
|
||||
|
||||
// ── Plain InputStream passthrough ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void read_singleByte() throws IOException {
|
||||
BufferedByteSource src = sourceOf("AB");
|
||||
assertEquals('A', src.read());
|
||||
assertEquals('B', src.read());
|
||||
assertEquals(-1, src.read());
|
||||
}
|
||||
|
||||
@Test
|
||||
void read_intoArray() throws IOException {
|
||||
BufferedByteSource src = sourceOf("hello world");
|
||||
byte[] buf = new byte[5];
|
||||
int n = src.read(buf, 0, 5);
|
||||
assertEquals(5, n);
|
||||
assertEquals("hello", new String(buf, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
@Test
|
||||
void read_largerThanInternalBuffer_bypassesBufferCorrectly() throws IOException {
|
||||
String big = "x".repeat(20_000);
|
||||
BufferedByteSource src = new BufferedByteSource(
|
||||
new ByteArrayInputStream(big.getBytes(StandardCharsets.US_ASCII)), null, 4096);
|
||||
byte[] out = new byte[20_000];
|
||||
int total = 0;
|
||||
while (total < out.length) {
|
||||
int n = src.read(out, total, out.length - total);
|
||||
if (n < 0) break;
|
||||
total += n;
|
||||
}
|
||||
assertEquals(20_000, total);
|
||||
}
|
||||
|
||||
// ── peek / prependOnce ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void peek_doesNotConsume() throws IOException {
|
||||
BufferedByteSource src = sourceOf("abcdef");
|
||||
byte[] dst = new byte[3];
|
||||
int n = src.peek(dst, 0, 3);
|
||||
assertEquals(3, n);
|
||||
assertEquals("abc", new String(dst, StandardCharsets.US_ASCII));
|
||||
// Still readable from the start — peek must not have advanced the position.
|
||||
assertEquals('a', src.read());
|
||||
assertEquals('b', src.read());
|
||||
}
|
||||
|
||||
@Test
|
||||
void peek_rejectsLengthAboveBufferCapacity() {
|
||||
BufferedByteSource src = new BufferedByteSource(new ByteArrayInputStream(new byte[0]), null, 16);
|
||||
assertThrows(IllegalArgumentException.class, () -> src.peek(new byte[20], 0, 20));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prependOnce_servedBeforeUnderlyingBytes() throws IOException {
|
||||
BufferedByteSource src = sourceOf("world");
|
||||
byte[] prefix = "hello ".getBytes(StandardCharsets.US_ASCII);
|
||||
src.prependOnce(prefix, 0, prefix.length);
|
||||
|
||||
byte[] out = new byte[11];
|
||||
int total = 0;
|
||||
while (total < out.length) {
|
||||
int n = src.read(out, total, out.length - total);
|
||||
if (n < 0) break;
|
||||
total += n;
|
||||
}
|
||||
assertEquals("hello world", new String(out, 0, total, StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
@Test
|
||||
void prependOnce_rejectsSecondCallBeforeFirstIsConsumed() {
|
||||
BufferedByteSource src = sourceOf("x");
|
||||
byte[] a = "a".getBytes(StandardCharsets.US_ASCII);
|
||||
src.prependOnce(a, 0, 1);
|
||||
assertThrows(IllegalStateException.class, () -> src.prependOnce(a, 0, 1));
|
||||
}
|
||||
|
||||
// ── Deadline mechanism, EX-37's actual regression coverage ──────────────
|
||||
|
||||
@Test
|
||||
void clearDeadline_withNullSocket_doesNotThrow() throws IOException {
|
||||
BufferedByteSource src = sourceOf("data");
|
||||
src.setDeadline(System.nanoTime() + 1_000_000_000L);
|
||||
assertDoesNotThrow(src::clearDeadline);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadlineAlreadyExpired_throwsSocketTimeoutException_evenWithNullSocket() {
|
||||
BufferedByteSource src = sourceOf(""); // empty: forces fillFromUnderlying on the next read
|
||||
src.setDeadline(System.nanoTime() - 1_000_000_000L); // already in the past
|
||||
assertThrows(SocketTimeoutException.class, () -> src.read(new byte[1], 0, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deadlineNotYetExpired_readsNormally_withNullSocket() throws IOException {
|
||||
BufferedByteSource src = sourceOf("z");
|
||||
src.setDeadline(System.nanoTime() + 30_000_000_000L); // 30s in the future
|
||||
assertEquals('z', src.read());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bytesAlreadyBuffered_areServedRegardlessOfDeadline() throws IOException {
|
||||
// peek() fills the internal buffer without a deadline; a since-expired deadline must not
|
||||
// block already-buffered bytes from being read (only underlying-stream reads are bounded).
|
||||
BufferedByteSource src = sourceOf("buffered");
|
||||
src.peek(new byte[8], 0, 8);
|
||||
src.setDeadline(System.nanoTime() - 1); // already expired
|
||||
assertEquals('b', src.read()); // served from the buffer — no underlying read needed
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearDeadline_thenRead_neverThrowsTimeoutAfterward() throws IOException {
|
||||
BufferedByteSource src = sourceOf("ok");
|
||||
src.setDeadline(System.nanoTime() - 1); // expired
|
||||
src.clearDeadline();
|
||||
assertEquals('o', src.read()); // deadline cleared — must not time out
|
||||
}
|
||||
|
||||
// ── available / skip / close ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void skip_advancesPastBufferedAndUnderlyingBytes() throws IOException {
|
||||
BufferedByteSource src = sourceOf("abcdef");
|
||||
long skipped = src.skip(3);
|
||||
assertEquals(3, skipped);
|
||||
assertEquals('d', src.read());
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_delegatesToUnderlyingStream() {
|
||||
java.io.InputStream[] closed = new java.io.InputStream[1];
|
||||
java.io.InputStream in = new ByteArrayInputStream(new byte[0]) {
|
||||
@Override public void close() throws IOException { closed[0] = this; super.close(); }
|
||||
};
|
||||
BufferedByteSource src = new BufferedByteSource(in, null);
|
||||
assertDoesNotThrow(src::close);
|
||||
assertSame(in, closed[0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user