~bigbes/sr-ht-spec

ref: 8219ede1804c144de2a2f2e42f3f476bff4b622a sr-ht-spec/gitx/refsrule.go -rw-r--r-- 9.8 KiB
8219ede1 — Eugene Blikh feat(web,service): the owner mints and revokes agent tokens in a browser 13 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
253
254
255
256
257
258
259
260
261
262
package gitx

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

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

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// ProposalPrefix is the namespace agents may write and nothing else. It is
// core's constant under this package's name: the prefix a ref is checked
// against here and the prefix a proposal row's branch is built from are one
// value, or the two disagree the day one of them is edited.
const ProposalPrefix = core.ProposalPrefix

// 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 derivation is core's, so a branch cut here and a branch recorded on the
// proposal row cannot drift apart. What this wrapper adds is the failure class
// gitx callers branch on: an id that names no proposal is a bad revision here,
// exactly like a malformed ref, and core's ErrInvalidProposalID stays in the
// chain for a caller that wants to tell the two apart.
func ProposalBranch(id int64) (string, error) {
	branch, err := core.ProposalBranch(id)
	if err != nil {
		return "", fmt.Errorf("%w: %w", ErrBadRev, err)
	}
	return branch, 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
}