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/hook/releases.go +110
| @@ -0,0 +1,110 @@ | |||
| 1 | package hook | ||
| 2 | |||
| 3 | // Published releases are immutable, and this is the only place that can enforce | ||
| 4 | // it: the tag and the pointers arrive in ordinary pushes, and nothing else sees | ||
| 5 | // both. A download link whose bytes can change is worse than no link — the ETag | ||
| 6 | // moves with the bytes, so a cache tells the truth while the human who saved the | ||
| 7 | // old file has no way to notice. | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "fmt" | ||
| 11 | "sort" | ||
| 12 | "strings" | ||
| 13 | |||
| 14 | "github.com/rodent-software/vole/internal/gitx" | ||
| 15 | "github.com/rodent-software/vole/internal/issues" | ||
| 16 | "github.com/rodent-software/vole/internal/releases" | ||
| 17 | ) | ||
| 18 | |||
| 19 | // releaseFindings reports the changes that would detach a published artifact from | ||
| 20 | // the bytes its link serves: | ||
| 21 | // | ||
| 22 | // - moving or deleting a tag that releases/<tag>/ holds artifacts for; | ||
| 23 | // - deleting a pointer, or re-pointing one at different bytes. | ||
| 24 | // | ||
| 25 | // Adding artifacts is allowed: an artifact is published when its pointer lands, so | ||
| 26 | // there is nothing for an addition to invalidate. | ||
| 27 | func releaseFindings(g *gitx.Repo, c change) []string { | ||
| 28 | if strings.HasPrefix(c.ref, "refs/tags/") { | ||
| 29 | name := strings.TrimPrefix(c.ref, "refs/tags/") | ||
| 30 | if !tagHasArtifacts(g, name) { | ||
| 31 | return nil | ||
| 32 | } | ||
| 33 | verb := "moved" | ||
| 34 | if c.new == gitx.ZeroSHA || c.new == "" { | ||
| 35 | verb = "deleted" | ||
| 36 | } | ||
| 37 | return []string{fmt.Sprintf( | ||
| 38 | "%s: this tag has release artifacts attached, so it cannot be %s; "+ | ||
| 39 | "a release is immutable once published. Tag the new commit with a new name", | ||
| 40 | c.ref, verb)} | ||
| 41 | } | ||
| 42 | if c.ref != issues.Ref { | ||
| 43 | return nil | ||
| 44 | } | ||
| 45 | if c.old == "" || c.old == gitx.ZeroSHA { | ||
| 46 | return nil // a new metadata branch publishes nothing old | ||
| 47 | } | ||
| 48 | before := releaseBlobs(g, c.old) | ||
| 49 | if len(before) == 0 { | ||
| 50 | return nil | ||
| 51 | } | ||
| 52 | after := releaseBlobs(g, c.new) // an empty map when the branch is deleted | ||
| 53 | var problems []string | ||
| 54 | for path, sha := range before { | ||
| 55 | now, ok := after[path] | ||
| 56 | switch { | ||
| 57 | case !ok: | ||
| 58 | problems = append(problems, fmt.Sprintf( | ||
| 59 | "%s: a published artifact cannot be removed; publish a new release instead", path)) | ||
| 60 | case now != sha: | ||
| 61 | problems = append(problems, fmt.Sprintf( | ||
| 62 | "%s: a published artifact cannot be re-pointed at different bytes; "+ | ||
| 63 | "attach the new file under a new name", path)) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | sort.Strings(problems) | ||
| 67 | return problems | ||
| 68 | } | ||
| 69 | |||
| 70 | // releaseBlobs maps every path under releases/ in a commit to its blob id. The | ||
| 71 | // blob id is the pointer text, so an unchanged artifact has an unchanged id: the | ||
| 72 | // comparison needs no parsing and cannot be fooled by a reformatted pointer. | ||
| 73 | func releaseBlobs(g *gitx.Repo, commit string) map[string]string { | ||
| 74 | if commit == "" || commit == gitx.ZeroSHA { | ||
| 75 | return nil | ||
| 76 | } | ||
| 77 | entries, err := g.LsTreeR(commit) | ||
| 78 | if err != nil { | ||
| 79 | return nil | ||
| 80 | } | ||
| 81 | out := map[string]string{} | ||
| 82 | for _, e := range entries { | ||
| 83 | if strings.HasPrefix(e.Name, releases.Dir+"/") { | ||
| 84 | out[e.Name] = e.SHA | ||
| 85 | } | ||
| 86 | } | ||
| 87 | return out | ||
| 88 | } | ||
| 89 | |||
| 90 | // tagHasArtifacts reports whether the metadata branch has a releases/<tag>/ | ||
| 91 | // directory. It reads the current tip rather than the pushed tree: a tag push and | ||
| 92 | // a pointer push can arrive in either order, and a tag is only bound to artifacts | ||
| 93 | // that already exist. | ||
| 94 | func tagHasArtifacts(g *gitx.Repo, tag string) bool { | ||
| 95 | tip, err := g.RevParse(issues.Ref) | ||
| 96 | if err != nil || tip == "" { | ||
| 97 | return false | ||
| 98 | } | ||
| 99 | entries, err := g.LsTreeR(tip) | ||
| 100 | if err != nil { | ||
| 101 | return false | ||
| 102 | } | ||
| 103 | prefix := releases.Dir + "/" + tag + "/" | ||
| 104 | for _, e := range entries { | ||
| 105 | if strings.HasPrefix(e.Name, prefix) { | ||
| 106 | return true | ||
| 107 | } | ||
| 108 | } | ||
| 109 | return false | ||
| 110 | } | ||