// SPDX-License-Identifier: AGPL-3.0-or-later package app import ( "bytes" "compress/gzip" "encoding/json" "fmt" "image" "image/color" "image/png" "io" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" ) // Trojan Source: a right-to-left override and an isolate around code. const webTrojan = "package main\n\nfunc main() {\n\taccess := \"user\u202e \u2066// admin only\u2069 \u2066\"\n\t_ = access\n}\n" func tinyPNG(t *testing.T) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 2, 2)) img.Set(0, 0, color.RGBA{R: 255, A: 255}) var b bytes.Buffer require.NoError(t, png.Encode(&b, img)) return b.Bytes() } // webFilesRepo is the repository of the tree, blob and raw tests: files of // every kind the pages treat differently, a directory README, a branch and a // tag with a slash. func (h *harness) webFilesRepo(name string) { h.t.Helper() _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{ Name: h.name(name), Description: "Files of every kind", Visibility: domain.VisibilityPublic, }) require.NoError(h.t, err) wt := h.t.TempDir() h.mustGit(wt, "init", "-q", "--initial-branch=main") h.writeFile(wt, "README.md", []byte("# Files\n\nSee [the guide](docs/guide.md).\n")) h.writeFile(wt, "main.go", []byte("package main\n\nimport \"fmt\"\n\n// main prints.\nfunc main() {\n\tfmt.Println(\"hi \", 42)\n}\n")) h.writeFile(wt, "docs/README.md", []byte("# Docs\n\nBack to [the guide](guide.md) and [root](../main.go).\n")) h.writeFile(wt, "docs/guide.md", []byte("# Guide\n\n## Steps\n\nRun it.\n\n```sh\nmake all\n```\n")) h.writeFile(wt, "src/lib/util.go", []byte("package lib\n")) h.writeFile(wt, "notes.txt", []byte("plain notes\nsecond line\n")) h.writeFile(wt, "big.txt", bytes.Repeat([]byte("0123456789abcdef\n"), 70000)) // 1.1 MiB h.writeFile(wt, "lines.txt", bytes.Repeat([]byte("x\n"), 20005)) h.writeFile(wt, "data.bin", []byte("\x00\x01\x02binary\x00")) h.writeFile(wt, "pic.png", tinyPNG(h.t)) h.writeFile(wt, "logo.svg", []byte(``)) h.writeFile(wt, "page.html", []byte("\n")) h.writeFile(wt, "evil.go", []byte(webTrojan)) h.writeFile(wt, "empty.txt", nil) h.writeFile(wt, "fake.png", []byte("\n")) h.writeFile(wt, "fake.jpg", []byte("GIF89a\x00\x00binary\x00")) h.mustGit(wt, "tag", "v1.0") h.mustGit(wt, "checkout", "-q", "-b", "feature/x") h.writeFile(wt, "x/y.txt", []byte("on the feature branch\n")) h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags") } // AC1: the tree and blob pages render for a fixture repository; the HTML is // compared after normalising times and ids. func TestWebFilesGolden(t *testing.T) { h := newHarness(t, harnessOpts{}) h.webFilesRepo("files") for _, tt := range []struct { name, path string status int }{ {"tree-root", "/files/tree/main", 200}, {"tree-subdir", "/files/tree/main/docs", 200}, {"tree-nested", "/files/tree/main/src/lib", 200}, {"tree-slash-ref", "/files/tree/feature/x/x", 200}, {"blob-code", "/files/blob/main/main.go", 200}, {"blob-markdown", "/files/blob/main/docs/guide.md", 200}, {"blob-text", "/files/blob/main/notes.txt", 200}, {"blob-too-large", "/files/blob/main/big.txt", 200}, {"blob-binary", "/files/blob/main/data.bin", 200}, {"blob-image", "/files/blob/main/pic.png", 200}, {"blob-svg", "/files/blob/main/logo.svg", 200}, {"blob-trojan", "/files/blob/main/evil.go", 200}, {"blob-empty", "/files/blob/main/empty.txt", 200}, } { t.Run(tt.name, func(t *testing.T) { status, hdr, body := h.get(tt.path) require.Equal(t, tt.status, status) assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) assert.Contains(t, hdr.Get("Content-Type"), "text/html") assertNoInline(t, body) golden(t, tt.name, normalise(body)) }) } } // AC1, AC2, AC7: ?rows answers the entries of a directory as table rows only, // for the Files view to insert under the directory; a private repository is // the 404 of a missing one. func TestWebTreeRows(t *testing.T) { h := newHarness(t, harnessOpts{}) h.webFilesRepo("files") h.seed("secret-sauce", domain.VisibilityPrivate, "dir/a.txt") // The page links its directories to their rows. _, _, page := h.get("/files/tree/main") assert.Contains(t, page, `data-expand="/files/tree/main/docs?rows" aria-expanded="false"`) assert.Contains(t, page, `docs/`) // Every row says its place among the entries of its own directory. assert.Contains(t, page, `aria-level="1" aria-posinset="1" aria-setsize="15" data-kind="dir"`) assert.Contains(t, page, `aria-posinset="15" aria-setsize="15" data-kind="file"`) assert.Equal(t, 15, strings.Count(page, `role="treeitem"`)) assert.Contains(t, page, `", "the palette lists no file") status, hdr, body := h.get("/files/tree/main/docs?rows") require.Equal(t, 200, status) assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) assert.Contains(t, hdr.Get("Content-Type"), "text/html") assert.NotContains(t, body, "README.md`) assert.Contains(t, body, `guide.md`) assert.NotContains(t, body, "data-cursor", "the cursor stays on the page") assertNoInline(t, body) // A subdirectory row can be opened in turn. _, _, body = h.get("/files/tree/main/src?rows") assert.Contains(t, body, `data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`) assert.Contains(t, body, `data-kind="dir"`) _, _, body = h.get("/files/tree/main/src/lib?rows") assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`) assert.NotContains(t, body, "data-expand") // Not found, and private as anonymous: the 404 page, nothing of the rows. for _, p := range []string{"/files/tree/main/nope?rows", "/secret-sauce/tree/main/dir?rows", "/secret-sauce/tree/main?rows"} { status, _, body = h.get(p) assert.Equal(t, 404, status, p) assert.NotContains(t, body, "a.txt", p) } } // ?rows on a directory with more entries than are listed ends with the row // that links to the directory page, which says the listing is cut. func TestWebTreeRowsTruncated(t *testing.T) { h := newHarness(t, harnessOpts{}) _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("wide"), Visibility: domain.VisibilityPublic}) require.NoError(t, err) wt := h.t.TempDir() h.mustGit(wt, "init", "-q", "--initial-branch=main") // The limit is domain.MaxTreeEntries: one commit with a few more. n := domain.MaxTreeEntries + 3 require.NoError(t, os.MkdirAll(filepath.Join(wt, "many"), 0o755)) for i := range n { require.NoError(t, os.WriteFile(filepath.Join(wt, "many", fmt.Sprintf("f%05d", i)), nil, 0o644)) } require.NoError(t, os.WriteFile(filepath.Join(wt, "small.txt"), []byte("x\n"), 0o644)) h.mustGit(wt, "add", ".") h.mustGit(wt, "commit", "-q", "-m", "wide") h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/wide.git"), "main") status, _, body := h.get("/wide/tree/main/many?rows") require.Equal(t, 200, status) assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "more entries: open the directory`) assert.Equal(t, 1, strings.Count(body, "tree-more")) // The listing is cut: the size is unknown (-1), and the row that says so is the next position. assert.Equal(t, domain.MaxTreeEntries, strings.Count(body, `aria-setsize="-1" data-kind=`)) assert.Contains(t, body, `aria-posinset="1" aria-setsize="-1" data-kind="file"`) assert.Contains(t, body, fmt.Sprintf(`class="tree-more" role="treeitem" aria-level="1" aria-posinset="%d" aria-setsize="-1"`, domain.MaxTreeEntries+1)) // A directory that fits has no such row. _, _, body = h.get("/wide/tree?rows") assert.NotContains(t, body, "tree-more") // The page of the directory says the listing is cut, in a note, and its // rows are of unknown number. status, _, page := h.get("/wide/tree/main/many") require.Equal(t, 200, status) assert.Contains(t, page, fmt.Sprintf(`

