From b8b8ca7b6bb10d7fc2c49a68a46cf8352b2c521b Mon Sep 17 00:00:00 2001 From: Brian Faust Date: Thu, 3 Sep 2026 07:19:33 +0300 Subject: [PATCH 1/2] chore(cohesion): adopt ecosystem metadata Expose router ownership, package selection, and documentation through the versioned cohesion contract so local and hosted checks detect drift. Impact: patch --- .github/workflows/ci.yml | 4 +- .golib.yaml | 4 +- CHANGELOG.md | 7 ++++ Makefile | 11 +++++- README.md | 7 ++++ modules.json | 82 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 108 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c355dd..9ff673e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,9 @@ permissions: jobs: ci: - uses: faustbrian/go-library-tools/.github/workflows/library-ci.yml@1f9629e5f27418600460b55a50a5b2fc81697fab # v1.2.0 + uses: faustbrian/go-library-tools/.github/workflows/library-ci.yml@6c76f5c670d193ce369a7242d4c634f1117286e9 # v1.3.0 cohesion enforcement with: - tooling_sha: 1f9629e5f27418600460b55a50a5b2fc81697fab + tooling_sha: 6c76f5c670d193ce369a7242d4c634f1117286e9 release_dry_run: ${{ inputs.release_dry_run || false }} required: diff --git a/.golib.yaml b/.golib.yaml index 85d9987..f9ecbc8 100644 --- a/.golib.yaml +++ b/.golib.yaml @@ -1,6 +1,6 @@ schema_version: 1 -tool_version: v1.2.0 -tool_checksums_sha256: 7de6de3a14184eced8c0114bf802cb24a808f3e1592d358f6f6f3d02552d0e27 +tool_version: v1.3.0 +tool_checksums_sha256: 72d38d7d0cab33bfeb9345df6b141eacf9841c5d6817f597ff25b957dc8dc48f manifest: modules: modules.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6b741..7a5e252 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ Versioning. specification decision, conformance, history, and monitoring contract. - Expose the owned middleware, JSON-RPC, and service integrations through the typed interoperability gate required by the repository contract. +- Adopt the checksum-verified `go-library-tools` v1.3.0 CLI, schema-v2 + cohesion metadata, and repository-local cohesion gate while retaining the + router's public API and package-owned verification evidence. +- Pin reusable CI to the immutable v1.3.0 workflow so hosted checks enforce + the same cohesion contract as local validation. ### Documentation @@ -42,6 +47,8 @@ Versioning. - Replace archived monorepo links and completed execution artifacts with a standalone, human-oriented documentation structure. +- Link the router to the immutable v1.3.0 Golib ecosystem index and + service-edge package-selection guidance. ## [1.0.0] - 2026-08-25 diff --git a/Makefile b/Makefile index 3098c3e..7925167 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,20 @@ GOLIB ?= golib -.PHONY: check ci inventory repository-check +.PHONY: check ci cohesion inventory repository-check check: $(GOLIB) check --all ci: $(GOLIB) repository check + $(GOLIB) cohesion check $(GOLIB) check --all -inventory repository-check: +cohesion: + $(GOLIB) cohesion check + +inventory: + $(GOLIB) inventory + +repository-check: $(GOLIB) repository check diff --git a/README.md b/README.md index e7fd272..2e3ba2f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,10 @@ The minimum supported toolchain is Go 1.26.6. The package has no runtime dependencies and no global router, reflection discovery, controller resolver, container, session, template, or application lifecycle. +See the versioned [Golib ecosystem index](https://github.com/faustbrian/go-library-tools/blob/v1.3.0/docs/ecosystem/README.md) +and its [service-edge package guidance](https://github.com/faustbrian/go-library-tools/blob/v1.3.0/docs/ecosystem/design-language.md#package-families-and-selection) +for the shared design language and related packages. + ## Five-minute start ```go @@ -71,6 +75,9 @@ Run `make check` for the blocking local checks. NilAway remains visible as an advisory result in the shared contract. Each target is independently reproducible. +Run `make cohesion` to validate this module's catalog classification, +ownership contract, documentation entry points, and ecosystem navigation. + ## License MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE). diff --git a/modules.json b/modules.json index e052da4..45d52f6 100644 --- a/modules.json +++ b/modules.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "repository": "github.com/faustbrian/go-router", "go_version": "1.26.6", "modules": [ @@ -72,6 +72,86 @@ "goal_files": [], "goal_status": "not-applicable", "goal_evidence": [], + "cohesion": { + "family": "service-edge", + "secondary_capabilities": [ + "http-and-service-edge", + "protocols-and-schemas" + ], + "responsibility": "Provide explicit startup-time HTTP route composition, immutable compiled dispatch, safe URL generation, and deterministic route introspection over ordinary net/http handlers.", + "non_goals": [ + "Own HTTP server lifecycle, handler or middleware resources, authentication, authorization, sessions, templates, dependency injection, or application bootstrap." + ], + "public_package_identifier": "router", + "primary_entry_packages": [ + "github.com/faustbrian/go-router" + ], + "package_selection": { + "github.com/faustbrian/go-router": "Use for explicit immutable HTTP route composition, dispatch, introspection, mounts, and named URL generation while retaining ordinary net/http handler ownership.", + "github.com/faustbrian/go-router/routertest": "Use in tests for deterministic router compilation, serving, status assertions, and route-table inspection." + }, + "lifecycle_status": "active", + "maturity": "stable", + "construction_styles": [ + "builder-compile", + "functional-options", + "validated-value" + ], + "lifecycle_styles": [ + "stateless" + ], + "ownership": { + "configuration": "caller", + "mutable_inputs": [ + "borrow", + "copy" + ], + "runtime_resources": "none", + "background_work": "none" + }, + "optional_owned_dependencies": [], + "adapters": [], + "companions": [ + "github.com/faustbrian/go-http-middleware", + "github.com/faustbrian/go-router/routertest", + "github.com/faustbrian/go-service" + ], + "supported_go": { + "minimum": "1.26.6", + "tested": [ + "1.26.6" + ] + }, + "supported_platforms": [ + "portable-go" + ], + "supported_backends": [], + "supported_protocols": [ + "http-1-1", + "rfc-3986", + "rfc-9110", + "rfc-9112" + ], + "documentation": { + "readme": "README.md", + "api": "docs/api.md", + "adoption": "docs/adoption.md", + "security": "docs/security.md", + "compatibility": "docs/compatibility.md", + "performance": "docs/performance.md", + "examples": "docs/quickstart.md", + "faq": "docs/faq.md", + "changelog": "CHANGELOG.md", + "pkg_go_dev": "https://pkg.go.dev/github.com/faustbrian/go-router", + "ecosystem_index": "https://github.com/faustbrian/go-library-tools/blob/v1.3.0/docs/ecosystem/README.md" + }, + "known_good_compatibility_sets": [], + "delivery": { + "implementation": "not-started", + "hardening": "not-started", + "release": "not-started" + } + }, "gates": { "api_compatibility": true, "benchmarks": true, From 24dd6e54466f78b58b0c8d39b1ff8a7fbf304b30 Mon Sep 17 00:00:00 2001 From: Brian Faust Date: Thu, 3 Sep 2026 10:48:26 +0300 Subject: [PATCH 2/2] fix(router): preserve pinned redirect behavior Keep escaped canonical and subtree redirect locations stable against the reviewed Go 1.26.6 contract across later toolchains. Impact: patch --- CHANGELOG.md | 9 ++++ compatibility_security_test.go | 72 +++++++++++++++++++++++++++++ compiler.go | 1 - docs/specification-decisions.md | 60 +++++++++++++----------- router.go | 41 +++++++++++++--- specification/README.md | 4 +- specification/conformance.json | 8 ++-- specification/decision-history.json | 4 +- specification/decisions.json | 20 ++++---- specification/monitoring.json | 18 ++++---- 10 files changed, 177 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5e252..d9a4908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Versioning. router's public API and package-owned verification evidence. - Pin reusable CI to the immutable v1.3.0 workflow so hosted checks enforce the same cohesion contract as local validation. +- Preserve the pinned Go 1.26.6 escaped-path redirect serialization across + later Go toolchains instead of silently adopting Go 1.27 wire changes. ### Documentation @@ -30,11 +32,14 @@ Versioning. decision history. - ROUTER-DEC-001 sha256:56f4ea57efd1fb24b8650c883bea47f05872c63d6fb4f4fb43f1f2bdafe9ff8c + - ROUTER-DEC-001 sha256:f446182ad093b6e3dc1f40e180fe8ad03f88733233cf8a3f2722740c3b804585 - ROUTER-DEC-002 sha256:f8dbfe4fe493cc3b71c400f985206aef6654f266f705ff0a6f8fdbb9fd979811 - ROUTER-DEC-003 sha256:e57444cf3c6e408a366c339ed4f622897c655ccf85dfa6545628d07e04cd5964 - ROUTER-DEC-003 sha256:131ba5fada36d81eb47b1d595b624ee2cf0b95e6cfdfde47409b8492e156d2f3 - ROUTER-DEC-004 sha256:d06af40fb0a74ae46f2f22d843c09effd2c397777bf07d2a9f931fdec3e9efb2 - ROUTER-DEC-005 sha256:383d99f58bc5bf65b234941feb386c096b673e5276e45cba86aa31e5d80971e9 + - ROUTER-DEC-005 sha256:35abe45420556f55ed3d65b43fb6f2d172710ada3949468b4a0526aac74db0d4 + - ROUTER-DEC-005 sha256:adf00e74ff1fee7ab18248fef929a805e30d4dfd14ddaa69d67859d70af701f0 - ROUTER-DEC-006 sha256:9fa23204c8ff2ae12d5631c7947091b32436be3bf075a29f2700037a7bce27c1 - ROUTER-DEC-007 sha256:1eb13de30f37d6bc1e8e7924ace29a967364d88f22570ca7cc84f2c5659e9139 - ROUTER-DEC-008 sha256:bcf6f5c4c6b7b3960e028c33645cedbb22dcfab2d7460d2acee73077edee2c2b @@ -49,6 +54,10 @@ Versioning. standalone, human-oriented documentation structure. - Link the router to the immutable v1.3.0 Golib ecosystem index and service-edge package-selection guidance. +- Bind monitored Go source files to immutable Go 1.26.6 URLs and record the + reviewed Go 1.27 redirect change without changing the support baseline. +- Record RFC 9110 Erratum 9162 as behavior-neutral because generated `Allow` + field values already use comma-space separation. ## [1.0.0] - 2026-08-25 diff --git a/compatibility_security_test.go b/compatibility_security_test.go index e68521c..418defa 100644 --- a/compatibility_security_test.go +++ b/compatibility_security_test.go @@ -254,6 +254,78 @@ func TestCanonicalRedirectsPrecedeRouteAndMethodSelection(t *testing.T) { } } +func TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior(t *testing.T) { + t.Parallel() + + canonical := mustCompile(t, router.New()) + subtreeBuilder := router.New() + mustRegister(t, subtreeBuilder, router.Route{ + Methods: []string{http.MethodGet}, Path: "/encoded%2f/", Handler: http.NotFoundHandler(), + }) + subtree := mustCompile(t, subtreeBuilder) + + for _, testCase := range []struct { + name string + router http.Handler + target string + location string + }{ + { + name: "canonical path", + router: canonical, + target: "/files/a%2Fb//tail?source=test", + location: "/files/a%252Fb/tail?source=test", + }, + { + name: "subtree root", + router: subtree, + target: "/encoded%2F", + location: "/encoded/", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + response := httptest.NewRecorder() + testCase.router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, testCase.target, nil)) + if response.Code != http.StatusTemporaryRedirect || response.Header().Get("Location") != testCase.location { + t.Fatalf("%s: status=%d location=%q", testCase.target, response.Code, response.Header().Get("Location")) + } + }) + } +} + +func TestPinnedSubtreeRedirectDoesNotOverrideExplicitRoot(t *testing.T) { + t.Parallel() + + for _, testCase := range []struct { + name string + rootPattern string + treePattern string + requestTarget string + }{ + {name: "literal", rootPattern: "/tree", treePattern: "/tree/", requestTarget: "/tree"}, + {name: "wildcard", rootPattern: "/{value}", treePattern: "/{value}/", requestTarget: "/tree"}, + } { + t.Run(testCase.name, func(t *testing.T) { + builder := router.New() + mustRegister(t, builder, router.Route{ + Methods: []string{http.MethodGet}, Path: testCase.rootPattern, + Handler: http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.Header().Set("X-Route", "root") + writer.WriteHeader(http.StatusNoContent) + }), + }) + mustRegister(t, builder, router.Route{ + Methods: []string{http.MethodGet}, Path: testCase.treePattern, Handler: http.NotFoundHandler(), + }) + response := httptest.NewRecorder() + mustCompile(t, builder).ServeHTTP(response, httptest.NewRequest(http.MethodGet, testCase.requestTarget, nil)) + if response.Code != http.StatusNoContent || response.Header().Get("X-Route") != "root" || response.Header().Get("Location") != "" { + t.Fatalf("status=%d route=%q location=%q", response.Code, response.Header().Get("X-Route"), response.Header().Get("Location")) + } + }) + } +} + func TestDocumentedServeMuxDispatchDivergences(t *testing.T) { t.Parallel() diff --git a/compiler.go b/compiler.go index 6b2c15c..7b0c1a8 100644 --- a/compiler.go +++ b/compiler.go @@ -49,7 +49,6 @@ func (b *Builder) Compile() (*Router, error) { methodNotAllowed: b.methodNotAllowed, automaticOptions: b.automaticOptions, redirectPolicy: b.redirectPolicy, - canonicalizer: http.NewServeMux(), } hostIndexes := make(map[string]int) for _, route := range routes { diff --git a/docs/specification-decisions.md b/docs/specification-decisions.md index b19522f..6b878aa 100644 --- a/docs/specification-decisions.md +++ b/docs/specification-decisions.md @@ -22,12 +22,14 @@ Superseded decisions remain linked from their replacements. - **Selected behavior, security and resource consequences, compatibility and wire consequences:** Delegate supported literal, `{name}`, `{name...}`, and `{$}` path parsing, specificity, conflict, - extraction, GET-to-HEAD, and redirects to the pinned `ServeMux`. Package - extensions operate outside that matcher; no copied internal source, unsafe, - or registration-order tie-break exists. + extraction, and GET-to-HEAD behavior to `ServeMux`. Canonical and subtree + redirect locations preserve the pinned Go 1.26.6 serialization across later + toolchains. Package extensions operate outside the matcher; no copied matcher, + unsafe, or registration-order tie-break exists. - **Evidence, public surface, upstream, and reconsideration:** `TestSupportedMatchingIsDifferentialWithServeMux`, `TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux`, and + `TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior` plus `FuzzRoutePatternCompilation` cover `Builder.Register`, `Compile`, and `Router.ServeHTTP`. Reconsider when the minimum Go routing contract changes. @@ -55,15 +57,16 @@ Superseded decisions remain linked from their replacements. "Support literal paths only" ], "peer_behavior": "Go 1.26.6 ServeMux is the maintained differential peer for supported precedence, escaped segments, redirects, and path-value extraction.", - "selected_behavior": "Delegate supported literal, named wildcard, remainder wildcard, and end-marker parsing, specificity, conflicts, path values, GET-to-HEAD matching, and redirects to Go 1.26.6 ServeMux.", - "rationale": "Delegation preserves the declared Go contract and avoids an unreviewed parallel matcher.", + "selected_behavior": "Delegate supported literal, named wildcard, remainder wildcard, and end-marker parsing, specificity, conflicts, path values, and GET-to-HEAD matching to ServeMux while preserving Go 1.26.6 canonical and subtree redirect serialization across later toolchains.", + "rationale": "Delegation preserves the declared matching contract without an unreviewed parallel matcher, while a narrow redirect boundary prevents later toolchains from silently changing the pinned wire behavior.", "security_consequences": "No copied unsafe matcher or registration-order tie-break can reinterpret escaped path structure.", "resource_consequences": "ServeMux owns matcher construction and dispatch within the router's finite registration and request limits.", "compatibility_consequences": "Supported patterns track the pinned Go 1.26.6 contract; package extensions remain explicitly separate.", - "wire_consequences": "Dispatch, redirects, and extracted path values match ServeMux for the supported surface.", + "wire_consequences": "Dispatch and extracted path values match ServeMux for the supported surface, and redirect locations retain Go 1.26.6 escaped-path serialization.", "executable_evidence": [ "TestSupportedMatchingIsDifferentialWithServeMux", - "TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux" + "TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux", + "TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior" ], "fixture_evidence": [ "compatibility_security_test.go" @@ -87,7 +90,7 @@ Superseded decisions remain linked from their replacements. } ``` -Authority URL: https://go.dev/src/net/http/server.go?m=text +Authority URL: https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/server.go @@ -173,7 +176,7 @@ Authority URL: https://go.dev/src/net/http/server.go?m=text } ``` -Authority URL: https://go.dev/src/net/http/server.go?m=text +Authority URL: https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/server.go @@ -361,17 +364,20 @@ Authority URL: https://www.rfc-editor.org/rfc/rfc9112.txt disable all canonicalization, clean decoded paths, or classify structural changes using escaped paths. Routers disagree on `%2F` and trailing slash. - **Selected behavior, security and resource consequences, compatibility and wire consequences:** - Follow ServeMux canonical and subtree - redirects by default before route/method miss selection. `RejectRedirects` - converts structural redirects to 404 using escaped-path semantics and - standard patterns. Encoded separators and dot text inside a wildcard remain - data; literal and percent-encoded dot segments in registered patterns are - rejected. + Follow Go 1.26.6 ServeMux canonical and subtree redirects by default before + route/method miss selection, preserving that version's Location serialization + on later toolchains. `RejectRedirects` converts structural redirects to 404 + using escaped-path semantics and standard patterns. Encoded separators and + dot text inside a wildcard remain data; literal and percent-encoded dot + segments in registered patterns are rejected. - **Evidence, public surface, upstream, and reconsideration:** `TestCanonicalRedirectsPrecedeRouteAndMethodSelection`, + `TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior`, + `TestPinnedSubtreeRedirectDoesNotOverrideExplicitRoot`, `TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData`, and `TestRejectRedirectPolicyRejectsSemanticSubtreeRoots` cover `RedirectPolicy`. - Reconsider when Go canonicalization behavior changes. + Reconsider when the minimum Go version changes or a versioned compatibility + decision adopts newer redirect serialization.
Machine-auditable decision record @@ -397,14 +403,16 @@ Authority URL: https://www.rfc-editor.org/rfc/rfc9112.txt "Classify structural changes using escaped paths" ], "peer_behavior": "Go 1.26.6 ServeMux is the maintained differential peer for canonical redirects, escaped slashes, dot segments, and subtree roots.", - "selected_behavior": "Follow ServeMux canonical and subtree redirects by default before miss selection; when RejectRedirects is selected, convert structural redirects to 404 using escaped-path semantics while keeping encoded separators and dot text inside wildcards as data.", - "rationale": "Default delegation preserves Go behavior and escaped-path classification prevents encoded data from becoming false structure.", + "selected_behavior": "Follow Go 1.26.6 ServeMux canonical and subtree redirects by default before miss selection, preserving that version's redirect serialization across later toolchains; when RejectRedirects is selected, convert structural redirects to 404 using escaped-path semantics while keeping encoded separators and dot text inside wildcards as data.", + "rationale": "Pinned redirect serialization prevents toolchain upgrades from silently changing Location values, and escaped-path classification prevents encoded data from becoming false structure.", "security_consequences": "Literal and percent-encoded dot segments in registered patterns are rejected and encoded separators cannot bypass structural checks.", "resource_consequences": "Redirect classification uses bounded request and pattern data.", - "compatibility_consequences": "FollowRedirects remains ServeMux-compatible; RejectRedirects is an explicit defensive divergence.", - "wire_consequences": "Canonical requests redirect by default or return 404 under the explicit rejection policy.", + "compatibility_consequences": "FollowRedirects remains compatible with the pinned Go 1.26.6 ServeMux behavior on every supported toolchain; RejectRedirects is an explicit defensive divergence.", + "wire_consequences": "Canonical requests use Go 1.26.6 redirect locations by default or return 404 under the explicit rejection policy.", "executable_evidence": [ "TestCanonicalRedirectsPrecedeRouteAndMethodSelection", + "TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior", + "TestPinnedSubtreeRedirectDoesNotOverrideExplicitRoot", "TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData", "TestRejectRedirectPolicyRejectsSemanticSubtreeRoots" ], @@ -425,11 +433,11 @@ Authority URL: https://www.rfc-editor.org/rfc/rfc9112.txt "docs/specification-decisions.md" ], "upstream_status": "Go 1.26.6 ServeMux is the redirect-behavior authority.", - "reconsider_when": "Go canonicalization behavior changes." + "reconsider_when": "The minimum supported Go version changes or a versioned compatibility decision adopts newer redirect serialization." } ``` -Authority URL: https://go.dev/src/net/http/server.go?m=text +Authority URL: https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/server.go
@@ -601,7 +609,7 @@ Authority URL: https://www.rfc-editor.org/rfc/rfc9110.txt } ``` -Authority URL: https://go.dev/src/net/http/server.go?m=text +Authority URL: https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/server.go @@ -688,7 +696,7 @@ Authority URL: https://go.dev/src/net/http/server.go?m=text } ``` -Authority URL: https://go.dev/src/net/http/server.go?m=text +Authority URL: https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/server.go @@ -772,7 +780,7 @@ Authority URL: https://go.dev/src/net/http/server.go?m=text } ``` -Authority URL: https://go.dev/src/net/http/server.go?m=text +Authority URL: https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/server.go @@ -859,7 +867,7 @@ Authority URL: https://go.dev/src/net/http/server.go?m=text } ``` -Authority URL: https://go.dev/src/net/http/request.go?m=text +Authority URL: https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/request.go diff --git a/router.go b/router.go index 2eb2798..f7f6182 100644 --- a/router.go +++ b/router.go @@ -49,7 +49,6 @@ type Router struct { methodNotAllowed http.Handler automaticOptions bool redirectPolicy RedirectPolicy - canonicalizer *http.ServeMux } // Routes returns the deterministic compiled route table. @@ -116,12 +115,16 @@ func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) { return } if r.redirectPolicy == FollowRedirects && nonCanonicalPath(request.URL.EscapedPath()) { - r.canonicalizer.ServeHTTP(writer, request) + servePinnedCanonicalRedirect(writer, request) return } hosts := r.matchingHosts(authorityHost(request.Host)) if host := r.matchingHostForMethod(hosts, request); host != nil { + if host.requiresSubtreeRedirect(request) { + servePinnedSubtreeRedirect(writer, request) + return + } host.mux.ServeHTTP(writer, request) return } @@ -261,9 +264,15 @@ func (h *compiledHost) requiresRedirect(request *http.Request) bool { if nonCanonicalPath(request.URL.EscapedPath()) { return true } + return h.requiresSubtreeRedirect(request) +} + +func (h *compiledHost) requiresSubtreeRedirect(request *http.Request) bool { for _, redirects := range h.redirects { if _, pattern := redirects.Handler(request); pattern != "" { - return true + _, matchedPattern := h.mux.Handler(request) + _, matchedPath, ok := strings.Cut(matchedPattern, " ") + return ok && redirectRoot(matchedPath) != "" } } return false @@ -281,18 +290,38 @@ func redirectRoot(pattern string) string { return "" } +func servePinnedCanonicalRedirect(writer http.ResponseWriter, request *http.Request) { + redirect := &url.URL{ + Path: canonicalPath(request.URL.EscapedPath()), + RawQuery: request.URL.RawQuery, + } + http.RedirectHandler(redirect.String(), http.StatusTemporaryRedirect).ServeHTTP(writer, request) +} + +func servePinnedSubtreeRedirect(writer http.ResponseWriter, request *http.Request) { + redirect := &url.URL{ + Path: canonicalPath(request.URL.Path) + "/", + RawQuery: request.URL.RawQuery, + } + http.RedirectHandler(redirect.String(), http.StatusTemporaryRedirect).ServeHTTP(writer, request) +} + func nonCanonicalPath(value string) bool { + return canonicalPath(value) != value +} + +func canonicalPath(value string) string { if value == "" { - return true + return "/" } if value[0] != '/' { - return true + value = "/" + value } cleaned := pathpkg.Clean(value) if strings.HasSuffix(value, "/") && cleaned != "/" { cleaned += "/" } - return cleaned != value + return cleaned } func (r *Router) allMethods() []string { diff --git a/specification/README.md b/specification/README.md index 69fb52a..3b776f8 100644 --- a/specification/README.md +++ b/specification/README.md @@ -18,11 +18,11 @@ reconsideration behind this conformance matrix. | Decision | Authority | Executable evidence | Differential evidence | | --- | --- | --- | --- | -| ROUTER-DEC-001 | Go 1.26.6 `http.ServeMux` | `TestSupportedMatchingIsDifferentialWithServeMux`, `TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux` | `specification/interoperability.tsv` | +| ROUTER-DEC-001 | Go 1.26.6 `http.ServeMux` | `TestSupportedMatchingIsDifferentialWithServeMux`, `TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux`, `TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior` | `specification/interoperability.tsv` | | ROUTER-DEC-002 | Go 1.26.6 `http.ServeMux` registration | `TestCompileReturnsTypedConflictsAndFreezesOnlyOnSuccess`, `TestConflictFailureDoesNotConstructAnyMiddleware`, `TestRegistrationOrderDoesNotChangeDispatchOrIntrospection`, `TestPatternValidationPropagatesUncontrolledPanics` | `specification/interoperability.tsv` | | ROUTER-DEC-003 | RFC 9110 methods and `Allow` | `TestCompiledRouterPreservesHTTPMethodSemantics`, `TestExplicitOptionsAndHeadRoutesWin`, `TestDefaultNotFoundAndMethodNotAllowedMatchServeMux`, `TestDocumentedServeMuxDispatchDivergences` | `specification/interoperability.tsv` | | ROUTER-DEC-004 | RFC 9112 request-target forms | `TestAsteriskOptionsAndMalformedAuthority`, `TestMalformedRequestsBypassCustomHandlersAndRouteMiddleware`, `TestUnsupportedConnectRouteFailsAtStartup` | `specification/interoperability.tsv` | -| ROUTER-DEC-005 | Go 1.26.6 `http.ServeMux` redirects | `TestCanonicalRedirectsPrecedeRouteAndMethodSelection`, `TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData`, `TestRejectRedirectPolicyRejectsSemanticSubtreeRoots` | `specification/interoperability.tsv` | +| ROUTER-DEC-005 | Go 1.26.6 `http.ServeMux` redirects | `TestCanonicalRedirectsPrecedeRouteAndMethodSelection`, `TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior`, `TestPinnedSubtreeRedirectDoesNotOverrideExplicitRoot`, `TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData`, `TestRejectRedirectPolicyRejectsSemanticSubtreeRoots` | `specification/interoperability.tsv` | | ROUTER-DEC-006 | RFC 9110 authority handling | `TestHostPatternsMatchPortsAndSingleLabels`, `TestHostSpecificityFallbackAndEquivalentPatterns`, `TestAmbiguousHostPatternsAndUnsafeAuthoritiesAreRejected` | `specification/interoperability.tsv` | | ROUTER-DEC-007 | Go 1.26.6 handler composition | `TestNestedGroupsFlattenComposition`, `TestGroupCompositionRejectsInvalidAndPartialState`, `TestNestedGroupPrefixesUseComposedBudgets` | Not assessed | | ROUTER-DEC-008 | Go 1.26.6 handlers and response writers | `TestMiddlewareOrderAndIntrospectionAreStableAndImmutable`, `TestRouteMayExcludeNamedGroupMiddleware`, `TestMiddlewareMayShortCircuitPanicCancelAndReenter`, `TestRouterPreservesResponseWriterOptionalInterfaces` | Not assessed | diff --git a/specification/conformance.json b/specification/conformance.json index b5eb66f..56f6056 100644 --- a/specification/conformance.json +++ b/specification/conformance.json @@ -4,12 +4,12 @@ { "id": "ROUTER-DEC-001", "authoritative_sources": ["go-net-http"], - "executable_evidence": ["TestSupportedMatchingIsDifferentialWithServeMux", "TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux"], + "executable_evidence": ["TestSupportedMatchingIsDifferentialWithServeMux", "TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux", "TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior"], "fixtures": ["compatibility_security_test.go"], "fuzz": ["FuzzRoutePatternCompilation"], "differential_evidence": ["specification/interoperability.tsv"], "differential_classification": "maintained peer agreement", - "public_behavior": ["Supported patterns use Go 1.26.6 ServeMux matching, precedence, redirects, and path values."] + "public_behavior": ["Supported patterns use Go 1.26.6 ServeMux matching, precedence, redirect serialization, and path values across supported toolchains."] }, { "id": "ROUTER-DEC-002", @@ -44,12 +44,12 @@ { "id": "ROUTER-DEC-005", "authoritative_sources": ["go-net-http"], - "executable_evidence": ["TestCanonicalRedirectsPrecedeRouteAndMethodSelection", "TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData", "TestRejectRedirectPolicyRejectsSemanticSubtreeRoots"], + "executable_evidence": ["TestCanonicalRedirectsPrecedeRouteAndMethodSelection", "TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior", "TestPinnedSubtreeRedirectDoesNotOverrideExplicitRoot", "TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData", "TestRejectRedirectPolicyRejectsSemanticSubtreeRoots"], "fixtures": ["compatibility_security_test.go"], "fuzz": ["FuzzRequestTargets"], "differential_evidence": ["specification/interoperability.tsv"], "differential_classification": "deliberate policy difference", - "public_behavior": ["ServeMux redirects run by default and RejectRedirects classifies structure from escaped paths."] + "public_behavior": ["Go 1.26.6 ServeMux redirect serialization runs by default across supported toolchains and RejectRedirects classifies structure from escaped paths."] }, { "id": "ROUTER-DEC-006", diff --git a/specification/decision-history.json b/specification/decision-history.json index 19e7aa9..ee8ba74 100644 --- a/specification/decision-history.json +++ b/specification/decision-history.json @@ -1,11 +1,11 @@ { "schema_version": 1, "entries": [ - {"id": "ROUTER-DEC-001", "digests": ["56f4ea57efd1fb24b8650c883bea47f05872c63d6fb4f4fb43f1f2bdafe9ff8c"]}, + {"id": "ROUTER-DEC-001", "digests": ["56f4ea57efd1fb24b8650c883bea47f05872c63d6fb4f4fb43f1f2bdafe9ff8c", "f446182ad093b6e3dc1f40e180fe8ad03f88733233cf8a3f2722740c3b804585"]}, {"id": "ROUTER-DEC-002", "digests": ["f8dbfe4fe493cc3b71c400f985206aef6654f266f705ff0a6f8fdbb9fd979811"]}, {"id": "ROUTER-DEC-003", "digests": ["e57444cf3c6e408a366c339ed4f622897c655ccf85dfa6545628d07e04cd5964", "131ba5fada36d81eb47b1d595b624ee2cf0b95e6cfdfde47409b8492e156d2f3"]}, {"id": "ROUTER-DEC-004", "digests": ["d06af40fb0a74ae46f2f22d843c09effd2c397777bf07d2a9f931fdec3e9efb2"]}, - {"id": "ROUTER-DEC-005", "digests": ["383d99f58bc5bf65b234941feb386c096b673e5276e45cba86aa31e5d80971e9"]}, + {"id": "ROUTER-DEC-005", "digests": ["383d99f58bc5bf65b234941feb386c096b673e5276e45cba86aa31e5d80971e9", "35abe45420556f55ed3d65b43fb6f2d172710ada3949468b4a0526aac74db0d4", "adf00e74ff1fee7ab18248fef929a805e30d4dfd14ddaa69d67859d70af701f0"]}, {"id": "ROUTER-DEC-006", "digests": ["9fa23204c8ff2ae12d5631c7947091b32436be3bf075a29f2700037a7bce27c1"]}, {"id": "ROUTER-DEC-007", "digests": ["1eb13de30f37d6bc1e8e7924ace29a967364d88f22570ca7cc84f2c5659e9139"]}, {"id": "ROUTER-DEC-008", "digests": ["bcf6f5c4c6b7b3960e028c33645cedbb22dcfab2d7460d2acee73077edee2c2b"]}, diff --git a/specification/decisions.json b/specification/decisions.json index e45fabb..1c3be2a 100644 --- a/specification/decisions.json +++ b/specification/decisions.json @@ -24,13 +24,13 @@ "issue": "The standard library defines matching behavior, but a router can delegate to it, copy it, or define incompatible precedence and escaping rules.", "interpretations": ["Delegate supported behavior to ServeMux", "Copy internal matching behavior", "Implement an independent matcher", "Support literal paths only"], "peer_behavior": "Go 1.26.6 ServeMux is the maintained differential peer for supported precedence, escaped segments, redirects, and path-value extraction.", - "selected_behavior": "Delegate supported literal, named wildcard, remainder wildcard, and end-marker parsing, specificity, conflicts, path values, GET-to-HEAD matching, and redirects to Go 1.26.6 ServeMux.", - "rationale": "Delegation preserves the declared Go contract and avoids an unreviewed parallel matcher.", + "selected_behavior": "Delegate supported literal, named wildcard, remainder wildcard, and end-marker parsing, specificity, conflicts, path values, and GET-to-HEAD matching to ServeMux while preserving Go 1.26.6 canonical and subtree redirect serialization across later toolchains.", + "rationale": "Delegation preserves the declared matching contract without an unreviewed parallel matcher, while a narrow redirect boundary prevents later toolchains from silently changing the pinned wire behavior.", "security_consequences": "No copied unsafe matcher or registration-order tie-break can reinterpret escaped path structure.", "resource_consequences": "ServeMux owns matcher construction and dispatch within the router's finite registration and request limits.", "compatibility_consequences": "Supported patterns track the pinned Go 1.26.6 contract; package extensions remain explicitly separate.", - "wire_consequences": "Dispatch, redirects, and extracted path values match ServeMux for the supported surface.", - "executable_evidence": ["TestSupportedMatchingIsDifferentialWithServeMux", "TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux"], + "wire_consequences": "Dispatch and extracted path values match ServeMux for the supported surface, and redirect locations retain Go 1.26.6 escaped-path serialization.", + "executable_evidence": ["TestSupportedMatchingIsDifferentialWithServeMux", "TestSupportedMethodsAndLiteralHostsAreDifferentialWithServeMux", "TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior"], "fixture_evidence": ["compatibility_security_test.go"], "fuzz_evidence": ["FuzzRoutePatternCompilation"], "interoperability_evidence": ["specification/interoperability.tsv"], @@ -144,20 +144,20 @@ "issue": "ServeMux canonicalizes paths and subtree roots, while a defensive redirect policy must avoid treating percent-encoded separators or dot text as decoded structure.", "interpretations": ["Always follow ServeMux redirects", "Disable canonicalization", "Clean decoded paths", "Classify structural changes using escaped paths"], "peer_behavior": "Go 1.26.6 ServeMux is the maintained differential peer for canonical redirects, escaped slashes, dot segments, and subtree roots.", - "selected_behavior": "Follow ServeMux canonical and subtree redirects by default before miss selection; when RejectRedirects is selected, convert structural redirects to 404 using escaped-path semantics while keeping encoded separators and dot text inside wildcards as data.", - "rationale": "Default delegation preserves Go behavior and escaped-path classification prevents encoded data from becoming false structure.", + "selected_behavior": "Follow Go 1.26.6 ServeMux canonical and subtree redirects by default before miss selection, preserving that version's redirect serialization across later toolchains; when RejectRedirects is selected, convert structural redirects to 404 using escaped-path semantics while keeping encoded separators and dot text inside wildcards as data.", + "rationale": "Pinned redirect serialization prevents toolchain upgrades from silently changing Location values, and escaped-path classification prevents encoded data from becoming false structure.", "security_consequences": "Literal and percent-encoded dot segments in registered patterns are rejected and encoded separators cannot bypass structural checks.", "resource_consequences": "Redirect classification uses bounded request and pattern data.", - "compatibility_consequences": "FollowRedirects remains ServeMux-compatible; RejectRedirects is an explicit defensive divergence.", - "wire_consequences": "Canonical requests redirect by default or return 404 under the explicit rejection policy.", - "executable_evidence": ["TestCanonicalRedirectsPrecedeRouteAndMethodSelection", "TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData", "TestRejectRedirectPolicyRejectsSemanticSubtreeRoots"], + "compatibility_consequences": "FollowRedirects remains compatible with the pinned Go 1.26.6 ServeMux behavior on every supported toolchain; RejectRedirects is an explicit defensive divergence.", + "wire_consequences": "Canonical requests use Go 1.26.6 redirect locations by default or return 404 under the explicit rejection policy.", + "executable_evidence": ["TestCanonicalRedirectsPrecedeRouteAndMethodSelection", "TestPinnedServeMuxRedirectsPreserveGo126EscapedPathBehavior", "TestPinnedSubtreeRedirectDoesNotOverrideExplicitRoot", "TestRejectRedirectPolicyTreatsEncodedSeparatorsAsWildcardData", "TestRejectRedirectPolicyRejectsSemanticSubtreeRoots"], "fixture_evidence": ["compatibility_security_test.go"], "fuzz_evidence": ["FuzzRequestTargets"], "interoperability_evidence": ["specification/interoperability.tsv"], "public_apis": ["RedirectPolicy", "Router.ServeHTTP"], "documentation": ["docs/specification-decisions.md"], "upstream_status": "Go 1.26.6 ServeMux is the redirect-behavior authority.", - "reconsider_when": "Go canonicalization behavior changes." + "reconsider_when": "The minimum supported Go version changes or a versioned compatibility decision adopts newer redirect serialization." }, { "id": "ROUTER-DEC-006", diff --git a/specification/monitoring.json b/specification/monitoring.json index 9f5fd09..6f1cd90 100644 --- a/specification/monitoring.json +++ b/specification/monitoring.json @@ -1,30 +1,30 @@ { "schema_version": 1, - "reviewed_at": "2026-08-30", + "reviewed_at": "2026-09-03", "review_interval_days": 90, "authorities": [ { "id": "go-net-http", "kind": "specification", "version": "Go 1.26.6", - "url": "https://go.dev/src/net/http/server.go?m=text", - "sha256": "8a46dd48f65aba824366170f8f335417af263fc60b7b4e04ef2a4bba4a21253d", + "url": "https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/server.go", + "sha256": "ac720f2b48b30f6e1a58d125a08488c7849fd9723b5060d47d2304cde4de6a76", "specifications": ["Go 1.26.6 net/http and net/url contracts"] }, { "id": "go-net-http-request", "kind": "specification", "version": "Go 1.26.6", - "url": "https://go.dev/src/net/http/request.go?m=text", - "sha256": "c3257079994b4e4f74f01cef72508919983ba31f91fcf599d348e9d52cec1540", + "url": "https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/http/request.go", + "sha256": "4a34a9e157a606a046d5b9f8f761c571c4762668fa351d92048515a595939c38", "specifications": ["Go 1.26.6 net/http and net/url contracts"] }, { "id": "go-net-url", "kind": "specification", "version": "Go 1.26.6", - "url": "https://go.dev/src/net/url/url.go?m=text", - "sha256": "44c33f88526abf3b5955d6c97297be5c9d228e741b33e52c940dcac5a496e091", + "url": "https://raw.githubusercontent.com/golang/go/go1.26.6/src/net/url/url.go", + "sha256": "7bec001746c2cd512275c7269bc293f3d36cb004ddb193d736e02e361a3778aa", "specifications": ["Go 1.26.6 net/http and net/url contracts"] }, { @@ -32,7 +32,7 @@ "kind": "releases", "version": "", "url": "https://go.dev/dl/?mode=json&include=all", - "sha256": "c0696d6e5708cce9644204513ee4ecb994f3fd748065c54b0890158c34631207", + "sha256": "638127a053a86576fc235aa196b26145c6f2fce8ce839ded767212a18d1c9415", "specifications": ["Go 1.26.6 net/http and net/url contracts"] }, { @@ -64,7 +64,7 @@ "kind": "errata", "version": "", "url": "https://errata.rfc-editor.org/search/?rfc_number=9110&presentation=records", - "sha256": "38bd006c96f8963d58573f704c5313a5f81968b90738c03ade0b036ec7bbdf4b", + "sha256": "1f6790054c0cdb2f2a70a94fa2b9c73b09a4ee0578a32b4a3006ed0ecfaac86d", "specifications": ["RFC 9110 HTTP Semantics"] }, {