detached 0219778

refactor(web): split writing the path list and match raw validators as a list

paths separates authorizing, the 304 and loading from writePathList, which
only writes the body. The JSON of the paths is not HTML-escaped. raw reads
If-None-Match with etagMatches, so a list of validators, weak ones and *
give a 304 as they do for paths.
commit
0219778b77916c30fc21e26cabb155f77512d155
author
brice · · 2026-10-12 05:10 UTC
parent
b06e385
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +40 -8
M internal/adapters/web/paths.go +18 -6
@@ -82,18 +82,27 @@ func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
8282 s.fail(ctx, w, r, err)
8383 return
8484 }
85+ // Authorized, not modified and loaded: all that is left is to send it.
86+ validators(w.Header())
87+ if err := writePathList(w, r, body, gz); err != nil {
88+ s.fail(ctx, w, r, err)
89+ }
90+}
91+
92+// writePathList sends a list (gzipped JSON, as the cache keeps it): as it is
93+// to a client that accepts gzip, unzipped to the others. The validators are
94+// already set. It fails only before anything is written.
95+func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) error {
8596 var plain *gzip.Reader
8697 if !gz {
98+ var err error
8799 if plain, err = gzip.NewReader(bytes.NewReader(body)); err != nil {
88- s.fail(ctx, w, r, err)
89- return
100+ return err
90101 }
91102 }
92-
93103 h := w.Header()
94104 h.Set("Content-Type", "application/json")
95105 h.Set("X-Content-Type-Options", "nosniff")
96- validators(h)
97106 if gz {
98107 h.Set("Content-Encoding", "gzip")
99108 h.Set("Content-Length", strconv.Itoa(len(body)))
@@ -101,12 +110,13 @@ func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
101110 if r.Method != http.MethodHead {
102111 _, _ = w.Write(body)
103112 }
104- return
113+ return nil
105114 }
106115 w.WriteHeader(http.StatusOK)
107116 if r.Method != http.MethodHead {
108117 _, _ = io.Copy(w, plain)
109118 }
119+ return nil
110120 }
111121
112122 // buildPaths lists the paths of a commit and returns them as gzipped JSON.
@@ -131,7 +141,9 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro
131141 if err != nil {
132142 return nil, err
133143 }
134- if err := json.NewEncoder(zw).Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil {
144+ enc := json.NewEncoder(zw)
145+ enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are
146+ if err := enc.Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil {
135147 return nil, err
136148 }
137149 if err := zw.Close(); err != nil {
@@ -82,18 +82,27 @@ func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
82 s.fail(ctx, w, r, err)82 s.fail(ctx, w, r, err)
83 return83 return
84 }84 }
85+ // Authorized, not modified and loaded: all that is left is to send it.
86+ validators(w.Header())
87+ if err := writePathList(w, r, body, gz); err != nil {
88+ s.fail(ctx, w, r, err)
89+ }
90+}
91+
92+// writePathList sends a list (gzipped JSON, as the cache keeps it): as it is
93+// to a client that accepts gzip, unzipped to the others. The validators are
94+// already set. It fails only before anything is written.
95+func writePathList(w http.ResponseWriter, r *http.Request, body []byte, gz bool) error {
85 var plain *gzip.Reader96 var plain *gzip.Reader
86 if !gz {97 if !gz {
98+ var err error
87 if plain, err = gzip.NewReader(bytes.NewReader(body)); err != nil {99 if plain, err = gzip.NewReader(bytes.NewReader(body)); err != nil {
88- s.fail(ctx, w, r, err)100+ return err
89- return
90 }101 }
91 }102 }
92-
93 h := w.Header()103 h := w.Header()
94 h.Set("Content-Type", "application/json")104 h.Set("Content-Type", "application/json")
95 h.Set("X-Content-Type-Options", "nosniff")105 h.Set("X-Content-Type-Options", "nosniff")
96- validators(h)
97 if gz {106 if gz {
98 h.Set("Content-Encoding", "gzip")107 h.Set("Content-Encoding", "gzip")
99 h.Set("Content-Length", strconv.Itoa(len(body)))108 h.Set("Content-Length", strconv.Itoa(len(body)))
@@ -101,12 +110,13 @@ func (s *Server) paths(w http.ResponseWriter, r *http.Request) {
101 if r.Method != http.MethodHead {110 if r.Method != http.MethodHead {
102 _, _ = w.Write(body)111 _, _ = w.Write(body)
103 }112 }
104- return113+ return nil
105 }114 }
106 w.WriteHeader(http.StatusOK)115 w.WriteHeader(http.StatusOK)
107 if r.Method != http.MethodHead {116 if r.Method != http.MethodHead {
108 _, _ = io.Copy(w, plain)117 _, _ = io.Copy(w, plain)
109 }118 }
119+ return nil
110 }120 }
111 121
112 // buildPaths lists the paths of a commit and returns them as gzipped JSON.122 // buildPaths lists the paths of a commit and returns them as gzipped JSON.
@@ -131,7 +141,9 @@ func (s *Server) buildPaths(ctx context.Context, repo, sha string) ([]byte, erro
131 if err != nil {141 if err != nil {
132 return nil, err142 return nil, err
133 }143 }
134- if err := json.NewEncoder(zw).Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil {144+ enc := json.NewEncoder(zw)
145+ enc.SetEscapeHTML(false) // a path is not HTML; "<", ">" and "&" stay as they are
146+ if err := enc.Encode(pathList{Paths: paths, Truncated: res.Msg.GetTruncated()}); err != nil {
135 return nil, err147 return nil, err
136 }148 }
137 if err := zw.Close(); err != nil {149 if err := zw.Close(); err != nil {
M internal/adapters/web/paths_test.go +21 -1
@@ -3,6 +3,7 @@
33 package web
44
55 import (
6+ "bytes"
67 "compress/gzip"
78 "context"
89 "io"
@@ -25,6 +26,7 @@ import (
2526 type pathsGit struct {
2627 ermiteconnect.GitServiceClient
2728 lists atomic.Int32
29+ paths []string // the list; a.go and b/c.go when nil
2830 }
2931
3032 func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
@@ -33,7 +35,11 @@ func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.Re
3335
3436 func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) {
3537 g.lists.Add(1)
36- return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: []string{"a.go", "b/c.go"}}), nil
38+ paths := g.paths
39+ if paths == nil {
40+ paths = []string{"a.go", "b/c.go"}
41+ }
42+ return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: paths}), nil
3743 }
3844
3945 // A revalidation is answered after the access check and before the list is
@@ -151,3 +157,17 @@ func TestEtagMatches(t *testing.T) {
151157 assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header)
152158 }
153159 }
160+
161+// A path is not HTML: "<", ">" and "&" are not escaped in the JSON.
162+func TestBuildPathsDoesNotEscapeHTML(t *testing.T) {
163+ git := &pathsGit{paths: []string{"a&b/<c>.go"}}
164+ srv, err := New(Config{Repos: failing{}, Git: git, PublicURL: "https://forge.example"})
165+ require.NoError(t, err)
166+ body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40))
167+ require.NoError(t, err)
168+ zr, err := gzip.NewReader(bytes.NewReader(body))
169+ require.NoError(t, err)
170+ out, err := io.ReadAll(zr)
171+ require.NoError(t, err)
172+ assert.Contains(t, string(out), `"a&b/<c>.go"`)
173+}
@@ -3,6 +3,7 @@
3 package web3 package web
4 4
5 import (5 import (
6+ "bytes"
6 "compress/gzip"7 "compress/gzip"
7 "context"8 "context"
8 "io"9 "io"
@@ -25,6 +26,7 @@ import (
25 type pathsGit struct {26 type pathsGit struct {
26 ermiteconnect.GitServiceClient27 ermiteconnect.GitServiceClient
27 lists atomic.Int3228 lists atomic.Int32
29+ paths []string // the list; a.go and b/c.go when nil
28 }30 }
29 31
30 func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {32 func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.ResolveRevisionRequest]) (*connect.Response[pb.ResolveRevisionResponse], error) {
@@ -33,7 +35,11 @@ func (g *pathsGit) ResolveRevision(_ context.Context, req *connect.Request[pb.Re
33 35
34 func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) {36 func (g *pathsGit) ListPaths(_ context.Context, req *connect.Request[pb.ListPathsRequest]) (*connect.Response[pb.ListPathsResponse], error) {
35 g.lists.Add(1)37 g.lists.Add(1)
36- return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: []string{"a.go", "b/c.go"}}), nil38+ paths := g.paths
39+ if paths == nil {
40+ paths = []string{"a.go", "b/c.go"}
41+ }
42+ return connect.NewResponse(&pb.ListPathsResponse{CommitId: req.Msg.GetRevision(), Paths: paths}), nil
37 }43 }
38 44
39 // A revalidation is answered after the access check and before the list is45 // A revalidation is answered after the access check and before the list is
@@ -151,3 +157,17 @@ func TestEtagMatches(t *testing.T) {
151 assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header)157 assert.Equal(t, tt.want, etagMatches(tt.header, tag), "%q", tt.header)
152 }158 }
153 }159 }
160+
161+// A path is not HTML: "<", ">" and "&" are not escaped in the JSON.
162+func TestBuildPathsDoesNotEscapeHTML(t *testing.T) {
163+ git := &pathsGit{paths: []string{"a&b/<c>.go"}}
164+ srv, err := New(Config{Repos: failing{}, Git: git, PublicURL: "https://forge.example"})
165+ require.NoError(t, err)
166+ body, err := srv.buildPaths(context.Background(), "r", strings.Repeat("a", 40))
167+ require.NoError(t, err)
168+ zr, err := gzip.NewReader(bytes.NewReader(body))
169+ require.NoError(t, err)
170+ out, err := io.ReadAll(zr)
171+ require.NoError(t, err)
172+ assert.Contains(t, string(out), `"a&b/<c>.go"`)
173+}
M internal/adapters/web/raw.go +1 -1
@@ -60,7 +60,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
6060 // private, and a shared cache must not keep serving its files (AC2). The
6161 // ETag keeps the revalidation cheap.
6262 cacheHeaders(ctx, h, "no-cache", sessionCache)
63- if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") {
63+ if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) {
6464 w.WriteHeader(http.StatusNotModified)
6565 return
6666 }
@@ -60,7 +60,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
60 // private, and a shared cache must not keep serving its files (AC2). The60 // private, and a shared cache must not keep serving its files (AC2). The
61 // ETag keeps the revalidation cheap.61 // ETag keeps the revalidation cheap.
62 cacheHeaders(ctx, h, "no-cache", sessionCache)62 cacheHeaders(ctx, h, "no-cache", sessionCache)
63- if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") {63+ if etag := h.Get("ETag"); etag != "" && etagMatches(r.Header.Get("If-None-Match"), etag) {
64 w.WriteHeader(http.StatusNotModified)64 w.WriteHeader(http.StatusNotModified)
65 return65 return
66 }66 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close