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