From 01114d4d625ff4c69d221d6f80312a9cbe7c3a97 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 6 Aug 2026 20:49:57 +0200 Subject: [PATCH 1/3] fix(jawsboot): treat route prefixes as literal paths --- jawsboot/jawsboot.go | 15 ++++-- jawsboot/jawsboot_test.go | 102 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/jawsboot/jawsboot.go b/jawsboot/jawsboot.go index 3766b7cc..a12356e2 100644 --- a/jawsboot/jawsboot.go +++ b/jawsboot/jawsboot.go @@ -22,7 +22,7 @@ var assetsFS embed.FS // included in the page head through [jaws.Jaws.GenerateHeadHTML]. The prefix may // be absolute ("/static"), relative ("static") or empty; the returned URL path // and the path component of the registered handler pattern are kept identical in -// all cases. +// all cases. [http.ServeMux] pattern syntax in prefix is treated as literal URL path data. // // Setup also registers [http.NotFoundHandler] (404) routes under prefix for the // bundled bootstrap *.map sourcemap paths, quietly answering devtools probes for @@ -48,13 +48,18 @@ func Setup(jw *jaws.Jaws, handleFn jaws.HandleFunc, prefix string) (urls []*url. // so it is always a valid URL path; construct the URL directly rather // than via the fallible url.Parse. abspath := staticserve.EnsurePrefixSlash(path.Join(prefix, ss.Name)) - urls = append(urls, &url.URL{Path: abspath}) - handleFn(staticserve.NormalizeGET(abspath), ss) + u := &url.URL{Path: abspath} + urls = append(urls, u) + // Register the serialized path so ServeMux treats braces and other + // pattern syntax in the logical URL path as literal data. + handleFn(staticserve.NormalizeGET(u.String()), ss) } // Quietly 404 the predictable devtools source-map probes for the bundled // assets; they are served only at their exact content-hashed paths. - handleFn(staticserve.NormalizeGET(path.Join(prefix, "bootstrap.bundle.min.js.map")), http.NotFoundHandler()) - handleFn(staticserve.NormalizeGET(path.Join(prefix, "bootstrap.min.css.map")), http.NotFoundHandler()) + for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { + u := &url.URL{Path: staticserve.EnsurePrefixSlash(path.Join(prefix, name))} + handleFn(staticserve.NormalizeGET(u.String()), http.NotFoundHandler()) + } } return } diff --git a/jawsboot/jawsboot_test.go b/jawsboot/jawsboot_test.go index e2c00eb6..3a318133 100644 --- a/jawsboot/jawsboot_test.go +++ b/jawsboot/jawsboot_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "path" "strconv" "strings" @@ -232,6 +233,107 @@ func TestJawsBoot_SetupPrefixVariants(t *testing.T) { } } +func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { + for _, tc := range []struct { + name string + prefix string + outsidePrefix string + }{ + {name: "absolute partial segment", prefix: "/static{assets}"}, + {name: "relative partial segment", prefix: "static{assets}"}, + {name: "complete wildcard segment", prefix: "/{assets}", outsidePrefix: "/outside"}, + } { + t.Run(tc.name, func(t *testing.T) { + const fallbackStatus = http.StatusTeapot + mux := http.NewServeMux() + mux.HandleFunc("GET /", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(fallbackStatus) + }) + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + + var ( + urls []*url.URL + setupErr error + panicValue any + ) + func() { + defer func() { panicValue = recover() }() + urls, setupErr = jawsboot.Setup(jw, mux.Handle, tc.prefix) + }() + if panicValue != nil { + t.Fatalf("Setup(%q) panicked: %v", tc.prefix, panicValue) + } + if setupErr != nil { + t.Fatal(setupErr) + } + if len(urls) == 0 { + t.Fatal("Setup returned no URLs") + } + + for _, u := range urls { + assetURL := u.String() + if !strings.Contains(assetURL, "%7B") || !strings.Contains(assetURL, "%7D") { + t.Errorf("asset URL %q does not contain an escaped brace pair", assetURL) + } + if strings.Contains(assetURL, "%257B") || strings.Contains(assetURL, "%257D") { + t.Errorf("asset URL %q contains double-escaped braces", assetURL) + } + r := httptest.NewRequest(http.MethodGet, assetURL, nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, r) + if rr.Code != http.StatusOK { + t.Errorf("GET %q = %d, want 200", assetURL, rr.Code) + } + if _, pattern := mux.Handler(r); pattern != staticserve.NormalizeGET(assetURL) { + t.Errorf("GET %q matched pattern %q, want literal pattern %q", + assetURL, pattern, staticserve.NormalizeGET(assetURL)) + } + + if tc.outsidePrefix != "" { + outsideURI := path.Join(tc.outsidePrefix, path.Base(u.Path)) + outsideRequest := httptest.NewRequest(http.MethodGet, outsideURI, nil) + outsideRecorder := httptest.NewRecorder() + mux.ServeHTTP(outsideRecorder, outsideRequest) + if outsideRecorder.Code != fallbackStatus { + t.Errorf("GET %q = %d, want fallback status %d", + outsideURI, outsideRecorder.Code, fallbackStatus) + } + } + } + + for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { + mapPath := staticserve.EnsurePrefixSlash(path.Join(tc.prefix, name)) + mapURL := (&url.URL{Path: mapPath}).String() + r := httptest.NewRequest(http.MethodGet, mapURL, nil) + if _, pattern := mux.Handler(r); pattern != staticserve.NormalizeGET(mapURL) { + t.Errorf("GET %q matched pattern %q, want literal 404 pattern %q", + mapURL, pattern, staticserve.NormalizeGET(mapURL)) + } + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, r) + if rr.Code != http.StatusNotFound { + t.Errorf("GET %q = %d, want 404", mapURL, rr.Code) + } + + if tc.outsidePrefix != "" { + outsideURI := path.Join(tc.outsidePrefix, name) + outsideRequest := httptest.NewRequest(http.MethodGet, outsideURI, nil) + outsideRecorder := httptest.NewRecorder() + mux.ServeHTTP(outsideRecorder, outsideRequest) + if outsideRecorder.Code != fallbackStatus { + t.Errorf("GET %q = %d, want fallback status %d", + outsideURI, outsideRecorder.Code, fallbackStatus) + } + } + } + }) + } +} + // TestJawsBoot_SetupReturnedURLs pins jawsboot.Setup's exported (urls, err) contract // directly, independently of jaws.Setup's wrapping: every returned URL is absolute // and resolves to a handler registered via the supplied HandleFunc, for absolute, From 088178e62f2d71ead7b6d64b62c43dc8252a026c Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 6 Aug 2026 21:09:58 +0200 Subject: [PATCH 2/3] test(jawsboot): pin brace-prefixed asset URLs --- jawsboot/jawsboot_test.go | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/jawsboot/jawsboot_test.go b/jawsboot/jawsboot_test.go index 3a318133..20b4952e 100644 --- a/jawsboot/jawsboot_test.go +++ b/jawsboot/jawsboot_test.go @@ -234,6 +234,7 @@ func TestJawsBoot_SetupPrefixVariants(t *testing.T) { } func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { + assets := expectedStaticAssets(t, testAssetsFS, "assets/static", "") for _, tc := range []struct { name string prefix string @@ -273,15 +274,21 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { if len(urls) == 0 { t.Fatal("Setup returned no URLs") } - + if got, want := len(urls), len(assets); got != want { + t.Errorf("Setup returned %d asset URLs, want %d", got, want) + } + returnedURLs := make(map[string]bool, len(urls)) for _, u := range urls { - assetURL := u.String() - if !strings.Contains(assetURL, "%7B") || !strings.Contains(assetURL, "%7D") { - t.Errorf("asset URL %q does not contain an escaped brace pair", assetURL) - } - if strings.Contains(assetURL, "%257B") || strings.Contains(assetURL, "%257D") { - t.Errorf("asset URL %q contains double-escaped braces", assetURL) + returnedURLs[u.String()] = true + } + + for _, exp := range assets { + assetPath := staticserve.EnsurePrefixSlash(path.Join(tc.prefix, exp.ss.Name)) + assetURL := (&url.URL{Path: assetPath}).String() + if !returnedURLs[assetURL] { + t.Errorf("Setup(%q) did not return expected asset URL %q", tc.prefix, assetURL) } + delete(returnedURLs, assetURL) r := httptest.NewRequest(http.MethodGet, assetURL, nil) rr := httptest.NewRecorder() mux.ServeHTTP(rr, r) @@ -294,7 +301,7 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { } if tc.outsidePrefix != "" { - outsideURI := path.Join(tc.outsidePrefix, path.Base(u.Path)) + outsideURI := path.Join(tc.outsidePrefix, exp.ss.Name) outsideRequest := httptest.NewRequest(http.MethodGet, outsideURI, nil) outsideRecorder := httptest.NewRecorder() mux.ServeHTTP(outsideRecorder, outsideRequest) @@ -304,6 +311,9 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { } } } + for unexpectedURL := range returnedURLs { + t.Errorf("Setup(%q) returned unexpected asset URL %q", tc.prefix, unexpectedURL) + } for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { mapPath := staticserve.EnsurePrefixSlash(path.Join(tc.prefix, name)) From 375f8f8c910183ac01b89c90ebcc9399a8f00f84 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 07:13:46 +0200 Subject: [PATCH 3/3] chore: restart GitHub Actions