From 4f1a178f299ec84c88df6c535a717af317527344 Mon Sep 17 00:00:00 2001 From: Patricio Whittingslow Date: Sun, 26 Jul 2026 17:22:09 -0300 Subject: [PATCH] first Multipart approach --- http/httphi/bench_test.go | 32 +++++ http/httphi/exchange.go | 116 ++++++++++++++++ http/httphi/exchange_test.go | 247 +++++++++++++++++++++++++++++++++ http/httphi/router.go | 4 + http/httpraw/header.go | 36 ++++- http/httpraw/header_test.go | 33 +++++ http/httpraw/multipart.go | 22 ++- http/httpraw/multipart_test.go | 50 ++++++- http/httpraw/parse.go | 3 + 9 files changed, 528 insertions(+), 15 deletions(-) diff --git a/http/httphi/bench_test.go b/http/httphi/bench_test.go index 0d9996e..c7f25b7 100644 --- a/http/httphi/bench_test.go +++ b/http/httphi/bench_test.go @@ -4,6 +4,7 @@ import ( "io" "testing" + "github.com/soypat/lneto/http/httpraw" "github.com/soypat/lneto/internal" ) @@ -95,3 +96,34 @@ func BenchmarkHandle(b *testing.B) { }) } } + +// benchForm is package level so the Form's pair slice is reused across requests, +// as a real handler holding one per goroutine would. +var benchForm httpraw.Form + +// BenchmarkRequestParseForm measures reading and parsing a urlencoded body into +// a buffer the caller owns. Nothing on the path may allocate. +func BenchmarkRequestParseForm(b *testing.B) { + const request = "POST /f HTTP/1.1\r\nHost: tinygo.org\r\n" + + "Content-Type: application/x-www-form-urlencoded\r\nContent-Length: 27\r\n\r\n" + + "user=gopher&msg=hello+world" + buf := make([]byte, 64) + var mux MuxSlice + mux.Handle("POST /f", func(ex *Exchange) { + err := ex.RequestParseForm(&benchForm, buf, nopBackoff) + if err != nil || benchForm.Len() != 2 { + panic("invalid result") + } + }) + conn := &benchConn{request: request} + exch := benchExchange(b, conn) + b.ReportAllocs() + b.SetBytes(int64(len(request))) + b.ResetTimer() + for i := 0; i < b.N; i++ { + conn.rewind() + exch.Release() + exch.Acquire(conn) + Handle(exch, &mux, nopBackoff) + } +} diff --git a/http/httphi/exchange.go b/http/httphi/exchange.go index 99c9561..938ed78 100644 --- a/http/httphi/exchange.go +++ b/http/httphi/exchange.go @@ -380,6 +380,122 @@ func (exch *Exchange) RequestParseCookie(dst *httpraw.Cookie, key string) error return dst.ParseBytes(value) } +// RequestContentType returns the request's Content-Type field value as it +// appears on the wire, parameters included, nil if absent. Test it with +// [httpraw.MediaTypeIs] and pick parameters out with [httpraw.ContentParam]. +func (exch *Exchange) RequestContentType() []byte { + return exch.RequestHeader("Content-Type") +} + +// RequestContentLength returns the body length declared by the request's +// Content-Length field. An absent field is not a client error: such a request +// has no body at all, RFC 9112 6.3. Check for the error to answer 411 instead. +// See [httpraw.Header.ContentLength]. +func (exch *Exchange) RequestContentLength() (int64, error) { + return exch.RequestHeaderRaw().ContentLength() +} + +// RequestParseForm reads the request body into buf and parses it as +// "application/x-www-form-urlencoded" into dst. buf is the only storage used and +// the only limit: a body longer than buf is refused with [lneto.ErrBufferFull] +// before a single byte is read, leaving the caller free to answer 413. Pairs are +// left as they arrived, call [httpraw.Form.Decode] to decode them in place. +// +// Unlike http.Request.ParseForm the query string is not folded in, reach it with +// [Exchange.RequestQuery] or [Exchange.AppendQuery]. The body is consumed, so +// call this before [Exchange.ReadBody]. +// +// A request with no Content-Length has no body, RFC 9112 6.3, and yields an +// empty form. Use [Exchange.RequestContentLength] to tell that apart from a body +// that arrived empty. backoff paces reads that return no data, as in [Handle]. +func (exch *Exchange) RequestParseForm(dst *httpraw.Form, buf []byte, backoff lneto.BackoffStrategy) error { + if !httpraw.MediaTypeIs(exch.RequestContentType(), "application/x-www-form-urlencoded") { + return errNotFormEncoded + } else if exch.RequestHeader("Transfer-Encoding") != nil { + // Chunked bodies are framed, so reading Content-Length bytes off the + // wire would parse chunk sizes as form data. httpraw does not decode them. + return errUnsupportedTransferCoding + } + length, err := exch.RequestContentLength() + if err != nil { + dst.Reset(buf[:0]) + return dst.Parse() // No length is no body, RFC 9112 6.3. + } else if length > int64(len(buf)) { + return lneto.ErrBufferFull // Refuse before reading, caller may answer 413. + } + buf = buf[:length] + var consecutiveBackoffs uint + for read := 0; read < len(buf); { + n, err := exch.ReadBody(buf[read:]) + if err != nil { + return err + } else if n == 0 { + backoff.Do(consecutiveBackoffs) + consecutiveBackoffs++ + continue + } + consecutiveBackoffs = 0 + read += n + } + dst.Reset(buf) + return dst.Parse() +} + +// RequestParseMultipart prepares dst from the boundary parameter of the +// request's Content-Type field. It reads no body: multipart parts declare no +// length, so the caller drives the loop with a buffer it owns and decides per +// part what to keep and when a part has grown too large. +// +// A part header and the bytes held back by [httpraw.Multipart.NextBody] both ask +// to be completed the same way: compact what is left to the front of the buffer +// and read more in behind it. A buffer that fills without completing either is +// the caller's cue that the part is too large to go on with. +// +// // refill compacts rest to the front of buf and reads more of the body in. +// refill := func(rest []byte) ([]byte, error) { +// n := copy(buf, rest) +// if n == len(buf) { +// return nil, lneto.ErrBufferFull +// } +// nr, err := exch.ReadBody(buf[n:]) +// return buf[:n+nr], err +// } +// +// err := exch.RequestParseMultipart(&mp) +// rest := buf[:0] +// for { +// next, err := mp.NextHeader(&hdr, rest) +// if err == io.EOF { +// break // Closing delimiter, body done. +// } else if err == httpraw.ErrNeedMoreData { +// rest, err = refill(rest) +// // ...handle err, then: +// continue +// } else if err != nil { +// return err +// } +// rest = next +// for { +// body, next, done := mp.NextBody(rest) +// // Consume body for hdr.Name, hdr.Filename. +// rest = next +// if done { +// break +// } +// rest, err = refill(rest) +// if err != nil { +// return err +// } +// } +// } +func (exch *Exchange) RequestMultipart() (mp httpraw.Multipart, err error) { + contentType := exch.RequestContentType() + if !httpraw.MediaTypeIs(contentType, "multipart/form-data") { + return mp, errNotMultipart + } + return mp, mp.SetContentType(contentType) +} + // RequestHeader returns the value of the first request header field matching // key, or nil if absent. Key matching is case sensitive. func (exch *Exchange) RequestHeader(key string) []byte { diff --git a/http/httphi/exchange_test.go b/http/httphi/exchange_test.go index 12267a4..1e7d9cd 100644 --- a/http/httphi/exchange_test.go +++ b/http/httphi/exchange_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "strconv" "strings" "testing" @@ -611,3 +612,249 @@ func TestExchangeAppendQueryReusesBuffer(t *testing.T) { 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) + } +} + +// refill compacts the bytes the multipart parser held back to the front of buf +// and reads more of the body in behind them. +func refill(exch *Exchange, buf, rest []byte) ([]byte, error) { + n := copy(buf, rest) + if n == len(buf) { + return nil, lneto.ErrBufferFull // A delimiter or part header longer than buf. + } + nr, err := exch.ReadBody(buf[n:]) + return buf[:n+nr], err +} + +// The whole multipart loop as a handler writes it, over a body split so that a +// part straddles two reads and the caller must compact and refill. +func TestExchangeRequestParseMultipart(t *testing.T) { + const ( + boundary = "--xyz" + head = "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=" + boundary + "\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" + ) + conn := newConn(head + part1 + part2[:20]) + conn.AddSegment(part2[20:] + tail) + conn.Hangup() + + var got []string + var gotErr error + var sm MuxSlice + sm.Reset(1) + sm.Handle("/f", func(exch *Exchange) { + var mp httpraw.Multipart + if mp, gotErr = exch.RequestMultipart(); gotErr != nil { + return + } + var hdr httpraw.MultipartHeader + buf := make([]byte, 128) + rest := buf[:0] + for { + next, err := mp.NextHeader(&hdr, rest) + if err == io.EOF { + return // Closing delimiter, body done. + } else if err == httpraw.ErrNeedMoreData { + if rest, gotErr = refill(exch, buf, rest); gotErr != nil { + return + } + continue + } else if err != nil { + gotErr = err + return + } + name, total := string(hdr.Name), 0 + rest = next + for { + body, next, done := mp.NextBody(rest) + total += len(body) + rest = next + if done { + break + } + if rest, gotErr = refill(exch, buf, rest); gotErr != nil { + return + } + } + got = append(got, name+":"+strconv.Itoa(total)) + } + }) + exch := newExchange(t, conn, 1024, false) + if err := Handle(exch, &sm, nopBackoff); err != nil { + t.Fatal(err) + } + if gotErr != nil { + t.Fatal(gotErr) + } + const want = "caption:8|photo:7" + if strings.Join(got, "|") != want { + t.Errorf("want %q, got %q", want, strings.Join(got, "|")) + } +} + +// 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) + } + } +} diff --git a/http/httphi/router.go b/http/httphi/router.go index 143ef71..0e25263 100644 --- a/http/httphi/router.go +++ b/http/httphi/router.go @@ -22,6 +22,10 @@ var ( errNoRequestProto = errors.New("httphi: request line with no HTTP version") errBusyExchanges = errors.New("httphi: exchanges still serving, cannot reuse their buffers") errRouterTornDown = errors.New("httphi: router torn down, configure it before serving") + + errNotFormEncoded = errors.New("httphi: request body is not application/x-www-form-urlencoded") + errNotMultipart = errors.New("httphi: request body is not multipart/form-data") + errUnsupportedTransferCoding = errors.New("httphi: transfer coding not decoded, read the body directly") ) type conn = io.ReadWriteCloser diff --git a/http/httpraw/header.go b/http/httpraw/header.go index 7b6b071..c85d1dc 100644 --- a/http/httpraw/header.go +++ b/http/httpraw/header.go @@ -4,15 +4,17 @@ import ( "bytes" "io" "slices" + "strconv" ) const ( - methodGet = "GET" - strHTTP11 = "HTTP/1.1" - strCRLF = "\r\n" - headerCookie = "Cookie" - headerConnection = "Connection" - strClose = "close" + methodGet = "GET" + strHTTP11 = "HTTP/1.1" + strCRLF = "\r\n" + headerCookie = "Cookie" + headerConnection = "Connection" + headerContentLength = "Content-Length" + strClose = "close" ) // Flags is a bitset of signals gathered while parsing or building a header, @@ -343,6 +345,28 @@ func (h *Header) Get(key string) []byte { return nil } +// ContentLength returns the body length declared by the Content-Length field. +// Fails with an error if the field is absent, which for a request means the +// message has no body at all unless a transfer coding applies, RFC 9112 6.3. +// The value must be digits only, so a negative or list-valued field is rejected +// rather than guessed at. +func (h *Header) ContentLength() (int64, error) { + kv := h.peekHeader(headerContentLength) + if !kv.isValid() { + return 0, errNoContentLength + } + value := trimOWS(h.hbuf.musttoken(kv.value)) + if len(value) == 0 { + return 0, errBadContentLength + } + // Unsigned parse of 63 bits rejects a sign and anything past int64's range. + n, err := strconv.ParseUint(b2s(value), 10, 63) + if err != nil { + return 0, errBadContentLength // strconv's error allocates and is not comparable. + } + return int64(n), nil +} + // Add adds a new key-value pair to the HTTP header. Calling Add mangles the buffer. func (h *Header) Add(key, value string) { h.appendHeader(key, value) diff --git a/http/httpraw/header_test.go b/http/httpraw/header_test.go index 188373d..edc8bd0 100644 --- a/http/httpraw/header_test.go +++ b/http/httpraw/header_test.go @@ -173,6 +173,39 @@ func TestHeaderRequestPath(t *testing.T) { } } +func TestHeaderContentLength(t *testing.T) { + for _, test := range []struct { + field string // Extra header lines, empty for an absent field. + want int64 + wantErr error + }{ + {field: "Content-Length: 0", want: 0}, + {field: "Content-Length: 12", want: 12}, + {field: "Content-Length: 12 ", want: 12}, // OWS around the value, RFC 9110 5.6.3. + {field: "Content-Length: 9223372036854775807", want: 9223372036854775807}, + {field: "", wantErr: errNoContentLength}, // No body, RFC 9112 6.3. + {field: "Content-Length:", wantErr: errBadContentLength}, // Present but empty. + {field: "Content-Length: -1", wantErr: errBadContentLength}, // Digits only, RFC 9112 6.2. + {field: "Content-Length: 1 2", wantErr: errBadContentLength}, // Not a list. + {field: "Content-Length: 9223372036854775808", wantErr: errBadContentLength}, + } { + var h Header + raw := "POST / HTTP/1.1\r\nHost: h\r\n" + if test.field != "" { + raw += test.field + "\r\n" + } + if err := h.ParseBytes(false, []byte(raw+"\r\n")); err != nil { + t.Fatal(err) + } + got, err := h.ContentLength() + if err != test.wantErr { + t.Errorf("%q: want error %v, got %v", test.field, test.wantErr, err) + } else if err == nil && got != test.want { + t.Errorf("%q: want %d, got %d", test.field, test.want, got) + } + } +} + func TestNextQueryPair(t *testing.T) { for _, test := range []struct { uri string diff --git a/http/httpraw/multipart.go b/http/httpraw/multipart.go index 173cb42..448565d 100644 --- a/http/httpraw/multipart.go +++ b/http/httpraw/multipart.go @@ -49,9 +49,12 @@ type MultipartHeader struct { Filename []byte } -// SetContentType sets the boundary parameter of a Content-Type field value, -// i.e: "abc123" for "multipart/form-data; boundary=abc123". The leading "--" of -// the wire delimiter is not included. Returns nil if there is no such parameter. +// SetContentType sets [Multipart.Boundary] from the boundary parameter of a +// Content-Type field value, i.e: "abc123" for +// "multipart/form-data; boundary=abc123". The leading "--" the delimiter carries +// on the wire is not included. Fails when the parameter is absent or is not +// 1 to 70 characters long, RFC 2046 5.1.1; a zero length boundary would match +// every "--" in the body. func (m *Multipart) SetContentType(contentType []byte) error { m.Boundary = ContentParam(contentType, "boundary") if len(m.Boundary) == 0 || len(m.Boundary) > 70 { @@ -86,7 +89,7 @@ func (m *Multipart) NextHeader(dst *MultipartHeader, data []byte) (rest []byte, if after >= len(data) { return nil, ErrNeedMoreData } else if data[after] != '\n' { - return nil, errInvalidName // Junk between delimiter and part. + return nil, errBadDelimiter } after++ end := bytes.Index(data[after:], []byte("\r\n\r\n")) @@ -155,6 +158,17 @@ func (m *Multipart) indexPartEnd(data []byte) int { return -1 } +// MediaTypeIs reports whether a Content-Type field value carries the given +// media type, ignoring case and any parameters that follow it, i.e: true for +// "text/plain; charset=utf-8" and media type "text/plain". mediaType must be +// ASCII lowercase. RFC 9110 8.3.1. +func MediaTypeIs(value []byte, mediaType string) bool { + if semi := bytes.IndexByte(value, ';'); semi >= 0 { + value = value[:semi] + } + return equalFold(trimOWS(value), mediaType) +} + // ContentParam returns the value of a parameter of a header field value, i.e: // "utf-8" for key "charset" of "text/plain; charset=utf-8". Quoted values are // returned without their quotes and with escapes left as they appear on the diff --git a/http/httpraw/multipart_test.go b/http/httpraw/multipart_test.go index c72b292..a9060bb 100644 --- a/http/httpraw/multipart_test.go +++ b/http/httpraw/multipart_test.go @@ -28,17 +28,25 @@ func TestMultipartBoundary(t *testing.T) { for _, test := range []struct { contentType string want string + wantErr bool }{ {contentType: "multipart/form-data; boundary=abc123", want: "abc123"}, {contentType: "multipart/form-data; boundary=\"a b\"", want: "a b"}, {contentType: "multipart/form-data; charset=utf-8; boundary=xyz", want: "xyz"}, - {contentType: "multipart/form-data; BOUNDARY=xyz", want: "xyz"}, // Keys are case insensitive. - {contentType: "multipart/form-data", want: ""}, // Absent. - {contentType: "application/x-www-form-urlencoded", want: ""}, + {contentType: "multipart/form-data; BOUNDARY=xyz", want: "xyz"}, // Keys are case insensitive. + {contentType: "multipart/form-data", wantErr: true}, // Absent, RFC 2046 5.1.1 requires it. + {contentType: "application/x-www-form-urlencoded", wantErr: true}, // Not multipart at all. + {contentType: "multipart/form-data; boundary=", wantErr: true}, // Empty matches every "--". } { err := mp.SetContentType([]byte(test.contentType)) - if err != nil { - t.Skip("asdasd") + if test.wantErr { + if err == nil { + t.Errorf("%q: want error, got boundary %q", test.contentType, mp.Boundary) + } + continue + } else if err != nil { + t.Errorf("%q: %s", test.contentType, err) + continue } got := string(mp.Boundary) if got != test.want { @@ -47,6 +55,28 @@ func TestMultipartBoundary(t *testing.T) { } } +func TestMediaTypeIs(t *testing.T) { + for _, test := range []struct { + value string + media string + want bool + }{ + {value: "text/plain", media: "text/plain", want: true}, + {value: "text/plain; charset=utf-8", media: "text/plain", want: true}, + {value: "text/plain;charset=utf-8", media: "text/plain", want: true}, + {value: "Text/Plain", media: "text/plain", want: true}, // Case insensitive, RFC 9110 8.3.1. + {value: " text/plain ; x=1", media: "text/plain", want: true}, + {value: "text/plain", media: "text/html"}, + {value: "text/plainish", media: "text/plain"}, // Prefix must not match. + {value: "", media: "text/plain"}, + {value: "multipart/form-data; boundary=abc", media: "multipart/form-data", want: true}, + } { + if got := MediaTypeIs([]byte(test.value), test.media); got != test.want { + t.Errorf("%q is %q: want %v, got %v", test.value, test.media, test.want, got) + } + } +} + func TestContentParam(t *testing.T) { for _, test := range []struct { value string @@ -106,6 +136,16 @@ func TestNextPartHeaderNeedMore(t *testing.T) { } } +// Junk between the delimiter and the part header is a multipart framing error, +// not a header field name error. +func TestNextPartHeaderJunk(t *testing.T) { + m := Multipart{Boundary: []byte("abc")} + var hdr MultipartHeader + if _, err := m.NextHeader(&hdr, []byte("--abcX\r\nA: b\r\n\r\n")); err != errBadDelimiter { + t.Errorf("want errBadDelimiter, got %v", err) + } +} + // The closing delimiter ends iteration. func TestNextPartHeaderEnd(t *testing.T) { m := Multipart{Boundary: []byte(multiBoundary)} diff --git a/http/httpraw/parse.go b/http/httpraw/parse.go index 666640f..8ae5cf3 100644 --- a/http/httpraw/parse.go +++ b/http/httpraw/parse.go @@ -33,6 +33,9 @@ var ( errCookiesParsed = errors.New("cookies already parsed, reset before parsing again") errBufferTooLarge = errors.New("httpraw: buffer exceeds max size (offsets are uint16)") errBadPercentEncode = errors.New("httpraw: invalid percent-encoding in URL") + errBadDelimiter = errors.New("httpraw: junk between multipart delimiter and part") + errNoContentLength = errors.New("httpraw: no Content-Length field") + errBadContentLength = errors.New("httpraw: invalid Content-Length value") ) // maxBufLen bounds the header buffer. Offsets/lengths are stored as uint16