From cdba08e12f12444e4b0aa40c83e71c7c9508bfb5 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Sun, 24 May 2026 22:06:28 -0500 Subject: [PATCH 01/14] implement h2spec --- Dockerfile.h2spec | 68 +++++++++++++++++++++++ h2spec_entrypoint.sh | 32 +++++++++++ run_h2spec.sh | 21 ++++++++ src/h2spec_server_main.lita | 31 +++++++++++ test/h2spec_test.lita | 105 ++++++++++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 Dockerfile.h2spec create mode 100755 h2spec_entrypoint.sh create mode 100755 run_h2spec.sh create mode 100644 src/h2spec_server_main.lita create mode 100644 test/h2spec_test.lita diff --git a/Dockerfile.h2spec b/Dockerfile.h2spec new file mode 100644 index 0000000..9f81b37 --- /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 src/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/h2spec_server_main.lita b/src/h2spec_server_main.lita new file mode 100644 index 0000000..0c1a7dd --- /dev/null +++ b/src/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) +} From 32e2edfe1df62d460adf54ac7c8c9d2e519b8fb0 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Sun, 24 May 2026 22:08:13 -0500 Subject: [PATCH 02/14] fix up --- Dockerfile.h2spec | 2 +- {src => test}/h2spec_server_main.lita | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {src => test}/h2spec_server_main.lita (100%) diff --git a/Dockerfile.h2spec b/Dockerfile.h2spec index 9f81b37..22790e3 100644 --- a/Dockerfile.h2spec +++ b/Dockerfile.h2spec @@ -38,7 +38,7 @@ COPY . . RUN litac install # Build the h2spec server (swap in the dedicated entry point) -RUN cp src/h2spec_server_main.lita src/main.lita && litac build +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 diff --git a/src/h2spec_server_main.lita b/test/h2spec_server_main.lita similarity index 100% rename from src/h2spec_server_main.lita rename to test/h2spec_server_main.lita From 4047ff411030571df41bffb34a408d7d3f4c773d Mon Sep 17 00:00:00 2001 From: tonysparks Date: Sun, 24 May 2026 22:21:39 -0500 Subject: [PATCH 03/14] fix tests --- test/http2_e2e_test.lita | 1 - test/http2_phase1_test.lita | 2 -- test/http_sse_test.lita | 3 --- test/http_websocket_test.lita | 3 --- test/main_test.lita | 3 --- test/tls_test.lita | 1 - 6 files changed, 13 deletions(-) diff --git a/test/http2_e2e_test.lita b/test/http2_e2e_test.lita index f55db70..dbf54a0 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 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 From aee71ff15b6975fc531ffdbd41b8433216551478 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 10:57:09 -0500 Subject: [PATCH 04/14] Get http2spec test cases working --- src/http2_connection.lita | 226 +++++++++++++++++++++++++++++++------- src/http2_hpack.lita | 46 +++++++- src/http_connection.lita | 2 +- src/http_worker.lita | 28 ++++- 4 files changed, 256 insertions(+), 46 deletions(-) diff --git a/src/http2_connection.lita b/src/http2_connection.lita index 81d4487..684f301 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) // --------------------------------------------------------------------------- @@ -179,6 +186,7 @@ public func (this: *Http2Connection) process( HTTP2_CLIENT_PREFACE.buffer as (*const void), HTTP2_CLIENT_PREFACE.length as (usize)) != 0) { Debug("HTTP/2: invalid connection preface\n") + http2WriteGoaway(writeBuf, 0_u32, HTTP2_ERR_PROTOCOL_ERROR) return Status.ERROR_PARSING_HTTP_REQUEST } @@ -204,8 +212,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 +271,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) { @@ -261,19 +301,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 +339,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 +358,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 +397,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) @@ -372,6 +435,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 +478,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 +520,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 +555,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 +626,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 +652,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 +712,7 @@ func (this: *Http2Connection) handleData( func (this: *Http2Connection) handleRstStream( hdr: *Http2FrameHeader, + payload: *u8, writeBuf: *StringBuilder ) : Status { if(hdr.streamId == 0) { @@ -587,11 +724,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 +755,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 +767,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 +829,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,7 +836,6 @@ public func (this: *Http2Connection) flushBlockedStreams(writeBuf: *StringBuilde this.closeStream(i) } } - // Stop if the connection window is exhausted 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..0ddc7fa 100644 --- a/src/http_worker.lita +++ b/src/http_worker.lita @@ -848,16 +848,25 @@ 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] + var b1 = readBuffer.buffer[1] + var b2 = readBuffer.buffer[2] + if(b0 == 'P' && b1 == 'R' && b2 == 'I') { 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 starters: GET, DELETE, HEAD, OPTIONS, TRACE, CONNECT, POST/PUT/PATCH session.protocolVersion = ProtocolVersion.HTTP1 + } else { + // Unknown prefix — treat as invalid HTTP/2 connection preface (RFC 3.5). + // The HTTP/2 state machine will detect the bad preface and send GOAWAY PROTOCOL_ERROR. + session.protocolVersion = ProtocolVersion.HTTP2 + session.state = SessionState.HTTP2_OPEN + session.http2.init(&session.requestAllocator.allocator) + Debug("Unknown connection prefix on socket: %d — treating as invalid HTTP/2\n", session.connection.handle()) } return true } @@ -948,6 +957,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. From 200e4364c412397a4b2af8afbc425dca3577486d Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 11:36:46 -0500 Subject: [PATCH 05/14] more tests --- src/http2_connection.lita | 15 ++++++++++---- src/http_worker.lita | 42 +++++++++++++++++++++++++++++++------- test/http2_e2e_test.lita | 27 +++++------------------- test/http2_hpack_test.lita | 11 ++++------ 4 files changed, 55 insertions(+), 40 deletions(-) diff --git a/src/http2_connection.lita b/src/http2_connection.lita index 684f301..d9b2d2b 100644 --- a/src/http2_connection.lita +++ b/src/http2_connection.lita @@ -178,18 +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) { diff --git a/src/http_worker.lita b/src/http_worker.lita index 0ddc7fa..f9452a6 100644 --- a/src/http_worker.lita +++ b/src/http_worker.lita @@ -848,25 +848,53 @@ internal func (this: *WorkerThread) tryDetectProtocol( readBuffer.length = totalSoFar return false } - var b0 = readBuffer.buffer[0] - var b1 = readBuffer.buffer[1] - var b2 = readBuffer.buffer[2] + 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 if(b0 == 'G' || b0 == 'D' || b0 == 'H' || b0 == 'O' || b0 == 'T' || b0 == 'C' || (b0 == 'P' && b1 != 'R')) { - // Known HTTP/1.1 method starters: GET, DELETE, HEAD, OPTIONS, TRACE, CONNECT, POST/PUT/PATCH + // Known HTTP/1.1 method first byte: GET, DELETE, HEAD, OPTIONS, TRACE, CONNECT, POST/PUT/PATCH. session.protocolVersion = ProtocolVersion.HTTP1 } else { - // Unknown prefix — treat as invalid HTTP/2 connection preface (RFC 3.5). - // The HTTP/2 state machine will detect the bad preface and send GOAWAY PROTOCOL_ERROR. + // 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("Unknown connection prefix on socket: %d — treating as invalid HTTP/2\n", session.connection.handle()) + Debug("Non-HTTP/1.1 connection prefix on socket: %d — treating as invalid HTTP/2\n", session.connection.handle()) } return true } diff --git a/test/http2_e2e_test.lita b/test/http2_e2e_test.lita index dbf54a0..6d85fc5 100644 --- a/test/http2_e2e_test.lita +++ b/test/http2_e2e_test.lita @@ -986,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() @@ -1003,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() @@ -1027,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) } From fce6990646351152ec1f11261d01577d2dc59ccd Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 11:41:02 -0500 Subject: [PATCH 06/14] run test suite --- .github/workflows/ci.yml | 6 ++++++ src/http2_connection.lita | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) 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/src/http2_connection.lita b/src/http2_connection.lita index d9b2d2b..4da422c 100644 --- a/src/http2_connection.lita +++ b/src/http2_connection.lita @@ -843,7 +843,9 @@ public func (this: *Http2Connection) flushBlockedStreams(writeBuf: *StringBuilde this.closeStream(i) } } - if(this.sendWindow <= 0) { break } + if(this.sendWindow <= 0) { + break + } } } From 78c25f61422222e379e9e34670b53aaf216e59c5 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 13:54:24 -0500 Subject: [PATCH 07/14] fix tests --- src/http2_connection.lita | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/http2_connection.lita b/src/http2_connection.lita index 4da422c..970c446 100644 --- a/src/http2_connection.lita +++ b/src/http2_connection.lita @@ -295,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) @@ -415,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 } From cc0977425a845adb2e4f7c23b7e619edc3982297 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 14:35:01 -0500 Subject: [PATCH 08/14] h1spec test suite --- Dockerfile.h1spec | 62 ++++++++ h1spec_entrypoint.sh | 32 ++++ h1spec_runner.py | 279 +++++++++++++++++++++++++++++++++++ run_h1spec.sh | 21 +++ test/h1spec_server_main.lita | 33 +++++ test/h1spec_test.lita | 108 ++++++++++++++ 6 files changed, 535 insertions(+) create mode 100644 Dockerfile.h1spec create mode 100755 h1spec_entrypoint.sh create mode 100644 h1spec_runner.py create mode 100755 run_h1spec.sh create mode 100644 test/h1spec_server_main.lita create mode 100644 test/h1spec_test.lita diff --git a/Dockerfile.h1spec b/Dockerfile.h1spec new file mode 100644 index 0000000..3df6030 --- /dev/null +++ b/Dockerfile.h1spec @@ -0,0 +1,62 @@ +# Build ringhttp and run HTTP/1.1 RFC 7230/7231 conformance tests against it. +# +# docker build -f Dockerfile.h1spec -t ring-h1spec . +# docker run --rm --security-opt seccomp=unconfined ring-h1spec + +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.h2spec) +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 h1spec server (swap in the dedicated entry point) +RUN cp test/h1spec_server_main.lita src/main.lita && litac build + +# ── runtime ────────────────────────────────────────────────────────────────── +FROM ubuntu:24.04 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + liburing2 \ + netcat-openbsd \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +EXPOSE 9090 + +WORKDIR /app +COPY --from=builder /build/bin/ring ./ring +COPY h1spec_runner.py ./h1spec_runner.py +COPY h1spec_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-h1spec /app/entrypoint.sh +CMD ["/app/ring"] diff --git a/h1spec_entrypoint.sh b/h1spec_entrypoint.sh new file mode 100755 index 0000000..e06f27a --- /dev/null +++ b/h1spec_entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Start the ringhttp server, wait for it, run h1spec conformance tests, 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 h1spec conformance suite against localhost:$PORT..." +python3 /app/h1spec_runner.py +EXIT_CODE=$? + +kill "$SERVER_PID" 2>/dev/null || true +echo "==> h1spec exited with code $EXIT_CODE" +exit "$EXIT_CODE" diff --git a/h1spec_runner.py b/h1spec_runner.py new file mode 100644 index 0000000..5a9984b --- /dev/null +++ b/h1spec_runner.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +h1spec_runner.py — HTTP/1.1 RFC 7230/7231 conformance tests for ringhttp. + +Exit code: 0 = all passed, 1 = one or more failures. + +Usage: h1spec_runner.py [--host HOST] [--port PORT] +""" +import socket +import sys +import time + +HOST = "localhost" +PORT = 9090 + +# Parse --host / --port from argv +_args = sys.argv[1:] +while _args: + if _args[0] == "--host" and len(_args) > 1: + HOST = _args[1]; _args = _args[2:] + elif _args[0] == "--port" and len(_args) > 1: + PORT = int(_args[1]); _args = _args[2:] + else: + _args = _args[1:] + +PASS = 0 +FAIL = 0 + + +def result(name: str, ok: bool, detail: str = "") -> None: + global PASS, FAIL + if ok: + print(f" PASS {name}") + PASS += 1 + else: + msg = f": {detail}" if detail else "" + print(f" FAIL {name}{msg}") + FAIL += 1 + + +def _read_response(sock, method="GET"): + """Read exactly one HTTP/1.x response from an open socket. + + Returns (status_line, headers, body) where body is bytes. + headers is a dict with lower-cased keys. + Pass method="HEAD" so the body is never read (RFC 7230 §3.3). + """ + raw = b"" + while b"\r\n\r\n" not in raw: + chunk = sock.recv(4096) + if not chunk: + break + raw += chunk + + sep = raw.find(b"\r\n\r\n") + head_raw = raw[:sep] + remainder = raw[sep + 4:] + + lines = head_raw.split(b"\r\n") + status_line = lines[0].decode("latin-1") + headers = {} + for line in lines[1:]: + if b":" in line: + k, v = line.split(b":", 1) + headers[k.strip().lower().decode("latin-1")] = v.strip().decode("latin-1") + + status_code = int(status_line.split(" ")[1]) if " " in status_line else 0 + + # RFC 7230 §3.3: no body for HEAD, 1xx, 204, 304 + if method.upper() == "HEAD" or status_code in (204, 304) or 100 <= status_code < 200: + return status_line, headers, b"" + + if "content-length" in headers: + need = int(headers["content-length"]) + body = remainder + while len(body) < need: + chunk = sock.recv(4096) + if not chunk: + break + body += chunk + return status_line, headers, body[:need] + + if "chunked" in headers.get("transfer-encoding", "").lower(): + body = b"" + buf = remainder + while True: + while b"\r\n" not in buf: + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + size_str, buf = buf.split(b"\r\n", 1) + size = int(size_str.strip(), 16) + if size == 0: + break + while len(buf) < size + 2: + chunk = sock.recv(4096) + if not chunk: + break + buf += chunk + body += buf[:size] + buf = buf[size + 2:] + return status_line, headers, body + + # No framing — read until close + body = remainder + while True: + try: + chunk = sock.recv(4096) + if not chunk: + break + body += chunk + except socket.timeout: + break + return status_line, headers, body + + +def connect(): + s = socket.create_connection((HOST, PORT), timeout=5) + s.settimeout(5) + return s + + +def one_shot(req: bytes): + """Send req on a fresh connection, return (status_line, headers, body).""" + with connect() as s: + s.sendall(req) + return _read_response(s) + + +# ── Section 1: Status line format (RFC 7230 §3.1.2) ───────────────────────── +print("Section 1: Status line format (RFC 7230 §3.1.2)") + +sl, hdrs, body = one_shot(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") +parts = sl.split(" ", 2) + +result("1.1 status line has at least 2 SP-separated tokens", len(parts) >= 2) +result("1.2 HTTP-version is HTTP/1.0 or HTTP/1.1", + parts[0] in ("HTTP/1.0", "HTTP/1.1") if parts else False) +result("1.3 status code is exactly 3 digits", + len(parts) > 1 and len(parts[1]) == 3 and parts[1].isdigit()) +result("1.4 GET / returns 200", + parts[1] == "200" if len(parts) > 1 else False) + +# ── Section 2: Content framing (RFC 7230 §3.3) ─────────────────────────────── +print("\nSection 2: Content-Length / Transfer-Encoding (RFC 7230 §3.3)") + +has_cl = "content-length" in hdrs +has_te = "transfer-encoding" in hdrs + +result("2.1 response has Content-Length or Transfer-Encoding", + has_cl or has_te) +result("2.2 Content-Length and Transfer-Encoding are not both present", + not (has_cl and has_te)) + +if has_cl: + try: + cl = int(hdrs["content-length"]) + result("2.3 Content-Length is a non-negative integer", cl >= 0) + result("2.4 Content-Length matches actual body length", + cl == len(body), f"header={cl} body={len(body)}") + except ValueError: + result("2.3 Content-Length is a non-negative integer", False, "not an integer") + result("2.4 Content-Length matches actual body length", False, "unparseable") + +# ── Section 3: Host header requirement (RFC 7230 §5.4) ─────────────────────── +print("\nSection 3: Host header requirement (RFC 7230 §5.4)") + +sl3, _, _ = one_shot(b"GET / HTTP/1.1\r\nConnection: close\r\n\r\n") +code3 = sl3.split(" ")[1] if " " in sl3 else "" +result("3.1 HTTP/1.1 request without Host returns 400", + code3 == "400", f"got {code3}") + +# ── Section 4: HEAD method (RFC 7231 §4.3.2) ───────────────────────────────── +print("\nSection 4: HEAD method (RFC 7231 §4.3.2)") + +with connect() as s: + s.sendall(b"HEAD / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + sl4, hdrs4, body4 = _read_response(s, "HEAD") + +parts4 = sl4.split(" ", 2) +result("4.1 HEAD returns 200", parts4[1] == "200" if len(parts4) > 1 else False) +result("4.2 HEAD response has no body", len(body4) == 0, f"got {len(body4)} bytes") + +# HEAD Content-Length should match GET Content-Length +if has_cl and "content-length" in hdrs4: + result("4.3 HEAD Content-Length equals GET Content-Length", + hdrs4["content-length"] == hdrs["content-length"], + f"HEAD={hdrs4['content-length']} GET={hdrs['content-length']}") + +# ── Section 5: 404 for unknown resource (RFC 7231 §6.5.4) ─────────────────── +print("\nSection 5: 404 Not Found (RFC 7231 §6.5.4)") + +sl5, _, _ = one_shot(b"GET /no-such-path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") +code5 = sl5.split(" ")[1] if " " in sl5 else "" +result("5.1 unknown path returns 4xx", + code5.startswith("4"), f"got {code5}") + +# ── Section 6: POST method (RFC 7231 §4.3.3) ───────────────────────────────── +print("\nSection 6: POST method (RFC 7231 §4.3.3)") + +payload = b"hello" +post_req = ( + b"POST / HTTP/1.1\r\n" + b"Host: localhost\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: " + str(len(payload)).encode() + b"\r\n" + b"Connection: close\r\n" + b"\r\n" + payload +) +sl6, hdrs6, body6 = one_shot(post_req) +code6 = sl6.split(" ")[1] if " " in sl6 else "" +result("6.1 POST / returns 2xx", code6.startswith("2"), f"got {code6}") + +if "content-length" in hdrs6: + try: + cl6 = int(hdrs6["content-length"]) + result("6.2 POST response Content-Length matches body", + cl6 == len(body6), f"header={cl6} body={len(body6)}") + except ValueError: + result("6.2 POST response Content-Length matches body", False, "not an integer") + +# ── Section 7: HTTP/1.0 compatibility (RFC 7230 §2.6) ──────────────────────── +print("\nSection 7: HTTP/1.0 compatibility (RFC 7230 §2.6)") + +sl7, _, _ = one_shot(b"GET / HTTP/1.0\r\nHost: localhost\r\n\r\n") +code7 = sl7.split(" ")[1] if " " in sl7 else "" +result("7.1 HTTP/1.0 request gets a valid response", + code7.startswith("2") or code7.startswith("4"), f"got {code7}") + +# ── Section 8: Keep-alive (RFC 7230 §6.3) ───────────────────────────────────── +print("\nSection 8: Keep-alive — two requests on one connection (RFC 7230 §6.3)") + +try: + with connect() as s: + # First request — no Connection: close so connection stays open + req_a = b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n" + s.sendall(req_a) + sl_a, hdrs_a, body_a = _read_response(s) + code_a = sl_a.split(" ")[1] if " " in sl_a else "" + + # Second request on the same socket + req_b = b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + s.sendall(req_b) + sl_b, _, _ = _read_response(s) + code_b = sl_b.split(" ")[1] if " " in sl_b else "" + + result("8.1 first pipelined request returns 200", code_a == "200", f"got {code_a}") + result("8.2 second pipelined request returns 200", code_b == "200", f"got {code_b}") +except Exception as e: + result("8.1 first pipelined request returns 200", False, str(e)) + result("8.2 second pipelined request returns 200", False, "skipped") + +# ── Section 9: Connection: close (RFC 7230 §6.6) ───────────────────────────── +print("\nSection 9: Connection: close (RFC 7230 §6.6)") + +try: + with connect() as s: + s.sendall(b"GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + sl9, hdrs9, _ = _read_response(s) + code9 = sl9.split(" ")[1] if " " in sl9 else "" + + # After reading the response the server should close; another recv returns b"" + try: + leftover = s.recv(4096) + closed = leftover == b"" + except (ConnectionResetError, OSError): + closed = True + + result("9.1 Connection: close response is 200", code9 == "200", f"got {code9}") + result("9.2 server closes connection after Connection: close response", closed) +except Exception as e: + result("9.1 Connection: close response is 200", False, str(e)) + result("9.2 server closes connection after Connection: close response", False, "skipped") + +# ── Summary ─────────────────────────────────────────────────────────────────── +print(f"\n{'=' * 60}") +print(f"Results: {PASS} passed, {FAIL} failed") +sys.exit(1 if FAIL > 0 else 0) diff --git a/run_h1spec.sh b/run_h1spec.sh new file mode 100755 index 0000000..f9c0cb4 --- /dev/null +++ b/run_h1spec.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Build and run HTTP/1.1 RFC conformance tests against ringhttp. +# Usage: ./run_h1spec.sh [--no-cache] +# +# Requires --security-opt seccomp=unconfined for io_uring syscalls. +# Exit code mirrors the test runner: 0 = all passed, non-zero = failures. + +set -euo pipefail + +IMAGE=ring-h1spec +NO_CACHE= + +if [[ "${1:-}" == "--no-cache" ]]; then + NO_CACHE="--no-cache" +fi + +echo "==> Building h1spec image..." +docker build $NO_CACHE -f Dockerfile.h1spec -t "$IMAGE" . + +echo "==> Running h1spec..." +docker run --rm --security-opt seccomp=unconfined "$IMAGE" /app/entrypoint.sh diff --git a/test/h1spec_server_main.lita b/test/h1spec_server_main.lita new file mode 100644 index 0000000..cb6b567 --- /dev/null +++ b/test/h1spec_server_main.lita @@ -0,0 +1,33 @@ +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.addHttpController(HttpMethod.POST, "/", null, + HttpController{.callback = RootHandler}) + server.start() + defer server.close() + return 0 +} diff --git a/test/h1spec_test.lita b/test/h1spec_test.lita new file mode 100644 index 0000000..a07b0c9 --- /dev/null +++ b/test/h1spec_test.lita @@ -0,0 +1,108 @@ +// h1spec RFC 7230/7231 conformance test for ringhttp. +// +// Starts the ring server in-process (using the native I/O backend) and runs +// h1spec_runner.py from the ring-h1spec Docker image against it. +// +// The ring-h1spec image must be built before running this test: +// +// docker build -f Dockerfile.h1spec -t ring-h1spec . +// +// Run with: +// litac test -file test/h1spec_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 H1SPEC_PORT: u16 = 9090_u16 + +// ── Server ──────────────────────────────────────────────────────────────────── + +func h1specRootHandler(ctx: *RequestHandlerContext) : i32 { + ctx.response.status = 200 + ctx.response.type = ResponseType.BODY + ctx.response.body.append("OK") + return 1 +} + +struct H1SpecServer { + config: HttpConfig + barrier: Barrier +} + +var gServer = H1SpecServer{} +var gServerReady = false + +func serverThread(arg: *void) : i32 { + var ts = arg as (*H1SpecServer) + var server = HttpServer{} + server.init(&ts.config) + server.addHttpController(HttpMethod.GET, "/", null, + HttpController{.callback = h1specRootHandler}) + server.addHttpController(HttpMethod.POST, "/", null, + HttpController{.callback = h1specRootHandler}) + ts.barrier.wait() + server.start() + defer server.close() + return 0 +} + +func ensureServer() { + if(gServerReady) { return; } + gServer.config = HttpConfig{ + .allocator = defaultAllocator, + .port = H1SPEC_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("h1spec_rfc7230_7231_conformance") +func testH1SpecConformance() { + if(!DockerAvailable()) { + printf("SKIP h1spec: Docker not available\n") + return; + } + + var checkCmd = StringBuilderInit(64) + defer checkCmd.free() + checkCmd.append("docker image inspect ring-h1spec > /dev/null 2>&1") + if(wexitstatus(system(checkCmd.cStr())) != 0) { + printf("SKIP h1spec: ring-h1spec image not built.\n") + printf(" Build: docker build -f Dockerfile.h1spec -t ring-h1spec .\n") + return; + } + + ensureServer() + + // h1spec_runner.py 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-h1spec python3 /app/h1spec_runner.py --host host.docker.internal --port %d", + H1SPEC_PORT as (i32)) + + var exitCode = wexitstatus(system(runCmd.cStr())) + assert(exitCode == 0) +} From 8b2db39b13d60e652d5d626e9d71c7bf7eabaea9 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 14:42:04 -0500 Subject: [PATCH 09/14] run tests in ci --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c7647b..5cf6ef5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,12 @@ jobs: - name: Run h2spec RFC 7540 conformance run: docker run --rm --security-opt seccomp=unconfined ring-h2spec /app/entrypoint.sh + - name: Build h1spec conformance image + run: docker build --no-cache -f Dockerfile.h1spec -t ring-h1spec . + + - name: Run h1spec RFC 7230/7231 conformance + run: docker run --rm --security-opt seccomp=unconfined ring-h1spec /app/entrypoint.sh + test-macos: name: Test (macOS) runs-on: macos-latest From 70058f30699e914e16b207fc4727735263260175 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 15:56:32 -0500 Subject: [PATCH 10/14] more tests --- .github/workflows/ci.yml | 5 - src/http_common.lita | 1 + src/http_parser.lita | 11 +- src/http_request.lita | 1 + src/http_worker.lita | 3 + src/http_worker_iouring.lita | 5 + src/http_worker_kqueue.lita | 8 + test/h1spec_server_main.lita | 33 -- test/h1spec_test.lita | 708 ++++++++++++++++++++++++++++++++--- test/http_parser_test.lita | 8 +- 10 files changed, 682 insertions(+), 101 deletions(-) delete mode 100644 test/h1spec_server_main.lita diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cf6ef5..a526612 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,11 +47,6 @@ jobs: - name: Run h2spec RFC 7540 conformance run: docker run --rm --security-opt seccomp=unconfined ring-h2spec /app/entrypoint.sh - - name: Build h1spec conformance image - run: docker build --no-cache -f Dockerfile.h1spec -t ring-h1spec . - - - name: Run h1spec RFC 7230/7231 conformance - run: docker run --rm --security-opt seccomp=unconfined ring-h1spec /app/entrypoint.sh test-macos: name: Test (macOS) diff --git a/src/http_common.lita b/src/http_common.lita index 4fc53df..de77dd8 100644 --- a/src/http_common.lita +++ b/src/http_common.lita @@ -57,6 +57,7 @@ public enum Status { // HTTP/1.1 protocol requirements ERROR_MISSING_HOST_HEADER, + ERROR_DUPLICATE_HOST_HEADER, // more than one Host header → 400 ERROR_HTTP_VERSION_NOT_SUPPORTED, ERROR_CONFLICTING_BODY_HEADERS, // both Transfer-Encoding and Content-Length → 400 ERROR_CONFLICTING_CONTENT_LENGTH, // duplicate Content-Length with different values → 400 diff --git a/src/http_parser.lita b/src/http_parser.lita index 9bfa557..facfedd 100644 --- a/src/http_parser.lita +++ b/src/http_parser.lita @@ -212,6 +212,7 @@ func (this: *HttpParser) parseHeaders(input: *String, result: *HttpRequest) : St defer *input = input.substring(headerEndIndex + 2) result.headers.init(16, this.allocator) + result.flags &= ~HttpFlags.HOST_PROVIDED var headers = input.split($"\r\n") while(headers.hasNext()) { @@ -244,7 +245,13 @@ func (this: *HttpParser) parseHeaders(input: *String, result: *HttpRequest) : St func (this: *HttpParser) checkHeader(header: *HttpHeader, result: *HttpRequest) : Status { // TODO: Optimize case insensitivity - if (header.name.equalsIgnoreCase($"connection")) { + if (header.name.equalsIgnoreCase($"host")) { + if((result.flags & HttpFlags.HOST_PROVIDED) != 0) { + return Status.ERROR_DUPLICATE_HOST_HEADER + } + result.flags |= HttpFlags.HOST_PROVIDED + } + else if (header.name.equalsIgnoreCase($"connection")) { if (header.values.containsIgnoreCase($"close")) { result.flags |= HttpFlags.DISABLE_KEEP_ALIVE } @@ -259,7 +266,7 @@ func (this: *HttpParser) checkHeader(header: *HttpHeader, result: *HttpRequest) } else if (header.name.equalsIgnoreCase($"content-length")) { var parsed = header.values.parseU64() - if(parsed.error != ParseError.NONE) { + if(parsed.error != ParseError.NONE || parsed.len == 0 || parsed.len != header.values.length) { return Status.ERROR_INVALID_CONTENT_LENGTH } var newLen = MAX(0, parsed.value as (i32)) diff --git a/src/http_request.lita b/src/http_request.lita index 65cd8ee..8243bde 100644 --- a/src/http_request.lita +++ b/src/http_request.lita @@ -58,6 +58,7 @@ public enum HttpFlags { KEEP_ALIVE_REQUESTED = (1<<13), // Connection: keep-alive was explicit (HTTP/1.0 opt-in) EXPECT_UNKNOWN = (1<<14), // Expect header present with unrecognised value → 417 CONFLICTING_CONTENT_LENGTH = (1<<15), // duplicate Content-Length with differing values → 400 + HOST_PROVIDED = (1<<16), // Host header seen; set again → 400 } /** diff --git a/src/http_worker.lita b/src/http_worker.lita index f9452a6..8cd7208 100644 --- a/src/http_worker.lita +++ b/src/http_worker.lita @@ -229,6 +229,9 @@ internal func (this: *WorkerThread) sendBadRequest( case Status.ERROR_MISSING_HOST_HEADER: response.body.append("HTTP/1.1 requests must include a Host header\n") break + case Status.ERROR_DUPLICATE_HOST_HEADER: + response.body.append("HTTP/1.1 requests must not send more than one Host header\n") + break case Status.ERROR_HTTP_VERSION_NOT_SUPPORTED: response.status = 505 response.body.append("HTTP Version Not Supported\n") diff --git a/src/http_worker_iouring.lita b/src/http_worker_iouring.lita index d975bc7..33ef6c2 100644 --- a/src/http_worker_iouring.lita +++ b/src/http_worker_iouring.lita @@ -1231,6 +1231,11 @@ func (this: *WorkerThread) handleCompletionRing( if(session.response.status == 100) { return this.queueNextRead(ring, session) } + // RFC 7230 §6.6: close when Connection: close or HTTP/1.0 without keep-alive. + if(connection.disableKeepAlive) { + this.closeSession(session) + return Status.OK + } Debug("Preparing connection for another HTTP request for socket: %d\n", connection.handle()) var savedSsl = session.ssl session.begin(connection.handle()) diff --git a/src/http_worker_kqueue.lita b/src/http_worker_kqueue.lita index 41f7156..e5d27e7 100644 --- a/src/http_worker_kqueue.lita +++ b/src/http_worker_kqueue.lita @@ -402,6 +402,13 @@ func (this: *WorkerThread) resetRequest(session: *SessionContext) { var connection = &session.connection + // RFC 7230 §6.6: close the TCP connection when Connection: close was requested + // or when the client spoke HTTP/1.0 without opting in to keep-alive. + if(connection.disableKeepAlive) { + this.closeSession(session) + return; + } + // Preserve TLS state across session.begin() — begin() resets ssl to null, but a // keep-alive connection reuses the same SSL object for subsequent requests. var savedSsl = session.ssl @@ -859,6 +866,7 @@ func (this: *WorkerThread) handleReadReady(session: *SessionContext) { case Status.ERROR_INVALID_BODY_EXCEEDED_LIMIT: case Status.ERROR_PARSING_HTTP_REQUEST: case Status.ERROR_MISSING_HOST_HEADER: + case Status.ERROR_DUPLICATE_HOST_HEADER: case Status.ERROR_HTTP_VERSION_NOT_SUPPORTED: case Status.ERROR_EXPECT_FAILED: case Status.ERROR_CONFLICTING_BODY_HEADERS: diff --git a/test/h1spec_server_main.lita b/test/h1spec_server_main.lita deleted file mode 100644 index cb6b567..0000000 --- a/test/h1spec_server_main.lita +++ /dev/null @@ -1,33 +0,0 @@ -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.addHttpController(HttpMethod.POST, "/", null, - HttpController{.callback = RootHandler}) - server.start() - defer server.close() - return 0 -} diff --git a/test/h1spec_test.lita b/test/h1spec_test.lita index a07b0c9..8f8f139 100644 --- a/test/h1spec_test.lita +++ b/test/h1spec_test.lita @@ -1,60 +1,98 @@ -// h1spec RFC 7230/7231 conformance test for ringhttp. +// HTTP/1.1 RFC 7230/7231 conformance tests for ringhttp. // -// Starts the ring server in-process (using the native I/O backend) and runs -// h1spec_runner.py from the ring-h1spec Docker image against it. -// -// The ring-h1spec image must be built before running this test: -// -// docker build -f Dockerfile.h1spec -t ring-h1spec . +// Starts the ring server in-process on a background thread and probes it with +// raw TCP connections — no Docker or external tools required. // // Run with: // litac test -file test/h1spec_test.lita import "std/libc" -import "std/string/builder" +import "std/string" +import "std/fmt" import "std/assert" import "std/thread" import "std/thread/barrier" import "std/mem" -import "std/testcontainers/docker" +import "std/string/builder" +import "std/net/posix_socket" import "http_ring" -const H1SPEC_PORT: u16 = 9090_u16 +@raw(""" +#include +#include +#include +static void _h1spec_setTimeout(int fd, int sec) { + struct timeval tv = { sec, 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, (const void*)&tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, (const void*)&tv, sizeof(tv)); +} +// Blocks up to 500 ms waiting for the server to close the connection. +// Returns 1 if connection is still open, 0 if closed (FIN received). +static int _h1spec_peekOpen(int fd) { + struct timeval tv = { 0, 500000 }; // 500 ms + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, (const void*)&tv, sizeof(tv)); + char b[1]; + int r = (int)recv(fd, b, 1, MSG_PEEK); + // Restore 5-second timeout + struct timeval tv2 = { 5, 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, (const void*)&tv2, sizeof(tv2)); + if (r == 0) return 0; // FIN = closed + return 1; // data or timeout = still open +} +"""); +@foreign func _h1spec_setTimeout(fd: i32, sec: i32); +@foreign func _h1spec_peekOpen(fd: i32) : i32; + +const H1SPEC_PORT: u16 = 9191_u16 +const H1SPEC_TIMEOUT: i32 = 5 +const H1_LARGE_SIZE: i32 = 1024 // body size for /large route -// ── Server ──────────────────────────────────────────────────────────────────── +// ── Handlers ────────────────────────────────────────────────────────────────── -func h1specRootHandler(ctx: *RequestHandlerContext) : i32 { +func h1RootHandler(ctx: *RequestHandlerContext) : i32 { ctx.response.status = 200 ctx.response.type = ResponseType.BODY ctx.response.body.append("OK") return 1 } -struct H1SpecServer { +func h1LargeHandler(ctx: *RequestHandlerContext) : i32 { + ctx.response.status = 200 + ctx.response.type = ResponseType.BODY + for(var i = 0; i < H1_LARGE_SIZE; i += 1) { + ctx.response.body.appendChar('A') + } + return 1 +} + +// ── Server ──────────────────────────────────────────────────────────────────── + +struct H1TestServer { config: HttpConfig barrier: Barrier } +var gH1Server: H1TestServer +var gH1ServerReady = false -var gServer = H1SpecServer{} -var gServerReady = false - -func serverThread(arg: *void) : i32 { - var ts = arg as (*H1SpecServer) +func h1ServerThread(arg: *void) : i32 { + var ts = arg as (*H1TestServer) var server = HttpServer{} server.init(&ts.config) - server.addHttpController(HttpMethod.GET, "/", null, - HttpController{.callback = h1specRootHandler}) - server.addHttpController(HttpMethod.POST, "/", null, - HttpController{.callback = h1specRootHandler}) + server.addHttpController(HttpMethod.GET, "/", null, + HttpController{.callback = h1RootHandler}) + server.addHttpController(HttpMethod.POST, "/", null, + HttpController{.callback = h1RootHandler}) + server.addHttpController(HttpMethod.GET, "/large", null, + HttpController{.callback = h1LargeHandler}) ts.barrier.wait() server.start() defer server.close() return 0 } -func ensureServer() { - if(gServerReady) { return; } - gServer.config = HttpConfig{ +func h1EnsureServer() { + if(gH1ServerReady) { return; } + gH1Server.config = HttpConfig{ .allocator = defaultAllocator, .port = H1SPEC_PORT, .numThreads = 2, @@ -62,47 +100,607 @@ func ensureServer() { .keepAliveTimeoutInSec = 30, .isLogEnabled = false, } - gServer.barrier.init(2) + gH1Server.barrier.init(2) var t = Thread{} - assert(t.create(serverThread, &gServer) == ThreadStatus.SUCCESS) - gServer.barrier.wait() + assert(t.create(h1ServerThread, &gH1Server) == ThreadStatus.SUCCESS) + gH1Server.barrier.wait() ThreadSleepMSec(200) - gServerReady = true + gH1ServerReady = true +} + +// ── Socket helpers ───────────────────────────────────────────────────────────── + +func h1Connect() : i32 { + var fd = socket(AF_INET, SOCK_STREAM, 0) + if(fd < 0) { return -1 } + var addr: sockaddr_in + memset(&addr, 0, sizeof(:sockaddr_in)) + addr.sin_family = AF_INET + addr.sin_port = htons(H1SPEC_PORT) + inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr) + if(connect(fd, (&addr) as (*sockaddr), sizeof(:sockaddr_in) as (socklen_t)) < 0) { + close(fd) + return -1 + } + _h1spec_setTimeout(fd, H1SPEC_TIMEOUT) + return fd +} + +func h1WriteAll(fd: i32, req: *const char) : bool { + var len = strlen(req) as (i32) + var sent = 0 + while(sent < len) { + var r = send(fd, (req + sent) as (*void), (len - sent) as (usize), 0) + if(r <= 0) { return false } + sent += r as (i32) + } + return true +} + +// ── Response parser ──────────────────────────────────────────────────────────── + +struct H1Resp { + buf: [8192]char + totalLen: i32 + headerLen: i32 // index of first body byte (right after \r\n\r\n) + statusCode: i32 +} + +// Read a complete HTTP/1.1 response from fd into resp. +// Pass isHead=true for HEAD requests so no body bytes are consumed. +func h1ReadFull(fd: i32, resp: *H1Resp, isHead: bool) : bool { + resp.totalLen = 0 + resp.headerLen = 0 + resp.statusCode = 0 + + // Read until the \r\n\r\n header terminator appears. + while(resp.totalLen < 8188) { + var n = recv(fd, (&resp.buf[resp.totalLen]) as (*void), + (8188 - resp.totalLen) as (usize), 0) + if(n <= 0) { break } + resp.totalLen += n as (i32) + for(var i = 0; i <= resp.totalLen - 4; i += 1) { + if(resp.buf[i] == '\r' && resp.buf[i+1] == '\n' && + resp.buf[i+2] == '\r' && resp.buf[i+3] == '\n') { + resp.headerLen = i + 4 + break + } + } + if(resp.headerLen > 0) { break } + } + if(resp.headerLen == 0) { return false } + + // Status code lives at bytes [9..12): "HTTP/1.x NNN ..." + resp.statusCode = 0 + for(var i = 9; i < 12 && i < resp.totalLen; i += 1) { + var c = resp.buf[i] + if(c < '0' || c > '9') { break } + resp.statusCode = resp.statusCode * 10 + (c as (i32) - '0' as (i32)) + } + + if(isHead) { return true } + if(resp.statusCode == 204 || resp.statusCode == 304 || + (resp.statusCode >= 100 && resp.statusCode < 200)) { + return true + } + + // Content-Length framing: read exactly that many body bytes. + var cl = h1ContentLength(resp) + if(cl >= 0) { + var need = resp.headerLen + cl + while(resp.totalLen < need && resp.totalLen < 8190) { + var n = recv(fd, (&resp.buf[resp.totalLen]) as (*void), + (need - resp.totalLen) as (usize), 0) + if(n <= 0) { break } + resp.totalLen += n as (i32) + } + return true + } + + // No framing: read until close or timeout. + while(resp.totalLen < 8188) { + var n = recv(fd, (&resp.buf[resp.totalLen]) as (*void), + (8188 - resp.totalLen) as (usize), 0) + if(n <= 0) { break } + resp.totalLen += n as (i32) + } + return true +} + +func h1ContentLength(resp: *H1Resp) : i32 { + var headers = String{.buffer = resp.buf, .length = resp.headerLen} + var idx = headers.indexOf($"Content-Length: ") + if(idx < 0) { return -1 } + var after = headers.substring(idx + 16) + var end = after.indexOf($"\r\n") + if(end < 0) { return -1 } + var p = after.substring(0, end).parseInt() + if(p.error != ParseError.NONE) { return -1 } + return p.value as (i32) +} + +func h1HasTE(resp: *H1Resp) : bool { + var headers = String{.buffer = resp.buf, .length = resp.headerLen} + return headers.contains($"Transfer-Encoding:") } -// ── Test ────────────────────────────────────────────────────────────────────── +func h1HasHeader(resp: *H1Resp, needle: *const char) : bool { + var headers = String{.buffer = resp.buf, .length = resp.headerLen} + return headers.contains(StringInit(needle)) +} + +func h1BodyLen(resp: *H1Resp) : i32 { + return resp.totalLen - resp.headerLen +} -// Extracts exit code from POSIX waitpid() status (WEXITSTATUS). -func wexitstatus(s: i32) : i32 { - return (s >> 8) & 0xFF +// Quick one-shot: connect, send, read, close. Returns status code or -1. +func h1OneShot(req: *const char) : i32 { + var fd = h1Connect() + if(fd < 0) { return -1 } + defer close(fd) + if(!h1WriteAll(fd, req)) { return -1 } + var resp: H1Resp + if(!h1ReadFull(fd, &resp, false)) { return -1 } + return resp.statusCode } -@test("h1spec_rfc7230_7231_conformance") -func testH1SpecConformance() { - if(!DockerAvailable()) { - printf("SKIP h1spec: Docker not available\n") - return; +// ── Test helper ──────────────────────────────────────────────────────────────── + +func check(pass: *i32, fail: *i32, name: *const char, ok: bool) { + if(ok) { + printf(" PASS %s\n", name) + ;*pass += 1 + } else { + printf(" FAIL %s\n", name) + ;*fail += 1 + } +} + +// ── Section 1: Status line format (RFC 7230 §3.1.2) ────────────────────────── + +@test("h1spec_1_status_line") +func testStatusLine() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 1: Status line format (RFC 7230 §3.1.2)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &resp, false)) + + var sl = String{.buffer = resp.buf, .length = resp.headerLen} + check(&p, &f, "1.1 starts with HTTP/", sl.startsWith($"HTTP/")) + check(&p, &f, "1.2 HTTP version is 1.0 or 1.1", + sl.startsWith($"HTTP/1.0") || sl.startsWith($"HTTP/1.1")) + check(&p, &f, "1.3 GET / returns 200", resp.statusCode == 200) + check(&p, &f, "1.4 status line terminated with CRLF", sl.contains($"\r\n")) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 2: Response framing (RFC 7230 §3.3) ─────────────────────────────── + +@test("h1spec_2_content_framing") +func testContentFraming() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 2: Response framing (RFC 7230 §3.3)\n") + + // Small body — GET / + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &resp, false)) + + var hasCL = h1ContentLength(&resp) >= 0 + var hasTE = h1HasTE(&resp) + check(&p, &f, "2.1 has Content-Length or Transfer-Encoding", hasCL || hasTE) + check(&p, &f, "2.2 Content-Length and Transfer-Encoding not both present", + !(hasCL && hasTE)) + if(hasCL) { + var cl = h1ContentLength(&resp) + check(&p, &f, "2.3 Content-Length is non-negative", cl >= 0) + check(&p, &f, "2.4 Content-Length matches actual body", + cl == h1BodyLen(&resp)) } - var checkCmd = StringBuilderInit(64) - defer checkCmd.free() - checkCmd.append("docker image inspect ring-h1spec > /dev/null 2>&1") - if(wexitstatus(system(checkCmd.cStr())) != 0) { - printf("SKIP h1spec: ring-h1spec image not built.\n") - printf(" Build: docker build -f Dockerfile.h1spec -t ring-h1spec .\n") - return; + // Large body — GET /large + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var resp2: H1Resp + assert(h1WriteAll(fd2, "GET /large HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &resp2, false)) + + var hasCL2 = h1ContentLength(&resp2) >= 0 + if(hasCL2) { + var cl2 = h1ContentLength(&resp2) + check(&p, &f, "2.5 /large: Content-Length equals H1_LARGE_SIZE", + cl2 == H1_LARGE_SIZE) + check(&p, &f, "2.6 /large: Content-Length matches body received", + cl2 == h1BodyLen(&resp2)) } - ensureServer() + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 3: Host header requirement (RFC 7230 §5.4) ──────────────────────── + +@test("h1spec_3_host_header") +func testHostHeader() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 3: Host header requirement (RFC 7230 §5.4)\n") + + check(&p, &f, "3.1 HTTP/1.1 without Host returns 400", + h1OneShot("GET / HTTP/1.1\r\n\r\n") == 400) + + check(&p, &f, "3.2 duplicate Host returns 400", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nHost: example.com\r\n\r\n") == 400) + + check(&p, &f, "3.3 valid Host returns 200", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 4: HTTP version handling (RFC 7230 §2.6) ───────────────────────── + +@test("h1spec_4_http_version") +func testHttpVersion() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 4: HTTP version handling (RFC 7230 §2.6)\n") + + check(&p, &f, "4.1 HTTP/1.1 request succeeds", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 200) + + check(&p, &f, "4.2 HTTP/1.0 request gets a valid response", + h1OneShot("GET / HTTP/1.0\r\nHost: localhost\r\n\r\n") >= 200) + + check(&p, &f, "4.3 HTTP/2.0 returns 505", + h1OneShot("GET / HTTP/2.0\r\nHost: localhost\r\n\r\n") == 505) + + check(&p, &f, "4.4 unknown version HTTP/9.9 returns 400 or 505", + h1OneShot("GET / HTTP/9.9\r\nHost: localhost\r\n\r\n") == 400 || + h1OneShot("GET / HTTP/9.9\r\nHost: localhost\r\n\r\n") == 505) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 5: HEAD method (RFC 7231 §4.3.2) ───────────────────────────────── + +@test("h1spec_5_head_method") +func testHeadMethod() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 5: HEAD method (RFC 7231 §4.3.2)\n") + + // GET first so we can compare Content-Length + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var getRsp: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &getRsp, false)) + var getCL = h1ContentLength(&getRsp) + + // HEAD + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var headRsp: H1Resp + assert(h1WriteAll(fd2, "HEAD / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &headRsp, true)) + + check(&p, &f, "5.1 HEAD returns 200", headRsp.statusCode == 200) + check(&p, &f, "5.2 HEAD response has no body", h1BodyLen(&headRsp) == 0) + if(getCL >= 0 && h1ContentLength(&headRsp) >= 0) { + check(&p, &f, "5.3 HEAD Content-Length equals GET Content-Length", + h1ContentLength(&headRsp) == getCL) + } + check(&p, &f, "5.4 HEAD response has CRLF header terminator", + headRsp.headerLen > 0) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 6: 404 Not Found (RFC 7231 §6.5.4) ──────────────────────────────── + +@test("h1spec_6_not_found") +func testNotFound() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 6: 404 Not Found (RFC 7231 §6.5.4)\n") + + check(&p, &f, "6.1 /no-such-path returns 404", + h1OneShot("GET /no-such-path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 404) + + check(&p, &f, "6.2 /a/b/c/d/e returns 404", + h1OneShot("GET /a/b/c/d/e HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 404) + + check(&p, &f, "6.3 404 response has Content-Length or Transfer-Encoding", + h1OneShot("GET /missing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 404) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 7: POST method (RFC 7231 §4.3.3) ───────────────────────────────── + +@test("h1spec_7_post_method") +func testPostMethod() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 7: POST method (RFC 7231 §4.3.3)\n") + + // POST with body + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + assert(h1WriteAll(fd, + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")) + assert(h1ReadFull(fd, &resp, false)) + check(&p, &f, "7.1 POST returns 2xx", resp.statusCode >= 200 && resp.statusCode < 300) + var hasCL = h1ContentLength(&resp) >= 0 + var hasTE = h1HasTE(&resp) + check(&p, &f, "7.2 POST response has framing header", hasCL || hasTE) + if(hasCL) { + check(&p, &f, "7.3 POST response Content-Length matches body", + h1ContentLength(&resp) == h1BodyLen(&resp)) + } + + // POST with zero-length body + check(&p, &f, "7.4 POST with zero Content-Length returns 2xx", + h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + >= 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 8: Method handling (RFC 7231 §4, §6.6.2) ───────────────────────── + +@test("h1spec_8_method_handling") +func testMethodHandling() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 8: Method handling (RFC 7231 §4, §6.6.2)\n") + + // Unknown/unregistered methods + var patch = h1OneShot("PATCH / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + check(&p, &f, "8.1 PATCH (unregistered) returns 405 or 501", + patch == 405 || patch == 501) + + var del = h1OneShot("DELETE / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "8.2 DELETE (unregistered) returns 405 or 501", + del == 405 || del == 501) + + // Completely unknown method token + var bogus = h1OneShot("FOOBAR / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "8.3 unknown method token returns 400, 405, or 501", + bogus == 400 || bogus == 405 || bogus == 501) + + // OPTIONS + var opts = h1OneShot("OPTIONS / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "8.4 OPTIONS returns 200, 204, 405, or 501", + opts == 200 || opts == 204 || opts == 405 || opts == 501) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 9: Connection handling (RFC 7230 §6) ────────────────────────────── + +@test("h1spec_9_connection") +func testConnection() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 9: Connection handling (RFC 7230 §6)\n") + + // HTTP/1.1 keep-alive: send two requests on the same connection + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var r1: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")) + assert(h1ReadFull(fd, &r1, false)) + check(&p, &f, "9.1 first request on persistent conn returns 200", r1.statusCode == 200) + + var r2: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &r2, false)) + check(&p, &f, "9.2 second request on same conn returns 200", r2.statusCode == 200) + + // Connection: close — server should close after the response + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var rc: H1Resp + assert(h1WriteAll(fd2, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &rc, false)) + check(&p, &f, "9.3 Connection: close response is 200", rc.statusCode == 200) + check(&p, &f, "9.4 server closes connection after Connection: close", + _h1spec_peekOpen(fd2) == 0) + + // Three sequential requests on one connection + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var ra: H1Resp; var rb: H1Resp; var rcc: H1Resp + assert(h1WriteAll(fd3, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")) + assert(h1ReadFull(fd3, &ra, false)) + assert(h1WriteAll(fd3, "GET /large HTTP/1.1\r\nHost: localhost\r\n\r\n")) + assert(h1ReadFull(fd3, &rb, false)) + assert(h1WriteAll(fd3, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd3, &rcc, false)) + check(&p, &f, "9.5 3rd sequential request returns 200", rcc.statusCode == 200) + check(&p, &f, "9.6 /large on reused connection has correct CL", + h1ContentLength(&rb) == H1_LARGE_SIZE) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 10: HTTP/1.0 compatibility (RFC 7230 §2.6) ──────────────────────── + +@test("h1spec_10_http10") +func testHttp10() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 10: HTTP/1.0 compatibility (RFC 7230 §2.6)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.0\r\nHost: localhost\r\n\r\n")) + assert(h1ReadFull(fd, &resp, false)) + + check(&p, &f, "10.1 HTTP/1.0 request returns 200", + resp.statusCode == 200) + check(&p, &f, "10.2 HTTP/1.0 response begins with HTTP/", + String{.buffer = resp.buf, .length = 8}.startsWith($"HTTP/")) + check(&p, &f, "10.3 HTTP/1.0 connection closes after response", + _h1spec_peekOpen(fd) == 0) + + // HTTP/1.0 + Connection: keep-alive — should reuse + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r1: H1Resp + assert(h1WriteAll(fd2, "GET / HTTP/1.0\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n")) + assert(h1ReadFull(fd2, &r1, false)) + check(&p, &f, "10.4 HTTP/1.0 + Connection: keep-alive returns 200", + r1.statusCode == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 11: Bad request handling (RFC 7230 §3.1, §3.3) ─────────────────── + +@test("h1spec_11_bad_requests") +func testBadRequests() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 11: Bad request handling (RFC 7230 §3.1, §3.3)\n") + + // Duplicate Content-Length with conflicting values + check(&p, &f, "11.1 conflicting duplicate Content-Length returns 400", + h1OneShot( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nContent-Length: 10\r\nConnection: close\r\n\r\nhello") == 400) + + // Non-numeric Content-Length + check(&p, &f, "11.2 non-numeric Content-Length returns 400", + h1OneShot( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\nConnection: close\r\n\r\n") == 400) + + // Negative Content-Length + check(&p, &f, "11.3 negative Content-Length returns 400", + h1OneShot( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: -1\r\nConnection: close\r\n\r\n") == 400) + + // Both Transfer-Encoding and Content-Length (RFC 7230 §3.3.3) + check(&p, &f, "11.4 Transfer-Encoding + Content-Length together returns 400", + h1OneShot( + "POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nContent-Length: 5\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n") == 400) + + // Unrecognised Expect value (RFC 7231 §5.1.1 → 417) + check(&p, &f, "11.5 unknown Expect value returns 417", + h1OneShot( + "GET / HTTP/1.1\r\nHost: localhost\r\nExpect: bogus\r\nConnection: close\r\n\r\n") == 417) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 12: Expect: 100-continue (RFC 7231 §5.1.1) ─────────────────────── + +@test("h1spec_12_expect_continue") +func testExpectContinue() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 12: Expect: 100-continue (RFC 7231 §5.1.1)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + + // Send headers with Expect: 100-continue, then send body + assert(h1WriteAll(fd, + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\nContent-Length: 5\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n")) + + // Server should either send 100 or proceed to respond directly + assert(h1ReadFull(fd, &resp, false)) + var code = resp.statusCode + + if(code == 100) { + // Got 100; now send body and read final response + assert(h1WriteAll(fd, "hello")) + assert(h1ReadFull(fd, &resp, false)) + code = resp.statusCode + } + + check(&p, &f, "12.1 Expect: 100-continue gets 100 or final 2xx", + code >= 100 && code < 300) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 13: Response headers (RFC 7231 §7) ──────────────────────────────── + +@test("h1spec_13_response_headers") +func testResponseHeaders() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 13: Response headers (RFC 7231 §7)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &resp, false)) + + check(&p, &f, "13.1 response includes Connection header", + h1HasHeader(&resp, "Connection:")) + + // 404 response must also be well-formed + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r404: H1Resp + assert(h1WriteAll(fd2, "GET /missing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &r404, false)) + check(&p, &f, "13.2 404 has Content-Length or Transfer-Encoding", + h1ContentLength(&r404) >= 0 || h1HasTE(&r404)) + check(&p, &f, "13.3 404 Content-Length matches body", + h1ContentLength(&r404) < 0 || h1ContentLength(&r404) == h1BodyLen(&r404)) + + // HEAD: no body, but Content-Length header still expected + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var rHead: H1Resp + assert(h1WriteAll(fd3, "HEAD / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd3, &rHead, true)) + check(&p, &f, "13.4 HEAD response body is empty", h1BodyLen(&rHead) == 0) + check(&p, &f, "13.5 HEAD response has Content-Length header", + h1ContentLength(&rHead) >= 0) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 14: URI and header size limits (RFC 7230 §3.1.1, §3.2) ────────── + +@test("h1spec_14_size_limits") +func testSizeLimits() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 14: URI and header size limits (RFC 7230 §3.1.1, §3.2)\n") + + // Very long URI — should get 414 Request-URI Too Long + var longUri: [9100]char + memset(longUri, 'A' as (i32), 9099_usize) + ;longUri[0] = '/' + ;longUri[9099] = '\0' + var longReq: [9200]char + var n = sprintf(longReq, + "GET %s HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", + longUri) + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + h1WriteAll(fd, longReq) + var resp: H1Resp + h1ReadFull(fd, &resp, false) + check(&p, &f, "14.1 overlong URI returns 414 or 400", + resp.statusCode == 414 || resp.statusCode == 400) - // h1spec_runner.py 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-h1spec python3 /app/h1spec_runner.py --host host.docker.internal --port %d", - H1SPEC_PORT as (i32)) + // Very long header value — should get 431 Request Header Fields Too Large + var longVal: [9100]char + memset(longVal, 'B' as (i32), 9099_usize) + ;longVal[9099] = '\0' + var bigHdrReq: [9300]char + sprintf(bigHdrReq, + "GET / HTTP/1.1\r\nHost: localhost\r\nX-Big: %s\r\nConnection: close\r\n\r\n", + longVal) + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + h1WriteAll(fd2, bigHdrReq) + var resp2: H1Resp + h1ReadFull(fd2, &resp2, false) + check(&p, &f, "14.2 overlong header returns 431 or 400", + resp2.statusCode == 431 || resp2.statusCode == 400) - var exitCode = wexitstatus(system(runCmd.cStr())) - assert(exitCode == 0) + printf(" %d passed, %d failed\n", p, f); assert(f == 0) } diff --git a/test/http_parser_test.lita b/test/http_parser_test.lita index 021611e..1eaf866 100644 --- a/test/http_parser_test.lita +++ b/test/http_parser_test.lita @@ -1116,9 +1116,7 @@ func testMultipartPartHeaderWithoutColon() { // Content-Length edge cases // --------------------------------------------------------------------------- -// A non-numeric Content-Length value must be silently coerced to 0 — no error. -// parseU64() returns 0 for non-numeric input; MAX(0, 0) = 0. -// CONTENT_LENGTH_PROVIDED must still be set because the header was present. +// RFC 7230 §3.3.2: a non-numeric Content-Length must be rejected with an error. @test func testNonNumericContentLength() { var parser = HttpParser{} @@ -1126,9 +1124,7 @@ func testNonNumericContentLength() { var result: HttpRequest var input = StringInit("GET / HTTP/1.1\r\nContent-Length: abc\r\n\r\n") - assert(parser.parse(input, &result) == Status.OK) - assert(result.bodyLength == 0) - assert((result.flags & HttpFlags.CONTENT_LENGTH_PROVIDED) != 0) + assert(parser.parse(input, &result) == Status.ERROR_INVALID_CONTENT_LENGTH) } // A very large Content-Length that overflows i32 must be clamped to 0 by From d1c55df54e294f274a6df101ccd5348722271d4f Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 20:29:53 -0500 Subject: [PATCH 11/14] more tests --- test/h1spec_test.lita | 293 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) diff --git a/test/h1spec_test.lita b/test/h1spec_test.lita index 8f8f139..b35bca3 100644 --- a/test/h1spec_test.lita +++ b/test/h1spec_test.lita @@ -229,6 +229,18 @@ func h1HasHeader(resp: *H1Resp, needle: *const char) : bool { return headers.contains(StringInit(needle)) } +// Returns true if the named header line contains token anywhere on that line. +func h1HeaderContains(resp: *H1Resp, header: *const char, token: *const char) : bool { + var headers = String{.buffer = resp.buf, .length = resp.headerLen} + var hi = headers.indexOf(StringInit(header)) + if(hi < 0) { return false } + var lineStart = headers.substring(hi) + var lineEnd = lineStart.indexOf($"\r\n") + if(lineEnd < 0) { lineEnd = lineStart.length } + var line = lineStart.substring(0, lineEnd) + return line.contains(StringInit(token)) +} + func h1BodyLen(resp: *H1Resp) : i32 { return resp.totalLen - resp.headerLen } @@ -704,3 +716,284 @@ func testSizeLimits() { printf(" %d passed, %d failed\n", p, f); assert(f == 0) } + +// ── Section 15: Chunked request body (RFC 7230 §4.1) ───────────────────────── + +@test("h1spec_15_chunked_request_body") +func testChunkedRequestBody() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 15: Chunked request body (RFC 7230 §4.1)\n") + + // Single chunk + var c1 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n") + check(&p, &f, "15.1 single-chunk POST returns 2xx", c1 >= 200 && c1 < 300) + + // Two chunks concatenated + var c2 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n3\r\nabc\r\n2\r\nde\r\n0\r\n\r\n") + check(&p, &f, "15.2 two-chunk POST returns 2xx", c2 >= 200 && c2 < 300) + + // Chunk extension — server must ignore "; name=value" after the size + var c3 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5;ext=foo\r\nhello\r\n0\r\n\r\n") + check(&p, &f, "15.3 chunk extension ignored, returns 2xx", c3 >= 200 && c3 < 300) + + // Zero-length body (only the terminator chunk) + var c4 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\n") + check(&p, &f, "15.4 empty chunked body returns 2xx", c4 >= 200 && c4 < 300) + + // Trailing header after last chunk (RFC 7230 §4.1.2 — server must accept) + var c5 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\nX-Checksum: abc\r\n\r\n") + check(&p, &f, "15.5 chunked with trailer header returns 2xx", c5 >= 200 && c5 < 300) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 16: Absolute-form request target (RFC 7230 §5.3.2) ─────────────── + +@test("h1spec_16_absolute_form_uri") +func testAbsoluteFormUri() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 16: Absolute-form request target (RFC 7230 §5.3.2)\n") + + // Full URI as request target — server must strip the authority + check(&p, &f, "16.1 GET http://localhost/ returns 200", + h1OneShot("GET http://localhost/ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 200) + + // With explicit port + check(&p, &f, "16.2 GET http://localhost:9191/ returns 200", + h1OneShot("GET http://localhost:9191/ HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 200) + + // Unknown path via absolute form + check(&p, &f, "16.3 absolute-form unknown path returns 404", + h1OneShot("GET http://localhost/no-such-path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 404) + + // Large resource via absolute form + check(&p, &f, "16.4 absolute-form GET /large returns 200", + h1OneShot("GET http://localhost/large HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 17: Method case sensitivity (RFC 7230 §3.1.1) ──────────────────── + +@test("h1spec_17_method_case") +func testMethodCase() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 17: Method case sensitivity (RFC 7230 §3.1.1)\n") + + // RFC 7230 §3.1.1: method tokens are case-sensitive; lowercase must not match + var lower = h1OneShot("get / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "17.1 lowercase 'get' returns 4xx or 5xx", lower >= 400) + + var mixed = h1OneShot("Get / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "17.2 mixed-case 'Get' returns 4xx or 5xx", mixed >= 400) + + var upper = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + check(&p, &f, "17.3 correct uppercase POST still returns 2xx", upper >= 200 && upper < 300) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 18: Error response framing (RFC 7230 §3.3) ─────────────────────── + +@test("h1spec_18_error_response_framing") +func testErrorResponseFraming() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 18: Error response framing (RFC 7230 §3.3)\n") + + // 400 — missing Host header + var fd1 = h1Connect(); assert(fd1 >= 0); defer close(fd1) + var r400: H1Resp + h1WriteAll(fd1, "GET / HTTP/1.1\r\n\r\n") + h1ReadFull(fd1, &r400, false) + check(&p, &f, "18.1 400 response has Content-Length or TE", + h1ContentLength(&r400) >= 0 || h1HasTE(&r400)) + check(&p, &f, "18.2 400 Content-Length matches body", + h1ContentLength(&r400) < 0 || h1ContentLength(&r400) == h1BodyLen(&r400)) + + // 404 — unknown route + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r404: H1Resp + h1WriteAll(fd2, "GET /does-not-exist HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + h1ReadFull(fd2, &r404, false) + check(&p, &f, "18.3 404 response has Content-Length or TE", + h1ContentLength(&r404) >= 0 || h1HasTE(&r404)) + check(&p, &f, "18.4 404 Content-Length matches body", + h1ContentLength(&r404) < 0 || h1ContentLength(&r404) == h1BodyLen(&r404)) + + // 501 — unregistered method + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var r501: H1Resp + h1WriteAll(fd3, "PUT / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + h1ReadFull(fd3, &r501, false) + check(&p, &f, "18.5 501/405 response has Content-Length or TE", + h1ContentLength(&r501) >= 0 || h1HasTE(&r501)) + check(&p, &f, "18.6 501/405 Content-Length matches body", + h1ContentLength(&r501) < 0 || h1ContentLength(&r501) == h1BodyLen(&r501)) + + // 505 — unsupported HTTP version + var fd4 = h1Connect(); assert(fd4 >= 0); defer close(fd4) + var r505: H1Resp + h1WriteAll(fd4, "GET / HTTP/2.0\r\nHost: localhost\r\n\r\n") + h1ReadFull(fd4, &r505, false) + check(&p, &f, "18.7 505 response has Content-Length or TE", + h1ContentLength(&r505) >= 0 || h1HasTE(&r505)) + check(&p, &f, "18.8 505 Content-Length matches body", + h1ContentLength(&r505) < 0 || h1ContentLength(&r505) == h1BodyLen(&r505)) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 19: Query string routing (RFC 7230 §5.3.1) ─────────────────────── + +@test("h1spec_19_query_string_routing") +func testQueryStringRouting() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 19: Query string routing (RFC 7230 §5.3.1)\n") + + // Query must be stripped before routing; /? still matches / + check(&p, &f, "19.1 GET /?key=value returns 200", + h1OneShot("GET /?key=value HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 200) + + check(&p, &f, "19.2 GET /large?page=2 returns 200", + h1OneShot("GET /large?page=2 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 200) + + // Unknown path with query is still 404 + check(&p, &f, "19.3 GET /missing?x=y returns 404", + h1OneShot("GET /missing?x=y HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") == 404) + + // POST with query + var c4 = h1OneShot("POST /?debug=1 HTTP/1.1\r\nHost: localhost\r\nContent-Length: 3\r\nConnection: close\r\n\r\nfoo") + check(&p, &f, "19.4 POST /?debug=1 returns 2xx", c4 >= 200 && c4 < 300) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 20: OPTIONS method (RFC 7231 §4.3.7) ───────────────────────────── + +@test("h1spec_20_options_method") +func testOptionsMethod() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 20: OPTIONS method (RFC 7231 §4.3.7)\n") + + // OPTIONS on a registered path → 200 + Allow (auto-response) + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + h1WriteAll(fd, "OPTIONS / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + h1ReadFull(fd, &resp, false) + check(&p, &f, "20.1 OPTIONS / returns 200", resp.statusCode == 200) + check(&p, &f, "20.2 OPTIONS response includes Allow", h1HasHeader(&resp, "Allow:")) + check(&p, &f, "20.3 Allow lists OPTIONS", h1HeaderContains(&resp, "Allow:", "OPTIONS")) + check(&p, &f, "20.4 Allow lists GET", h1HeaderContains(&resp, "Allow:", "GET")) + check(&p, &f, "20.5 Allow lists POST", h1HeaderContains(&resp, "Allow:", "POST")) + + // OPTIONS on an unknown path → server still auto-responds 200 (server-wide capabilities) + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r2: H1Resp + h1WriteAll(fd2, "OPTIONS /unknown HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + h1ReadFull(fd2, &r2, false) + check(&p, &f, "20.6 OPTIONS /unknown returns 200", r2.statusCode == 200) + check(&p, &f, "20.7 OPTIONS /unknown still has Allow header", h1HasHeader(&r2, "Allow:")) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 21: 405 Method Not Allowed + Allow header (RFC 7231 §6.5.5) ────── + +@test("h1spec_21_method_not_allowed") +func testMethodNotAllowed() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 21: 405 Method Not Allowed (RFC 7231 §6.5.5)\n") + + // PUT / — GET and POST are registered; PUT is not + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + h1WriteAll(fd, "PUT / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + h1ReadFull(fd, &resp, false) + check(&p, &f, "21.1 PUT / returns 405", resp.statusCode == 405) + check(&p, &f, "21.2 405 includes Allow header", h1HasHeader(&resp, "Allow:")) + check(&p, &f, "21.3 Allow includes GET", h1HeaderContains(&resp, "Allow:", "GET")) + check(&p, &f, "21.4 Allow includes POST", h1HeaderContains(&resp, "Allow:", "POST")) + check(&p, &f, "21.5 405 has Content-Length or TE", + h1ContentLength(&resp) >= 0 || h1HasTE(&resp)) + check(&p, &f, "21.6 405 Content-Length matches body", + h1ContentLength(&resp) < 0 || h1ContentLength(&resp) == h1BodyLen(&resp)) + + // DELETE / — also not registered + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r2: H1Resp + h1WriteAll(fd2, "DELETE / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + h1ReadFull(fd2, &r2, false) + check(&p, &f, "21.7 DELETE / returns 405", r2.statusCode == 405) + check(&p, &f, "21.8 DELETE 405 also includes Allow", h1HasHeader(&r2, "Allow:")) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 22: Body boundary in keep-alive (RFC 7230 §3.3) ────────────────── + +@test("h1spec_22_body_boundary") +func testBodyBoundary() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 22: Body boundary in keep-alive (RFC 7230 §3.3)\n") + + // POST body must not bleed into the next request on the same connection + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + assert(h1WriteAll(fd, "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nhello")) + var r1: H1Resp + assert(h1ReadFull(fd, &r1, false)) + check(&p, &f, "22.1 POST with body returns 2xx", r1.statusCode >= 200 && r1.statusCode < 300) + + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + var r2: H1Resp + assert(h1ReadFull(fd, &r2, false)) + check(&p, &f, "22.2 GET after POST returns 200", r2.statusCode == 200) + + // Same test with a chunked POST + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + assert(h1WriteAll(fd2, "POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n")) + var r3: H1Resp + assert(h1ReadFull(fd2, &r3, false)) + check(&p, &f, "22.3 chunked POST returns 2xx", r3.statusCode >= 200 && r3.statusCode < 300) + + assert(h1WriteAll(fd2, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + var r4: H1Resp + assert(h1ReadFull(fd2, &r4, false)) + check(&p, &f, "22.4 GET after chunked POST returns 200", r4.statusCode == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 23: Header value edge cases (RFC 7230 §3.2) ────────────────────── + +@test("h1spec_23_header_value_edge_cases") +func testHeaderValueEdgeCases() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 23: Header value edge cases (RFC 7230 §3.2)\n") + + // Colon in value must not be treated as a second header separator + check(&p, &f, "23.1 header value with colons returns 200", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nX-Token: scheme:name:extra\r\nConnection: close\r\n\r\n") == 200) + + // Empty header value (valid per RFC 7230 §3.2) + check(&p, &f, "23.2 empty header value returns 200", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nX-Empty:\r\nConnection: close\r\n\r\n") == 200) + + // Multiple headers with the same non-Host name (server must not reject) + check(&p, &f, "23.3 duplicate non-Host headers accepted", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nX-Tag: a\r\nX-Tag: b\r\nConnection: close\r\n\r\n") == 200) + + // Complex Authorization value with colons and base64 padding + check(&p, &f, "23.4 Authorization with colons in value returns 200", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic dXNlcjpwYXNz\r\nConnection: close\r\n\r\n") == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} From e4cfeef638fb43121315a8ec2766f57ed99e9208 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 20:45:14 -0500 Subject: [PATCH 12/14] more tests --- src/http_worker.lita | 3 +- src/http_worker_iouring.lita | 7 +- src/http_worker_kqueue.lita | 6 +- test/h1spec_test.lita | 158 +++++++++++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) diff --git a/src/http_worker.lita b/src/http_worker.lita index 8cd7208..686ce15 100644 --- a/src/http_worker.lita +++ b/src/http_worker.lita @@ -861,8 +861,9 @@ internal func (this: *WorkerThread) tryDetectProtocol( session.http2.init(&session.requestAllocator.allocator) Debug("HTTP/2 connection detected on socket: %d\n", session.connection.handle()) } else if(b0 == 'G' || b0 == 'D' || b0 == 'H' || b0 == 'O' || b0 == 'T' || b0 == 'C' || - (b0 == 'P' && b1 != 'R')) { + (b0 == 'P' && b1 != 'R') || b0 == '\r') { // Known HTTP/1.1 method first byte: GET, DELETE, HEAD, OPTIONS, TRACE, CONNECT, POST/PUT/PATCH. + // '\r' (CRLF) cannot start HTTP/2 (which begins "PRI "); route to HTTP/1.1 parser for 400. session.protocolVersion = ProtocolVersion.HTTP1 } else { // Unknown first byte — sniff the URL to distinguish HTTP/1.1 from an invalid HTTP/2 preface. diff --git a/src/http_worker_iouring.lita b/src/http_worker_iouring.lita index 33ef6c2..f5b3401 100644 --- a/src/http_worker_iouring.lita +++ b/src/http_worker_iouring.lita @@ -1107,13 +1107,18 @@ func (this: *WorkerThread) handleCompletionRing( case Status.ERROR_INVALID_BODY_EXCEEDED_LIMIT: case Status.ERROR_PARSING_HTTP_REQUEST: case Status.ERROR_MISSING_HOST_HEADER: + case Status.ERROR_DUPLICATE_HOST_HEADER: case Status.ERROR_HTTP_VERSION_NOT_SUPPORTED: case Status.ERROR_EXPECT_FAILED: case Status.ERROR_CONFLICTING_BODY_HEADERS: case Status.ERROR_CONFLICTING_CONTENT_LENGTH: case Status.ERROR_INVALID_CONTENT_LENGTH: case Status.ERROR_UNSUPPORTED_REQUEST_METHOD: - case Status.ERROR_WEB_SOCKET_MISSING_CONNECTION: { + case Status.ERROR_WEB_SOCKET_MISSING_CONNECTION: + case Status.ERROR_HTTP_PARSING_INVALID_REQUEST_LINE: + case Status.ERROR_HTTP_PARSING_INVALID_HEADER_PARAM: + case Status.ERROR_HTTP_PARSING_INVALID_CHUNK: + case Status.ERROR_HTTP_PARSING_CHUNK_INCOMPLETE: { return this.sendBadRequest(session, status) } default: { diff --git a/src/http_worker_kqueue.lita b/src/http_worker_kqueue.lita index e5d27e7..bcf4ed5 100644 --- a/src/http_worker_kqueue.lita +++ b/src/http_worker_kqueue.lita @@ -873,7 +873,11 @@ func (this: *WorkerThread) handleReadReady(session: *SessionContext) { case Status.ERROR_CONFLICTING_CONTENT_LENGTH: case Status.ERROR_INVALID_CONTENT_LENGTH: case Status.ERROR_UNSUPPORTED_REQUEST_METHOD: - case Status.ERROR_WEB_SOCKET_MISSING_CONNECTION: { + case Status.ERROR_WEB_SOCKET_MISSING_CONNECTION: + case Status.ERROR_HTTP_PARSING_INVALID_REQUEST_LINE: + case Status.ERROR_HTTP_PARSING_INVALID_HEADER_PARAM: + case Status.ERROR_HTTP_PARSING_INVALID_CHUNK: + case Status.ERROR_HTTP_PARSING_CHUNK_INCOMPLETE: { this.sendBadRequest(session, status) break } diff --git a/test/h1spec_test.lita b/test/h1spec_test.lita index b35bca3..4090f05 100644 --- a/test/h1spec_test.lita +++ b/test/h1spec_test.lita @@ -997,3 +997,161 @@ func testHeaderValueEdgeCases() { printf(" %d passed, %d failed\n", p, f); assert(f == 0) } + +// ── Section 24: Transfer-Encoding case insensitivity (RFC 7230 §3.3.1) ──────── + +@test("h1spec_24_te_case_insensitive") +func testTECaseInsensitive() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 24: Transfer-Encoding case insensitivity (RFC 7230 §3.3.1)\n") + + // RFC 7230 §3.3.1: transfer-coding names are case-insensitive + var c1 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: CHUNKED\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n") + check(&p, &f, "24.1 Transfer-Encoding: CHUNKED (uppercase) returns 2xx", c1 >= 200 && c1 < 300) + + var c2 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: Chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n") + check(&p, &f, "24.2 Transfer-Encoding: Chunked (mixed case) returns 2xx", c2 >= 200 && c2 < 300) + + var c3 = h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\ntransfer-encoding: chunked\r\nConnection: close\r\n\r\n3\r\nabc\r\n0\r\n\r\n") + check(&p, &f, "24.3 transfer-encoding: chunked (lowercase name) returns 2xx", c3 >= 200 && c3 < 300) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 25: HEAD /large framing (RFC 7231 §4.3.2) ──────────────────────── + +@test("h1spec_25_head_large") +func testHeadLarge() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 25: HEAD /large framing (RFC 7231 §4.3.2)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + assert(h1WriteAll(fd, "HEAD /large HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &resp, true)) + + check(&p, &f, "25.1 HEAD /large returns 200", resp.statusCode == 200) + check(&p, &f, "25.2 HEAD /large body is empty", h1BodyLen(&resp) == 0) + check(&p, &f, "25.3 HEAD /large Content-Length is 1024", h1ContentLength(&resp) == H1_LARGE_SIZE) + + // Verify HEAD Content-Length matches GET Content-Length + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var getRsp: H1Resp + assert(h1WriteAll(fd2, "GET /large HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &getRsp, false)) + check(&p, &f, "25.4 HEAD Content-Length equals GET Content-Length", + h1ContentLength(&resp) == h1ContentLength(&getRsp)) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 26: Malformed request line (RFC 7230 §3.1.1) ───────────────────── + +@test("h1spec_26_malformed_request_line") +func testMalformedRequestLine() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 26: Malformed request line (RFC 7230 §3.1.1)\n") + + // Method with no path or version + check(&p, &f, "26.1 'GET\\r\\n\\r\\n' returns 400", + h1OneShot("GET\r\n\r\n") == 400) + + // Blank line as entire request + check(&p, &f, "26.2 empty request line returns 400", + h1OneShot("\r\n\r\n") == 400) + + // Missing HTTP version + check(&p, &f, "26.3 'GET /\\r\\n\\r\\n' (no version) returns 400", + h1OneShot("GET /\r\n\r\n") == 400) + + // Two tokens but no version + check(&p, &f, "26.4 'GET / \\r\\n\\r\\n' (empty version) returns 400 or 505", + h1OneShot("GET / \r\n\r\n") == 400 || h1OneShot("GET / \r\n\r\n") == 505) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 27: OPTIONS * (RFC 7231 §4.3.7) ────────────────────────────────── + +@test("h1spec_27_options_star") +func testOptionsStar() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 27: OPTIONS * (RFC 7231 §4.3.7)\n") + + // Asterisk-form applies to the origin server in general, not a specific resource + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + h1WriteAll(fd, "OPTIONS * HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + h1ReadFull(fd, &resp, false) + check(&p, &f, "27.1 OPTIONS * returns 200", resp.statusCode == 200) + check(&p, &f, "27.2 OPTIONS * response has Allow", h1HasHeader(&resp, "Allow:")) + check(&p, &f, "27.3 OPTIONS * Allow includes OPTIONS", h1HeaderContains(&resp, "Allow:", "OPTIONS")) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 28: GET with request body (RFC 7230 §3.3) ──────────────────────── + +@test("h1spec_28_get_with_body") +func testGetWithBody() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 28: GET with request body (RFC 7230 §3.3)\n") + + // RFC 7230 §3.3: a server must not apply Content-Length restrictions based on method; + // GET with a body is unusual but not forbidden + var c1 = h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello") + check(&p, &f, "28.1 GET with Content-Length body returns 2xx", c1 >= 200 && c1 < 300) + + // Subsequent GET on keep-alive must not include previous body + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 3\r\n\r\nfoo")) + var r1: H1Resp + assert(h1ReadFull(fd, &r1, false)) + check(&p, &f, "28.2 GET with body returns 200", r1.statusCode == 200) + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + var r2: H1Resp + assert(h1ReadFull(fd, &r2, false)) + check(&p, &f, "28.3 subsequent GET on same conn returns 200", r2.statusCode == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 29: Keep-alive with varying response sizes (RFC 7230 §6.3) ──────── + +@test("h1spec_29_keepalive_varying_sizes") +func testKeepaliveVaryingSizes() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 29: Keep-alive with varying response sizes (RFC 7230 §6.3)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + + // Small → large → small: each Content-Length must be exact + var rSmall1: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")) + assert(h1ReadFull(fd, &rSmall1, false)) + check(&p, &f, "29.1 small GET returns 200", rSmall1.statusCode == 200) + check(&p, &f, "29.2 small GET Content-Length correct", h1ContentLength(&rSmall1) == h1BodyLen(&rSmall1)) + + var rLarge: H1Resp + assert(h1WriteAll(fd, "GET /large HTTP/1.1\r\nHost: localhost\r\n\r\n")) + assert(h1ReadFull(fd, &rLarge, false)) + check(&p, &f, "29.3 large GET returns 200", rLarge.statusCode == 200) + check(&p, &f, "29.4 large GET Content-Length is 1024", h1ContentLength(&rLarge) == H1_LARGE_SIZE) + check(&p, &f, "29.5 large GET body is 1024 bytes", h1BodyLen(&rLarge) == H1_LARGE_SIZE) + + var rSmall2: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &rSmall2, false)) + check(&p, &f, "29.6 second small GET returns 200", rSmall2.statusCode == 200) + check(&p, &f, "29.7 second small GET Content-Length correct", h1ContentLength(&rSmall2) == h1BodyLen(&rSmall2)) + check(&p, &f, "29.8 both small GETs have same Content-Length", + h1ContentLength(&rSmall1) == h1ContentLength(&rSmall2)) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} From 4b9ab1bdcc569c1014d4dd5a7b3e7b9470af09b1 Mon Sep 17 00:00:00 2001 From: tonysparks Date: Mon, 25 May 2026 21:04:24 -0500 Subject: [PATCH 13/14] more tests --- test/h1spec_test.lita | 190 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/test/h1spec_test.lita b/test/h1spec_test.lita index 4090f05..2e8a15f 100644 --- a/test/h1spec_test.lita +++ b/test/h1spec_test.lita @@ -245,6 +245,14 @@ func h1BodyLen(resp: *H1Resp) : i32 { return resp.totalLen - resp.headerLen } +// Returns true if the status line has a non-empty reason phrase after the 3-digit code. +// Format: "HTTP/1.1 NNN REASON\r\n" — byte 12 must be SP, byte 13 must not be CR. +func h1HasReasonPhrase(resp: *H1Resp) : bool { + if(resp.totalLen < 14) { return false } + if(resp.buf[12] != ' ') { return false } + return resp.buf[13] != '\r' +} + // Quick one-shot: connect, send, read, close. Returns status code or -1. func h1OneShot(req: *const char) : i32 { var fd = h1Connect() @@ -1155,3 +1163,185 @@ func testKeepaliveVaryingSizes() { printf(" %d passed, %d failed\n", p, f); assert(f == 0) } + +// ── Section 30: Date header (RFC 7231 §7.1.1.2) ────────────────────────────── + +@test("h1spec_30_date_header") +func testDateHeader() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 30: Date header (RFC 7231 §7.1.1.2)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var r200: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &r200, false)) + check(&p, &f, "30.1 200 response has Date header", h1HasHeader(&r200, "Date:")) + + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r404: H1Resp + assert(h1WriteAll(fd2, "GET /no-such-path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &r404, false)) + check(&p, &f, "30.2 404 response has Date header", h1HasHeader(&r404, "Date:")) + + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var r400: H1Resp + h1WriteAll(fd3, "GET / HTTP/1.1\r\n\r\n") + h1ReadFull(fd3, &r400, false) + check(&p, &f, "30.3 400 response has Date header", h1HasHeader(&r400, "Date:")) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 31: Server header (RFC 7231 §7.4.2) ────────────────────────────── + +@test("h1spec_31_server_header") +func testServerHeader() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 31: Server header (RFC 7231 §7.4.2)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var r200: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &r200, false)) + check(&p, &f, "31.1 200 response has Server header", h1HasHeader(&r200, "Server:")) + + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r404: H1Resp + assert(h1WriteAll(fd2, "GET /no-such-path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &r404, false)) + check(&p, &f, "31.2 404 response has Server header", h1HasHeader(&r404, "Server:")) + + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var r400: H1Resp + h1WriteAll(fd3, "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: abc\r\nConnection: close\r\n\r\n") + h1ReadFull(fd3, &r400, false) + check(&p, &f, "31.3 400 response has Server header", h1HasHeader(&r400, "Server:")) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 32: Reason phrase in status line (RFC 7230 §3.1.2) ─────────────── + +@test("h1spec_32_reason_phrase") +func testReasonPhrase() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 32: Reason phrase in status line (RFC 7230 §3.1.2)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var r200: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &r200, false)) + check(&p, &f, "32.1 200 status line has SP after code", r200.totalLen > 12 && r200.buf[12] == ' ') + check(&p, &f, "32.2 200 status line has non-empty reason", h1HasReasonPhrase(&r200)) + + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r404: H1Resp + assert(h1WriteAll(fd2, "GET /no-such-path HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &r404, false)) + check(&p, &f, "32.3 404 status line has non-empty reason", h1HasReasonPhrase(&r404)) + + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var r400: H1Resp + h1WriteAll(fd3, "GET / HTTP/1.1\r\n\r\n") + h1ReadFull(fd3, &r400, false) + check(&p, &f, "32.4 400 status line has non-empty reason", h1HasReasonPhrase(&r400)) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 33: Header field name case insensitivity (RFC 7230 §3.2) ────────── + +@test("h1spec_33_header_case_insensitive") +func testHeaderCaseInsensitive() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 33: Header field name case insensitivity (RFC 7230 §3.2)\n") + + check(&p, &f, "33.1 HOST: (uppercase) is accepted", + h1OneShot("GET / HTTP/1.1\r\nHOST: localhost\r\nConnection: close\r\n\r\n") == 200) + + check(&p, &f, "33.2 host: (lowercase) is accepted", + h1OneShot("GET / HTTP/1.1\r\nhost: localhost\r\nconnection: close\r\n\r\n") == 200) + + check(&p, &f, "33.3 CONTENT-LENGTH: (uppercase) is accepted", + h1OneShot("POST / HTTP/1.1\r\nHOST: localhost\r\nCONTENT-LENGTH: 2\r\nCONNECTION: close\r\n\r\nhi") == 200) + + check(&p, &f, "33.4 TRANSFER-ENCODING: chunked (uppercase) is accepted", + h1OneShot("POST / HTTP/1.1\r\nHOST: localhost\r\nTRANSFER-ENCODING: chunked\r\nCONNECTION: close\r\n\r\n2\r\nhi\r\n0\r\n\r\n") == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 34: Zero Content-Length body (RFC 7230 §3.3.2) ─────────────────── + +@test("h1spec_34_zero_content_length") +func testZeroContentLength() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 34: Zero Content-Length (RFC 7230 §3.3.2)\n") + + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var rPost: H1Resp + assert(h1WriteAll(fd, "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &rPost, false)) + check(&p, &f, "34.1 POST with Content-Length: 0 returns 2xx", + rPost.statusCode >= 200 && rPost.statusCode < 300) + + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var rGet: H1Resp + assert(h1WriteAll(fd2, "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &rGet, false)) + check(&p, &f, "34.2 GET with Content-Length: 0 returns 200", rGet.statusCode == 200) + + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var rHead: H1Resp + assert(h1WriteAll(fd3, "HEAD / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd3, &rHead, true)) + check(&p, &f, "34.3 HEAD with Content-Length: 0 returns 200", rHead.statusCode == 200) + check(&p, &f, "34.4 HEAD response has no body", h1BodyLen(&rHead) == 0) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 35: Multiple instances of the same header (RFC 7230 §3.2.2) ─────── + +@test("h1spec_35_multiple_headers") +func testMultipleHeaders() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 35: Multiple instances of same header (RFC 7230 §3.2.2)\n") + + check(&p, &f, "35.1 multiple Accept headers are accepted", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nAccept: text/html\r\nAccept: application/json\r\nConnection: close\r\n\r\n") == 200) + + check(&p, &f, "35.2 multiple Accept-Encoding headers are accepted", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n") == 200) + + check(&p, &f, "35.3 multiple unknown headers are accepted", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nX-Req-Id: 1\r\nX-Req-Id: 2\r\nConnection: close\r\n\r\n") == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 36: TRACE and CONNECT routing (RFC 7231 §4.3.6, §4.3.8) ────────── + +@test("h1spec_36_trace_connect") +func testTraceConnect() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 36: TRACE and CONNECT routing (RFC 7231 §4.3.6, §4.3.8)\n") + + var trace = h1OneShot("TRACE / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "36.1 TRACE / returns 4xx or 5xx", trace >= 400) + + var traceStar = h1OneShot("TRACE * HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "36.2 TRACE * returns 4xx or 5xx", traceStar >= 400) + + var conn = h1OneShot("CONNECT localhost:80 HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + check(&p, &f, "36.3 CONNECT returns 4xx or 5xx", conn >= 400) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} From c08eb1579672fce228b364e4b83b3b8df628043e Mon Sep 17 00:00:00 2001 From: tonysparks Date: Wed, 27 May 2026 17:31:09 -0500 Subject: [PATCH 14/14] more compliance tests --- src/http_parser.lita | 2 +- test/h1spec_test.lita | 120 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/http_parser.lita b/src/http_parser.lita index facfedd..dc2ce14 100644 --- a/src/http_parser.lita +++ b/src/http_parser.lita @@ -231,7 +231,7 @@ func (this: *HttpParser) parseHeaders(input: *String, result: *HttpRequest) : St return Status.ERROR_HTTP_PARSING_INVALID_HEADER_PARAM } - currentHeader.name = header.substring(0, colonIndex).trim() + currentHeader.name = header.substring(0, colonIndex).trim() currentHeader.values = header.substring(colonIndex + 1).trim() var status = this.checkHeader(¤tHeader, result) diff --git a/test/h1spec_test.lita b/test/h1spec_test.lita index 2e8a15f..b1af71b 100644 --- a/test/h1spec_test.lita +++ b/test/h1spec_test.lita @@ -1345,3 +1345,123 @@ func testTraceConnect() { printf(" %d passed, %d failed\n", p, f); assert(f == 0) } + +// ── Section 38: Obs-fold (header continuation lines) (RFC 7230 §3.2.6) ──────── + +@test("h1spec_38_obs_fold") +func testObsFold() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 38: Obs-fold rejection (RFC 7230 §3.2.6)\n") + + // SP at the start of a line → obsolete header folding → 400 + check(&p, &f, "38.1 SP continuation line returns 400", + h1OneShot("GET / HTTP/1.1\r\nHost: local\r\n host\r\nConnection: close\r\n\r\n") == 400) + + // HT at the start of a line → same + check(&p, &f, "38.2 HT continuation line returns 400", + h1OneShot("GET / HTTP/1.1\r\nHost: local\r\n\thost\r\nConnection: close\r\n\r\n") == 400) + + // Normal multi-line request (no obs-fold) must still work + check(&p, &f, "38.3 normal headers without obs-fold are accepted", + h1OneShot("GET / HTTP/1.1\r\nHost: localhost\r\nX-A: 1\r\nX-B: 2\r\nConnection: close\r\n\r\n") == 200) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 39: POST without body framing (RFC 7230 §3.3.3 / RFC 7231 §6.5.10) + +@test("h1spec_39_post_no_framing") +func testPostNoFraming() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 39: POST without body framing (RFC 7230 §3.3.3)\n") + + // POST with body data but no Content-Length or Transfer-Encoding → 411 + check(&p, &f, "39.1 POST with body but no framing returns 411", + h1OneShot("POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\nhello") == 411) + + // POST with no body and no Content-Length is treated as zero-length body → 200 + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var resp: H1Resp + assert(h1WriteAll(fd, "POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd, &resp, false)) + check(&p, &f, "39.2 POST with no body and no Content-Length returns 2xx", + resp.statusCode >= 200 && resp.statusCode < 300) + + // 411 response has Content-Length framing + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var r411: H1Resp + assert(h1WriteAll(fd2, "POST / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\nhello")) + assert(h1ReadFull(fd2, &r411, false)) + check(&p, &f, "39.3 411 response has Content-Length or TE", + h1ContentLength(&r411) >= 0 || h1HasTE(&r411)) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 40: Connection header in response (RFC 7230 §6.1, §6.3) ─────────── + +@test("h1spec_40_connection_response_header") +func testConnectionResponseHeader() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 40: Connection header in response (RFC 7230 §6.1, §6.3)\n") + + // Default (no Connection in request) → Connection: keep-alive + var fd = h1Connect(); assert(fd >= 0); defer close(fd) + var rKeep: H1Resp + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")) + assert(h1ReadFull(fd, &rKeep, false)) + check(&p, &f, "40.1 response has Connection header", + h1HasHeader(&rKeep, "Connection:")) + check(&p, &f, "40.2 default 200 has Connection: keep-alive", + h1HeaderContains(&rKeep, "Connection:", "keep-alive")) + // Connection is still open — send a second request to confirm + assert(h1WriteAll(fd, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + var rClose2: H1Resp + assert(h1ReadFull(fd, &rClose2, false)) + check(&p, &f, "40.3 connection stayed open after keep-alive response", + rClose2.statusCode == 200) + + // Connection: close → response has Connection: close + var fd2 = h1Connect(); assert(fd2 >= 0); defer close(fd2) + var rClose: H1Resp + assert(h1WriteAll(fd2, "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd2, &rClose, false)) + check(&p, &f, "40.4 Connection: close request → response has Connection: close", + h1HeaderContains(&rClose, "Connection:", "close")) + + // 404 also carries Connection header + var fd3 = h1Connect(); assert(fd3 >= 0); defer close(fd3) + var r404: H1Resp + assert(h1WriteAll(fd3, "GET /nope HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")) + assert(h1ReadFull(fd3, &r404, false)) + check(&p, &f, "40.5 404 response has Connection header", + h1HasHeader(&r404, "Connection:")) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +} + +// ── Section 41: Identical duplicate Content-Length (RFC 7230 §3.3.2) ────────── + +@test("h1spec_41_identical_duplicate_cl") +func testIdenticalDuplicateCL() { + h1EnsureServer() + var p = 0; var f = 0 + printf("\nSection 41: Identical duplicate Content-Length (RFC 7230 §3.3.2)\n") + + // RFC 7230 §3.3.2: duplicate Content-Length with SAME value — server MAY accept or reject. + // Our server tolerates identical duplicates (keeps the first value). + var same = h1OneShot( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello") + check(&p, &f, "41.1 duplicate Content-Length with same value returns 2xx or 400", + same >= 200 && same < 300 || same == 400) + + // Differing values must be rejected (already tested in §11, verify here too) + check(&p, &f, "41.2 duplicate Content-Length with different values returns 400", + h1OneShot( + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\nContent-Length: 6\r\nConnection: close\r\n\r\nhello") == 400) + + printf(" %d passed, %d failed\n", p, f); assert(f == 0) +}