io.ErrNoProgress on parsing form spin

This commit is contained in:
Patricio Whittingslow
2026-07-28 23:08:54 -03:00
parent e1766d072f
commit 29c0d2a008
3 changed files with 468 additions and 223 deletions
+6 -1
View File
@@ -445,7 +445,12 @@ func (exch *Exchange) RequestParseForm(dst *httpraw.Form, buf []byte) error {
for read := 0; read < len(buf); { for read := 0; read < len(buf); {
n, err := exch.ReadBody(buf[read:]) n, err := exch.ReadBody(buf[read:])
read += n read += n
if n == 0 && err != nil { if n == 0 {
if err == nil {
err = io.ErrNoProgress
} else if err == io.EOF {
break
}
return err return err
} }
} }
+441 -212
View File
@@ -4,10 +4,8 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"net/http"
"strconv" "strconv"
"strings" "strings"
"unsafe"
"testing" "testing"
"time" "time"
@@ -69,12 +67,14 @@ func TestExchangeWriteHeader(t *testing.T) {
// Longest status text in status.go: worst case for the status line buffer. // 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"}, {code: 511, want: "HTTP/1.1 511 Network Authentication Required\r\n\r\n"},
} { } {
conn := newConn("") t.Run(strconv.Itoa(test.code), func(t *testing.T) {
exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64}) conn := newConn("")
exch.WriteHeader(test.code) exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64})
if got := conn.ViewWritten(); got != test.want { exch.WriteHeader(test.code)
t.Errorf("code %d: want %q, got %q", test.code, test.want, got) if got := conn.ViewWritten(); got != test.want {
} t.Errorf("want %q, got %q", test.want, got)
}
})
} }
} }
@@ -365,35 +365,37 @@ func TestExchangeStageOKAndFail(t *testing.T) {
const field = len(key) + len(value) + len(":\r\n") const field = len(key) + len(value) + len(":\r\n")
const numHeaderCap = 4 const numHeaderCap = 4
for _, bufLen := range []int{field + 2, field + 1, field} { for _, bufLen := range []int{field + 2, field + 1, field} {
conn := newConn("") t.Run("buffer"+strconv.Itoa(bufLen), func(t *testing.T) {
exch := new(Exchange) conn := newConn("")
exch.Configure(ExchangeConfig{ exch := new(Exchange)
RawBuf: make([]byte, bufLen), exch.Configure(ExchangeConfig{
RequestBufferLim: bufLen, RawBuf: make([]byte, bufLen),
NumHeaderKVCap: numHeaderCap, RequestBufferLim: bufLen,
}) NumHeaderKVCap: numHeaderCap,
if !exch.Acquire(conn) { })
t.Fatal("fresh exchange failed to acquire connection") 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 = "" set := exch.StageHeader(key, value)
} n, err := exch.FlushHeader()
if got := conn.ViewWritten(); got != want {
t.Errorf("buffer %d: want %q, got %q", bufLen, want, got) want := "HTTP/1.1 200 OK\r\n"
} if set {
if wantSet := bufLen >= field+2; set != wantSet { want += key + ":" + value + "\r\n"
t.Errorf("buffer %d: want SetHeader=%v, got %v", bufLen, wantSet, set) 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)
}
})
} }
} }
@@ -402,19 +404,24 @@ func TestExchangeStageOKAndFail(t *testing.T) {
func TestHandleLeavesConnOpen(t *testing.T) { func TestHandleLeavesConnOpen(t *testing.T) {
var sm MuxSlice var sm MuxSlice
sm.Handle("GET /", staticPage(t, "ok")) sm.Handle("GET /", staticPage(t, "ok"))
for _, request := range []string{ for _, test := range []struct {
"GET / HTTP/1.1\r\nHost: h\r\n\r\n", // Served. name string
"GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", // 404. request string
"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. {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"},
} { } {
conn := newConn(request) t.Run(test.name, func(t *testing.T) {
conn.Hangup() conn := newConn(test.request)
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) conn.Hangup()
Handle(exch, &sm, nopBackoff) exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
if conn.IsClosed() { Handle(exch, &sm, nopBackoff)
t.Errorf("Handle closed the connection for %q", request) if conn.IsClosed() {
} t.Errorf("Handle closed the connection for %q", test.request)
}
})
} }
} }
@@ -516,14 +523,17 @@ func TestExchangeSetHeaderInt(t *testing.T) {
{value: 1, base: 2, want: "\r\n"}, // Below base 10, dropped. {value: 1, base: 2, want: "\r\n"}, // Below base 10, dropped.
{value: 1, base: 37, want: "\r\n"}, // Above base 36, dropped. {value: 1, base: 37, want: "\r\n"}, // Above base 36, dropped.
} { } {
conn := newConn("") name := strconv.FormatInt(test.value, 10) + "_base" + strconv.Itoa(test.base)
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256}) t.Run(name, func(t *testing.T) {
exch.StageHeaderInt("N", test.value, test.base) conn := newConn("")
exch.WriteHeader(200) exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256})
got, _ := strings.CutPrefix(conn.ViewWritten(), "HTTP/1.1 200 OK\r\n") exch.StageHeaderInt("N", test.value, test.base)
if got != test.want { exch.WriteHeader(200)
t.Errorf("value %d base %d: want %q, got %q", test.value, test.base, test.want, got) 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)
}
})
} }
} }
@@ -592,22 +602,34 @@ func TestExchangeAppendQuery(t *testing.T) {
{uri: "/x?q=%zz", key: "q", decoded: true, want: "", wantPresent: false}, {uri: "/x?q=%zz", key: "q", decoded: true, want: "", wantPresent: false},
{uri: "/x?q=%zz", key: "q", want: "%zz", wantPresent: true}, // Undecoded, passed through. {uri: "/x?q=%zz", key: "q", want: "%zz", wantPresent: true}, // Undecoded, passed through.
} { } {
var sm MuxSlice // The path is the same for every case: name them by what differs, and
var got string // keep the '/' out so the name stays a single -run element.
var present bool name := strings.TrimPrefix(test.uri, "/x")
sm.Handle("/x", func(ex *Exchange) { if name == "" {
var value []byte name = "noquery"
value, present = ex.AppendQuery(nil, test.key, test.decoded) }
got = string(value) name += "_" + test.key
}) if test.decoded {
serve(t, "GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n", &sm) 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.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 { 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) t.Errorf("want present=%v, got %v", test.wantPresent, present)
} }
if got != test.want { if got != test.want {
t.Errorf("%s key %q decoded=%v: want %q, got %q", test.uri, test.key, test.decoded, test.want, got) t.Errorf("want %q, got %q", test.want, got)
} }
})
} }
} }
@@ -656,78 +678,170 @@ func formString(f *httpraw.Form) string {
const formType = "Content-Type: application/x-www-form-urlencoded\r\n" 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) { func TestExchangeRequestParseForm(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
request string formVals []formPair
bufSize int // Defaults to 64. // wantVals defaults to formVals: set it only where what comes back out
want string // differs from what went in, as decoding makes it.
wantErr error 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", 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", formVals: []formPair{{key: "a", value: "1"}, {key: "b", value: "2"}, {key: "c", value: "3"}},
want: "a=1|b=2|c=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. // A flag and an empty value stay distinguishable, unlike http.FormValue.
name: "flag and empty", name: "flag and empty",
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 4\r\n\r\na&b=", formVals: []formPair{{key: "a", flag: true}, {key: "b", value: ""}},
want: "a|b=",
}, { }, {
name: "left encoded", // Decoding is the caller's call: untouched without it.
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 7\r\n\r\nn=a%20b", name: "left encoded",
want: "n=a%20b", formVals: []formPair{{key: "n", value: "a%20b"}},
}, { }, {
name: "media type parameters", // Decode reaches both keys and values.
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", name: "decoded",
want: "a=1", 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. // Only the body is parsed: the query string is not folded in.
name: "query not folded", 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", formVals: []formPair{{key: "a", value: "1"}},
want: "a=1", target: "/f?q=go",
}, { }, {
// No Content-Length means no body at all, RFC 9112 6.3. name: "no content length",
name: "no content length", noContentLength: true,
request: "POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "\r\n",
want: "",
}, { }, {
name: "wrong media type", 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", formVals: []formPair{{key: "a", value: "1"}},
wantErr: errNotFormEncoded, contentType: "text/plain",
wantErr: errNotFormEncoded,
}, { }, {
name: "no media type", name: "no media type",
request: "POST /f HTTP/1.1\r\nHost: h\r\nContent-Length: 3\r\n\r\na=1", formVals: []formPair{{key: "a", value: "1"}},
wantErr: errNotFormEncoded, noContentType: true,
wantErr: errNotFormEncoded,
}, { }, {
name: "chunked", // The coding is refused on the field alone, so the body stays off.
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", name: "chunked",
wantErr: errUnsupportedTransferCoding, noContentLength: true,
extraHeaders: "Transfer-Encoding: chunked\r\n",
wantErr: errUnsupportedTransferCoding,
}, { }, {
name: "body larger than buffer", 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", formVals: []formPair{{key: "a", value: "1"}, {key: "b", value: "2"}, {key: "c", value: "3"}},
bufSize: 4, bufsize: 4,
wantErr: lneto.ErrShortBuffer, wantErr: lneto.ErrShortBuffer,
}, },
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
bufSize := test.bufSize bufSize := test.bufsize
if bufSize == 0 { if bufSize == 0 {
bufSize = 64 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)
var form httpraw.Form var form httpraw.Form
var gotErr error var gotErr error
var sm MuxSlice var sm MuxSlice
sm.Reset(1) sm.Reset(1)
sm.Handle("/f", func(exch *Exchange) { sm.Handle("/f", func(exch *Exchange) {
gotErr = exch.RequestParseForm(&form, make([]byte, bufSize)) gotErr = exch.RequestParseForm(&form, make([]byte, bufSize))
if gotErr == nil && test.callDecode {
gotErr = form.Decode()
}
}) })
serve(t, test.request, &sm) serve(t, builder.String(), &sm)
if gotErr != test.wantErr { if gotErr != test.wantErr {
t.Fatalf("want error %v, got %v", test.wantErr, gotErr) 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 got := formString(&form); test.wantErr == nil && got != test.want { if form.Len() != len(wantVals) {
t.Errorf("want %q, got %q", test.want, got) 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)
}
} }
}) })
} }
@@ -792,15 +906,50 @@ func (p *partBuffer) Write(b []byte) (int, error) {
func (p *partBuffer) Close() error { p.closed = true; return 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 // serveMultipart serves request to a handler that streams its multipart body
// with [Exchange.ReadMultiparts] over a buffer of bufSize bytes. segments are // with [Exchange.ReadMultiparts] over a buffer of bufSize bytes. Bytes past
// delivered on later reads, so the parser must compact and read more to see // each offset in segmentAt are delivered on later reads, so the parser must
// them. skip names the parts whose sink is refused, exercising discarding. // compact and read more to see them. discard names the parts whose sink is
func serveMultipart(t *testing.T, request string, bufSize int, skip string, segments ...string) ([]MultipartSink, error) { // refused.
func serveMultipart(t *testing.T, request string, bufSize int, discard []string, segmentAt []int) ([]MultipartSink, error) {
t.Helper() t.Helper()
conn := newConn(request) prev := 0
for _, segment := range segments { for _, off := range segmentAt {
conn.AddSegment(segment) 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() conn.Hangup()
var parts []MultipartSink var parts []MultipartSink
@@ -809,14 +958,15 @@ func serveMultipart(t *testing.T, request string, bufSize int, skip string, segm
sm.Reset(1) sm.Reset(1)
sm.Handle("/f", func(exch *Exchange) { sm.Handle("/f", func(exch *Exchange) {
newSink := func(hdr *httpraw.MultipartHeader) io.WriteCloser { newSink := func(hdr *httpraw.MultipartHeader) io.WriteCloser {
if skip != "" && string(hdr.Name) == skip { for _, name := range discard {
return nil // Discard this part's content. if string(hdr.Name) == name {
return nil // Discard this part's content.
}
} }
return new(partBuffer) return new(partBuffer)
} }
parts, gotErr = exch.ReadMultiparts(parts, make([]byte, bufSize), newSink) parts, gotErr = exch.ReadMultiparts(parts, make([]byte, bufSize), newSink)
}) })
const x = unsafe.Sizeof(http.Request{})
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024}) exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
if err := Handle(exch, &sm, nopBackoff); err != nil { if err := Handle(exch, &sm, nopBackoff); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -824,6 +974,49 @@ func serveMultipart(t *testing.T, request string, bufSize int, skip string, segm
return parts, gotErr 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 // partsString renders parts as "name=content" joined by '|', a file part shown
// as "name(filename)=content" and a discarded one as "name=<nil>". Fails the // as "name(filename)=content" and a discarded one as "name=<nil>". Fails the
// test if a sink was left open, which would hide a part that never ended. // test if a sink was left open, which would hide a part that never ended.
@@ -855,85 +1048,115 @@ func partsString(t *testing.T, parts []MultipartSink) string {
return sb.String() return sb.String()
} }
// Names, filenames and content of every part, over a body split so that a part // mpTeaser is content that teases the parser with delimiter prefixes that never
// straddles two reads and the parser must compact and read more. // 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) { func TestExchangeReadMultiparts(t *testing.T) {
const ( for _, test := range []struct {
head = "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=--xyz\r\n\r\n" name string
part1 = "----xyz\r\nContent-Disposition: form-data; name=\"caption\"\r\n\r\nhi there\r\n" boundary string // Defaults to "xyz".
part2 = "----xyz\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"beach.png\"\r\n\r\n\x89PNG\r\n\x00\r\n" parts []multipartPart // The body sent.
tail = "----xyz--\r\n" // 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.
parts, err := serveMultipart(t, head+part1+part2[:20], 128, "", part2[20:]+tail) wantParts []multipartPart
if err != nil { // segmentAt are offsets into the body where a later read begins. The
t.Fatal(err) // request header always arrives in the first read.
} segmentAt []int
const want = "caption=hi there|photo(beach.png)=\x89PNG\r\n\x00" bufsize int // Defaults to 128.
if got := partsString(t, parts); got != want { wantErr error
t.Errorf("want %q, got %q", want, got) }{
} {
} // 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
}
// A part longer than the buffer must come out whole: every compaction has to parts, err := serveMultipart(t, head+string(body), bufSize, discard, segmentAt)
// keep the tail NextBody held back, or content that looks like the start of a if err != test.wantErr {
// delimiter is dropped. The header's Name must survive those reads too. t.Fatalf("want error %v, got %v", test.wantErr, err)
func TestExchangeReadMultipartsPartLargerThanBuffer(t *testing.T) { }
// Content teases the parser with delimiter prefixes that never complete. if test.wantErr != nil {
content := strings.Repeat("\r\n--xy", 16) + strings.Repeat("A", 100) + "\r\n--xyy" // Nothing parsed is promised on failure, and a part reported
body := "--xyz\r\nContent-Disposition: form-data; name=\"blob\"\r\n\r\n" + content + "\r\n--xyz--\r\n" // for a header that never parsed is a part the caller cannot
head := "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=xyz\r\n\r\n" // act on.
parts, err := serveMultipart(t, head, 64, "", body[:30], body[30:]) if len(parts) != 0 {
if err != nil { t.Errorf("want no parts reported, got %d: %q", len(parts), partsString(t, parts))
t.Fatal(err) }
} return
want := "blob=" + content }
if got := partsString(t, parts); got != want { checkParts(t, parts, wantParts)
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)=<nil>|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)
} }
} }
@@ -948,20 +1171,26 @@ func TestExchangeRequestParseMultipartRejects(t *testing.T) {
{contentType: "multipart/form-data", wantErr: true}, // Boundary is required. {contentType: "multipart/form-data", wantErr: true}, // Boundary is required.
{contentType: "", wantErr: true}, {contentType: "", wantErr: true},
} { } {
var gotErr error name := test.contentType
var sm MuxSlice if name == "" {
sm.Reset(1) name = "no content type"
sm.Handle("/f", func(exch *Exchange) { }
_, gotErr = exch.RequestMultipart() 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)
}
}) })
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)
}
} }
} }
+21 -10
View File
@@ -231,15 +231,16 @@ func TestRouterRequestVisibleToHandler(t *testing.T) {
func TestRouterMux(t *testing.T) { func TestRouterMux(t *testing.T) {
const bufferSize = 1024 const bufferSize = 1024
for _, test := range []struct { for _, test := range []struct {
name string name string
request string request string
want string // Response body, empty means no handler must run. want string // Response body the matched handler must have written.
wantNoHandler bool // No registration matches: the router must answer 404 itself.
}{ }{
{name: "get root", request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n", want: "root"}, {name: "get root", request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n", want: "root"},
{name: "get page", request: "GET /page HTTP/1.1\r\nHost: h\r\n\r\n", want: "page"}, {name: "get page", request: "GET /page HTTP/1.1\r\nHost: h\r\n\r\n", want: "page"},
{name: "any method", request: "DELETE /any HTTP/1.1\r\nHost: h\r\n\r\n", want: "any"}, {name: "any method", request: "DELETE /any HTTP/1.1\r\nHost: h\r\n\r\n", want: "any"},
{name: "method mismatch", request: "POST / HTTP/1.1\r\nHost: h\r\n\r\n", want: ""}, {name: "method mismatch", request: "POST / HTTP/1.1\r\nHost: h\r\n\r\n", wantNoHandler: true},
{name: "unknown uri", request: "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", want: ""}, {name: "unknown uri", request: "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", wantNoHandler: true},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
var ( var (
@@ -258,14 +259,24 @@ func TestRouterMux(t *testing.T) {
conn.AwaitClose(t, time.Second) conn.AwaitClose(t, time.Second)
got := conn.ViewWritten() got := conn.ViewWritten()
if test.want == "" { _, body, found := strings.Cut(got, "\r\n\r\n")
if strings.Contains(got, "root") || strings.Contains(got, "page") || strings.Contains(got, "any") { if !found {
t.Errorf("no handler must run, got response %q", got) t.Fatalf("header block never terminated: %q", got)
}
if test.wantNoHandler {
if !strings.HasPrefix(got, "HTTP/1.1 404 ") {
t.Errorf("want a 404 answer, got %q", got)
}
if body != "" {
t.Errorf("no handler must run, got body %q", body)
} }
return return
} }
if !strings.HasSuffix(got, test.want) { if !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") {
t.Errorf("want body %q, got response %q", test.want, got) t.Errorf("want a 200 answer, got %q", got)
}
if body != test.want {
t.Errorf("want body %q, got %q", test.want, body)
} }
}) })
} }