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