v0.25.1 7fabed9

web_files_test.go RawHistoryFolder

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&lt;b&gt;&amp;&#34;name.txt`)
243	assert.Contains(t, page, "bidi&lt;U+202E&gt;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 &lt;b&gt;`, "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">&lt;U+202E&gt;</span>`)
437	assert.Contains(t, body, "&lt;U+2066&gt;")
438	assert.Contains(t, body, "&lt;U+2069&gt;")
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, "&lt;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}

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 43

↑↓ moveenter openesc close