1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "bytes"
7 "compress/gzip"
8 "encoding/json"
9 "fmt"
10 "image"
11 "image/color"
12 "image/png"
13 "io"
14 "net/http"
15 "os"
16 "path/filepath"
17 "regexp"
18 "strconv"
19 "strings"
20 "testing"
21
22 "github.com/stretchr/testify/assert"
23 "github.com/stretchr/testify/require"
24
25 "bnema.dev/ermite/internal/domain"
26 "bnema.dev/ermite/internal/ports"
27)
28
29// Trojan Source: a right-to-left override and an isolate around code.
30const webTrojan = "package main\n\nfunc main() {\n\taccess := \"user\u202e \u2066// admin only\u2069 \u2066\"\n\t_ = access\n}\n"
31
32func tinyPNG(t *testing.T) []byte {
33 t.Helper()
34 img := image.NewRGBA(image.Rect(0, 0, 2, 2))
35 img.Set(0, 0, color.RGBA{R: 255, A: 255})
36 var b bytes.Buffer
37 require.NoError(t, png.Encode(&b, img))
38 return b.Bytes()
39}
40
41// webFilesRepo is the repository of the tree, blob and raw tests: files of
42// every kind the pages treat differently, a directory README, a branch and a
43// tag with a slash.
44func (h *harness) webFilesRepo(name string) {
45 h.t.Helper()
46 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{
47 Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic,
48 })
49 require.NoError(h.t, err)
50 wt := h.t.TempDir()
51 h.mustGit(wt, "init", "-q", "--initial-branch=main")
52 h.writeFile(wt, "README.md", []byte("# Files\n\nSee [the guide](docs/guide.md).\n"))
53 h.writeFile(wt, "main.go", []byte("package main\n\nimport \"fmt\"\n\n// main prints.\nfunc main() {\n\tfmt.Println(\"hi <b>\", 42)\n}\n"))
54 h.writeFile(wt, "docs/README.md", []byte("# Docs\n\nBack to [the guide](guide.md) and [root](../main.go).\n"))
55 h.writeFile(wt, "docs/guide.md", []byte("# Guide\n\n## Steps\n\nRun it.\n\n```sh\nmake all\n```\n"))
56 h.writeFile(wt, "src/lib/util.go", []byte("package lib\n"))
57 h.writeFile(wt, "notes.txt", []byte("plain notes\nsecond line\n"))
58 h.writeFile(wt, "big.txt", bytes.Repeat([]byte("0123456789abcdef\n"), 70000)) // 1.1 MiB
59 h.writeFile(wt, "lines.txt", bytes.Repeat([]byte("x\n"), 20005))
60 h.writeFile(wt, "data.bin", []byte("\x00\x01\x02binary\x00"))
61 h.writeFile(wt, "pic.png", tinyPNG(h.t))
62 h.writeFile(wt, "logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script></svg>`))
63 h.writeFile(wt, "page.html", []byte("<script>alert(3)</script>\n"))
64 h.writeFile(wt, "evil.go", []byte(webTrojan))
65 h.writeFile(wt, "empty.txt", nil)
66 h.writeFile(wt, "fake.png", []byte("<html><script>alert(1)</script></html>\n"))
67 h.writeFile(wt, "fake.jpg", []byte("GIF89a\x00\x00binary\x00"))
68 h.mustGit(wt, "tag", "v1.0")
69 h.mustGit(wt, "checkout", "-q", "-b", "feature/x")
70 h.writeFile(wt, "x/y.txt", []byte("on the feature branch\n"))
71 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags")
72}
73
74// AC1: the tree and blob pages render for a fixture repository; the HTML is
75// compared after normalising times and ids.
76func TestWebFilesGolden(t *testing.T) {
77 h := newHarness(t, harnessOpts{})
78 h.webFilesRepo("files")
79
80 for _, tt := range []struct {
81 name, path string
82 status int
83 }{
84 {"tree-root", "/files/tree/main", 200},
85 {"tree-subdir", "/files/tree/main/docs", 200},
86 {"tree-nested", "/files/tree/main/src/lib", 200},
87 {"tree-slash-ref", "/files/tree/feature/x/x", 200},
88 {"blob-code", "/files/blob/main/main.go", 200},
89 {"blob-markdown", "/files/blob/main/docs/guide.md", 200},
90 {"blob-text", "/files/blob/main/notes.txt", 200},
91 {"blob-too-large", "/files/blob/main/big.txt", 200},
92 {"blob-binary", "/files/blob/main/data.bin", 200},
93 {"blob-image", "/files/blob/main/pic.png", 200},
94 {"blob-svg", "/files/blob/main/logo.svg", 200},
95 {"blob-trojan", "/files/blob/main/evil.go", 200},
96 {"blob-empty", "/files/blob/main/empty.txt", 200},
97 } {
98 t.Run(tt.name, func(t *testing.T) {
99 status, hdr, body := h.get(tt.path)
100 require.Equal(t, tt.status, status)
101 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
102 assert.Contains(t, hdr.Get("Content-Type"), "text/html")
103 assertNoInline(t, body)
104 golden(t, tt.name, normalise(body))
105 })
106 }
107}
108
109// AC1, AC2, AC7: ?rows answers the entries of a directory as table rows only,
110// for the Files view to insert under the directory; a private repository is
111// the 404 of a missing one.
112func TestWebTreeRows(t *testing.T) {
113 h := newHarness(t, harnessOpts{})
114 h.webFilesRepo("files")
115 h.seed("secret-sauce", domain.VisibilityPrivate, "dir/a.txt")
116
117 // The page links its directories to their rows.
118 _, _, page := h.get("/files/tree/main")
119 assert.Contains(t, page, `data-expand="/files/tree/main/docs?rows" aria-expanded="false"`)
120 assert.Contains(t, page, `<a class="t-name" href="/files/tree/main/docs" title="directory">docs/</a>`)
121 // Every row says its place among the entries of its own directory.
122 assert.Contains(t, page, `aria-level="1" aria-posinset="1" aria-setsize="15" data-kind="dir"`)
123 assert.Contains(t, page, `aria-posinset="15" aria-setsize="15" data-kind="file"`)
124 assert.Equal(t, 15, strings.Count(page, `role="treeitem"`))
125 assert.Contains(t, page, `<a class="t-commit" href="/files/commit/`)
126 assert.NotContains(t, page, "<h1", "no README below the tree: the summary shows it")
127 assert.NotContains(t, page, "See the guide")
128 assert.Contains(t, page, `<script src="/-/static/js/files.`)
129 assert.NotContains(t, page, "main.go</span>", "the palette lists no file")
130
131 status, hdr, body := h.get("/files/tree/main/docs?rows")
132 require.Equal(t, 200, status)
133 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
134 assert.Contains(t, hdr.Get("Content-Type"), "text/html")
135 assert.NotContains(t, body, "<html")
136 assert.NotContains(t, body, "<table")
137 assert.NotContains(t, body, "<ul")
138 assert.Equal(t, 2, strings.Count(body, "<li"), "one row per entry")
139 assert.Contains(t, body, `aria-posinset="1" aria-setsize="2"`)
140 assert.Contains(t, body, `aria-posinset="2" aria-setsize="2"`, "positions count the entries of the directory itself")
141 assert.Contains(t, body, `<a class="t-name" href="/files/blob/main/docs/README.md">README.md</a>`)
142 assert.Contains(t, body, `<a class="t-name" href="/files/blob/main/docs/guide.md">guide.md</a>`)
143 assert.NotContains(t, body, "data-cursor", "the cursor stays on the page")
144 assertNoInline(t, body)
145
146 // A subdirectory row can be opened in turn.
147 _, _, body = h.get("/files/tree/main/src?rows")
148 assert.Contains(t, body, `data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`)
149 assert.Contains(t, body, `data-kind="dir"`)
150 _, _, body = h.get("/files/tree/main/src/lib?rows")
151 assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`)
152 assert.NotContains(t, body, "data-expand")
153
154 // Not found, and private as anonymous: the 404 page, nothing of the rows.
155 for _, p := range []string{"/files/tree/main/nope?rows", "/secret-sauce/tree/main/dir?rows", "/secret-sauce/tree/main?rows"} {
156 status, _, body = h.get(p)
157 assert.Equal(t, 404, status, p)
158 assert.NotContains(t, body, "a.txt", p)
159 }
160}
161
162// ?rows on a directory with more entries than are listed ends with the row
163// that links to the directory page, which says the listing is cut.
164func TestWebTreeRowsTruncated(t *testing.T) {
165 h := newHarness(t, harnessOpts{})
166 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic})
167 require.NoError(t, err)
168 wt := h.t.TempDir()
169 h.mustGit(wt, "init", "-q", "--initial-branch=main")
170 // The limit is domain.MaxTreeEntries: one commit with a few more.
171 n := domain.MaxTreeEntries + 3
172 require.NoError(t, os.MkdirAll(filepath.Join(wt, "many"), 0o755))
173 for i := range n {
174 require.NoError(t, os.WriteFile(filepath.Join(wt, "many", fmt.Sprintf("f%05d", i)), nil, 0o644))
175 }
176 require.NoError(t, os.WriteFile(filepath.Join(wt, "small.txt"), []byte("x\n"), 0o644))
177 h.mustGit(wt, "add", ".")
178 h.mustGit(wt, "commit", "-q", "-m", "wide")
179 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/wide.git"), "main")
180
181 status, _, body := h.get("/wide/tree/main/many?rows")
182 require.Equal(t, 200, status)
183 assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "<li"), "the listed entries and the row that says there are more")
184 assert.Contains(t, body, `<a class="t-name" href="/wide/tree/main/many">more entries: open the directory</a>`)
185 assert.Equal(t, 1, strings.Count(body, "tree-more"))
186 // The listing is cut: the size is unknown (-1), and the row that says so is the next position.
187 assert.Equal(t, domain.MaxTreeEntries, strings.Count(body, `aria-setsize="-1" data-kind=`))
188 assert.Contains(t, body, `aria-posinset="1" aria-setsize="-1" data-kind="file"`)
189 assert.Contains(t, body, fmt.Sprintf(`class="tree-more" role="treeitem" aria-level="1" aria-posinset="%d" aria-setsize="-1"`, domain.MaxTreeEntries+1))
190
191 // A directory that fits has no such row.
192 _, _, body = h.get("/wide/tree?rows")
193 assert.NotContains(t, body, "tree-more")
194
195 // The page of the directory says the listing is cut, in a note, and its
196 // rows are of unknown number.
197 status, _, page := h.get("/wide/tree/main/many")
198 require.Equal(t, 200, status)
199 assert.Contains(t, page, fmt.Sprintf(`<p class="note">this directory has more entries than are listed here: the first %d are shown.</p>`, domain.MaxTreeEntries))
200 assert.Contains(t, page, `aria-posinset="1" aria-setsize="-1" data-kind="file"`)
201 assert.NotContains(t, page, "tree-more", "the page of the directory has no row for the rest: its note says it")
202 _, _, page = h.get("/wide/tree/main")
203 assert.NotContains(t, page, `class="note"`, "a directory that fits has no note")
204}
205
206// Entries that are not plain files or directories say what they are in words
207// (the node drawn for them is aria-hidden), and a name is text: HTML in it is
208// escaped and invisible characters show as markers.
209func TestWebTreeEntryKinds(t *testing.T) {
210 h := newHarness(t, harnessOpts{})
211 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("kinds"), Visibility: domain.VisibilityPublic})
212 require.NoError(t, err)
213 wt := h.t.TempDir()
214 h.mustGit(wt, "init", "-q", "--initial-branch=main")
215 h.writeFile(wt, "target.txt", []byte("t\n"))
216 h.writeFile(wt, `odd<b>&"name.txt`, []byte("x\n"))
217 h.writeFile(wt, "bidi\u202eevil.txt", []byte("x\n"))
218 h.writeFile(wt, "dir/inner.txt", []byte("x\n"))
219 // A symbolic link and a submodule entry (a gitlink: no clone is needed).
220 require.NoError(t, os.Symlink("target.txt", filepath.Join(wt, "link.txt")))
221 h.mustGit(wt, "add", "link.txt")
222 h.mustGit(wt, "update-index", "--add", "--cacheinfo", "160000,"+strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))+",vendor")
223 h.mustGit(wt, "commit", "-q", "-m", "kinds")
224 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/kinds.git"), "main")
225
226 status, _, page := h.get("/kinds/tree/main")
227 require.Equal(t, 200, status)
228 assertNoInline(t, page)
229
230 // A symbolic link has a link to its page, and says what it is.
231 assert.Contains(t, page, `data-kind="link"`)
232 assert.Contains(t, page, `<a class="t-name" href="/kinds/blob/main/link.txt" title="symbolic link">link.txt</a> <span class="sr"> (symbolic link)</span>`)
233 // A submodule has no page here: a name that is not a link.
234 assert.Contains(t, page, `data-kind="module"`)
235 assert.Contains(t, page, `<span class="t-name" title="submodule">vendor</span> <span class="sr"> (submodule)</span>`)
236 // A directory says so too, and a file says nothing more.
237 assert.Contains(t, page, `title="directory">dir/</a> <span class="sr"> (directory)</span>`)
238 assert.Contains(t, page, `href="/kinds/blob/main/target.txt">target.txt</a> </span>`)
239
240 // HTML in a name is escaped; an invisible character shows as its marker.
241 assert.NotContains(t, page, "<b>&")
242 assert.Contains(t, page, `odd<b>&"name.txt`)
243 assert.Contains(t, page, "bidi<U+202E>evil.txt")
244 assert.NotContains(t, page, "\u202e")
245}
246
247// Ref names with a slash are found by their longest prefix, and a commit id
248// works where a ref does.
249func TestWebFilesRefs(t *testing.T) {
250 h := newHarness(t, harnessOpts{})
251 h.webFilesRepo("files")
252
253 _, _, body := h.get("/files/tree/feature/x")
254 assert.Contains(t, body, ">x/<", "the directory of the branch feature/x")
255 status, _, body := h.get("/files/blob/feature/x/x/y.txt")
256 require.Equal(t, 200, status)
257 assert.Contains(t, body, "on the feature branch")
258 status, _, body = h.get("/files/blob/v1.0/main.go")
259 require.Equal(t, 200, status, "a tag")
260 assert.Contains(t, body, "package")
261
262 sha := h.bareGit("files", "rev-parse", "main")
263 status, _, body = h.get("/files/blob/" + sha + "/notes.txt")
264 require.Equal(t, 200, status, "a commit id")
265 assert.Contains(t, body, "plain notes")
266 assert.Contains(t, body, "detached")
267 status, _, _ = h.get("/files/tree/" + sha[:7])
268 assert.Equal(t, 200, status, "an abbreviated commit id")
269
270 // The entries of a directory stay under the directory and the ref, and the
271 // tree goes up to the parent.
272 _, _, body = h.get("/files/tree/main/docs")
273 assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`)
274 assert.Contains(t, body, `data-up="/files/tree/main"`)
275 _, _, body = h.get("/files/tree/main/src/lib")
276 assert.Contains(t, body, `data-up="/files/tree/main/src"`)
277}
278
279// AC3: what does not exist is the 404 page, with no hint of why.
280func TestWebFilesNotFound(t *testing.T) {
281 h := newHarness(t, harnessOpts{})
282 h.webFilesRepo("files")
283 h.seed("quiet", domain.VisibilityPublic) // empty repository
284
285 for _, p := range []string{
286 "/files/tree/nope", "/files/tree/main/no/such/dir", "/files/blob/main/nothing.txt", "/files/blob/nope/main.go",
287 "/files/blob/main", "/files/blob/main/docs", "/files/tree/main/main.go", "/files/raw/main/nothing.txt",
288 "/files/raw/main", "/files/raw/main/docs", "/files/blob/ffffffffffffffffffffffffffffffffffffffff/main.go",
289 "/files/blob", "/files/raw", "/files/blob/main/../main.go",
290 "/quiet/tree/main", "/quiet/tree", "/quiet/blob/main/x", "/never/blob/main/x", "/never/raw/main/x",
291 } {
292 status, hdr, body := h.get(p)
293 assert.Equal(t, 404, status, p)
294 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p)
295 assert.Contains(t, hdr.Get("Content-Type"), "text/html", p)
296 assert.NotContains(t, body, "nothing.txt", p)
297 }
298 status, _, body := h.get("/files/blob/main/nothing.txt")
299 require.Equal(t, 404, status)
300 _, _, other := h.get("/files/tree/nope")
301 assert.Equal(t, normalise(other), normalise(body), "one 404 page for everything")
302}
303
304// AC2: the tree, blob and raw pages of a private repository are the 404 of a
305// missing one.
306func TestWebFilesPrivate(t *testing.T) {
307 h := newHarness(t, harnessOpts{})
308 h.seed("secret-sauce", domain.VisibilityPrivate, "README.md", "a.txt")
309
310 for _, kind := range []string{"tree", "blob", "raw"} {
311 for _, p := range []string{"/secret-sauce/" + kind + "/main", "/secret-sauce/" + kind + "/main/a.txt"} {
312 status, hdr, body := h.get(p)
313 assert.Equal(t, 404, status, p)
314 assert.NotContains(t, body, "secret-sauce", p)
315 assert.NotContains(t, body, "content of", p)
316 assert.NotContains(t, hdr.Get("Content-Type"), "text/plain", p)
317 _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1))
318 assert.Equal(t, normalise(missing), normalise(body), "%s is the page of a missing repository", p)
319 }
320 }
321}
322
323func rawGet(t *testing.T, h *harness, path string) (int, http.Header, []byte) {
324 t.Helper()
325 status, hdr, body := h.get(path)
326 return status, hdr, []byte(body)
327}
328
329// The raw route serves text as text/plain, the four image types as images,
330// everything else as an attachment; never as HTML or SVG; and always with
331// nosniff and a CSP that makes the file inert.
332func TestWebRaw(t *testing.T) {
333 h := newHarness(t, harnessOpts{})
334 h.webFilesRepo("files")
335 sha := h.bareGit("files", "rev-parse", "main")
336
337 for _, tt := range []struct {
338 name, path, typ, disposition string
339 }{
340 {"text", "/files/raw/main/notes.txt", "text/plain; charset=utf-8", ""},
341 {"code", "/files/raw/main/main.go", "text/plain; charset=utf-8", ""},
342 {"markdown", "/files/raw/main/README.md", "text/plain; charset=utf-8", ""},
343 {"html is text", "/files/raw/main/page.html", "text/plain; charset=utf-8", ""},
344 {"empty", "/files/raw/main/empty.txt", "text/plain; charset=utf-8", ""},
345 {"large text", "/files/raw/main/big.txt", "text/plain; charset=utf-8", ""},
346 {"png", "/files/raw/main/pic.png", "image/png", ""},
347 {"binary", "/files/raw/main/data.bin", "application/octet-stream", `attachment; filename=data.bin`},
348 {"svg is text", "/files/raw/main/logo.svg", "text/plain; charset=utf-8", ""},
349 {"slash ref", "/files/raw/feature/x/x/y.txt", "text/plain; charset=utf-8", ""},
350 {"tag", "/files/raw/v1.0/main.go", "text/plain; charset=utf-8", ""},
351 {"commit", "/files/raw/" + sha + "/notes.txt", "text/plain; charset=utf-8", ""},
352 } {
353 t.Run(tt.name, func(t *testing.T) {
354 status, hdr, body := rawGet(t, h, tt.path)
355 require.Equal(t, 200, status)
356 assert.Equal(t, tt.typ, hdr.Get("Content-Type"))
357 assert.Equal(t, tt.disposition, hdr.Get("Content-Disposition"))
358 assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"))
359 assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy"))
360 assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"))
361 assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length"))
362 // Even at a full commit id: the repository can turn private.
363 assert.Equal(t, "no-cache", hdr.Get("Cache-Control"))
364 })
365 }
366
367 // Content is the file, byte for byte.
368 _, _, body := rawGet(t, h, "/files/raw/main/data.bin")
369 assert.Equal(t, []byte("\x00\x01\x02binary\x00"), body)
370 _, _, body = rawGet(t, h, "/files/raw/main/pic.png")
371 assert.Equal(t, tinyPNG(t), body)
372 _, _, body = rawGet(t, h, "/files/raw/main/big.txt")
373 assert.Len(t, body, 17*70000)
374 _, _, body = rawGet(t, h, "/files/raw/main/evil.go")
375 assert.Equal(t, webTrojan, string(body), "raw is not altered")
376
377 // A file named like an image is not one unless its bytes say so.
378 _, hdr, _ := rawGet(t, h, "/files/raw/main/fake.png")
379 assert.Equal(t, "text/plain; charset=utf-8", hdr.Get("Content-Type"), "the type comes from the bytes, not the name")
380 _, hdr, _ = rawGet(t, h, "/files/raw/main/fake.jpg")
381 assert.Equal(t, "image/gif", hdr.Get("Content-Type"))
382 _, _, page := h.get("/files/blob/main/fake.png")
383 assert.NotContains(t, page, "<script>alert", "shown as code, escaped")
384
385 // HEAD and conditional requests.
386 req, err := http.NewRequest(http.MethodGet, h.url("/files/raw/main/notes.txt"), nil)
387 require.NoError(t, err)
388 resp, err := http.DefaultClient.Do(req)
389 require.NoError(t, err)
390 _ = resp.Body.Close()
391 etag := resp.Header.Get("ETag")
392 require.NotEmpty(t, etag)
393 req.Header.Set("If-None-Match", etag)
394 resp, err = http.DefaultClient.Do(req)
395 require.NoError(t, err)
396 _ = resp.Body.Close()
397 assert.Equal(t, http.StatusNotModified, resp.StatusCode)
398 // A list of validators, weak ones and "*" match as well.
399 for _, inm := range []string{`"other", ` + etag, "W/" + etag, "*"} {
400 req.Header.Set("If-None-Match", inm)
401 resp, err = http.DefaultClient.Do(req)
402 require.NoError(t, err)
403 _ = resp.Body.Close()
404 assert.Equal(t, http.StatusNotModified, resp.StatusCode, inm)
405 }
406 req.Header.Set("If-None-Match", `"other", "another"`)
407 resp, err = http.DefaultClient.Do(req)
408 require.NoError(t, err)
409 _ = resp.Body.Close()
410 assert.Equal(t, http.StatusOK, resp.StatusCode, "no validator matches")
411 assert.Equal(t, http.StatusOK, h.status(http.MethodHead, "/files/raw/main/notes.txt", nil))
412}
413
414var reLineID = regexp.MustCompile(`<span class="line" id="L(\d+)">`)
415
416// AC1, AC6: the code view numbers its lines with anchors, the page has no
417// inline script or style, and a file that attacks the page is shown inert.
418func TestWebBlobCode(t *testing.T) {
419 h := newHarness(t, harnessOpts{})
420 h.webFilesRepo("files")
421
422 _, _, body := h.get("/files/blob/main/main.go")
423 assert.Equal(t, 8, len(reLineID.FindAllString(body, -1)))
424 assert.Contains(t, body, `<a class="ln" href="#L6">6</a>`)
425 assert.Contains(t, body, `<span class="kn">package</span>`)
426 assert.Contains(t, body, `hi <b>`, "source is escaped")
427 assert.NotContains(t, body, "<b>")
428 assertNoInline(t, body)
429 assert.Regexp(t, `href="/files/raw/main/main.go"`, body)
430
431 // Trojan Source: the controls are shown, not applied.
432 _, _, body = h.get("/files/blob/main/evil.go")
433 for _, r := range []string{"\u202e", "\u2066", "\u2069"} {
434 assert.NotContains(t, body, r, "%+q is not output", r)
435 }
436 assert.Contains(t, body, `<span class="ctl" title="U+202E right-to-left override"><U+202E></span>`)
437 assert.Contains(t, body, "<U+2066>")
438 assert.Contains(t, body, "<U+2069>")
439
440 // The image and SVG: only the four raster types are images.
441 _, _, body = h.get("/files/blob/main/pic.png")
442 assert.Contains(t, body, `<img src="/files/raw/main/pic.png"`)
443 _, _, body = h.get("/files/blob/main/logo.svg")
444 assert.NotContains(t, body, "<img")
445 assert.NotContains(t, body, "<svg", "the source is escaped, never markup")
446 assert.NotContains(t, body, "<script>alert")
447 assert.Contains(t, body, "<svg")
448 assertNoInline(t, body)
449
450 // Too many lines: the first ones, with a notice.
451 _, _, body = h.get("/files/blob/main/lines.txt")
452 assert.Equal(t, 20000, len(reLineID.FindAllString(body, -1)))
453 assert.Contains(t, body, "20005 lines: showing the first 20000")
454
455 // Markdown: rendered, source under a fold, links under the directory.
456 _, _, body = h.get("/files/blob/main/docs/guide.md")
457 assert.Contains(t, body, `<details class="source">`)
458 assert.Contains(t, body, "<h1 id=\"md-guide\">Guide</h1>")
459 assert.Contains(t, body, `href="#md-steps"`, "outline")
460}
461
462// AC6 on the new pages: the CSP of the site, the security headers, nothing
463// inline and nothing loaded from another origin.
464func TestWebFilesContentSecurity(t *testing.T) {
465 h := newHarness(t, harnessOpts{})
466 h.webFilesRepo("files")
467
468 const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; object-src 'none'"
469 for _, p := range []string{
470 "/files/tree/main", "/files/tree/main/docs", "/files/blob/main/main.go", "/files/blob/main/docs/guide.md",
471 "/files/blob/main/pic.png", "/files/blob/main/logo.svg", "/files/blob/main/evil.go", "/files/blob/main/big.txt",
472 "/files/blob/main/data.bin", "/files/tree/nope",
473 } {
474 _, hdr, body := h.get(p)
475 assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p)
476 assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p)
477 assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p)
478 assertNoInline(t, body)
479 assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p)
480 assert.False(t, reImport.MatchString(body), p)
481 for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) {
482 assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1])
483 }
484 }
485
486 // The theme of the code view is a static file with no colour of its own.
487 _, _, page := h.get("/files/blob/main/main.go")
488 css := regexp.MustCompile(`href="(/-/static/css/[^"]+)"`).FindStringSubmatch(page)
489 require.Len(t, css, 2)
490 _, _, sheet := h.get(css[1])
491 assert.Contains(t, sheet, ".chroma .kn { color: var(--syn-keyword)")
492 assert.NotRegexp(t, `\.chroma[^{]*\{[^}]*#[0-9a-fA-F]{3,6}`, sheet)
493}
494
495// AC4: GET /<repo>/paths/<sha> lists the file paths of a commit as gzipped
496// JSON, behind the access rule on every request, even when the list is
497// already cached; a visitor revalidates (no-cache + ETag), the owner's copy is
498// private.
499func TestWebPaths(t *testing.T) {
500 c := newCLIHarness(t)
501 h := c.harness
502 h.webFilesRepo("files")
503 sha := h.bareGit("files", "rev-parse", "main")
504
505 var want []string
506 for _, p := range strings.Split(h.bareGit("files", "ls-tree", "-r", "-z", "--name-only", "main"), "\x00") {
507 if p != "" {
508 want = append(want, p)
509 }
510 }
511 require.NotEmpty(t, want)
512 b := browser{h}
513 path := "/files/paths/" + sha
514 type list struct {
515 Paths []string `json:"paths"`
516 Truncated bool `json:"truncated"`
517 }
518 decodeList := func(t *testing.T, body string) list {
519 t.Helper()
520 var l list
521 require.NoError(t, json.Unmarshal([]byte(body), &l))
522 return l
523 }
524 gunzip := func(t *testing.T, body string) string {
525 t.Helper()
526 zr, err := gzip.NewReader(strings.NewReader(body))
527 require.NoError(t, err)
528 out, err := io.ReadAll(zr)
529 require.NoError(t, err)
530 return string(out)
531 }
532
533 // Anonymous with gzip: the cached bytes as they are. The client of the
534 // harness would unzip by itself, so the encoding is asked for explicitly
535 // through a transport that does not.
536 raw := func(hdr map[string]string) page {
537 req, err := http.NewRequest(http.MethodGet, h.url(path), nil)
538 require.NoError(t, err)
539 for k, v := range hdr {
540 req.Header.Set(k, v)
541 }
542 resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
543 require.NoError(t, err)
544 defer func() { _ = resp.Body.Close() }()
545 out, err := io.ReadAll(resp.Body)
546 require.NoError(t, err)
547 return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)}
548 }
549 gz := raw(map[string]string{"Accept-Encoding": "gzip"})
550 require.Equal(t, 200, gz.status)
551 assert.Equal(t, "gzip", gz.hdr.Get("Content-Encoding"))
552 assert.Equal(t, "application/json", gz.hdr.Get("Content-Type"))
553 assert.Equal(t, "nosniff", gz.hdr.Get("X-Content-Type-Options"))
554 assert.Equal(t, "no-cache", gz.hdr.Get("Cache-Control"))
555 assert.Equal(t, "Accept-Encoding", gz.hdr.Get("Vary"))
556 etag := gz.hdr.Get("ETag")
557 assert.Equal(t, `"paths-`+sha+`-gz"`, etag)
558 got := decodeList(t, gunzip(t, gz.body))
559 assert.Equal(t, want, got.Paths, "git ls-tree -r order")
560 assert.False(t, got.Truncated)
561
562 // Without gzip: plain JSON, the same list.
563 plain := raw(nil)
564 require.Equal(t, 200, plain.status)
565 assert.Empty(t, plain.hdr.Get("Content-Encoding"))
566 assert.Equal(t, want, decodeList(t, plain.body).Paths)
567 assert.Equal(t, `"paths-`+sha+`"`, plain.hdr.Get("ETag"), "another variant, another validator")
568
569 // Revalidation: 304, still through the access rule. The matrix of
570 // validators (variants, weak, "*") is in paths_test.go.
571 nm := raw(map[string]string{"If-None-Match": etag, "Accept-Encoding": "gzip"})
572 assert.Equal(t, http.StatusNotModified, nm.status)
573 assert.Empty(t, nm.body)
574 assert.Equal(t, "no-cache", nm.hdr.Get("Cache-Control"))
575
576 // Only a full commit id: abbreviations, refs, upper case and unknown ids.
577 for _, p := range []string{
578 "/files/paths/" + sha[:7], "/files/paths/" + sha[:39], "/files/paths/main", "/files/paths/" + strings.ToUpper(sha),
579 "/files/paths/" + strings.Repeat("0", 40), "/files/paths/", "/never/paths/" + sha,
580 } {
581 assert.Equal(t, 404, b.get(p, nil).status, p)
582 }
583
584 // Another repository does not serve a commit it does not have, though
585 // the list of that commit is cached.
586 h.seed("other", domain.VisibilityPublic, "x.txt")
587 assert.Equal(t, 404, b.get("/other/paths/"+sha, nil).status)
588
589 // The owner: private, never stored; the list is the same.
590 token := c.signIn(t)
591 own := b.get(path, as(token))
592 require.Equal(t, 200, own.status)
593 assert.Equal(t, "private, no-store", own.hdr.Get("Cache-Control"))
594 assert.Contains(t, own.hdr.Values("Vary"), "Cookie")
595 assert.Equal(t, want, decodeList(t, own.body).Paths)
596
597 // The repository turns private: the list is cached on the server, and
598 // anonymous gets the 404 of a missing repository, as the owner still
599 // reads it.
600 priv := domain.VisibilityPrivate
601 _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("files"), ports.UpdateRepoParams{Visibility: &priv})
602 require.NoError(t, err)
603 for _, hdr := range []map[string]string{nil, {"Accept-Encoding": "gzip"}, {"If-None-Match": etag}} {
604 p := b.get(path, hdr)
605 assert.Equal(t, 404, p.status)
606 assert.NotContains(t, p.body, "README.md")
607 assert.NotContains(t, p.hdr.Get("Content-Type"), "json")
608 }
609 assert.Equal(t, 200, b.get(path, as(token)).status)
610}
611
612// AC3, AC4: the Files page carries the finder and the data it needs, on the
613// commit the page shows, however the URL spelled it; the other pages carry
614// the paths of their commit and no finder.
615func TestWebFinderPage(t *testing.T) {
616 h := newHarness(t, harnessOpts{})
617 h.webFilesRepo("files")
618 sha := h.bareGit("files", "rev-parse", "main")
619 attr := func(body, name string) string {
620 m := regexp.MustCompile(`<body[^>]*\b` + name + `="([^"]*)"`).FindStringSubmatch(body)
621 if m == nil {
622 return "<none>"
623 }
624 return m[1]
625 }
626
627 for _, tt := range []struct{ name, path, paths, blob string }{
628 {"branch", "/files/tree/main", "/files/paths/" + sha, "/files/blob/main"},
629 {"subdirectory", "/files/tree/main/docs", "/files/paths/" + sha, "/files/blob/main"},
630 {"slash ref", "/files/tree/feature/x", "/files/paths/" + h.bareGit("files", "rev-parse", "feature/x"), "/files/blob/feature/x"},
631 {"tag", "/files/tree/v1.0", "/files/paths/" + sha, "/files/blob/v1.0"},
632 {"full id", "/files/tree/" + sha, "/files/paths/" + sha, "/files/blob/" + sha},
633 {"abbreviated id", "/files/tree/" + sha[:7], "/files/paths/" + sha, "/files/blob/" + sha[:7]},
634 {"blob page", "/files/blob/main/main.go", "/files/paths/" + sha, "/files/blob/main"},
635 {"blob at full id", "/files/blob/" + sha + "/main.go", "/files/paths/" + sha, "/files/blob/" + sha},
636 {"blob at abbreviated id", "/files/blob/" + sha[:7] + "/main.go", "/files/paths/" + sha, "/files/blob/" + sha[:7]},
637 } {
638 t.Run(tt.name, func(t *testing.T) {
639 status, _, body := h.get(tt.path)
640 require.Equal(t, 200, status)
641 assert.Equal(t, tt.paths, attr(body, "data-paths"))
642 assert.Equal(t, tt.blob, attr(body, "data-blob"))
643 if strings.Contains(tt.path, "/tree/") {
644 assert.Contains(t, body, `id="finder-q"`)
645 assert.Contains(t, body, `id="finder-list"`)
646 assert.Contains(t, body, `placeholder="Find a file in this repository…"`)
647 } else {
648 assert.NotContains(t, body, `id="finder-q"`)
649 }
650 })
651 }
652 // No commit to search: no data.
653 _, _, body := h.get("/nothing-here")
654 assert.Equal(t, "<none>", attr(body, "data-paths"))
655 _, _, body = h.get("/files/refs")
656 assert.Equal(t, "/files/paths/"+sha, attr(body, "data-paths"))
657}