package httphi import ( "context" "errors" "io" "strings" "testing" "time" "github.com/soypat/lneto/http/httpraw" "github.com/soypat/lneto" ) func nopBackoff(consecutiveBackoffs uint) time.Duration { return lneto.BackoffFlagNop } // newExchange returns an Exchange acquired on conn, ready to serve a request. func newExchange(t *testing.T, conn conn, bufferSize int, normalizeKeys bool) *Exchange { t.Helper() exch := new(Exchange) exch.Configure(make([]byte, 2*bufferSize), bufferSize, normalizeKeys) 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, bufferSize, false) 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) { 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"}, } { conn := newConn("") exch := newExchange(t, conn, 128, false) exch.WriteHeader(test.code) if got := conn.ViewWritten(); got != test.want { t.Errorf("code %d: want %q, got %q", test.code, test.want, got) } } } // Status line is written once: a second WriteHeader must not reach the wire. func TestExchangeWriteHeaderOnce(t *testing.T) { conn := newConn("") exch := newExchange(t, conn, 128, false) 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) { const body = "hello" conn := newConn("") exch := newExchange(t, conn, 128, false) 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, 128, 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, bufferSize, false) 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.RequestMethod()) 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, 1024, false) 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") } }) } } // No registered handler must yield 404, not an empty response. func TestHandleNoHandler(t *testing.T) { var sm MuxSlice 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, 128, false) 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, 128, false) 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) { const key, value = "K", "V" const field = len(key) + len(value) + len(":\r\n") for _, bufLen := range []int{field + 2, field + 1, field} { conn := newConn("") exch := new(Exchange) exch.Configure(make([]byte, bufLen), bufLen, false) 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("buffer %d: want %q, got %q", bufLen, want, got) } if wantSet := bufLen >= field+2; set != wantSet { t.Errorf("buffer %d: want SetHeader=%v, got %v", bufLen, 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 _, request := range []string{ "GET / HTTP/1.1\r\nHost: h\r\n\r\n", // Served. "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", // 404. "GET /\r\nHost: h\r\n\r\n", // Rejected: no HTTP version. "GET / HTTP/1.1\r\nBadFieldNoColon\r\n\r\n", // Rejected: parse error. } { conn := newConn(request) conn.Hangup() exch := newExchange(t, conn, 1024, false) Handle(exch, &sm, nopBackoff) if conn.IsClosed() { t.Errorf("Handle closed the connection for %q", 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, 1024, false) 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, 1024, false) 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, 128, false) 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. } { conn := newConn("") exch := newExchange(t, conn, 256, false) exch.StageHeaderInt("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("value %d base %d: want %q, got %q", test.value, test.base, test.want, got) } } } // SetHeaderInt must format into the response buffer without allocating. func TestExchangeSetHeaderIntNoAlloc(t *testing.T) { exch := newExchange(t, newConn(""), 256, false) allocs := testing.AllocsPerRun(100, func() { exch.StageHeaderInt("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. } { var sm MuxSlice var got string var present bool sm.Handle("/x", func(ex *Exchange) { var value []byte value, present = ex.AppendQuery(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("%s key %q decoded=%v: want present=%v, got %v", test.uri, test.key, test.decoded, test.wantPresent, present) } if got != test.want { t.Errorf("%s key %q decoded=%v: want %q, got %q", test.uri, test.key, test.decoded, 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.AppendQuery(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" func TestExchangeRequestParseForm(t *testing.T) { for _, test := range []struct { name string request string bufSize int // Defaults to 64. want string wantErr error }{ { name: "pairs", request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&b=2&c=3", want: "a=1|b=2|c=3", }, { // A flag and an empty value stay distinguishable, unlike http.FormValue. name: "flag and empty", request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 4\r\n\r\na&b=", want: "a|b=", }, { name: "left encoded", request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 7\r\n\r\nn=a%20b", want: "n=a%20b", }, { name: "media type parameters", request: "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: application/x-www-form-urlencoded; charset=utf-8\r\nContent-Length: 3\r\n\r\na=1", want: "a=1", }, { // Only the body is parsed: the query string is not folded in. name: "query not folded", request: "POST /f?q=go HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 3\r\n\r\na=1", want: "a=1", }, { // No Content-Length means no body at all, RFC 9112 6.3. name: "no content length", request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "\r\n", want: "", }, { name: "wrong media type", request: "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: text/plain\r\nContent-Length: 3\r\n\r\na=1", wantErr: errNotFormEncoded, }, { name: "no media type", request: "POST /f HTTP/1.1\r\nHost: h\r\nContent-Length: 3\r\n\r\na=1", wantErr: errNotFormEncoded, }, { name: "chunked", request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Transfer-Encoding: chunked\r\n\r\n3\r\na=1\r\n0\r\n\r\n", wantErr: errUnsupportedTransferCoding, }, { name: "body larger than buffer", request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&b=2&c=3", bufSize: 4, wantErr: lneto.ErrBufferFull, }, } { t.Run(test.name, func(t *testing.T) { bufSize := test.bufSize if bufSize == 0 { bufSize = 64 } var form httpraw.Form var gotErr error var sm MuxSlice sm.Reset(1) sm.Handle("/f", func(exch *Exchange) { gotErr = exch.RequestParseForm(&form, make([]byte, bufSize), nopBackoff) }) serve(t, test.request, &sm) if gotErr != test.wantErr { t.Fatalf("want error %v, got %v", test.wantErr, gotErr) } if got := formString(&form); test.wantErr == nil && got != test.want { t.Errorf("want %q, got %q", test.want, got) } }) } } // 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, make([]byte, 64), nopBackoff) }) exch := newExchange(t, conn, 1024, false) 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, make([]byte, 64), nopBackoff); 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 } // serveMultipart serves request to a handler that streams its multipart body // with [Exchange.ReadMultiparts] over a buffer of bufSize bytes. segments are // delivered on later reads, so the parser must compact and read more to see // them. skip names the parts whose sink is refused, exercising discarding. func serveMultipart(t *testing.T, request string, bufSize int, skip string, segments ...string) ([]MultipartSink, error) { t.Helper() conn := newConn(request) for _, segment := range segments { conn.AddSegment(segment) } 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 skip != "" && string(hdr.Name) == skip { return nil // Discard this part's content. } return new(partBuffer) } parts, gotErr = exch.ReadMultiparts(parts, make([]byte, bufSize), newSink, nopBackoff) }) exch := newExchange(t, conn, 1024, false) if err := Handle(exch, &sm, nopBackoff); err != nil { t.Fatal(err) } return parts, gotErr } // 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() } // 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. func TestExchangeReadMultiparts(t *testing.T) { const ( head = "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=--xyz\r\n\r\n" part1 = "----xyz\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\nhi there\r\n" part2 = "----xyz\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"beach.png\"\r\n\r\n\x89PNG\r\n\x00\r\n" tail = "----xyz--\r\n" ) parts, err := serveMultipart(t, head+part1+part2[:20], 128, "", part2[20:]+tail) if err != nil { t.Fatal(err) } const want = "caption=hi there|photo(beach.png)=\x89PNG\r\n\x00" if got := partsString(t, parts); got != want { t.Errorf("want %q, got %q", want, got) } } // 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. func TestExchangeReadMultipartsPartLargerThanBuffer(t *testing.T) { // Content teases the parser with delimiter prefixes that never complete. content := strings.Repeat("\r\n--xy", 16) + strings.Repeat("A", 100) + "\r\n--xyy" body := "--xyz\r\nContent-Disposition: form-data; name=\"blob\"\r\n\r\n" + content + "\r\n--xyz--\r\n" head := "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=xyz\r\n\r\n" parts, err := serveMultipart(t, head, 64, "", body[:30], body[30:]) if err != nil { t.Fatal(err) } want := "blob=" + content if got := partsString(t, parts); got != want { t.Errorf("want %q, got %q", want, got) } } // A nil sink discards a part's content without losing its place in the body: // the parts around it must still arrive whole. func TestExchangeReadMultipartsDiscardsPart(t *testing.T) { const ( head = "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=xyz\r\n\r\n" part1 = "--xyz\r\nContent-Disposition: form-data; name=\"keep\"\r\n\r\nkept\r\n" part2 = "--xyz\r\nContent-Disposition: form-data; name=\"huge\"; filename=\"big.bin\"\r\n\r\n" part3 = "--xyz\r\nContent-Disposition: form-data; name=\"also\"\r\n\r\nkept too\r\n" tail = "--xyz--\r\n" ) discarded := strings.Repeat("Z", 200) + "\r\n" parts, err := serveMultipart(t, head+part1+part2+discarded+part3+tail, 96, "huge") if err != nil { t.Fatal(err) } const want = "keep=kept|huge(big.bin)=|also=kept too" if got := partsString(t, parts); got != want { t.Errorf("want %q, got %q", want, got) } } // A part header that does not fit the buffer cannot be completed by reading // more, so the caller is told instead of spinning. func TestExchangeReadMultipartsHeaderLargerThanBuffer(t *testing.T) { head := "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=xyz\r\n\r\n" body := "--xyz\r\nContent-Disposition: form-data; name=\"" + strings.Repeat("n", 64) + "\"\r\n\r\nv\r\n--xyz--\r\n" parts, err := serveMultipart(t, head+body, 32, "") if err != lneto.ErrShortBuffer { t.Errorf("want %v, got %v", lneto.ErrShortBuffer, err) } if len(parts) != 0 { t.Errorf("want no parts reported for a header that never parsed, got %d", len(parts)) } } // A buffer too small to ever outgrow a delimiter is a caller error, refused // before any of the body is read. func TestExchangeReadMultipartsBufferUnusable(t *testing.T) { head := "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=xyz\r\n\r\n" body := "--xyz\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nv\r\n--xyz--\r\n" if _, err := serveMultipart(t, head+body, len("\r\n--xyz"), ""); err != lneto.ErrInvalidConfig { t.Errorf("want %v, got %v", lneto.ErrInvalidConfig, err) } } // 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}, } { 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("%q: want error %v, got %v", test.contentType, test.wantErr, gotErr) } } }