diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 182d37d..6c7647b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,12 @@ jobs: - name: Run tests run: litac test + - name: Build h2spec conformance image + run: docker build --no-cache -f Dockerfile.h2spec -t ring-h2spec . + + - name: Run h2spec RFC 7540 conformance + run: docker run --rm --security-opt seccomp=unconfined ring-h2spec /app/entrypoint.sh + test-macos: name: Test (macOS) runs-on: macos-latest diff --git a/Dockerfile.h2spec b/Dockerfile.h2spec new file mode 100644 index 0000000..22790e3 --- /dev/null +++ b/Dockerfile.h2spec @@ -0,0 +1,68 @@ +# Build ringhttp and run h2spec (RFC 7540 conformance) against it. +# +# docker build -f Dockerfile.h2spec -t ring-h2spec . +# docker run --rm --security-opt seccomp=unconfined ring-h2spec + +FROM ubuntu:24.04 AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + git \ + curl \ + liburing-dev \ + libcurl4-openssl-dev \ + libssl-dev \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Two-stage litac bootstrap (same as Dockerfile.test) +RUN git clone https://github.com/tonysparks/litac-lang /opt/litac-lang + +RUN gcc -O2 -o /usr/local/bin/litac_bootstrap \ + /opt/litac-lang/bootstrap/litac_linux.c \ + -D_CRT_SECURE_NO_WARNINGS -D_DEFAULT_SOURCE \ + -I/opt/litac-lang/include \ + -I/opt/litac-lang/stdlib/std/http/libcurl/include \ + -L/opt/litac-lang/lib \ + -lm -lrt -lpthread -lcurl + +ENV LITAC_HOME=/opt/litac-lang + +RUN mkdir -p /opt/litac-lang/bin/output \ + && cd /opt/litac-lang \ + && litac_bootstrap build \ + && cp /opt/litac-lang/bin/output/litac /usr/local/bin/litac + +WORKDIR /build +COPY . . +RUN litac install + +# Build the h2spec server (swap in the dedicated entry point) +RUN cp test/h2spec_server_main.lita src/main.lita && litac build + +# Build h2spec from source — no ARM64 pre-built binary exists for v2.6.0. +FROM golang:1.22-bookworm AS h2spec-builder +RUN git clone --depth 1 --branch v2.6.0 https://github.com/summerwind/h2spec.git /tmp/h2spec \ + && cd /tmp/h2spec \ + && go build -o /usr/local/bin/h2spec ./cmd/h2spec \ + && rm -rf /tmp/h2spec + +# ── runtime ────────────────────────────────────────────────────────────────── +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + liburing2 \ + netcat-openbsd \ + && rm -rf /var/lib/apt/lists/* + +EXPOSE 9090 + +WORKDIR /app +COPY --from=builder /build/bin/ring ./ring +COPY --from=h2spec-builder /usr/local/bin/h2spec /usr/local/bin/h2spec +COPY h2spec_entrypoint.sh ./entrypoint.sh +RUN chmod +x ./ring ./entrypoint.sh + +# Default: run the ring server so testcontainers can start + exec into it. +# For standalone use, override with: docker run ring-h2spec /app/entrypoint.sh +CMD ["/app/ring"] diff --git a/h2spec_entrypoint.sh b/h2spec_entrypoint.sh new file mode 100755 index 0000000..0bf87b5 --- /dev/null +++ b/h2spec_entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Start the ringhttp server, wait for it, run h2spec, propagate exit code. +set -euo pipefail + +PORT=9090 +WAIT_SEC=30 + +echo "==> Starting ringhttp on port $PORT..." +/app/ring & +SERVER_PID=$! + +echo "==> Waiting for server (up to ${WAIT_SEC}s)..." +for i in $(seq 1 $WAIT_SEC); do + if nc -z localhost $PORT 2>/dev/null; then + echo " Ready after ${i}s" + break + fi + if [ "$i" -eq "$WAIT_SEC" ]; then + echo "ERROR: server did not start within ${WAIT_SEC}s" + kill "$SERVER_PID" 2>/dev/null || true + exit 1 + fi + sleep 1 +done + +echo "==> Running h2spec against localhost:$PORT..." +h2spec -h localhost -p "$PORT" --timeout 10 +EXIT_CODE=$? + +kill "$SERVER_PID" 2>/dev/null || true +echo "==> h2spec exited with code $EXIT_CODE" +exit "$EXIT_CODE" diff --git a/run_h2spec.sh b/run_h2spec.sh new file mode 100755 index 0000000..ce582cb --- /dev/null +++ b/run_h2spec.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Build and run the h2spec conformance suite against ringhttp. +# Usage: ./run_h2spec.sh [--no-cache] +# +# Requires --security-opt seccomp=unconfined for io_uring syscalls. +# Exit code mirrors h2spec: 0 = all passed, non-zero = failures. + +set -euo pipefail + +IMAGE=ring-h2spec +NO_CACHE= + +if [[ "${1:-}" == "--no-cache" ]]; then + NO_CACHE="--no-cache" +fi + +echo "==> Building h2spec image..." +docker build $NO_CACHE -f Dockerfile.h2spec -t "$IMAGE" . + +echo "==> Running h2spec..." +docker run --rm --security-opt seccomp=unconfined "$IMAGE" /app/entrypoint.sh diff --git a/src/http2_connection.lita b/src/http2_connection.lita index 81d4487..970c446 100644 --- a/src/http2_connection.lita +++ b/src/http2_connection.lita @@ -1,12 +1,5 @@ // HTTP/2 connection-level state machine (RFC 7540 §3–6). // -// Phase 2 scope (builds on Phase 1): -// - Full settings handshake (parses client SETTINGS payload) -// - HEADERS / CONTINUATION frame accumulation -// - DATA frame accumulation -// - RST_STREAM and WINDOW_UPDATE handling -// - Enqueues ready streams for concurrent dispatch -// // All frame I/O is buffer-based: no socket calls inside this file. // Route dispatch is done by the worker after process() returns. @@ -26,6 +19,20 @@ import "http2_stream" import "http_common" import "http_request" +// --------------------------------------------------------------------------- +// Byte-order helpers +// --------------------------------------------------------------------------- + +func beU32(p: *u8) : u32 { + return ((p[0] as (u32)) << 24) | ((p[1] as (u32)) << 16) | + ((p[2] as (u32)) << 8) | (p[3] as (u32)) +} + +// Like beU32 but strips the reserved high bit (stream-id / dependency fields). +func beU31(p: *u8) : u32 { + return beU32(p) & 0x7fffffff_u32 +} + // --------------------------------------------------------------------------- // SETTINGS parameter identifiers (RFC 7540 §6.5.2) // --------------------------------------------------------------------------- @@ -171,17 +178,25 @@ public func (this: *Http2Connection) process( // Step 1: consume the client connection preface (first 24 bytes) // ----------------------------------------------------------------------- if(this.state == Http2ConnState.PREFACE_PENDING) { - if(readBuf.length < HTTP2_CLIENT_PREFACE.length) { - return Status.PARTIAL_REQUEST_DISPATCHING_READ + // Validate as much of the preface as we have — fail fast on mismatch + // so a truncated invalid preface (e.g. "SM\r\n\r\n") gets GOAWAY immediately + // instead of waiting for the full 24 bytes. + var checkLen = readBuf.length + if(checkLen > HTTP2_CLIENT_PREFACE.length) { + checkLen = HTTP2_CLIENT_PREFACE.length } - if(memcmp(readBuf.buffer as (*const void), HTTP2_CLIENT_PREFACE.buffer as (*const void), - HTTP2_CLIENT_PREFACE.length as (usize)) != 0) { + checkLen as (usize)) != 0) { Debug("HTTP/2: invalid connection preface\n") + http2WriteGoaway(writeBuf, 0_u32, HTTP2_ERR_PROTOCOL_ERROR) return Status.ERROR_PARSING_HTTP_REQUEST } + if(readBuf.length < HTTP2_CLIENT_PREFACE.length) { + return Status.PARTIAL_REQUEST_DISPATCHING_READ + } + var consumed = HTTP2_CLIENT_PREFACE.length var remaining = readBuf.length - consumed if(remaining > 0) { @@ -204,8 +219,34 @@ public func (this: *Http2Connection) process( return Status.ERROR_PARSING_HTTP_REQUEST } + // RFC 7540 §4.2: reject frames exceeding our advertised limit. + // Checked before waiting for the full payload so oversized frames + // don't stall the connection waiting for data that may never arrive. + if(hdr.length > H2_DEFAULT_MAX_FRAME_SIZE as (u32)) { + // HEADERS/CONTINUATION carry a header block → always a connection error. + // Everything else on stream 0 → connection error. + // DATA on a non-zero stream → stream error (RST_STREAM). + if(hdr.type == HTTP2_FRAME_HEADERS || + hdr.type == HTTP2_FRAME_CONTINUATION || + hdr.type == HTTP2_FRAME_PUSH_PROMISE || + hdr.streamId == 0_u32 + ) { + this.sendGoaway(writeBuf, HTTP2_ERR_FRAME_SIZE_ERROR) + return Status.ERROR_IO_ERROR + } + // Stream-level frame size error + http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_FRAME_SIZE_ERROR) + // We can't skip the payload since we don't have it — close connection + this.sendGoaway(writeBuf, HTTP2_ERR_FRAME_SIZE_ERROR) + return Status.ERROR_IO_ERROR + } + var totalLen = 9 + hdr.length as (i32) if(readBuf.length < totalLen) { + // Ensure the buffer has enough room for the rest of this frame. + if(readBuf.capacity < totalLen) { + readBuf.reserve(totalLen + 9) + } break // partial frame — wait for more data } @@ -237,8 +278,14 @@ func (this: *Http2Connection) processFrame( writeBuf: *StringBuilder ) : Status { - if(hdr.streamId > this.lastStreamId) { - this.lastStreamId = hdr.streamId + // RFC 7540 §6.10: while waiting for a CONTINUATION, ONLY a CONTINUATION + // frame (for the same stream) is permitted. Anything else is a + // connection error of type PROTOCOL_ERROR. + if(this.pendingHeadersIdx >= 0) { + if(hdr.type != HTTP2_FRAME_CONTINUATION) { + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } } if(hdr.type == HTTP2_FRAME_SETTINGS) { @@ -248,8 +295,15 @@ func (this: *Http2Connection) processFrame( return this.handlePing(hdr, payload, writeBuf) } if(hdr.type == HTTP2_FRAME_GOAWAY) { + // RFC 7540 §6.8: GOAWAY must be on stream 0. + if(hdr.streamId != 0) { + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } + // Client is shutting down — keep processing existing frames (e.g. a PING + // sent in the same burst) so we respond gracefully rather than RST-ing. Debug("HTTP/2: received GOAWAY from client\n") - return Status.ERROR_IO_ERROR + return Status.OK } if(hdr.type == HTTP2_FRAME_HEADERS) { return this.handleHeaders(hdr, payload, writeBuf) @@ -261,19 +315,24 @@ func (this: *Http2Connection) processFrame( return this.handleData(hdr, payload, writeBuf) } if(hdr.type == HTTP2_FRAME_RST_STREAM) { - return this.handleRstStream(hdr, writeBuf) + return this.handleRstStream(hdr, payload, writeBuf) } if(hdr.type == HTTP2_FRAME_WINDOW_UPDATE) { return this.handleWindowUpdate(hdr, payload, writeBuf) } if(hdr.type == HTTP2_FRAME_PRIORITY) { - // Advisory only — ignore (RFC 7540 §6.3) - return Status.OK + return this.handlePriority(hdr, payload, writeBuf) + } + if(hdr.type == HTTP2_FRAME_PUSH_PROMISE) { + // RFC 7540 §8.2.1: clients MUST NOT send PUSH_PROMISE frames. + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR } - // RFC 7540 §4.1: "Implementations MUST ignore and discard any frame that - // has a type that is unknown." This applies to both stream-0 and all - // other streams — unknown types are never a protocol error. + // RFC 7540 §4.1: unknown frame types are ignored on stream-level; on + // stream 0 they're also ignored unless they change compression state. + // §5.5: unknown extension frames MUST NOT alter connection state. + // When no CONTINUATION is pending (guarded above), ignore silently. Debug("HTTP/2: ignoring unknown frame type 0x%02x on stream %u\n", hdr.type, hdr.streamId) return Status.OK @@ -294,6 +353,11 @@ func (this: *Http2Connection) handleSettings( } if(hdr.flags & HTTP2_FLAG_ACK) { + // RFC 7540 §6.5: SETTINGS ACK MUST have an empty payload. + if(hdr.length != 0) { + this.sendGoaway(writeBuf, HTTP2_ERR_FRAME_SIZE_ERROR) + return Status.ERROR_IO_ERROR + } Debug("HTTP/2: received SETTINGS ACK from client\n") return Status.OK } @@ -308,21 +372,34 @@ func (this: *Http2Connection) handleSettings( for(var i = 0; i < numParams; i += 1) { var p = payload + i * 6 var id = ((p[0] as (i32)) << 8) | (p[1] as (i32)) - var val = (((p[2] as (u32)) << 24) | ((p[3] as (u32)) << 16) | - ((p[4] as (u32)) << 8) | (p[5] as (u32))) as (i32) + var val = beU32(p + 2) as (i32) if(id == H2_SETTINGS_HEADER_TABLE_SIZE) { - this.hpack.updateMaxSize(val) + // This is the client's decoding table size (our encoding limit). + // We don't use a dynamic table for encoding, so nothing to do. + } else if(id == H2_SETTINGS_ENABLE_PUSH) { + // RFC 7540 §6.5.2: value MUST be 0 or 1. + if(val != 0 && val != 1) { + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } } else if(id == H2_SETTINGS_INITIAL_WINDOW_SIZE) { if(val as (u32) > 0x7fffffff_u32) { this.sendGoaway(writeBuf, HTTP2_ERR_FLOW_CONTROL_ERROR) return Status.ERROR_IO_ERROR } + var delta = val - this.remoteInitialWindowSize this.remoteInitialWindowSize = val - // Update all open streams + // Update all open streams by the delta for(var s = 0; s < MAX_H2_STREAMS; s += 1) { if(this.streams[s].state != Http2StreamState.IDLE) { - this.streams[s].sendWindow = val + var newWindow = this.streams[s].sendWindow + delta + // RFC 7540 §6.9.2: overflow → FLOW_CONTROL_ERROR + if(newWindow > 0x7fffffff || newWindow < -0x7fffffff) { + this.sendGoaway(writeBuf, HTTP2_ERR_FLOW_CONTROL_ERROR) + return Status.ERROR_IO_ERROR + } + this.streams[s].sendWindow = newWindow } } } else if(id == H2_SETTINGS_MAX_CONCURRENT_STREAMS) { @@ -334,7 +411,7 @@ func (this: *Http2Connection) handleSettings( } this.remoteMaxFrameSize = val } - // ENABLE_PUSH, MAX_HEADER_LIST_SIZE: noted but not enforced + // MAX_HEADER_LIST_SIZE: noted but not enforced } http2WriteSettingsAck(writeBuf) @@ -345,6 +422,10 @@ func (this: *Http2Connection) handleSettings( Debug("HTTP/2: connection OPEN\n") } + // Unblock any streams that were waiting for a larger flow-control window. + // SETTINGS_INITIAL_WINDOW_SIZE may have increased sendWindow for open streams. + this.flushBlockedStreams(writeBuf) + return Status.OK } @@ -372,6 +453,35 @@ func (this: *Http2Connection) handlePing( return Status.OK } +// --------------------------------------------------------------------------- +// PRIORITY +// --------------------------------------------------------------------------- + +func (this: *Http2Connection) handlePriority( + hdr: *Http2FrameHeader, + payload: *u8, + writeBuf: *StringBuilder +) : Status { + // RFC 7540 §6.3: PRIORITY on stream 0 is a connection error. + if(hdr.streamId == 0) { + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } + // RFC 7540 §6.3: PRIORITY length must be exactly 5 octets. + if(hdr.length != 5) { + http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_FRAME_SIZE_ERROR) + return Status.OK + } + // RFC 7540 §5.3.1: a stream cannot depend on itself. + var dep = beU31(payload) + if(dep == hdr.streamId) { + http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_PROTOCOL_ERROR) + return Status.OK + } + // Advisory — no other action needed. + return Status.OK +} + // --------------------------------------------------------------------------- // HEADERS // --------------------------------------------------------------------------- @@ -386,13 +496,41 @@ func (this: *Http2Connection) handleHeaders( return Status.ERROR_IO_ERROR } - // RFC 7540 §5.1: new client-initiated stream IDs must be odd and increasing - if((hdr.streamId & 1_u32) == 0_u32 || hdr.streamId <= this.lastStreamId - 1_u32) { - // Could be a stream we already closed; send RST_STREAM + // RFC 7540 §5.1.1: client-initiated streams must use odd stream IDs. + if((hdr.streamId & 1_u32) == 0_u32) { + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } + + // Check if a stream with this ID already exists in our table. + var existingIdx = this.findStream(hdr.streamId) + if(existingIdx >= 0) { + var existingStream = &this.streams[existingIdx] + if(existingStream.state == Http2StreamState.OPEN) { + if(hdr.flags & HTTP2_FLAG_END_STREAM) { + // Trailer HEADERS on an open stream: valid only if no pseudo-headers. + // We close the stream and enqueue for dispatch. + existingStream.state = Http2StreamState.HALF_CLOSED_REMOTE + this.enqueueReady(existingIdx) + return Status.OK + } + // Second HEADERS without END_STREAM on an open stream. + http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_PROTOCOL_ERROR) + return Status.OK + } + // Stream is in another non-IDLE state → STREAM_CLOSED. http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_STREAM_CLOSED) return Status.OK } + // RFC 7540 §5.1.1: stream IDs must be strictly increasing. + // A HEADERS for a stream ID ≤ lastStreamId means reuse of a previously + // opened (now closed) stream — treat as a connection error (STREAM_CLOSED). + if(hdr.streamId <= this.lastStreamId) { + this.sendGoaway(writeBuf, HTTP2_ERR_STREAM_CLOSED) + return Status.ERROR_IO_ERROR + } + // Reject if we have no free slots if(this.streamCount >= MAX_H2_STREAMS) { http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_REFUSED_STREAM) @@ -400,11 +538,15 @@ func (this: *Http2Connection) handleHeaders( } // HEADERS must not arrive while a previous stream's headers are incomplete + // (already checked in processFrame, but kept as a safety net) if(this.pendingHeadersIdx >= 0) { this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) return Status.ERROR_IO_ERROR } + // Valid new stream — update lastStreamId + this.lastStreamId = hdr.streamId + var idx = this.allocStream(hdr.streamId) if(idx < 0) { http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_REFUSED_STREAM) @@ -431,12 +573,19 @@ func (this: *Http2Connection) handleHeaders( } } - // Strip PRIORITY prefix if set (RFC 7540 §6.2) + // Strip PRIORITY prefix if set (RFC 7540 §6.2) and check self-dependency if(hdr.flags & HTTP2_FLAG_PRIORITY_F) { if(dataLen < 5) { this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) return Status.ERROR_IO_ERROR } + var dep = beU31(data) + if(dep == hdr.streamId) { + // RFC 7540 §5.3.1: self-dependency is a stream error PROTOCOL_ERROR. + http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_PROTOCOL_ERROR) + this.closeStream(idx) + return Status.OK + } data += 5 dataLen -= 5 } @@ -495,7 +644,6 @@ func (this: *Http2Connection) handleContinuation( this.pendingHeadersIdx = -1 // If the original HEADERS had END_STREAM (no body), dispatch now. - // Without this, a GET whose headers span CONTINUATION would never run. if(stream.endStream) { stream.state = Http2StreamState.HALF_CLOSED_REMOTE this.enqueueReady(this.findStream(hdr.streamId)) @@ -522,7 +670,13 @@ func (this: *Http2Connection) handleData( var idx = this.findStream(hdr.streamId) if(idx < 0) { - // Stream doesn't exist — RST_STREAM + // Stream not in our table. + if(hdr.streamId > this.lastStreamId) { + // Never opened — truly idle: RFC 7540 §5.1 says connection error PROTOCOL_ERROR. + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } + // Previously opened and closed — stream error STREAM_CLOSED. http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_STREAM_CLOSED) return Status.OK } @@ -576,6 +730,7 @@ func (this: *Http2Connection) handleData( func (this: *Http2Connection) handleRstStream( hdr: *Http2FrameHeader, + payload: *u8, writeBuf: *StringBuilder ) : Status { if(hdr.streamId == 0) { @@ -587,11 +742,16 @@ func (this: *Http2Connection) handleRstStream( return Status.ERROR_IO_ERROR } var idx = this.findStream(hdr.streamId) - if(idx >= 0) { - this.closeStream(idx) - // Any queued entry for this index will be skipped by the worker - // because closeStream sets state to IDLE. + if(idx < 0) { + if(hdr.streamId > this.lastStreamId) { + // RST_STREAM on truly idle stream — connection error PROTOCOL_ERROR. + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } + // Previously closed — ignore per RFC 7540 §6.4. + return Status.OK } + this.closeStream(idx) return Status.OK } @@ -613,10 +773,7 @@ func (this: *Http2Connection) handleWindowUpdate( return Status.OK } - var increment = (((payload[0] as (u32)) << 24) | - ((payload[1] as (u32)) << 16) | - ((payload[2] as (u32)) << 8) | - (payload[3] as (u32))) & 0x7fffffff_u32 + var increment = beU31(payload) if(increment == 0_u32) { if(hdr.streamId == 0) { @@ -628,11 +785,26 @@ func (this: *Http2Connection) handleWindowUpdate( } if(hdr.streamId == 0) { + // RFC 7540 §6.9.1: connection-level window must not exceed 2^31-1. + if(this.sendWindow > (0x7fffffff - increment as (i32))) { + this.sendGoaway(writeBuf, HTTP2_ERR_FLOW_CONTROL_ERROR) + return Status.ERROR_IO_ERROR + } this.sendWindow += increment as (i32) this.flushBlockedStreams(writeBuf) } else { + // Check for idle stream (never opened). + if(this.findStream(hdr.streamId) < 0 && hdr.streamId > this.lastStreamId) { + this.sendGoaway(writeBuf, HTTP2_ERR_PROTOCOL_ERROR) + return Status.ERROR_IO_ERROR + } var idx = this.findStream(hdr.streamId) if(idx >= 0) { + // RFC 7540 §6.9.1: stream-level window must not exceed 2^31-1. + if(this.streams[idx].sendWindow > (0x7fffffff - increment as (i32))) { + http2WriteRstStream(writeBuf, hdr.streamId, HTTP2_ERR_FLOW_CONTROL_ERROR) + return Status.OK + } this.streams[idx].sendWindow += increment as (i32) if(this.streams[idx].state == Http2StreamState.SEND_BLOCKED) { if(this.flushPendingStream(&this.streams[idx], writeBuf)) { @@ -675,7 +847,6 @@ public func (this: *Http2Connection) flushPendingStream( } // Scan all SEND_BLOCKED streams and flush whatever the windows now allow. -// Called after any WINDOW_UPDATE is processed. public func (this: *Http2Connection) flushBlockedStreams(writeBuf: *StringBuilder) { for(var i = 0; i < MAX_H2_STREAMS; i += 1) { if(this.streams[i].state == Http2StreamState.SEND_BLOCKED) { @@ -683,8 +854,9 @@ public func (this: *Http2Connection) flushBlockedStreams(writeBuf: *StringBuilde this.closeStream(i) } } - // Stop if the connection window is exhausted - if(this.sendWindow <= 0) { break } + if(this.sendWindow <= 0) { + break + } } } diff --git a/src/http2_hpack.lita b/src/http2_hpack.lita index a0fd873..03b7a92 100644 --- a/src/http2_hpack.lita +++ b/src/http2_hpack.lita @@ -197,6 +197,7 @@ public struct Hpack { dynCount: i32 dynTableSize: i32 dynMaxSize: i32 + maxAllowed: i32 // SETTINGS_HEADER_TABLE_SIZE limit from our own SETTINGS allocator: *Allocator } @@ -246,6 +247,7 @@ public func (this: *Hpack) init(allocator: *Allocator) { memset(this, 0, sizeof(:Hpack)) this.allocator = allocator this.dynMaxSize = HPACK_INITIAL_TABLE_SIZE + this.maxAllowed = HPACK_INITIAL_TABLE_SIZE this.buildTrie() } @@ -371,6 +373,8 @@ func (this: *Hpack) huffDecode( out: *StringBuilder ) : bool { var node: i32 = 0 + var bitsAfterLastSymbol: i32 = 0 + var sawZeroBit: bool = false for(var i = 0; i < inputLen; i += 1) { var byte = input[i] as (i32) for(var bit = 7; bit >= 0; bit -= 1) { @@ -379,6 +383,8 @@ func (this: *Hpack) huffDecode( if(nxt < 0) { return false } + bitsAfterLastSymbol += 1 + if(b == 0) { sawZeroBit = true } var sym = this.huffTrie[nxt].symbol as (i32) if(sym >= 0) { if(sym == 256) { @@ -387,11 +393,18 @@ func (this: *Hpack) huffDecode( var ch = sym as (char) out.appendStrn(&ch, 1) node = 0 + bitsAfterLastSymbol = 0 + sawZeroBit = false } else { node = nxt } } } + // RFC 7541 §5.2: padding bits MUST be ≤7 and MUST be the EOS prefix (all 1s). + if(node != 0) { + if(bitsAfterLastSymbol > 7) { return false } + if(sawZeroBit) { return false } + } return true } @@ -525,7 +538,6 @@ func pseudoBit(name: String) : i32 { if(name.equals($":method")) { return PSEUDO_METHOD } if(name.equals($":path")) { return PSEUDO_PATH } if(name.equals($":scheme")) { return PSEUDO_SCHEME } - if(name.equals($":status")) { return PSEUDO_STATUS } return 0 } @@ -544,8 +556,9 @@ public func (this: *Hpack) decode( pseudoError: *bool ) : Status { var pos = 0 - var seenPseudo = 0 // bitmask of pseudo-headers seen so far - var seenRegular = false // true once we see a non-pseudo header + var seenPseudo = 0 + var seenRegular = false + var seenHeader = false // any indexed/literal field seen (table size updates must precede) while(pos < payloadLen) { var first = payload[pos] as (i32) @@ -557,12 +570,19 @@ public func (this: *Hpack) decode( if(r.value < 1) { return Status.ERROR_PARSING_HTTP_REQUEST } + // RFC 7541 §2.3.3: index beyond static+dynamic table is a decoding error. + if(r.value > 61 && (r.value - 62) >= this.dynCount) { + return Status.ERROR_PARSING_HTTP_REQUEST + } var name = this.indexName(r.value) var value = this.indexValue(r.value) + seenHeader = true var bit = pseudoBit(name) if(bit != 0) { if(seenRegular || (seenPseudo & bit) != 0) { *pseudoError = true } seenPseudo |= bit + } else if(name.length > 0 && name.buffer[0] == ':') { + *pseudoError = true // response-only or unknown pseudo-header } else { seenRegular = true } @@ -574,6 +594,9 @@ public func (this: *Hpack) decode( pos = ri.newPos var name: String if(ri.value > 0) { + if(ri.value > 61 && (ri.value - 62) >= this.dynCount) { + return Status.ERROR_PARSING_HTTP_REQUEST + } name = this.indexName(ri.value) } else { var rn = this.decodeString(payload, payloadLen, pos, allocator) @@ -589,10 +612,13 @@ public func (this: *Hpack) decode( } pos = rv.newPos this.dynAdd(name, rv.value) + seenHeader = true var bit = pseudoBit(name) if(bit != 0) { if(seenRegular || (seenPseudo & bit) != 0) { *pseudoError = true } seenPseudo |= bit + } else if(name.length > 0 && name.buffer[0] == ':') { + *pseudoError = true } else { seenRegular = true } @@ -602,6 +628,14 @@ public func (this: *Hpack) decode( // §6.3 Dynamic Table Size Update var r = decodeInt(payload, payloadLen, pos + 1, 5, first) pos = r.newPos + // RFC 7541 §4.2: size updates MUST appear before any header field. + if(seenHeader) { + return Status.ERROR_PARSING_HTTP_REQUEST + } + // RFC 7541 §6.3: new size MUST NOT exceed SETTINGS_HEADER_TABLE_SIZE. + if(r.value > this.maxAllowed) { + return Status.ERROR_PARSING_HTTP_REQUEST + } this.updateMaxSize(r.value) } else { @@ -610,6 +644,9 @@ public func (this: *Hpack) decode( pos = ri.newPos var name: String if(ri.value > 0) { + if(ri.value > 61 && (ri.value - 62) >= this.dynCount) { + return Status.ERROR_PARSING_HTTP_REQUEST + } name = this.indexName(ri.value) } else { var rn = this.decodeString(payload, payloadLen, pos, allocator) @@ -624,10 +661,13 @@ public func (this: *Hpack) decode( return Status.ERROR_PARSING_HTTP_REQUEST } pos = rv.newPos + seenHeader = true var bit = pseudoBit(name) if(bit != 0) { if(seenRegular || (seenPseudo & bit) != 0) { *pseudoError = true } seenPseudo |= bit + } else if(name.length > 0 && name.buffer[0] == ':') { + *pseudoError = true } else { seenRegular = true } diff --git a/src/http_connection.lita b/src/http_connection.lita index 7f28b37..6ca0404 100644 --- a/src/http_connection.lita +++ b/src/http_connection.lita @@ -18,7 +18,7 @@ import "http_file" import "http_server" -public const READ_BUFFER_SIZE = 16 * KiB as (i32) +public const READ_BUFFER_SIZE = 32 * KiB as (i32) public const WRITE_BUFFER_SIZE = 8 * KiB as (i32) public const COMPRESSION_SIZE = 1 * KiB as (i32) diff --git a/src/http_worker.lita b/src/http_worker.lita index 45e4312..f9452a6 100644 --- a/src/http_worker.lita +++ b/src/http_worker.lita @@ -848,16 +848,53 @@ internal func (this: *WorkerThread) tryDetectProtocol( readBuffer.length = totalSoFar return false } - if(readBuffer.buffer[0] == 'P' && - readBuffer.buffer[1] == 'R' && - readBuffer.buffer[2] == 'I' - ) { + var b0 = readBuffer.buffer[0] as (u8) + var b1 = readBuffer.buffer[1] as (u8) + var b2 = readBuffer.buffer[2] as (u8) + if(b0 == 'P' && b1 == 'R' && b2 == 'I') { + // HTTP/2 prior knowledge (RFC 7540 §3.5). session.protocolVersion = ProtocolVersion.HTTP2 session.state = SessionState.HTTP2_OPEN session.http2.init(&session.requestAllocator.allocator) Debug("HTTP/2 connection detected on socket: %d\n", session.connection.handle()) - } else { + } else if(b0 == 'G' || b0 == 'D' || b0 == 'H' || b0 == 'O' || b0 == 'T' || b0 == 'C' || + (b0 == 'P' && b1 != 'R')) { + // Known HTTP/1.1 method first byte: GET, DELETE, HEAD, OPTIONS, TRACE, CONNECT, POST/PUT/PATCH. session.protocolVersion = ProtocolVersion.HTTP1 + } else { + // Unknown first byte — sniff the URL to distinguish HTTP/1.1 from an invalid HTTP/2 preface. + // HTTP/1.1 request lines are "METHOD SP url SP version CRLF" where url starts with '/'. + // Buffer up to SNIFF bytes, then look for the first space followed by '/'. + const SNIFF = 20 + var limit = totalSoFar + if(limit > SNIFF) { limit = SNIFF } + + var spaceIdx = -1 + for(var i = 0; i < limit - 1; i += 1) { + if(readBuffer.buffer[i] as (u8) == ' ' as (u8)) { + spaceIdx = i + break + } + } + + if(spaceIdx < 0) { + // No space found yet; buffer and wait for more bytes (if under SNIFF limit). + if(totalSoFar < SNIFF) { + readBuffer.length = totalSoFar + return false + } + // Exceeds SNIFF without a space — not HTTP/1.1. Fall through to HTTP/2 path. + } else if(readBuffer.buffer[spaceIdx + 1] as (u8) == '/' as (u8)) { + // Space + slash matches "METHOD /path" — treat as HTTP/1.1. + session.protocolVersion = ProtocolVersion.HTTP1 + return true + } + // Space found but URL doesn't start with '/', OR no space within SNIFF bytes — + // treat as invalid HTTP/2 connection preface (RFC 7540 §3.5): send GOAWAY. + session.protocolVersion = ProtocolVersion.HTTP2 + session.state = SessionState.HTTP2_OPEN + session.http2.init(&session.requestAllocator.allocator) + Debug("Non-HTTP/1.1 connection prefix on socket: %d — treating as invalid HTTP/2\n", session.connection.handle()) } return true } @@ -948,6 +985,15 @@ internal func (this: *WorkerThread) http2DispatchStream( return Status.OK } + // RFC 7540 §8.1.2.6: content-length MUST equal the sum of DATA frame payload lengths. + if((req.flags & HttpFlags.CONTENT_LENGTH_PROVIDED) != 0) { + if(req.bodyLength != stream.bodyBuf.length) { + http2WriteRstStream(&session.connection.writeBuffer, streamId, HTTP2_ERR_PROTOCOL_ERROR) + h2.closeStream(streamIdx) + return Status.OK + } + } + // RFC 7540 §8.1.2.2: connection-specific header fields are forbidden in H2. // connection, keep-alive, proxy-connection, transfer-encoding, upgrade, and // te (unless value is exactly "trailers") MUST be treated as stream errors. diff --git a/test/h2spec_server_main.lita b/test/h2spec_server_main.lita new file mode 100644 index 0000000..0c1a7dd --- /dev/null +++ b/test/h2spec_server_main.lita @@ -0,0 +1,31 @@ +import "std/libc" +import "std/string" +import "std/string/builder" +import "std/mem" +import "http_ring" + +func RootHandler(ctx: *RequestHandlerContext) : i32 { + ctx.response.status = 200 + ctx.response.type = ResponseType.BODY + ctx.response.body.append("OK") + return 1 +} + +func main(argc: i32, argv: **char) : i32 { + var config = HttpConfig{ + .allocator = defaultAllocator, + .port = 9090_u16, + .numThreads = 2, + .maxPoolSize = 32, + .keepAliveTimeoutInSec = 30, + .isLogEnabled = false, + } + + var server = HttpServer{} + server.init(&config) + server.addHttpController(HttpMethod.GET, "/", null, + HttpController{.callback = RootHandler}) + server.start() + defer server.close() + return 0 +} diff --git a/test/h2spec_test.lita b/test/h2spec_test.lita new file mode 100644 index 0000000..6f6bd71 --- /dev/null +++ b/test/h2spec_test.lita @@ -0,0 +1,105 @@ +// h2spec RFC 7540 conformance test for ringhttp. +// +// Starts the ring server in-process (using the native I/O backend) and runs +// h2spec from the ring-h2spec Docker image against it. +// +// The ring-h2spec image must be built before running this test: +// +// docker build -f Dockerfile.h2spec -t ring-h2spec . +// +// Run with: +// litac test -file test/h2spec_test.lita + +import "std/libc" +import "std/string/builder" +import "std/assert" +import "std/thread" +import "std/thread/barrier" +import "std/mem" +import "std/testcontainers/docker" +import "http_ring" + +const H2SPEC_PORT: u16 = 9090_u16 + +// ── Server ──────────────────────────────────────────────────────────────────── + +func h2specRootHandler(ctx: *RequestHandlerContext) : i32 { + ctx.response.status = 200 + ctx.response.type = ResponseType.BODY + ctx.response.body.append("OK") + return 1 +} + +struct H2SpecServer { + config: HttpConfig + barrier: Barrier +} + +var gServer = H2SpecServer{} +var gServerReady = false + +func serverThread(arg: *void) : i32 { + var ts = arg as (*H2SpecServer) + var server = HttpServer{} + server.init(&ts.config) + server.addHttpController(HttpMethod.GET, "/", null, + HttpController{.callback = h2specRootHandler}) + ts.barrier.wait() + server.start() + defer server.close() + return 0 +} + +func ensureServer() { + if(gServerReady) { return; } + gServer.config = HttpConfig{ + .allocator = defaultAllocator, + .port = H2SPEC_PORT, + .numThreads = 2, + .maxPoolSize = 32, + .keepAliveTimeoutInSec = 30, + .isLogEnabled = false, + } + gServer.barrier.init(2) + var t = Thread{} + assert(t.create(serverThread, &gServer) == ThreadStatus.SUCCESS) + gServer.barrier.wait() + ThreadSleepMSec(200) + gServerReady = true +} + +// ── Test ────────────────────────────────────────────────────────────────────── + +// Extracts exit code from POSIX waitpid() status (WEXITSTATUS). +func wexitstatus(s: i32) : i32 { + return (s >> 8) & 0xFF +} + +@test("h2spec_rfc7540_conformance") +func testH2SpecConformance() { + if(!DockerAvailable()) { + printf("SKIP h2spec: Docker not available\n") + return; + } + + var checkCmd = StringBuilderInit(64) + defer checkCmd.free() + checkCmd.append("docker image inspect ring-h2spec > /dev/null 2>&1") + if(wexitstatus(system(checkCmd.cStr())) != 0) { + printf("SKIP h2spec: ring-h2spec image not built.\n") + printf(" Build: docker build -f Dockerfile.h2spec -t ring-h2spec .\n") + return; + } + + ensureServer() + + // h2spec runs inside the container; the ring server runs on the host. + // host.docker.internal resolves to the host IP inside Docker Desktop containers. + var runCmd = StringBuilderInit(256) + defer runCmd.free() + runCmd.append("docker run --rm ring-h2spec h2spec -h host.docker.internal -p %d --timeout 10", + H2SPEC_PORT as (i32)) + + var exitCode = wexitstatus(system(runCmd.cStr())) + assert(exitCode == 0) +} diff --git a/test/http2_e2e_test.lita b/test/http2_e2e_test.lita index f55db70..6d85fc5 100644 --- a/test/http2_e2e_test.lita +++ b/test/http2_e2e_test.lita @@ -16,7 +16,6 @@ import "std/string/builder" import "std/net/posix_socket" import "http_ring" -@foreign func connect(sockfd: i32, addr: *sockaddr, addrlen: socklen_t) : i32; @raw(""" #include @@ -987,6 +986,7 @@ func testH2ErrorWindowUpdateZeroConnection() { // --------------------------------------------------------------------------- // HEADERS with even stream ID (RFC 7540 §5.1.1: client streams are odd). +// This is a connection error (GOAWAY), not a stream error. @test("http2_e2e_error_even_stream_id") func testH2ErrorEvenStreamId() { ensureServer() @@ -1004,18 +1004,11 @@ func testH2ErrorEvenStreamId() { assert(h2WriteAll(fd, hdr, 9)) assert(h2WriteAll(fd, hpack.buffer, hpack.length)) - var frame: H2Frame - assert(readNextFrame(fd, 0x03, &frame)) // expect RST_STREAM - assert(frame.streamId == 2_u32) - - // processFrame() updates lastStreamId=2 before rejecting; next valid stream is 3. - // Connection still alive — make a valid request on stream 3. - assert(sendHeadersFrame(fd, 3_u32, &hpack, true)) - assert(readNextFrame(fd, 0x01, &frame)) - assert(decodeResponseStatus(frame.payload, frame.length) == 200) + assert(expectGoaway(fd)) } -// DATA arrives on a stream that was never opened. +// DATA arrives on a stream that was never opened (idle state). +// RFC 7540 §5.1: MUST treat as a connection error of type PROTOCOL_ERROR. @test("http2_e2e_error_data_unknown_stream") func testH2ErrorDataUnknownStream() { ensureServer() @@ -1028,18 +1021,7 @@ func testH2ErrorDataUnknownStream() { const body = "hi" assert(sendRawFrame(fd, 2, 0x00, 0x01, 7_u32, body)) - var frame: H2Frame - assert(readNextFrame(fd, 0x03, &frame)) // expect RST_STREAM - assert(frame.streamId == 7_u32) - - // processFrame() updates lastStreamId=7 before rejecting; next valid stream is 9. - // Connection still alive. - var hpack = StringBuilderInit(64, defaultAllocator) - defer hpack.free() - buildGetHpack($"/hello", &hpack) - assert(sendHeadersFrame(fd, 9_u32, &hpack, true)) - assert(readNextFrame(fd, 0x01, &frame)) - assert(decodeResponseStatus(frame.payload, frame.length) == 200) + assert(expectGoaway(fd)) } // Stream-level WINDOW_UPDATE with increment=0 (RFC 7540 §6.9.1: RST_STREAM PROTOCOL_ERROR). diff --git a/test/http2_hpack_test.lita b/test/http2_hpack_test.lita index 59bd6ef..9cf5f9c 100644 --- a/test/http2_hpack_test.lita +++ b/test/http2_hpack_test.lita @@ -378,14 +378,10 @@ func testHpackDynamicTableSizeUpdateEvicts() { wire2[0] = 0x20_u8 // dynamic table size update → 0 wire2[1] = (0x80 | 62) as (u8) // index 62 (now empty table) + // After the table is cleared, index 62 is out of bounds — must be COMPRESSION_ERROR. var req2 = hpackRequest() var pe2 = false - hpackDecodeOk(&hpack, wire2 as (*const u8), 2, &req2, &pe2) - - // The evicted entry "x-tag" must not appear as a header. - for(var i = 0; i < req2.headers.length; i += 1) { - assert(!req2.headers.get(i).name.equals($"x-tag")) - } + assert(hpack.decode(wire2 as (*const u8), 2, &req2, defaultAllocator as (*Allocator), &pe2) != Status.OK) } // --------------------------------------------------------------------------- @@ -585,7 +581,8 @@ func testHpackEncodeResponseStaticNameRoundtrip() { hpack2.init(defaultAllocator as (*Allocator)) var req = hpackRequest() var pe = false + // encodeResponse emits :status (response-only pseudo-header), so pe will fire + // when decoded through the request decoder — that's expected and harmless here. hpackDecodeOk(&hpack2, out.buffer as (*const u8), out.length, &req, &pe) - assert(!pe) assert(req.bodyLength == 42) } diff --git a/test/http2_phase1_test.lita b/test/http2_phase1_test.lita index 65fef7c..05615e0 100644 --- a/test/http2_phase1_test.lita +++ b/test/http2_phase1_test.lita @@ -12,8 +12,6 @@ import "std/string/builder" import "std/net/posix_socket" import "http_ring" -@foreign -func connect(sockfd: i32, addr: *sockaddr, addrlen: socklen_t) : i32; @raw(""" #include diff --git a/test/http_sse_test.lita b/test/http_sse_test.lita index 97e34cf..6d63a1e 100644 --- a/test/http_sse_test.lita +++ b/test/http_sse_test.lita @@ -12,9 +12,6 @@ import "std/net/posix_socket" import "http_ring" import "test_webserver" -// connect() is not in posix_socket.lita, declare it here -@foreign -func connect(sockfd: i32, addr: *sockaddr, addrlen: socklen_t) : i32; const SSE_TEST_PORT = 19083_u16 diff --git a/test/http_websocket_test.lita b/test/http_websocket_test.lita index 9d09ede..369956f 100644 --- a/test/http_websocket_test.lita +++ b/test/http_websocket_test.lita @@ -16,9 +16,6 @@ import "std/encoding/base64" import "http_ring" import "test_webserver" -// connect() is not in posix_socket.lita, declare it here -@foreign -func connect(sockfd: i32, addr: *sockaddr, addrlen: socklen_t) : i32; @raw(""" #include diff --git a/test/main_test.lita b/test/main_test.lita index d5bfb65..d3fc982 100644 --- a/test/main_test.lita +++ b/test/main_test.lita @@ -20,9 +20,6 @@ import "std/time" import "http_ring" import "test_webserver" -// connect(2) is not exposed by std/net/posix_socket — declare it here. -@foreign -func connect(sockfd: i32, addr: *sockaddr, addrlen: socklen_t) : i32; @raw(""" #include diff --git a/test/tls_test.lita b/test/tls_test.lita index ccdf7cc..1323095 100644 --- a/test/tls_test.lita +++ b/test/tls_test.lita @@ -25,7 +25,6 @@ import "std/http/http" as stdhttp import "http_ring" -@foreign func connect(sockfd: i32, addr: *sockaddr, addrlen: socklen_t) : i32; // --------------------------------------------------------------------------- // Test server — TLS on port 9595