coverage~bigbes/sr-ht-spec64cae3afgitx/refsrule.go

Coverage
96.3% 79/82 statements
Δ
+0.0
Blob
0c061a4
1 package gitx
2
3 import (
4 "fmt"
5 "strconv"
6 "strings"
7 "unicode/utf8"
8
9 "github.com/go-git/go-git/v5/plumbing"
10
11 "sourcecraft.dev/bigbes/sr-ht-spec/core"
12 )
13
14 // ProposalPrefix is the namespace agents may write and nothing else. It is
15 // core's constant under this package's name: the prefix a ref is checked
16 // against here and the prefix a proposal row's branch is built from are one
17 // value, or the two disagree the day one of them is edited.
18 const ProposalPrefix = core.ProposalPrefix
19
20 // branchRefPrefix is the only ref namespace a space repository uses. Tags and
21 // notes are not part of the model, and a branch outside these two namespaces
22 // would be invisible to the reader, the reconciler and the index.
23 const branchRefPrefix = "refs/heads/"
24
25 // maxRefLen caps a ref name. Well under any filesystem limit; the point is to
26 // keep a pathological name out of the hook's error message and the loose-ref
27 // directory, not to be permissive.
28 const maxRefLen = 255
29
30 // PrincipalKind is who is pushing. There is exactly one human on this instance
31 // and many agents, so this is the whole of the identity the refs rule needs:
32 // the boundary that matters is not human-versus-human, it is what an agent may
33 // move.
34 type PrincipalKind string
35
36 const (
37 // PrincipalHuman is the owner, pushing over SSH through receive-pack. Their
38 // push is the approval — there is nobody to review it.
39 PrincipalHuman PrincipalKind = "human"
40
41 // PrincipalAgent is any agent token. One token or many, the constraint is
42 // the same and it is the one that bounds the damage a runaway agent can do.
43 PrincipalAgent PrincipalKind = "agent"
44 )
45
46 // ParsePrincipalKind validates a principal kind arriving from a hook
47 // environment or a token row.
48 33 func ParsePrincipalKind(s string) (PrincipalKind, error) {
49 33 switch PrincipalKind(s) {
50 29 case PrincipalHuman, PrincipalAgent:
51 29 return PrincipalKind(s), nil
52 }
53 4 return "", fmt.Errorf("%w: principal %q is not one of human|agent", ErrRefRejected, s)
54 }
55
56 // RefUpdate is one proposed ref move, as the update hook sees it.
57 type RefUpdate struct {
58 // Ref is the full ref name, e.g. "refs/heads/main".
59 Ref string
60
61 // Old is the value the ref currently holds; zero means the ref is being
62 // created.
63 Old plumbing.Hash
64
65 // New is the value proposed; zero means the ref is being deleted.
66 New plumbing.Hash
67
68 // FastForward reports whether New is reachable from Old. The caller must
69 // compute it — Old.IsZero() || Repo.IsAncestor(ctx, Old, New) — because
70 // ancestry needs the object database and CheckRefUpdate is a pure function
71 // so that it can be exhaustively tested. It is ignored for deletions.
72 FastForward bool
73 }
74
75 // CheckRefUpdate is the refs rule: may this principal move this ref?
76 //
77 // The human pushes to the approved branch. Agents may only write proposal
78 // branches.
79 //
80 // Concretely:
81 //
82 // - Only branches exist. A tag, a note or any other ref namespace is refused
83 // for both principals, because nothing in the model reads one and a ref the
84 // reader and reconciler do not know about is a place for content to rot.
85 // - The approved branch: the human only, fast-forward only, never deleted.
86 // A force-update is refused even from the owner — it would orphan every
87 // proposal's recorded base and silently rewrite approved text.
88 // - proposals/*: either principal, any update including a force-update or a
89 // delete. A proposal branch is scratch space; nothing reads it as canonical
90 // and rewriting one is how an agent revises its own work.
91 //
92 // It is a pure function of its arguments so hooks/ can call it without a
93 // repository and so every combination can be tested. A nil error means the
94 // update is permitted; every rejection wraps ErrRefRejected with a message fit
95 // to send back to the pushing client.
96 32 func CheckRefUpdate(principal PrincipalKind, approvedBranch string, u RefUpdate) error {
97 32 if _, err := ParsePrincipalKind(string(principal)); err != nil {
98 4 return err
99 4 }
100 28 if err := ValidateBranch(approvedBranch); err != nil {
101 5 return fmt.Errorf("%w: approved branch %q is unusable: %v", ErrRefRejected, approvedBranch, err)
102 5 }
103 23 if err := validateRefName(u.Ref); err != nil {
104 2 return fmt.Errorf("%w: %v", ErrRefRejected, err)
105 2 }
106 21 if u.Old.IsZero() && u.New.IsZero() {
107 1 return fmt.Errorf("%w: %s: update moves nothing (old and new are both zero)", ErrRefRejected, u.Ref)
108 1 }
109 20 if !strings.HasPrefix(u.Ref, branchRefPrefix) {
110 2 return fmt.Errorf("%w: %s: only branches under %s may be updated in a space",
111 2 ErrRefRejected, u.Ref, branchRefPrefix)
112 2 }
113 18 branch := strings.TrimPrefix(u.Ref, branchRefPrefix)
114 18 if err := ValidateBranch(branch); err != nil {
115 0 return fmt.Errorf("%w: %v", ErrRefRejected, err)
116 0 }
117
118 18 switch {
119 8 case branch == approvedBranch:
120 8 if principal != PrincipalHuman {
121 3 return fmt.Errorf("%w: %s: an agent may only write %s*, not the approved branch",
122 3 ErrRefRejected, u.Ref, ProposalPrefix)
123 3 }
124 5 if u.New.IsZero() {
125 1 return fmt.Errorf("%w: %s: the approved branch may not be deleted", ErrRefRejected, u.Ref)
126 1 }
127 4 if !u.FastForward {
128 1 return fmt.Errorf("%w: %s: the approved branch takes fast-forwards only, not a force-update",
129 1 ErrRefRejected, u.Ref)
130 1 }
131 3 return nil
132
133 5 case IsProposalBranch(branch):
134 5 return nil
135
136 5 default:
137 5 return fmt.Errorf("%w: %s: a space carries the approved branch %q and %s* and nothing else",
138 5 ErrRefRejected, u.Ref, approvedBranch, ProposalPrefix)
139 }
140 }
141
142 // IsProposalBranch reports whether a short branch name is in the proposal
143 // namespace. The bare name "proposals" is not: it is the namespace itself, and
144 // a branch by that name would block every proposal branch under it.
145 122 func IsProposalBranch(branch string) bool {
146 122 if !strings.HasPrefix(branch, ProposalPrefix) {
147 24 return false
148 24 }
149 98 if ValidateBranch(branch) != nil {
150 1 return false
151 1 }
152 97 return strings.TrimPrefix(branch, ProposalPrefix) != ""
153 }
154
155 // ProposalBranch is the branch name for a proposal id, "proposals/42".
156 //
157 // The derivation is core's, so a branch cut here and a branch recorded on the
158 // proposal row cannot drift apart. What this wrapper adds is the failure class
159 // gitx callers branch on: an id that names no proposal is a bad revision here,
160 // exactly like a malformed ref, and core's ErrInvalidProposalID stays in the
161 // chain for a caller that wants to tell the two apart.
162 2 func ProposalBranch(id int64) (string, error) {
163 2 branch, err := core.ProposalBranch(id)
164 2 if err != nil {
165 1 return "", fmt.Errorf("%w: %w", ErrBadRev, err)
166 1 }
167 1 return branch, nil
168 }
169
170 // ParseProposalBranch recovers the proposal id from a branch name produced by
171 // ProposalBranch. A proposal branch with a non-numeric suffix is valid as a ref
172 // but carries no id, so ok is false rather than the id being guessed.
173 6 func ParseProposalBranch(branch string) (int64, bool) {
174 6 if !IsProposalBranch(branch) {
175 3 return 0, false
176 3 }
177 3 id, err := strconv.ParseInt(strings.TrimPrefix(branch, ProposalPrefix), 10, 64)
178 3 if err != nil || id <= 0 {
179 2 return 0, false
180 2 }
181 1 return id, true
182 }
183
184 // ValidateBranch checks a short branch name ("main", "proposals/42").
185 264 func ValidateBranch(branch string) error {
186 264 if err := validateRefComponent("branch", branch); err != nil {
187 27 return err
188 27 }
189 // Rejecting the full-ref spelling here is what stops "refs/heads/main" from
190 // being accepted as a branch and expanding to refs/heads/refs/heads/main.
191 237 if strings.HasPrefix(branch, "refs/") {
192 2 return fmt.Errorf("%w: branch %q must be a short name, not a full ref", ErrBadRev, branch)
193 2 }
194 235 return validateRefName(branchRefPrefix + branch)
195 }
196
197 // validateRefName applies git's ref-name rules to a full ref, plus a length cap
198 // and a UTF-8 check that git-check-ref-format does not make.
199 258 func validateRefName(ref string) error {
200 258 if err := validateRefComponent("ref", ref); err != nil {
201 1 return err
202 1 }
203 257 if !strings.HasPrefix(ref, "refs/") {
204 1 return fmt.Errorf("%w: ref %q must start with \"refs/\"", ErrBadRev, ref)
205 1 }
206 256 if err := plumbing.ReferenceName(ref).Validate(); err != nil {
207 0 return fmt.Errorf("%w: ref %q: %v", ErrBadRev, ref, err)
208 0 }
209 256 return nil
210 }
211
212 // validateRefComponent holds the checks shared by revisions, branches and full
213 // refs: length, UTF-8, no control characters, no traversal, and none of the
214 // bytes that make a name mean something else to a shell, to git's revision
215 // parser, or to a reader looking at a review page.
216 628 func validateRefComponent(kind, s string) error {
217 628 if s == "" {
218 2 return fmt.Errorf("%w: empty %s", ErrBadRev, kind)
219 2 }
220 626 if len(s) > maxRefLen {
221 1 return fmt.Errorf("%w: %s is too long (%d > %d)", ErrBadRev, kind, len(s), maxRefLen)
222 1 }
223 625 if !utf8.ValidString(s) {
224 0 return fmt.Errorf("%w: %s %q is not valid UTF-8", ErrBadRev, kind, s)
225 0 }
226 9530 for _, r := range s {
227 9530 if r < 0x20 || r == 0x7f {
228 2 return fmt.Errorf("%w: %s %q contains a control character", ErrBadRev, kind, s)
229 2 }
230 9528 switch r {
231 15 case ' ', '~', '^', ':', '?', '*', '[', '\\', '"', '\'', '<', '>', '|', ';', '&', '$', '`', '\t':
232 15 return fmt.Errorf("%w: %s %q contains a disallowed character %q", ErrBadRev, kind, s, r)
233 }
234 }
235 608 if s[0] == '-' {
236 4 return fmt.Errorf("%w: %s %q must not start with '-'", ErrBadRev, kind, s)
237 4 }
238 604 if strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/") {
239 3 return fmt.Errorf("%w: %s %q must not start or end with '/'", ErrBadRev, kind, s)
240 3 }
241 601 if strings.Contains(s, "..") {
242 5 return fmt.Errorf("%w: %s %q must not contain '..'", ErrBadRev, kind, s)
243 5 }
244 596 if strings.Contains(s, "//") {
245 1 return fmt.Errorf("%w: %s %q must not contain an empty component", ErrBadRev, kind, s)
246 1 }
247 595 if strings.Contains(s, "@{") {
248 3 return fmt.Errorf("%w: %s %q must not contain \"@{\"", ErrBadRev, kind, s)
249 3 }
250 592 if strings.HasSuffix(s, ".lock") || strings.Contains(s, ".lock/") {
251 1 return fmt.Errorf("%w: %s %q must not have a \".lock\" component", ErrBadRev, kind, s)
252 1 }
253 591 if s == "@" {
254 1 return fmt.Errorf("%w: %s must not be \"@\"", ErrBadRev, kind)
255 1 }
256 1380 for _, comp := range strings.Split(s, "/") {
257 1380 if strings.HasPrefix(comp, ".") || strings.HasSuffix(comp, ".") {
258 3 return fmt.Errorf("%w: %s %q component %q must not start or end with '.'", ErrBadRev, kind, s, comp)
259 3 }
260 }
261 587 return nil
262 }