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 4: a published release cannot change under its link
948a6d7dcdacb72d6f90074ac0e5f9999314c4f4 · Keenan Nemetz · 17d ago · parent 85cc89fe
Phase 7 slice 4: a published release cannot change under its link The last piece of the release story is the one that makes the download links trustworthy: pre-receive now refuses to move or delete a tag that has artifacts attached, and on the metadata branch refuses removing a pointer or re-pointing one at different bytes. Comparing blob ids is enough — a pointer's id is its text, so "unchanged" needs no parsing and cannot be fooled by reformatting. Adding an artifact stays legal: an artifact is published when its pointer lands, so an addition invalidates nothing. The rules sit with the integrity checks rather than the review gate, because a download that can change bytes is broken whoever pushed it. An admin may override, with a line in data/audit.log, following the force-push precedent: a mistyped tag is otherwise unrepairable, and an unaudited override leaves no trace of the bytes that changed under somebody's link. Four refusals are tested through a writer account (moved tag, deleted tag, removed pointer, re-pointed pointer), plus the admin override and its audit line.
internal/server/releases_test.go +137
| @@ -464,3 +464,140 @@ func TestReleaseArtifactsArePrivateForPrivateRepos(t *testing.T) { | |||
| 464 | 464 | t.Fatalf("private artifact = %d, want 404", code) | |
| 465 | 465 | } | |
| 466 | 466 | } | |
| 467 | |||
| 468 | // pushMetaAs pushes a metadata change as a specific key, so a test can act as a | ||
| 469 | // writer rather than as the repository's admin owner. | ||
| 470 | func pushMetaAs(t *testing.T, f *fixture, key string, files map[string]string, remove []string, msg string) (string, error) { | ||
| 471 | t.Helper() | ||
| 472 | meta := f.newMetaRepo(t) | ||
| 473 | meta.sync() | ||
| 474 | for name, content := range files { | ||
| 475 | p := filepath.Join(meta.dir, name) | ||
| 476 | if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { | ||
| 477 | t.Fatal(err) | ||
| 478 | } | ||
| 479 | if err := os.WriteFile(p, []byte(content), 0o644); err != nil { | ||
| 480 | t.Fatal(err) | ||
| 481 | } | ||
| 482 | } | ||
| 483 | for _, name := range remove { | ||
| 484 | if _, errs, err := runGit(t, meta.dir, "rm", "-q", name); err != nil { | ||
| 485 | t.Fatalf("git rm %s: %v: %s", name, err, errs) | ||
| 486 | } | ||
| 487 | } | ||
| 488 | if _, errs, err := runGit(t, meta.dir, "add", "-A"); err != nil { | ||
| 489 | t.Fatalf("add: %v: %s", err, errs) | ||
| 490 | } | ||
| 491 | if _, errs, err := runGit(t, meta.dir, "commit", "-qm", msg); err != nil { | ||
| 492 | t.Fatalf("commit: %v: %s", err, errs) | ||
| 493 | } | ||
| 494 | _, errs, err := fgit(t, key, meta.dir, "push", "origin", "HEAD:refs/heads/vole") | ||
| 495 | return errs, err | ||
| 496 | } | ||
| 497 | |||
| 498 | func pointerFor(t *testing.T, f *fixture, data []byte) string { | ||
| 499 | t.Helper() | ||
| 500 | oid, err := f.srv.LFS.PutBytes(data) | ||
| 501 | if err != nil { | ||
| 502 | t.Fatal(err) | ||
| 503 | } | ||
| 504 | return fmt.Sprintf("version https://git-lfs.github.com/spec/v1\noid sha256:%s\nsize %d\n", oid, len(data)) | ||
| 505 | } | ||
| 506 | |||
| 507 | // TestPublishedReleaseIsImmutable covers the rule from four directions: the tag | ||
| 508 | // cannot move, the tag cannot be deleted, a pointer cannot be removed, and a | ||
| 509 | // pointer cannot be re-pointed. A link that can change bytes is worse than no | ||
| 510 | // link, and a writer is the person most likely to do it by accident. | ||
| 511 | func TestPublishedReleaseIsImmutable(t *testing.T) { | ||
| 512 | f := newFixture(t) | ||
| 513 | f.publish(f.repo) | ||
| 514 | work := workRepo(t, map[string]string{"a.txt": "one\n"}) | ||
| 515 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 516 | t.Fatalf("push: %s", errs) | ||
| 517 | } | ||
| 518 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "notes\n"); err != nil { | ||
| 519 | t.Fatalf("tag: %s", errs) | ||
| 520 | } | ||
| 521 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil { | ||
| 522 | t.Fatalf("push tag: %s", errs) | ||
| 523 | } | ||
| 524 | attachArtifact(t, f, "v1.0.0", "tool", []byte("the published bytes")) | ||
| 525 | |||
| 526 | writerKey := f.addWriter(t) | ||
| 527 | |||
| 528 | // A second commit for the tag to be moved onto. | ||
| 529 | if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("two\n"), 0o644); err != nil { | ||
| 530 | t.Fatal(err) | ||
| 531 | } | ||
| 532 | if _, errs, err := runGit(t, work, "add", "-A"); err != nil { | ||
| 533 | t.Fatal(errs) | ||
| 534 | } | ||
| 535 | if _, errs, err := runGit(t, work, "commit", "-qm", "second"); err != nil { | ||
| 536 | t.Fatal(errs) | ||
| 537 | } | ||
| 538 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 539 | t.Fatalf("push: %s", errs) | ||
| 540 | } | ||
| 541 | |||
| 542 | if _, errs, err := runGit(t, work, "tag", "-f", "-a", "v1.0.0", "-m", "moved\n"); err != nil { | ||
| 543 | t.Fatalf("re-tag: %s", errs) | ||
| 544 | } | ||
| 545 | if _, errs, err := fgit(t, writerKey, work, "push", "--force", f.sshURL(f.repo), | ||
| 546 | "refs/tags/v1.0.0:refs/tags/v1.0.0"); err == nil { | ||
| 547 | t.Fatalf("moving a released tag was accepted: %s", errs) | ||
| 548 | } else if !strings.Contains(errs, "release artifacts attached") { | ||
| 549 | t.Fatalf("refusal does not explain itself: %s", errs) | ||
| 550 | } | ||
| 551 | |||
| 552 | if _, errs, err := fgit(t, writerKey, work, "push", f.sshURL(f.repo), | ||
| 553 | ":refs/tags/v1.0.0"); err == nil { | ||
| 554 | t.Fatalf("deleting a released tag was accepted: %s", errs) | ||
| 555 | } | ||
| 556 | |||
| 557 | // A pointer cannot be removed... | ||
| 558 | if _, err := pushMetaAs(t, f, writerKey, nil, []string{"releases/v1.0.0/tool"}, "drop the artifact"); err == nil { | ||
| 559 | t.Fatal("removing a published artifact was accepted") | ||
| 560 | } | ||
| 561 | // ...and cannot be re-pointed at different bytes under the same name. | ||
| 562 | if _, err := pushMetaAs(t, f, writerKey, | ||
| 563 | map[string]string{"releases/v1.0.0/tool": pointerFor(t, f, []byte("different bytes"))}, nil, | ||
| 564 | "swap the artifact"); err == nil { | ||
| 565 | t.Fatal("re-pointing a published artifact was accepted") | ||
| 566 | } | ||
| 567 | // Adding another artifact is not a rewrite: it invalidates nothing. | ||
| 568 | if _, err := pushMetaAs(t, f, writerKey, | ||
| 569 | map[string]string{"releases/v1.0.0/tool2": pointerFor(t, f, []byte("second artifact"))}, nil, | ||
| 570 | "add an artifact"); err != nil { | ||
| 571 | t.Fatalf("adding an artifact was refused: %v", err) | ||
| 572 | } | ||
| 573 | } | ||
| 574 | |||
| 575 | // TestAdminCanRepairARelease, audited: refusing an admin outright leaves a | ||
| 576 | // mistyped tag unrepairable, and an unaudited override leaves no trace of the | ||
| 577 | // bytes that changed under somebody's link. | ||
| 578 | func TestAdminCanRepairARelease(t *testing.T) { | ||
| 579 | f := newFixture(t) | ||
| 580 | f.publish(f.repo) | ||
| 581 | work := workRepo(t, map[string]string{"a.txt": "one\n"}) | ||
| 582 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 583 | t.Fatalf("push: %s", errs) | ||
| 584 | } | ||
| 585 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "notes\n"); err != nil { | ||
| 586 | t.Fatalf("tag: %s", errs) | ||
| 587 | } | ||
| 588 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil { | ||
| 589 | t.Fatalf("push tag: %s", errs) | ||
| 590 | } | ||
| 591 | attachArtifact(t, f, "v1.0.0", "tool", []byte("bytes")) | ||
| 592 | |||
| 593 | if _, err := pushMetaAs(t, f, f.keyPath, nil, []string{"releases/v1.0.0/tool"}, "remove a bad artifact"); err != nil { | ||
| 594 | t.Fatalf("an admin could not repair the release: %v", err) | ||
| 595 | } | ||
| 596 | audit, err := os.ReadFile(f.cfg.AuditLog()) | ||
| 597 | if err != nil { | ||
| 598 | t.Fatalf("read the audit log: %v", err) | ||
| 599 | } | ||
| 600 | if !strings.Contains(string(audit), "release rewrite") { | ||
| 601 | t.Fatalf("the override left no audit line:\n%s", audit) | ||
| 602 | } | ||
| 603 | } | ||