package httphi import ( "context" "errors" "io" "slices" "strconv" "strings" "testing" "time" "github.com/soypat/lneto/http/httpraw" "github.com/soypat/lneto" ) func nopBackoff(consecutiveBackoffs uint) time.Duration { return lneto.BackoffFlagNop } // defaultNumHeaderKVCap is the field table tests get unless they set their own: // room for a realistic request, the sizing being what the test is about only // where it says so. const defaultNumHeaderKVCap = 32 // defaultKVCap is the pair table size tests hand to [httpraw.Form.Reset], a // bounded form needing room for the pairs it parses. See [httpraw.Form.Reset]. const defaultKVCap = 8 // newExchange returns an Exchange acquired on conn, ready to serve a request. func newExchange(t *testing.T, conn conn, cfg ExchangeConfig) *Exchange { t.Helper() exch := new(Exchange) if cfg.NumHeaderKVCap == 0 { cfg.NumHeaderKVCap = defaultNumHeaderKVCap } exch.Configure(cfg) if !exch.Acquire(conn) { t.Fatal("fresh exchange failed to acquire connection") } return exch } // serve runs a single exchange to completion on the calling goroutine. func serve(t *testing.T, request string, mux Mux) *rwconn { t.Helper() const bufferSize = 1024 conn := newConn(request) conn.Hangup() // Whole request already pending, nothing more will arrive. exch := newExchange(t, conn, ExchangeConfig{ RawBuf: make([]byte, 2*bufferSize), RequestBufferLim: bufferSize, }) err := Handle(exch, mux, nopBackoff) if err != nil { t.Fatalf("Handle(%q): %s", request, err) } return conn } // WriteHeader must emit a complete status line terminated in CRLF followed by // the end-of-headers CRLF, for every status code including the longest text. func TestExchangeWriteHeader(t *testing.T) { var buf [128]byte for _, test := range []struct { code int want string }{ {code: 200, want: "HTTP/1.1 200 OK\r\n\r\n"}, {code: 404, want: "HTTP/1.1 404 Not Found\r\n\r\n"}, {code: 500, want: "HTTP/1.1 500 Internal Server Error\r\n\r\n"}, // Longest status text in status.go: worst case for the status line buffer. {code: 511, want: "HTTP/1.1 511 Network Authentication Required\r\n\r\n"}, } { t.Run(strconv.Itoa(test.code), func(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64}) exch.WriteHeader(test.code) if got := conn.ViewWritten(); got != test.want { t.Errorf("want %q, got %q", test.want, got) } }) } } // Status line is written once: a second WriteHeader must not reach the wire. func TestExchangeWriteHeaderOnce(t *testing.T) { var buf [128]byte conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64}) exch.WriteHeader(404) exch.WriteHeader(500) const want = "HTTP/1.1 404 Not Found\r\n\r\n" if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } } // Write with no prior WriteHeader must flush a 200 header ahead of the body. func TestExchangeWriteFlushesHeader(t *testing.T) { var buf [128]byte const body = "hello" conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64}) n, err := exch.WriteBody([]byte(body)) if err != nil { t.Fatal(err) } if n != len(body) { t.Errorf("want %d bytes written, got %d", len(body), n) } const want = "HTTP/1.1 200 OK\r\n\r\n" + body if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } } func TestExchangeSetHeader(t *testing.T) { for _, test := range []struct { name string normalize bool set [][2]string want string // Header block emitted after the status line. }{ {name: "none", want: "\r\n"}, {name: "single", set: [][2]string{{"Content-Type", "text/plain"}}, want: "Content-Type:text/plain\r\n\r\n"}, { name: "multiple", set: [][2]string{{"Content-Type", "text/plain"}, {"Content-Length", "5"}}, want: "Content-Type:text/plain\r\nContent-Length:5\r\n\r\n", }, { name: "normalized key", normalize: true, set: [][2]string{{"content-TYPE", "text/plain"}}, want: "Content-Type:text/plain\r\n\r\n", }, { name: "key kept verbatim when not normalizing", set: [][2]string{{"content-TYPE", "text/plain"}}, want: "content-TYPE:text/plain\r\n\r\n", }, {name: "empty value", set: [][2]string{{"X-Empty", ""}}, want: "X-Empty:\r\n\r\n"}, } { t.Run(test.name, func(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 128), RequestBufferLim: 64, NormalizeOutgoingKeys: test.normalize}) for _, kv := range test.set { if !exch.StageHeader(kv[0], kv[1]) { t.Fatalf("SetHeader(%q,%q) reported insufficient memory", kv[0], kv[1]) } } exch.WriteHeader(200) got, found := strings.CutPrefix(conn.ViewWritten(), "HTTP/1.1 200 OK\r\n") if !found { t.Fatalf("want 200 status line, got %q", conn.ViewWritten()) } if got != test.want { t.Errorf("want header block %q, got %q", test.want, got) } }) } } // SetHeader must refuse to write past the buffer and say so, never panic nor // emit a truncated field. func TestExchangeSetHeaderOOM(t *testing.T) { const bufferSize = 32 conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*bufferSize), RequestBufferLim: bufferSize}) if exch.StageHeader("X-Big", strings.Repeat("v", 4*bufferSize)) { t.Fatal("want insufficient memory reported for oversized header value") } exch.WriteHeader(200) if got := conn.ViewWritten(); strings.Contains(got, "X-Big") { t.Errorf("dropped header must not appear in response, got %q", got) } } // Request line and fields the handler observes, over a spread of well formed // and awkward but legal request headers. func TestHandleRequestFields(t *testing.T) { for _, test := range []struct { name string request string wantMethod string wantURI string wantHost string }{ { name: "minimal", request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n", wantMethod: "GET", wantURI: "/", wantHost: "h", }, { name: "query string", request: "GET /search?q=go&n=1 HTTP/1.1\r\nHost: h\r\n\r\n", wantMethod: "GET", wantURI: "/search?q=go&n=1", wantHost: "h", }, { name: "no fields", request: "GET /x HTTP/1.1\r\n\r\n", wantMethod: "GET", wantURI: "/x", wantHost: "", }, { name: "post", request: "POST /submit HTTP/1.1\r\nHost: h\r\nContent-Length: 0\r\n\r\n", wantMethod: "POST", wantURI: "/submit", wantHost: "h", }, { name: "extension method", request: "FROBNICATE / HTTP/1.1\r\nHost: h\r\n\r\n", wantMethod: "FROBNICATE", wantURI: "/", wantHost: "h", }, } { t.Run(test.name, func(t *testing.T) { var gotMethod, gotURI, gotHost string var sm MuxSlice route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path. sm.Handle(route, func(ex *Exchange) { gotMethod = string(ex.RequestMethodBytes()) gotURI = string(ex.RequestTarget()) gotHost = string(ex.RequestHeader("Host")) ex.WriteHeader(200) }) serve(t, test.request, &sm) if gotMethod != test.wantMethod { t.Errorf("want method %q, got %q", test.wantMethod, gotMethod) } if gotURI != test.wantURI { t.Errorf("want URI %q, got %q", test.wantURI, gotURI) } if gotHost != test.wantHost { t.Errorf("want Host %q, got %q", test.wantHost, gotHost) } }) } } // Malformed request headers must fail the exchange, never reach a handler. func TestHandleMalformedRequest(t *testing.T) { for _, test := range []struct { name string request string }{ {name: "no colon in field", request: "GET / HTTP/1.1\r\nBadFieldNoColon\r\n\r\n"}, {name: "no protocol", request: "GET /\r\nHost: h\r\n\r\n"}, {name: "empty request line", request: "\r\n\r\n"}, {name: "truncated header", request: "GET / HTTP/1.1\r\nHost: h\r\n"}, } { t.Run(test.name, func(t *testing.T) { var handled bool var sm MuxSlice sm.Handle("/", func(ex *Exchange) { handled = true }) conn := newConn(test.request) conn.Hangup() exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) if err := Handle(exch, &sm, nopBackoff); err == nil { t.Error("want error on malformed request, got nil") } if handled { t.Error("handler must not run on malformed request") } }) } } // A request-line naming a version this package does not speak must be answered // 505 without reaching a handler, RFC 9112 2.6. The third token is not // validated by the parser, so anything that is not HTTP/1.0 or HTTP/1.1 lands // here: bogus versions, non-HTTP tokens, and the tail of a request-target that // contained a space and got split across the URI/protocol boundary. func TestHandleUnsupportedProtocol(t *testing.T) { for _, test := range []struct { name string request string }{ {name: "future major", request: "GET / HTTP/6.9\r\nHost: h\r\n\r\n"}, {name: "future major keepalive", request: "GET / HTTP/6.9\r\nHost: h\r\nConnection: keep-alive\r\n\r\n"}, {name: "http2", request: "GET / HTTP/2.0\r\nHost: h\r\n\r\n"}, {name: "long minor", request: "GET / HTTP/1.10\r\nHost: h\r\n\r\n"}, {name: "lowercase", request: "GET / http/1.1\r\nHost: h\r\n\r\n"}, {name: "not http", request: "GET / BANANA\r\nHost: h\r\n\r\n"}, {name: "space in target", request: "GET /a b HTTP/1.1\r\nHost: h\r\n\r\n"}, } { t.Run(test.name, func(t *testing.T) { var handled bool var sm MuxSlice sm.Handle("/", func(ex *Exchange) { handled = true }) sm.Handle("/a", func(ex *Exchange) { handled = true }) conn := newConn(test.request) conn.Hangup() exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) if err := Handle(exch, &sm, nopBackoff); err == nil { t.Error("want error on unsupported protocol, got nil") } if handled { t.Error("handler must not run on unsupported protocol") } const want = "HTTP/1.1 505 HTTP Version Not Supported\r\n\r\n" if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } }) } } // HTTP/1.0 predates HTTP/1.1 but is still served: only the connection is not // kept alive. It must not be swept up by the 505 gate. func TestHandleHTTP10Served(t *testing.T) { var sm MuxSlice sm.Handle("/", func(ex *Exchange) { ex.WriteHeader(200) }) conn := serve(t, "GET / HTTP/1.0\r\nHost: h\r\n\r\n", &sm) const want = "HTTP/1.1 200 OK\r\n\r\n" if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } } // No registered handler must yield 404, not an empty response. func TestHandleNoHandler(t *testing.T) { var sm MuxSlice // "/{$}" is the root and nothing else; a bare "/" is a catch-all that would // match /nowhere too, see [SetPathValues]. sm.Handle("GET /{$}", func(ex *Exchange) { t.Error("handler must not run") }) conn := serve(t, "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", &sm) const want = "HTTP/1.1 404 Not Found\r\n\r\n" if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } } // A handler that writes nothing must still produce a valid response. func TestHandleSilentHandler(t *testing.T) { var sm MuxSlice sm.Handle("/", func(ex *Exchange) {}) conn := serve(t, "GET / HTTP/1.1\r\nHost: h\r\n\r\n", &sm) const want = "HTTP/1.1 200 OK\r\n\r\n" if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } } // Body bytes arriving in the same segment as the header must be readable. var _ io.ReadWriteCloser = (*ExchangeRW)(nil) // ExchangeRW writes the response body and reads the request body, so it may be // handed to code that wants an io.ReadWriter. func TestExchangeRW(t *testing.T) { const body = "hello" conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*128), RequestBufferLim: 128}) var rw ExchangeRW exch.ReadWriter(&rw) n, err := io.WriteString(&rw, body) if err != nil { t.Fatal(err) } if n != len(body) { t.Errorf("want %d bytes written, got %d", len(body), n) } const want = "HTTP/1.1 200 OK\r\n\r\n" + body if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } } // The exchange is pooled and reused: a handle kept past the request it was // taken from must fail instead of reaching the next request's connection. func TestExchangeRWOutlivesExchange(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*128), RequestBufferLim: 128}) var rw ExchangeRW exch.ReadWriter(&rw) if !rw.IsValid() { t.Fatal("want a fresh handle to be valid") } exch.Release() if rw.IsValid() { t.Error("want handle invalidated by release") } if _, err := rw.Write([]byte("late")); err == nil { t.Error("want error writing through a released exchange, got nil") } if _, err := rw.Read(make([]byte, 4)); err == nil { t.Error("want error reading through a released exchange, got nil") } if got := conn.ViewWritten(); strings.Contains(got, "late") { t.Errorf("late write reached the connection: %q", got) } } func TestExchangeReadBody(t *testing.T) { const body = "message body" var got string var readErr error var sm MuxSlice sm.Handle("POST /", func(ex *Exchange) { dst := make([]byte, len(body)) n, err := ex.ReadBody(dst) got, readErr = string(dst[:n]), err ex.WriteHeader(200) }) serve(t, "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 12\r\n\r\n"+body, &sm) if readErr != nil { t.Fatal(readErr) } if got != body { t.Errorf("want body %q, got %q", body, got) } } // SetHeader must budget every byte it writes: colon, CRLF, and the CRLF that // FlushHeader appends after the last field. Buffers that fit all but the last // byte must be refused, never overrun. func TestExchangeStageOKAndFail(t *testing.T) { // Long enough that the buffers under test clear the smallest a header // buffer may be, while still being sized to the byte around the field. const key, value = "X-A-Header-Field-Key", "a-header-field-value" const field = len(key) + len(value) + len(":\r\n") const numHeaderCap = 4 for _, bufLen := range []int{field + 2, field + 1, field} { t.Run("buffer"+strconv.Itoa(bufLen), func(t *testing.T) { conn := newConn("") exch := new(Exchange) exch.Configure(ExchangeConfig{ RawBuf: make([]byte, bufLen), RequestBufferLim: bufLen, NumHeaderKVCap: numHeaderCap, }) if !exch.Acquire(conn) { t.Fatal("fresh exchange failed to acquire connection") } set := exch.StageHeader(key, value) n, err := exch.FlushHeader() want := "HTTP/1.1 200 OK\r\n" if set { want += key + ":" + value + "\r\n" want += "\r\n" } else { if err != lneto.ErrBufferFull || n != 0 { t.Fatal("expected buffer full and no data written:", err, n) } want = "" } if got := conn.ViewWritten(); got != want { t.Errorf("want %q, got %q", want, got) } if wantSet := bufLen >= field+2; set != wantSet { t.Errorf("want SetHeader=%v, got %v", wantSet, set) } }) } } // Handle never closes the connection, on any outcome: the caller owns it so // that error policy and connection reuse stay the caller's decision. func TestHandleLeavesConnOpen(t *testing.T) { var sm MuxSlice sm.Handle("GET /", staticPage(t, "ok")) for _, test := range []struct { name string request string }{ {name: "served", request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n"}, {name: "404", request: "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n"}, {name: "rejected no http version", request: "GET /\r\nHost: h\r\n\r\n"}, {name: "rejected parse error", request: "GET / HTTP/1.1\r\nBadFieldNoColon\r\n\r\n"}, } { t.Run(test.name, func(t *testing.T) { conn := newConn(test.request) conn.Hangup() exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) Handle(exch, &sm, nopBackoff) if conn.IsClosed() { t.Errorf("Handle closed the connection for %q", test.request) } }) } } // Hijacking hands the connection to the handler, so Release must not close it. // Ownership must not carry over: the next connection the exchange serves is // the router's again and must be closed on Release. func TestExchangeHijackOwnership(t *testing.T) { var sm MuxSlice var hijackErr error sm.Handle("GET /", func(ex *Exchange) { _, _, hijackErr = ex.HijackRaw(nil) }) first := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n") first.Hangup() exch := newExchange(t, first, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) if err := Handle(exch, &sm, nopBackoff); err != nil { t.Fatal(err) } if hijackErr != nil { t.Fatal(hijackErr) } exch.Release() if first.IsClosed() { t.Error("hijacked connection must stay open after Release") } second := newConn("") if !exch.Acquire(second) { t.Fatal("released exchange must be acquirable") } exch.Release() if !second.IsClosed() { t.Error("connection must be closed on Release: hijack of a previous request must not carry over") } } // Idle peer policy belongs to the connection: Handle keeps retrying an empty // read until the conn itself reports failure, so a stalled peer ends the // exchange through the conn's deadline instead of pinning the exchange. func TestHandleIdlePeerEndsOnConnDeadline(t *testing.T) { var sm MuxSlice sm.Handle("/", func(ex *Exchange) { t.Error("handler must not run on partial request") }) conn := newConn("GET / HTTP") // Peer stalls mid request line, never hangs up. conn.SetDeadline(time.Now().Add(10 * time.Millisecond)) exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) done := make(chan error, 1) go func() { done <- Handle(exch, &sm, nopBackoff) }() select { case err := <-done: if !errors.Is(err, context.DeadlineExceeded) { t.Errorf("want connection deadline error, got %v", err) } case <-time.After(time.Second): t.Fatal("Handle ignored the connection deadline") } } // A body must never reach the wire without its header: if flushing the header // fails, Write must report the failure and send nothing. func TestExchangeWriteHeaderFlushFails(t *testing.T) { const body = "body" conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*128), RequestBufferLim: 128}) conn.FailWrites(1) // Status line write fails, body write would succeed. n, err := exch.WriteBody([]byte(body)) if err == nil { t.Error("want error when header flush fails, got nil") } if n != 0 { t.Errorf("want 0 bytes written, got %d", n) } if got := conn.ViewWritten(); got != "" { t.Errorf("want nothing on the wire, got %q", got) } // Writes after a failed header stay failed: the response is unrecoverable, // a body without its header would corrupt the stream. if _, err = exch.WriteBody([]byte(body)); err == nil { t.Error("want error on write after failed header flush, got nil") } if got := conn.ViewWritten(); got != "" { t.Errorf("want nothing on the wire, got %q", got) } } func TestExchangeSetHeaderInt(t *testing.T) { for _, test := range []struct { value int64 base int want string // Header block emitted after the status line. }{ {value: 1234, base: 10, want: "N:1234\r\n\r\n"}, {value: 0, base: 10, want: "N:0\r\n\r\n"}, {value: -42, base: 10, want: "N:-42\r\n\r\n"}, {value: 255, base: 16, want: "N:ff\r\n\r\n"}, {value: 9223372036854775807, base: 10, want: "N:9223372036854775807\r\n\r\n"}, {value: -9223372036854775808, base: 10, want: "N:-9223372036854775808\r\n\r\n"}, {value: 1, base: 2, want: "\r\n"}, // Below base 10, dropped. {value: 1, base: 37, want: "\r\n"}, // Above base 36, dropped. } { name := strconv.FormatInt(test.value, 10) + "_base" + strconv.Itoa(test.base) t.Run(name, func(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256}) exch.StageHeaderIntBase("N", test.value, test.base) exch.WriteHeader(200) got, _ := strings.CutPrefix(conn.ViewWritten(), "HTTP/1.1 200 OK\r\n") if got != test.want { t.Errorf("want %q, got %q", test.want, got) } }) } } // SetHeaderInt must format into the response buffer without allocating. func TestExchangeSetHeaderIntNoAlloc(t *testing.T) { exch := newExchange(t, newConn(""), ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256}) allocs := testing.AllocsPerRun(100, func() { exch.StageHeaderIntBase("Content-Length", 1234567890, 10) }) if allocs != 0 { t.Errorf("SetHeaderInt allocated %v times, want 0", allocs) } } // The Mux matches on the request path: a query string must not defeat routing. func TestHandleMuxOnPath(t *testing.T) { var sm MuxSlice var gotPath, gotQuery string sm.Handle("GET /search", func(ex *Exchange) { gotPath = string(ex.RequestPath()) rawkey, rawval, rest := httpraw.NextQueryPair(ex.RequestQuery()) for rawkey != nil { gotQuery += string(rawkey) + "=" + string(rawval) + ";" rawkey, rawval, rest = httpraw.NextQueryPair(rest) } ex.WriteHeader(200) }) conn := serve(t, "GET /search?q=go&n=1 HTTP/1.1\r\nHost: h\r\n\r\n", &sm) if gotPath != "/search" { t.Errorf("want path %q, got %q", "/search", gotPath) } if gotQuery != "q=go;n=1;" { t.Errorf("want query %q, got %q", "q=go;n=1;", gotQuery) } if got := conn.ViewWritten(); !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") { t.Errorf("want the handler to have run, got %q", got) } } func TestExchangeAppendQuery(t *testing.T) { for _, test := range []struct { uri string key string decoded bool want string wantPresent bool }{ {uri: "/x?q=go", key: "q", want: "go", wantPresent: true}, {uri: "/x?q=go&n=1", key: "n", want: "1", wantPresent: true}, {uri: "/x?a=1&a=2", key: "a", want: "1", wantPresent: true}, // First match wins. {uri: "/x?q=go", key: "nope", want: "", wantPresent: false}, // Absent. {uri: "/x", key: "q", want: "", wantPresent: false}, // No query at all. {uri: "/x?debug&q=go", key: "debug", want: "", wantPresent: true}, // Flag: present, no value. {uri: "/x?q=", key: "q", want: "", wantPresent: true}, // Present, empty. // Decoding is opt-in and applies to the value only. {uri: "/x?q=hello%20world", key: "q", want: "hello%20world", wantPresent: true}, {uri: "/x?q=hello%20world", key: "q", decoded: true, want: "hello world", wantPresent: true}, {uri: "/x?q=a+b", key: "q", want: "a+b", wantPresent: true}, {uri: "/x?q=a+b", key: "q", decoded: true, want: "a b", wantPresent: true}, // Keys are matched decoded: "a b" cannot appear raw. {uri: "/x?a%20b=c", key: "a b", want: "c", wantPresent: true}, {uri: "/x?a+b=c", key: "a b", want: "c", wantPresent: true}, // Malformed escapes: a bad key is skipped, a bad value is not returned. {uri: "/x?%zz=1&q=go", key: "q", want: "go", wantPresent: true}, {uri: "/x?q=%zz", key: "q", decoded: true, want: "", wantPresent: false}, {uri: "/x?q=%zz", key: "q", want: "%zz", wantPresent: true}, // Undecoded, passed through. } { // The path is the same for every case: name them by what differs, and // keep the '/' out so the name stays a single -run element. name := strings.TrimPrefix(test.uri, "/x") if name == "" { name = "noquery" } name += "_" + test.key if test.decoded { name += "_decoded" } t.Run(name, func(t *testing.T) { var sm MuxSlice var got string var present bool sm.Handle("/x", func(ex *Exchange) { var value []byte value, present = ex.RequestQueryAppend(nil, test.key, test.decoded) got = string(value) }) serve(t, "GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n", &sm) if present != test.wantPresent { t.Errorf("want present=%v, got %v", test.wantPresent, present) } if got != test.want { t.Errorf("want %q, got %q", test.want, got) } }) } } // AppendQuery appends to dst, leaving what was already there untouched, and // does not allocate when dst has the capacity. func TestExchangeAppendQueryReusesBuffer(t *testing.T) { var sm MuxSlice var got string var allocs float64 dst := make([]byte, 0, 64) sm.Handle("/x", func(ex *Exchange) { var value []byte allocs = testing.AllocsPerRun(50, func() { value, _ = ex.RequestQueryAppend(dst[:len("prefix:")], "q", true) }) got = string(value) // Conversion allocates, keep it out of the measurement. }) copy(dst[:cap(dst)], "prefix:") serve(t, "GET /x?q=hello%20world HTTP/1.1\r\nHost: h\r\n\r\n", &sm) if got != "prefix:hello world" { t.Errorf("want %q, got %q", "prefix:hello world", got) } if allocs != 0 { t.Errorf("AppendQuery allocated %v times into a buffer with capacity, want 0", allocs) } } // formString renders a form as "key=value" pairs joined by '|', a pair with no // value shown as the bare key. func formString(f *httpraw.Form) string { var sb strings.Builder for i := 0; i < f.Len(); i++ { if i > 0 { sb.WriteByte('|') } key, value := f.Pair(i) sb.Write(key) if value != nil { sb.WriteByte('=') sb.Write(value) } } return sb.String() } const formType = "Content-Type: application/x-www-form-urlencoded\r\n" // formPair is one key/value pair of a form body. flag sends the key bare, with // no '=', which parses back as a nil value: distinct from a present but empty // one, unlike http.FormValue. type formPair struct { key, value string flag bool } // appendForm renders pairs as a urlencoded body, joining them with '&'. func appendForm(dst []byte, pairs []formPair) []byte { for i, pair := range pairs { if i > 0 { dst = append(dst, '&') } dst = append(dst, pair.key...) if !pair.flag { dst = append(dst, '=') dst = append(dst, pair.value...) } } return dst } func TestExchangeRequestParseForm(t *testing.T) { for _, test := range []struct { name string formVals []formPair // wantVals defaults to formVals: set it only where what comes back out // differs from what went in, as decoding makes it. wantVals []formPair target string // Request target, defaults to "/f". contentType string // Media type, defaults to application/x-www-form-urlencoded. noContentType bool // Send no Content-Type field at all. noContentLength bool // Send no Content-Length field: no body at all, RFC 9112 6.3. extraHeaders string // Header fields sent verbatim, each CRLF terminated. callDecode bool bufsize int // Defaults to 64. wantErr error }{ { name: "pairs", formVals: []formPair{{key: "a", value: "1"}, {key: "b", value: "2"}, {key: "c", value: "3"}}, }, { name: "long value", formVals: []formPair{{key: "a", value: ""}, {key: "k", value: strings.Repeat("k", 20)}}, }, { // A flag and an empty value stay distinguishable, unlike http.FormValue. name: "flag and empty", formVals: []formPair{{key: "a", flag: true}, {key: "b", value: ""}}, }, { // Decoding is the caller's call: untouched without it. name: "left encoded", formVals: []formPair{{key: "n", value: "a%20b"}}, }, { // Decode reaches both keys and values. name: "decoded", formVals: []formPair{{key: "a+b", value: "c%20d"}, {key: "e", value: "f%2B"}}, wantVals: []formPair{{key: "a b", value: "c d"}, {key: "e", value: "f+"}}, callDecode: true, }, { name: "media type parameters", formVals: []formPair{{key: "a", value: "1"}}, contentType: "application/x-www-form-urlencoded; charset=utf-8", }, { // Only the body is parsed: the query string is not folded in. name: "query not folded", formVals: []formPair{{key: "a", value: "1"}}, target: "/f?q=go", }, { name: "no content length", noContentLength: true, }, { name: "wrong media type", formVals: []formPair{{key: "a", value: "1"}}, contentType: "text/plain", wantErr: errNotFormEncoded, }, { // An absent field is not a wrong one: no media type is no body, the // same answer "no content length" gets above. Only a type that is // present and not form encoded is an error. name: "no media type", formVals: []formPair{{key: "a", value: "1"}}, noContentType: true, wantVals: []formPair{}, }, { // The coding is refused on the field alone, so the body stays off. name: "chunked", noContentLength: true, extraHeaders: "Transfer-Encoding: chunked\r\n", wantErr: errUnsupportedTransferCoding, }, { // The form bounds itself now, so an oversized body is the form // refusing to grow rather than a short buffer handed in. name: "body larger than buffer", formVals: []formPair{{key: "a", value: "1"}, {key: "b", value: "2"}, {key: "c", value: "3"}}, bufsize: 4, wantErr: httpraw.ErrBufferExhausted, }, } { t.Run(test.name, func(t *testing.T) { bufSize := test.bufsize if bufSize == 0 { bufSize = 64 } target := test.target if target == "" { target = "/f" } contentType := test.contentType if contentType == "" { contentType = "application/x-www-form-urlencoded" } wantVals := test.wantVals if wantVals == nil { wantVals = test.formVals } body := appendForm(nil, test.formVals) var builder strings.Builder builder.WriteString("POST ") builder.WriteString(target) builder.WriteString(" HTTP/1.1\r\nHost: h\r\n") if !test.noContentType { builder.WriteString("Content-Type: ") builder.WriteString(contentType) builder.WriteString("\r\n") } builder.WriteString(test.extraHeaders) if !test.noContentLength { builder.WriteString("Content-Length: ") builder.WriteString(strconv.Itoa(len(body))) builder.WriteString("\r\n") } builder.WriteString("\r\n") builder.Write(body) // The form owns the memory: bufSize bounds it here, growth off so an // oversized body is reported rather than allocated for. var form httpraw.Form form.Reset(make([]byte, 0, bufSize), defaultKVCap) form.EnableBufferGrowth(false) var gotErr error var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { gotErr = exch.RequestParseForm(&form, false, false) if gotErr == nil && test.callDecode { gotErr = form.Decode() } }) serve(t, builder.String(), &sm) if gotErr != test.wantErr { t.Fatalf("want error %v, got %v", test.wantErr, gotErr) } else if test.wantErr != nil { return // Nothing is promised about the form on failure. } if form.Len() != len(wantVals) { t.Fatalf("want %d pairs parsed, got %d: %q", len(wantVals), form.Len(), formString(&form)) } for i, want := range wantVals { key, value := form.Pair(i) if b2s(key) != want.key { t.Errorf("pair %d: want key %q, got %q", i, want.key, key) } switch { case want.flag && value != nil: t.Errorf("pair %d: want no value, got %q", i, value) case !want.flag && value == nil: t.Errorf("pair %d: want value %q, got no value", i, want.value) case !want.flag && b2s(value) != want.value: t.Errorf("pair %d: want value %q, got %q", i, want.value, value) } } }) } } // A body arriving after the header, in its own segment, must still be parsed whole. func TestExchangeRequestParseFormSplit(t *testing.T) { conn := newConn("POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&") conn.AddSegment("b=2&c=3") conn.Hangup() var form httpraw.Form var gotErr error var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { gotErr = exch.RequestParseForm(&form, false, false) }) exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) if err := Handle(exch, &sm, nopBackoff); err != nil { t.Fatal(err) } if gotErr != nil { t.Fatal(gotErr) } if got := formString(&form); got != "a=1|b=2|c=3" { t.Errorf("want %q, got %q", "a=1|b=2|c=3", got) } } // Decode is the caller's call, and it must reach both keys and values. func TestExchangeRequestParseFormDecode(t *testing.T) { var form httpraw.Form var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { if err := exch.RequestParseForm(&form, false, false); err != nil { t.Error(err) } else if err = form.Decode(); err != nil { t.Error(err) } }) serve(t, "POST /f HTTP/1.1\r\nHost: h\r\n"+formType+"Content-Length: 16\r\n\r\na+b=c%20d&e=f%2B", &sm) if got := formString(&form); got != "a b=c d|e=f+" { t.Errorf("want %q, got %q", "a b=c d|e=f+", got) } } // partBuffer is a sink that keeps a part's content in memory and records that // [Exchange.ReadMultiparts] closed it. type partBuffer struct { content []byte closed bool } func (p *partBuffer) Write(b []byte) (int, error) { if p.closed { return 0, errors.New("write to closed part sink") } p.content = append(p.content, b...) return len(b), nil } func (p *partBuffer) Close() error { p.closed = true; return nil } // multipartPart is one part of a multipart/form-data body. discard makes the // sink factory refuse it, exercising [Exchange.ReadMultiparts]' discard path. type multipartPart struct { name, filename, content string discard bool } // appendMultipart renders parts as a multipart/form-data body delimited by // boundary, closed off with the terminating delimiter. func appendMultipart(dst []byte, boundary string, parts []multipartPart) []byte { for _, part := range parts { dst = append(dst, "--"+boundary+"\r\n"...) dst = append(dst, `Content-Disposition: form-data; name="`+part.name+`"`...) if part.filename != "" { dst = append(dst, `; filename="`+part.filename+`"`...) } dst = append(dst, "\r\n\r\n"...) dst = append(dst, part.content...) dst = append(dst, "\r\n"...) } return append(dst, "--"+boundary+"--\r\n"...) } // serveMultipart serves request to a handler that streams its multipart body // with [Exchange.ReadMultiparts] over a buffer of bufSize bytes. Bytes past // each offset in segmentAt are delivered on later reads, so the parser must // compact and read more to see them. discard names the parts whose sink is // refused. func serveMultipart(t *testing.T, request string, bufSize int, discard []string, segmentAt []int) ([]MultipartSink, error) { t.Helper() prev := 0 for _, off := range segmentAt { if off < prev || off > len(request) { t.Fatalf("segment offset %d out of order or past the %d byte request", off, len(request)) } prev = off } conn := newConn(request[:firstOr(segmentAt, len(request))]) for i, off := range segmentAt { end := len(request) if i+1 < len(segmentAt) { end = segmentAt[i+1] } conn.AddSegment(request[off:end]) } conn.Hangup() var parts []MultipartSink var gotErr error var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { newSink := func(hdr *httpraw.MultipartHeader) io.WriteCloser { if slices.Contains(discard, string(hdr.Name)) { return nil // Discard this part's content. } return new(partBuffer) } parts, gotErr = exch.ReadMultiparts(parts, make([]byte, bufSize), newSink) }) exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) if err := Handle(exch, &sm, nopBackoff); err != nil { t.Fatal(err) } return parts, gotErr } func firstOr(s []int, or int) int { if len(s) == 0 { return or } return s[0] } // checkParts asserts the part header and streamed content of every sink, that a // discarded part has no sink at all, and that no sink was left open, which would // hide a part that never ended. func checkParts(t *testing.T, got []MultipartSink, want []multipartPart) { t.Helper() if len(got) != len(want) { t.Fatalf("want %d parts, got %d: %q", len(want), len(got), partsString(t, got)) } for i, want := range want { part := &got[i] if string(part.Header.Name) != want.name { t.Errorf("part %d: want name %q, got %q", i, want.name, part.Header.Name) } if string(part.Header.Filename) != want.filename { t.Errorf("part %d: want filename %q, got %q", i, want.filename, part.Header.Filename) } if want.discard { if part.Sink != nil { t.Errorf("part %d: want no sink for a discarded part, got one", i) } continue } if part.Sink == nil { t.Errorf("part %d: want content %q, got no sink", i, want.content) continue } sink := part.Sink.(*partBuffer) if !sink.closed { t.Errorf("part %d (%q): sink left open", i, want.name) } if string(sink.content) != want.content { t.Errorf("part %d (%q): want content %q, got %q", i, want.name, want.content, sink.content) } } } // partsString renders parts as "name=content" joined by '|', a file part shown // as "name(filename)=content" and a discarded one as "name=". Fails the // test if a sink was left open, which would hide a part that never ended. func partsString(t *testing.T, parts []MultipartSink) string { t.Helper() var sb strings.Builder for i := range parts { if i > 0 { sb.WriteByte('|') } part := &parts[i] sb.Write(part.Header.Name) if len(part.Header.Filename) > 0 { sb.WriteByte('(') sb.Write(part.Header.Filename) sb.WriteByte(')') } sb.WriteByte('=') if part.Sink == nil { sb.WriteString("") continue } sink := part.Sink.(*partBuffer) if !sink.closed { t.Errorf("part %q: sink left open", part.Header.Name) } sb.Write(sink.content) } return sb.String() } // mpTeaser is content that teases the parser with delimiter prefixes that never // complete, so a compaction that fails to hold the tail back drops part of it. var mpTeaser = strings.Repeat("\r\n--xy", 16) + strings.Repeat("A", 100) + "\r\n--xyy" func TestExchangeReadMultiparts(t *testing.T) { for _, test := range []struct { name string boundary string // Defaults to "xyz". parts []multipartPart // The body sent. // wantParts defaults to parts: set it only where what comes back out // differs from what went in. A wantErr case reports no parts at all. wantParts []multipartPart // segmentAt are offsets into the body where a later read begins. The // request header always arrives in the first read. segmentAt []int bufsize int // Defaults to 128. wantErr error }{ { // Names, filenames and content of every part, over a body split so // that a part straddles two reads and the parser must compact and // read more. The boundary opens with dashes of its own, which the // delimiter's leading "--" must not be confused with. name: "parts and files", boundary: "--xyz", parts: []multipartPart{ {name: "caption", content: "hi there"}, {name: "photo", filename: "beach.png", content: "\x89PNG\r\n\x00"}, }, segmentAt: []int{89}, // Inside the second part's header. }, { // A part longer than the buffer must come out whole: every // compaction has to keep the tail NextBody held back, or content // that looks like the start of a delimiter is dropped. The header's // Name must survive those reads too. name: "part larger than buffer", parts: []multipartPart{{name: "blob", content: mpTeaser}}, segmentAt: []int{0, 30}, // Header alone, then 30 bytes of body. bufsize: 64, }, { // A nil sink discards a part's content without losing its place in // the body: the parts around it must still arrive whole. name: "discards part", parts: []multipartPart{ {name: "keep", content: "kept"}, {name: "huge", filename: "big.bin", content: strings.Repeat("Z", 200), discard: true}, {name: "also", content: "kept too"}, }, bufsize: 96, }, { // A part header that does not fit the buffer cannot be completed by // reading more, so the caller is told instead of spinning. name: "header larger than buffer", parts: []multipartPart{{name: strings.Repeat("n", 64), content: "v"}}, bufsize: 32, wantErr: lneto.ErrShortBuffer, }, { // A buffer too small to ever outgrow a delimiter is a caller error, // refused before any of the body is read. name: "buffer unusable", parts: []multipartPart{{name: "a", content: "v"}}, bufsize: len("\r\n--xyz"), wantErr: lneto.ErrInvalidConfig, }, } { t.Run(test.name, func(t *testing.T) { boundary := test.boundary if boundary == "" { boundary = "xyz" } bufSize := test.bufsize if bufSize == 0 { bufSize = 128 } wantParts := test.wantParts if wantParts == nil { wantParts = test.parts } var discard []string for _, part := range test.parts { if part.discard { discard = append(discard, part.name) } } head := "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=" + boundary + "\r\n\r\n" body := appendMultipart(nil, boundary, test.parts) segmentAt := make([]int, len(test.segmentAt)) for i, off := range test.segmentAt { if off < 0 || off >= len(body) { t.Fatalf("segment offset %d is not inside the %d byte body", off, len(body)) } segmentAt[i] = len(head) + off } parts, err := serveMultipart(t, head+string(body), bufSize, discard, segmentAt) if err != test.wantErr { t.Fatalf("want error %v, got %v", test.wantErr, err) } if test.wantErr != nil { // Nothing parsed is promised on failure, and a part reported // for a header that never parsed is a part the caller cannot // act on. if len(parts) != 0 { t.Errorf("want no parts reported, got %d: %q", len(parts), partsString(t, parts)) } return } checkParts(t, parts, wantParts) }) } } // A request that is not multipart, or whose boundary is missing, must be refused. func TestExchangeRequestParseMultipartRejects(t *testing.T) { for _, test := range []struct { contentType string wantErr bool }{ {contentType: "multipart/form-data; boundary=xyz"}, {contentType: "application/x-www-form-urlencoded", wantErr: true}, {contentType: "multipart/form-data", wantErr: true}, // Boundary is required. {contentType: "", wantErr: true}, } { name := test.contentType if name == "" { name = "no content type" } t.Run(name, func(t *testing.T) { var gotErr error var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { _, gotErr = exch.RequestMultipart() }) request := "POST /f HTTP/1.1\r\nHost: h\r\n" if test.contentType != "" { request += "Content-Type: " + test.contentType + "\r\n" } serve(t, request+"\r\n", &sm) if (gotErr != nil) != test.wantErr { t.Errorf("want error %v, got %v", test.wantErr, gotErr) } }) } } // A request from a browser carries around twenty header fields. Serving one // must not depend on how many fields the parser happens to have room for: the // exchange's buffer is the memory the caller granted, and the field table comes // out of it. func TestHandleBrowserSizedRequest(t *testing.T) { const wantMode = "navigate" request := "GET /echo HTTP/1.1\r\nHost: lneto.test\r\n" + "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36\r\n" + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\r\n" + "Accept-Language: en-US,en;q=0.5\r\nAccept-Encoding: gzip, deflate, br\r\n" + "Upgrade-Insecure-Requests: 1\r\nSec-Fetch-Dest: document\r\nSec-Fetch-Site: none\r\n" + "Sec-Ch-Ua: \"Chromium\";v=\"120\"\r\nCache-Control: max-age=0\r\nDnt: 1\r\n" + "Referer: https://lneto.test/index.html\r\nCookie: session=abcdef0123456789; theme=dark\r\n" + "X-Trace: 0123456789abcdef\r\nX-Client: bench\r\nX-Seq: 42\r\nX-Tag: alpha\r\n" + "X-Nonce: cafebabe\r\nX-Mode: " + wantMode + "\r\n\r\n" var gotMode string var fields int var sm MuxSlice sm.Reset(1) sm.Handle("GET /echo", func(exch *Exchange) { gotMode = string(exch.RequestHeader("X-Mode")) exch.RequestHeaderV1Raw().ForEach(func(key, value []byte) bool { fields++ return true }) }) conn := newConn(request) conn.Hangup() exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*8192), RequestBufferLim: 8192}) if err := Handle(exch, &sm, nopBackoff); err != nil { t.Fatalf("serving a browser sized request: %s", err) } if gotMode != wantMode { t.Errorf("last header field read back as %q, want %q", gotMode, wantMode) } const sent = 19 if fields < sent { t.Errorf("handler saw %d header fields, request carried %d", fields, sent) } } // A request larger than the exchange has room for must be answered, not // dropped: the peer learns its request was too large instead of seeing the // connection go away. Either bound may be the one it ran into, and both are // reported to the caller. func TestHandleRequestTooLargeAnswers431(t *testing.T) { var request strings.Builder request.WriteString("GET /echo HTTP/1.1\r\nHost: lneto.test\r\n") for i := range 512 { request.WriteByte('H') request.WriteString(strconv.Itoa(i)) request.WriteString(":v\r\n") } request.WriteString("\r\n") for _, test := range []struct { name string cfg ExchangeConfig wantErr error }{ { // Room for every byte of the block, but not for its fields. The // field table only bounds the request when growth is refused: // otherwise it is the size the parser starts from, not a limit. name: "more fields than the table holds", cfg: ExchangeConfig{ RawBuf: make([]byte, 16*1024), RequestBufferLim: 8 * 1024, NumHeaderKVCap: 16, NoRequestBufferGrowth: true, }, wantErr: httpraw.ErrHeaderTooMany, }, { // Room for the fields, but not for the bytes they arrive in. name: "more bytes than the buffer holds", cfg: ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024, NumHeaderKVCap: 1024, NoRequestBufferGrowth: true}, wantErr: httpraw.ErrBufferExhausted, }, } { t.Run(test.name, func(t *testing.T) { var served bool var sm MuxSlice sm.Reset(1) sm.Handle("GET /echo", func(exch *Exchange) { served = true }) conn := newConn(request.String()) conn.Hangup() exch := newExchange(t, conn, test.cfg) err := Handle(exch, &sm, nopBackoff) if !errors.Is(err, test.wantErr) { t.Fatalf("want %v reported to the caller, got %v", test.wantErr, err) } if served { t.Fatal("handler ran on a request the parser could not hold") } got := conn.ViewWritten() if !strings.HasPrefix(got, "HTTP/1.1 431 ") { t.Errorf("want a 431 answer, got %q", firstLine(got)) } }) } } func firstLine(s string) string { if before, _, ok := strings.Cut(s, "\r\n"); ok { return before } return s } // A body that already arrived alongside the request header must be handed over // without touching the connection again. A peer that sent a whole request and // is waiting for its answer sends nothing more, so a read for bytes already in // hand blocks until the connection's deadline, or forever without one. func TestExchangeReadBodyDoesNotReadPastWhatArrived(t *testing.T) { const body = "message body" dst := make([]byte, 64) // Deliberately larger than the body. var got string var readErr error var sm MuxSlice sm.Reset(1) sm.Handle("POST /", func(ex *Exchange) { n, err := ex.ReadBody(dst) got, readErr = string(dst[:n]), err ex.WriteHeader(200) }) // The peer is still there, waiting to be answered: a read for bytes it is // not going to send blocks, exactly as it does on a socket. conn := &blockingConn{request: "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 12\r\n\r\n" + body} exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) done := make(chan struct{}) go func() { defer close(done) Handle(exch, &sm, nopBackoff) }() select { case <-done: case <-time.After(2 * time.Second): t.Fatal("ReadBody blocked waiting for a body that had already arrived") } if readErr != nil { t.Fatal(readErr) } if got != body { t.Errorf("want body %q, got %q", body, got) } } // blockingConn delivers a request and then blocks on reads, the way a peer // awaiting its answer does. Writes are discarded. type blockingConn struct { request string read int blocked chan struct{} } func (c *blockingConn) Read(b []byte) (int, error) { if c.read >= len(c.request) { if c.blocked == nil { c.blocked = make(chan struct{}) } <-c.blocked // Nothing more is coming, and nothing unblocks this. return 0, io.EOF } n := copy(b, c.request[c.read:]) c.read += n return n, nil } func (c *blockingConn) Write(b []byte) (int, error) { return len(b), nil } func (c *blockingConn) Close() error { return nil } // Field names are case insensitive, RFC 9110 5.1, and HTTP/2 mandates lowercase // ones, so an h2-to-h1 proxy sends "content-type". The field must be found // whatever the case, or form parsing refuses a body it should accept. func TestExchangeRequestContentTypeFolded(t *testing.T) { for _, name := range []string{"Content-Type", "content-type", "CONTENT-TYPE", "cOnTeNt-TyPe"} { t.Run(name, func(t *testing.T) { const formType = "application/x-www-form-urlencoded" var gotType string var gotErr error var form httpraw.Form var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { gotType = string(exch.RequestContentType()) gotErr = exch.RequestParseForm(&form, false, false) }) serve(t, "POST /f HTTP/1.1\r\nHost: h\r\n"+name+": "+formType+"\r\nContent-Length: 3\r\n\r\na=1", &sm) if gotType != formType { t.Fatalf("want Content-Type %q, got %q", formType, gotType) } if gotErr != nil { t.Fatalf("want form parsed, got %v", gotErr) } if key, value := form.Pair(0); b2s(key) != "a" || b2s(value) != "1" { t.Errorf("want a=1, got %q=%q", key, value) } }) } } // The Transfer-Encoding guard stops chunk framing being read as form data. A // lowercase field name must not slip past it: with a Content-Length alongside, // the chunk sizes and their CRLFs land inside the parsed pairs. func TestExchangeRequestParseFormFoldedTransferEncoding(t *testing.T) { for _, name := range []string{"Transfer-Encoding", "transfer-encoding", "TRANSFER-ENCODING"} { t.Run(name, func(t *testing.T) { const body = "3\r\na=1\r\n0\r\n\r\n" var gotErr error var form httpraw.Form var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { gotErr = exch.RequestParseForm(&form, false, false) }) serve(t, "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: application/x-www-form-urlencoded\r\n"+ name+": chunked\r\nContent-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm) if gotErr != errUnsupportedTransferCoding { t.Fatalf("want %v, got %v with %d pairs %q", errUnsupportedTransferCoding, gotErr, form.Len(), formString(&form)) } }) } } // Respond replaces the stage/stage/stage/write boilerplate every handler paid, // deriving Content-Length from the body so it cannot disagree with what is sent. func TestExchangeRespond(t *testing.T) { for _, test := range []struct { name string code int contentType string body string want string }{ { name: "html", code: 200, contentType: "text/html", body: "

