fix Mux bug not matching paths correctly; httpraw HTTP V1 naming applied

This commit is contained in:
Patricio Whittingslow
2026-07-30 16:40:44 -03:00
parent aa259d1efd
commit 9ff97f1808
18 changed files with 410 additions and 174 deletions
+20 -22
View File
@@ -34,7 +34,7 @@ type Exchange struct {
rawbuf []byte
respHeaderOff uint16
respHeaderLen uint16
reqHdr httpraw.Header
reqHdr httpraw.HeaderV1
pathValues []PathValue
// bodyRW is the reader handed to [httpraw.Form.ReadLimited], kept here so
// boxing it into an io.Reader allocates nothing per request.
@@ -171,13 +171,18 @@ func (exch *Exchange) Release() {
// written to and used without modifying the staged response first line.
//
// Staging headers will write to this buffer so use mindfully.
// To access only the request header buffer portion use [httpraw.Header.BufferRaw] limited
// to [httpraw.Header.BufferParsed] as returned by [Exchange.RequestHeaderRaw].
// To access only the request header buffer portion use [httpraw.HeaderV1.BufferRaw] limited
// to [httpraw.HeaderV1.BufferParsed] as returned by [Exchange.requestHeaderRaw].
// Writing to this section will not change the contents read by [Exchange.ReadBody].
//
// In [Router] context, the size of this buffer is influenced directly by [RouterConfig] HeaderBufferSize fields.
func (exch *Exchange) UnsafeRawBuffer() []byte { return exch.rawbuf }
// RequestHeaderV1Raw returns the parsed request header for access beyond the
// Request* methods, such as [httpraw.HeaderV1.ForEach]. Valid until the exchange
// is released, and writing to it corrupts the response.
func (exch *Exchange) RequestHeaderV1Raw() *httpraw.HeaderV1 { return &exch.reqHdr }
// StageHeader stages a response header field, written on the first
// [Exchange.FlushHeader], [Exchange.WriteHeader] or [Exchange.WriteBody].
// Returns false and drops the field if the response buffer cannot fit it.
@@ -478,13 +483,6 @@ func (exch *Exchange) MuxPattern() string {
return exch.matchedPattern
}
// RequestHeaderRaw returns the parsed request header for access beyond the
// Request* methods, such as [httpraw.Header.ForEach]. Valid until the exchange
// is released, and writing to it corrupts the response.
func (exch *Exchange) RequestHeaderRaw() *httpraw.Header {
return &exch.reqHdr
}
// RequestParseCookie parses the request's key header field into dst, i.e:
// "Cookie". The caller owns dst and its buffer, so it may be reused between
// requests.
@@ -499,14 +497,14 @@ func (exch *Exchange) RequestParseCookie(dst *httpraw.Cookie, key string) error
func (exch *Exchange) RequestContentType() []byte {
// Folded: field names are case insensitive and HTTP/2 mandates lowercase, so
// a proxy translating h2 to h1 sends "content-type", RFC 9110 5.1.
return exch.RequestHeaderRaw().GetFold("Content-Type")
return exch.RequestHeaderV1Raw().GetFold("Content-Type")
}
// RequestContentLength returns the body length declared by the request's
// Content-Length field. An absent field is signalled with present=false and no error.
// See [httpraw.Header.ContentLength].
// See [httpraw.HeaderV1.ContentLength].
func (exch *Exchange) RequestContentLength() (_ int64, present bool, _ error) {
return exch.RequestHeaderRaw().ContentLength()
return exch.RequestHeaderV1Raw().ContentLength()
}
// RequestParseForm parses "application/x-www-form-urlencoded" pairs into dst
@@ -569,7 +567,7 @@ func (exch *Exchange) readBodyForm(dst *httpraw.Form) error {
return nil // No declared encoding is no form, RFC 9110 8.3.
} else if !httpraw.MediaTypeIs(contentType, "application/x-www-form-urlencoded") {
return errNotFormEncoded
} else if exch.RequestHeaderRaw().GetFold("Transfer-Encoding") != nil {
} else if exch.RequestHeaderV1Raw().GetFold("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
@@ -723,26 +721,26 @@ func (exch *Exchange) ReadMultiparts(dst []MultipartSink, buf []byte, newSink fu
// 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 {
header := exch.RequestHeaderRaw()
header := exch.RequestHeaderV1Raw()
return header.Get(key)
}
// RequestTarget returns the request-target (URI) of the request line, i.e:
// "/search?q=go". See [httpraw.Header.RequestTarget].
// "/search?q=go". See [httpraw.HeaderV1.RequestTarget].
func (exch *Exchange) RequestTarget() []byte {
return exch.RequestHeaderRaw().RequestTarget()
return exch.RequestHeaderV1Raw().RequestTarget()
}
// RequestPath returns the request-target (URI) up to the query string. This is
// what the [Mux] matches on, i.e: "/search" for a request to "/search?q=go".
func (exch *Exchange) RequestPath() []byte {
return exch.RequestHeaderRaw().RequestPath()
return exch.RequestHeaderV1Raw().RequestPath()
}
// RequestQuery returns the request's query string as it appears on the wire.
// Iterate it with [httpraw.NextQueryPair]. See [httpraw.Header.RequestQuery].
// Iterate it with [httpraw.NextQueryPair]. See [httpraw.HeaderV1.RequestQuery].
func (exch *Exchange) RequestQuery() []byte {
return exch.RequestHeaderRaw().RequestQuery()
return exch.RequestHeaderV1Raw().RequestQuery()
}
// RequestQueryValue returns an undecoded view of the first query parameter
@@ -835,11 +833,11 @@ func (exch *Exchange) RequestMethod() Method {
// RequestMethod returns the request line's method as a []byte view, i.e: "GET".
func (exch *Exchange) RequestMethodRaw() []byte {
return exch.RequestHeaderRaw().Method()
return exch.RequestHeaderV1Raw().Method()
}
// RequestConnectionClose returns true if the client asked for the connection to
// be closed after this exchange with a "Connection: close" header field.
func (exch *Exchange) RequestConnectionClose() bool {
return exch.RequestHeaderRaw().ConnectionClose()
return exch.RequestHeaderV1Raw().ConnectionClose()
}
+1 -1
View File
@@ -1282,7 +1282,7 @@ func TestHandleBrowserSizedRequest(t *testing.T) {
sm.Reset(1)
sm.Handle("GET /echo", func(exch *Exchange) {
gotMode = string(exch.RequestHeader("X-Mode"))
exch.RequestHeaderRaw().ForEach(func(key, value []byte) bool {
exch.RequestHeaderV1Raw().ForEach(func(key, value []byte) bool {
fields++
return true
})
+2 -2
View File
@@ -64,7 +64,7 @@ const (
// segSizes are the chunk sizes a request may be delivered in, index 0 meaning
// "all at once". Splitting a request mid-CRLF or before a colon is what drives
// [httpraw.Header.TryParse]'s resumption path. Entries may be appended, never
// [httpraw.HeaderV1.TryParse]'s resumption path. Entries may be appended, never
// changed: an existing index must keep splitting exactly as it does today.
var segSizes = [16]int{0, 1, 2, 3, 5, 7, 11, 16, 23, 37, 64, 101, 173, 256, 509, 1024}
@@ -152,7 +152,7 @@ func checkResponse(t *testing.T, written string) {
if !strings.Contains(written, "\r\n\r\n") {
t.Fatalf("header block never terminated: %q", written)
}
var resp httpraw.Header
var resp httpraw.HeaderV1
const asResponse = true
if err := resp.ParseBytes(asResponse, []byte(written)); err != nil {
t.Fatalf("response does not parse back: %s in %q", err, written)
+82 -9
View File
@@ -81,6 +81,18 @@ func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error {
}
func (exch *Exchange) handleError(err error) {
if err == lneto.ErrUnsupported {
// httpraw refused a first line naming a version it does not speak, before
// spending the field loop on it. An empty protocol is a HTTP/0.9
// simple-request, RFC 9112 3: a malformed 1.x request-line rather than a
// version there is any point naming back.
if len(exch.reqHdr.Protocol()) == 0 {
exch.WriteHeader(int(StatusBadRequest))
} else {
exch.WriteHeader(int(StatusHTTPVersionNotSupported))
}
return
}
if err == httpraw.ErrHeaderTooMany || err == httpraw.ErrBufferExhausted || exch.reqHdr.BufferFree() == 0 {
// The peer is owed an answer: no larger buffer is coming, so
// say so instead of dropping the connection, RFC 6585 5.
@@ -221,6 +233,7 @@ type MuxSlice struct {
path string
handler HandlerFunc
pathVals int
spec int
}
}
@@ -230,30 +243,50 @@ func (sm *MuxSlice) Reset(capacity int) {
internal.SliceReuse(&sm._handlers, capacity)
}
// LookupHandler returns the handler registered for request path, or nil if none matches.
// The first registration matching both method and uri wins.
// LookupHandler returns the handler registered for request path, or nil if none
// matches. The most specific matching registration wins, not the first, so the
// catch-all "/" may be registered alongside the endpoints it backs without
// shadowing them, as in http.ServeMux, see [patternSpecificity]. Registrations
// of equal specificity are resolved in registration order.
//
// Every method this package does not name is [MethUnknown], so a request with an
// extension method matches a bare-path registration and any registration naming
// an extension method, whichever it names. Tell PROPFIND from MKCOL inside the
// handler with [Exchange.RequestMethodRaw].
func (sm *MuxSlice) LookupHandler(method Method, path []byte, dstPathVals []PathValue) (matched string, _ HandlerFunc) {
for _, endpoint := range sm._handlers {
best := -1
bestSpec := 0
for i, endpoint := range sm._handlers {
if endpoint.method != MethUndefined && endpoint.method != method {
continue
} else if best >= 0 && endpoint.spec <= bestSpec {
continue // Cannot beat the incumbent, so do not pay to match it.
}
// Method matches. A pattern ending in '/' is a wildcard despite binding no
// values: the trailing slash is an anonymous "{...}", so it must go
// through the matcher and not a literal compare, see [SetPathValues].
var ok bool
if isWildcardPattern(endpoint.path) {
if ok, _ := SetPathValues(dstPathVals, endpoint.path, path); ok {
return endpoint.path, endpoint.handler
}
} else if b2s(path) == endpoint.path {
return endpoint.path, endpoint.handler
// dstPathVals is scratch during the scan: a candidate that matches and
// is then beaten, or one that is beaten and clears on failure, would
// leave the winner's values wrong, so the winner is bound below.
ok, _ = SetPathValues(dstPathVals, endpoint.path, path)
} else {
ok = b2s(path) == endpoint.path
}
if ok {
best, bestSpec = i, endpoint.spec
}
}
return "", nil
if best < 0 {
return "", nil
}
winner := sm._handlers[best]
if isWildcardPattern(winner.path) {
clear(dstPathVals) // The scan may have bound more values than the winner does.
SetPathValues(dstPathVals, winner.path, path)
}
return winner.path, winner.handler
}
// MaxPathValues returns the maximum number of path values any endpoint could have.
@@ -303,11 +336,51 @@ func (sm *MuxSlice) Handle(optMethodAndPath string, handler HandlerFunc) {
}
v := internal.SliceReclaim(&sm._handlers)
v.pathVals = countPathValues(url)
v.spec = patternSpecificity(url)
v.method = method
v.path = url
v.handler = handler
}
// patternSpecificity scores how tightly pattern pins a path, letting
// [MuxSlice.LookupHandler] prefer the most specific match over the first one
// registered. A literal segment pins harder than a wildcard segment, and a
// pattern left open at the end ("/", "/files/", "/{p...}") pins less than one
// spent on the whole path, so "/cnt" outscores "/" and "/users/me" outscores
// "/users/{id}". Scoring at registration keeps lookup to an integer compare.
//
// The score is a total order over patterns, which the subset relation is not:
// neither of "/a/{x}/c" and "/a/b/{y}" is more specific than the other, and they
// tie here where http.ServeMux rejects the pair as conflicting. A tie is settled
// by registration order rather than by a panic.
func patternSpecificity(pattern string) (spec int) {
if len(pattern) == 0 || pattern[0] != '/' {
return 0
}
pattern = pattern[1:]
for {
if len(pattern) == 0 {
return spec // Nothing after a slash: an anonymous "{...}" taking the rest.
}
segment, rest, more := strings.Cut(pattern, "/")
name, isMulti, isWildcard := pathWildcard(segment)
switch {
case isWildcard && name == "$":
return spec + 1 // Ends the path, so nothing is left open.
case isWildcard && isMulti:
return spec // Takes the remainder, pinning nothing more.
case isWildcard:
spec++
default:
spec += 2
}
if !more {
return spec + 1 // Spent on the last segment: the pattern is exact.
}
pattern = rest
}
}
// countPathValues is how many values pattern can bind, which is what sizes the
// slice [SetPathValues] writes into. Only a named wildcard segment binds: "{$}"
// marks the path's end, an anonymous "{...}" has no name to bind under, and a
+79
View File
@@ -363,6 +363,85 @@ func TestMuxSliceHandleAllowsExtensionMethod(t *testing.T) {
}
}
// A catch-all registered before the endpoints it sits above must not swallow
// them. "/" is a wildcard pattern: its trailing slash is an anonymous "{...}",
// so a purely first-match-wins scan hands every request to it and the specific
// registrations below become dead code, answered with the root page instead of
// their own body. Registering the site root first is the ordinary way to write
// a mux, so the more specific pattern has to win regardless of order, as in
// http.ServeMux.
func TestMuxSliceSpecificPatternBeatsEarlierCatchAll(t *testing.T) {
for _, test := range []struct {
name string
register []string
path string
want string
}{
{
name: "root registered first",
register: []string{"/", "/hello", "/cnt", "/6"},
path: "/cnt",
want: "/cnt",
}, {
name: "root registered last",
register: []string{"/hello", "/cnt", "/6", "/"},
path: "/cnt",
want: "/cnt",
}, {
name: "root still serves the root path",
register: []string{"/", "/cnt"},
path: "/",
want: "/",
}, {
name: "root still catches the unregistered",
register: []string{"/", "/cnt"},
path: "/nowhere",
want: "/",
}, {
name: "subtree wildcard loses to its own literal",
register: []string{"/files/", "/files/index"},
path: "/files/index",
want: "/files/index",
}, {
name: "subtree wildcard keeps the rest",
register: []string{"/files/", "/files/index"},
path: "/files/a/b",
want: "/files/",
}, {
name: "longer literal prefix wins over shorter subtree",
register: []string{"/", "/files/", "/files/a/b"},
path: "/files/a/b",
want: "/files/a/b",
}, {
name: "named wildcard loses to the literal it covers",
register: []string{"/users/{id}", "/users/me"},
path: "/users/me",
want: "/users/me",
}, {
name: "named wildcard keeps everything else",
register: []string{"/users/{id}", "/users/me"},
path: "/users/42",
want: "/users/{id}",
},
} {
t.Run(test.name, func(t *testing.T) {
var sm MuxSlice
sm.Reset(len(test.register))
for _, pattern := range test.register {
sm.Handle(pattern, func(ex *Exchange) { ex.WriteHeader(200) })
}
pathVals := make([]PathValue, max(sm.MaxPathValues(), 0))
got, handler := sm.LookupHandler(MethGet, []byte(test.path), pathVals)
if handler == nil {
t.Fatalf("%s matched no handler, want %q", test.path, test.want)
}
if got != test.want {
t.Errorf("%s matched pattern %q, want %q", test.path, got, test.want)
}
})
}
}
// pathVals sizes the slice SetPathValues writes into, so it must count exactly
// the wildcards that bind. Counting braces over-reports: "{$}" marks the path's
// end, an anonymous "{...}" has no name, and a brace inside a literal segment is