diff --git a/http/httphi/bench_test.go b/http/httphi/bench_test.go index dc67e1f..99fb33c 100644 --- a/http/httphi/bench_test.go +++ b/http/httphi/bench_test.go @@ -69,7 +69,7 @@ func BenchmarkHandle(b *testing.B) { handler: func(ex *Exchange) { ex.StageHeader("Content-Type", "text/plain") ex.StageHeaderInt("Content-Length", int64(len(benchBody)), 10) - data, present := ex.AppendQuery(buf[:0], "abc", true) + data, present := ex.RequestQueryAppend(buf[:0], "abc", true) if !present || !internal.BytesEqual(data, expect) { panic("invalid result") } diff --git a/http/httphi/example_test.go b/http/httphi/example_test.go index 07b6bf8..9af835d 100644 --- a/http/httphi/example_test.go +++ b/http/httphi/example_test.go @@ -62,15 +62,19 @@ func ExampleMuxSlice_query_forms_multipart() { mux.Handle("/users/{id}", func(ex *httphi.Exchange) { userID := ex.PathValue("id") - fmt.Printf("someone requested data for user %s", userID) + fmt.Printf("someone requested data for user %s\n", userID) }) mux.Handle("/query", func(ex *httphi.Exchange) { // query parameter in URL. const decodeQuery = true const queryKey = "search" - queryValue, present := ex.AppendQuery(nil, queryKey, decodeQuery) - fmt.Printf("got query=%v %s=%s", present, queryKey, queryValue) + valueRaw, present := ex.RequestQueryValue(queryKey) + if !present { + return + } + valueDecoded, present := ex.RequestQueryAppend(nil, queryKey, decodeQuery) + fmt.Printf("got query=%v %s=%s (raw:%s)\n", present, queryKey, valueDecoded, valueRaw) }) mux.Handle("GET /form", func(ex *httphi.Exchange) { diff --git a/http/httphi/exchange.go b/http/httphi/exchange.go index e1148dd..30f506a 100644 --- a/http/httphi/exchange.go +++ b/http/httphi/exchange.go @@ -49,11 +49,24 @@ type Exchange struct { readErr error } +// ExchangeConfig is the memory an [Exchange] is fixed to for the rest of its +// life by [Exchange.Configure]. A [Router] derives one per exchange from its +// [RouterConfig], which is what bounds the router's memory. type ExchangeConfig struct { - RawBuf []byte - RequestBufferLim int - NumHeaderKVCap int + // RawBuf is the single buffer holding the request header, the response + // header and any surplus body. See [Exchange.UnsafeRawBuffer]. + RawBuf []byte + // RequestBufferLim reserves the first bytes of RawBuf for the request + // header, the rest being the response. Configure panics if it exceeds RawBuf. + RequestBufferLim int + // NumHeaderKVCap is how many request header fields may be parsed. A request + // carrying more is answered 431, see [httpraw.ErrHeaderTooMany]. + NumHeaderKVCap int + // NormalizeOutgoingKeys normalizes staged response header keys as they are + // written, i.e: "content-type" becomes "Content-Type". NormalizeOutgoingKeys bool + // NoRequestBufferGrowth holds the request header to RequestBufferLim rather + // than growing it. A header outgrowing it is answered 431, see [httpraw.ErrBufferExhausted]. NoRequestBufferGrowth bool // MaxPathValues is how many wildcards a single pattern may bind, read back with // [Exchange.PathValue]. A pattern with more never matches, see [SetPathValues]. @@ -443,7 +456,7 @@ func (exch *Exchange) RequestContentLength() (_ int64, present bool, _ error) { // 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 +// [Exchange.RequestQuery] or [Exchange.RequestQueryAppend]. 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 @@ -615,47 +628,51 @@ func (exch *Exchange) RequestQuery() []byte { return exch.RequestHeaderRaw().RequestQuery() } -// AppendQuery appends the value of the first query parameter matching key to -// dst and reports whether the parameter was present. A parameter with no value -// ("?debug") and one with an empty value ("?debug=") are both present with -// nothing appended. +// RequestQueryValue returns an undecoded view of the first query parameter +// matching key and reports whether it was present. Keys are matched decoded, so +// key "a b" finds "a%20b" and "a+b"; a parameter whose key is a malformed +// escape is skipped. A parameter with no value ("?debug") and one with an empty +// value ("?debug=") are both present with a zero length view. // -// Keys are matched decoded, so key "a b" finds "a%20b" and "a+b". Values are -// appended raw unless decoded is set, in which case percent escapes and '+' are -// decoded. A parameter whose value fails to decode is reported absent, and a -// parameter whose key fails to decode is skipped. -// -// dst doubles as scratch space for decoding candidate keys, so AppendQuery only -// allocates when dst lacks the capacity to hold the longest key it inspects. -func (exch *Exchange) AppendQuery(dst []byte, key string, decoded bool) (valueAppended []byte, present bool) { +// The view aliases the request buffer, so copy it to outlive the handler or use +// [Exchange.RequestQueryAppend] to decode it out. +func (exch *Exchange) RequestQueryValue(key string) (rawValue []byte, present bool) { const plusAsSpace = true // Query strings are form encoded, unlike paths. - base := len(dst) rawkey, rawval, rest := httpraw.NextQueryPair(exch.RequestQuery()) for ; rawkey != nil; rawkey, rawval, rest = httpraw.NextQueryPair(rest) { - if b2s(rawkey) != key { - // Key may be encoded: decode it over dst's free space and compare. - // A decoded key cannot appear raw, so this cannot alias a real key. - dst = slices.Grow(dst, len(rawkey)) - scratch := dst[base : base+len(rawkey)] - n, err := httpraw.CopyDecodedPercentURL(scratch, rawkey, plusAsSpace) - if err != nil || b2s(scratch[:n]) != key { - continue // Malformed or different key, keep looking. - } + // Compare raw first: a key needing no decoding is the common case, and + // the decoding compare walks the key an escape at a time. + if b2s(rawkey) == key || httpraw.EqualDecodedPercentURL(rawkey, key, plusAsSpace) { + return rawval, true } - if len(rawval) == 0 { - return dst[:base], true // Flag or empty value, nothing to append. - } - dst = slices.Grow(dst, len(rawval)) - if !decoded { - return append(dst[:base], rawval...), true - } - n, err := httpraw.CopyDecodedPercentURL(dst[base:base+len(rawval)], rawval, plusAsSpace) - if err != nil { - return dst[:base], false // Do not hand back half a decode. - } - return dst[:base+n], true } - return dst[:base], false + return nil, false +} + +// RequestQueryAppend appends the value of the first query parameter matching key to +// dst and reports whether the parameter was present, matching keys as +// [Exchange.RequestQueryValue] does. A parameter with no value ("?debug") and +// one with an empty value ("?debug=") are both present with nothing appended. +// +// Values are appended raw unless decoded is set, in which case percent escapes +// and '+' are decoded. A parameter whose value fails to decode is reported +// absent, dst being left as it was rather than holding half a decode. +func (exch *Exchange) RequestQueryAppend(dst []byte, key string, decoded bool) (valueAppended []byte, present bool) { + const plusAsSpace = true // Query strings are form encoded, unlike paths. + rawval, present := exch.RequestQueryValue(key) + if !present || len(rawval) == 0 { + return dst, present + } + if !decoded { + return append(dst, rawval...), true + } + base := len(dst) + dst = slices.Grow(dst, len(rawval)) + n, err := httpraw.CopyDecodedPercentURL(dst[base:base+len(rawval)], rawval, plusAsSpace) + if err != nil { + return dst[:base], false // Do not hand back half a decode. + } + return dst[:base+n], true } // RequestMethod returns the request line's method, i.e: "GET". See diff --git a/http/httphi/exchange_test.go b/http/httphi/exchange_test.go index 531f6b2..45e3707 100644 --- a/http/httphi/exchange_test.go +++ b/http/httphi/exchange_test.go @@ -671,7 +671,7 @@ func TestExchangeAppendQuery(t *testing.T) { var present bool sm.Handle("/x", func(ex *Exchange) { var value []byte - value, present = ex.AppendQuery(nil, test.key, test.decoded) + value, present = ex.RequestQueryAppend(nil, test.key, test.decoded) got = string(value) }) serve(t, "GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n", &sm) @@ -696,7 +696,7 @@ func TestExchangeAppendQueryReusesBuffer(t *testing.T) { sm.Handle("/x", func(ex *Exchange) { var value []byte allocs = testing.AllocsPerRun(50, func() { - value, _ = ex.AppendQuery(dst[:len("prefix:")], "q", true) + value, _ = ex.RequestQueryAppend(dst[:len("prefix:")], "q", true) }) got = string(value) // Conversion allocates, keep it out of the measurement. }) diff --git a/http/httphi/httphi_fuzz_test.go b/http/httphi/httphi_fuzz_test.go index 71590d0..209f452 100644 --- a/http/httphi/httphi_fuzz_test.go +++ b/http/httphi/httphi_fuzz_test.go @@ -242,7 +242,7 @@ func FuzzQueryAndForm(f *testing.F) { if n > len(key) { t.Fatalf("decoding key %q grew it to %d bytes", key, n) } - if _, present := exch.AppendQuery(nil, string(dec[:n]), decoded); !present { + if _, present := exch.RequestQueryAppend(nil, string(dec[:n]), decoded); !present { t.Fatalf("query pair %q absent from AppendQuery", key) } } diff --git a/http/httpraw/cookie.go b/http/httpraw/cookie.go index 21d2075..0606c00 100644 --- a/http/httpraw/cookie.go +++ b/http/httpraw/cookie.go @@ -10,6 +10,12 @@ type Cookie struct { kv KVBuffer } +// EnableBufferGrowth allows the cookie's buffer to grow past what [Cookie.Reset] was +// handed. See [KVBuffer.EnableBufferGrowth]. +func (c *Cookie) EnableBufferGrowth(enableBufferGrowth bool) { + c.kv.EnableBufferGrowth(enableBufferGrowth) +} + // Reset functions very similarly to [Header.Reset]. Can be used for in-place cookie parsing. func (c *Cookie) Reset(buf []byte, capKV int) { c.kv.Reset(buf, capKV) } diff --git a/http/httpraw/form.go b/http/httpraw/form.go index 35d39a4..5ba7c43 100644 --- a/http/httpraw/form.go +++ b/http/httpraw/form.go @@ -11,6 +11,8 @@ type Form struct { kv KVBuffer } +// EnableBufferGrowth allows the form's buffer to grow past what [Form.Reset] was +// handed. See [KVBuffer.EnableBufferGrowth]. func (f *Form) EnableBufferGrowth(enableGrowth bool) { f.kv.EnableBufferGrowth(enableGrowth) } // Reset discards parsed pairs and sets the buffer to parse in place. diff --git a/http/httpraw/header.go b/http/httpraw/header.go index c145d52..e5d5364 100644 --- a/http/httpraw/header.go +++ b/http/httpraw/header.go @@ -634,6 +634,42 @@ func CopyDecodedPercentURL(dst, value []byte, plusAsSpace bool) (n int, err erro } } +// EqualDecodedPercentURL reports whether value, once decoded, equals want. It +// decodes as it compares so it needs no scratch buffer, and reports false on a +// malformed escape just as [CopyDecodedPercentURL] errors on one. +// plusAsSpace decodes '+' to ' ', correct for query and form-encoded data but +// NOT for path segments. +func EqualDecodedPercentURL(value []byte, want string, plusAsSpace bool) bool { + w := 0 + for i := 0; i < len(value); { + var c byte + switch { + case value[i] == '%': + if i+2 >= len(value) { + return false // Truncated escape at end of value. + } + hi, okhi := unhexdigit(value[i+1]) + lo, oklo := unhexdigit(value[i+2]) + if !okhi || !oklo { + return false + } + c = hi<<4 | lo + i += 3 + case plusAsSpace && value[i] == '+': + c = ' ' + i++ + default: + c = value[i] + i++ + } + if w >= len(want) || want[w] != c { + return false + } + w++ + } + return w == len(want) +} + // copyPlusDecoded copies src to dst replacing '+' with ' ' if plusAsSpace set. func copyPlusDecoded(dst, src []byte, plusAsSpace bool) int { n := copy(dst, src) diff --git a/http/httpraw/header_test.go b/http/httpraw/header_test.go index 8825a98..449f494 100644 --- a/http/httpraw/header_test.go +++ b/http/httpraw/header_test.go @@ -653,3 +653,51 @@ func TestHeader_FieldTableFullIsReported(t *testing.T) { t.Fatalf("want ErrHeaderFieldsTooLarge, got %v", err) } } + +// EqualDecodedPercentURL must agree with CopyDecodedPercentURL on every input: +// same decoded bytes, and false wherever the copying decoder reports an error. +func TestEqualDecodedPercentURL(t *testing.T) { + for _, value := range []string{ + "", "plain", "a+b", "a%20b", "%41%42", "100%25", "a%2Fb", "+", "%2b", + "trailing%", "trailing%4", "%zz", "a%2", "%%", "a+b%20c", "%00", + } { + for _, plusAsSpace := range []bool{false, true} { + dst := make([]byte, len(value)) + n, err := CopyDecodedPercentURL(dst, []byte(value), plusAsSpace) + // The copying decoder is the reference: whatever it produces is what + // an equal comparison must accept, and only that. + want := "" + if err == nil { + want = string(dst[:n]) + } + got := EqualDecodedPercentURL([]byte(value), want, plusAsSpace) + if err != nil { + if got { + t.Errorf("%q plus=%v: malformed escape must not compare equal", value, plusAsSpace) + } + continue + } + if !got { + t.Errorf("%q plus=%v: want equal to its own decoding %q", value, plusAsSpace, want) + } + if EqualDecodedPercentURL([]byte(value), want+"x", plusAsSpace) { + t.Errorf("%q plus=%v: must not equal a longer want", value, plusAsSpace) + } + if want != "" && EqualDecodedPercentURL([]byte(value), want[:len(want)-1], plusAsSpace) { + t.Errorf("%q plus=%v: must not equal a shorter want", value, plusAsSpace) + } + } + } +} + +// The comparison must not allocate: it is the reason a query lookup can return +// a view without scratch space. +func TestEqualDecodedPercentURLNoAlloc(t *testing.T) { + value := []byte("a%20long%2Dish+key") + allocs := testing.AllocsPerRun(100, func() { + EqualDecodedPercentURL(value, "a long-ish key", true) + }) + if allocs != 0 { + t.Fatalf("EqualDecodedPercentURL allocated %v times, want 0", allocs) + } +} diff --git a/http/httpraw/kvbuffer.go b/http/httpraw/kvbuffer.go index 2986d9e..0a35ea4 100644 --- a/http/httpraw/kvbuffer.go +++ b/http/httpraw/kvbuffer.go @@ -8,7 +8,7 @@ import ( "github.com/soypat/lneto/internal" ) -// KVBuffer is a common key-value store engine for Cookie, Form, and other HTTP abstractions that need +// KVBuffer is a common key-value store engine for Cookie, Form, Header and other HTTP abstractions that need // a key-value store with underlying buffer memory. type KVBuffer struct { buf []byte @@ -18,8 +18,12 @@ type KVBuffer struct { func (mb *KVBuffer) free() int { return cap(mb.buf) - len(mb.buf) } +// BufferRaw returns the underlying buffer, its length being the portion in use. +// Stored pairs alias it, so writing to it mangles them. func (mb *KVBuffer) BufferRaw() []byte { return mb.buf } +// EnableBufferGrowth allows the buffer to grow past the memory [KVBuffer.Reset] +// was handed. The setting outlives Reset; with growth off callers get [ErrBufferExhausted]. func (mb *KVBuffer) EnableBufferGrowth(enableGrowth bool) { if enableGrowth { mb.flags &^= flagNoBufferGrow @@ -30,8 +34,11 @@ func (mb *KVBuffer) EnableBufferGrowth(enableGrowth bool) { func (mb *KVBuffer) discardKVs() { mb.kvs = mb.kvs[:0] } +// BufferGrowthEnabled reports whether the buffer may grow, see [KVBuffer.EnableBufferGrowth]. func (mb *KVBuffer) BufferGrowthEnabled() bool { return !mb.flags.HasAny(flagNoBufferGrow) } +// ReadFromBytes appends buf to the underlying buffer, accumulating data to parse. +// Returns [ErrBufferExhausted] when buf does not fit and growth is disabled. func (mb *KVBuffer) ReadFromBytes(buf []byte) error { if len(buf) == 0 { return io.ErrNoProgress // Nothing handed over, not a buffer problem. @@ -48,6 +55,8 @@ func (mb *KVBuffer) ReadFromBytes(buf []byte) error { return nil } +// ReadLimited appends at most limit bytes read from r to the underlying buffer. +// A read returning data alongside [io.EOF] reports a nil error, later ones io.EOF. func (mb *KVBuffer) ReadLimited(r io.Reader, limit int) (int, error) { free := mb.free() growthEnabled := mb.BufferGrowthEnabled() @@ -72,6 +81,8 @@ func (mb *KVBuffer) ReadLimited(r io.Reader, limit int) (int, error) { return n, err } +// Reset discards all pairs and takes buf as the buffer to parse in place, nil +// reusing the current one. kvCap sizes the pair table. Only the growth setting survives. func (mb *KVBuffer) Reset(buf []byte, kvCap int) { if buf == nil { mb.buf = mb.buf[:0] @@ -82,6 +93,8 @@ func (mb *KVBuffer) Reset(buf []byte, kvCap int) { mb.flags = mb.flags & flagNoBufferGrow // Only flag persisted is buffer grow config. } +// CopyFrom replaces the receiver's contents with a copy of src, sharing no +// memory with it afterwards. func (mb *KVBuffer) CopyFrom(src *KVBuffer) { mb.buf = append(mb.buf[:0], src.buf...) mb.kvs = append(mb.kvs[:0], src.kvs...) @@ -124,6 +137,9 @@ func (mb *KVBuffer) HasKeyValue(key, value string) bool { } return false } + +// Add appends a pair, keeping any already sharing the key: use [KVBuffer.Set] +// to replace instead. Reports false if the buffer could not hold it. func (mb *KVBuffer) Add(key, value string) (enoughSpace bool) { mb.appendPair(key, value) return mb.getIdx(key) >= 0 @@ -215,7 +231,11 @@ func (mb *KVBuffer) setInternal(key, value []byte) (enoughSpace bool) { return true } +// Len returns the number of slots stored, counting those [KVBuffer.Set] invalidated. func (mb *KVBuffer) Len() int { return len(mb.kvs) } + +// At returns the i'th pair in wire order. value is nil for a pair holding none, +// which is what tells a form's "ok" from "ok=". func (mb *KVBuffer) At(i int) (key, value []byte) { kv := mb.kvs[i] if !kv.HasValue() { @@ -233,7 +253,10 @@ func (mb *KVBuffer) setAt(i int, k, v []byte) { } } +// AtKey is [KVBuffer.At] limited to the i'th key. func (mb *KVBuffer) AtKey(i int) (key []byte) { return mb.musttoken(mb.kvs[i].key) } + +// AtValue is [KVBuffer.At] limited to the i'th value, nil when the pair holds none. func (mb *KVBuffer) AtValue(i int) (key []byte) { if !mb.kvs[i].HasValue() { return nil diff --git a/x/rawsock/rawsock_linux_test.go b/x/rawsock/rawsock_linux_test.go deleted file mode 100644 index fe56cb8..0000000 --- a/x/rawsock/rawsock_linux_test.go +++ /dev/null @@ -1,64 +0,0 @@ -//go:build !tinygo && linux - -package rawsock - -import ( - "net" - "runtime" - "testing" -) - -// TestAcceptConnDoesNotAllocate pins down what AcceptConn exists for. A server -// that owns its connection storage still pays an allocation per connection if -// accepting one allocates, which is what syscall.Accept does with the peer -// address it returns. -func TestAcceptConnDoesNotAllocate(t *testing.T) { - var ln Listener - err := ln.Listen(0) - if err != nil { - t.Fatal(err) - } - defer ln.Close() - addr := ln.Addr().String() - - const n = 32 - dialed := make([]net.Conn, 0, n) - defer func() { - for _, c := range dialed { - c.Close() - } - }() - for range n { - c, err := net.Dial("tcp", addr) - if err != nil { - t.Fatal(err) - } - dialed = append(dialed, c) - } - - var conn Conn - // The first accept warms whatever the runtime wants to warm, and is where - // the peer address is checked: reading it hands a value to a net.Addr - // interface, which allocates whatever the accept did. - if err = ln.AcceptConn(&conn); err != nil { - t.Fatal(err) - } - if conn.RemoteAddr().String() != dialed[0].LocalAddr().String() { - t.Errorf("accepted peer %s, dialer says %s", conn.RemoteAddr(), dialed[0].LocalAddr()) - } - conn.Close() - - var before, after runtime.MemStats - runtime.GC() - runtime.ReadMemStats(&before) - for i := 1; i < n; i++ { - if err = ln.AcceptConn(&conn); err != nil { - t.Fatal(err) - } - conn.Close() - } - runtime.ReadMemStats(&after) - if allocs := after.Mallocs - before.Mallocs; allocs != 0 { - t.Errorf("AcceptConn allocated %d times over %d accepts, want 0", allocs, n-1) - } -}