vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Phase 7 slice 2: a release is a tag the web can read
c56911e2c3ede0f70c7cde560042806dbf4b4ab0 · Keenan Nemetz · 17d ago · parent f76c635b
Phase 7 slice 2: a release is a tag the web can read A release is an annotated tag, so nothing new is stored: the tag message is the notes, the tree is the tarball, and both are already in every clone. - internal/gitx/tags.go lists refs/tags peeled to their commits and tells an annotated tag from a lightweight one; a lightweight tag's %(contents) is the commit message, so reading it as notes would invent a release description nobody wrote - internal/gitx/archive.go streams `git archive`; tarballs are generated per request and never stored, so they cannot disagree with the tag - internal/web/releases.go renders the list (lightweight tags labelled), the release page (notes through the same sanitiser as every pushed body, commits since the previous release, two downloads) and the archive handlers: ETag is the tag object id plus the format, conditional requests get 304, always Content-Disposition: attachment with nosniff, compressed streams queue behind a semaphore of 4 while plain tar does not - "since the previous release" is `git describe --abbrev=0 <tag>^`, not the next row of the tag list: two tags made in the same second sort arbitrarily, and a page that changes its baseline between requests is worse than one without one - the page warns when the tagged tree tracks LFS, because git archive copies pointer files verbatim (measured in spike D) - cmd/vole release create writes the annotated tag and refuses an empty message or an existing name: re-tagging is how a download link comes to mean different bytes Measured end to end against the binary with an 11.6 MiB, 2,566-file repository: release list 6.1 ms, release page 12.6 ms, .tar 53.7 ms / 13.7 MiB, .tar.gz 218 ms / 1.8 MiB, conditional request 304. Every file in the served tarball hashed to its ls-tree blob id (2,565 of 2,565).
internal/server/releases_test.go +304
| @@ -0,0 +1,304 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // A release is an annotated tag, so these tests push tags the way a user would | ||
| 4 | // and then read the web: the list, the notes, the commits since the previous | ||
| 5 | // release, and the tarball generated from the tag. | ||
| 6 | |||
| 7 | import ( | ||
| 8 | "archive/tar" | ||
| 9 | "bytes" | ||
| 10 | "compress/gzip" | ||
| 11 | "io" | ||
| 12 | "net/http" | ||
| 13 | "os" | ||
| 14 | "path/filepath" | ||
| 15 | "strings" | ||
| 16 | "testing" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // releaseFixture builds a repository with one commit tagged v0.9.0 and a second | ||
| 20 | // commit tagged v1.0.0, both pushed, so "since the previous release" has a | ||
| 21 | // previous release to point at. | ||
| 22 | func releaseFixture(t *testing.T) *fixture { | ||
| 23 | t.Helper() | ||
| 24 | f := newFixture(t) | ||
| 25 | f.publish(f.repo) | ||
| 26 | |||
| 27 | work := workRepo(t, map[string]string{"readme.md": "first\n", "main.go": "package main\n"}) | ||
| 28 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 29 | t.Fatalf("initial push: %s", errs) | ||
| 30 | } | ||
| 31 | if _, errs, err := runGit(t, work, "tag", "-a", "v0.9.0", "-m", "beta\n"); err != nil { | ||
| 32 | t.Fatalf("tag v0.9.0: %s", errs) | ||
| 33 | } | ||
| 34 | |||
| 35 | if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("first\nsecond\n"), 0o644); err != nil { | ||
| 36 | t.Fatal(err) | ||
| 37 | } | ||
| 38 | if _, errs, err := runGit(t, work, "add", "-A"); err != nil { | ||
| 39 | t.Fatalf("add: %s", errs) | ||
| 40 | } | ||
| 41 | if _, errs, err := runGit(t, work, "commit", "-qm", "add the second line"); err != nil { | ||
| 42 | t.Fatalf("commit: %s", errs) | ||
| 43 | } | ||
| 44 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 45 | t.Fatalf("second push: %s", errs) | ||
| 46 | } | ||
| 47 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "First stable release\n\nHighlights:\n- the second line\n"); err != nil { | ||
| 48 | t.Fatalf("tag v1.0.0: %s", errs) | ||
| 49 | } | ||
| 50 | // A lightweight tag is not a release; it must still be listed and downloadable. | ||
| 51 | if _, errs, err := runGit(t, work, "tag", "scratch-tag"); err != nil { | ||
| 52 | t.Fatalf("lightweight tag: %s", errs) | ||
| 53 | } | ||
| 54 | for _, tag := range []string{"v0.9.0", "v1.0.0", "scratch-tag"} { | ||
| 55 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/"+tag+":refs/tags/"+tag); err != nil { | ||
| 56 | t.Fatalf("push tag %s: %s", tag, errs) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | return f | ||
| 60 | } | ||
| 61 | |||
| 62 | func TestReleaseListAndNotes(t *testing.T) { | ||
| 63 | f := releaseFixture(t) | ||
| 64 | |||
| 65 | code, body := f.get("/r/" + f.repo + "/releases") | ||
| 66 | if code != 200 { | ||
| 67 | t.Fatalf("releases page = %d", code) | ||
| 68 | } | ||
| 69 | for _, want := range []string{"v1.0.0", "v0.9.0", "First stable release", "beta"} { | ||
| 70 | if !strings.Contains(body, want) { | ||
| 71 | t.Fatalf("releases page does not show %q:\n%s", want, body) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | // The lightweight tag is listed but labelled, because it is not a release. | ||
| 75 | if !strings.Contains(body, "lightweight") { | ||
| 76 | t.Fatalf("releases page does not label the lightweight tag:\n%s", body) | ||
| 77 | } | ||
| 78 | // The repository page counts releases, and counts only the annotated ones. | ||
| 79 | if _, repoPage := f.get("/r/" + f.repo); !strings.Contains(repoPage, "/releases") { | ||
| 80 | t.Fatalf("repository page does not link to releases:\n%s", repoPage) | ||
| 81 | } | ||
| 82 | |||
| 83 | code, page := f.get("/r/" + f.repo + "/releases/v1.0.0") | ||
| 84 | if code != 200 { | ||
| 85 | t.Fatalf("release page = %d", code) | ||
| 86 | } | ||
| 87 | for _, want := range []string{ | ||
| 88 | "First stable release", | ||
| 89 | "the second line", // the notes body is rendered, not just the subject | ||
| 90 | "1 commit(s) since v0.9.0", | ||
| 91 | "add the second line", // the commit the release contains | ||
| 92 | "archive.tar", "archive.tar.gz", | ||
| 93 | } { | ||
| 94 | if !strings.Contains(page, want) { | ||
| 95 | t.Fatalf("release page does not show %q:\n%s", want, page) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | // A repository with no LFS attributes gets no LFS warning. | ||
| 99 | if strings.Contains(page, "pointer files") { | ||
| 100 | t.Fatalf("release page warns about LFS in a repository that does not use it:\n%s", page) | ||
| 101 | } | ||
| 102 | // The first release has no previous tag to range over, and still renders. | ||
| 103 | if code, first := f.get("/r/" + f.repo + "/releases/v0.9.0"); code != 200 || !strings.Contains(first, "beta") { | ||
| 104 | t.Fatalf("first release page = %d, notes missing", code) | ||
| 105 | } | ||
| 106 | if code, _ := f.get("/r/" + f.repo + "/releases/nope"); code != 404 { | ||
| 107 | t.Fatalf("unknown tag = %d, want 404", code) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | // TestReleaseNotesAreSanitised matters because the notes are somebody's markdown: | ||
| 112 | // a tag message is pushed content, so it goes through the same renderer as every | ||
| 113 | // other pushed body. | ||
| 114 | func TestReleaseNotesAreSanitised(t *testing.T) { | ||
| 115 | f := newFixture(t) | ||
| 116 | f.publish(f.repo) | ||
| 117 | work := workRepo(t, map[string]string{"readme.md": "x\n"}) | ||
| 118 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 119 | t.Fatalf("push: %s", errs) | ||
| 120 | } | ||
| 121 | notes := "<script>alert(1)</script>\n\nand *some* markdown\n" | ||
| 122 | if _, errs, err := runGit(t, work, "tag", "-a", "v2.0.0", "-m", notes); err != nil { | ||
| 123 | t.Fatalf("tag: %s", errs) | ||
| 124 | } | ||
| 125 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v2.0.0:refs/tags/v2.0.0"); err != nil { | ||
| 126 | t.Fatalf("push tag: %s", errs) | ||
| 127 | } | ||
| 128 | |||
| 129 | _, page := f.get("/r/" + f.repo + "/releases/v2.0.0") | ||
| 130 | if strings.Contains(page, "<script>alert(1)</script>") { | ||
| 131 | t.Fatalf("release notes execute as markup:\n%s", page) | ||
| 132 | } | ||
| 133 | if !strings.Contains(page, "<em>some</em>") { | ||
| 134 | t.Fatalf("release notes are not rendered as markdown:\n%s", page) | ||
| 135 | } | ||
| 136 | } | ||
| 137 | |||
| 138 | // TestReleasePageWarnsAboutLFSPointers pins the finding from spike D: git archive | ||
| 139 | // copies pointer files verbatim, so a tarball of an LFS repository is not the | ||
| 140 | // bytes the reader expects. The page has to say so. | ||
| 141 | func TestReleasePageWarnsAboutLFSPointers(t *testing.T) { | ||
| 142 | f := newFixture(t) | ||
| 143 | f.publish(f.repo) | ||
| 144 | work := workRepo(t, map[string]string{ | ||
| 145 | ".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n", | ||
| 146 | "readme.md": "x\n", | ||
| 147 | }) | ||
| 148 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 149 | t.Fatalf("push: %s", errs) | ||
| 150 | } | ||
| 151 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "with lfs\n"); err != nil { | ||
| 152 | t.Fatalf("tag: %s", errs) | ||
| 153 | } | ||
| 154 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil { | ||
| 155 | t.Fatalf("push tag: %s", errs) | ||
| 156 | } | ||
| 157 | |||
| 158 | code, page := f.get("/r/" + f.repo + "/releases/v1.0.0") | ||
| 159 | if code != 200 { | ||
| 160 | t.Fatalf("release page = %d", code) | ||
| 161 | } | ||
| 162 | if !strings.Contains(page, "pointer files") { | ||
| 163 | t.Fatalf("release page does not warn about LFS pointers:\n%s", page) | ||
| 164 | } | ||
| 165 | |||
| 166 | } | ||
| 167 | |||
| 168 | func TestReleaseArchiveIsGeneratedFromTheTag(t *testing.T) { | ||
| 169 | f := releaseFixture(t) | ||
| 170 | |||
| 171 | code, body := f.get("/r/" + f.repo + "/releases/v1.0.0/archive.tar") | ||
| 172 | if code != 200 { | ||
| 173 | t.Fatalf("archive.tar = %d", code) | ||
| 174 | } | ||
| 175 | files := tarContents(t, strings.NewReader(body), false) | ||
| 176 | if got := files[f.repo+"-v1.0.0/readme.md"]; got != "first\nsecond\n" { | ||
| 177 | t.Fatalf("tarball readme = %q, want the second commit's content", got) | ||
| 178 | } | ||
| 179 | // The prefix is one directory, so extracting does not scatter the tree. | ||
| 180 | for name := range files { | ||
| 181 | if !strings.HasPrefix(name, f.repo+"-v1.0.0/") { | ||
| 182 | t.Fatalf("tarball entry %q is not under the release prefix", name) | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | // The first tag's tarball has the older content: this is what proves the | ||
| 187 | // archive is built from the tag rather than from the branch head. | ||
| 188 | _, older := f.get("/r/" + f.repo + "/releases/v0.9.0/archive.tar") | ||
| 189 | if got := tarContents(t, strings.NewReader(older), false)[f.repo+"-v0.9.0/readme.md"]; got != "first\n" { | ||
| 190 | t.Fatalf("v0.9.0 tarball readme = %q", got) | ||
| 191 | } | ||
| 192 | |||
| 193 | // A gzipped variant, decoded, holds the same file. | ||
| 194 | code, gz := f.get("/r/" + f.repo + "/releases/v1.0.0/archive.tar.gz") | ||
| 195 | if code != 200 { | ||
| 196 | t.Fatalf("archive.tar.gz = %d", code) | ||
| 197 | } | ||
| 198 | if got := tarContents(t, strings.NewReader(gz), true)[f.repo+"-v1.0.0/readme.md"]; got != "first\nsecond\n" { | ||
| 199 | t.Fatalf("gzipped tarball readme = %q", got) | ||
| 200 | } | ||
| 201 | |||
| 202 | // A lightweight tag has a tarball too: the archive is the tree, and the | ||
| 203 | // absence of notes does not change what the tree is. | ||
| 204 | if code, _ := f.get("/r/" + f.repo + "/releases/scratch-tag/archive.tar"); code != 200 { | ||
| 205 | t.Fatalf("lightweight tag archive = %d", code) | ||
| 206 | } | ||
| 207 | if code, _ := f.get("/r/" + f.repo + "/releases/nope/archive.tar"); code != 404 { | ||
| 208 | t.Fatalf("unknown tag archive = %d, want 404", code) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | // TestReleaseArchiveValidators checks the two headers that make a generated | ||
| 213 | // tarball cacheable without a store behind it: an ETag derived from the tag | ||
| 214 | // object, and a 304 when the client already has those bytes. | ||
| 215 | func TestReleaseArchiveValidators(t *testing.T) { | ||
| 216 | f := releaseFixture(t) | ||
| 217 | |||
| 218 | url := f.http.URL + "/r/" + f.repo + "/releases/v1.0.0/archive.tar" | ||
| 219 | resp, err := http.Get(url) | ||
| 220 | if err != nil { | ||
| 221 | t.Fatal(err) | ||
| 222 | } | ||
| 223 | io.Copy(io.Discard, resp.Body) | ||
| 224 | resp.Body.Close() | ||
| 225 | etag := resp.Header.Get("ETag") | ||
| 226 | if etag == "" { | ||
| 227 | t.Fatal("archive has no ETag") | ||
| 228 | } | ||
| 229 | if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") { | ||
| 230 | t.Fatalf("archive Content-Disposition = %q, want attachment", cd) | ||
| 231 | } | ||
| 232 | if resp.Header.Get("X-Content-Type-Options") != "nosniff" { | ||
| 233 | t.Fatal("archive is served without nosniff") | ||
| 234 | } | ||
| 235 | |||
| 236 | req, err := http.NewRequest(http.MethodGet, url, nil) | ||
| 237 | if err != nil { | ||
| 238 | t.Fatal(err) | ||
| 239 | } | ||
| 240 | req.Header.Set("If-None-Match", etag) | ||
| 241 | again, err := http.DefaultClient.Do(req) | ||
| 242 | if err != nil { | ||
| 243 | t.Fatal(err) | ||
| 244 | } | ||
| 245 | defer again.Body.Close() | ||
| 246 | if again.StatusCode != http.StatusNotModified { | ||
| 247 | t.Fatalf("conditional archive request = %d, want 304", again.StatusCode) | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 251 | func TestReleasePagesAreNotPublicForPrivateRepos(t *testing.T) { | ||
| 252 | f := newFixture(t) | ||
| 253 | // Deliberately not published: an anonymous reader may not see it exists. | ||
| 254 | work := workRepo(t, map[string]string{"readme.md": "secret\n"}) | ||
| 255 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 256 | t.Fatalf("push: %s", errs) | ||
| 257 | } | ||
| 258 | for _, path := range []string{ | ||
| 259 | "/r/" + f.repo + "/releases", | ||
| 260 | "/r/" + f.repo + "/releases/main", | ||
| 261 | "/r/" + f.repo + "/releases/main/archive.tar", | ||
| 262 | } { | ||
| 263 | if code, _ := f.get(path); code != 404 { | ||
| 264 | t.Fatalf("%s on a private repo = %d, want 404", path, code) | ||
| 265 | } | ||
| 266 | } | ||
| 267 | } | ||
| 268 | |||
| 269 | // tarContents reads a tar stream into path -> body. Paths keep their prefix, so | ||
| 270 | // the caller can assert on the extraction directory too. | ||
| 271 | func tarContents(t *testing.T, r io.Reader, gzipped bool) map[string]string { | ||
| 272 | t.Helper() | ||
| 273 | if gzipped { | ||
| 274 | zr, err := gzip.NewReader(r) | ||
| 275 | if err != nil { | ||
| 276 | t.Fatalf("gzip: %v", err) | ||
| 277 | } | ||
| 278 | defer zr.Close() | ||
| 279 | r = zr | ||
| 280 | } | ||
| 281 | out := map[string]string{} | ||
| 282 | tr := tar.NewReader(r) | ||
| 283 | for { | ||
| 284 | hdr, err := tr.Next() | ||
| 285 | if err == io.EOF { | ||
| 286 | break | ||
| 287 | } | ||
| 288 | if err != nil { | ||
| 289 | t.Fatalf("tar: %v", err) | ||
| 290 | } | ||
| 291 | if hdr.Typeflag != tar.TypeReg { | ||
| 292 | continue | ||
| 293 | } | ||
| 294 | var buf bytes.Buffer | ||
| 295 | if _, err := io.Copy(&buf, tr); err != nil { | ||
| 296 | t.Fatalf("tar body %s: %v", hdr.Name, err) | ||
| 297 | } | ||
| 298 | out[hdr.Name] = buf.String() | ||
| 299 | } | ||
| 300 | if len(out) == 0 { | ||
| 301 | t.Fatal("tarball is empty") | ||
| 302 | } | ||
| 303 | return out | ||
| 304 | } | ||