Files
lneto/http/httpraw/multipart.go
T
Pat Whittingslow 5c54030f19 add http/httphi (#171)
* add http/httphi

* begin adding httphi tests

* claude found neat bugs

* add low level Handle function and more tests

* more tests, run go generate

* add Hijacker-like functionality

* improve locking and acquisition of Exchanges in reconfiguring

* several bugfixes, add internal.IntLen, round up http-linux example with new router API

* small nit

* add benchmarks

* add query handling

* remove ForEach pattern, allocates in TinyGo

* massive documentation push and code reordering in files

* Router.Handle returns error after being torn down

* run go fix

* rework Mux interface to receive a string request path

* add MethodFrom

* minor doc nit

* fail on incomplete staging

* add raw buffer access

* add streaming API distinct from Exchange

* begin adding multipart form logic

* finish rounding up multipart form parsing

* remove status type

* first Multipart approach

* begin adding readMultiPart

* add Exchange.ReadMultiparts reimagining of clanker slop

* ai insists with backoffs

* simplify clanker slop

* apply go fix

* add a pattern argument to Mux

* explicit header key/value alloc and add ExchangeConfig

* fix tests after excplicit header alloc change

* fix examples

* run go fix

* expose rawsock as experimental package (will use for external benchmarks)

* remove backoff from form parsing

* @MDr164 suggestions get potential fixes

* apply go fix

* add examples

* add README.md

* fix rawsock tinygo implementation

* apply @MDr164 various fixes

* update documentation on ContentLength methods and fix bug in Form reset on empty body

* fix tests

* add fuzz tests

* run go fix

* io.ErrNoProgress on parsing form spin

* run go fix

* remove backoff assumption from Router

* httphi.Handle rejects unsupported protocols

* go format router.go

* add kvbuffer

* rewrite Cookie with KVBuffer

* mid refactor of KVBuffer into Header

* work on KVBuffer exhausted semantics

* add Go's ServeMux Request.PathValue access semantics to Exchange, Mux and MuxSlice

* add PathValue example

* document all the things; improve req Query semantics; add Form.EnableBufferGrowth

* unexport kvBuffer

* add Exchange.PathValueAppend

* use stdlib in example instead of rawsock

* remove rawsock from http example

* add darwin arch rawsock

* fix example

* rename Router.TeardownGoroutines to Shutdown matching http.Server.Shutdown

* rename types and identifiers

* @MDr164 Content-Type and Transfer-Encoding bug catches
2026-07-29 20:20:21 -03:00

273 lines
8.5 KiB
Go

package httpraw
import (
"bytes"
"io"
)
// Multipart splits a "multipart/form-data" body into its parts. Such 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.
//
// Multipart stores none of the body, leaving the caller to decide what to keep,
// what to skip and when a part has grown too large. Both methods report how much
// of buf they consumed, which the caller compacts away before reading more in:
//
// var m httpraw.Multipart
// m.SetContentType(contentType)
// var hdr httpraw.MultipartHeader
// for {
// parsed, err := m.NextHeader(&hdr, buf[:buflen])
// if err != nil {
// break // io.EOF at the closing delimiter, body done.
// } else if parsed == 0 {
// // Header block incomplete: read more into buf[buflen:] and retry.
// continue
// }
// buflen = copy(buf, buf[parsed:buflen])
// for {
// bodyLen, restOff, done := m.NextBody(buf[:buflen])
// // Consume buf[:bodyLen] for hdr.Name, then compact and read more.
// buflen = copy(buf, buf[restOff:buflen])
// if done {
// break
// }
// }
// }
type Multipart struct {
// Boundary is the delimiter parameter of the body's Content-Type field,
// without the leading "--" the delimiter carries on the wire.
Boundary []byte
}
// MultipartHeader is a part's header block and the Content-Disposition
// parameters that identify it.
type MultipartHeader struct {
// PartView is the part's raw header block, ending in its final CRLF. It
// aliases the buffer it was parsed from, so it is only valid until that
// buffer is compacted or read into again.
PartView []byte
// Name is the name parameter of a part's Content-Disposition field,
// i.e: "photo" for `form-data; name="photo"; filename="beach.png"`.
// Copied out of the buffer, so it outlives it, and reused between parts.
Name []byte
// Filename is the filename parameter of a part's Content-Disposition
// field, empty when the part is not a file upload. Copied like Name.
Filename []byte
}
// Reset clears the header for the next part, keeping the buffers Name and
// Filename were copied into so a reused header stops allocating.
func (hdr *MultipartHeader) Reset() {
hdr.PartView = nil
hdr.Name = hdr.Name[:0]
hdr.Filename = hdr.Filename[:0]
}
// 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 {
return errNoBoundary // RFC 2046 5.1.1: 1..70 characters, required.
}
return nil
}
// NextHeader parses the leading part's header block off a multipart body into
// dst, returning how many bytes of data it consumed: the part's content begins
// at data[parsedLen]. A zero parsedLen and no error means data holds no complete
// delimiter and header block yet, so the caller reads more in and retries.
// Returns [io.EOF] once the closing delimiter is reached. dst is reset on error.
func (m *Multipart) NextHeader(dst *MultipartHeader, data []byte) (parsedLen int, err error) {
dst.Reset()
if len(m.Boundary) == 0 {
return 0, errNoBoundary
}
idx := m.indexDelimiter(data)
if idx < 0 {
return 0, nil
}
after := idx + len("--") + len(m.Boundary)
if after+2 > len(data) {
return 0, nil // Cannot tell a closing delimiter yet.
} else if data[after] == '-' && data[after+1] == '-' {
return 0, io.EOF
}
// Delimiter is followed by CRLF, then the part's header block.
if data[after] == '\r' {
after++
}
if after >= len(data) {
return 0, nil
} else if data[after] != '\n' {
return 0, errBadDelimiter
}
after++
end := bytes.Index(data[after:], []byte("\r\n\r\n"))
if end < 0 {
return 0, nil
}
dst.PartView = data[after : after+end+2]
disposition := partField(dst.PartView)
dst.Name = append(dst.Name[:0], ContentParam(disposition, "name")...)
dst.Filename = append(dst.Filename[:0], ContentParam(disposition, "filename")...)
return after + end + 4, nil
}
// NextBody reports how much of data is part content, data[:bodyLen], and where
// what is left begins, data[restOff:], which the caller compacts to the front of
// its buffer before reading more in. done reports the part ended, in which case
// data[restOff:] begins the next part's delimiter; otherwise the bytes past
// bodyLen are a tail held back because it could still turn into a delimiter.
func (m *Multipart) NextBody(data []byte) (bodyLen, restOff int, done bool) {
idx := m.indexPartEnd(data)
if idx >= 0 {
return idx, idx + len("\r\n"), true
}
// Longest prefix of "\r\n--"+boundary that could still be completed.
hold := min(len("\r\n--")+len(m.Boundary)-1, len(data))
return len(data) - hold, len(data) - hold, false
}
// indexDelimiter returns the offset of the leading "--"+Boundary in data.
func (m *Multipart) indexDelimiter(data []byte) int {
for i := 0; i+len("--")+len(m.Boundary) <= len(data); i++ {
dash := bytes.IndexByte(data[i:], '-')
if dash < 0 {
return -1
}
i += dash
if i+len("--")+len(m.Boundary) > len(data) {
return -1
}
if data[i+1] == '-' && b2s(data[i+2:i+2+len(m.Boundary)]) == b2s(m.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 (m *Multipart) indexPartEnd(data []byte) int {
for i := 0; i+len("\r\n--")+len(m.Boundary) <= len(data); i++ {
cr := bytes.IndexByte(data[i:], '\r')
if cr < 0 {
return -1
}
i += cr
if i+len("\r\n--")+len(m.Boundary) > len(data) {
return -1
}
if data[i+1] == '\n' && data[i+2] == '-' && data[i+3] == '-' &&
b2s(data[i+4:i+4+len(m.Boundary)]) == b2s(m.Boundary) {
return i
}
}
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
// 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
}
// 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
}
// 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
}