coverage~bigbes/sr-ht-spec64cae3afservice/push.go

Coverage
89.0% 121/136 statements
Δ
+0.0
Blob
78d7c9f
1 package service
2
3 import (
4 "context"
5 "encoding/hex"
6 "fmt"
7 "sort"
8 "strings"
9
10 "github.com/go-git/go-git/v5/plumbing"
11
12 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
13 "sourcecraft.dev/bigbes/sr-ht-spec/core"
14 "sourcecraft.dev/bigbes/sr-ht-spec/db"
15 "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
16 )
17
18 // PushRequest is one proposed ref update, as the `update` hook sees it and
19 // forwards it to the daemon over the localhost RPC.
20 //
21 // Object names are hex strings rather than plumbing.Hash so hooks/ can build
22 // this straight from the hook's argv without importing gitx, and so an
23 // unparseable value is a rejection rather than something that silently becomes
24 // the zero hash — which the refs rule would read as "creating a branch".
25 type PushRequest struct {
26 // Space is the space being pushed to.
27 Space core.SpaceRef
28
29 // Principal is who is pushing, as authn resolved the SSH key or token the
30 // forced command was invoked with. An anonymous principal is refused: there
31 // is no unauthenticated write path.
32 Principal authn.Principal
33
34 // Ref is the full ref name, "refs/heads/main".
35 Ref string
36
37 // Old is the value the ref currently holds. Empty or the all-zero object
38 // name means the ref is being created.
39 Old string
40
41 // New is the value proposed. Empty or the all-zero object name means the
42 // ref is being deleted.
43 New string
44
45 // SkipValidation carries `--push-option=skip-validation`.
46 //
47 // It waives frontmatter and document-id validation and nothing else. The
48 // refs rule is never skippable: the escape hatch exists so a hook bug or a
49 // bad schema cannot lock the owner out of their own repository, not so that
50 // an agent can reach the approved branch.
51 SkipValidation bool
52 }
53
54 // PushProblemKind classifies one reason a push was refused, so the API and the
55 // review UI can branch on it without parsing the message.
56 type PushProblemKind string
57
58 const (
59 // ProblemRefsRule is the refs rule refusing this principal this ref. Never
60 // skippable.
61 ProblemRefsRule PushProblemKind = "refs-rule"
62
63 // ProblemFrontmatter is a document whose frontmatter is missing,
64 // unparseable, or fails the space's schema.
65 ProblemFrontmatter PushProblemKind = "frontmatter"
66
67 // ProblemDuplicateID is two documents in the pushed tree carrying one id.
68 ProblemDuplicateID PushProblemKind = "duplicate-id"
69
70 // ProblemIDCollision is a document id already registered to another space.
71 ProblemIDCollision PushProblemKind = "id-collision"
72 )
73
74 // PushProblem is one reason a push was refused.
75 type PushProblem struct {
76 // Kind is the class of failure.
77 Kind PushProblemKind
78
79 // Path is the offending document, empty for a problem that is not about
80 // one document (the refs rule).
81 Path string
82
83 // Detail is one line naming what is wrong, written for the human reading
84 // their terminal after a rejected `git push`.
85 Detail string
86 }
87
88 // PushRejection is the structured refusal the `update` hook prints and exits
89 // non-zero on. It wraps ErrPushRejected, so callers match the class with
90 // errors.Is and type-assert only when they want the detail.
91 type PushRejection struct {
92 Space core.SpaceRef
93 Ref string
94 Problems []PushProblem
95
96 // Skippable reports whether `--push-option=skip-validation` would let this
97 // push through. It is false whenever any problem is a refs-rule violation,
98 // and the message says so rather than suggesting a flag that will not help.
99 Skippable bool
100 }
101
102 4 func (e *PushRejection) Is(target error) bool { return target == ErrPushRejected }
103
104 // Error renders the rejection as the message a human sees on their terminal
105 // after `git push`. Every line is short enough to survive git's "remote: "
106 // prefix in an 80-column terminal, and the offending document is always named
107 // first, because "which file" is the first thing anybody wants to know.
108 3 func (e *PushRejection) Error() string {
109 3 var b strings.Builder
110 3 fmt.Fprintf(&b, "spec.sr.ht rejected this push.\n\n")
111 3 fmt.Fprintf(&b, " space: %s\n", e.Space)
112 3 fmt.Fprintf(&b, " ref: %s\n\n", e.Ref)
113 4 for _, p := range e.Problems {
114 4 if p.Path != "" {
115 3 fmt.Fprintf(&b, " %s\n %s\n", p.Path, p.Detail)
116 3 continue
117 }
118 1 fmt.Fprintf(&b, " %s\n", p.Detail)
119 }
120 3 fmt.Fprintf(&b, "\n%s. Nothing was written; the ref still points where it did.\n",
121 3 plural(len(e.Problems), "problem"))
122 3 if e.Skippable {
123 2 b.WriteString("Re-push with --push-option=skip-validation to bypass frontmatter\n")
124 2 b.WriteString("and document-id validation.\n")
125 2 } else {
126 1 b.WriteString("The refs rule cannot be bypassed: --push-option=skip-validation\n")
127 1 b.WriteString("waives frontmatter and document-id validation only.\n")
128 1 }
129 3 return b.String()
130 }
131
132 3 func plural(n int, what string) string {
133 3 if n == 1 {
134 2 return fmt.Sprintf("1 %s", what)
135 2 }
136 1 return fmt.Sprintf("%d %ss", n, what)
137 }
138
139 // ValidatePush is what the `update` hook calls, per ref, before the ref moves.
140 //
141 // It answers in two parts, and the split is the design's:
142 //
143 // 1. The refs rule — may this principal move this ref? Always checked, never
144 // skippable, and checked first so that a rejection for the right reason is
145 // not preceded by pages of schema complaints.
146 // 2. Frontmatter and document-id validation of everything this push changes.
147 // Waived by SkipValidation, because a hook bug or a bad schema must never
148 // be able to lock the owner out of their own repository.
149 //
150 // A nil return means the push may proceed. A *PushRejection means it must not,
151 // and its Error() is the text to print. Any other error is an infrastructure
152 // failure — Postgres down, repository unreadable — and the hook must fail
153 // closed on it: a rejected push is recoverable in one command, while a silently
154 // unvalidated one is a corruption discovered much later.
155 5 func (s *Service) ValidatePush(ctx context.Context, req PushRequest) error {
156 5 sp, err := s.OpenSpace(ctx, req.Space)
157 5 if err != nil {
158 0 return err
159 0 }
160
161 5 oldHash, err := parseObjectName("old", req.Old)
162 5 if err != nil {
163 0 return err
164 0 }
165 5 newHash, err := parseObjectName("new", req.New)
166 5 if err != nil {
167 0 return err
168 0 }
169
170 5 if problem := s.checkRefsRule(ctx, sp, req, oldHash, newHash); problem != nil {
171 1 return &PushRejection{
172 1 Space: req.Space,
173 1 Ref: req.Ref,
174 1 Problems: []PushProblem{*problem},
175 1 Skippable: false,
176 1 }
177 1 }
178
179 // A deletion leaves no tree to validate, and skip-validation waives
180 // everything that is left. Both still went through the refs rule above.
181 4 if newHash.IsZero() || req.SkipValidation {
182 1 return nil
183 1 }
184
185 3 problems, err := s.validateContent(ctx, sp, oldHash, newHash)
186 3 if err != nil {
187 0 return err
188 0 }
189 3 if len(problems) > 0 {
190 2 return &PushRejection{
191 2 Space: req.Space,
192 2 Ref: req.Ref,
193 2 Problems: problems,
194 2 Skippable: true,
195 2 }
196 2 }
197 1 return nil
198 }
199
200 // checkRefsRule applies gitx.CheckRefUpdate, computing the fast-forward fact it
201 // cannot compute itself. It returns nil when the update is permitted.
202 13 func (s *Service) checkRefsRule(ctx context.Context, sp *Space, req PushRequest, old, new plumbing.Hash) *PushProblem {
203 13 kind, err := principalKind(req.Principal)
204 13 if err != nil {
205 0 return &PushProblem{Kind: ProblemRefsRule, Detail: err.Error()}
206 0 }
207
208 // Ancestry is only meaningful when both ends name a commit. A creation has
209 // no old value and a deletion has no new one; gitx treats a creation as a
210 // fast-forward and ignores the flag entirely for a deletion.
211 13 fastForward := old.IsZero()
212 13 if !old.IsZero() && !new.IsZero() {
213 9 ff, err := sp.Repo.IsAncestor(ctx, old, new)
214 9 if err != nil {
215 0 // Not knowing whether this is a fast-forward is not permission to
216 0 // assume it is: an unreadable object must refuse the push, not
217 0 // wave through a force-update of the approved branch.
218 0 return &PushProblem{
219 0 Kind: ProblemRefsRule,
220 0 Detail: fmt.Sprintf("cannot determine whether %s..%s is a fast-forward: %v", old, new, err),
221 0 }
222 0 }
223 9 fastForward = ff
224 }
225
226 13 err = gitx.CheckRefUpdate(kind, sp.ApprovedBranch(), gitx.RefUpdate{
227 13 Ref: req.Ref,
228 13 Old: old,
229 13 New: new,
230 13 FastForward: fastForward,
231 13 })
232 13 if err != nil {
233 6 return &PushProblem{Kind: ProblemRefsRule, Detail: err.Error()}
234 6 }
235 7 return nil
236 }
237
238 // validateContent validates the frontmatter of every document this push changes
239 // and checks document-id uniqueness, both within the pushed tree and against
240 // the global registry.
241 3 func (s *Service) validateContent(ctx context.Context, sp *Space, old, new plumbing.Hash) ([]PushProblem, error) {
242 3 all, changed, err := s.changedDocuments(ctx, sp, old, new)
243 3 if err != nil {
244 0 return nil, err
245 0 }
246
247 // The schema is read at the *new* revision, so a push that edits .spec.yml
248 // is validated against the policy it is installing. Validating against the
249 // old one would make a schema change and the documents that satisfy it
250 // impossible to land in a single push.
251 3 policy, err := s.Policy(ctx, sp, new.String())
252 3 if err != nil {
253 0 return nil, err
254 0 }
255
256 3 problems, refs := validateDocuments(all, changed, policy.Schema)
257 3
258 3 collisions, err := s.store.CheckDocIDCollisions(ctx, sp.ID, refs)
259 3 if err != nil {
260 0 return nil, fmt.Errorf("service: check document id collisions for %s: %w", sp.Ref, err)
261 0 }
262 3 byID := make(map[string]string, len(refs))
263 3 for _, r := range refs {
264 2 byID[r.ID.String()] = r.Path
265 2 }
266 3 for _, c := range collisions {
267 1 owner, err := s.store.GetSpaceByID(ctx, c.Existing.SpaceID)
268 1 if err != nil {
269 0 return nil, fmt.Errorf("service: resolve space %d holding document id %s: %w",
270 0 c.Existing.SpaceID, c.DocID, err)
271 0 }
272 1 problems = append(problems, PushProblem{
273 1 Kind: ProblemIDCollision,
274 1 Path: byID[c.DocID.String()],
275 1 Detail: fmt.Sprintf("id %s is already registered to %s at %s",
276 1 c.DocID, owner.Ref, c.Existing.Path),
277 1 })
278 }
279 3 sort.SliceStable(problems, func(i, j int) bool { return problems[i].Path < problems[j].Path })
280 3 return problems, nil
281 }
282
283 // changedDocuments returns every document at the new revision, and the subset
284 // of them this push changes.
285 //
286 // The baseline is the ref's old value, or — when the ref is being created — the
287 // space's approved head. A brand-new proposal branch is cut from the approved
288 // branch, so comparing it against nothing would revalidate the entire space and
289 // let one document that was pushed with --push-option=skip-validation block
290 // every future proposal branch.
291 //
292 // Only the changed subset is schema-validated, for the same reason. Malformed
293 // documents already on a branch are tolerated rather than fatal: a single typo
294 // must not become an outage that blocks every later push.
295 5 func (s *Service) changedDocuments(ctx context.Context, sp *Space, old, new plumbing.Hash) (all, changed []Document, err error) {
296 5 all, err = s.ListDocuments(ctx, sp, new.String())
297 5 if err != nil {
298 0 return nil, nil, err
299 0 }
300
301 5 baseline := old.String()
302 5 if old.IsZero() {
303 1 baseline = ApprovedRev
304 1 }
305 5 before, err := s.ListDocuments(ctx, sp, baseline)
306 5 if err != nil {
307 0 return nil, nil, err
308 0 }
309 5 prior := make(map[string]string, len(before))
310 5 for _, d := range before {
311 4 prior[d.Path] = d.Blob
312 4 }
313
314 8 for _, d := range all {
315 8 if prior[d.Path] != d.Blob {
316 5 changed = append(changed, d)
317 5 }
318 }
319 5 return all, changed, nil
320 }
321
322 // validateDocuments is the whole of push validation that needs neither git nor
323 // Postgres: schema conformance of the changed documents, and id uniqueness
324 // within the pushed tree. It returns the problems it found and the (id, path)
325 // refs of the changed documents, which is what the registry check runs against.
326 //
327 // A duplicate id is reported only when at least one of the documents carrying
328 // it is part of this push. Two colliding documents that were both already there
329 // are somebody's earlier skip-validation typo; rejecting every subsequent push
330 // until they are fixed would turn a cosmetic error into a lockout, and the fix
331 // itself would be unpushable.
332 10 func validateDocuments(all, changed []Document, schema core.Schema) ([]PushProblem, []db.DocRef) {
333 10 byID := make(map[string][]string)
334 15 for _, d := range all {
335 15 fm, _, err := core.ParseDocument(d.Data)
336 15 if err != nil {
337 3 continue // reported below if this document is part of the push
338 }
339 12 if id, err := core.ParseDocID(fm.ID); err == nil {
340 11 byID[id.String()] = append(byID[id.String()], d.Path)
341 11 }
342 }
343
344 10 var problems []PushProblem
345 10 var refs []db.DocRef
346 10 for _, d := range changed {
347 10 fm, _, err := core.ParseDocument(d.Data)
348 10 if err != nil {
349 2 problems = append(problems, PushProblem{
350 2 Kind: ProblemFrontmatter,
351 2 Path: d.Path,
352 2 Detail: err.Error(),
353 2 })
354 2 continue
355 }
356 8 if err := schema.ValidateFrontmatter(fm); err != nil {
357 2 problems = append(problems, PushProblem{
358 2 Kind: ProblemFrontmatter,
359 2 Path: d.Path,
360 2 Detail: err.Error(),
361 2 })
362 2 continue
363 }
364 6 id, err := core.ParseDocID(fm.ID)
365 6 if err != nil {
366 0 // Reachable only when the space's schema does not require `id`.
367 0 // Such a document is unregistrable but not malformed, so it is not
368 0 // a problem — it simply contributes nothing to the registry.
369 0 continue
370 }
371 6 if others := without(byID[id.String()], d.Path); len(others) > 0 {
372 1 problems = append(problems, PushProblem{
373 1 Kind: ProblemDuplicateID,
374 1 Path: d.Path,
375 1 Detail: fmt.Sprintf("id %s is also carried by %s",
376 1 id, strings.Join(others, ", ")),
377 1 })
378 1 }
379 6 refs = append(refs, db.DocRef{ID: id, Path: d.Path})
380 }
381 10 sort.SliceStable(problems, func(i, j int) bool { return problems[i].Path < problems[j].Path })
382 10 return problems, refs
383 }
384
385 // without returns paths with one occurrence of self removed.
386 6 func without(paths []string, self string) []string {
387 6 out := make([]string, 0, len(paths))
388 6 dropped := false
389 7 for _, p := range paths {
390 7 if p == self && !dropped {
391 6 dropped = true
392 6 continue
393 }
394 1 out = append(out, p)
395 }
396 6 if len(out) == 0 {
397 5 return nil
398 5 }
399 1 return out
400 }
401
402 // principalKind maps a resolved identity onto the two principals the refs rule
403 // knows about. An anonymous principal is refused rather than mapped to either:
404 // there is no unauthenticated write path, and defaulting it to "agent" would
405 // give an unidentified pusher the proposal namespace.
406 18 func principalKind(p authn.Principal) (gitx.PrincipalKind, error) {
407 18 switch {
408 10 case p.IsOwner():
409 10 return gitx.PrincipalHuman, nil
410 7 case p.IsAgent():
411 7 return gitx.PrincipalAgent, nil
412 1 default:
413 1 return "", fmt.Errorf("no credential identifies this push; %s may not write any ref", p)
414 }
415 }
416
417 // zeroObjectName is git's "this ref does not exist" sentinel as the hook spells
418 // it on the command line.
419 const zeroObjectName = "0000000000000000000000000000000000000000"
420
421 // parseObjectName converts a hook's hex argument into an object name. The empty
422 // string and the all-zero name both mean "absent".
423 //
424 // plumbing.NewHash is deliberately not used: it maps anything unparseable to
425 // the zero hash, which the refs rule would read as a branch creation or a
426 // deletion. A malformed argument is a bug in whatever built the request, and it
427 // fails here rather than becoming a permitted force-push.
428 33 func parseObjectName(which, s string) (plumbing.Hash, error) {
429 33 if s == "" || s == zeroObjectName {
430 7 return plumbing.ZeroHash, nil
431 7 }
432 26 if len(s) != len(zeroObjectName) {
433 2 return plumbing.ZeroHash, fmt.Errorf("service: %s object name %q is not %d hex digits",
434 2 which, s, len(zeroObjectName))
435 2 }
436 24 if _, err := hex.DecodeString(s); err != nil {
437 1 return plumbing.ZeroHash, fmt.Errorf("service: %s object name %q is not hex: %w", which, s, err)
438 1 }
439 23 return plumbing.NewHash(s), nil
440 }