diff --git a/pkg/agent/endpoint/router/alloc_test.go b/pkg/agent/endpoint/router/alloc_test.go new file mode 100644 index 000000000..43d6c1d2d --- /dev/null +++ b/pkg/agent/endpoint/router/alloc_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 LiveKit, Inc. + +//go:build !race + +package router + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The race runtime allocates, so this cannot run under -race. +func TestMatchZeroAlloc(t *testing.T) { + specs := benchSpecs(256) + r, _ := build(t, specs) + for _, c := range benchCases(specs) { + n := testing.AllocsPerRun(200, func() { r.Match(c.path, c.mask) }) + require.Zero(t, n, c.name) + } + + // an ambiguous table searches, and must not allocate while it does + amb, _ := build(t, get("/f/{name}.{ext}", "/{p:path}/end")) + n := testing.AllocsPerRun(200, func() { amb.Match("/f/a.b.c", mGET) }) + require.Zero(t, n, "ambiguous") +} diff --git a/pkg/agent/endpoint/router/bench_test.go b/pkg/agent/endpoint/router/bench_test.go new file mode 100644 index 000000000..40a391db1 --- /dev/null +++ b/pkg/agent/endpoint/router/bench_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "testing" +) + +// benchSpecs is shaped like a FastAPI app: a few static routes, then resource +// collections with an id param, then a catch-all. +func benchSpecs(n int) []spec { + specs := []spec{ + {"/health", mGET}, + {"/ready", mGET}, + {"/metrics", mGET}, + } + for i := len(specs); i < n-1; i += 2 { + res := fmt.Sprintf("/api/v1/res%d", i) + specs = append(specs, spec{res, mGET | mPOST}) + specs = append(specs, spec{res + "/{id:int}", mGET | mPUT}) + } + specs = append(specs, spec{"/static/{p:path}", mGET}) + return specs[:min(n, len(specs))] +} + +type benchCase struct { + name, path string + mask Mask +} + +func benchCases(specs []spec) []benchCase { + last := specs[len(specs)-2].tpl + return []benchCase{ + {"hit_first", "/health", mGET}, + {"hit_last", last[:len(last)-len("/{id:int}")] + "/42", mGET}, + {"method_not_allowed", "/health", mPOST}, + {"miss", "/api/v1/nope/42", mGET}, + } +} + +func BenchmarkMatch(b *testing.B) { + for _, n := range []int{8, 64, 256} { + specs := benchSpecs(n) + r, _ := build(b, specs) + for _, c := range benchCases(specs) { + b.Run(fmt.Sprintf("routes=%d/%s", n, c.name), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + r.Match(c.path, c.mask) + } + }) + } + } +} + +// No shared prefixes, so the root's edge scan cannot be narrowed. +func BenchmarkMatchWideFanout(b *testing.B) { + specs := make([]spec, 0, 256) + for i := range 256 { + specs = append(specs, spec{fmt.Sprintf("/%c%c/x", 'a'+i/16, 'a'+i%16), mGET}) + } + r, _ := build(b, specs) + b.ReportAllocs() + for b.Loop() { + r.Match("/pp/x", mGET) + } +} + +func BenchmarkBuild(b *testing.B) { + specs := benchSpecs(256) + tpls := make([]*Template, len(specs)) + for i, s := range specs { + t, err := ParseTemplate(s.tpl) + if err != nil { + b.Fatal(err) + } + tpls[i] = t + } + b.ReportAllocs() + for b.Loop() { + bld := NewBuilder[string]() + for i, t := range tpls { + _ = bld.Add(t, specs[i].mask, specs[i].tpl) + } + bld.Build() + } +} diff --git a/pkg/agent/endpoint/router/build.go b/pkg/agent/endpoint/router/build.go new file mode 100644 index 000000000..a2ec9f3a8 --- /dev/null +++ b/pkg/agent/endpoint/router/build.go @@ -0,0 +1,195 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import "errors" + +const MaxRoutes = 1 << 16 + +var errTooManyRoutes = errors.New("router: too many routes") + +type buildNode[T any] struct { + kids []buildEdge[T] + leaves []leaf[T] + minIdx uint32 +} + +type buildEdge[T any] struct { + lit string + to *buildNode[T] + minIdx uint32 + kind kind +} + +// Builder accumulates a route table. A route's index is its position in the +// order added. +type Builder[T any] struct { + root *buildNode[T] + n uint32 + ambiguous []string +} + +func NewBuilder[T any]() *Builder[T] { + return &Builder[T]{root: &buildNode[T]{minIdx: noIdx}} +} + +// Add appends a route. Routes must be added in declaration order: the first +// one added that matches a path wins. +func (b *Builder[T]) Add(t *Template, m Mask, v T) error { + if b.n >= MaxRoutes { + return errTooManyRoutes + } + idx := b.n + b.n++ + + if ambiguousTemplate(t) { + b.ambiguous = append(b.ambiguous, t.raw) + } + + n := b.root + if n.minIdx == noIdx { + n.minIdx = idx + } + for _, e := range t.elements { + if e.kind == kindLiteral { + n = insertLiteral(n, e.lit, idx) + } else { + n = insertParam(n, e.kind, idx) + } + } + n.leaves = append(n.leaves, leaf[T]{idx: idx, mask: m, val: v}) + return nil +} + +// Build freezes the table, compacting its nodes into one arena. +func (b *Builder[T]) Build() *Router[T] { + r := &Router[T]{ambiguous: b.ambiguous, routes: int(b.n)} + r.nodes = make([]node[T], 0, countNodes(b.root)) + r.compact(b.root) + return r +} + +func countNodes[T any](n *buildNode[T]) int { + total := 1 + for i := range n.kids { + total += countNodes(n.kids[i].to) + } + return total +} + +func (r *Router[T]) compact(bn *buildNode[T]) uint32 { + ni := uint32(len(r.nodes)) + r.nodes = append(r.nodes, node[T]{leaves: bn.leaves, minIdx: bn.minIdx}) + + kids := make([]edge, len(bn.kids)) + for i := range bn.kids { + be := &bn.kids[i] + kids[i] = edge{lit: be.lit, minIdx: be.minIdx, kind: be.kind, single: singleRun(be)} + if be.kind == kindLiteral { + kids[i].first = be.lit[0] + } + } + // recurse before wiring: appending children may move the arena + for i := range bn.kids { + kids[i].to = r.compact(bn.kids[i].to) + } + r.nodes[ni].kids = kids + return ni +} + +// singleRun reports that only the convertor's greedy run can lead anywhere: a +// shorter run leaves at the head a byte the convertor could have consumed, and +// no child below can start with one. A terminal param qualifies vacuously. +func singleRun[T any](e *buildEdge[T]) bool { + if e.kind == kindLiteral || e.kind == kindUUID { + return true + } + for i := range e.to.kids { + k := &e.to.kids[i] + if k.kind != kindLiteral || e.kind.charset(k.lit[0]) { + return false + } + } + return true +} + +// ambiguousTemplate reports whether a template's own shape can force a search, +// independent of what other templates put in the tree. +func ambiguousTemplate(t *Template) bool { + for i, e := range t.elements { + if e.kind == kindLiteral || e.kind == kindUUID { + continue + } + if i == len(t.elements)-1 { + continue // terminal: only the greedy run can reach the end + } + next := t.elements[i+1] + if next.kind != kindLiteral || e.kind.charset(next.lit[0]) { + return true + } + } + return false +} + +func insertLiteral[T any](n *buildNode[T], lit string, idx uint32) *buildNode[T] { + for len(lit) > 0 { + e := literalEdge(n, lit[0]) + if e == nil { + child := &buildNode[T]{minIdx: idx} + n.kids = append(n.kids, buildEdge[T]{kind: kindLiteral, lit: lit, to: child, minIdx: idx}) + return child + } + cp := commonPrefix(e.lit, lit) + if cp < len(e.lit) { + // the edge keeps its slot and its minIdx, so kids stay ordered + mid := &buildNode[T]{minIdx: e.minIdx} + mid.kids = append(mid.kids, buildEdge[T]{kind: kindLiteral, lit: e.lit[cp:], to: e.to, minIdx: e.minIdx}) + e.lit, e.to = e.lit[:cp], mid + } + n, lit = e.to, lit[cp:] + } + return n +} + +func insertParam[T any](n *buildNode[T], k kind, idx uint32) *buildNode[T] { + for i := range n.kids { + if n.kids[i].kind == k { + return n.kids[i].to + } + } + child := &buildNode[T]{minIdx: idx} + n.kids = append(n.kids, buildEdge[T]{kind: k, to: child, minIdx: idx}) + return child +} + +// literalEdge finds the one literal edge that can consume c. Splitting keeps +// the first bytes of a node's literal edges distinct, so there is at most one. +func literalEdge[T any](n *buildNode[T], c byte) *buildEdge[T] { + for i := range n.kids { + if e := &n.kids[i]; e.kind == kindLiteral && e.lit[0] == c { + return e + } + } + return nil +} + +func commonPrefix(a, b string) int { + n := min(len(a), len(b)) + i := 0 + for i < n && a[i] == b[i] { + i++ + } + return i +} diff --git a/pkg/agent/endpoint/router/fuzz_test.go b/pkg/agent/endpoint/router/fuzz_test.go new file mode 100644 index 000000000..cec93d1dd --- /dev/null +++ b/pkg/agent/endpoint/router/fuzz_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// fragments compose into templates. %d takes a per-template counter so +// generated param names never collide, which ParseTemplate rejects. +var fragments = []string{ + "/a", "/b", "/ab", "/abc", "/", + "/{p%d}", "/{n%d:int}", "/{f%d:float}", "/{u%d:uuid}", "/{r%d:path}", + "{q%d}", ".json", ".{e%d}", "-{s%d}", "0", "/end", "//", "\n", "{", "x", +} + +func genSpecs(script []byte) []spec { + var specs []spec + var b strings.Builder + n := 0 + flush := func() { + if b.Len() > 0 { + specs = append(specs, spec{"/" + b.String(), Mask(n%7) + 1}) + b.Reset() + } + } + for _, c := range script { + if int(c)%(len(fragments)+2) >= len(fragments) { + flush() + continue + } + fmt.Fprintf(&b, fragments[int(c)%len(fragments)], n) + n++ + } + flush() + if len(specs) > 64 { + specs = specs[:64] + } + return specs +} + +// The trie answers what a linear scan of compiled starlette patterns answers: +// the same winning template and the same result, for every table and path. +func FuzzMatchAgainstOracle(f *testing.F) { + f.Add([]byte{5, 200, 0, 5}, "/x/y", uint8(1)) + f.Add([]byte{5, 1, 200, 1, 5}, "/a/b", uint8(1)) + f.Add([]byte{9, 200, 15}, "/a/b/end", uint8(1)) + f.Add([]byte{5, 12, 200, 5}, "/f/a.b.json", uint8(1)) + f.Add([]byte{7, 11, 200, 7}, "/1.25.json", uint8(3)) + f.Add([]byte{8, 200, 8, 15}, "/123e4567e89b12d3a456426614174000", uint8(1)) + f.Add([]byte{0, 1, 2, 3, 200, 0, 200, 2}, "/ab", uint8(1)) + f.Add([]byte{5, 200, 5, 200, 5}, "/x\n", uint8(7)) + f.Add([]byte{13, 5, 200, 5}, "/a-b", uint8(1)) + + f.Fuzz(func(t *testing.T, script []byte, path string, q uint8) { + specs := genSpecs(script) + if len(specs) == 0 { + return + } + + b := NewBuilder[string]() + o := &oracle{} + added := 0 + for _, s := range specs { + tpl, err := ParseTemplate(s.tpl) + if err != nil { + continue + } + require.NoError(t, o.add(s.tpl, s.mask), "oracle rejected an accepted template %q", s.tpl) + require.NoError(t, b.Add(tpl, s.mask, s.tpl)) + added++ + } + if added == 0 { + return + } + r := b.Build() + + mask := Mask(q) + got, res := r.Match(path, mask) + if res == ResultOverBudget { + return + } + wantTpl, wantRes := o.match(path, mask) + require.Equal(t, wantRes, res, "path %q mask %d over %v", path, mask, specs) + require.Equal(t, wantTpl, got, "path %q mask %d over %v", path, mask, specs) + }) +} diff --git a/pkg/agent/endpoint/router/match.go b/pkg/agent/endpoint/router/match.go new file mode 100644 index 000000000..3617d7e2a --- /dev/null +++ b/pkg/agent/endpoint/router/match.go @@ -0,0 +1,129 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import "strings" + +// state lives in Match's frame and is threaded by pointer; nothing reachable +// from it may escape. +type state[T any] struct { + path string + q Mask + val T + // best is the lowest index of a route matching both path and mask. Only a + // full match may assign it, since it gates pruning. + best uint32 + // acc is the second accepting position, len(path)-1 when the path ends in a + // newline and -1 otherwise: a template anchors as `\n?$`. + acc int + steps int + partial bool +} + +// Match returns the value of the first route in declaration order whose +// template matches path and whose mask intersects q. It returns ResultPartial +// when a template matched but no such route carried the mask, and +// ResultOverBudget when the search exceeded its step budget. +// +// Match allocates nothing. +func (r *Router[T]) Match(path string, q Mask) (T, Result) { + st := state[T]{path: path, q: q, best: noIdx, acc: -1} + if n := len(path); n > 0 && path[n-1] == '\n' { + st.acc = n - 1 + } + + if !r.walk(0, 0, &st) { + // a cut-short search may have missed a lower-indexed route, so anything + // found is not necessarily what declaration order selects + var zero T + return zero, ResultOverBudget + } + switch { + case st.best != noIdx: + return st.val, ResultFull + case st.partial: + var zero T + return zero, ResultPartial + default: + var zero T + return zero, ResultNone + } +} + +// walk explores the subtree at ni with pos bytes of the path consumed, +// reporting false when the step budget ran out. Declaration order is +// uncorrelated with depth, so a match is not a stopping condition; only the +// minIdx prune cuts the scan short. +func (r *Router[T]) walk(ni uint32, pos int, st *state[T]) bool { + n := &r.nodes[ni] + + if pos == len(st.path) || pos == st.acc { + for i := range n.leaves { + lf := &n.leaves[i] + if lf.idx >= st.best { + break + } + if lf.mask&st.q != 0 { + st.best, st.val = lf.idx, lf.val + break + } + st.partial = true + } + // a path convertor matches the empty string, so a route may still + // terminate below this node at this same position + } + + rest := st.path[pos:] + for i := range n.kids { + e := &n.kids[i] + if e.minIdx >= st.best { + break // kids ascend by minIdx, so no later one can improve on best + } + + if e.kind == kindLiteral { + if len(e.lit) > len(rest) || rest[0] != e.first || !strings.HasPrefix(rest, e.lit) { + continue + } + if !r.walk(e.to, pos+len(e.lit), st) { + return false + } + continue + } + + k := e.kind.scan(rest) + if k < 0 { + continue + } + if e.single { + if !r.walk(e.to, pos+k, st) { + return false + } + continue + } + for ; k >= 0; k = e.kind.next(rest, k) { + st.steps++ + if st.steps > maxSteps { + return false + } + if !r.walk(e.to, pos+k, st) { + return false + } + if e.minIdx >= st.best { + break + } + } + } + return true +} diff --git a/pkg/agent/endpoint/router/oracle_test.go b/pkg/agent/endpoint/router/oracle_test.go new file mode 100644 index 000000000..abec73be2 --- /dev/null +++ b/pkg/agent/endpoint/router/oracle_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "regexp" + "strings" +) + +// One compiled regex per route, scanned linearly. Transcribed from starlette's +// compile_path and carrying its own parser, so it references the hand-rolled +// scanner as well as the trie. + +var oracleConvertors = map[string]string{ + "str": `[^/]+`, + "path": `.*`, + "int": `[0-9]+`, + "float": `[0-9]+(?:\.[0-9]+)?`, + "uuid": `[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}`, +} + +// starlette's PARAM_REGEX +var oracleParamRegex = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?\}`) + +func oracleCompile(path string) (*regexp.Regexp, error) { + var pattern strings.Builder + pattern.WriteString("^") + + idx := 0 + for _, m := range oracleParamRegex.FindAllStringSubmatchIndex(path, -1) { + start, end := m[0], m[1] + convertor := "str" + if m[4] != -1 { + convertor = path[m[4]+1 : m[5]] + } + p, ok := oracleConvertors[convertor] + if !ok { + return nil, fmt.Errorf("unknown convertor %q", convertor) + } + pattern.WriteString(regexp.QuoteMeta(path[idx:start])) + pattern.WriteString("(?:") + pattern.WriteString(p) + pattern.WriteString(")") + idx = end + } + pattern.WriteString(regexp.QuoteMeta(path[idx:])) + // python's '$' is Go's `\n?\z` + pattern.WriteString("\n?$") + + return regexp.Compile(pattern.String()) +} + +type oracleRoute struct { + re *regexp.Regexp + raw string + mask Mask +} + +type oracle struct{ routes []oracleRoute } + +func (o *oracle) add(raw string, mask Mask) error { + re, err := oracleCompile(raw) + if err != nil { + return err + } + o.routes = append(o.routes, oracleRoute{re: re, raw: raw, mask: mask}) + return nil +} + +func (o *oracle) match(path string, q Mask) (string, Result) { + partial := false + for _, r := range o.routes { + if !r.re.MatchString(path) { + continue + } + if r.mask&q != 0 { + return r.raw, ResultFull + } + partial = true + } + if partial { + return "", ResultPartial + } + return "", ResultNone +} diff --git a/pkg/agent/endpoint/router/router.go b/pkg/agent/endpoint/router/router.go new file mode 100644 index 000000000..5511d54cd --- /dev/null +++ b/pkg/agent/endpoint/router/router.go @@ -0,0 +1,118 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package router matches request paths against an ordered table of +// starlette-style path templates. The first route declared that matches wins: +// declaration order is the entire priority rule. +package router + +import ( + "go.uber.org/zap/zapcore" +) + +// maxSteps bounds the backtracking a single Match may do. Only a non-single +// edge spends a step, so a table of unambiguous templates never reaches it. +const maxSteps = 100_000 + +// Mask is an opaque per-route tag. A route fully matches when its mask +// intersects the query mask, and partially matches when only its template does. +type Mask uint32 + +type Result uint8 + +const ( + ResultNone Result = iota + // ResultPartial means a template matched but no route carrying the queried + // mask did. + ResultPartial + ResultFull + // ResultOverBudget means the search exceeded maxSteps and was abandoned, so + // no route was decided. + ResultOverBudget +) + +func (r Result) String() string { + switch r { + case ResultNone: + return "none" + case ResultPartial: + return "partial" + case ResultFull: + return "full" + case ResultOverBudget: + return "over-budget" + } + return "unknown" +} + +// noIdx is above every route index, so an unset best never prunes. +const noIdx = ^uint32(0) + +type edge struct { + lit string // kindLiteral only + to uint32 // index into Router.nodes + minIdx uint32 // mirrors nodes[to].minIdx, so a prune dereferences no child + kind kind + first byte // kindLiteral only: lit[0] + single bool // the convertor admits one viable run at any position +} + +type leaf[T any] struct { + idx uint32 + mask Mask + val T +} + +type node[T any] struct { + kids []edge + // several routes can terminate at one node: /u/{id} and /u/{name} compile + // alike, and a path may be declared once per method + leaves []leaf[T] + minIdx uint32 +} + +// Router matches paths against a route table. It is built once and never +// mutated, so readers need no lock and a new table is published by a single +// atomic.Pointer store, which synchronises everything a reader reaches. +type Router[T any] struct { + nodes []node[T] + ambiguous []string + routes int +} + +// MarshalLogObject describes the table's shape. A Router holds no per-match +// state. +func (r *Router[T]) MarshalLogObject(e zapcore.ObjectEncoder) error { + if r == nil { + return nil + } + e.AddInt("routes", r.routes) + if len(r.ambiguous) > 0 { + err := e.AddArray("ambiguous", zapcore.ArrayMarshalerFunc(func(a zapcore.ArrayEncoder) error { + for _, t := range r.ambiguous { + a.AppendString(t) + } + return nil + })) + if err != nil { + return err + } + } + return nil +} + +// Ambiguous returns the templates whose shape forces the matcher to backtrack - +// a param that a following literal can extend, adjacent params, or a non-final +// path convertor. The result is read-only. +func (r *Router[T]) Ambiguous() []string { return r.ambiguous } diff --git a/pkg/agent/endpoint/router/router_test.go b/pkg/agent/endpoint/router/router_test.go new file mode 100644 index 000000000..c9537401f --- /dev/null +++ b/pkg/agent/endpoint/router/router_test.go @@ -0,0 +1,254 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +const ( + mGET Mask = 1 << iota + mPOST + mPUT +) + +type spec struct { + tpl string + mask Mask +} + +func get(tpls ...string) []spec { + s := make([]spec, len(tpls)) + for i, t := range tpls { + s[i] = spec{t, mGET} + } + return s +} + +func build(t testing.TB, specs []spec) (*Router[string], *oracle) { + t.Helper() + b := NewBuilder[string]() + o := &oracle{} + for _, s := range specs { + tpl, err := ParseTemplate(s.tpl) + require.NoError(t, err, s.tpl) + require.NoError(t, b.Add(tpl, s.mask, s.tpl)) + require.NoError(t, o.add(s.tpl, s.mask), s.tpl) + } + return b.Build(), o +} + +// Every case asserts the expected answer and that the oracle agrees. +func TestMatchAdversarial(t *testing.T) { + cases := []struct { + name string + routes []spec + path string + mask Mask + want string + res Result + }{ + // declaration order decides + {"order beats specificity", get("/u/{id}", "/u/me"), "/u/me", mGET, "/u/{id}", ResultFull}, + {"order the other way", get("/u/me", "/u/{id}"), "/u/me", mGET, "/u/me", ResultFull}, + {"order with int first", get("/x/{a:int}", "/x/{b}"), "/x/42", mGET, "/x/{a:int}", ResultFull}, + {"order falls through to str", get("/x/{a:int}", "/x/{b}"), "/x/4x", mGET, "/x/{b}", ResultFull}, + {"literal buried deep", get("/a/{x}/c", "/a/b/c"), "/a/b/c", mGET, "/a/{x}/c", ResultFull}, + + // the method dimension must not let a partial prune a later full match + {"later method wins", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull}, + {"wildcard partial then literal full", []spec{{"/{a}", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull}, + {"partial at a lower index", []spec{{"/a/{x}", mGET}, {"/a/b", mPOST}}, "/a/b", mPOST, "/a/b", ResultFull}, + {"no method matches", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPUT, "", ResultPartial}, + {"nothing matches", get("/x"), "/nope", mGET, "", ResultNone}, + + // mid-segment params, which the walk must search for + {"name dot ext", get("/f/{name}.{ext}"), "/f/a.b.c", mGET, "/f/{name}.{ext}", ResultFull}, + {"name dot ext needs a dot", get("/f/{name}.{ext}"), "/f/abc", mGET, "", ResultNone}, + {"suffix literal", get("/f/{name}.json"), "/f/a.b.json", mGET, "/f/{name}.json", ResultFull}, + {"prefix and suffix", get("/v{major}.{minor}"), "/v1.2", mGET, "/v{major}.{minor}", ResultFull}, + {"adjacent params", get("/{a}{b}"), "/xy", mGET, "/{a}{b}", ResultFull}, + + // greedy path, which crosses '/' + {"path backtracks to a literal", get("/{p:path}/end"), "/a/b/end/end", mGET, "/{p:path}/end", ResultFull}, + {"path matches empty", get("/files/{p:path}"), "/files/", mGET, "/files/{p:path}", ResultFull}, + {"path spans slashes", get("/files/{p:path}"), "/files/a/b/c.txt", mGET, "/files/{p:path}", ResultFull}, + {"path refuses newline", get("/files/{p:path}"), "/files/a\nb", mGET, "", ResultNone}, + {"str accepts newline", get("/files/{p}"), "/files/a\nb", mGET, "/files/{p}", ResultFull}, + + // a template anchors as `\n?$` + {"trailing newline accepted", get("/x"), "/x\n", mGET, "/x", ResultFull}, + {"two trailing newlines rejected", get("/x"), "/x\n\n", mGET, "", ResultNone}, + {"path convertor eats to the newline", get("/files/{p:path}"), "/files/a\n", mGET, "/files/{p:path}", ResultFull}, + + // float's optional fraction backtracks + {"float with fraction", get("/p/{v:float}.json"), "/p/1.25.json", mGET, "/p/{v:float}.json", ResultFull}, + {"float without fraction", get("/p/{v:float}.json"), "/p/1.json", mGET, "/p/{v:float}.json", ResultFull}, + {"float rejects a bare dot", get("/p/{v:float}"), "/p/1.", mGET, "", ResultNone}, + {"float takes the fraction", get("/p/{v:float}"), "/p/1.25", mGET, "/p/{v:float}", ResultFull}, + + // int followed by a digit needs a shorter run + {"int backtracks off a digit", get("/{n:int}0/x"), "/120/x", mGET, "/{n:int}0/x", ResultFull}, + + // uuid admits exactly one length + {"uuid hyphenated", get("/o/{u:uuid}"), "/o/123e4567-e89b-12d3-a456-426614174000", mGET, "/o/{u:uuid}", ResultFull}, + {"uuid bare", get("/o/{u:uuid}"), "/o/123e4567e89b12d3a456426614174000", mGET, "/o/{u:uuid}", ResultFull}, + {"uuid then literal", get("/o/{u:uuid}/z"), "/o/123e4567e89b12d3a456426614174000/z", mGET, "/o/{u:uuid}/z", ResultFull}, + {"uuid too short", get("/o/{u:uuid}"), "/o/123e4567", mGET, "", ResultNone}, + + // radix splits + {"split abc", get("/abc", "/abd", "/ab", "/a"), "/ab", mGET, "/ab", ResultFull}, + {"split abd", get("/abc", "/abd", "/ab", "/a"), "/abd", mGET, "/abd", ResultFull}, + {"split a", get("/abc", "/abd", "/ab", "/a"), "/a", mGET, "/a", ResultFull}, + {"split reversed", get("/a", "/ab", "/abd", "/abc"), "/abc", mGET, "/abc", ResultFull}, + {"split miss", get("/abc", "/abd"), "/abe", mGET, "", ResultNone}, + + // literals are byte-exact + {"case sensitive", get("/token"), "/Token", mGET, "", ResultNone}, + {"trailing slash is not implied", get("/token"), "/token/", mGET, "", ResultNone}, + {"registered with a slash", get("/token/"), "/token/", mGET, "/token/", ResultFull}, + + // degenerate paths + {"root", get("/"), "/", mGET, "/", ResultFull}, + {"double slash", get("/", "//"), "//", mGET, "//", ResultFull}, + {"empty segment", get("/a//b"), "/a//b", mGET, "/a//b", ResultFull}, + {"str will not cross a slash", get("/a/{x}"), "/a/b/c", mGET, "", ResultNone}, + {"str needs a byte", get("/a/{x}"), "/a/", mGET, "", ResultNone}, + {"a decoded %2F is just a slash", get("/files/{p}"), "/files/a/b", mGET, "", ResultNone}, + + // a brace that opens nothing well-formed is a literal + {"stray brace", get("/a{b"), "/a{b", mGET, "/a{b", ResultFull}, + {"empty convertor is literal", get("/x/{a:}"), "/x/{a:}", mGET, "/x/{a:}", ResultFull}, + {"digit-leading name is literal", get("/x/{1bad}"), "/x/{1bad}", mGET, "/x/{1bad}", ResultFull}, + {"doubled brace", get("/{{a}"), "/{x", mGET, "/{{a}", ResultFull}, + } + + for _, c := range cases { + r, o := build(t, c.routes) + + got, res := r.Match(c.path, c.mask) + require.Equal(t, c.res, res, "%s: %q", c.name, c.path) + require.Equal(t, c.want, got, "%s: %q", c.name, c.path) + + oGot, oRes := o.match(c.path, c.mask) + require.Equal(t, c.res, oRes, "oracle disagrees on %s: %q", c.name, c.path) + require.Equal(t, c.want, oGot, "oracle disagrees on %s: %q", c.name, c.path) + } +} + +// Whether a template can force a search is fixed at build time. +func TestAmbiguousClassification(t *testing.T) { + cases := []struct { + tpl string + ambiguous bool + }{ + {"/static", false}, + {"/u/{id}", false}, + {"/u/{id}/posts", false}, + {"/u/{id:int}/posts", false}, + {"/files/{p:path}", false}, + {"/o/{u:uuid}/z", false}, + {"/o/{u:uuid}{rest}", false}, + {"/f/{name}.json", true}, + {"/f/{name}.{ext}", true}, + {"/{p:path}/end", true}, + {"/p/{v:float}.json", true}, + {"/{n:int}0/x", true}, + {"/{a}{b}", true}, + {"/{n:int}/x", false}, + {"/{v:float}/x", false}, + } + for _, c := range cases { + r, _ := build(t, get(c.tpl)) + require.Equal(t, c.ambiguous, len(r.Ambiguous()) == 1, "%s", c.tpl) + } +} + +// An unambiguous table holds no edge that can search, so no path length reaches +// the budget; an ambiguous one gives up at it. +func TestStepBudget(t *testing.T) { + r, _ := build(t, get("/api/{v:int}/res/{id}/sub/{name}")) + for i := range r.nodes { + for _, e := range r.nodes[i].kids { + require.True(t, e.single, "%q", e.lit) + } + } + _, res := r.Match("/api/1/res/"+strings.Repeat("x", 4096)+"/sub/y", mGET) + require.Equal(t, ResultFull, res) + + r, _ = build(t, get("/{a}{b}{c}{d}x")) + _, res = r.Match("/"+strings.Repeat("a", 4096), mGET) + require.Equal(t, ResultOverBudget, res) +} + +func TestEmptyRouter(t *testing.T) { + r := NewBuilder[string]().Build() + got, res := r.Match("/x", mGET) + require.Equal(t, ResultNone, res) + require.Empty(t, got) +} + +// Readers match against a published table while writers swap in new ones. +func TestConcurrentSwapUnderLoad(t *testing.T) { + tables := make([]*Router[string], 4) + for i := range tables { + tables[i], _ = build(t, get( + fmt.Sprintf("/v%d/{id:int}", i), + "/f/{name}.json", + "/static/{p:path}", + "/health", + )) + } + + var h atomic.Pointer[Router[string]] + h.Store(tables[0]) + + var stop atomic.Bool + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + for i := 0; !stop.Load(); i++ { + h.Store(tables[i%len(tables)]) + } + }) + } + for range 8 { + wg.Go(func() { + for !stop.Load() { + r := h.Load() + r.Match("/v2/42", mGET) + r.Match("/f/a.b.json", mGET) + r.Match("/static/a/b/c", mGET) + r.Match("/health", mPOST) + } + }) + } + time.Sleep(200 * time.Millisecond) + stop.Store(true) + wg.Wait() +} + +func TestMarshalLogObject(t *testing.T) { + r, _ := build(t, get("/health", "/f/{name}.json")) + + enc := zapcore.NewMapObjectEncoder() + require.NoError(t, r.MarshalLogObject(enc)) + require.Equal(t, 2, enc.Fields["routes"]) + require.Equal(t, []any{"/f/{name}.json"}, enc.Fields["ambiguous"]) + + plain, _ := build(t, get("/health")) + enc = zapcore.NewMapObjectEncoder() + require.NoError(t, plain.MarshalLogObject(enc)) + require.NotContains(t, enc.Fields, "ambiguous") + + var nilRouter *Router[string] + require.NoError(t, nilRouter.MarshalLogObject(zapcore.NewMapObjectEncoder())) +} diff --git a/pkg/agent/endpoint/router/scan.go b/pkg/agent/endpoint/router/scan.go new file mode 100644 index 000000000..f5ab748d6 --- /dev/null +++ b/pkg/agent/endpoint/router/scan.go @@ -0,0 +1,130 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import "strings" + +// Scanning is byte-wise: every byte a convertor distinguishes ('/', '\n', and +// [0-9a-fA-F-]) is ASCII, and UTF-8 is self-synchronising, so no multi-byte +// sequence can contain one. + +// scan returns the longest run the convertor consumes at the head of s, or -1 +// when it cannot match at all. +func (k kind) scan(s string) int { + switch k { + case kindStr: + if n := runTo(s, '/'); n > 0 { + return n + } + case kindPath: + return runTo(s, '\n') + case kindInt: + if n := digitRun(s); n > 0 { + return n + } + case kindFloat: + d := digitRun(s) + if d == 0 { + break + } + if d < len(s) && s[d] == '.' { + if f := digitRun(s[d+1:]); f > 0 { + return d + 1 + f + } + } + return d + case kindUUID: + return uuidRun(s) + } + return -1 +} + +// next returns the next shorter viable run after prev, or -1 when prev was the +// last candidate. Candidates descend, so runs are enumerated greedily. +func (k kind) next(s string, prev int) int { + switch k { + case kindStr, kindInt: + if prev > 1 { + return prev - 1 + } + case kindPath: + if prev > 0 { + return prev - 1 + } + case kindFloat: + // the integer part is a digit run from the head, so a '.' can only sit + // at its end: viable runs are 1..d and d+1+1..d+1+f, never d+1 + d := digitRun(s) + switch { + case prev > d+2: + return prev - 1 + case prev == d+2: + return d + case prev > 1: + return prev - 1 + } + case kindUUID: + // every '-?' is forced by the input: skipping a present hyphen demands a + // hex digit where the hyphen is, so the shape admits one length + } + return -1 +} + +// charset reports whether a shorter run of this convertor could be followed by +// c, which is what makes an edge ambiguous. +func (k kind) charset(c byte) bool { + switch k { + case kindStr: + return c != '/' + case kindPath: + return c != '\n' + case kindInt: + return isDigit(c) + case kindFloat: + return isDigit(c) || c == '.' + } + return false +} + +func runTo(s string, c byte) int { + if i := strings.IndexByte(s, c); i >= 0 { + return i + } + return len(s) +} + +func digitRun(s string) int { + i := 0 + for i < len(s) && isDigit(s[i]) { + i++ + } + return i +} + +func uuidRun(s string) int { + i := 0 + for g, n := range [...]int{8, 4, 4, 4, 12} { + if g > 0 && i < len(s) && s[i] == '-' { + i++ + } + for range n { + if i >= len(s) || !isHex(s[i]) { + return -1 + } + i++ + } + } + return i +} diff --git a/pkg/agent/endpoint/router/template.go b/pkg/agent/endpoint/router/template.go new file mode 100644 index 000000000..97a5d9f14 --- /dev/null +++ b/pkg/agent/endpoint/router/template.go @@ -0,0 +1,159 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +// kind is what one element of a template consumes. The convertor set is part +// of the wire contract and is closed. +type kind uint8 + +const ( + kindLiteral kind = iota + kindStr // [^/]+ + kindPath // .*, which excludes '\n' + kindInt // [0-9]+ + kindFloat // [0-9]+(?:\.[0-9]+)? + kindUUID // 8-4-4-4-12 hex, every hyphen optional +) + +func convertorKind(name string) (kind, bool) { + switch name { + case "str": + return kindStr, true + case "path": + return kindPath, true + case "int": + return kindInt, true + case "float": + return kindFloat, true + case "uuid": + return kindUUID, true + } + return 0, false +} + +// element is one step of a parsed template: literal bytes to match exactly, or +// a convertor to consume. +type element struct { + kind kind + lit string // kindLiteral only +} + +// Template is a parsed starlette-style path template. Parsing mirrors +// starlette's compile_path exactly. +type Template struct { + // the template as declared + raw string + elements []element +} + +// String returns the template as declared. +func (t *Template) String() string { return t.raw } + +// ParseTemplate parses a starlette path template. Custom convertors are +// rejected: only the five built-ins may travel over the wire. +func ParseTemplate(path string) (*Template, error) { + if !strings.HasPrefix(path, "/") { + return nil, fmt.Errorf("path template must start with '/': %q", path) + } + // literals are compared byte-wise, which agrees with rune-wise semantics + // only for valid UTF-8 + if !utf8.ValidString(path) { + return nil, fmt.Errorf("path template is not valid UTF-8: %q", path) + } + + t := &Template{raw: path} + seen := map[string]struct{}{} + lit := 0 + for i := 0; i < len(path); { + name, convertor, end, ok := scanParam(path, i) + if !ok { + i++ + continue + } + k, ok := convertorKind(convertor) + if !ok { + return nil, fmt.Errorf("unknown path convertor %q in template %q", convertor, path) + } + if _, dup := seen[name]; dup { + return nil, fmt.Errorf("duplicated param name %q in template %q", name, path) + } + seen[name] = struct{}{} + + if i > lit { + t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:i]}) + } + t.elements = append(t.elements, element{kind: k}) + i, lit = end, end + } + if lit < len(path) { + t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:]}) + } + return t, nil +} + +// scanParam matches starlette's PARAM_REGEX at i: +// +// {([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?} +// +// Everything between matches is a literal, so a brace that does not open a +// well-formed param is an ordinary literal character. +func scanParam(s string, i int) (name, convertor string, end int, ok bool) { + if s[i] != '{' { + return "", "", 0, false + } + j := i + 1 + n := scanIdent(s, j) + if n == j { + return "", "", 0, false + } + name, j = s[j:n], n + + convertor = "str" + if j < len(s) && s[j] == ':' { + c := scanIdent(s, j+1) + if c == j+1 { + return "", "", 0, false + } + convertor, j = s[j+1:c], c + } + if j >= len(s) || s[j] != '}' { + return "", "", 0, false + } + return name, convertor, j + 1, true +} + +func scanIdent(s string, i int) int { + if i >= len(s) || !(isAlpha(s[i]) || s[i] == '_') { + return i + } + j := i + 1 + for j < len(s) && (isAlpha(s[j]) || isDigit(s[j]) || s[j] == '_') { + j++ + } + return j +} + +func isAlpha(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } + +func isHex(c byte) bool { + return isDigit(c) || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} diff --git a/pkg/agent/endpoint/router/template_test.go b/pkg/agent/endpoint/router/template_test.go new file mode 100644 index 000000000..e03f5942f --- /dev/null +++ b/pkg/agent/endpoint/router/template_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func matches(t *testing.T, tpl, path string) bool { + t.Helper() + r, o := build(t, get(tpl)) + got, res := r.Match(path, mGET) + oGot, oRes := o.match(path, mGET) + require.Equal(t, oRes, res, "oracle disagrees: %q vs %q", tpl, path) + require.Equal(t, oGot, got, "oracle disagrees: %q vs %q", tpl, path) + return res == ResultFull +} + +func TestTemplateStarletteSemantics(t *testing.T) { + cases := []struct { + template string + path string + match bool + }{ + {"/token", "/token", true}, + {"/token", "/token/", false}, + {"/token", "/Token", false}, + {"/users/{id}", "/users/42", true}, + {"/users/{id}", "/users/42/posts", false}, + {"/users/{id}", "/users/", false}, + {"/users/{id:int}", "/users/42", true}, + {"/users/{id:int}", "/users/4x2", false}, + {"/files/{p:path}", "/files/a/b/c.txt", true}, + {"/files/{p:path}", "/files/", true}, + {"/price/{v:float}", "/price/1.25", true}, + {"/price/{v:float}", "/price/1.", false}, + {"/obj/{u:uuid}", "/obj/123e4567-e89b-12d3-a456-426614174000", true}, + // starlette's uuid convertor makes every hyphen optional + {"/obj/{u:uuid}", "/obj/123e4567e89b12d3a456426614174000", true}, + {"/obj/{u:uuid}", "/obj/123e4567", false}, + {"/a/{x}/b/{y}", "/a/1/b/2", true}, + {"/a/{x}/b/{y}", "/a/1/c/2", false}, + } + for _, c := range cases { + require.Equal(t, c.match, matches(t, c.template, c.path), "%s vs %s", c.template, c.path) + } +} + +// Vectors generated from CPython against a transcription of compile_path. +func TestTemplateTrailingNewline(t *testing.T) { + cases := []struct { + template string + path string + match bool + }{ + {"/x", "/x\n", true}, + {"/x", "/x\n\n", false}, + {"/x", "/x\nz", false}, + {"/files/{p:path}", "/files/a\n", true}, + {"/files/{p:path}", "/files/a\nb", false}, + {"/files/{p}", "/files/a\n", true}, + {"/files/{p}", "/files/a\nb", true}, + {"/n/{i:int}", "/n/42\n", true}, + {"/n/{i:int}", "/n/4\n2", false}, + {"/", "/\n", true}, + {"/a/", "/a/\n", true}, + } + for _, c := range cases { + require.Equal(t, c.match, matches(t, c.template, c.path), "%q vs %q", c.template, c.path) + } +} + +func TestParseTemplateRejects(t *testing.T) { + for _, tpl := range []string{ + "/x/{id:slug}", + "/x/{a}/{a}", + "no-slash", + "/x/\xff", + } { + _, err := ParseTemplate(tpl) + require.Error(t, err, tpl) + } +} + +// A brace that opens nothing well-formed is an ordinary literal. +func TestParseTemplateLenientBraces(t *testing.T) { + for _, tpl := range []string{"/a{b", "/x/{a:}", "/x/{1bad}", "/{{a}", "/}", "/{}"} { + tt, err := ParseTemplate(tpl) + require.NoError(t, err, tpl) + require.Equal(t, tpl, tt.String()) + } +}