diff --git a/bearer/bearer_bench_test.go b/bearer/bearer_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d5c9915f36e95259699f7a7bd074e6c58ba76ecb --- /dev/null +++ b/bearer/bearer_bench_test.go @@ -0,0 +1,178 @@ +package bearer + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +// The grant strings these benchmarks present. The stateless one is what a short +// token carries; the registered one adds the id: member that turns step 4 from +// a no-op into a cache lookup. +const ( + benchGrants = "bench:upload cov:upload artifacts:upload" + benchGrantsRegistered = benchGrants + " id:4711" +) + +// The sinks keep a validated token from being discarded as an unread result. +var ( + sinkToken *Token + sinkErr error +) + +// liveDaemon stands in for tokens.sr.ht's revocation endpoint and answers 204 +// — live — to everything. +// +// It is only ever asked once per benchmark: the warm-up call below populates +// the cache, and every measured iteration is then the cached path, which is the +// one a running service spends its time in. A benchmark that hit the server on +// every iteration would be measuring httptest's loopback, not this package. +func liveDaemon(b *testing.B) *httptest.Server { + b.Helper() + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + b.Cleanup(s.Close) + return s +} + +// benchValidator builds a validator pointed at a daemon that says "live". +func benchValidator(b *testing.B) *Validator { + b.Helper() + + s := liveDaemon(b) + v, err := New(Options{ + Origin: s.URL, + ClientID: callerClientID, + NodeID: callerNodeID, + HTTPClient: s.Client(), + }) + if err != nil { + b.Fatalf("building the validator: %v", err) + } + return v +} + +// BenchmarkValidate is the whole of SPEC ch. 6 on one presented token, which is +// what a service pays per authenticated request. +// +// The four cases are the four outcomes that happen in production: +// +// - stateless: an HMAC, a grant parse and a map lookup, and no network at all. +// This is the common case under the default configuration and the number +// that matters. +// - registered_cached: the same, plus step 4 answered from the cache under the +// validator's mutex. The difference between it and stateless is the price of +// revocability. +// - forbidden: a good credential that does not carry the action. It costs more +// than a success, because the refusal renders the grant set into its message +// — worth knowing, since a misconfigured client retries this in a loop. +// - invalid: junk. Step 1 refuses it without allocating a token, and this is +// the path an instance under a flood of forged credentials runs; it must +// stay the cheapest thing here. +func BenchmarkValidate(b *testing.B) { + ctx := context.Background() + v := benchValidator(b) + + stateless := ourToken(benchGrants) + registered := ourToken(benchGrantsRegistered) + + // Warm the revocation cache, and check that the fixture validates at all — + // a token that fails here would turn every measured iteration into an early + // refusal and report it as a fast success. + if _, err := v.Validate(ctx, registered, "bench:upload"); err != nil { + b.Fatalf("the registered fixture must validate: %v", err) + } + + b.Run("stateless", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkToken, sinkErr = v.Validate(ctx, stateless, "bench:upload") + } + if sinkErr != nil { + b.Fatalf("unexpected refusal: %v", sinkErr) + } + }) + + b.Run("registered_cached", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkToken, sinkErr = v.Validate(ctx, registered, "bench:upload") + } + if sinkErr != nil { + b.Fatalf("unexpected refusal: %v", sinkErr) + } + }) + + b.Run("forbidden", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkToken, sinkErr = v.Validate(ctx, stateless, "dolt:push") + } + if !errors.Is(sinkErr, ErrForbidden) { + b.Fatalf("this case must be refused with ErrForbidden, got %v", sinkErr) + } + }) + + b.Run("invalid", func(b *testing.B) { + // Not a token: the shape a scanner presents. + const junk = "not-a-token-at-all-just-a-string-somebody-tried" + + b.ReportAllocs() + for b.Loop() { + sinkToken, sinkErr = v.Validate(ctx, junk, "bench:upload") + } + if !errors.Is(sinkErr, ErrInvalid) { + b.Fatalf("this case must be refused with ErrInvalid, got %v", sinkErr) + } + }) +} + +// BenchmarkInspect is the path a service's identity middleware runs: the same +// four steps without step 3, once per request, upstream of the router. Read it +// beside BenchmarkValidate/stateless — the gap is the grant check that Inspect +// leaves to the handler. +func BenchmarkInspect(b *testing.B) { + ctx := context.Background() + v := benchValidator(b) + presented := ourToken(benchGrants) + + b.ReportAllocs() + for b.Loop() { + sinkToken, sinkErr = v.Inspect(ctx, presented) + } + if sinkErr != nil { + b.Fatalf("unexpected refusal: %v", sinkErr) + } +} + +// BenchmarkValidateParallel is the same registered token validated from many +// goroutines at once, which is how a service actually holds a Validator: one of +// them, behind every request handler. +// +// The revocation cache is a map behind a single mutex, so this is where that +// choice shows up. A per-request HMAC is embarrassingly parallel; a shared lock +// is not, and the point of measuring it is to know which of the two dominates +// before somebody proposes sharding the cache. +func BenchmarkValidateParallel(b *testing.B) { + ctx := context.Background() + v := benchValidator(b) + registered := ourToken(benchGrantsRegistered) + + if _, err := v.Validate(ctx, registered, "bench:upload"); err != nil { + b.Fatalf("the registered fixture must validate: %v", err) + } + + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if _, err := v.Validate(ctx, registered, "bench:upload"); err != nil { + b.Errorf("unexpected refusal: %v", err) + return + } + } + }) +} diff --git a/chimw/chimw_bench_test.go b/chimw/chimw_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a5e97945293c57a263855e1b53118e5d16dc0baf --- /dev/null +++ b/chimw/chimw_bench_test.go @@ -0,0 +1,125 @@ +package chimw + +import ( + "io" + "log/slog" + "net/http" + "testing" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" +) + +// nullWriter is a ResponseWriter that keeps nothing; see the same type in +// middleware for why a recorder is the wrong instrument here. +type nullWriter struct{ header http.Header } + +func (w *nullWriter) Header() http.Header { return w.header } +func (w *nullWriter) Write(b []byte) (int, error) { return len(b), nil } +func (w *nullWriter) WriteHeader(int) {} + +// discardLogger is a real handler doing real encoding work, writing nowhere. +// The formatter's cost is the attributes it builds and the record the handler +// encodes; sending that to a buffer would grow one by b.N records and measure +// the allocator instead. +func discardLogger() *slog.Logger { + return slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug})) +} + +// benchRouter mounts one handler at /page behind the middlewares the case +// wants, which is how a service installs them. +func benchRouter(f SlogFormatter, withRequestID bool) http.Handler { + r := chi.NewRouter() + if withRequestID { + r.Use(chimiddleware.RequestID) + } + r.Use(RequestLogger(f)) + r.Get("/page", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + return r +} + +// benchRequest is reused across iterations: chi routes on a copy carrying its +// own route context and leaves this value alone. +func benchRequest(b *testing.B, path string) *http.Request { + b.Helper() + + r, err := http.NewRequest(http.MethodGet, "http://bench.example.org"+path, nil) + if err != nil { + b.Fatalf("building the request: %v", err) + } + return r +} + +// BenchmarkRequestLogger is the outermost middleware of every service on the +// instance: one record per request, built and encoded on the request's own +// goroutine before it returns. +// +// The three cases are the three shapes that exist in production. "logged" is +// the ordinary request. "with_request_id" adds chi's RequestID above, which is +// how these routers are actually wired and which costs a context lookup plus a +// sixth attribute. "skipped" is a probe hitting /healthz every second — it must +// stay far cheaper than the other two, because that is the entire reason Skip +// exists. +func BenchmarkRequestLogger(b *testing.B) { + logger := discardLogger() + + b.Run("logged", func(b *testing.B) { + h := benchRouter(SlogFormatter{Logger: logger}, false) + r := benchRequest(b, "/page") + + b.ReportAllocs() + for b.Loop() { + h.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, r) + } + }) + + b.Run("with_request_id", func(b *testing.B) { + h := benchRouter(SlogFormatter{Logger: logger}, true) + r := benchRequest(b, "/page") + + b.ReportAllocs() + for b.Loop() { + h.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, r) + } + }) + + b.Run("skipped", func(b *testing.B) { + h := benchRouter(SlogFormatter{ + Logger: logger, + Skip: SkipPaths("/page"), + }, false) + r := benchRequest(b, "/page") + + b.ReportAllocs() + for b.Loop() { + h.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, r) + } + }) +} + +// BenchmarkGetHead measures the pair a page route registers: the GET a viewer +// makes and the HEAD a monitor or a proxy makes through the same handler. Both +// go through the routing tree this package writes into, so this is the cost of +// the convenience rather than of chi. +func BenchmarkGetHead(b *testing.B) { + r := chi.NewRouter() + GetHead(r, "/page", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + for _, method := range []string{http.MethodGet, http.MethodHead} { + b.Run(method, func(b *testing.B) { + req, err := http.NewRequest(method, "http://bench.example.org/page", nil) + if err != nil { + b.Fatalf("building the request: %v", err) + } + + b.ReportAllocs() + for b.Loop() { + r.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, req) + } + }) + } +} diff --git a/csrf/csrf_bench_test.go b/csrf/csrf_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..359426eb1588983c61d042840d5ea310ec419cea --- /dev/null +++ b/csrf/csrf_bench_test.go @@ -0,0 +1,80 @@ +package csrf + +import ( + "net/http" + "testing" +) + +// nullWriter is a ResponseWriter that keeps nothing. httptest.NewRecorder grows +// a buffer and a header map per request, and this measurement is of the guard +// rather than of the recorder. +type nullWriter struct{ header http.Header } + +func (w *nullWriter) Header() http.Header { return w.header } +func (w *nullWriter) Write(b []byte) (int, error) { return len(b), nil } +func (w *nullWriter) WriteHeader(int) {} + +// The sink keeps a benchmarked predicate from being discarded as dead code. +var sinkBool bool + +// BenchmarkRequire measures the middleware as it is installed — in front of the +// whole router, on every request, so the safe-method case below is what the +// overwhelming majority of an instance's traffic pays. +// +// The four cases are the four paths through claimMatches: a method that is +// exempt without looking at a header, a POST that names us in Origin, a POST +// that only carries a Referer (which is one more Get and one more parse), and a +// POST from somewhere else, which reaches the deny handler. +func BenchmarkRequire(b *testing.B) { + // A handler that does nothing: what is measured is the chain above it. + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + guarded := Require(selfOrigin, nil)(next) + + cases := []struct { + name string + req *http.Request + }{ + {"safe_method", request(http.MethodGet, nil)}, + {"origin_match", request(http.MethodPost, map[string]string{"Origin": selfOrigin})}, + {"referer_only", request(http.MethodPost, map[string]string{ + "Referer": selfOrigin + "/settings/tokens", + })}, + {"denied", request(http.MethodPost, map[string]string{"Origin": "https://evil.example.net"})}, + } + + for _, c := range cases { + b.Run(c.name, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + // A fresh header map per iteration: the deny path writes into + // it, and reusing one would measure a second write to a map + // that already has the key. + guarded.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, c.req) + } + }) + } +} + +// BenchmarkSameOrigin is the exported predicate, and it is here to be read next +// to BenchmarkRequire: the middleware parses selfOrigin once at construction +// and this one parses it again on every call. That difference is the argument +// the package comment makes for preferring Require, in nanoseconds. +func BenchmarkSameOrigin(b *testing.B) { + r := request(http.MethodPost, map[string]string{"Origin": selfOrigin}) + + b.ReportAllocs() + for b.Loop() { + sinkBool = SameOrigin(r, selfOrigin) + } +} + +// BenchmarkSafeMethod is the first thing every request meets. It is a switch, +// so the number is expected to be uninteresting — which is the point: if it +// ever stops being uninteresting, something grew a map or a string compare on +// the path in front of the whole router. +func BenchmarkSafeMethod(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkBool = SafeMethod(http.MethodGet) + } +} diff --git a/grants/grants_bench_test.go b/grants/grants_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0c1bc7b56eb8df72b7ecd2e066943c3599f146a4 --- /dev/null +++ b/grants/grants_bench_test.go @@ -0,0 +1,124 @@ +package grants + +import "testing" + +// The grant string these benchmarks work on is the shape a real working token +// carries on this instance: a handful of : members from the +// vocabulary of SPEC ch. 3, plus the reserved id: member a registered token is +// stamped with. It is deliberately unsorted — Parse sorts on the way out, and a +// pre-sorted input would measure a cheaper parse than the one that runs. +const benchGrantString = "dolt:push bench:upload cov:upload artifacts:upload dolt:pull meta:profile id:4711" + +// The sinks exist so that nothing below can be discarded as a call whose result +// is never read. b.Loop already keeps the call itself, but the assignment is +// what keeps the *value* alive across the toolchain versions this builds on. +var ( + sinkGrants Grants + sinkBool bool + sinkString string + sinkErr error +) + +// BenchmarkParse is the per-request cost of this package, not a corner of it: +// bearer.decodeOurs parses the grant string of every presented token on every +// request, so this allocation profile is the one every service pays per call. +func BenchmarkParse(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkGrants, sinkErr = Parse(benchGrantString) + } + if sinkErr != nil { + b.Fatalf("the fixture must parse: %v", sinkErr) + } +} + +// BenchmarkParseRequested is the mint path — the same parse with the reserved +// id: member refused. It is here beside Parse because the refusal is a +// privilege boundary, and a change that made it cost noticeably more than the +// stored parse would be a change worth seeing. +func BenchmarkParseRequested(b *testing.B) { + // Without the id: member, which ParseRequested refuses by design. + const requested = "dolt:push bench:upload cov:upload artifacts:upload dolt:pull meta:profile" + + b.ReportAllocs() + for b.Loop() { + sinkGrants, sinkErr = ParseRequested(requested) + } + if sinkErr != nil { + b.Fatalf("the fixture must parse: %v", sinkErr) + } +} + +// BenchmarkHas is step 3 of the validation: one map lookup, taken on every +// authorized request. The miss is measured beside the hit because a refusal is +// what a flood of ill-scoped tokens produces, and the two must cost the same — +// a set whose miss is slower than its hit answers "was this refused?" to +// anybody who can time it. +func BenchmarkHas(b *testing.B) { + g, err := Parse(benchGrantString) + if err != nil { + b.Fatalf("the fixture must parse: %v", err) + } + universal := All() + + b.Run("hit", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkBool = g.Has("bench:upload") + } + }) + b.Run("miss", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkBool = g.Has("dolt:admin") + } + }) + b.Run("universal", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkBool = universal.Has("bench:upload") + } + }) +} + +// BenchmarkIsSubsetOf is the narrowing rule of SPEC ch. 2, which runs once per +// exchange. The "narrower" case walks the whole member set and is the one that +// bounds the cost; "wider" is the early refusal. +func BenchmarkIsSubsetOf(b *testing.B) { + parent, err := Parse(benchGrantString) + if err != nil { + b.Fatalf("the fixture must parse: %v", err) + } + child, err := Parse("bench:upload cov:upload id:8123") + if err != nil { + b.Fatalf("the fixture must parse: %v", err) + } + + b.Run("narrower", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkBool = child.IsSubsetOf(parent) + } + }) + b.Run("wider", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + sinkBool = parent.IsSubsetOf(child) + } + }) +} + +// BenchmarkString is the render half: sorting the members and appending the +// id:. It runs whenever a set is written back into a token payload or a +// database column, and it is the one operation here that sorts. +func BenchmarkString(b *testing.B) { + g, err := Parse(benchGrantString) + if err != nil { + b.Fatalf("the fixture must parse: %v", err) + } + + b.ReportAllocs() + for b.Loop() { + sinkString = g.String() + } +} diff --git a/middleware/middleware_bench_test.go b/middleware/middleware_bench_test.go new file mode 100644 index 0000000000000000000000000000000000000000..47fe86d64e8e861f095e25ca4858df8502d1eafc --- /dev/null +++ b/middleware/middleware_bench_test.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "io" + "log/slog" + "net/http" + "testing" +) + +// nullWriter is a ResponseWriter that keeps nothing: httptest.NewRecorder grows +// a buffer per request and this measurement is of the middleware. +type nullWriter struct{ header http.Header } + +func (w *nullWriter) Header() http.Header { return w.header } +func (w *nullWriter) Write(b []byte) (int, error) { return len(b), nil } +func (w *nullWriter) WriteHeader(int) {} + +// discardLog silences the default logger for one benchmark. The panic case +// below logs a stack per iteration, and without this the run would be measuring +// the terminal as much as the middleware — and would bury the result under it. +func discardLog(b *testing.B) { + b.Helper() + + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + b.Cleanup(func() { slog.SetDefault(previous) }) +} + +// benchRequest is the request every case below serves. chi and net/http do not +// mutate it — a router that needs a context puts the new one on a copy — so one +// value is safe to reuse across iterations, and building it per iteration would +// measure httptest.NewRequest. +func benchRequest(b *testing.B) *http.Request { + b.Helper() + + r, err := http.NewRequest(http.MethodGet, "http://bench.example.org/tokens", nil) + if err != nil { + b.Fatalf("building the request: %v", err) + } + return r +} + +// BenchmarkPrivateCache is two header writes in front of every response on the +// instance — the cheapest middleware here and the one on the most paths. +func BenchmarkPrivateCache(b *testing.B) { + h := PrivateCache(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + r := benchRequest(b) + + b.ReportAllocs() + for b.Loop() { + h.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, r) + } +} + +// BenchmarkRecoverPanics measures both halves of the guard. +// +// "clean" is what every request that does not panic pays: one wrapper +// allocation, one deferred recover. It is the number that matters, because it +// is charged to the whole surface for the benefit of the rare request below. +// +// "panicking" is the rendered 500, stack capture and log record included. It is +// slow on purpose — debug.Stack() walks the goroutine — and it is here so that +// a change which makes an already bad minute worse is visible. +func BenchmarkRecoverPanics(b *testing.B) { + discardLog(b) + + r := benchRequest(b) + + b.Run("clean", func(b *testing.B) { + h := RecoverPanics(renderInternal)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + b.ReportAllocs() + for b.Loop() { + h.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, r) + } + }) + + b.Run("panicking", func(b *testing.B) { + h := RecoverPanics(renderInternal)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + panic("the store is not reachable") + })) + + b.ReportAllocs() + for b.Loop() { + h.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, r) + } + }) +} + +// BenchmarkChain is the two middlewares as a service installs them, one inside +// the other, so that the sum is measured rather than inferred from the two +// numbers above — the wrapper allocations compose and the header writes do not. +func BenchmarkChain(b *testing.B) { + discardLog(b) + + h := PrivateCache(RecoverPanics(renderInternal)( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }))) + r := benchRequest(b) + + b.ReportAllocs() + for b.Loop() { + h.ServeHTTP(&nullWriter{header: make(http.Header, 4)}, r) + } +}