Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/releases_test.go

Line attribution on main · 635 line(s)

c56911e2
Keenan Nemetz
17d ago
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"
85cc89fe
Keenan Nemetz
17d ago
11 "fmt"
c56911e2
Keenan Nemetz
17d ago
12 "io"
13 "net/http"
14 "os"
15 "path/filepath"
16 "strings"
17 "testing"
85cc89fe
Keenan Nemetz
17d ago
18 "time"
19
d37d301f
Keenan Nemetz
17h ago
20 "rodent.software/vole/forge"
c56911e2
Keenan Nemetz
17d ago
21 )
22
23 // releaseFixture builds a repository with one commit tagged v0.9.0 and a second
24 // commit tagged v1.0.0, both pushed, so "since the previous release" has a
25 // previous release to point at.
26 func releaseFixture(t *testing.T) *fixture {
27 t.Helper()
28 f := newFixture(t)
29 f.publish(f.repo)
30
31 work := workRepo(t, map[string]string{"readme.md": "first\n", "main.go": "package main\n"})
32 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
33 t.Fatalf("initial push: %s", errs)
34 }
35 if _, errs, err := runGit(t, work, "tag", "-a", "v0.9.0", "-m", "beta\n"); err != nil {
36 t.Fatalf("tag v0.9.0: %s", errs)
37 }
38
39 if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("first\nsecond\n"), 0o644); err != nil {
40 t.Fatal(err)
41 }
42 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
43 t.Fatalf("add: %s", errs)
44 }
45 if _, errs, err := runGit(t, work, "commit", "-qm", "add the second line"); err != nil {
46 t.Fatalf("commit: %s", errs)
47 }
48 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
49 t.Fatalf("second push: %s", errs)
50 }
51 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "First stable release\n\nHighlights:\n- the second line\n"); err != nil {
52 t.Fatalf("tag v1.0.0: %s", errs)
53 }
54 // A lightweight tag is not a release; it must still be listed and downloadable.
55 if _, errs, err := runGit(t, work, "tag", "scratch-tag"); err != nil {
56 t.Fatalf("lightweight tag: %s", errs)
57 }
58 for _, tag := range []string{"v0.9.0", "v1.0.0", "scratch-tag"} {
59 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/"+tag+":refs/tags/"+tag); err != nil {
60 t.Fatalf("push tag %s: %s", tag, errs)
61 }
62 }
63 return f
64 }
65
66 func TestReleaseListAndNotes(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
67 t.Parallel()
c56911e2
Keenan Nemetz
17d ago
68 f := releaseFixture(t)
69
ed03b3d1
Keenan Nemetz
5h ago
70 code, body := f.get("/" + f.repo + "/releases")
c56911e2
Keenan Nemetz
17d ago
71 if code != 200 {
72 t.Fatalf("releases page = %d", code)
73 }
74 for _, want := range []string{"v1.0.0", "v0.9.0", "First stable release", "beta"} {
75 if !strings.Contains(body, want) {
76 t.Fatalf("releases page does not show %q:\n%s", want, body)
77 }
78 }
79 // The lightweight tag is listed but labelled, because it is not a release.
80 if !strings.Contains(body, "lightweight") {
81 t.Fatalf("releases page does not label the lightweight tag:\n%s", body)
82 }
83 // The repository page counts releases, and counts only the annotated ones.
ed03b3d1
Keenan Nemetz
5h ago
84 if _, repoPage := f.get("/" + f.repo); !strings.Contains(repoPage, "/releases") {
c56911e2
Keenan Nemetz
17d ago
85 t.Fatalf("repository page does not link to releases:\n%s", repoPage)
86 }
87
ed03b3d1
Keenan Nemetz
5h ago
88 code, page := f.get("/" + f.repo + "/releases/v1.0.0")
c56911e2
Keenan Nemetz
17d ago
89 if code != 200 {
90 t.Fatalf("release page = %d", code)
91 }
92 for _, want := range []string{
93 "First stable release",
94 "the second line", // the notes body is rendered, not just the subject
95 "1 commit(s) since v0.9.0",
96 "add the second line", // the commit the release contains
97 "archive.tar", "archive.tar.gz",
98 } {
99 if !strings.Contains(page, want) {
100 t.Fatalf("release page does not show %q:\n%s", want, page)
101 }
102 }
103 // A repository with no LFS attributes gets no LFS warning.
104 if strings.Contains(page, "pointer files") {
105 t.Fatalf("release page warns about LFS in a repository that does not use it:\n%s", page)
106 }
107 // The first release has no previous tag to range over, and still renders.
ed03b3d1
Keenan Nemetz
5h ago
108 if code, first := f.get("/" + f.repo + "/releases/v0.9.0"); code != 200 || !strings.Contains(first, "beta") {
c56911e2
Keenan Nemetz
17d ago
109 t.Fatalf("first release page = %d, notes missing", code)
110 }
ed03b3d1
Keenan Nemetz
5h ago
111 if code, _ := f.get("/" + f.repo + "/releases/nope"); code != 404 {
c56911e2
Keenan Nemetz
17d ago
112 t.Fatalf("unknown tag = %d, want 404", code)
113 }
114 }
115
116 // TestReleaseNotesAreSanitised matters because the notes are somebody's markdown:
117 // a tag message is pushed content, so it goes through the same renderer as every
118 // other pushed body.
119 func TestReleaseNotesAreSanitised(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
120 t.Parallel()
c56911e2
Keenan Nemetz
17d ago
121 f := newFixture(t)
122 f.publish(f.repo)
123 work := workRepo(t, map[string]string{"readme.md": "x\n"})
124 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
125 t.Fatalf("push: %s", errs)
126 }
127 notes := "<script>alert(1)</script>\n\nand *some* markdown\n"
128 if _, errs, err := runGit(t, work, "tag", "-a", "v2.0.0", "-m", notes); err != nil {
129 t.Fatalf("tag: %s", errs)
130 }
131 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v2.0.0:refs/tags/v2.0.0"); err != nil {
132 t.Fatalf("push tag: %s", errs)
133 }
134
ed03b3d1
Keenan Nemetz
5h ago
135 _, page := f.get("/" + f.repo + "/releases/v2.0.0")
c56911e2
Keenan Nemetz
17d ago
136 if strings.Contains(page, "<script>alert(1)</script>") {
137 t.Fatalf("release notes execute as markup:\n%s", page)
138 }
139 if !strings.Contains(page, "<em>some</em>") {
140 t.Fatalf("release notes are not rendered as markdown:\n%s", page)
141 }
142 }
143
520470e1
Keenan Nemetz
16d ago
144 // TestReleasePageWarnsAboutLFSPointers checks that git archive copies pointer
145 // files verbatim, so a tarball of an LFS repository is not the bytes the reader
146 // expects. The page has to say so.
c56911e2
Keenan Nemetz
17d ago
147 func TestReleasePageWarnsAboutLFSPointers(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
148 t.Parallel()
c56911e2
Keenan Nemetz
17d ago
149 f := newFixture(t)
150 f.publish(f.repo)
151 work := workRepo(t, map[string]string{
152 ".gitattributes": "*.bin filter=lfs diff=lfs merge=lfs -text\n",
153 "readme.md": "x\n",
154 })
155 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
156 t.Fatalf("push: %s", errs)
157 }
158 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "with lfs\n"); err != nil {
159 t.Fatalf("tag: %s", errs)
160 }
161 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil {
162 t.Fatalf("push tag: %s", errs)
163 }
164
ed03b3d1
Keenan Nemetz
5h ago
165 code, page := f.get("/" + f.repo + "/releases/v1.0.0")
c56911e2
Keenan Nemetz
17d ago
166 if code != 200 {
167 t.Fatalf("release page = %d", code)
168 }
169 if !strings.Contains(page, "pointer files") {
170 t.Fatalf("release page does not warn about LFS pointers:\n%s", page)
171 }
172
173 }
174
175 func TestReleaseArchiveIsGeneratedFromTheTag(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
176 t.Parallel()
c56911e2
Keenan Nemetz
17d ago
177 f := releaseFixture(t)
178
ed03b3d1
Keenan Nemetz
5h ago
179 code, body := f.get("/" + f.repo + "/releases/v1.0.0/archive.tar")
c56911e2
Keenan Nemetz
17d ago
180 if code != 200 {
181 t.Fatalf("archive.tar = %d", code)
182 }
183 files := tarContents(t, strings.NewReader(body), false)
184 if got := files[f.repo+"-v1.0.0/readme.md"]; got != "first\nsecond\n" {
185 t.Fatalf("tarball readme = %q, want the second commit's content", got)
186 }
187 // The prefix is one directory, so extracting does not scatter the tree.
188 for name := range files {
189 if !strings.HasPrefix(name, f.repo+"-v1.0.0/") {
190 t.Fatalf("tarball entry %q is not under the release prefix", name)
191 }
192 }
193
194 // The first tag's tarball has the older content: this is what proves the
195 // archive is built from the tag rather than from the branch head.
ed03b3d1
Keenan Nemetz
5h ago
196 _, older := f.get("/" + f.repo + "/releases/v0.9.0/archive.tar")
c56911e2
Keenan Nemetz
17d ago
197 if got := tarContents(t, strings.NewReader(older), false)[f.repo+"-v0.9.0/readme.md"]; got != "first\n" {
198 t.Fatalf("v0.9.0 tarball readme = %q", got)
199 }
200
201 // A gzipped variant, decoded, holds the same file.
ed03b3d1
Keenan Nemetz
5h ago
202 code, gz := f.get("/" + f.repo + "/releases/v1.0.0/archive.tar.gz")
c56911e2
Keenan Nemetz
17d ago
203 if code != 200 {
204 t.Fatalf("archive.tar.gz = %d", code)
205 }
206 if got := tarContents(t, strings.NewReader(gz), true)[f.repo+"-v1.0.0/readme.md"]; got != "first\nsecond\n" {
207 t.Fatalf("gzipped tarball readme = %q", got)
208 }
209
210 // A lightweight tag has a tarball too: the archive is the tree, and the
211 // absence of notes does not change what the tree is.
ed03b3d1
Keenan Nemetz
5h ago
212 if code, _ := f.get("/" + f.repo + "/releases/scratch-tag/archive.tar"); code != 200 {
c56911e2
Keenan Nemetz
17d ago
213 t.Fatalf("lightweight tag archive = %d", code)
214 }
ed03b3d1
Keenan Nemetz
5h ago
215 if code, _ := f.get("/" + f.repo + "/releases/nope/archive.tar"); code != 404 {
c56911e2
Keenan Nemetz
17d ago
216 t.Fatalf("unknown tag archive = %d, want 404", code)
217 }
218 }
219
220 // TestReleaseArchiveValidators checks the two headers that make a generated
221 // tarball cacheable without a store behind it: an ETag derived from the tag
222 // object, and a 304 when the client already has those bytes.
223 func TestReleaseArchiveValidators(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
224 t.Parallel()
c56911e2
Keenan Nemetz
17d ago
225 f := releaseFixture(t)
226
ed03b3d1
Keenan Nemetz
5h ago
227 url := f.http.URL + "/" + f.repo + "/releases/v1.0.0/archive.tar"
c56911e2
Keenan Nemetz
17d ago
228 resp, err := http.Get(url)
229 if err != nil {
230 t.Fatal(err)
231 }
232 io.Copy(io.Discard, resp.Body)
233 resp.Body.Close()
234 etag := resp.Header.Get("ETag")
235 if etag == "" {
236 t.Fatal("archive has no ETag")
237 }
238 if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") {
239 t.Fatalf("archive Content-Disposition = %q, want attachment", cd)
240 }
241 if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
242 t.Fatal("archive is served without nosniff")
243 }
244
245 req, err := http.NewRequest(http.MethodGet, url, nil)
246 if err != nil {
247 t.Fatal(err)
248 }
249 req.Header.Set("If-None-Match", etag)
250 again, err := http.DefaultClient.Do(req)
251 if err != nil {
252 t.Fatal(err)
253 }
254 defer again.Body.Close()
255 if again.StatusCode != http.StatusNotModified {
256 t.Fatalf("conditional archive request = %d, want 304", again.StatusCode)
257 }
258 }
259
260 func TestReleasePagesAreNotPublicForPrivateRepos(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
261 t.Parallel()
c56911e2
Keenan Nemetz
17d ago
262 f := newFixture(t)
263 // Deliberately not published: an anonymous reader may not see it exists.
264 work := workRepo(t, map[string]string{"readme.md": "secret\n"})
265 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
266 t.Fatalf("push: %s", errs)
267 }
268 for _, path := range []string{
ed03b3d1
Keenan Nemetz
5h ago
269 "/" + f.repo + "/releases",
270 "/" + f.repo + "/releases/main",
271 "/" + f.repo + "/releases/main/archive.tar",
c56911e2
Keenan Nemetz
17d ago
272 } {
273 if code, _ := f.get(path); code != 404 {
274 t.Fatalf("%s on a private repo = %d, want 404", path, code)
275 }
276 }
277 }
278
279 // tarContents reads a tar stream into path -> body. Paths keep their prefix, so
280 // the caller can assert on the extraction directory too.
281 func tarContents(t *testing.T, r io.Reader, gzipped bool) map[string]string {
282 t.Helper()
283 if gzipped {
284 zr, err := gzip.NewReader(r)
285 if err != nil {
286 t.Fatalf("gzip: %v", err)
287 }
288 defer zr.Close()
289 r = zr
290 }
291 out := map[string]string{}
292 tr := tar.NewReader(r)
293 for {
294 hdr, err := tr.Next()
295 if err == io.EOF {
296 break
297 }
298 if err != nil {
299 t.Fatalf("tar: %v", err)
300 }
301 if hdr.Typeflag != tar.TypeReg {
302 continue
303 }
304 var buf bytes.Buffer
305 if _, err := io.Copy(&buf, tr); err != nil {
306 t.Fatalf("tar body %s: %v", hdr.Name, err)
307 }
308 out[hdr.Name] = buf.String()
309 }
310 if len(out) == 0 {
311 t.Fatal("tarball is empty")
312 }
313 return out
314 }
85cc89fe
Keenan Nemetz
17d ago
315
316 // attachArtifact publishes one file as a release artifact the way the CLI does:
317 // upload the bytes to the store, then push a pointer that names them. The order
318 // matters, and the hook enforces it — see the ordering test below.
319 func attachArtifact(t *testing.T, f *fixture, tag, name string, data []byte) (string, string) {
320 t.Helper()
65b88b3b
Keenan Nemetz
16d ago
321 oid, err := f.srv.LFS.PutBytes(f.repo, data)
85cc89fe
Keenan Nemetz
17d ago
322 if err != nil {
323 t.Fatalf("store the artifact: %v", err)
324 }
325 meta := f.newMetaRepo(t)
326 pointer := fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %d\n", oid, len(data))
327 if err := meta.apply(map[string]string{"releases/" + tag + "/" + name: pointer}, "attach "+name); err != nil {
328 t.Fatalf("push the pointer: %v", err)
329 }
330 return oid, pointer
331 }
332
333 func TestReleaseArtifactDownload(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
334 t.Parallel()
85cc89fe
Keenan Nemetz
17d ago
335 f := releaseFixture(t)
336 data := []byte("a release binary, not source code\n")
337 oid, _ := attachArtifact(t, f, "v1.0.0", "tool-linux-amd64", data)
338
339 // The page names the artifact and links to it, so a reader never has to guess
340 // a URL or hold a sha256.
ed03b3d1
Keenan Nemetz
5h ago
341 code, page := f.get("/" + f.repo + "/releases/v1.0.0")
85cc89fe
Keenan Nemetz
17d ago
342 if code != 200 {
343 t.Fatalf("release page = %d", code)
344 }
345 for _, want := range []string{"tool-linux-amd64", "/artifacts/tool-linux-amd64", "SHA-256"} {
346 if !strings.Contains(page, want) {
347 t.Fatalf("release page does not show %q:\n%s", want, page)
348 }
349 }
350
ed03b3d1
Keenan Nemetz
5h ago
351 url := f.http.URL + "/" + f.repo + "/releases/v1.0.0/artifacts/tool-linux-amd64"
85cc89fe
Keenan Nemetz
17d ago
352 resp, err := http.Get(url)
353 if err != nil {
354 t.Fatal(err)
355 }
356 body, _ := io.ReadAll(resp.Body)
357 resp.Body.Close()
358 if resp.StatusCode != 200 {
359 t.Fatalf("artifact download = %d", resp.StatusCode)
360 }
25343521
Keenan Nemetz
3d ago
361 if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "public") {
362 t.Fatalf("public artifact Cache-Control = %q, want public", cc)
363 }
85cc89fe
Keenan Nemetz
17d ago
364 if !bytes.Equal(body, data) {
365 t.Fatalf("downloaded %q, want %q", body, data)
366 }
367 // The validator is the content id, and the disposition is always an
368 // attachment: an artifact must never render on this origin.
369 if got, want := resp.Header.Get("ETag"), `"`+oid+`"`; got != want {
370 t.Fatalf("ETag = %q, want %q", got, want)
371 }
372 if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") {
373 t.Fatalf("Content-Disposition = %q", cd)
374 }
375 if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
376 t.Fatal("artifact is served without nosniff")
377 }
378
379 // A conditional request costs no bytes.
380 req, _ := http.NewRequest(http.MethodGet, url, nil)
381 req.Header.Set("If-None-Match", `"`+oid+`"`)
382 again, err := http.DefaultClient.Do(req)
383 if err != nil {
384 t.Fatal(err)
385 }
386 again.Body.Close()
387 if again.StatusCode != http.StatusNotModified {
388 t.Fatalf("conditional artifact request = %d, want 304", again.StatusCode)
389 }
390
391 // Range support is what makes a large artifact resumable.
392 req, _ = http.NewRequest(http.MethodGet, url, nil)
393 req.Header.Set("Range", "bytes=0-3")
394 part, err := http.DefaultClient.Do(req)
395 if err != nil {
396 t.Fatal(err)
397 }
398 sliced, _ := io.ReadAll(part.Body)
399 part.Body.Close()
400 if part.StatusCode != http.StatusPartialContent || string(sliced) != string(data[:4]) {
401 t.Fatalf("range request = %d %q, want 206 %q", part.StatusCode, sliced, data[:4])
402 }
403
ed03b3d1
Keenan Nemetz
5h ago
404 if code, _ := f.get("/" + f.repo + "/releases/v1.0.0/artifacts/nope"); code != 404 {
85cc89fe
Keenan Nemetz
17d ago
405 t.Fatalf("unknown artifact = %d, want 404", code)
406 }
407 }
408
409 // TestReleasePointerWithoutObjectIsRefused is the reason attach uploads first.
410 // receive-pack cannot notice — the bytes were never in the pack — so the hook has
411 // to read the pointer and check the store, or the release would advertise a
412 // download that 404s.
413 func TestReleasePointerWithoutObjectIsRefused(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
414 t.Parallel()
85cc89fe
Keenan Nemetz
17d ago
415 f := releaseFixture(t)
416 meta := f.newMetaRepo(t)
417 missing := strings.Repeat("ab", 32)
418 pointer := fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize 10\n", missing)
419 err := meta.apply(map[string]string{"releases/v1.0.0/ghost": pointer}, "attach a ghost")
420 if err == nil {
421 t.Fatal("the push accepted a pointer whose object was never uploaded")
422 }
423 if !strings.Contains(err.Error(), missing[:12]) {
424 t.Fatalf("the refusal does not name the missing object: %v", err)
425 }
426 // And the release page does not show it, because the ref never moved.
ed03b3d1
Keenan Nemetz
5h ago
427 if code, _ := f.get("/" + f.repo + "/releases/v1.0.0/artifacts/ghost"); code != 404 {
85cc89fe
Keenan Nemetz
17d ago
428 t.Fatalf("ghost artifact = %d, want 404", code)
429 }
430 }
431
432 // TestGCCountsReleaseArtifactsAsReachable pins the design decision that made the
433 // pointer the whole record: the sweep marks objects named by pointer blobs in any
434 // tree reachable from any ref, and the metadata branch is one of those refs.
435 func TestGCCountsReleaseArtifactsAsReachable(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
436 t.Parallel()
85cc89fe
Keenan Nemetz
17d ago
437 f := releaseFixture(t)
438 keep := []byte("published artifact")
439 drop := []byte("an upload nobody referenced")
440 keepOID, _ := attachArtifact(t, f, "v1.0.0", "keep.bin", keep)
65b88b3b
Keenan Nemetz
16d ago
441 dropOID, err := f.srv.LFS.PutBytes(f.repo, drop)
85cc89fe
Keenan Nemetz
17d ago
442 if err != nil {
443 t.Fatal(err)
444 }
445
c5902451
Keenan Nemetz
16d ago
446 res, err := forge.GC(forge.GCOptions{
447 Repos: []forge.GCRepo{{Name: f.repo, Dir: filepath.Join(f.cfg.ReposDir(), f.repo+".git")}},
85cc89fe
Keenan Nemetz
17d ago
448 Store: f.srv.LFS,
449 // Far enough ahead that the grace period protects nothing.
450 Now: time.Now().Add(48 * time.Hour),
451 })
452 if err != nil {
453 t.Fatalf("gc: %v", err)
454 }
65b88b3b
Keenan Nemetz
16d ago
455 if !f.srv.LFS.Has(f.repo, keepOID) {
85cc89fe
Keenan Nemetz
17d ago
456 t.Fatalf("gc swept the published artifact (%+v)", res)
457 }
65b88b3b
Keenan Nemetz
16d ago
458 if f.srv.LFS.Has(f.repo, dropOID) {
85cc89fe
Keenan Nemetz
17d ago
459 t.Fatalf("gc kept an object nothing reaches (%+v)", res)
460 }
461 }
462
463 func TestReleaseArtifactsArePrivateForPrivateRepos(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
464 t.Parallel()
85cc89fe
Keenan Nemetz
17d ago
465 f := newFixture(t)
466 work := workRepo(t, map[string]string{"readme.md": "secret\n"})
467 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
468 t.Fatalf("push: %s", errs)
469 }
470 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "notes\n"); err != nil {
471 t.Fatalf("tag: %s", errs)
472 }
473 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil {
474 t.Fatalf("push tag: %s", errs)
475 }
25343521
Keenan Nemetz
3d ago
476 attachArtifact(t, f, "v1.0.0", "tool", []byte("secret\n"))
ed03b3d1
Keenan Nemetz
5h ago
477 if code, _ := f.get("/" + f.repo + "/releases/v1.0.0/artifacts/tool"); code != 404 {
85cc89fe
Keenan Nemetz
17d ago
478 t.Fatalf("private artifact = %d, want 404", code)
479 }
25343521
Keenan Nemetz
3d ago
480
481 // An authorized reader may fetch the bytes, but a shared cache must not keep
482 // them and hand them to the next anonymous reader.
483 _, cookie := f.browserSession(t, f.ownerID)
484 for _, path := range []string{
ed03b3d1
Keenan Nemetz
5h ago
485 "/" + f.repo + "/releases/v1.0.0/artifacts/tool",
486 "/" + f.repo + "/releases/v1.0.0/archive.tar",
25343521
Keenan Nemetz
3d ago
487 } {
488 resp, _ := f.browserGet(t, path, cookie)
489 if resp.StatusCode != http.StatusOK {
490 t.Fatalf("%s = %d, want 200 for the owner", path, resp.StatusCode)
491 }
492 if cc := resp.Header.Get("Cache-Control"); strings.Contains(cc, "public") {
493 t.Fatalf("%s: Cache-Control = %q on a private repository", path, cc)
494 }
495 }
85cc89fe
Keenan Nemetz
17d ago
496 }
948a6d7d
Keenan Nemetz
17d ago
497
498 // pushMetaAs pushes a metadata change as a specific key, so a test can act as a
499 // writer rather than as the repository's admin owner.
500 func pushMetaAs(t *testing.T, f *fixture, key string, files map[string]string, remove []string, msg string) (string, error) {
501 t.Helper()
502 meta := f.newMetaRepo(t)
503 meta.sync()
504 for name, content := range files {
505 p := filepath.Join(meta.dir, name)
506 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
507 t.Fatal(err)
508 }
509 if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
510 t.Fatal(err)
511 }
512 }
513 for _, name := range remove {
514 if _, errs, err := runGit(t, meta.dir, "rm", "-q", name); err != nil {
515 t.Fatalf("git rm %s: %v: %s", name, err, errs)
516 }
517 }
518 if _, errs, err := runGit(t, meta.dir, "add", "-A"); err != nil {
519 t.Fatalf("add: %v: %s", err, errs)
520 }
521 if _, errs, err := runGit(t, meta.dir, "commit", "-qm", msg); err != nil {
522 t.Fatalf("commit: %v: %s", err, errs)
523 }
524 _, errs, err := fgit(t, key, meta.dir, "push", "origin", "HEAD:refs/heads/vole")
525 return errs, err
526 }
527
528 func pointerFor(t *testing.T, f *fixture, data []byte) string {
529 t.Helper()
65b88b3b
Keenan Nemetz
16d ago
530 oid, err := f.srv.LFS.PutBytes(f.repo, data)
948a6d7d
Keenan Nemetz
17d ago
531 if err != nil {
532 t.Fatal(err)
533 }
534 return fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %d\n", oid, len(data))
535 }
536
537 // TestPublishedReleaseIsImmutable covers the rule from four directions: the tag
538 // cannot move, the tag cannot be deleted, a pointer cannot be removed, and a
539 // pointer cannot be re-pointed. A link that can change bytes is worse than no
540 // link, and a writer is the person most likely to do it by accident.
541 func TestPublishedReleaseIsImmutable(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
542 t.Parallel()
948a6d7d
Keenan Nemetz
17d ago
543 f := newFixture(t)
544 f.publish(f.repo)
545 work := workRepo(t, map[string]string{"a.txt": "one\n"})
546 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
547 t.Fatalf("push: %s", errs)
548 }
549 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "notes\n"); err != nil {
550 t.Fatalf("tag: %s", errs)
551 }
552 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil {
553 t.Fatalf("push tag: %s", errs)
554 }
555 attachArtifact(t, f, "v1.0.0", "tool", []byte("the published bytes"))
556
557 writerKey := f.addWriter(t)
558
559 // A second commit for the tag to be moved onto.
560 if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("two\n"), 0o644); err != nil {
561 t.Fatal(err)
562 }
563 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
564 t.Fatal(errs)
565 }
566 if _, errs, err := runGit(t, work, "commit", "-qm", "second"); err != nil {
567 t.Fatal(errs)
568 }
569 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
570 t.Fatalf("push: %s", errs)
571 }
572
573 if _, errs, err := runGit(t, work, "tag", "-f", "-a", "v1.0.0", "-m", "moved\n"); err != nil {
574 t.Fatalf("re-tag: %s", errs)
575 }
576 if _, errs, err := fgit(t, writerKey, work, "push", "--force", f.sshURL(f.repo),
577 "refs/tags/v1.0.0:refs/tags/v1.0.0"); err == nil {
578 t.Fatalf("moving a released tag was accepted: %s", errs)
579 } else if !strings.Contains(errs, "release artifacts attached") {
580 t.Fatalf("refusal does not explain itself: %s", errs)
581 }
582
583 if _, errs, err := fgit(t, writerKey, work, "push", f.sshURL(f.repo),
584 ":refs/tags/v1.0.0"); err == nil {
585 t.Fatalf("deleting a released tag was accepted: %s", errs)
586 }
587
588 // A pointer cannot be removed...
589 if _, err := pushMetaAs(t, f, writerKey, nil, []string{"releases/v1.0.0/tool"}, "drop the artifact"); err == nil {
590 t.Fatal("removing a published artifact was accepted")
591 }
592 // ...and cannot be re-pointed at different bytes under the same name.
593 if _, err := pushMetaAs(t, f, writerKey,
594 map[string]string{"releases/v1.0.0/tool": pointerFor(t, f, []byte("different bytes"))}, nil,
595 "swap the artifact"); err == nil {
596 t.Fatal("re-pointing a published artifact was accepted")
597 }
598 // Adding another artifact is not a rewrite: it invalidates nothing.
599 if _, err := pushMetaAs(t, f, writerKey,
600 map[string]string{"releases/v1.0.0/tool2": pointerFor(t, f, []byte("second artifact"))}, nil,
601 "add an artifact"); err != nil {
602 t.Fatalf("adding an artifact was refused: %v", err)
603 }
604 }
605
606 // TestAdminCanRepairARelease, audited: refusing an admin outright leaves a
607 // mistyped tag unrepairable, and an unaudited override leaves no trace of the
608 // bytes that changed under somebody's link.
609 func TestAdminCanRepairARelease(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
610 t.Parallel()
948a6d7d
Keenan Nemetz
17d ago
611 f := newFixture(t)
612 f.publish(f.repo)
613 work := workRepo(t, map[string]string{"a.txt": "one\n"})
614 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
615 t.Fatalf("push: %s", errs)
616 }
617 if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "notes\n"); err != nil {
618 t.Fatalf("tag: %s", errs)
619 }
620 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil {
621 t.Fatalf("push tag: %s", errs)
622 }
623 attachArtifact(t, f, "v1.0.0", "tool", []byte("bytes"))
624
625 if _, err := pushMetaAs(t, f, f.keyPath, nil, []string{"releases/v1.0.0/tool"}, "remove a bad artifact"); err != nil {
626 t.Fatalf("an admin could not repair the release: %v", err)
627 }
628 audit, err := os.ReadFile(f.cfg.AuditLog())
629 if err != nil {
630 t.Fatalf("read the audit log: %v", err)
631 }
b1499c72
Keenan Nemetz
13d ago
632 if !strings.Contains(string(audit), "\trelease.rewrite\tkeenan\tdemo\t") {
948a6d7d
Keenan Nemetz
17d ago
633 t.Fatalf("the override left no audit line:\n%s", audit)
634 }
635 }