From 2e760121607b84cc99a6f68db84726961fbbb262 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 22:20:07 +0200 Subject: [PATCH 1/2] fix(jawsboot): clean parent-relative prefixes --- jawsboot/jawsboot.go | 13 ++++++++----- jawsboot/jawsboot_test.go | 40 ++++++++++++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/jawsboot/jawsboot.go b/jawsboot/jawsboot.go index a12356e2..07e7e04e 100644 --- a/jawsboot/jawsboot.go +++ b/jawsboot/jawsboot.go @@ -20,9 +20,11 @@ var assetsFS embed.FS // // It is intended to be passed to [jaws.Jaws.Setup]. Returned URLs should be // 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. [http.ServeMux] pattern syntax in prefix is treated as literal URL path data. +// be absolute ("/static"), relative ("static") or empty. It is cleaned relative +// to the URL root before asset names are joined. The returned URL path and the +// path component of the registered handler pattern are kept identical in 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 @@ -35,6 +37,7 @@ var assetsFS embed.FS // paths over content-hashed embedded asset names, so err only ever reflects a // failure to walk the embedded filesystem. func Setup(jw *jaws.Jaws, handleFn jaws.HandleFunc, prefix string) (urls []*url.URL, err error) { + rootedPrefix := path.Join("/", prefix) var files []*staticserve.StaticServe if err = staticserve.WalkDir(assetsFS, "assets/static", func(filename string, ss *staticserve.StaticServe) (err error) { files = append(files, ss) @@ -47,7 +50,7 @@ func Setup(jw *jaws.Jaws, handleFn jaws.HandleFunc, prefix string) (urls []*url. // a clean, slash-rooted path over a content-hashed embedded asset name, // 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)) + abspath := path.Join(rootedPrefix, ss.Name) u := &url.URL{Path: abspath} urls = append(urls, u) // Register the serialized path so ServeMux treats braces and other @@ -57,7 +60,7 @@ func Setup(jw *jaws.Jaws, handleFn jaws.HandleFunc, prefix string) (urls []*url. // Quietly 404 the predictable devtools source-map probes for the bundled // assets; they are served only at their exact content-hashed paths. for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { - u := &url.URL{Path: staticserve.EnsurePrefixSlash(path.Join(prefix, name))} + u := &url.URL{Path: path.Join(rootedPrefix, name)} handleFn(staticserve.NormalizeGET(u.String()), http.NotFoundHandler()) } } diff --git a/jawsboot/jawsboot_test.go b/jawsboot/jawsboot_test.go index 20b4952e..0e8a8925 100644 --- a/jawsboot/jawsboot_test.go +++ b/jawsboot/jawsboot_test.go @@ -186,11 +186,11 @@ func TestJawsBoot_SetupNilHandleFuncGeneratesHead(t *testing.T) { } // TestJawsBoot_SetupPrefixVariants verifies that for any prefix form (absolute, -// relative or empty) every asset URL emitted into the head HTML resolves to a -// registered handler. +// relative, parent-relative or empty) every asset URL emitted into the head HTML +// resolves to a registered handler. func TestJawsBoot_SetupPrefixVariants(t *testing.T) { assets := expectedStaticAssets(t, testAssetsFS, "assets/static", "") - for _, prefix := range []string{"/static", "static", ""} { + for _, prefix := range []string{"/static", "static", "../static", ""} { t.Run("prefix="+strconv.Quote(prefix), func(t *testing.T) { mux := http.NewServeMux() jw, err := jaws.New() @@ -210,7 +210,7 @@ func TestJawsBoot_SetupPrefixVariants(t *testing.T) { head := sb.String() for _, exp := range assets { - wantURI := staticserve.EnsurePrefixSlash(path.Join(prefix, exp.ss.Name)) + wantURI := path.Join("/", prefix, exp.ss.Name) if !strings.Contains(head, `"`+wantURI+`"`) { t.Errorf("head html missing %q", wantURI) } @@ -222,7 +222,7 @@ func TestJawsBoot_SetupPrefixVariants(t *testing.T) { } for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { - mapURI := staticserve.EnsurePrefixSlash(path.Join(prefix, name)) + mapURI := path.Join("/", prefix, name) rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, mapURI, nil)) if rr.Code != http.StatusNotFound { @@ -347,9 +347,9 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { // 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, -// relative and empty prefixes. +// relative, parent-relative and empty prefixes. func TestJawsBoot_SetupReturnedURLs(t *testing.T) { - for _, prefix := range []string{"/static", "static", ""} { + for _, prefix := range []string{"/static", "static", "../static", ""} { t.Run("prefix="+strconv.Quote(prefix), func(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -357,9 +357,11 @@ func TestJawsBoot_SetupReturnedURLs(t *testing.T) { } defer jw.Close() + mux := http.NewServeMux() registered := map[string]bool{} - handleFn := func(pattern string, _ http.Handler) { + handleFn := func(pattern string, handler http.Handler) { registered[pattern] = true + mux.Handle(pattern, handler) } urls, err := jawsboot.Setup(jw, handleFn, prefix) @@ -370,12 +372,32 @@ func TestJawsBoot_SetupReturnedURLs(t *testing.T) { t.Fatal("Setup returned no URLs") } for _, u := range urls { - if !strings.HasPrefix(u.String(), "/") { + if !path.IsAbs(u.Path) { t.Errorf("returned URL %q is not absolute", u.String()) } + if u.Path != path.Clean(u.Path) { + t.Errorf("returned URL path %q is not clean", u.Path) + } if !registered[staticserve.NormalizeGET(u.String())] { t.Errorf("returned URL %q has no matching registered handler", u.String()) } + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, u.String(), nil)) + if rr.Code != http.StatusOK { + t.Errorf("GET returned URL %q = %d, want 200", u.String(), rr.Code) + } + } + + for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { + mapURI := path.Join("/", prefix, name) + if !registered[staticserve.NormalizeGET(mapURI)] { + t.Errorf("source-map path %q has no matching registered handler", mapURI) + } + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, mapURI, nil)) + if rr.Code != http.StatusNotFound { + t.Errorf("GET source-map path %q = %d, want 404", mapURI, rr.Code) + } } }) } From 01dce1a6af5dfb9c780dd1a89ad6b04f79986d63 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 7 Aug 2026 23:17:44 +0200 Subject: [PATCH 2/2] test(jawsboot): pin normalized prefix outcomes --- jawsboot/jawsboot.go | 10 ++--- jawsboot/jawsboot_test.go | 94 ++++++++++++++++++++++++++------------- 2 files changed, 69 insertions(+), 35 deletions(-) diff --git a/jawsboot/jawsboot.go b/jawsboot/jawsboot.go index 07e7e04e..f92f89b4 100644 --- a/jawsboot/jawsboot.go +++ b/jawsboot/jawsboot.go @@ -20,11 +20,11 @@ var assetsFS embed.FS // // It is intended to be passed to [jaws.Jaws.Setup]. Returned URLs should be // included in the page head through [jaws.Jaws.GenerateHeadHTML]. The prefix may -// be absolute ("/static"), relative ("static") or empty. It is cleaned relative -// to the URL root before asset names are joined. The returned URL path and the -// path component of the registered handler pattern are kept identical in all -// cases. [http.ServeMux] pattern syntax in prefix is treated as literal URL path -// data. +// be absolute ("/static"), relative ("static") or empty. The prefix is rooted +// and cleaned before asset names are joined, so "../static" becomes "/static". +// The returned URL path and the path component of the registered handler pattern +// are kept identical in 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 diff --git a/jawsboot/jawsboot_test.go b/jawsboot/jawsboot_test.go index 0e8a8925..596d64ac 100644 --- a/jawsboot/jawsboot_test.go +++ b/jawsboot/jawsboot_test.go @@ -21,6 +21,32 @@ import ( //go:embed assets var testAssetsFS embed.FS +var setupPrefixCases = [...]struct { + name string + prefix string + wantRoot string +}{ + {name: "absolute", prefix: "/static", wantRoot: "/static"}, + {name: "relative", prefix: "static", wantRoot: "/static"}, + {name: "parent-relative", prefix: "../static", wantRoot: "/static"}, + {name: "empty", prefix: "", wantRoot: "/"}, +} + +func expectedJawsBootURL(wantRoot, name string) string { + return (&url.URL{Path: path.Join(wantRoot, name)}).String() +} + +func runSetupWithoutPanic(t *testing.T, prefix string, setup func()) { + t.Helper() + defer func() { + if recovered := recover(); recovered != nil { + t.Helper() + t.Fatalf("Setup(%q) panicked: %v", prefix, recovered) + } + }() + setup() +} + // Asset files are already tracked by git. Keep these tests focused on serving, // headers and integration behavior; do not add stored-hash provenance tests for // files whose contents and history are in the repository. @@ -190,16 +216,20 @@ func TestJawsBoot_SetupNilHandleFuncGeneratesHead(t *testing.T) { // resolves to a registered handler. func TestJawsBoot_SetupPrefixVariants(t *testing.T) { assets := expectedStaticAssets(t, testAssetsFS, "assets/static", "") - for _, prefix := range []string{"/static", "static", "../static", ""} { - t.Run("prefix="+strconv.Quote(prefix), func(t *testing.T) { + for _, tc := range setupPrefixCases { + t.Run(tc.name+"="+strconv.Quote(tc.prefix), func(t *testing.T) { mux := http.NewServeMux() jw, err := jaws.New() if err != nil { t.Fatal(err) } defer jw.Close() - if err := jw.Setup(mux.Handle, prefix, jawsboot.Setup); err != nil { - t.Fatal(err) + var setupErr error + runSetupWithoutPanic(t, tc.prefix, func() { + setupErr = jw.Setup(mux.Handle, tc.prefix, jawsboot.Setup) + }) + if setupErr != nil { + t.Fatal(setupErr) } rq := jw.NewRequest(nil) @@ -210,23 +240,23 @@ func TestJawsBoot_SetupPrefixVariants(t *testing.T) { head := sb.String() for _, exp := range assets { - wantURI := path.Join("/", prefix, exp.ss.Name) + wantURI := expectedJawsBootURL(tc.wantRoot, exp.ss.Name) if !strings.Contains(head, `"`+wantURI+`"`) { t.Errorf("head html missing %q", wantURI) } rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, wantURI, nil)) if rr.Code != http.StatusOK { - t.Errorf("GET %q (prefix %q) = %d, want 200 (head URL must match a registered handler)", wantURI, prefix, rr.Code) + t.Errorf("GET %q (prefix %q) = %d, want 200 (head URL must match a registered handler)", wantURI, tc.prefix, rr.Code) } } for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { - mapURI := path.Join("/", prefix, name) + mapURI := expectedJawsBootURL(tc.wantRoot, name) rr := httptest.NewRecorder() mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, mapURI, nil)) if rr.Code != http.StatusNotFound { - t.Errorf("GET %q (prefix %q) = %d, want 404 (sourcemap probe must be 404)", mapURI, prefix, rr.Code) + t.Errorf("GET %q (prefix %q) = %d, want 404 (sourcemap probe must be 404)", mapURI, tc.prefix, rr.Code) } } }) @@ -238,11 +268,12 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { for _, tc := range []struct { name string prefix string + wantRoot 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"}, + {name: "absolute partial segment", prefix: "/static{assets}", wantRoot: "/static{assets}"}, + {name: "relative partial segment", prefix: "static{assets}", wantRoot: "/static{assets}"}, + {name: "complete wildcard segment", prefix: "/{assets}", wantRoot: "/{assets}", outsidePrefix: "/outside"}, } { t.Run(tc.name, func(t *testing.T) { const fallbackStatus = http.StatusTeapot @@ -257,17 +288,12 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { t.Cleanup(jw.Close) var ( - urls []*url.URL - setupErr error - panicValue any + urls []*url.URL + setupErr error ) - func() { - defer func() { panicValue = recover() }() + runSetupWithoutPanic(t, tc.prefix, func() { 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) } @@ -283,8 +309,7 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { } for _, exp := range assets { - assetPath := staticserve.EnsurePrefixSlash(path.Join(tc.prefix, exp.ss.Name)) - assetURL := (&url.URL{Path: assetPath}).String() + assetURL := expectedJawsBootURL(tc.wantRoot, exp.ss.Name) if !returnedURLs[assetURL] { t.Errorf("Setup(%q) did not return expected asset URL %q", tc.prefix, assetURL) } @@ -316,8 +341,7 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { } 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() + mapURL := expectedJawsBootURL(tc.wantRoot, name) 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", @@ -349,8 +373,8 @@ func TestJawsBoot_SetupLiteralBracePrefixes(t *testing.T) { // and resolves to a handler registered via the supplied HandleFunc, for absolute, // relative, parent-relative and empty prefixes. func TestJawsBoot_SetupReturnedURLs(t *testing.T) { - for _, prefix := range []string{"/static", "static", "../static", ""} { - t.Run("prefix="+strconv.Quote(prefix), func(t *testing.T) { + for _, tc := range setupPrefixCases { + t.Run(tc.name+"="+strconv.Quote(tc.prefix), func(t *testing.T) { jw, err := jaws.New() if err != nil { t.Fatal(err) @@ -364,13 +388,20 @@ func TestJawsBoot_SetupReturnedURLs(t *testing.T) { mux.Handle(pattern, handler) } - urls, err := jawsboot.Setup(jw, handleFn, prefix) - if err != nil { - t.Fatal(err) + var ( + urls []*url.URL + setupErr error + ) + runSetupWithoutPanic(t, tc.prefix, func() { + urls, setupErr = jawsboot.Setup(jw, handleFn, tc.prefix) + }) + if setupErr != nil { + t.Fatal(setupErr) } if len(urls) == 0 { t.Fatal("Setup returned no URLs") } + wantPathPrefix := strings.TrimSuffix(tc.wantRoot, "/") + "/" for _, u := range urls { if !path.IsAbs(u.Path) { t.Errorf("returned URL %q is not absolute", u.String()) @@ -378,6 +409,9 @@ func TestJawsBoot_SetupReturnedURLs(t *testing.T) { if u.Path != path.Clean(u.Path) { t.Errorf("returned URL path %q is not clean", u.Path) } + if !strings.HasPrefix(u.Path, wantPathPrefix) { + t.Errorf("returned URL path %q is outside expected root %q", u.Path, tc.wantRoot) + } if !registered[staticserve.NormalizeGET(u.String())] { t.Errorf("returned URL %q has no matching registered handler", u.String()) } @@ -389,7 +423,7 @@ func TestJawsBoot_SetupReturnedURLs(t *testing.T) { } for _, name := range []string{"bootstrap.bundle.min.js.map", "bootstrap.min.css.map"} { - mapURI := path.Join("/", prefix, name) + mapURI := expectedJawsBootURL(tc.wantRoot, name) if !registered[staticserve.NormalizeGET(mapURI)] { t.Errorf("source-map path %q has no matching registered handler", mapURI) }