hi

", want: "HTTP/1.1 200 OK\r\nContent-Type:text/html\r\nContent-Length:11\r\nConnection:close\r\n\r\n

hi

", }, { name: "empty body still declares zero length", code: 200, contentType: "text/plain", body: "", want: "HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\nContent-Length:0\r\nConnection:close\r\n\r\n", }, { name: "no content type staged when empty", code: 204, contentType: "", body: "", want: "HTTP/1.1 204 No Content\r\nContent-Length:0\r\nConnection:close\r\n\r\n", }, { name: "error code carries a body", code: 500, contentType: "text/plain", body: "boom", want: "HTTP/1.1 500 Internal Server Error\r\nContent-Type:text/plain\r\nContent-Length:4\r\nConnection:close\r\n\r\nboom", }, } { t.Run(test.name+"/bytes", func(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256}) if err := exch.Respond(test.code, test.contentType, []byte(test.body)); err != nil { t.Fatalf("Respond: %s", err) } if got := conn.ViewWritten(); got != test.want { t.Errorf("want %q, got %q", test.want, got) } }) t.Run(test.name+"/string", func(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256}) if err := exch.RespondString(test.code, test.contentType, test.body); err != nil { t.Fatalf("RespondString: %s", err) } if got := conn.ViewWritten(); got != test.want { t.Errorf("want %q, got %q", test.want, got) } }) } } // A response that does not fit must be reported, not shipped truncated: the // whole point of folding the boilerplate into one call. func TestExchangeRespondReportsOverflow(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 64), RequestBufferLim: 32}) err := exch.Respond(200, strings.Repeat("t", 200), []byte("body")) if err == nil { t.Fatal("want an error for a response header that cannot fit") } if got := conn.ViewWritten(); got != "" { t.Errorf("nothing must reach the wire, got %q", got) } if exch.ResponseError() == nil { t.Error("want the failure recorded on the exchange too") } } // Query and body are read into one form buffer and parsed together, so both // sources are present at once and read order decides which value a key resolves // to. A key carried by both keeps both pairs, in wire order. func TestExchangeRequestParseFormFoldsQuery(t *testing.T) { const body = "cnt=body&only=b" const target = "/f?cnt=query&page=2" for _, test := range []struct { name string parseURL, prioritizeURL bool wantCnt string wantPage string wantRendered string }{ { name: "body only", parseURL: false, wantCnt: "body", wantPage: "", wantRendered: "cnt=body|only=b", }, { name: "query first wins", parseURL: true, prioritizeURL: true, wantCnt: "query", wantPage: "2", wantRendered: "cnt=query|page=2|cnt=body|only=b", }, { name: "body first wins", parseURL: true, prioritizeURL: false, wantCnt: "body", wantPage: "2", wantRendered: "cnt=body|only=b|cnt=query|page=2", }, } { t.Run(test.name, func(t *testing.T) { var form httpraw.Form var gotErr error var sm MuxSlice sm.Reset(1) sm.Handle("POST /f", func(exch *Exchange) { gotErr = exch.RequestParseForm(&form, test.parseURL, test.prioritizeURL) }) serve(t, "POST "+target+" HTTP/1.1\r\nHost: h\r\n"+ "Content-Type: application/x-www-form-urlencoded\r\n"+ "Content-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm) if gotErr != nil { t.Fatalf("RequestParseForm: %s", gotErr) } if got := string(form.Get("cnt")); got != test.wantCnt { t.Errorf("want cnt=%q, got %q", test.wantCnt, got) } if got := string(form.Get("page")); got != test.wantPage { t.Errorf("want page=%q, got %q", test.wantPage, got) } if got := formString(&form); got != test.wantRendered { t.Errorf("want pairs %q, got %q", test.wantRendered, got) } }) } } // A GET with a query and no body must fold the query alone: no Content-Type // means no body to parse, which is not an error. func TestExchangeRequestParseFormQueryWithoutBody(t *testing.T) { var form httpraw.Form var gotErr error var sm MuxSlice sm.Reset(1) sm.Handle("GET /f", func(exch *Exchange) { gotErr = exch.RequestParseForm(&form, true, true) }) serve(t, "GET /f?a=1&b=2 HTTP/1.1\r\nHost: h\r\n\r\n", &sm) if gotErr != nil { t.Fatalf("want the query parsed with no body, got %s", gotErr) } if got := formString(&form); got != "a=1|b=2" { t.Errorf("want a=1|b=2, got %q", got) } } // The separator must not merge the two sources into one pair: without it the // last query pair and the first body pair run together. func TestExchangeRequestParseFormSourcesNotMerged(t *testing.T) { var form httpraw.Form var sm MuxSlice sm.Reset(1) sm.Handle("POST /f", func(exch *Exchange) { if err := exch.RequestParseForm(&form, true, true); err != nil { t.Fatal(err) } }) const body = "second=2" serve(t, "POST /f?first=1 HTTP/1.1\r\nHost: h\r\n"+ "Content-Type: application/x-www-form-urlencoded\r\n"+ "Content-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm) if got := formString(&form); got != "first=1|second=2" { t.Errorf("want first=1|second=2, got %q", got) } }