~bigbes/sr-ht-spec

ref: cac9fe1fb81fc0c100dab64c30ffe5d5f31a8442 sr-ht-spec/gitx/refsrule.go -rw-r--r-- 9.3 KiB
cac9fe1f — bigbes docs: three receive hooks, and the read plane's rev guard 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
package gitx

import (
	"fmt"
	"strconv"
	"strings"
	"unicode/utf8"

	"github.com/go-git/go-git/v5/plumbing"
)

// ProposalPrefix is the namespace agents may write and nothing else.
const ProposalPrefix = "proposals/"

// branchRefPrefix is the only ref namespace a space repository uses. Tags and
// notes are not part of the model, and a branch outside these two namespaces
// would be invisible to the reader, the reconciler and the index.
const branchRefPrefix = "refs/heads/"

// maxRefLen caps a ref name. Well under any filesystem limit; the point is to
// keep a pathological name out of the hook's error message and the loose-ref
// directory, not to be permissive.
const maxRefLen = 255

// PrincipalKind is who is pushing. There is exactly one human on this instance
// and many agents, so this is the whole of the identity the refs rule needs:
// the boundary that matters is not human-versus-human, it is what an agent may
// move.
type PrincipalKind string

const (
	// PrincipalHuman is the owner, pushing over SSH through receive-pack. Their
	// push is the approval — there is nobody to review it.
	PrincipalHuman PrincipalKind = "human"

	// PrincipalAgent is any agent token. One token or many, the constraint is
	// the same and it is the one that bounds the damage a runaway agent can do.
	PrincipalAgent PrincipalKind = "agent"
)

// ParsePrincipalKind validates a principal kind arriving from a hook
// environment or a token row.
func ParsePrincipalKind(s string) (PrincipalKind, error) {
	switch PrincipalKind(s) {
	case PrincipalHuman, PrincipalAgent:
		return PrincipalKind(s), nil
	}
	return "", fmt.Errorf("%w: principal %q is not one of human|agent", ErrRefRejected, s)
}

// RefUpdate is one proposed ref move, as the update hook sees it.
type RefUpdate struct {
	// Ref is the full ref name, e.g. "refs/heads/main".
	Ref string

	// Old is the value the ref currently holds; zero means the ref is being
	// created.
	Old plumbing.Hash

	// New is the value proposed; zero means the ref is being deleted.
	New plumbing.Hash

	// FastForward reports whether New is reachable from Old. The caller must
	// compute it — Old.IsZero() || Repo.IsAncestor(ctx, Old, New) — because
	// ancestry needs the object database and CheckRefUpdate is a pure function
	// so that it can be exhaustively tested. It is ignored for deletions.
	FastForward bool
}

// CheckRefUpdate is the refs rule: may this principal move this ref?
//
//	The human pushes to the approved branch. Agents may only write proposal
//	branches.
//
// Concretely:
//
//   - Only branches exist. A tag, a note or any other ref namespace is refused
//     for both principals, because nothing in the model reads one and a ref the
//     reader and reconciler do not know about is a place for content to rot.
//   - The approved branch: the human only, fast-forward only, never deleted.
//     A force-update is refused even from the owner — it would orphan every
//     proposal's recorded base and silently rewrite approved text.
//   - proposals/*: either principal, any update including a force-update or a
//     delete. A proposal branch is scratch space; nothing reads it as canonical
//     and rewriting one is how an agent revises its own work.
//
// It is a pure function of its arguments so hooks/ can call it without a
// repository and so every combination can be tested. A nil error means the
// update is permitted; every rejection wraps ErrRefRejected with a message fit
// to send back to the pushing client.
func CheckRefUpdate(principal PrincipalKind, approvedBranch string, u RefUpdate) error {
	if _, err := ParsePrincipalKind(string(principal)); err != nil {
		return err
	}
	if err := ValidateBranch(approvedBranch); err != nil {
		return fmt.Errorf("%w: approved branch %q is unusable: %v", ErrRefRejected, approvedBranch, err)
	}
	if err := validateRefName(u.Ref); err != nil {
		return fmt.Errorf("%w: %v", ErrRefRejected, err)
	}
	if u.Old.IsZero() && u.New.IsZero() {
		return fmt.Errorf("%w: %s: update moves nothing (old and new are both zero)", ErrRefRejected, u.Ref)
	}
	if !strings.HasPrefix(u.Ref, branchRefPrefix) {
		return fmt.Errorf("%w: %s: only branches under %s may be updated in a space",
			ErrRefRejected, u.Ref, branchRefPrefix)
	}
	branch := strings.TrimPrefix(u.Ref, branchRefPrefix)
	if err := ValidateBranch(branch); err != nil {
		return fmt.Errorf("%w: %v", ErrRefRejected, err)
	}

	switch {
	case branch == approvedBranch:
		if principal != PrincipalHuman {
			return fmt.Errorf("%w: %s: an agent may only write %s*, not the approved branch",
				ErrRefRejected, u.Ref, ProposalPrefix)
		}
		if u.New.IsZero() {
			return fmt.Errorf("%w: %s: the approved branch may not be deleted", ErrRefRejected, u.Ref)
		}
		if !u.FastForward {
			return fmt.Errorf("%w: %s: the approved branch takes fast-forwards only, not a force-update",
				ErrRefRejected, u.Ref)
		}
		return nil

	case IsProposalBranch(branch):
		return nil

	default:
		return fmt.Errorf("%w: %s: a space carries the approved branch %q and %s* and nothing else",
			ErrRefRejected, u.Ref, approvedBranch, ProposalPrefix)
	}
}

