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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 1h ago
4b8c9e1d
| 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 | "fmt" |
| 12 | "io" |
| 13 | "net/http" |
| 14 | "os" |
| 15 | "path/filepath" |
| 16 | "strings" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "rodent.software/vole/forge" |
| 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) { |
| 67 | t.Parallel() |
| 68 | f := releaseFixture(t) |
| 69 | |
| 70 | code, body := f.get("/" + f.repo + "/releases") |
| 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. |
| 84 | if _, repoPage := f.get("/" + f.repo); !strings.Contains(repoPage, "/releases") { |
| 85 | t.Fatalf("repository page does not link to releases:\n%s", repoPage) |
| 86 | } |
| 87 | |
| 88 | code, page := f.get("/" + f.repo + "/releases/v1.0.0") |
| 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. |
| 108 | if code, first := f.get("/" + f.repo + "/releases/v0.9.0"); code != 200 || !strings.Contains(first, "beta") { |
| 109 | t.Fatalf("first release page = %d, notes missing", code) |
| 110 | } |
| 111 | if code, _ := f.get("/" + f.repo + "/releases/nope"); code != 404 { |
| 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) { |
| 120 | t.Parallel() |
| 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 | |
| 135 | _, page := f.get("/" + f.repo + "/releases/v2.0.0") |
| 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 | |
| 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. |
| 147 | func TestReleasePageWarnsAboutLFSPointers(t *testing.T) { |
| 148 | t.Parallel() |
| 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 | |
| 165 | code, page := f.get("/" + f.repo + "/releases/v1.0.0") |
| 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) { |
| 176 | t.Parallel() |
| 177 | f := releaseFixture(t) |
| 178 | |
| 179 | code, body := f.get("/" + f.repo + "/releases/v1.0.0/archive.tar") |
| 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. |
| 196 | _, older := f.get("/" + f.repo + "/releases/v0.9.0/archive.tar") |
| 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. |
| 202 | code, gz := f.get("/" + f.repo + "/releases/v1.0.0/archive.tar.gz") |
| 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. |
| 212 | if code, _ := f.get("/" + f.repo + "/releases/scratch-tag/archive.tar"); code != 200 { |
| 213 | t.Fatalf("lightweight tag archive = %d", code) |
| 214 | } |
| 215 | if code, _ := f.get("/" + f.repo + "/releases/nope/archive.tar"); code != 404 { |
| 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) { |
| 224 | t.Parallel() |
| 225 | f := releaseFixture(t) |
| 226 | |
| 227 | url := f.http.URL + "/" + f.repo + "/releases/v1.0.0/archive.tar" |
| 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) { |
| 261 | t.Parallel() |
| 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{ |
| 269 | "/" + f.repo + "/releases", |
| 270 | "/" + f.repo + "/releases/main", |
| 271 | "/" + f.repo + "/releases/main/archive.tar", |
| 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 | } |
| 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() |
| 321 | oid, err := f.srv.LFS.PutBytes(f.repo, data) |
| 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) { |
| 334 | t.Parallel() |
| 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. |
| 341 | code, page := f.get("/" + f.repo + "/releases/v1.0.0") |
| 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 | |
| 351 | url := f.http.URL + "/" + f.repo + "/releases/v1.0.0/artifacts/tool-linux-amd64" |
| 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 | } |
| 361 | if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "public") { |
| 362 | t.Fatalf("public artifact Cache-Control = %q, want public", cc) |
| 363 | } |
| 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 | |
| 404 | if code, _ := f.get("/" + f.repo + "/releases/v1.0.0/artifacts/nope"); code != 404 { |
| 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) { |
| 414 | t.Parallel() |
| 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. |
| 427 | if code, _ := f.get("/" + f.repo + "/releases/v1.0.0/artifacts/ghost"); code != 404 { |
| 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) { |
| 436 | t.Parallel() |
| 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) |
| 441 | dropOID, err := f.srv.LFS.PutBytes(f.repo, drop) |
| 442 | if err != nil { |
| 443 | t.Fatal(err) |
| 444 | } |
| 445 | |
| 446 | res, err := forge.GC(forge.GCOptions{ |
| 447 | Repos: []forge.GCRepo{{Name: f.repo, Dir: filepath.Join(f.cfg.ReposDir(), f.repo+".git")}}, |
| 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 | } |
| 455 | if !f.srv.LFS.Has(f.repo, keepOID) { |
| 456 | t.Fatalf("gc swept the published artifact (%+v)", res) |
| 457 | } |
| 458 | if f.srv.LFS.Has(f.repo, dropOID) { |
| 459 | t.Fatalf("gc kept an object nothing reaches (%+v)", res) |
| 460 | } |
| 461 | } |
| 462 | |
| 463 | func TestReleaseArtifactsArePrivateForPrivateRepos(t *testing.T) { |
| 464 | t.Parallel() |
| 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 | } |
| 476 | attachArtifact(t, f, "v1.0.0", "tool", []byte("secret\n")) |
| 477 | if code, _ := f.get("/" + f.repo + "/releases/v1.0.0/artifacts/tool"); code != 404 { |
| 478 | t.Fatalf("private artifact = %d, want 404", code) |
| 479 | } |
| 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{ |
| 485 | "/" + f.repo + "/releases/v1.0.0/artifacts/tool", |
| 486 | "/" + f.repo + "/releases/v1.0.0/archive.tar", |
| 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 | } |
| 496 | } |
| 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() |
| 530 | oid, err := f.srv.LFS.PutBytes(f.repo, data) |
| 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) { |
| 542 | t.Parallel() |
| 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) { |
| 610 | t.Parallel() |
| 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 | } |
| 632 | if !strings.Contains(string(audit), "\trelease.rewrite\tkeenan\tdemo\t") { |
| 633 | t.Fatalf("the override left no audit line:\n%s", audit) |
| 634 | } |
| 635 | } |
| 636 |