begin adding multipart form logic

This commit is contained in:
Patricio Whittingslow
2026-07-26 12:56:53 -03:00
parent 92cf46e570
commit 4976cdf38e
4 changed files with 693 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
package httpraw
// Form holds "application/x-www-form-urlencoded" key-value pairs, the encoding
// HTML forms use for POST bodies and query strings alike. Methods function
// similarly to eponymous [Cookie] methods.
//
// Pairs are stored as they appear on the wire, percent-encoded and with '+'
// undecoded, until [Form.Decode] rewrites them in place. The caller bounds the
// data: Form parses the buffer it is handed and reads nothing more.
type Form struct {
buf []byte
kvs []argsKV
}
// Reset discards parsed pairs and sets the buffer to parse in place.
// If buf is nil the current buffer is reused.
func (f *Form) Reset(buf []byte) {
if buf == nil {
buf = f.buf[:0]
}
*f = Form{
buf: buf,
kvs: f.kvs[:0],
}
}
// ParseBytes copies the argument bytes to the Form's underlying buffer and parses them.
func (f *Form) ParseBytes(b []byte) error {
f.Reset(nil)
f.buf = append(f.buf[:0], b...)
return f.Parse()
}
// Parse parses the form's buffer in place.
func (f *Form) Parse() error {
f.kvs = f.kvs[:0]
key, value, rest := NextQueryPair(f.buf)
for key != nil {
kv := argsKV{key: bytes2tok(f.buf, key)}
if value != nil {
kv.value = bytes2tok(f.buf, value)
}
f.kvs = append(f.kvs, kv)
key, value, rest = NextQueryPair(rest)
}
return nil
}
// Decode rewrites every key and value in place, replacing percent escapes and
// '+' with the bytes they encode. Decoding only shrinks, so no memory is added.
func (f *Form) Decode() error {
const plusAsSpace = true // Form encoded data, unlike a path.
for i := range f.kvs {
kv := &f.kvs[i]
n, err := CopyDecodedPercentURL(tok2bytes(f.buf, kv.key), tok2bytes(f.buf, kv.key), plusAsSpace)
if err != nil {
return err
}
kv.key.len = tokint(n)
if !kv.HasValue() {
continue
}
n, err = CopyDecodedPercentURL(tok2bytes(f.buf, kv.value), tok2bytes(f.buf, kv.value), plusAsSpace)
if err != nil {
return err
}
kv.value.len = tokint(n)
}
return nil
}
// Len returns the amount of key-value pairs parsed.
func (f *Form) Len() int { return len(f.kvs) }
// Pair returns the i'th key-value pair in wire order. The value is nil for a
// pair with no '=', i.e: "ok" in "ok&q=go", which distinguishes it from "ok="
// where the value is present and empty.
func (f *Form) Pair(i int) (key, value []byte) {
kv := f.kvs[i]
key = tok2bytes(f.buf, kv.key)
if kv.HasValue() {
value = tok2bytes(f.buf, kv.value)
}
return key, value
}
// Get returns the value of the first pair matching key, nil if absent or if the
// pair has no value. Bytes are compared as stored, so call [Form.Decode] first
// when keys may be encoded.
func (f *Form) Get(key string) []byte {
for i := range f.kvs {
gotKey, value := f.Pair(i)
if b2s(gotKey) == key {
return value
}
}
return nil
}
// Has returns true if key is present, with or without a value.
func (f *Form) Has(key string) bool {
for i := range f.kvs {
if b2s(tok2bytes(f.buf, f.kvs[i].key)) == key {
return true
}
}
return false
}
// AppendKeyValues appends the form's wire representation to dst and returns it.
func (f *Form) AppendKeyValues(dst []byte) []byte {
for i := range f.kvs {
key, value := f.Pair(i)
if i > 0 {
dst = append(dst, '&')
}
dst = append(dst, key...)
if value != nil {
dst = append(dst, '=')
dst = append(dst, value...)
}
}
return dst
}
+134
View File
@@ -0,0 +1,134 @@
package httpraw
import (
"strings"
"testing"
)
// render joins a form's pairs as "key=value", a valueless key as "key".
func render(f *Form) string {
var sb strings.Builder
for i := range f.Len() {
key, value := f.Pair(i)
if i > 0 {
sb.WriteByte('|')
}
sb.Write(key)
if value != nil {
sb.WriteByte('=')
sb.Write(value)
}
}
return sb.String()
}
func TestFormParse(t *testing.T) {
for _, test := range []struct {
body string
want string
}{
{body: "", want: ""},
{body: "q=go", want: "q=go"},
{body: "name=Jos%C3%A9+P%C3%A9rez&msg=hi+there&ok=on", want: "name=Jos%C3%A9+P%C3%A9rez|msg=hi+there|ok=on"},
{body: "ok", want: "ok"}, // Flag: no '=' at all.
{body: "ok=", want: "ok="}, // Present but empty.
{body: "&&q=go&", want: "q=go"}, // Empty sequences skipped.
{body: "tag=a&tag=b", want: "tag=a|tag=b"}, // Duplicates kept in order.
{body: "=v", want: "=v"}, // Empty name kept.
{body: "a=b=c", want: "a=b=c"}, // Only the first '=' splits.
} {
var f Form
if err := f.ParseBytes([]byte(test.body)); err != nil {
t.Fatalf("%q: %s", test.body, err)
}
if got := render(&f); got != test.want {
t.Errorf("%q: want %q, got %q", test.body, test.want, got)
}
}
}
// Decode rewrites keys and values in place: percent escapes and '+' as space.
func TestFormDecode(t *testing.T) {
var f Form
const body = "name=Jos%C3%A9+P%C3%A9rez&a%20b=c%2Bd&ok"
if err := f.ParseBytes([]byte(body)); err != nil {
t.Fatal(err)
}
if err := f.Decode(); err != nil {
t.Fatal(err)
}
const want = "name=José Pérez|a b=c+d|ok"
if got := render(&f); got != want {
t.Errorf("want %q, got %q", want, got)
}
if got := string(f.Get("a b")); got != "c+d" {
t.Errorf("want decoded key lookup %q, got %q", "c+d", got)
}
}
// A malformed escape must be reported, never silently passed through.
func TestFormDecodeMalformed(t *testing.T) {
for _, body := range []string{"q=%zz", "%zz=v", "q=%4"} {
var f Form
if err := f.ParseBytes([]byte(body)); err != nil {
t.Fatal(err)
}
if err := f.Decode(); err == nil {
t.Errorf("%q: want decode error, got nil", body)
}
}
}
func TestFormGetHas(t *testing.T) {
var f Form
if err := f.ParseBytes([]byte("tag=a&tag=b&ok&empty=")); err != nil {
t.Fatal(err)
}
if got := string(f.Get("tag")); got != "a" {
t.Errorf("want first value %q, got %q", "a", got)
}
if got := f.Get("ok"); got != nil {
t.Errorf("want nil value for valueless key, got %q", got)
}
if got := f.Get("nope"); got != nil {
t.Errorf("want nil for absent key, got %q", got)
}
if v := f.Get("empty"); v == nil || len(v) != 0 {
t.Errorf("want present empty value, got %v", v)
}
for _, key := range []string{"tag", "ok", "empty"} {
if !f.Has(key) {
t.Errorf("want Has(%q) true", key)
}
}
if f.Has("nope") {
t.Error("want Has(nope) false")
}
}
func TestFormAppendKeyValues(t *testing.T) {
const body = "name=go&ok&empty=&tag=a&tag=b"
var f Form
if err := f.ParseBytes([]byte(body)); err != nil {
t.Fatal(err)
}
if got := string(f.AppendKeyValues(nil)); got != body {
t.Errorf("want round trip %q, got %q", body, got)
}
}
// Parsing into a reused Form must not allocate: the pair storage is reused.
func TestFormParseReuseNoAlloc(t *testing.T) {
body := []byte("name=go&tag=a&tag=b&ok")
var f Form
if err := f.ParseBytes(body); err != nil { // Warm up the pair storage.
t.Fatal(err)
}
allocs := testing.AllocsPerRun(100, func() {
f.Reset(body)
f.Parse()
})
if allocs != 0 {
t.Errorf("reused Form allocated %v times, want 0", allocs)
}
}
+220
View File
@@ -0,0 +1,220 @@
package httpraw
// Multipart bodies frame their fields with a delimiter instead of escaping them,
// so a part's value has no length: it ends where the next delimiter begins. The
// functions below split a body without storing any of it, leaving the caller to
// decide what to keep, what to skip and when a part has grown too large:
//
// boundary := httpraw.MultipartBoundary(contentType)
// for {
// hdr, rest, err := httpraw.NextPartHeader(buf, boundary)
// if err != nil {
// break // ErrEndOfParts, or ErrNeedMoreData: read more into buf and retry.
// }
// name := httpraw.PartName(hdr)
// for {
// body, next, done := httpraw.NextPartBody(rest, boundary)
// // Consume body, then compact next to the front of buf and read more.
// rest = next
// if done {
// break
// }
// }
// }
type Multipart struct {
Boundary []byte
}
func ()
// MultipartBoundary returns 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.
func MultipartBoundary(contentType []byte) []byte { return ContentParam(contentType, "boundary") }
// 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
// wire. Key matching is case insensitive, RFC 9110 5.6.6.
func ContentParam(value []byte, key string) []byte {
for len(value) > 0 {
semi := bytes.IndexByte(value, ';')
if semi < 0 {
return nil // No parameters left.
}
value = trimOWS(value[semi+1:])
eq := bytes.IndexByte(value, '=')
if eq < 0 {
return nil
}
gotKey := trimOWS(value[:eq])
value = value[eq+1:]
param := value
if len(param) > 0 && param[0] == '"' {
end := bytes.IndexByte(param[1:], '"')
if end < 0 {
return nil // Unterminated quoted string.
}
param, value = param[1:end+1], param[end+2:]
} else {
end := bytes.IndexByte(param, ';')
if end >= 0 {
param, value = param[:end], param[end:]
} else {
value = nil
}
param = trimOWS(param)
}
if equalFold(gotKey, key) {
return param
}
}
return nil
}
// NextPartHeader splits the leading part's header block off a multipart body,
// returning it with its final CRLF and the body bytes that follow it. Returns
// [ErrNeedMoreData] while data holds no complete delimiter and header block, and
// [ErrEndOfParts] once the closing delimiter is reached.
func NextPartHeader(data, boundary []byte) (partHdr, rest []byte, err error) {
if len(boundary) == 0 {
return nil, nil, errNoBoundary
}
idx := indexDelimiter(data, boundary)
if idx < 0 {
return nil, nil, ErrNeedMoreData
}
after := idx + len("--") + len(boundary)
if after+2 > len(data) {
return nil, nil, ErrNeedMoreData // Cannot tell a closing delimiter yet.
} else if data[after] == '-' && data[after+1] == '-' {
return nil, nil, ErrEndOfParts
}
// Delimiter is followed by CRLF, then the part's header block.
if data[after] == '\r' {
after++
}
if after >= len(data) {
return nil, nil, ErrNeedMoreData
} else if data[after] != '\n' {
return nil, nil, errInvalidName // Junk between delimiter and part.
}
after++
end := bytes.Index(data[after:], []byte("\r\n\r\n"))
if end < 0 {
return nil, nil, ErrNeedMoreData
}
return data[after : after+end+2], data[after+end+4:], nil
}
// NextPartBody returns the part bytes available in data, holding back any tail
// that could be the start of a delimiter. done reports the part ended, in which
// case rest begins the next part's delimiter; otherwise rest is the held back
// tail, which the caller compacts before reading more data into the buffer.
func NextPartBody(data, boundary []byte) (body, rest []byte, done bool) {
idx := indexPartEnd(data, boundary)
if idx >= 0 {
return data[:idx], data[idx+len("\r\n"):], true
}
// Longest prefix of "\r\n--"+boundary that could still be completed.
hold := len("\r\n--") + len(boundary) - 1
if hold > len(data) {
hold = len(data)
}
return data[:len(data)-hold], data[len(data)-hold:], false
}
// PartName returns the name parameter of a part's Content-Disposition field,
// i.e: "photo" for `form-data; name="photo"; filename="beach.png"`.
func PartName(partHdr []byte) []byte { return ContentParam(partField(partHdr), "name") }
// PartFileName returns the filename parameter of a part's Content-Disposition
// field, nil when the part is not a file upload.
func PartFileName(partHdr []byte) []byte { return ContentParam(partField(partHdr), "filename") }
// partField returns the Content-Disposition field value of a part header block.
func partField(partHdr []byte) []byte {
const key = "content-disposition"
for len(partHdr) > 0 {
eol := bytes.IndexByte(partHdr, '\n')
line := partHdr
if eol >= 0 {
line, partHdr = partHdr[:eol], partHdr[eol+1:]
} else {
partHdr = nil
}
colon := bytes.IndexByte(line, ':')
if colon > 0 && equalFold(line[:colon], key) {
return line[colon+1:]
}
}
return nil
}
// indexDelimiter returns the offset of the leading "--"+boundary in data.
func indexDelimiter(data, boundary []byte) int {
for i := 0; i+len("--")+len(boundary) <= len(data); i++ {
dash := bytes.IndexByte(data[i:], '-')
if dash < 0 {
return -1
}
i += dash
if i+len("--")+len(boundary) > len(data) {
return -1
}
if data[i+1] == '-' && b2s(data[i+2:i+2+len(boundary)]) == b2s(boundary) {
return i
}
}
return -1
}
// indexPartEnd returns the offset of the CRLF that closes a part, that is the
// CRLF preceding the next delimiter.
func indexPartEnd(data, boundary []byte) int {
for i := 0; i+len("\r\n--")+len(boundary) <= len(data); i++ {
cr := bytes.IndexByte(data[i:], '\r')
if cr < 0 {
return -1
}
i += cr
if i+len("\r\n--")+len(boundary) > len(data) {
return -1
}
if data[i+1] == '\n' && data[i+2] == '-' && data[i+3] == '-' &&
b2s(data[i+4:i+4+len(boundary)]) == b2s(boundary) {
return i
}
}
return -1
}
// trimOWS trims optional whitespace off both ends of b, RFC 9110 5.6.3.
func trimOWS(b []byte) []byte {
for len(b) > 0 && (b[0] == ' ' || b[0] == '\t') {
b = b[1:]
}
for len(b) > 0 && (b[len(b)-1] == ' ' || b[len(b)-1] == '\t') {
b = b[:len(b)-1]
}
return b
}
// equalFold compares b to the ASCII lowercase key, case insensitively.
func equalFold(b []byte, key string) bool {
if len(b) != len(key) {
return false
}
const asciiCapDiff = 'a' - 'A'
for i := range b {
c := b[i]
if c >= 'A' && c <= 'Z' {
c += asciiCapDiff
}
if c != key[i] {
return false
}
}
return true
}
+215
View File
@@ -0,0 +1,215 @@
package httpraw
import (
"strconv"
"strings"
"testing"
)
// A two part body: a text field and a PNG upload whose bytes contain CRLFs and
// even the boundary text, which must not desync the parser.
const (
multiBoundary = "----abc123"
multiBody = "------abc123\r\n" +
"Content-Disposition: form-data; name=\"caption\"\r\n" +
"\r\n" +
"hi there\r\n" +
"------abc123\r\n" +
"Content-Disposition: form-data; name=\"photo\"; filename=\"beach.png\"\r\n" +
"Content-Type: image/png\r\n" +
"\r\n" +
"\x89PNG\r\n--not-the-boundary\r\n\x00\xff\r\n" +
"------abc123--\r\n"
)
func TestMultipartBoundary(t *testing.T) {
for _, test := range []struct {
contentType string
want string
}{
{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: ""},
} {
got := MultipartBoundary([]byte(test.contentType))
if string(got) != test.want {
t.Errorf("%q: want %q, got %q", test.contentType, test.want, got)
}
}
}
func TestContentParam(t *testing.T) {
for _, test := range []struct {
value string
key string
want string
}{
{value: "text/plain; charset=utf-8", key: "charset", want: "utf-8"},
{value: "text/plain;charset=utf-8", key: "charset", want: "utf-8"}, // No space.
{value: "text/plain; charset=\"utf-8\"", key: "charset", want: "utf-8"},
{value: "form-data; name=\"photo\"; filename=\"a;b.png\"", key: "filename", want: "a;b.png"},
{value: "form-data; name=\"photo\"", key: "nope", want: ""},
{value: "form-data; names=x; name=y", key: "name", want: "y"}, // Prefix must not match.
{value: "text/plain", key: "charset", want: ""},
} {
got := ContentParam([]byte(test.value), test.key)
if string(got) != test.want {
t.Errorf("%q key %q: want %q, got %q", test.value, test.key, test.want, got)
}
}
}
func TestNextPartHeader(t *testing.T) {
boundary := []byte(multiBoundary)
hdr, rest, err := NextPartHeader([]byte(multiBody), boundary)
if err != nil {
t.Fatal(err)
}
const wantHdr = "Content-Disposition: form-data; name=\"caption\"\r\n"
if string(hdr) != wantHdr {
t.Errorf("want header %q, got %q", wantHdr, hdr)
}
if !strings.HasPrefix(string(rest), "hi there\r\n") {
t.Errorf("want rest at part body, got %q", rest)
}
}
// Incomplete data must ask for more, never guess.
func TestNextPartHeaderNeedMore(t *testing.T) {
boundary := []byte(multiBoundary)
for _, data := range []string{
"",
"------abc", // Delimiter cut short.
"------abc123\r\n", // No header block yet.
"------abc123\r\nContent-Disposition: form-", // Header block unterminated.
} {
if _, _, err := NextPartHeader([]byte(data), boundary); err != ErrNeedMoreData {
t.Errorf("%q: want ErrNeedMoreData, got %v", data, err)
}
}
}
// The closing delimiter ends iteration.
func TestNextPartHeaderEnd(t *testing.T) {
boundary := []byte(multiBoundary)
if _, _, err := NextPartHeader([]byte("------abc123--\r\n"), boundary); err != ErrEndOfParts {
t.Errorf("want ErrEndOfParts, got %v", err)
}
}
func TestNextPartBody(t *testing.T) {
boundary := []byte(multiBoundary)
_, rest, err := NextPartHeader([]byte(multiBody), boundary)
if err != nil {
t.Fatal(err)
}
body, rest, done := NextPartBody(rest, boundary)
if !done {
t.Fatal("want the part to end within the buffer")
}
if string(body) != "hi there" {
t.Errorf("want body %q, got %q", "hi there", body)
}
if !strings.HasPrefix(string(rest), "------abc123\r\n") {
t.Errorf("want rest at next delimiter, got %q", rest)
}
}
// A part whose bytes contain CRLFs and boundary-like text must survive intact.
func TestNextPartBodyBinary(t *testing.T) {
boundary := []byte(multiBoundary)
data := []byte(multiBody)
_, rest, err := NextPartHeader(data, boundary) // caption part.
if err != nil {
t.Fatal(err)
}
_, rest, _ = NextPartBody(rest, boundary)
_, rest, err = NextPartHeader(rest, boundary) // photo part.
if err != nil {
t.Fatal(err)
}
body, rest, done := NextPartBody(rest, boundary)
if !done {
t.Fatal("want the part to end within the buffer")
}
const want = "\x89PNG\r\n--not-the-boundary\r\n\x00\xff"
if string(body) != want {
t.Errorf("want body %q, got %q", want, body)
}
if _, _, err = NextPartHeader(rest, boundary); err != ErrEndOfParts {
t.Errorf("want ErrEndOfParts after last part, got %v", err)
}
}
// A delimiter split across two reads must not be mistaken for part data: the
// tail is held back until proven not to be a delimiter.
func TestNextPartBodySplitDelimiter(t *testing.T) {
boundary := []byte(multiBoundary)
const part = "hi there"
full := part + "\r\n------abc123\r\n"
for split := 1; split < len(full); split++ {
body, rest, done := NextPartBody([]byte(full[:split]), boundary)
if done {
continue // Whole delimiter already present, nothing to prove.
}
if len(body) > len(part) {
t.Fatalf("split %d: emitted %q, past the end of the part", split, body)
}
if string(body)+string(rest) != full[:split] {
t.Fatalf("split %d: body+rest %q%q does not reconstruct input", split, body, rest)
}
}
}
func TestPartNameFileName(t *testing.T) {
const photo = "Content-Disposition: form-data; name=\"photo\"; filename=\"beach.png\"\r\n" +
"Content-Type: image/png\r\n"
if got := string(PartName([]byte(photo))); got != "photo" {
t.Errorf("want name %q, got %q", "photo", got)
}
if got := string(PartFileName([]byte(photo))); got != "beach.png" {
t.Errorf("want filename %q, got %q", "beach.png", got)
}
const caption = "Content-Disposition: form-data; name=\"caption\"\r\n"
if got := string(PartName([]byte(caption))); got != "caption" {
t.Errorf("want name %q, got %q", "caption", got)
}
if got := PartFileName([]byte(caption)); got != nil {
t.Errorf("want nil filename for a non file part, got %q", got)
}
}
// The whole loop, as a caller writes it.
func TestMultipartLoop(t *testing.T) {
boundary := []byte(multiBoundary)
rest := []byte(multiBody)
var got []string
for {
hdr, next, err := NextPartHeader(rest, boundary)
if err == ErrEndOfParts {
break
} else if err != nil {
t.Fatal(err)
}
name := string(PartName(hdr))
total := 0
rest = next
for {
body, next, done := NextPartBody(rest, boundary)
total += len(body)
rest = next
if done {
break
}
t.Fatal("part must complete within the buffer")
}
got = append(got, name+":"+strconv.Itoa(total))
}
want := "caption:8|photo:29"
if strings.Join(got, "|") != want {
t.Errorf("want %q, got %q", want, strings.Join(got, "|"))
}
}