vitrine

dispatch: longest-prefix match, not first map-iteration hit

commit e5ae3403860482af5467de41f785287a22dd42a3 · parent bd278b0336 · browse this tree
Ximianth, 2026-08-14 16:19:15 UTC

Two mounts where one name nests under the other (e.g. "docs" and
"docs/deep") used to dispatch by map iteration order: whichever
prefix-matched first served the request.  Requests inside the nested
mount could land in either tree, varying run to run.

Now the longest matching mount name wins, always.  This also sets the
dispatch rule that forwarding mounts (upcoming) will share.
diff --git a/serve.go b/serve.go
index 6225ed3..71d2a4c 100644
--- a/serve.go
+++ b/serve.go
@@ -116,16 +116,28 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	mounts := *s.mounts.Load()
 
 	// A request path inside a mount is served from that tree.
+	// Longest prefix wins, so a mount nested under another mount's name
+	// (e.g. "docs/deep" under "docs") is dispatched deterministically
+	// to the more specific one, never by map iteration luck.
+	bestLen := -1
+	var bestTree tree
+	var bestMount string
 	for mount, tr := range mounts {
 		if req == mount || strings.HasPrefix(req, mount+"/") {
-			rel := strings.TrimPrefix(strings.TrimPrefix(req, mount), "/")
-			if rel == "" {
-				rel = "."
+			if len(mount) > bestLen {
+				bestLen = len(mount)
+				bestMount, bestTree = mount, tr
 			}
-			s.serveFromTree(w, r, tr, mount, rel)
-			return
 		}
 	}
+	if bestLen >= 0 {
+		rel := strings.TrimPrefix(strings.TrimPrefix(req, bestMount), "/")
+		if rel == "" {
+			rel = "."
+		}
+		s.serveFromTree(w, r, bestTree, bestMount, rel)
+		return
+	}
 
 	// A request path *above* a mount gets a synthetic listing
 	// showing only the exposed next-level names.
diff --git a/serve_test.go b/serve_test.go
index fa07d3c..17c8e6b 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -157,6 +157,37 @@ func TestSyntheticListingsAboveMount(t *testing.T) {
 	}
 }
 
+// TestNestedMountWinsOverParent pins dispatch to longest-prefix matching.
+// Both "docs" and "docs/deep" are mounted, and the parent tree contains its own
+// deep/note.md, so both mounts can serve the request path -- with different bytes.
+// Map iteration order made the old first-hit dispatch nondeterministic here,
+// hence the repeated requests: one lucky pass must not mask the bug.
+func TestNestedMountWinsOverParent(t *testing.T) {
+	dir := t.TempDir()
+	for name, content := range map[string]string{
+		"parent/deep/note.md": "from parent tree\n",
+		"child/note.md":       "from child mount\n",
+	} {
+		p := filepath.Join(dir, filepath.FromSlash(name))
+		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+			t.Fatal(err)
+		}
+		if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
+			t.Fatal(err)
+		}
+	}
+	srv := newServer(map[string]exhibit{
+		"docs":      {Path: filepath.Join(dir, "parent")},
+		"docs/deep": {Path: filepath.Join(dir, "child")},
+	}, theme{})
+	for i := 0; i < 20; i++ {
+		rec := get(t, srv, "/docs/deep/note.md?fmt=raw")
+		if got := rec.Body.String(); got != "from child mount\n" {
+			t.Fatalf("request %d served %q; want the more specific mount", i, got)
+		}
+	}
+}
+
 func TestUnmountedSiblingsInvisible(t *testing.T) {
 	srv := newNestedTestServer(t)
 	rec := get(t, srv, "/meta/research/")