// IsProposalBranch reports whether a short branch name is in the proposal
// namespace. The bare name "proposals" is not: it is the namespace itself, and
// a branch by that name would block every proposal branch under it.
func IsProposalBranch(branch string) bool {
	if !strings.HasPrefix(branch, ProposalPrefix) {
		return false
	}
	if ValidateBranch(branch) != nil {
		return false
	}
	return strings.TrimPrefix(branch, ProposalPrefix) != ""
}

// ProposalBranch is the branch name for a proposal id, "proposals/42". The id
// is the proposal row's primary key, which is what the branch and the stable
// proposal URL share.
func ProposalBranch(id int64) (string, error) {
	if id <= 0 {
		return "", fmt.Errorf("%w: proposal id %d must be positive", ErrBadRev, id)
	}
	return ProposalPrefix + strconv.FormatInt(id, 10), nil
}

// ParseProposalBranch recovers the proposal id from a branch name produced by
// ProposalBranch. A proposal branch with a non-numeric suffix is valid as a ref
// but carries no id, so ok is false rather than the id being guessed.
func ParseProposalBranch(branch string) (int64, bool) {
	if !IsProposalBranch(branch) {
		return 0, false
	}
	id, err := strconv.ParseInt(strings.TrimPrefix(branch, ProposalPrefix), 10, 64)
	if err != nil || id <= 0 {
		return 0, false
	}
	return id, true
}

// ValidateBranch checks a short branch name ("main", "proposals/42").
func ValidateBranch(branch string) error {
	if err := validateRefComponent("branch", branch); err != nil {
		return err
	}
	// Rejecting the full-ref spelling here is what stops "refs/heads/main" from
	// being accepted as a branch and expanding to refs/heads/refs/heads/main.
	if strings.HasPrefix(branch, "refs/") {
		return fmt.Errorf("%w: branch %q must be a short name, not a full ref", ErrBadRev, branch)
	}
	return validateRefName(branchRefPrefix + branch)
}

// validateRefName applies git's ref-name rules to a full ref, plus a length cap
// and a UTF-8 check that git-check-ref-format does not make.
func validateRefName(ref string) error {
	if err := validateRefComponent("ref", ref); err != nil {
		return err
	}
	if !strings.HasPrefix(ref, "refs/") {
		return fmt.Errorf("%w: ref %q must start with \"refs/\"", ErrBadRev, ref)
	}
	if err := plumbing.ReferenceName(ref).Validate(); err != nil {
		return fmt.Errorf("%w: ref %q: %v", ErrBadRev, ref, err)
	}
	return nil
}

// validateRefComponent holds the checks shared by revisions, branches and full
// refs: length, UTF-8, no control characters, no traversal, and none of the
// bytes that make a name mean something else to a shell, to git's revision
// parser, or to a reader looking at a review page.
func validateRefComponent(kind, s string) error {
	if s == "" {
		return fmt.Errorf("%w: empty %s", ErrBadRev, kind)
	}
	if len(s) > maxRefLen {
		return fmt.Errorf("%w: %s is too long (%d > %d)", ErrBadRev, kind, len(s), maxRefLen)
	}
	if !utf8.ValidString(s) {
		return fmt.Errorf("%w: %s %q is not valid UTF-8", ErrBadRev, kind, s)
	}
	for _, r := range s {
		if r < 0x20 || r == 0x7f {
			return fmt.Errorf("%w: %s %q contains a control character", ErrBadRev, kind, s)
		}
		switch r {
		case ' ', '~', '^', ':', '?', '*', '[', '\\', '"', '\'', '<', '>', '|', ';', '&', '$', '`', '\t':
			return fmt.Errorf("%w: %s %q contains a disallowed character %q", ErrBadRev, kind, s, r)
		}
	}
	if s[0] == '-' {
		return fmt.Errorf("%w: %s %q must not start with '-'", ErrBadRev, kind, s)
	}
	if strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/") {
		return fmt.Errorf("%w: %s %q must not start or end with '/'", ErrBadRev, kind, s)
	}
	if strings.Contains(s, "..") {
		return fmt.Errorf("%w: %s %q must not contain '..'", ErrBadRev, kind, s)
	}
	if strings.Contains(s, "//") {
		return fmt.Errorf("%w: %s %q must not contain an empty component", ErrBadRev, kind, s)
	}
	if strings.Contains(s, "@{") {
		return fmt.Errorf("%w: %s %q must not contain \"@{\"", ErrBadRev, kind, s)
	}
	if strings.HasSuffix(s, ".lock") || strings.Contains(s, ".lock/") {
		return fmt.Errorf("%w: %s %q must not have a \".lock\" component", ErrBadRev, kind, s)
	}
	if s == "@" {
		return fmt.Errorf("%w: %s must not be \"@\"", ErrBadRev, kind)
	}
	for _, comp := range strings.Split(s, "/") {
		if strings.HasPrefix(comp, ".") || strings.HasSuffix(comp, ".") {
			return fmt.Errorf("%w: %s %q component %q must not start or end with '.'", ErrBadRev, kind, s, comp)
		}
	}
	return nil
}