carlos / platform Public

edge: Carlos-Purge response-header purge channel (#22)

* edge: Carlos-Purge — an app invalidates its own cache on the response it already returns

A unit-backed DynamicUser tenant cannot reach any control socket, including
one built for itself, so it had no purge channel at all and its cached pages
were bounded by the five-minute TTL ceiling alone. Rather than mint groups,
users, ports or tokens to make one reachable, the app declares its purges on
the response it is already returning from the write:

    HTTP/1.1 302 Found
    Location: /events/42
    Carlos-Purge: /events/42, /events/42?lang=fr

The tenant-isolation invariant then holds by construction rather than by
permission bookkeeping: the host is the one the edge already resolved this
request to out of its own routing table, the header carries no host field and
the edge never accepts one, so there is no field in which one tenant could
address another — and nothing to reach, so "which tenants can reach this
channel" has no answer beyond "each tenant's own responses."

The hook goes on ModifyResponse, which is the only seam that is both early
enough and universal enough: it runs before ReverseProxy copies any header
onto the real ResponseWriter (so the strip is what makes "no client ever sees
it" true, including on a 101), before the response reaches
recordingResponseWriter at all (so the purge is strictly before maybeStore —
reverse those two and a cacheable GET purges exactly what it just stored, and
a hibernating route wakes on every request), and every proxied path in
ServeHTTP funnels through this one proxy: the upgrade bypass, the
hibernation/activator branch, the cacheable GET branch, and the plain
pass-through.

Read with joinHeader, never Header.Get. Carlos-Purge is legitimately
list-valued; Get returns only the first line, which is the bug this package
has already paid for on Cache-Control, Vary and Connection.

Bounds at 64 targets and 8 KiB of joined header, degrading to a whole-host
purge beyond either — the failure direction is deliberately over-purge, and
degrading is cheaper than honouring, so there is nothing to gain by exceeding
the bound.

CARLOS_PURGE=header joins the exec child's environment as an advisory var
under the same "only when there is a cache" gate as CARLOS_CONTROL_SOCKET.
The socket channel is untouched; the header is an additional channel and both
coexist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* edge: Carlos-Purge review round — scrub inbound, purge every query variant, decode the path

Eight review findings from PR #22.

CRITICAL — a client-supplied Carlos-Purge reached the app. The edge already
ignored it on requests, so the edge itself was never fooled; the app was the
hole. An app that reflects request headers onto its response (a debug echo, a
framework pass-through list, a CORS mirror) would launder a client's header
into an app-DECLARED one, and those ARE honoured — handing any client on the
internet the purge channel this design exists to keep unreachable, through a
bug in tenant code the edge cannot audit. Both Rewrite funcs now Del it;
unixProxy and tcpProxy have separate ones, so each needed it.

CONTRACT CHANGE — a bare-path target now purges EVERY query variant. Keying
"/events/42" on the exact empty query left /events/42?utm_source=… and
?lang=fr serving the pre-write body until their TTLs expired — precisely the
copies a reader arrives on. New Cache.PurgePath rides the existing byPath
index (both method groups, mirroring PurgeURL), so it is two map lookups, not
a scan. A target WITH "?" stays exact; "/events/42?" is how an app asks for
the unqueried variant alone.

Percent-encoding: targets are now PathUnescape'd on the path half, because
edge.go stores CacheKey.Path from r.URL.Path, which net/url has ALREADY
decoded — "/caf%C3%A9" previously purged nothing while looking like it
worked. The query half stays RAW, because CacheKey.RawQuery is
r.URL.RawQuery, raw as received: store and purge must agree, and they agree
on raw. Decoded bytes are re-checked, since decoding manufactures the shapes
the raw gate refuses ("/%2Fhost" -> "//host"). One deliberate asymmetry: a
decoded SPACE is allowed where a raw one is refused, or %20 would make "/a b"
permanently unpurgeable — an under-purge, the wrong failure direction.

The cap-cost comment was inverted. Degrading is not cheaper than honouring:
PurgeHost takes the global lock and scans every entry (4096 at
DefaultCacheMaxEntries) where honouring 64 targets is 128 map lookups —
roughly 32x MORE work. The caps bound per-response work and the degrade is
the SIMPLE path, not the cheap one. No behaviour change.

The 1xx/trailer claim is narrowed rather than fixed. ModifyResponse gets the
FINAL response only, so a Carlos-Purge on a 103 or in a trailer is neither
honoured nor stripped. Building a sanitizing ResponseWriter would mean
wrapping the raw ResponseWriter the upgrade path deliberately passes through,
re-opening the WebSocket-breakage class this package has already paid for.
Both gaps are pinned by tests instead, and the contract now also warns that
naming Carlos-Purge in Connection gets it deleted as hop-by-hop BEFORE the
hook runs — silently eaten, also pinned.

Complaint logs adopt the package's errs==1 || errs%300==0 shape (per class,
per host): every one of those lines is driven by a remote party's response on
the request hot path.

Documented that purge-before-store holds per-response, not globally: an
in-flight response stored after a purge can resurrect a stale body, bounded
by the TTL ceiling. Pre-existing, same for the socket channel.

CARLOS_PURGE/CARLOS_CONTROL_SOCKET are now agent-owned in BOTH directions.
fileEnv is tenant-supplied (bucket <host>.config.env, instance <host>.env),
so gating only on "does the agent set it" let a tenant set it itself and
claim a channel that isn't there on a cacheless box. The nil-cache branch now
removes both — the control socket especially, since a tenant-chosen socket
path is a name the agent never vouched for.

go build/vet clean; go test ./... -count=1 all 15 packages ok; go test -race
./internal/edge ./internal/activator clean; gofmt clean on touched files.
Each fix mutation-checked red before green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Merge main (per-entry Vary): rename test callers of unexported Cache.get

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8bc975470ebab1a2d5c5077f2615a6b1347cf5ea parent 0ca02f6
6 files changed, +1821 −3
  • internal/activator/activator_test.go +172 −0
  • internal/activator/backend_exec.go +42 −1
  • internal/edge/cache.go +28 −0
  • internal/edge/edge.go +26 −2
  • internal/edge/purge_header.go +401 −0
  • internal/edge/purge_header_test.go +1152 −0
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index 92756add4..ddf337c06 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -2219,6 +2219,178 @@ func TestExecBackendStartMergesConfigEnvUnderOwnEnv(t *testing.T) {
}
}
+// CARLOS_PURGE=header is the advisory half of the response-header purge
+// channel (internal/edge/purge_header.go): it tells the app this box's edge
+// reads Carlos-Purge, so the app can log or branch on it. It is ADDITIVE —
+// CARLOS_CONTROL_SOCKET must still be there beside it, because the header is
+// an additional channel and not a replacement — and it must never be a CLI
+// flag, for the same flag.ExitOnError reason the control socket isn't.
+//
+// The gate matches CARLOS_CONTROL_SOCKET's (b.Cache != nil, i.e. "there is
+// actually a cache to purge"), which is the same no-inert-feature discipline:
+// a variable naming a channel must not be set on a box where that channel
+// does nothing.
+func TestExecBackendStartAdvertisesHeaderPurgeChannel(t *testing.T) {
+ b := testExecBackend(t)
+ b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+ host := "purge-header.example.com"
+ argvPath, envPath := stageRecordingLiveFullEnv(t, b, host, "v1")
+
+ rt := registry.Route{
+ Host: host,
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt) // health-wait error from the fake binary is expected and irrelevant here
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ child := map[string]string{}
+ for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") {
+ if k, v, ok := strings.Cut(line, "="); ok {
+ child[k] = v
+ }
+ }
+ if got := child["CARLOS_PURGE"]; got != "header" {
+ t.Fatalf("child CARLOS_PURGE = %q, want %q", got, "header")
+ }
+ if got := child["CARLOS_CONTROL_SOCKET"]; got != rt.Socket+".control" {
+ t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want %q — the header channel is ADDITIONAL, it does not replace the socket", got, rt.Socket+".control")
+ }
+ gotArgv := strings.Fields(waitForFile(t, argvPath, time.Second))
+ wantArgv := []string{"--socket", rt.Socket, "--db", filepath.Join(b.DataDir, rt.Host+".db")}
+ if !slices.Equal(gotArgv, wantArgv) {
+ t.Fatalf("child argv = %v, want %v — a flag the app binary doesn't parse makes it exit 2 at startup", gotArgv, wantArgv)
+ }
+}
+
+// The advisory var is gated on there being a cache at all: with none, there
+// is no purge channel to advertise and setting the var would be a lie.
+func TestExecBackendStartWithNilCacheSetsNoPurgeVar(t *testing.T) {
+ b := testExecBackend(t) // b.Cache left nil
+ host := "no-cache-purge.example.com"
+ _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1")
+
+ rt := registry.Route{
+ Host: host,
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") {
+ if k, _, ok := strings.Cut(line, "="); ok && k == "CARLOS_PURGE" {
+ t.Fatalf("child saw %q with no cache configured", line)
+ }
+ }
+}
+
+// TestExecBackendStartTenantCannotClaimAbsentPurgeChannel closes the hole the
+// gate alone left open. fileEnv is TENANT-supplied — <host>.config.env arrives
+// from the bucket via `carlos env`, <host>.env is the instance's own file — so
+// gating only on "does the agent SET the var" left a tenant free to set it
+// itself. On a box with no cache the child would then be told a purge channel
+// exists when none does, and an app that trusts CARLOS_PURGE would skip its
+// own invalidation and serve stale content indefinitely.
+//
+// CARLOS_CONTROL_SOCKET is the same hole and a worse one: a tenant-chosen
+// socket path is a name the agent never vouched for. Both are removed.
+func TestExecBackendStartTenantCannotClaimAbsentPurgeChannel(t *testing.T) {
+ b := testExecBackend(t) // b.Cache left nil — no purge channel on this box
+ host := "liar.example.com"
+ _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1")
+
+ // The tenant declares both channels in its own config.
+ tenantEnv := "CARLOS_PURGE=header\nCARLOS_CONTROL_SOCKET=/tmp/attacker.sock\nKEPT=1\n"
+ if err := os.WriteFile(filepath.Join(b.DataDir, host+".config.env"), []byte(tenantEnv), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ rt := registry.Route{
+ Host: host,
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ child := map[string]string{}
+ for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") {
+ if k, v, ok := strings.Cut(line, "="); ok {
+ child[k] = v
+ }
+ }
+ if got, ok := child["CARLOS_PURGE"]; ok {
+ t.Fatalf("tenant-supplied CARLOS_PURGE=%q survived onto a box with no cache", got)
+ }
+ if got, ok := child["CARLOS_CONTROL_SOCKET"]; ok {
+ t.Fatalf("tenant-supplied CARLOS_CONTROL_SOCKET=%q survived onto a box with no cache", got)
+ }
+ // Only the agent-owned names are stripped; ordinary tenant config is
+ // untouched.
+ if got := child["KEPT"]; got != "1" {
+ t.Fatalf("child KEPT = %q, want %q — only the agent-owned vars may be removed", got, "1")
+ }
+}
+
+// The other branch: with a cache, the agent's values must OVERRIDE whatever
+// the tenant supplied, not merely be absent-or-present. A tenant that writes
+// CARLOS_CONTROL_SOCKET=/tmp/attacker.sock must not have the child pointed at
+// a socket the agent did not create.
+func TestExecBackendStartOverridesTenantSuppliedPurgeVars(t *testing.T) {
+ b := testExecBackend(t)
+ b.Cache = edge.NewCache(edge.DefaultCacheMaxEntries, edge.DefaultCacheMaxBodyBytes)
+ host := "override.example.com"
+ _, envPath := stageRecordingLiveFullEnv(t, b, host, "v1")
+
+ tenantEnv := "CARLOS_PURGE=socket-only\nCARLOS_CONTROL_SOCKET=/tmp/attacker.sock\n"
+ if err := os.WriteFile(filepath.Join(b.DataDir, host+".config.env"), []byte(tenantEnv), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ rt := registry.Route{
+ Host: host,
+ Kind: "instance",
+ Socket: filepath.Join(t.TempDir(), "instance.sock"),
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+
+ child := map[string]string{}
+ for _, line := range strings.Split(waitForFile(t, envPath, time.Second), "\n") {
+ if k, v, ok := strings.Cut(line, "="); ok {
+ child[k] = v
+ }
+ }
+ if got := child["CARLOS_PURGE"]; got != "header" {
+ t.Fatalf("child CARLOS_PURGE = %q, want %q — the agent's value must win over the tenant's", got, "header")
+ }
+ if want := rt.Socket + ".control"; child["CARLOS_CONTROL_SOCKET"] != want {
+ t.Fatalf("child CARLOS_CONTROL_SOCKET = %q, want %q — a tenant must not redirect the control socket",
+ child["CARLOS_CONTROL_SOCKET"], want)
+ }
+}
+
// Spawning a fresh process for a host is a deploy boundary: the code behind
// the host just changed, so every response cached from the old process is
// stale. Start drops them all before the new process serves anything —
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index 68f6393bb..ace80a2e8 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -252,9 +252,34 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin
// Only set when there's actually a purge listener to talk to, so the
// child's environment stays honest about what exists.
controlSocket := rt.Socket + ".control"
+ // Both purge-channel vars are AGENT-OWNED in BOTH directions: set when the
+ // channel exists, and REMOVED when it does not. The removal half is not
+ // decoration. fileEnv is tenant-supplied — <host>.config.env comes from the
+ // bucket via `carlos env`, <host>.env is the instance's own file — so
+ // without the else branch a tenant could put CARLOS_PURGE=header (or worse,
+ // CARLOS_CONTROL_SOCKET=/some/path) in its own config and have it survive
+ // into the child on a box with NO cache. The app would then be told a purge
+ // channel exists when none does: an app that trusts the var skips its own
+ // invalidation and serves stale content forever, and a socket path a tenant
+ // chose is a name the agent never vouched for.
+ //
+ // "A variable naming a channel must not be set where that channel does
+ // nothing" only holds if the agent is the sole author of those names.
+ //
+ // CARLOS_PURGE is ADVISORY: it tells the app that this box's edge reads the
+ // Carlos-Purge response header (internal/edge/purge_header.go), so the app
+ // can log or branch on it — but the channel works whether or not the app
+ // ever reads the variable, because a header the edge understands is inert
+ // when the app never emits it and honoured when it does. That is what makes
+ // the rollout ordering harmless. It does not replace the socket channel;
+ // both are live for exec-backed instances, and converging them is deferred
+ // (spec §10.3).
childVars := fileEnv
if b.Cache != nil {
- childVars = withVar(fileEnv, "CARLOS_CONTROL_SOCKET", controlSocket)
+ childVars = withVar(childVars, "CARLOS_CONTROL_SOCKET", controlSocket)
+ childVars = withVar(childVars, "CARLOS_PURGE", "header")
+ } else {
+ childVars = withoutVars(childVars, "CARLOS_CONTROL_SOCKET", "CARLOS_PURGE")
}
cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath)
cmd.Env = childEnv(childVars)
@@ -340,6 +365,22 @@ func withVar(env map[string]string, key, value string) map[string]string {
return out
}
+// withoutVars returns a copy of env with keys removed — the mirror of withVar,
+// and the half that makes an agent-owned variable actually agent-owned. Absent
+// it, "the agent decides whether this var is set" is only true when the agent
+// sets it; a tenant supplying the same name in its own env file wins by
+// default. Same copy discipline as withVar: the caller's map is never mutated.
+func withoutVars(env map[string]string, keys ...string) map[string]string {
+ out := make(map[string]string, len(env))
+ for k, v := range env {
+ out[k] = v
+ }
+ for _, k := range keys {
+ delete(out, k)
+ }
+ return out
+}
+
// mergeEnv composes the child's env-file layers: base (the sync-owned
// <host>.config.env, bucket-derived) under overlay (the instance's own
// <host>.env). The overlay wins every conflict — CARLOS_ADMIN_TOKEN
diff --git a/internal/edge/cache.go b/internal/edge/cache.go
index 0769b7ede..630a183c8 100644
--- a/internal/edge/cache.go
+++ b/internal/edge/cache.go
@@ -409,6 +409,34 @@ func (c *Cache) PurgeURL(host, path, rawQuery string) int {
return n
}
+// PurgePath drops every cached entry for (host, path) REGARDLESS of query
+// string — every query variant and every Vary variant of each, for both GET
+// and HEAD — and returns how many it removed.
+//
+// This is the primitive behind a bare-path Carlos-Purge target. PurgeURL
+// needs the exact RawQuery, so an app that wrote to /events/42 and purged
+// "/events/42" left /events/42?utm_source=newsletter serving the pre-write
+// body until its TTL ran out — the cached copies a campaign or a locale
+// switcher created are exactly the ones a reader is most likely to land on.
+//
+// It rides the byPath index, which already groups entries by
+// (Host, Method, Path) for the per-path admission cap, so this costs two map
+// lookups rather than a scan. Both method keys are purged for the same reason
+// PurgeURL does it: a write invalidates the GET and the HEAD alike.
+func (c *Cache) PurgePath(host, path string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ n := 0
+ for _, method := range [...]string{"GET", "HEAD"} {
+ pk := cachePathKey{Host: host, Method: method, Path: path}
+ for _, el := range elementsOf(c.byPath[pk]) {
+ c.removeLocked(el)
+ n++
+ }
+ }
+ return n
+}
+
// PurgeHost drops every cached entry for host — all paths, all query strings,
// all Vary variants — and returns how many it removed. This is what a deploy
// calls: a fresh process for a host means new code and potentially new
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 28a182909..9a723b0a2 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -33,6 +33,7 @@ import (
"slices"
"strconv"
"strings"
+ "sync"
"sync/atomic"
"syscall"
"time"
@@ -102,6 +103,12 @@ type Edge struct {
// invoking the hibernation activator or the backend — the mitigation
// this whole feature exists for. nil disables caching entirely.
cache *Cache
+ // purgeLogN counts Carlos-Purge complaints per (class, host) so
+ // logPurgeIssue can rate-limit them; those lines are driven by app
+ // responses on the request hot path and would otherwise be one journal
+ // line per request. Lazily created under purgeLogMu.
+ purgeLogMu sync.Mutex
+ purgeLogN map[string]int
}
// upstream is one served host: the handler for its backend, plus — when the
@@ -206,10 +213,25 @@ func (e *Edge) Reload() error {
}
var proxy http.Handler
switch {
+ // Both reverse proxies get the Carlos-Purge response hook
+ // (purge_header.go): the app declares its own host's cache
+ // invalidations on the response it is already returning, and the edge
+ // applies them and strips the header before any client sees it. The
+ // hook closes over e rather than e.cache, because EnableCache runs
+ // AFTER New's startup Reload — capturing e.cache here would capture
+ // nil and the channel would be permanently inert.
+ //
+ // Static and status routes get no hook: their responses are the
+ // edge's own, built from bucket bytes by static.go/status.go, with no
+ // upstream in a position to declare anything.
case t.Socket != "":
- proxy = unixProxy(t.Socket)
+ p := unixProxy(t.Socket)
+ p.ModifyResponse = e.purgeModifier(t.Host)
+ proxy = p
case t.Addr != "":
- proxy = tcpProxy(t.Addr)
+ p := tcpProxy(t.Addr)
+ p.ModifyResponse = e.purgeModifier(t.Host)
+ proxy = p
case t.Kind == "static":
proxy = e.staticHandlerFor(t)
case t.Kind == "status":
@@ -714,6 +736,7 @@ func unixProxy(socketPath string) *httputil.ReverseProxy {
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = "carlos" // placeholder; the unix dialer ignores it
pr.Out.Host = pr.In.Host // backend sees the real hostname
+ scrubInboundPurgeHeader(pr)
},
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -738,6 +761,7 @@ func tcpProxy(addr string) *httputil.ReverseProxy {
pr.Out.URL.Scheme = "http"
pr.Out.URL.Host = addr
pr.Out.Host = pr.In.Host // backend sees the real hostname
+ scrubInboundPurgeHeader(pr)
},
Transport: &http.Transport{
MaxIdleConns: 32,
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go
new file mode 100644
index 000000000..a25f73d6a
--- /dev/null
+++ b/internal/edge/purge_header.go
@@ -0,0 +1,401 @@
+package edge
+
+import (
+ "fmt"
+ "log"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "strings"
+)
+
+// PurgeHeader is the response header an app sets to invalidate its OWN
+// cached entries — the response-header purge channel from
+// docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md §4(e)/§6.1,
+// the follow-on to the cache spec's §3 "local purge channel".
+//
+// The app signals the purge on the response it is already returning from the
+// write:
+//
+// HTTP/1.1 302 Found
+// Location: /events/42
+// Carlos-Purge: /events/42, /events/42?lang=fr
+//
+// Why a header rather than a socket, a port, or a token: the tenant-isolation
+// invariant ("a tenant may purge only its own host's entries") holds here by
+// CONSTRUCTION, not by permission bookkeeping. The host is the one the edge
+// already resolved this request to out of its own routing table — the header
+// carries no host field and the edge never accepts one, so there is no field
+// in which one tenant could address another. There is also nothing to reach:
+// no socket, no port, no path, no secret, so "which tenants can reach this
+// channel" has no answer beyond "each tenant's own responses." That closes
+// the reachability half of the invariant, which the exec-backed control
+// socket (purge.go) explicitly does NOT — see its same-uid sibling note — and
+// which a DynamicUser unit tenant could never reach at all.
+//
+// This is the Surrogate-Key/Surrogate-Control (Fastly, Varnish) and
+// X-Accel-* (nginx) family: an upstream steering its own cache through
+// response metadata.
+//
+// It is an ADDITIONAL channel, not a replacement: CARLOS_CONTROL_SOCKET and
+// ListenPurge are unchanged for exec-backed instances and the two coexist.
+//
+// # The contract, precisely
+//
+// - RESPONSE ONLY, and only the FINAL response. The edge reads and strips
+// the header off the final response's header block. 1xx informational
+// responses (103 Early Hints) and HTTP trailers are neither honoured nor
+// stripped — see purgeModifier's note. An app must declare its purges in
+// final-response headers, and nowhere else.
+// - An app must NEVER name Carlos-Purge in the response's Connection
+// header. That marks it hop-by-hop, and net/http/httputil deletes
+// hop-by-hop headers BEFORE ModifyResponse runs — the declaration is
+// silently eaten and nothing is purged. Pinned by
+// TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook.
+// - It is never read from a REQUEST, and is scrubbed off every proxied
+// request before the app sees it (scrubInboundPurgeHeader).
+const PurgeHeader = "Carlos-Purge"
+
+// Bounds on one response's purge declaration. Beyond either, the edge
+// degrades to a whole-host purge rather than partially applying — the
+// failure direction is deliberately OVER-purge, never under-purge, because
+// an extra re-fetch is cheap and the parent spec's decision 3 puts freshness
+// above cleverness.
+//
+// They exist to bound the WORK one response can demand on the request hot
+// path: without them a single response could name a million targets and drive
+// a million locked cache operations before the client gets its bytes.
+//
+// Degrading is NOT the cheap path — it is the SIMPLE one, and it is more
+// expensive than honouring the declaration. Honouring 64 targets is 128 map
+// lookups (each target hits the GET and HEAD index groups); PurgeHost takes
+// the same global lock and scans EVERY entry in the cache, which at
+// DefaultCacheMaxEntries (4096) is an order of magnitude more work — roughly
+// 32x at a full cache, and worse the fuller the cache is. So an app gains
+// nothing by exceeding the bound except a bigger blast radius on its own
+// cache; the bound is a cliff in behaviour (targeted -> whole host), not an
+// invitation. What the caps genuinely buy is that the per-response cost has a
+// CEILING that does not scale with the header's length — one host scan
+// instead of unbounded lookups.
+//
+// 64 targets is well above what a real write invalidates (a page, its
+// paginated index, a couple of locale variants). 8 KiB bounds the joined
+// header itself, since 64 targets says nothing about how long each one is.
+const (
+ maxPurgeTargets = 64
+ maxPurgeHeaderBytes = 8 << 10 // 8 KiB
+)
+
+// scrubInboundPurgeHeader removes Carlos-Purge from a request on its way to
+// the app. Both proxy Rewrite funcs call it (unixProxy and tcpProxy have
+// separate ones, so this must be applied to each).
+//
+// The edge already ignores a request-borne Carlos-Purge — it only ever reads
+// the header off responses — so this is not about the edge being fooled. It
+// is about the APP being fooled into laundering it. An app that reflects
+// request headers onto its response (a debug echo, a framework's pass-through
+// list, a CORS mirror) would turn a client-supplied Carlos-Purge into an
+// app-DECLARED one, and app-declared ones are honoured. That would hand every
+// client on the internet the purge channel this design exists to keep
+// unreachable, and it would do it through a bug in the tenant's code that the
+// edge cannot audit. Deleting the header on the way in makes the laundering
+// impossible regardless of how any app behaves.
+func scrubInboundPurgeHeader(pr *httputil.ProxyRequest) {
+ pr.Out.Header.Del(PurgeHeader)
+}
+
+// purgeModifier builds the httputil.ReverseProxy ModifyResponse hook for one
+// route: it applies that response's declared purges and strips the header.
+//
+// ModifyResponse is the ONLY correct seam for this, and deliberately so:
+//
+// - It runs before ReverseProxy copies any header onto the real
+// ResponseWriter, so the strip is what makes "no client ever sees it"
+// true — including on the upgrade path, where the stdlib calls
+// ModifyResponse on a 101 before handing the connection to
+// handleUpgradeResponse.
+// - It runs before the response reaches recordingResponseWriter at all, so
+// it is strictly BEFORE maybeStore for the same response. A cacheable GET
+// that also purges therefore stores its fresh self rather than purging
+// what it just stored, and the stripped header can never be captured into
+// a stored response's headers.
+// - Every proxied path in Edge.ServeHTTP — the upgrade bypass, the
+// hibernation/activator branch (the activator calls this very proxy), the
+// cacheable GET branch, and the plain pass-through — funnels through this
+// one ReverseProxy. Hooking anywhere in ServeHTTP itself would have to be
+// repeated four times and would already be too late to strip.
+//
+// # What this seam does NOT cover
+//
+// ModifyResponse is handed the FINAL response only. Two header blocks
+// therefore pass through the edge without ever reaching this code, and the
+// contract states plainly that neither is a purge channel:
+//
+// - 1xx INFORMATIONAL responses. A 103 Early Hints block is forwarded to the
+// client by the stdlib's 1xx path; ModifyResponse is not called for it. A
+// Carlos-Purge on a 103 is therefore neither honoured nor stripped, and
+// the client MAY see it.
+// - TRAILERS. A header announced via Trailer and sent after the body is not
+// in resp.Header when this runs, so it is likewise neither honoured nor
+// stripped.
+//
+// Both are pinned by tests rather than fixed. Fixing them would mean wrapping
+// the client's ResponseWriter to filter 1xx and trailer writes, and the
+// upgrade path deliberately hands the RAW ResponseWriter to the stdlib — a
+// wrapper there re-opens the WebSocket-breakage class this package has
+// already paid for. Narrowing the promise is the honest trade: apps declare
+// purges in final-response headers, which is where every app already puts
+// them.
+//
+// # Ordering is per-response, not global
+//
+// "Purge strictly before store" holds for THIS response and no other. A
+// different request for the same URL that is already in flight — its response
+// received from the app, not yet stored — will still be stored AFTER this
+// purge runs, resurrecting a body that predates the write. The window is the
+// span between an in-flight response leaving the app and maybeStore running,
+// and the staleness it can reintroduce is bounded by the entry's TTL ceiling
+// rather than eliminated. This is pre-existing and identical for purge.go's
+// socket channel; a fix would need store-time fencing (a per-URL purge
+// generation counter checked in Put), which is deferred.
+//
+// The purge is honoured on ANY status and ANY method: a write is typically a
+// POST, which never enters the GET/HEAD caching path, and a POST-redirect-GET
+// signals on its 302. This is deliberately decoupled from cacheability, which
+// stays 2xx-only.
+//
+// host is the route's own host — the key Edge.ServeHTTP resolved r.Host to in
+// the upstream map, and therefore byte-identical to the Host component of
+// every cache key this request could touch. Taking it from the routing table
+// rather than re-reading it off the proxied response means no header, on
+// either side, can influence which host gets purged.
+func (e *Edge) purgeModifier(host string) func(*http.Response) error {
+ return func(resp *http.Response) error {
+ e.applyPurgeHeader(host, resp.Header)
+ return nil
+ }
+}
+
+// applyPurgeHeader honours and then removes h's Carlos-Purge lines.
+//
+// The strip is unconditional — before the cache nil-check, before parsing,
+// and even when the value is unusable. Carlos-Purge is an edge-internal
+// signal; a box with caching disabled must not forward it to clients any more
+// than a box with a cache does.
+func (e *Edge) applyPurgeHeader(host string, h http.Header) {
+ // joinHeader, never h.Get: Carlos-Purge is legitimately list-valued and
+ // may arrive on several lines (RFC 9110 §5.3 — one comma-joined line and
+ // several lines mean the same thing, and http.Header.Add produces the
+ // latter). h.Get returns only the FIRST line, which is the exact bug this
+ // package has paid for on Cache-Control, Vary, and Connection. Here it
+ // would silently drop every target after the first line — an under-purge,
+ // i.e. a stale page nobody notices until a customer does.
+ joined := joinHeader(h, PurgeHeader)
+ h.Del(PurgeHeader)
+ if joined == "" || e.cache == nil {
+ return
+ }
+ e.applyPurgeTargets(host, joined)
+}
+
+// applyPurgeTargets parses one response's joined Carlos-Purge value and
+// applies it to host's entries.
+func (e *Edge) applyPurgeTargets(host, joined string) {
+ if len(joined) > maxPurgeHeaderBytes {
+ e.logPurgeIssue("oversize", host, fmt.Sprintf(
+ "%s declared a %d-byte Carlos-Purge (max %d) — purging the whole host instead",
+ host, len(joined), maxPurgeHeaderBytes))
+ e.cache.PurgeHost(host)
+ return
+ }
+ targets := make([]string, 0, 8)
+ for _, t := range strings.Split(joined, ",") {
+ if t = strings.TrimSpace(t); t != "" {
+ targets = append(targets, t)
+ }
+ }
+ if len(targets) > maxPurgeTargets {
+ e.logPurgeIssue("count", host, fmt.Sprintf(
+ "%s declared %d Carlos-Purge targets (max %d) — purging the whole host instead",
+ host, len(targets), maxPurgeTargets))
+ e.cache.PurgeHost(host)
+ return
+ }
+ rejected, firstBad := 0, ""
+ for _, t := range targets {
+ if t == "*" {
+ // "every cache entry for this host" — and it subsumes any other
+ // target in the same declaration, so there is nothing left to do.
+ e.cache.PurgeHost(host)
+ return
+ }
+ path, rawQuery, exact, ok := purgeTargetKey(t)
+ if !ok {
+ rejected++
+ if firstBad == "" {
+ firstBad = t
+ }
+ continue
+ }
+ if exact {
+ e.cache.PurgeURL(host, path, rawQuery)
+ } else {
+ e.cache.PurgePath(host, path)
+ }
+ }
+ if rejected > 0 {
+ // One line per response, not one per target: a hostile app could
+ // otherwise turn every request into 64 log lines. The count is the
+ // signal; the first offender is the diagnostic. %q quotes any control
+ // bytes rather than emitting them into the journal.
+ e.logPurgeIssue("malformed", host, fmt.Sprintf(
+ "%s: ignored %d malformed Carlos-Purge target(s), first %q — a target must be an absolute path or \"*\", and may never carry a host",
+ host, rejected, truncateForLog(firstBad)))
+ }
+}
+
+// logPurgeIssue rate-limits one class of purge complaint for one host, using
+// the same first-then-every-300th shape as the registry watcher in edge.go.
+//
+// Every one of these lines is triggered by a REMOTE party — the app's own
+// response — on the request hot path, so an app that emits a malformed
+// declaration on every response would otherwise write one journal line per
+// request, indefinitely. That is a disk-fill and a log-noise problem at the
+// same time, and it drowns the one occurrence an operator needed to see. The
+// first is always logged (nothing is silently swallowed) and the running
+// count travels with each line, so the rate is visible in the journal.
+//
+// Counters are per (class, host). Hosts come from the edge's own routing
+// table, never from the header, so the map is bounded by the number of routes
+// on the box and cannot be grown by a remote party.
+func (e *Edge) logPurgeIssue(kind, host, msg string) {
+ e.purgeLogMu.Lock()
+ if e.purgeLogN == nil {
+ e.purgeLogN = make(map[string]int)
+ }
+ key := kind + " " + host
+ e.purgeLogN[key]++
+ n := e.purgeLogN[key]
+ e.purgeLogMu.Unlock()
+ if n == 1 || n%300 == 0 {
+ log.Printf("edge: %s (occurrence #%d)", msg, n)
+ }
+}
+
+// purgeTargetKey turns one declared target into the cache-key components it
+// addresses, or reports ok=false to refuse it outright.
+//
+// exact distinguishes the two forms the contract defines:
+//
+// - "/events/42" (NO "?") addresses the PATH: every cached entry under it is
+// purged regardless of query string. This is what an app almost always
+// means — after a write to /events/42, the cached /events/42?utm_source=x
+// that a campaign link populated is just as stale as the bare one, and
+// leaving it served the old body until its TTL expired. exact=false.
+// - "/events/42?lang=fr" (WITH "?") addresses that one variant exactly, for
+// an app that really does mean "only the French one". exact=true.
+// - "/events/42?" is the empty-query variant, exactly: a "?" was written, so
+// the target is exact and RawQuery is "". It purges the entry stored for a
+// request to "/events/42" and no other. The bare form is the way to ask
+// for "all of them"; this is the way to ask for "just the unqueried one".
+//
+// # Percent-encoding: the path is DECODED, the query is RAW
+//
+// This asymmetry is not a choice, it is what the store side does, and purge
+// must key identically or it silently purges nothing. edge.go builds every
+// cache key as:
+//
+// CacheKey{..., Path: r.URL.Path, RawQuery: r.URL.RawQuery}
+//
+// net/url's URL.Path is the DECODED path ("/caf%C3%A9" on the wire is stored
+// under "/café"), while URL.RawQuery is the query byte-for-byte as received,
+// never decoded. So a declared target is PathUnescape'd on its path half and
+// left verbatim on its query half. A target declaring "?q=caf%C3%A9" matches
+// an entry stored from a request whose query was literally "q=caf%C3%A9", and
+// does NOT match one whose query was "q=café" — raw compares to raw.
+//
+// A path that will not decode (a truncated "%A" or "%zz") is refused, not
+// guessed at: net/http would have rejected the same bytes in a request line,
+// so no cache entry can exist under them anyway.
+//
+// The decoded bytes are re-checked, because decoding can manufacture exactly
+// the shapes the raw gate refuses — "/%2F%2Fhost" decodes to "//host" and
+// "/%5Chost" to "/\host". One difference, deliberately: a decoded SPACE is
+// allowed where a raw one is refused. A raw space in a header value means a
+// malformed declaration, but %20 decodes to a real space that r.URL.Path
+// genuinely contains, so refusing it would make "/a b" permanently
+// unpurgeable — an under-purge, which is the failure direction this design
+// refuses to take.
+func purgeTargetKey(t string) (path, rawQuery string, exact, ok bool) {
+ if !validPurgeTarget(t) {
+ return "", "", false, false
+ }
+ rawPath, rawQuery, exact := strings.Cut(t, "?")
+ path, err := url.PathUnescape(rawPath)
+ if err != nil || !validDecodedPurgePath(path) {
+ return "", "", false, false
+ }
+ return path, rawQuery, exact, true
+}
+
+// validPurgeTarget reports whether t is an acceptable purge target: an
+// absolute-path URL with an optional query string.
+//
+// It is a REFUSAL gate, not a normaliser. A target that is absolute-URL-shaped
+// ("https://a.example/x"), authority-shaped ("a.example/x"), or
+// protocol-relative ("//a.example/x", and "/\a.example/x" — browsers and
+// several URL parsers treat a backslash as a slash, so it is the same
+// smuggle) is ignored outright and nothing is purged for it. It is never
+// stripped down to its path and honoured, because "purge /x" and "purge
+// a.example/x" are different requests and the second one is not a request
+// this channel accepts.
+//
+// Bytes at or below SP, and DEL, are refused wholesale: a real path is
+// percent-encoded, so a raw space or control byte in a target means the value
+// is malformed, and refusing them keeps anything log-injection-shaped out of
+// the honoured set. This gate runs on the target AS DECLARED; purgeTargetKey
+// re-checks the percent-decoded path with validDecodedPurgePath.
+func validPurgeTarget(t string) bool {
+ if t == "" || t[0] != '/' {
+ return false
+ }
+ if len(t) > 1 && (t[1] == '/' || t[1] == '\\') {
+ return false
+ }
+ for i := 0; i < len(t); i++ {
+ if c := t[i]; c <= ' ' || c == 0x7f {
+ return false
+ }
+ }
+ return true
+}
+
+// validDecodedPurgePath re-applies the shape gate to a percent-DECODED path.
+// Same refusals as validPurgeTarget with one exception: SP (0x20) is allowed
+// here, because a decoded space is a legitimate byte of r.URL.Path (see
+// purgeTargetKey). Control bytes and DEL stay refused — "%00", "%0a" and
+// friends must not reach the honoured set or the journal.
+func validDecodedPurgePath(p string) bool {
+ if p == "" || p[0] != '/' {
+ return false
+ }
+ if len(p) > 1 && (p[1] == '/' || p[1] == '\\') {
+ return false
+ }
+ for i := 0; i < len(p); i++ {
+ if c := p[i]; c < ' ' || c == 0x7f {
+ return false
+ }
+ }
+ return true
+}
+
+// truncateForLog bounds one logged target so an app cannot write an 8 KiB
+// line into the journal per request.
+func truncateForLog(s string) string {
+ const max = 128
+ if len(s) <= max {
+ return s
+ }
+ return s[:max] + "…"
+}
diff --git a/internal/edge/purge_header_test.go b/internal/edge/purge_header_test.go
new file mode 100644
index 000000000..1b8879f02
--- /dev/null
+++ b/internal/edge/purge_header_test.go
@@ -0,0 +1,1152 @@
+package edge
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/http/httptrace"
+ "net/textproto"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// purgeEdge builds an edge with a cache in front of one addr-backed route for
+// host, whose upstream runs h. hibernate=false is the direct-proxy shape (a
+// plain `routed` app behind the edge — tito production), true routes the
+// request through the activator instead.
+func purgeEdge(t *testing.T, host string, hibernate bool, h http.HandlerFunc) (*Edge, *registry.Registry) {
+ t.Helper()
+ backend := httptest.NewServer(h)
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ rt := registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: hibernate}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ var act Activator
+ if hibernate {
+ act = &fakeActivator{}
+ }
+ e, err := New(reg, act)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ // EnableCache deliberately AFTER New (which already ran Reload) — the
+ // production ordering in RunWithActivator. A purge hook that captured
+ // e.cache at reload time would capture nil and never purge anything.
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ return e, reg
+}
+
+// seed puts a trivially-cacheable entry for one URL so a purge has something
+// to remove.
+func seed(t *testing.T, c *Cache, host, path, rawQuery, body string) {
+ t.Helper()
+ c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery}, "", &CachedResponse{
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": {"text/html"}},
+ Body: []byte(body),
+ StoredAt: time.Now(),
+ TTL: time.Minute,
+ })
+}
+
+func cached(c *Cache, host, path, rawQuery string) bool {
+ _, ok := c.get(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery})
+ return ok
+}
+
+// TestPurgeHeaderOnPOSTPurgesAndIsStripped is the headline contract: a write
+// (POST — never a cacheable method, so it never touches the GET/HEAD caching
+// path at all) purges the URLs it names, and the client never sees the header.
+func TestPurgeHeaderOnPOSTPurgesAndIsStripped(t *testing.T) {
+ e, _ := purgeEdge(t, "w.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/events/42, /events/42?lang=fr")
+ w.Header().Set("Location", "/events/42")
+ w.WriteHeader(http.StatusFound)
+ })
+ seed(t, e.cache, "w.example.com", "/events/42", "", "old")
+ seed(t, e.cache, "w.example.com", "/events/42", "lang=fr", "vieux")
+ seed(t, e.cache, "w.example.com", "/events/43", "", "untouched")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://w.example.com/events/42/edit", strings.NewReader("x=1")))
+
+ if rec.Code != http.StatusFound {
+ t.Fatalf("status = %d, want 302", rec.Code)
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+ if cached(e.cache, "w.example.com", "/events/42", "") {
+ t.Fatal("/events/42 still cached after purge")
+ }
+ if cached(e.cache, "w.example.com", "/events/42", "lang=fr") {
+ t.Fatal("/events/42?lang=fr still cached after purge")
+ }
+ if !cached(e.cache, "w.example.com", "/events/43", "") {
+ t.Fatal("/events/43 was purged but nothing named it")
+ }
+}
+
+// TestPurgeHeaderOnDirectProxyPath pins the shape this feature exists for:
+// an addr-backed, NON-hibernating route (tito production) — no activator in
+// the picture at all.
+func TestPurgeHeaderOnDirectProxyPath(t *testing.T) {
+ e, _ := purgeEdge(t, "tito.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/tickets")
+ io.WriteString(w, "ok")
+ })
+ seed(t, e.cache, "tito.example.com", "/tickets", "", "old")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://tito.example.com/tickets/buy", nil))
+
+ if cached(e.cache, "tito.example.com", "/tickets", "") {
+ t.Fatal("direct-proxy path did not honour Carlos-Purge")
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+}
+
+// TestPurgeHeaderOnHibernatingPath proves the same on the activator path,
+// where the activator wraps the very same proxy.
+func TestPurgeHeaderOnHibernatingPath(t *testing.T) {
+ e, _ := purgeEdge(t, "hib.example.com", true, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/tickets")
+ io.WriteString(w, "ok")
+ })
+ seed(t, e.cache, "hib.example.com", "/tickets", "", "old")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://hib.example.com/tickets/buy", nil))
+
+ if cached(e.cache, "hib.example.com", "/tickets", "") {
+ t.Fatal("hibernating/activator path did not honour Carlos-Purge")
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+}
+
+// TestPurgeAppliesBeforeStore is the ordering regression: a cacheable GET
+// whose own response purges its own URL must end with the NEW response
+// cached, not with an empty entry. Purge runs in ModifyResponse, strictly
+// before maybeStore sees the response — reverse the two and this GET purges
+// exactly what it just stored, so the URL can never stay cached and a
+// hibernating route wakes on every single request.
+//
+// The URL is deliberately NOT pre-seeded: a seeded entry would be a cache hit
+// and the request would never reach the backend at all.
+func TestPurgeAppliesBeforeStore(t *testing.T) {
+ e, _ := purgeEdge(t, "order.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/page, /index")
+ w.Header().Set("Cache-Control", "max-age=60")
+ io.WriteString(w, "fresh")
+ })
+ seed(t, e.cache, "order.example.com", "/index", "", "stale index")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://order.example.com/page", nil))
+ if rec.Body.String() != "fresh" {
+ t.Fatalf("body = %q, want %q", rec.Body.String(), "fresh")
+ }
+
+ got, ok := e.cache.get(CacheKey{Host: "order.example.com", Method: "GET", Path: "/page"})
+ if !ok {
+ t.Fatal("nothing cached: the purge ran AFTER maybeStore and deleted what it just stored")
+ }
+ if string(got.Body) != "fresh" {
+ t.Fatalf("cached body = %q, want %q", got.Body, "fresh")
+ }
+ if v := got.Header.Get("Carlos-Purge"); v != "" {
+ t.Fatalf("Carlos-Purge was stored in the cached response: %q", v)
+ }
+ // The other target in the same declaration really was purged — proving
+ // the entry above survived because of ordering, not because the purge
+ // silently did nothing.
+ if cached(e.cache, "order.example.com", "/index", "") {
+ t.Fatal("/index survived: the purge did not run at all")
+ }
+}
+
+// TestPurgeHeaderStrippedFromCachedReplay — the second half of "the client
+// never sees it": a response stored and later replayed from cache must not
+// carry the header either.
+func TestPurgeHeaderStrippedFromCachedReplay(t *testing.T) {
+ e, _ := purgeEdge(t, "replay.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/other")
+ w.Header().Set("Cache-Control", "max-age=60")
+ io.WriteString(w, "page")
+ })
+
+ rec1 := httptest.NewRecorder()
+ e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://replay.example.com/page", nil))
+ if got := rec1.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("pass-through response carried Carlos-Purge: %q", got)
+ }
+ rec2 := httptest.NewRecorder()
+ e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://replay.example.com/page", nil))
+ if rec2.Body.String() != "page" {
+ t.Fatalf("second request body = %q, want a cache hit of %q", rec2.Body.String(), "page")
+ }
+ if got := rec2.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("cached replay carried Carlos-Purge: %q", got)
+ }
+}
+
+// TestPurgeHeaderStarPurgesHost — `*` maps to Cache.PurgeHost, and only for
+// the host being served.
+func TestPurgeHeaderStarPurgesHost(t *testing.T) {
+ e, _ := purgeEdge(t, "star.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "*")
+ w.WriteHeader(http.StatusNoContent)
+ })
+ seed(t, e.cache, "star.example.com", "/a", "", "a")
+ seed(t, e.cache, "star.example.com", "/b", "x=1", "b")
+ seed(t, e.cache, "other.example.com", "/a", "", "other")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://star.example.com/deploy", nil))
+
+ if cached(e.cache, "star.example.com", "/a", "") || cached(e.cache, "star.example.com", "/b", "x=1") {
+ t.Fatal("* did not purge the whole host")
+ }
+ if !cached(e.cache, "other.example.com", "/a", "") {
+ t.Fatal("* purged another host's entries")
+ }
+}
+
+// TestPurgeHeaderCannotTouchAnotherHost — the isolation half. Every shape
+// that smuggles an authority must be IGNORED, never reinterpreted: neither
+// the named foreign host's entry nor the serving host's same-path entry may
+// be touched by them.
+func TestPurgeHeaderCannotTouchAnotherHost(t *testing.T) {
+ smugglers := []string{
+ "https://a.example.com/x",
+ "http://a.example.com/x",
+ "//a.example.com/x",
+ `/\a.example.com/x`,
+ "a.example.com/x",
+ "x",
+ "",
+ "a.example.com",
+ "*.a.example.com",
+ }
+ for _, target := range smugglers {
+ t.Run(fmt.Sprintf("%q", target), func(t *testing.T) {
+ e, _ := purgeEdge(t, "b.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", target)
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "host A")
+ seed(t, e.cache, "b.example.com", "/x", "", "host B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://b.example.com/write", nil))
+
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatalf("target %q purged another host's entry", target)
+ }
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatalf("target %q was reinterpreted as the path /x on the serving host", target)
+ }
+ })
+ }
+}
+
+// TestPurgeHeaderMixedValidAndSmuggled — a rejected target does not stop the
+// valid ones beside it from being honoured.
+func TestPurgeHeaderMixedValidAndSmuggled(t *testing.T) {
+ e, _ := purgeEdge(t, "mix.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "https://a.example.com/x, /good, //a.example.com/y")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A x")
+ seed(t, e.cache, "a.example.com", "/y", "", "A y")
+ seed(t, e.cache, "mix.example.com", "/good", "", "good")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://mix.example.com/write", nil))
+
+ if cached(e.cache, "mix.example.com", "/good", "") {
+ t.Fatal("/good was not purged: one bad target discarded the whole header")
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") || !cached(e.cache, "a.example.com", "/y", "") {
+ t.Fatal("a smuggled target reached another host")
+ }
+}
+
+// TestListValuedPurgeHeader — the fourth instance of this package's
+// list-valued-header bug class, caught before merge this time. The header
+// split across two Add() calls must be fully honoured.
+func TestListValuedPurgeHeader(t *testing.T) {
+ e, _ := purgeEdge(t, "list.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Carlos-Purge", "/a")
+ w.Header().Add("Carlos-Purge", "/b, /c")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "list.example.com", "/a", "", "a")
+ seed(t, e.cache, "list.example.com", "/b", "", "b")
+ seed(t, e.cache, "list.example.com", "/c", "", "c")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list.example.com/write", nil))
+
+ for _, p := range []string{"/a", "/b", "/c"} {
+ if cached(e.cache, "list.example.com", p, "") {
+ t.Fatalf("%s survived: only the first Carlos-Purge line was read", p)
+ }
+ }
+ if got := rec.Header().Values("Carlos-Purge"); len(got) != 0 {
+ t.Fatalf("Carlos-Purge lines reached the client: %v", got)
+ }
+}
+
+// TestListValuedPurgeHeaderStarOnSecondLine — `*` hiding on a later line
+// still purges the whole host.
+func TestListValuedPurgeHeaderStarOnSecondLine(t *testing.T) {
+ e, _ := purgeEdge(t, "list2.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Add("Carlos-Purge", "/a")
+ w.Header().Add("Carlos-Purge", "*")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "list2.example.com", "/a", "", "a")
+ seed(t, e.cache, "list2.example.com", "/z", "", "z")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list2.example.com/write", nil))
+ _ = rec
+
+ if cached(e.cache, "list2.example.com", "/z", "") {
+ t.Fatal("* on the second header line was not honoured")
+ }
+}
+
+// TestPurgeHeaderNonCanonicalCase — Go canonicalizes header names off the
+// wire, so an app writing the lowercase form still lands on Carlos-Purge.
+func TestPurgeHeaderNonCanonicalCase(t *testing.T) {
+ e, _ := purgeEdge(t, "case.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header()["carlos-purge"] = []string{"/a"}
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "case.example.com", "/a", "", "a")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://case.example.com/write", nil))
+
+ if cached(e.cache, "case.example.com", "/a", "") {
+ t.Fatal("lowercase carlos-purge was not honoured")
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+}
+
+// TestPurgeHeaderEmptyAndWhitespaceValues — an empty header, and a header of
+// nothing but separators and spaces, purge nothing and break nothing.
+func TestPurgeHeaderEmptyAndWhitespaceValues(t *testing.T) {
+ for _, value := range []string{"", " ", ",,,", " , , "} {
+ t.Run(fmt.Sprintf("%q", value), func(t *testing.T) {
+ e, _ := purgeEdge(t, "empty.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", value)
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "empty.example.com", "/a", "", "a")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://empty.example.com/write", nil))
+
+ if !cached(e.cache, "empty.example.com", "/a", "") {
+ t.Fatalf("value %q purged something", value)
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+ })
+ }
+}
+
+// TestPurgeHeaderSurroundingWhitespace — targets are trimmed, so the
+// human-readable ", " form and a tab-padded one both work.
+func TestPurgeHeaderSurroundingWhitespace(t *testing.T) {
+ e, _ := purgeEdge(t, "ws.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", " /a ,\t/b\t")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "ws.example.com", "/a", "", "a")
+ seed(t, e.cache, "ws.example.com", "/b", "", "b")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ws.example.com/write", nil))
+ _ = rec
+
+ if cached(e.cache, "ws.example.com", "/a", "") || cached(e.cache, "ws.example.com", "/b", "") {
+ t.Fatal("whitespace-padded targets were not honoured")
+ }
+}
+
+// TestPurgeHeaderOnNon2xx — the purge is decoupled from cacheability: a 302
+// and a 404 both purge, and neither is stored.
+func TestPurgeHeaderOnNon2xx(t *testing.T) {
+ for _, status := range []int{http.StatusFound, http.StatusNotFound, http.StatusInternalServerError} {
+ t.Run(http.StatusText(status), func(t *testing.T) {
+ e, _ := purgeEdge(t, "nx.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/a")
+ w.Header().Set("Cache-Control", "max-age=60")
+ w.WriteHeader(status)
+ io.WriteString(w, "body")
+ })
+ seed(t, e.cache, "nx.example.com", "/a", "", "a")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://nx.example.com/thing", nil))
+ if rec.Code != status {
+ t.Fatalf("status = %d, want %d", rec.Code, status)
+ }
+ if cached(e.cache, "nx.example.com", "/a", "") {
+ t.Fatalf("a %d response did not purge", status)
+ }
+ if cached(e.cache, "nx.example.com", "/thing", "") {
+ t.Fatalf("a %d response was cached", status)
+ }
+ })
+ }
+}
+
+// TestPurgeHeaderBoundsDegradeToHost — over either bound the edge over-purges
+// (whole host) rather than partially applying. Freshness above cleverness.
+func TestPurgeHeaderBoundsDegradeToHost(t *testing.T) {
+ tooMany := make([]string, maxPurgeTargets+1)
+ for i := range tooMany {
+ tooMany[i] = fmt.Sprintf("/p%d", i)
+ }
+ cases := map[string]string{
+ "too many targets": strings.Join(tooMany, ","),
+ "oversized header": "/" + strings.Repeat("a", maxPurgeHeaderBytes),
+ }
+ for name, value := range cases {
+ t.Run(name, func(t *testing.T) {
+ e, _ := purgeEdge(t, "cap.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", value)
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "cap.example.com", "/untouched", "", "u")
+ seed(t, e.cache, "other.example.com", "/untouched", "", "o")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://cap.example.com/write", nil))
+
+ if cached(e.cache, "cap.example.com", "/untouched", "") {
+ t.Fatal("over-bound header did not degrade to a whole-host purge")
+ }
+ if !cached(e.cache, "other.example.com", "/untouched", "") {
+ t.Fatal("degrading to a host purge crossed the host boundary")
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+ })
+ }
+}
+
+// TestPurgeHeaderAtBound — exactly maxPurgeTargets is honoured target by
+// target, not degraded. Pins the boundary from the other side.
+func TestPurgeHeaderAtBound(t *testing.T) {
+ targets := make([]string, maxPurgeTargets)
+ for i := range targets {
+ targets[i] = fmt.Sprintf("/p%d", i)
+ }
+ e, _ := purgeEdge(t, "atcap.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", strings.Join(targets, ","))
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "atcap.example.com", "/p0", "", "p0")
+ seed(t, e.cache, "atcap.example.com", "/untouched", "", "u")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://atcap.example.com/write", nil))
+ _ = rec
+
+ if cached(e.cache, "atcap.example.com", "/p0", "") {
+ t.Fatal("/p0 was not purged at exactly the target bound")
+ }
+ if !cached(e.cache, "atcap.example.com", "/untouched", "") {
+ t.Fatal("exactly maxPurgeTargets degraded to a whole-host purge")
+ }
+}
+
+// TestPurgeHeaderNeverReadFromRequest — a client-supplied Carlos-Purge is
+// exactly the externally-reachable purge endpoint the parent spec rejects as
+// a wake-storm vector. It must do nothing.
+func TestPurgeHeaderNeverReadFromRequest(t *testing.T) {
+ e, _ := purgeEdge(t, "req.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, "ok")
+ })
+ seed(t, e.cache, "req.example.com", "/a", "", "a")
+
+ req := httptest.NewRequest("POST", "http://req.example.com/write", nil)
+ req.Header.Set("Carlos-Purge", "*")
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ if !cached(e.cache, "req.example.com", "/a", "") {
+ t.Fatal("a request-borne Carlos-Purge purged the cache")
+ }
+}
+
+// TestPurgeHeaderWithoutCacheIsStillStripped — with caching disabled there is
+// nothing to purge, but the header is an edge-internal signal and must still
+// never reach a client.
+func TestPurgeHeaderWithoutCacheIsStillStripped(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/a")
+ io.WriteString(w, "ok")
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "nocache.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://nocache.example.com/write", nil))
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client with caching disabled: %q", got)
+ }
+}
+
+// TestPurgeHeaderOnUpgradeBypassPath — the upgrade path skips caching AND the
+// recordingResponseWriter entirely (edge.go's isUpgradeRequest branch), so it
+// is the one proxied path where a purge hook placed anywhere but
+// ModifyResponse would silently not fire. This drives a REAL 101 handshake
+// over a real socket, which is the only way that branch is exercised end to
+// end.
+func TestPurgeHeaderOnUpgradeBypassPath(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, buf, err := w.(http.Hijacker).Hijack()
+ if err != nil {
+ t.Errorf("backend hijack: %v", err)
+ return
+ }
+ defer conn.Close()
+ fmt.Fprint(buf, "HTTP/1.1 101 Switching Protocols\r\n"+
+ "Upgrade: carlos-test\r\n"+
+ "Connection: Upgrade\r\n"+
+ "Carlos-Purge: /live\r\n\r\n")
+ buf.Flush()
+ // Hold the tunnel open long enough for the client to read the
+ // handshake response.
+ io.Copy(io.Discard, buf)
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "ws.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ seed(t, e.cache, "ws.example.com", "/live", "", "live")
+
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ conn, err := net.Dial("tcp", srv.Listener.Addr().String())
+ if err != nil {
+ t.Fatalf("dial edge: %v", err)
+ }
+ defer conn.Close()
+ fmt.Fprint(conn, "GET /socket HTTP/1.1\r\n"+
+ "Host: ws.example.com\r\n"+
+ "Upgrade: carlos-test\r\n"+
+ "Connection: Upgrade\r\n\r\n")
+ conn.SetReadDeadline(time.Now().Add(10 * time.Second))
+ resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
+ if err != nil {
+ t.Fatalf("read upgrade response: %v", err)
+ }
+ if resp.StatusCode != http.StatusSwitchingProtocols {
+ t.Fatalf("status = %d, want 101", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge survived onto the upgrade response: %q", got)
+ }
+ if cached(e.cache, "ws.example.com", "/live", "") {
+ t.Fatal("upgrade-bypass path did not honour Carlos-Purge")
+ }
+}
+
+// TestPurgeHeaderPurgesEveryVaryVariant — a target names a URL, and purging
+// it drops ALL stored variants of that URL (existing PurgeURL semantics).
+func TestPurgeHeaderPurgesEveryVaryVariant(t *testing.T) {
+ e, _ := purgeEdge(t, "vary.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/page")
+ w.WriteHeader(http.StatusOK)
+ })
+ base := CacheKey{Host: "vary.example.com", Method: "GET", Path: "/page"}
+ for _, lang := range []string{"en", "fr"} {
+ k := base
+ k.VaryValue = "Accept-Language=" + lang + ";"
+ e.cache.Put(k, "Accept-Language", &CachedResponse{
+ StatusCode: 200, Header: http.Header{}, Body: []byte(lang),
+ StoredAt: time.Now(), TTL: time.Minute,
+ })
+ }
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://vary.example.com/write", nil))
+ _ = rec
+
+ for _, lang := range []string{"en", "fr"} {
+ k := base
+ k.VaryValue = "Accept-Language=" + lang + ";"
+ if _, ok := e.cache.get(k); ok {
+ t.Fatalf("Vary variant %q survived a URL purge", lang)
+ }
+ }
+}
+
+// TestPurgeHeaderHEADEntriesToo — PurgeURL drops GET and HEAD together; a
+// write invalidates both.
+func TestPurgeHeaderHEADEntriesToo(t *testing.T) {
+ e, _ := purgeEdge(t, "head.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/page")
+ w.WriteHeader(http.StatusOK)
+ })
+ k := CacheKey{Host: "head.example.com", Method: "HEAD", Path: "/page"}
+ e.cache.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute})
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://head.example.com/write", nil))
+ _ = rec
+
+ if _, ok := e.cache.get(k); ok {
+ t.Fatal("the HEAD entry for a purged URL survived")
+ }
+}
+
+// TestValidPurgeTarget is the unit-level table for the shape gate, including
+// the paths that must stay VALID — a query string, a path containing a colon
+// (which is not an authority once the value starts with a single slash), and
+// a trailing slash.
+func TestValidPurgeTarget(t *testing.T) {
+ valid := []string{"/", "/a", "/a/b/", "/events/42", "/events/42?lang=fr", "/a:b", "/x://y", "/a%20b", "/a?b=c&d=e"}
+ invalid := []string{"", "*", "a", "a/b", "//host", `/\host`, "http://h/x", "https://h/x", "//", `//\`, "/a b", "/a\tb", "/a\nb", "/a\x00b", "/a\x7f"}
+ for _, v := range valid {
+ if !validPurgeTarget(v) {
+ t.Errorf("validPurgeTarget(%q) = false, want true", v)
+ }
+ }
+ for _, v := range invalid {
+ if validPurgeTarget(v) {
+ t.Errorf("validPurgeTarget(%q) = true, want false", v)
+ }
+ }
+}
+
+// TestPurgeHeaderScrubbedFromRequestBeforeUpstream — Carlos-Purge is an
+// edge-internal signal in BOTH directions. TestPurgeHeaderNeverReadFromRequest
+// pins that the edge itself ignores a client-supplied one; this pins that the
+// client-supplied one never reaches the APP either.
+//
+// It matters because the app is the other half of this contract. An app that
+// reflects request headers onto its response (a debug echo, a framework's
+// "copy these through" list, a CORS mirror) would turn a client-supplied
+// Carlos-Purge into an app-declared one — and that one IS honoured, which
+// hands any client on the internet the purge channel the whole design exists
+// to keep unreachable. Scrubbing it on the way in means no app can be tricked
+// into laundering it, regardless of how the app behaves.
+func TestPurgeHeaderScrubbedFromRequestBeforeUpstream(t *testing.T) {
+ seen := make(chan string, 1)
+ e, _ := purgeEdge(t, "scrub.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ seen <- strings.Join(r.Header.Values(PurgeHeader), "|")
+ io.WriteString(w, "ok")
+ })
+
+ req := httptest.NewRequest("POST", "http://scrub.example.com/write", nil)
+ req.Header.Add(PurgeHeader, "*")
+ req.Header.Add(PurgeHeader, "/a")
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ if got := <-seen; got != "" {
+ t.Fatalf("upstream saw Carlos-Purge %q — an echoing app would launder it into a real purge", got)
+ }
+}
+
+// The same scrub on the unix-socket proxy: unixProxy and tcpProxy have
+// separate Rewrite funcs, so a fix applied to one is not applied to the other.
+func TestPurgeHeaderScrubbedFromRequestOverUnixSocket(t *testing.T) {
+ sock := filepath.Join(t.TempDir(), "u.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") {
+ t.Skipf("unix-socket listeners not permitted in this environment: %v", err)
+ }
+ t.Fatalf("listen unix: %v", err)
+ }
+ seen := make(chan string, 1)
+ backend := &httptest.Server{
+ Listener: ln,
+ Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seen <- strings.Join(r.Header.Values(PurgeHeader), "|")
+ io.WriteString(w, "ok")
+ })},
+ }
+ backend.Start()
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "sockscrub.example.com", Kind: "instance", Socket: sock, Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ req := httptest.NewRequest("POST", "http://sockscrub.example.com/write", nil)
+ req.Header.Set(PurgeHeader, "*")
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ if got := <-seen; got != "" {
+ t.Fatalf("unix-socket upstream saw Carlos-Purge %q", got)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Query-variant semantics: a bare path means EVERY query variant.
+// ---------------------------------------------------------------------------
+
+// TestPurgeHeaderBarePathPurgesEveryQueryVariant is the contract change. An
+// app that writes /events/42 and declares "/events/42" means the page is
+// stale — all of it. Keying the purge on the exact empty query left
+// /events/42?utm_source=… (a campaign link) and /events/42?lang=fr serving the
+// pre-write body until their TTLs expired, which are precisely the copies a
+// reader arrives on.
+func TestPurgeHeaderBarePathPurgesEveryQueryVariant(t *testing.T) {
+ e, _ := purgeEdge(t, "q.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/events/42")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "q.example.com", "/events/42", "", "bare")
+ seed(t, e.cache, "q.example.com", "/events/42", "utm_source=newsletter", "utm")
+ seed(t, e.cache, "q.example.com", "/events/42", "lang=fr", "fr")
+ // A HEAD entry under a query, to prove both method groups of byPath are
+ // swept and not just GET.
+ headKey := CacheKey{Host: "q.example.com", Method: "HEAD", Path: "/events/42", RawQuery: "lang=fr"}
+ e.cache.Put(headKey, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute})
+ // Neighbours that share a query but not the path must be untouched.
+ seed(t, e.cache, "q.example.com", "/events/43", "lang=fr", "neighbour")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://q.example.com/events/42/edit", nil))
+ _ = rec
+
+ for _, rq := range []string{"", "utm_source=newsletter", "lang=fr"} {
+ if cached(e.cache, "q.example.com", "/events/42", rq) {
+ t.Fatalf("/events/42?%s survived a bare-path purge", rq)
+ }
+ }
+ if _, ok := e.cache.get(headKey); ok {
+ t.Fatal("the HEAD entry under a query survived a bare-path purge")
+ }
+ if !cached(e.cache, "q.example.com", "/events/43", "lang=fr") {
+ t.Fatal("a bare-path purge crossed into a different path")
+ }
+}
+
+// TestPurgeHeaderQueryFormPurgesOnlyThatVariant — the other half of the
+// contract: WITH a "?" the target is exact, so an app that really does mean
+// "only the French one" still has that.
+func TestPurgeHeaderQueryFormPurgesOnlyThatVariant(t *testing.T) {
+ e, _ := purgeEdge(t, "qx.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/events/42?lang=fr")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "qx.example.com", "/events/42", "", "bare")
+ seed(t, e.cache, "qx.example.com", "/events/42", "lang=fr", "fr")
+ seed(t, e.cache, "qx.example.com", "/events/42", "lang=de", "de")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qx.example.com/write", nil))
+ _ = rec
+
+ if cached(e.cache, "qx.example.com", "/events/42", "lang=fr") {
+ t.Fatal("the named variant was not purged")
+ }
+ for _, rq := range []string{"", "lang=de"} {
+ if !cached(e.cache, "qx.example.com", "/events/42", rq) {
+ t.Fatalf("/events/42?%s was purged, but a ?-form target is exact", rq)
+ }
+ }
+}
+
+// TestPurgeHeaderEmptyQueryFormIsExact — "/events/42?" is how an app asks for
+// the unqueried variant ALONE, now that the bare form means "all of them".
+func TestPurgeHeaderEmptyQueryFormIsExact(t *testing.T) {
+ e, _ := purgeEdge(t, "qe.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/events/42?")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "qe.example.com", "/events/42", "", "bare")
+ seed(t, e.cache, "qe.example.com", "/events/42", "lang=fr", "fr")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qe.example.com/write", nil))
+ _ = rec
+
+ if cached(e.cache, "qe.example.com", "/events/42", "") {
+ t.Fatal(`"/events/42?" did not purge the empty-query variant`)
+ }
+ if !cached(e.cache, "qe.example.com", "/events/42", "lang=fr") {
+ t.Fatal(`"/events/42?" purged a queried variant — the ?-form is exact`)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Percent-encoding: path decoded, query raw — matching how the key is stored.
+// ---------------------------------------------------------------------------
+
+// TestPurgeHeaderPercentEncodedPathDecodes — edge.go stores CacheKey.Path from
+// r.URL.Path, which net/url has ALREADY decoded. A target is therefore
+// PathUnescape'd before it is used as a key, or "/caf%C3%A9" would purge
+// nothing at all while looking like it worked.
+func TestPurgeHeaderPercentEncodedPathDecodes(t *testing.T) {
+ for _, tc := range []struct{ target, stored string }{
+ {"/a%20b", "/a b"},
+ {"/caf%C3%A9", "/café"},
+ {"/a%2Fb", "/a/b"},
+ } {
+ t.Run(tc.target, func(t *testing.T) {
+ e, _ := purgeEdge(t, "pct.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", tc.target)
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "pct.example.com", tc.stored, "", "old")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://pct.example.com/write", nil))
+ _ = rec
+
+ if cached(e.cache, "pct.example.com", tc.stored, "") {
+ t.Fatalf("target %q did not purge the entry stored under %q", tc.target, tc.stored)
+ }
+ })
+ }
+}
+
+// TestPurgeHeaderQueryIsComparedRaw pins the other side of the asymmetry.
+// CacheKey.RawQuery is r.URL.RawQuery — the query byte-for-byte as received,
+// never decoded — so a declared query must match RAW, not decoded.
+func TestPurgeHeaderQueryIsComparedRaw(t *testing.T) {
+ e, _ := purgeEdge(t, "rawq.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/s?q=caf%C3%A9, /s2?q=caf%C3%A9")
+ w.WriteHeader(http.StatusOK)
+ })
+ // Stored exactly as a request would arrive percent-encoded: raw matches raw.
+ seed(t, e.cache, "rawq.example.com", "/s", "q=caf%C3%A9", "encoded")
+ // Stored under a DECODED query. Nothing can arrive this way over the wire,
+ // and the raw-vs-raw comparison must not reach it.
+ seed(t, e.cache, "rawq.example.com", "/s2", "q=café", "decoded")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://rawq.example.com/write", nil))
+ _ = rec
+
+ if cached(e.cache, "rawq.example.com", "/s", "q=caf%C3%A9") {
+ t.Fatal("a raw-encoded query target did not match the raw-encoded stored key")
+ }
+ if !cached(e.cache, "rawq.example.com", "/s2", "q=café") {
+ t.Fatal("the query half was decoded before comparison — store and purge must agree on RAW")
+ }
+}
+
+// TestPurgeTargetKey is the unit table for the decode layer: what survives the
+// raw gate can still be refused once decoded, because decoding manufactures
+// the very shapes the raw gate exists to refuse.
+func TestPurgeTargetKey(t *testing.T) {
+ type want struct {
+ path string
+ rawQuery string
+ exact bool
+ }
+ ok := map[string]want{
+ "/a": {"/a", "", false},
+ "/events/42": {"/events/42", "", false},
+ "/events/42?lang=fr": {"/events/42", "lang=fr", true},
+ "/events/42?": {"/events/42", "", true},
+ "/a%20b": {"/a b", "", false}, // decoded space is a real path byte
+ "/caf%C3%A9": {"/café", "", false}, // decoded UTF-8
+ "/a%2Fb": {"/a/b", "", false}, // encoded slash decodes mid-path
+ "/s?q=caf%C3%A9": {"/s", "q=caf%C3%A9", true}, // query kept RAW
+ "/a:b": {"/a:b", "", false},
+ }
+ for target, w := range ok {
+ p, q, exact, good := purgeTargetKey(target)
+ if !good {
+ t.Errorf("purgeTargetKey(%q) refused a valid target", target)
+ continue
+ }
+ if p != w.path || q != w.rawQuery || exact != w.exact {
+ t.Errorf("purgeTargetKey(%q) = (%q, %q, %v), want (%q, %q, %v)",
+ target, p, q, exact, w.path, w.rawQuery, w.exact)
+ }
+ }
+ // Refused: undecodable, and decoded-into-a-smuggle or a control byte.
+ bad := []string{
+ "/a%zz", // not hex
+ "/a%A", // truncated escape
+ "/%2Fhost", // decodes to //host — protocol-relative
+ "/%2F%2Fhost", // decodes to ///host
+ `/%5Chost`, // decodes to /\host — the backslash smuggle
+ "/a%00b", // decoded NUL
+ "/a%0ab", // decoded LF — log-injection shaped
+ "/a%09b", // decoded TAB
+ "/a%7f", // decoded DEL
+ "//host", // refused by the raw gate already
+ "/a b", // raw space: malformed as declared
+ "",
+ "*",
+ }
+ for _, target := range bad {
+ if _, _, _, good := purgeTargetKey(target); good {
+ t.Errorf("purgeTargetKey(%q) accepted a target it must refuse", target)
+ }
+ }
+}
+
+// TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd — the decode re-check at the
+// level that matters: a target that decodes into a protocol-relative shape
+// must purge nothing, not be honoured as a path.
+func TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd(t *testing.T) {
+ // "/%2Fa.example.com/x" decodes to "//a.example.com/x" — leading slash plus
+ // one decoded slash. (Note "/%2F%2F…" would decode to THREE slashes and
+ // match nothing, which would make this test pass vacuously.)
+ e, _ := purgeEdge(t, "dec.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "/%2Fa.example.com/x, /good")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "host A")
+ seed(t, e.cache, "dec.example.com", "//a.example.com/x", "", "decoded shape")
+ seed(t, e.cache, "dec.example.com", "/good", "", "good")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://dec.example.com/write", nil))
+ _ = rec
+
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("a decoded smuggle reached another host")
+ }
+ if !cached(e.cache, "dec.example.com", "//a.example.com/x", "") {
+ t.Fatal("a target that decodes to //host was honoured as a path")
+ }
+ if cached(e.cache, "dec.example.com", "/good", "") {
+ t.Fatal("the valid target beside it was discarded")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// The narrowed contract: FINAL response headers only.
+// ---------------------------------------------------------------------------
+
+// TestPurgeHeaderOn1xxIsNotHonoured pins a documented GAP rather than a fix.
+//
+// ModifyResponse is handed the FINAL response only; the stdlib forwards 1xx
+// informational blocks to the client on a separate path that never reaches
+// this package. So a Carlos-Purge on a 103 Early Hints block is neither
+// honoured NOR stripped — the client sees it, and the assertion below records
+// that as the truth rather than pretending otherwise.
+//
+// It stays a gap on purpose. Catching it would mean wrapping the client's
+// ResponseWriter to filter 1xx writes, and the upgrade path deliberately hands
+// the RAW ResponseWriter to the stdlib — a wrapper there re-opens the
+// WebSocket-breakage class this package has already paid for. Narrowing the
+// promise is the cheaper, honest trade.
+func TestPurgeHeaderOn1xxIsNotHonoured(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Declared on the 103 block only, then removed so the FINAL response
+ // carries nothing — isolating the informational path.
+ w.Header().Set("Carlos-Purge", "/early")
+ w.WriteHeader(http.StatusEarlyHints)
+ w.Header().Del("Carlos-Purge")
+ io.WriteString(w, "ok")
+ }))
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "early.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ seed(t, e.cache, "early.example.com", "/early", "", "still here")
+
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ var got1xx string
+ req, err := http.NewRequest("POST", srv.URL+"/write", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = "early.example.com"
+ trace := &httptrace.ClientTrace{
+ Got1xxResponse: func(code int, h textproto.MIMEHeader) error {
+ if code == http.StatusEarlyHints {
+ got1xx = h.Get("Carlos-Purge")
+ }
+ return nil
+ },
+ }
+ req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ io.Copy(io.Discard, resp.Body)
+ resp.Body.Close()
+
+ // The load-bearing assertion: a 1xx declaration purges NOTHING.
+ if !cached(e.cache, "early.example.com", "/early", "") {
+ t.Fatal("a Carlos-Purge on a 103 was honoured — the contract says final-response headers only")
+ }
+ // And the documented consequence, pinned so a future stdlib change that
+ // silently starts stripping it shows up here as a surprise rather than a
+ // quiet behaviour drift.
+ if got1xx != "/early" {
+ t.Logf("note: the 103 block did not carry Carlos-Purge to the client (got %q); "+
+ "the contract permits either, but this used to reach the client", got1xx)
+ }
+ if got := resp.Header.Get("Carlos-Purge"); got != "" {
+ t.Fatalf("the FINAL response carried Carlos-Purge: %q", got)
+ }
+}
+
+// TestPurgeHeaderInTrailerIsNotHonoured — the other half of the narrowing. A
+// header announced via Trailer and sent after the body is not in resp.Header
+// when ModifyResponse runs, so it is neither honoured nor stripped.
+func TestPurgeHeaderInTrailerIsNotHonoured(t *testing.T) {
+ e, _ := purgeEdge(t, "trail.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Trailer", "Carlos-Purge")
+ w.WriteHeader(http.StatusOK)
+ io.WriteString(w, "body")
+ // Set AFTER the body: net/http moves this into the trailer block.
+ w.Header().Set("Carlos-Purge", "/trailed")
+ })
+ seed(t, e.cache, "trail.example.com", "/trailed", "", "still here")
+
+ srv := httptest.NewServer(e)
+ t.Cleanup(srv.Close)
+
+ req, err := http.NewRequest("POST", srv.URL+"/write", nil)
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Host = "trail.example.com"
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("do: %v", err)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+
+ // Proves the backend actually ran and the trailer was actually sent —
+ // without this the assertion below could pass because nothing happened.
+ if string(body) != "body" {
+ t.Fatalf("body = %q, want %q", body, "body")
+ }
+ if got := resp.Trailer.Get("Carlos-Purge"); got != "/trailed" {
+ t.Fatalf("the trailer did not arrive (got %q) — this test proves nothing without it", got)
+ }
+ if !cached(e.cache, "trail.example.com", "/trailed", "") {
+ t.Fatal("a Carlos-Purge sent as a TRAILER was honoured — the contract says final-response headers only")
+ }
+}
+
+// TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook is the footgun the
+// contract now names explicitly. httputil.ReverseProxy calls
+// removeHopByHopHeaders(res.Header) BEFORE ModifyResponse, so an app that
+// names Carlos-Purge in its Connection header has its declaration deleted
+// before this package can ever see it — it purges nothing, silently, and the
+// app author has no way to tell from the outside.
+func TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook(t *testing.T) {
+ e, _ := purgeEdge(t, "conn.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Connection", "Carlos-Purge")
+ w.Header().Set("Carlos-Purge", "/conn")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "conn.example.com", "/conn", "", "still here")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://conn.example.com/write", nil))
+
+ if !cached(e.cache, "conn.example.com", "/conn", "") {
+ t.Fatal("a Connection-listed Carlos-Purge was honoured — if the stdlib stopped " +
+ "treating it as hop-by-hop, the contract note about Connection is now wrong")
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Log rate-limiting.
+// ---------------------------------------------------------------------------
+
+// TestPurgeHeaderComplaintLogIsRateLimited — every one of these lines is
+// triggered by a remote party's response on the request hot path, so an app
+// that emits a malformed declaration on every response would write one journal
+// line per request forever. Same first-then-every-300th shape as the registry
+// watcher in edge.go.
+func TestPurgeHeaderComplaintLogIsRateLimited(t *testing.T) {
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
+ e, _ := purgeEdge(t, "noisy.example.com", false, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge", "a.example.com/smuggled")
+ w.WriteHeader(http.StatusOK)
+ })
+ const requests = 12
+ for i := 0; i < requests; i++ {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://noisy.example.com/write", nil))
+ }
+
+ got := strings.Count(buf.String(), "malformed Carlos-Purge target(s)")
+ if got != 1 {
+ t.Fatalf("%d requests produced %d complaint lines, want exactly 1 (the first); log:\n%s",
+ requests, got, buf.String())
+ }
+ // The first is always logged — nothing is silently swallowed.
+ if !strings.Contains(buf.String(), "occurrence #1") {
+ t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String())
+ }
+}