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/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 }