this directory has more entries than are listed here: the first %d are shown.

`, domain.MaxTreeEntries)) assert.Contains(t, page, `aria-posinset="1" aria-setsize="-1" data-kind="file"`) assert.NotContains(t, page, "tree-more", "the page of the directory has no row for the rest: its note says it") _, _, page = h.get("/wide/tree/main") assert.NotContains(t, page, `class="note"`, "a directory that fits has no note") } // Entries that are not plain files or directories say what they are in words // (the node drawn for them is aria-hidden), and a name is text: HTML in it is // escaped and invisible characters show as markers. func TestWebTreeEntryKinds(t *testing.T) { h := newHarness(t, harnessOpts{}) _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("kinds"), Visibility: domain.VisibilityPublic}) require.NoError(t, err) wt := h.t.TempDir() h.mustGit(wt, "init", "-q", "--initial-branch=main") h.writeFile(wt, "target.txt", []byte("t\n")) h.writeFile(wt, `odd&"name.txt`, []byte("x\n")) h.writeFile(wt, "bidi\u202eevil.txt", []byte("x\n")) h.writeFile(wt, "dir/inner.txt", []byte("x\n")) // A symbolic link and a submodule entry (a gitlink: no clone is needed). require.NoError(t, os.Symlink("target.txt", filepath.Join(wt, "link.txt"))) h.mustGit(wt, "add", "link.txt") h.mustGit(wt, "update-index", "--add", "--cacheinfo", "160000,"+strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))+",vendor") h.mustGit(wt, "commit", "-q", "-m", "kinds") h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/kinds.git"), "main") status, _, page := h.get("/kinds/tree/main") require.Equal(t, 200, status) assertNoInline(t, page) // A symbolic link has a link to its page, and says what it is. assert.Contains(t, page, `data-kind="link"`) assert.Contains(t, page, `link.txt (symbolic link)`) // A submodule has no page here: a name that is not a link. assert.Contains(t, page, `data-kind="module"`) assert.Contains(t, page, `vendor (submodule)`) // A directory says so too, and a file says nothing more. assert.Contains(t, page, `title="directory">dir/ (directory)`) assert.Contains(t, page, `href="/kinds/blob/main/target.txt">target.txt `) // HTML in a name is escaped; an invisible character shows as its marker. assert.NotContains(t, page, "&") assert.Contains(t, page, `odd<b>&"name.txt`) assert.Contains(t, page, "bidi<U+202E>evil.txt") assert.NotContains(t, page, "\u202e") } // Ref names with a slash are found by their longest prefix, and a commit id // works where a ref does. func TestWebFilesRefs(t *testing.T) { h := newHarness(t, harnessOpts{}) h.webFilesRepo("files") _, _, body := h.get("/files/tree/feature/x") assert.Contains(t, body, ">x/<", "the directory of the branch feature/x") status, _, body := h.get("/files/blob/feature/x/x/y.txt") require.Equal(t, 200, status) assert.Contains(t, body, "on the feature branch") status, _, body = h.get("/files/blob/v1.0/main.go") require.Equal(t, 200, status, "a tag") assert.Contains(t, body, "package") sha := h.bareGit("files", "rev-parse", "main") status, _, body = h.get("/files/blob/" + sha + "/notes.txt") require.Equal(t, 200, status, "a commit id") assert.Contains(t, body, "plain notes") assert.Contains(t, body, "detached") status, _, _ = h.get("/files/tree/" + sha[:7]) assert.Equal(t, 200, status, "an abbreviated commit id") // The entries of a directory stay under the directory and the ref, and the // tree goes up to the parent. _, _, body = h.get("/files/tree/main/docs") assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`) assert.Contains(t, body, `data-up="/files/tree/main"`) _, _, body = h.get("/files/tree/main/src/lib") assert.Contains(t, body, `data-up="/files/tree/main/src"`) } // AC3: what does not exist is the 404 page, with no hint of why. func TestWebFilesNotFound(t *testing.T) { h := newHarness(t, harnessOpts{}) h.webFilesRepo("files") h.seed("quiet", domain.VisibilityPublic) // empty repository for _, p := range []string{ "/files/tree/nope", "/files/tree/main/no/such/dir", "/files/blob/main/nothing.txt", "/files/blob/nope/main.go", "/files/blob/main", "/files/blob/main/docs", "/files/tree/main/main.go", "/files/raw/main/nothing.txt", "/files/raw/main", "/files/raw/main/docs", "/files/blob/ffffffffffffffffffffffffffffffffffffffff/main.go", "/files/blob", "/files/raw", "/files/blob/main/../main.go", "/quiet/tree/main", "/quiet/tree", "/quiet/blob/main/x", "/never/blob/main/x", "/never/raw/main/x", } { status, hdr, body := h.get(p) assert.Equal(t, 404, status, p) assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) assert.Contains(t, hdr.Get("Content-Type"), "text/html", p) assert.NotContains(t, body, "nothing.txt", p) } status, _, body := h.get("/files/blob/main/nothing.txt") require.Equal(t, 404, status) _, _, other := h.get("/files/tree/nope") assert.Equal(t, normalise(other), normalise(body), "one 404 page for everything") } // AC2: the tree, blob and raw pages of a private repository are the 404 of a // missing one. func TestWebFilesPrivate(t *testing.T) { h := newHarness(t, harnessOpts{}) h.seed("secret-sauce", domain.VisibilityPrivate, "README.md", "a.txt") for _, kind := range []string{"tree", "blob", "raw"} { for _, p := range []string{"/secret-sauce/" + kind + "/main", "/secret-sauce/" + kind + "/main/a.txt"} { status, hdr, body := h.get(p) assert.Equal(t, 404, status, p) assert.NotContains(t, body, "secret-sauce", p) assert.NotContains(t, body, "content of", p) assert.NotContains(t, hdr.Get("Content-Type"), "text/plain", p) _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1)) assert.Equal(t, normalise(missing), normalise(body), "%s is the page of a missing repository", p) } } } func rawGet(t *testing.T, h *harness, path string) (int, http.Header, []byte) { t.Helper() status, hdr, body := h.get(path) return status, hdr, []byte(body) } // The raw route serves text as text/plain, the four image types as images, // everything else as an attachment; never as HTML or SVG; and always with // nosniff and a CSP that makes the file inert. func TestWebRaw(t *testing.T) { h := newHarness(t, harnessOpts{}) h.webFilesRepo("files") sha := h.bareGit("files", "rev-parse", "main") for _, tt := range []struct { name, path, typ, disposition string }{ {"text", "/files/raw/main/notes.txt", "text/plain; charset=utf-8", ""}, {"code", "/files/raw/main/main.go", "text/plain; charset=utf-8", ""}, {"markdown", "/files/raw/main/README.md", "text/plain; charset=utf-8", ""}, {"html is text", "/files/raw/main/page.html", "text/plain; charset=utf-8", ""}, {"empty", "/files/raw/main/empty.txt", "text/plain; charset=utf-8", ""}, {"large text", "/files/raw/main/big.txt", "text/plain; charset=utf-8", ""}, {"png", "/files/raw/main/pic.png", "image/png", ""}, {"binary", "/files/raw/main/data.bin", "application/octet-stream", `attachment; filename=data.bin`}, {"svg is text", "/files/raw/main/logo.svg", "text/plain; charset=utf-8", ""}, {"slash ref", "/files/raw/feature/x/x/y.txt", "text/plain; charset=utf-8", ""}, {"tag", "/files/raw/v1.0/main.go", "text/plain; charset=utf-8", ""}, {"commit", "/files/raw/" + sha + "/notes.txt", "text/plain; charset=utf-8", ""}, } { t.Run(tt.name, func(t *testing.T) { status, hdr, body := rawGet(t, h, tt.path) require.Equal(t, 200, status) assert.Equal(t, tt.typ, hdr.Get("Content-Type")) assert.Equal(t, tt.disposition, hdr.Get("Content-Disposition")) assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options")) assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy")) assert.Equal(t, "DENY", hdr.Get("X-Frame-Options")) assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length")) // Even at a full commit id: the repository can turn private. assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) }) } // Content is the file, byte for byte. _, _, body := rawGet(t, h, "/files/raw/main/data.bin") assert.Equal(t, []byte("\x00\x01\x02binary\x00"), body) _, _, body = rawGet(t, h, "/files/raw/main/pic.png") assert.Equal(t, tinyPNG(t), body) _, _, body = rawGet(t, h, "/files/raw/main/big.txt") assert.Len(t, body, 17*70000) _, _, body = rawGet(t, h, "/files/raw/main/evil.go") assert.Equal(t, webTrojan, string(body), "raw is not altered") // A file named like an image is not one unless its bytes say so. _, hdr, _ := rawGet(t, h, "/files/raw/main/fake.png") assert.Equal(t, "text/plain; charset=utf-8", hdr.Get("Content-Type"), "the type comes from the bytes, not the name") _, hdr, _ = rawGet(t, h, "/files/raw/main/fake.jpg") assert.Equal(t, "image/gif", hdr.Get("Content-Type")) _, _, page := h.get("/files/blob/main/fake.png") assert.NotContains(t, page, "