Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

395 commit(s) on main

update readme Keenan Nemetz · 29m ago 32290114
history blame raw 32.5 KiB
1// Package hook enforces policy on the server side. It runs as pre-receive, so a
2// rejected push moves no refs at all: the decision is per-push, never per-ref.
3package hook
4
5import (
6 "bufio"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12 "time"
13
14 "rodent.software/vole/ci"
15 "rodent.software/vole/ci/runs"
16 "rodent.software/vole/core/git"
17 "rodent.software/vole/core/lfs"
18 "rodent.software/vole/forge/access"
19 "rodent.software/vole/forge/identity"
20 "rodent.software/vole/forge/index"
21 "rodent.software/vole/forge/landing"
22 "rodent.software/vole/forge/repo"
23 "rodent.software/vole/internal/audit"
24 "rodent.software/vole/internal/format"
25 "rodent.software/vole/metadata"
26)
27
28type Config struct {
29 RepoDir string
30 Actor string
31 // KeyID is the fingerprint the session authenticated with. It is not the account:
32 // it is what tells a run credential apart from the account's own key, because a
33 // credential may write less than the account it acts as.
34 KeyID string
35 MaxBlobBytes int64
36 // UsersDB, when set, is the roster database, read here for access rules and
37 // file locks. Attribution itself is the Actor name resolved at the handshake.
38 UsersDB string
39 // SpoolDir receives one file per accepted ref update, for the CI queue.
40 SpoolDir string
41 // RunsDB is the run queue, read here so required checks are enforced at the
42 // same single point as everything else.
43 RunsDB string
44 // LFS is the object store, read here so a pointer that names an object the
45 // server does not have fails the push that introduced it.
46 LFS *lfs.Store
47 // LFSWarnBytes flags binaries committed straight into git above this size.
48 // Zero disables the warning.
49 LFSWarnBytes int64
50 // Quota bounds for this repository. Zero disables the check, which is what a
51 // test or an offline command wants; the instance always sets them.
52 MaxRefs int
53 MaxMetadataEntries int
54 MaxMetadataBytes int64
55 MaxRepoBytes int64
56 MaxLFSBytes int64
57 // RepoID is the repository's stable id, which is how the LFS store keys its
58 // links. Empty disables the LFS quota.
59 RepoID string
60 // IndexDB, with RepoID, lets post-receive bring the read model up to the
61 // branch the push left behind, so the next page does not apply the delta.
62 // Empty disables the refresh.
63 IndexDB string
64 // AuditPath receives lines about privileged metadata operations. When it is
65 // empty or unwritable, an override is refused and an ordinary push is answered
66 // with a warning: the refs have already moved by the time post-receive runs.
67 AuditPath string
68 Out io.Writer
69 In io.Reader
70}
71
72type change struct {
73 old, new, ref string
74}
75
76// PreReceive returns a process exit code: 0 allows the push, 1 rejects it.
77func PreReceive(cfg Config) int {
78 if cfg.Out == nil {
79 cfg.Out = io.Discard
80 }
81 if cfg.In == nil {
82 cfg.In = strings.NewReader("")
83 }
84 // Access rules are in the instance database, keyed by the repository's stable id,
85 // so the gate has to open the repository the same way the server does rather than
86 // assume a directory is a repository.
87 var (
88 acls *access.Store
89 lockTable *lfs.LockStore
90 err error
91 )
92 if cfg.UsersDB != "" {
93 acls, err = access.Open(cfg.UsersDB)
94 if err != nil {
95 reject(cfg.Out, "cannot open the access rules: %v", err)
96 return 1
97 }
98 defer acls.Close()
99 // The push gate runs as a separate process, so it opens the roster itself
100 // to learn the actor's instance-wide role. Without this the hook would
101 // enforce only repository entries and silently ignore an invitation that
102 // granted contribute everywhere.
103 ident, err := identity.Open(cfg.UsersDB)
104 if err != nil {
105 reject(cfg.Out, "cannot open the roster: %v", err)
106 return 1
107 }
108 defer ident.Close()
109 acls.InstanceRole = ident.InstanceRole
110 // An entry or a runners-list membership outlives the account: a retired
111 // approver or runner must not keep satisfying this gate.
112 acls.Active = ident.Active
113 // The lock table shares that database, so the push path learns what is held
114 // without a second endpoint and without the client's cooperation.
115 lockTable, err = lfs.OpenLocks(cfg.UsersDB)
116 if err != nil {
117 reject(cfg.Out, "cannot open the lock table: %v", err)
118 return 1
119 }
120 defer lockTable.Close()
121 }
122 // GIT_DIR may be "." — that is what git hands a hook — so the directory is made
123 // absolute before anything is derived from it.
124 dir, err := filepath.Abs(cfg.RepoDir)
125 if err != nil {
126 reject(cfg.Out, "cannot resolve %s: %v", cfg.RepoDir, err)
127 return 1
128 }
129 m, mErr := repo.New(filepath.Dir(dir), "", acls).Open(repoNameFromDir(dir))
130 if mErr != nil {
131 reject(cfg.Out, "cannot open %s: %v", dir, mErr)
132 return 1
133 }
134 a, err := m.ACL()
135 if err != nil {
136 reject(cfg.Out, "cannot read repository access rules: %v", err)
137 return 1
138 }
139
140 // The account name is the authorization identity: it comes from the SSH key
141 // the server resolved, never from the client, and every ACL entry, approval
142 // and record is keyed by it.
143 actor := strings.TrimSpace(cfg.Actor)
144 if actor == "" {
145 reject(cfg.Out, "pushes must be authenticated; no user was supplied")
146 return 1
147 }
148
149 role := a.RoleFor(actor)
150 if !role.AtLeast(access.Contribute) {
151 reject(cfg.Out, "%s does not have maintain or contribute access to %s (role: %s)", actor, m.Name, role)
152 return 1
153 }
154
155 // A session opened with a run credential acts for one job, in one repository,
156 // until that job ends. The role is still the account's and the per-path rules below
157 // are the same ones a key gets; what the credential adds is the binding: this
158 // repository, and no other.
159 if keyID := strings.TrimSpace(cfg.KeyID); keyID != "" {
160 found, err := lookupRunCredential(cfg, keyID)
161 if err != nil {
162 reject(cfg.Out, "cannot read this job's credential: %v", err)
163 return 1
164 }
165 if found != nil {
166 switch {
167 case found.Account != actor:
168 reject(cfg.Out, "this job's credential is for %s, not %s", found.Account, actor)
169 return 1
170 case found.Repo != m.Name:
171 reject(cfg.Out, "this job's credential is for %s, not %s", found.Repo, m.Name)
172 return 1
173 }
174 }
175 }
176
177 changes, err := readChanges(cfg.In)
178 if err != nil {
179 reject(cfg.Out, "cannot read ref updates: %v", err)
180 return 1
181 }
182 if len(changes) == 0 {
183 return 0
184 }
185
186 g := git.Open(cfg.RepoDir)
187 // receive-pack reveals the pushed objects through a quarantine: it points
188 // GIT_OBJECT_DIRECTORY at the temporary object directory and lists the real
189 // one in the alternates. gitx scrubs those variables so commands against other
190 // repositories are not redirected, so they are restored here for this one.
191 if os.Getenv("GIT_QUARANTINE_PATH") != "" {
192 for _, k := range []string{"GIT_QUARANTINE_PATH", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} {
193 if v := os.Getenv(k); v != "" {
194 g.ExtraEnv = append(g.ExtraEnv, k+"="+v)
195 }
196 }
197 }
198 // Quotas are checked once, against the repository this push would leave, and
199 // before any per-ref work: a push that cannot be accepted should say so in one
200 // line rather than after a page of review findings.
201 problems := quotaProblems(cfg, g, changes)
202 for _, c := range changes {
203 // Only branches and tags are writable. The namespace beside them used to hold
204 // the ACL; nothing lives there now (see the access package), so it needs no rule
205 // of its own — the rule below already covers every ref that is not a branch or a
206 // tag.
207 if !strings.HasPrefix(c.ref, "refs/heads/") && !strings.HasPrefix(c.ref, "refs/tags/") {
208 problems = append(problems, fmt.Sprintf(
209 "%s: only refs/heads/* and refs/tags/* may be pushed", c.ref))
210 continue
211 }
212 // A contributor gets one branch namespace, named after the account name.
213 // The name is fixed at creation and never reissued, so it identifies the
214 // owner without a lookup and no rename can strand a branch. Everything
215 // else — metadata, shared branches, tags — belongs to write or admin
216 // access. The check is here rather than at the role gate because the gate
217 // only knows that the account may push something.
218 if !role.AtLeast(access.Maintain) && !access.IsContributorRef(actor, c.ref) &&
219 c.ref != metadata.Ref {
220 problems = append(problems, fmt.Sprintf(
221 "%s: the contribute role may only push branches under %s and the issues branch (role: %s)",
222 c.ref, access.ContributorBranchPrefix(actor), role))
223 continue
224 }
225 // The metadata branch is append-only in shape: linear history, no merge
226 // commits. A merge node in metadata says nothing and makes the log harder
227 // to read than the change it describes.
228 if c.ref == metadata.Ref {
229 if merges, err := g.CountMerges(c.old, c.new); err != nil {
230 problems = append(problems, fmt.Sprintf("%s: cannot check history shape: %v", c.ref, err))
231 } else if merges > 0 {
232 problems = append(problems, fmt.Sprintf(
233 "%s: metadata must have linear history (%d merge commit(s) would be added); rebase instead of merging", c.ref, merges))
234 }
235 }
236
237 // A rewrite of the metadata branch is allowed, because discussion content
238 // sometimes has to be redacted, but only by an admin and never silently.
239 if c.old != "" && c.old != git.ZeroSHA && c.new != git.ZeroSHA {
240 ff, err := g.IsAncestor(c.old, c.new)
241 if err != nil {
242 problems = append(problems, fmt.Sprintf("%s: cannot check fast-forward: %v", c.ref, err))
243 } else if !ff {
244 switch {
245 case role.AtLeast(access.Admin):
246 // A rewrite is allowed, but only on the record: an override that
247 // cannot be written is refused, which is the whole value of it.
248 if err := auditLine(cfg.AuditPath, audit.Record{
249 Action: "push.force",
250 Actor: actor,
251 Subject: m.Name,
252 Detail: fmt.Sprintf("%s %s -> %s", c.ref, format.SHA(c.old), format.SHA(c.new)),
253 }); err != nil {
254 problems = append(problems, fmt.Sprintf(
255 "%s: cannot record the forced push, refusing it: %v", c.ref, err))
256 }
257 case role == access.Contribute && access.IsContributorRef(actor, c.ref):
258 // The namespace is theirs: rebasing or deleting their own branch is
259 // not a rewrite of shared history.
260 default:
261 problems = append(problems, fmt.Sprintf(
262 "%s: rewriting history requires admin access; %s is %s", c.ref, actor, role))
263 }
264 }
265 }
266
267 // Workflows are executable policy, so the version being pushed is validated
268 // here rather than when a runner picks it up.
269 if strings.HasPrefix(c.ref, "refs/heads/") && c.new != "" && c.new != git.ZeroSHA {
270 touches, err := touchesWorkflows(g, c)
271 if err != nil {
272 problems = append(problems, fmt.Sprintf("%s: cannot check %s: %v", c.ref, ci.Dir, err))
273 } else if touches {
274 problems = append(problems, ci.ValidateTree(g, c.new)...)
275 }
276 }
277
278 // Integrity checks run before the protected-branch gate, because an admin's
279 // approval bypass is about review, not about whether the repository works:
280 // a pointer with no object behind it is broken no matter who pushed it.
281 if cfg.MaxBlobBytes > 0 && c.new != git.ZeroSHA {
282 overs, err := oversizedBlobs(g, c, cfg.MaxBlobBytes)
283 if err != nil {
284 problems = append(problems, fmt.Sprintf("%s: cannot check object sizes: %v", c.ref, err))
285 continue
286 }
287 problems = append(problems, overs...)
288 }
289 // LFS objects live outside the pack, so receive-pack cannot notice that a
290 // pointer names nothing. This is the only place that can.
291 if cfg.LFS != nil && c.new != git.ZeroSHA {
292 lfsProblems, warnings, err := lfsFindings(g, m.Name, c.new, cfg.LFS, cfg.LFSWarnBytes)
293 if err != nil {
294 problems = append(problems, fmt.Sprintf("%s: cannot check LFS pointers: %v", c.ref, err))
295 } else {
296 problems = append(problems, lfsProblems...)
297 printWarnings(cfg.Out, warnings)
298 }
299 }
300
301 // An LFS lock is held on a path, and a push that changes that path is exactly
302 // what the holder asked to be told about. This sits with the integrity checks
303 // rather than with review: a lock is not a review decision, and an admin may
304 // override it, audited, because a lock whose holder has left would otherwise
305 // make a path unpushable forever.
306 if lockTable != nil {
307 held, err := lockTable.Paths(m.ID)
308 if err != nil {
309 problems = append(problems, fmt.Sprintf("%s: cannot read the lock table: %v", c.ref, err))
310 } else if lockProblems, err := lockedPathFindings(g, c, held, actor); err != nil {
311 problems = append(problems, fmt.Sprintf("%s: cannot check locks: %v", c.ref, err))
312 } else if len(lockProblems) > 0 {
313 if role.AtLeast(access.Admin) {
314 for _, p := range lockProblems {
315 if err := auditLine(cfg.AuditPath, audit.Record{
316 Action: "lock.override",
317 Actor: actor,
318 Subject: m.Name,
319 Detail: c.ref + ": " + p,
320 }); err != nil {
321 problems = append(problems, fmt.Sprintf(
322 "%s: cannot record the lock override, refusing it: %v", c.ref, err))
323 continue
324 }
325 reject(cfg.Out, "admin override, audited: %s", p)
326 }
327 } else {
328 problems = append(problems, lockProblems...)
329 }
330 }
331 }
332
333 // A published release is immutable: the tag that names it and the pointers
334 // that carry its bytes. This sits with the integrity checks rather than
335 // with the review gate, because a download that can change bytes is broken
336 // no matter who pushed it — but an admin may override, audited, since a
337 // mistyped tag is a real problem and the alternative is a repository
338 // nobody can repair.
339 if relProblems := releaseFindings(g, c); len(relProblems) > 0 {
340 if role.AtLeast(access.Admin) {
341 for _, p := range relProblems {
342 if err := auditLine(cfg.AuditPath, audit.Record{
343 Action: "release.rewrite",
344 Actor: actor,
345 Subject: m.Name,
346 Detail: c.ref + ": " + p,
347 }); err != nil {
348 problems = append(problems, fmt.Sprintf(
349 "%s: cannot record the release rewrite, refusing it: %v", c.ref, err))
350 continue
351 }
352 reject(cfg.Out, "admin override, audited: %s", p)
353 }
354 } else {
355 problems = append(problems, relProblems...)
356 }
357 }
358
359 // The gate a push meets is the same one a server-side merge meets, so it is
360 // asked here in the same function the merge operation calls
361 // (forge/landing). An unprotected ref needs nothing; an admin keeps the bypass
362 // it always had; everyone else needs an approved request naming this exact
363 // commit, with the required checks passing.
364 if a.IsProtected(c.ref) {
365 branch := strings.TrimPrefix(c.ref, "refs/heads/")
366 merge, gateErr := landing.Gate{
367 Repo: g, Name: repoNameFromDir(cfg.RepoDir),
368 Runs: openRuns(cfg.RunsDB),
369 ACL: a, Actor: actor, Role: role,
370 }.Check(branch, c.new, c.old)
371 if gateErr != nil {
372 problems = append(problems, gateErr.Error())
373 continue
374 }
375 if merge != nil {
376 // The landing itself is in the repository's history, so a log that
377 // cannot be written is reported rather than turned into a refusal.
378 if err := auditLine(cfg.AuditPath, audit.Record{
379 Action: "merge",
380 Actor: actor,
381 Subject: m.Name,
382 Detail: fmt.Sprintf("%s via request %s (head %s) approved by %s",
383 c.ref, merge.Request.ID, format.SHA(merge.Request.Head), strings.Join(merge.Approvers, ",")),
384 }); err != nil {
385 reject(cfg.Out, "audit log write failed: %v", err)
386 }
387 }
388 }
389 // The issues branch is user-authored content, so it is validated at the
390 // boundary: bad data never reaches the tree the web reads.
391 if c.ref == metadata.Ref && c.new != git.ZeroSHA {
392 for _, prob := range metadata.ValidateIssues(g, c.new) {
393 problems = append(problems, "issues: "+prob)
394 }
395 for _, prob := range metadata.ValidateRequests(g, c.new) {
396 problems = append(problems, "requests: "+prob)
397 }
398 // The repository may require a linked issue when a request is opened.
399 // Check only the requests this push adds: existing requests keep their
400 // history when the policy is turned on, and an edit to one is not a new
401 // opening. The browser writes metadata directly, so it makes the same
402 // check in CreateChangeRequest.
403 if a.RequiresLinkedIssue() {
404 added, err := metadata.NewRequests(g, c.old, c.new)
405 if err != nil {
406 problems = append(problems, "requests: cannot check the linked-issue rule: "+err.Error())
407 } else {
408 for _, req := range added {
409 if len(req.Closes) == 0 {
410 problems = append(problems, fmt.Sprintf(
411 "requests: %s: opening a change request here requires at least one linked issue (use --closes <issue>)", req.ID))
412 }
413 }
414 }
415 }
416 // An approval is a statement about the actor's own review, so the file
417 // name must be the actor's account name. Without this, anyone with write
418 // access could commit approvals/<someone-else>.json and approve on their
419 // behalf.
420 for _, prob := range approvalOwnership(g, c, actor, role, cfg.AuditPath, m.Name) {
421 problems = append(problems, prob)
422 }
423 // A comment is somebody's own words, so changing or deleting it is
424 // theirs too — checked against the author on the *old* blob, which is
425 // the one that was validated when it landed.
426 for _, prob := range commentOwnership(g, c, actor, role, cfg.AuditPath, cfg.Out, m.Name) {
427 problems = append(problems, prob)
428 }
429 // What this push may write there is the account's capabilities, per path.
430 // The browser applies the same rules one action at a time, and the two
431 // transports have to keep offering the same account the same things.
432 problems = append(problems, writableMetadata(g, c, a, actor)...)
433 }
434 }
435
436 if len(problems) == 0 {
437 return 0
438 }
439 reject(cfg.Out, "push rejected (%d problem(s)):", len(problems))
440 for _, p := range problems {
441 fmt.Fprintf(cfg.Out, "vole: %s\n", p)
442 }
443 return 1
444}
445
446func oversizedBlobs(g *git.Repo, c change, max int64) ([]string, error) {
447 sizes, err := g.NewBlobSizes(c.old, c.new)
448 if err != nil {
449 return nil, err
450 }
451 var out []string
452 for sha, n := range sizes {
453 if n <= max {
454 continue
455 }
456 out = append(out, fmt.Sprintf(
457 "object %s is %d bytes, over the %d byte limit (consider git-lfs)", sha[:12], n, max))
458 if len(out) >= 5 {
459 out = append(out, "further oversized objects not listed")
460 break
461 }
462 }
463 return out, nil
464}
465
466func readChanges(r io.Reader) ([]change, error) {
467 var out []change
468 sc := bufio.NewScanner(r)
469 sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
470 for sc.Scan() {
471 line := strings.TrimSpace(sc.Text())
472 if line == "" {
473 continue
474 }
475 f := strings.Fields(line)
476 if len(f) != 3 {
477 return nil, fmt.Errorf("malformed ref update %q", line)
478 }
479 out = append(out, change{old: f[0], new: f[1], ref: f[2]})
480 }
481 return out, sc.Err()
482}
483
484// approvalOwnership enforces that an approval is written by the principal it
485// names. The file name is how the tree records "who approved", so it has to be
486// backed by the SSH identity the server resolved, not by anything the pusher
487// chose to type.
488//
489// Every write counts: added, modified, renamed, retyped. Checking only additions
490// is not enough, because a file that already exists can simply be edited to name
491// a different commit.
492func approvalOwnership(g *git.Repo, c change, actor string, role access.Role, auditPath, repoName string) []string {
493 // On the first push the branch has no old commit, so every approval in the
494 // tree is new and must be checked. Skipping that would let whoever creates the
495 // branch seed approvals in other people's names.
496 if c.old == "" || c.old == git.ZeroSHA {
497 blobs, err := g.LsTreeR(c.new)
498 if err != nil {
499 return []string{fmt.Sprintf("requests: cannot verify approvals in this push: %v", err)}
500 }
501 var problems []string
502 for _, b := range blobs {
503 if owner := metadata.ApprovalPath(b.Name); owner != "" && owner != actor {
504 problems = append(problems, fmt.Sprintf(
505 "requests: %s: %s may not create an approval for %s", b.Name, actor, owner))
506 }
507 }
508 return problems
509 }
510
511 status, err := g.DiffStatus(c.old, c.new)
512 if err != nil {
513 return []string{fmt.Sprintf("requests: cannot verify approvals in this push: %v", err)}
514 }
515 var problems []string
516 for path, st := range status {
517 owner := metadata.ApprovalPath(path)
518 if owner == "" || owner == actor {
519 continue
520 }
521 if st == "D" {
522 // Removing someone else's approval forges nothing, but it erases their
523 // statement. The owner may withdraw; an admin may clean up, because the
524 // alternative (force-push) is heavier for a one-line fix. Neither is
525 // silent: the admin path is audited.
526 if !role.AtLeast(access.Admin) {
527 problems = append(problems, fmt.Sprintf(
528 "requests: %s: %s may not delete %s's approval; only %s can withdraw it",
529 path, actor, owner, owner))
530 } else if err := auditLine(auditPath, audit.Record{
531 Action: "approval.remove",
532 Actor: actor,
533 Subject: repoName,
534 Detail: path,
535 }); err != nil {
536 problems = append(problems, fmt.Sprintf(
537 "requests: %s: cannot record the removal of an approval, refusing the push: %v", path, err))
538 }
539 continue
540 }
541 problems = append(problems, fmt.Sprintf(
542 "requests: %s: %s may not add or change %s's approval; only that account can approve",
543 path, actor, owner))
544 }
545 return problems
546}
547
548// commentOwnership enforces that a comment is changed or removed by the account
549// whose words it is.//
550// The author of a comment is a name in a file, and a name is not authentication.
551// What makes this check sound is *which* file it reads: the old blob was
552// validated when it landed, whereas the new one is whatever the pusher wrote.
553// Comparing the old author is also what closes the obvious bypass — editing the
554// author field to your own name and the body in the same push.
555//
556// Three things are deliberately not enforced here:
557//
558// - Adding a comment is free. A new file's author is a claim, exactly like an
559// issue's author field, and the push boundary has never pretended otherwise.
560// - Issue files are not covered. They carry shared state — state, labels,
561// assignees — so closing or labelling somebody else's issue is normal triage,
562// and a description can be corrected by any writer, which it already could be.
563// - A request's own file is not covered either, for the same reason: its state,
564// head and base are shared, and updating them is the author's normal edit.
565// - A first push has nothing to compare against, so it is skipped.
566func commentOwnership(g *git.Repo, c change, actor string, role access.Role, auditPath string, out io.Writer, repoName string) []string {
567 if c.old == "" || c.old == git.ZeroSHA {
568 return nil
569 }
570 status, err := g.DiffStatus(c.old, c.new)
571 if err != nil {
572 return []string{fmt.Sprintf("issues: cannot verify comment authorship: %v", err)}
573 }
574 var problems []string
575 for path, st := range status {
576 // An issue comment and a review comment on a change request are the same
577 // file shape, so one rule covers both.
578 if !metadata.IsIssueCommentPath(path) && !metadata.IsRequestCommentPath(path) {
579 continue
580 }
581 // A rename has no blob at the destination, so its author cannot be
582 // established; that is a refusal rather than a pass.
583 if st != "M" && st != "D" && st != "R" {
584 continue
585 }
586 raw, err := g.BlobAt(c.old, path)
587 if err != nil {
588 problems = append(problems, fmt.Sprintf(
589 "issues: %s: cannot establish whose comment this is, refusing the push: %v", path, err))
590 continue
591 }
592 author, err := metadata.AuthorOf(raw)
593 if err != nil {
594 problems = append(problems, fmt.Sprintf(
595 "issues: %s: cannot establish whose comment this is, refusing the push: %v", path, err))
596 continue
597 }
598 if author == actor {
599 continue
600 }
601 verb := "change"
602 if st == "D" {
603 verb = "delete"
604 }
605 // An admin may repair a thread — a comment by somebody who has left
606 // cannot be fixed by them — but never silently: the override is audited,
607 // like a force-push and a release rewrite.
608 if role.AtLeast(access.Admin) {
609 action := "comment.change"
610 if st == "D" {
611 action = "comment.delete"
612 }
613 if err := auditLine(auditPath, audit.Record{
614 Action: action,
615 Actor: actor,
616 Subject: repoName,
617 Detail: fmt.Sprintf("%s (author %s)", path, author),
618 }); err != nil {
619 problems = append(problems, fmt.Sprintf(
620 "issues: %s: cannot record the override, refusing the push: %v", path, err))
621 continue
622 }
623 reject(out, "admin override, audited: %s %s %s's comment", actor, verb, author)
624 continue
625 }
626 problems = append(problems, fmt.Sprintf(
627 "issues: %s: only %s can %s this comment; %s is not that account",
628 path, author, verb, actor))
629 }
630 return problems
631}
632
633// touchesWorkflows reports whether this push changes anything under the workflow
634// directory, so repositories without CI pay one diff and no more.
635func touchesWorkflows(g *git.Repo, c change) (bool, error) {
636 if c.old == "" || c.old == git.ZeroSHA {
637 blobs, err := g.LsTreeR(c.new)
638 if err != nil {
639 return false, err
640 }
641 for _, b := range blobs {
642 if strings.HasPrefix(b.Name, ci.Dir+"/") {
643 return true, nil
644 }
645 }
646 return false, nil
647 }
648 status, err := g.DiffStatus(c.old, c.new)
649 if err != nil {
650 return false, err
651 }
652 for p := range status {
653 if strings.HasPrefix(p, ci.Dir+"/") {
654 return true, nil
655 }
656 }
657 return false, nil
658}
659
660// repoNameFromDir derives the repository name from the directory git handed the
661// hook. It cannot use the path as given: hooks run with GIT_DIR="." (relative to
662// the repository), and receive-pack may pass a trailing separator, so a bare
663// filepath.Base returns "." for both.
664func repoNameFromDir(dir string) string {
665 abs, err := filepath.Abs(dir)
666 if err != nil {
667 abs = dir
668 }
669 return strings.TrimSuffix(filepath.Base(abs), ".git")
670}
671
672// auditLine is best-effort: failing to write the log must not change policy.
673// auditLine appends one record. Pre-receive treats an error as a refusal, because
674// the alternative is an override that happened and cannot be found; post-receive
675// reports it on the pusher's terminal, because the refs have already moved.
676func auditLine(path string, r audit.Record) error {
677 return audit.Append(path, r)
678}
679
680func reject(w io.Writer, format string, args ...any) {
681 fmt.Fprintf(w, "vole: "+format+"\n", args...)
682}
683
684// PostReceive hands accepted ref updates to the CI queue by writing one file per
685// update into the spool directory. It runs after the refs have moved, so what it
686// records is what actually landed, and it never fails a push: a spool write that
687// does not happen costs a CI run, not a commit.
688// PostReceive records what a push changed and queues its CI.
689//
690// The recording is the push log, and it is the whole of this forge's audit story for an
691// ordinary push: attribution is established once, at the boundary (the SSH handshake names
692// the account, and pre-receive refuses anything the account may not do), so what is left
693// for the instance to do is *write down what it decided*. One line per accepted ref
694// update, in the same append-only log as the privileged acts.
695//
696// It runs here rather than in pre-receive because this is the point at which the refs have
697// actually moved: pre-receive returning 0 is a permission, not an event.
698//
699// Nothing here may fail the push. A push that landed is done, and a full disk must not
700// turn it into an error the client reports as a rejected update.
701func PostReceive(cfg Config) int {
702 if cfg.Out == nil {
703 cfg.Out = io.Discard
704 }
705 repo := repoNameFromDir(cfg.RepoDir)
706 actor := strings.TrimSpace(cfg.Actor)
707 who := actor
708 if who == "" {
709 who = "unknown"
710 }
711 following := openFollower(cfg)
712 defer following.close()
713 sc := bufio.NewScanner(cfg.In)
714 for sc.Scan() {
715 fields := strings.Fields(sc.Text())
716 if len(fields) != 3 {
717 continue
718 }
719 old, next, ref := fields[0], fields[1], fields[2]
720 if err := auditLine(cfg.AuditPath, audit.Record{
721 Action: "push",
722 Actor: who,
723 Subject: repo,
724 Detail: fmt.Sprintf("%s %s -> %s", ref, shortOrNone(old), shortOrNone(next)),
725 }); err != nil {
726 // The update landed before this process ran, so the only honest report
727 // is on the pusher's terminal; failing the hook would make the client
728 // re-send something that already worked.
729 fmt.Fprintf(cfg.Out, "vole: audit log write failed: %v\n", err)
730 }
731 // A change request opened from a branch follows it: the push that just moved
732 // the ref moves the request's head in the same breath. It runs here, after the
733 // ref update, because the new tip is the event; pre-receive could only guess.
734 // Nothing about this may fail the push, and a sync that does not happen leaves
735 // the previous (already reviewed) head in place rather than approving new work.
736 //
737 // A branch no open request follows is the common case by a long way, and the
738 // answer is one indexed row: leaving it out of this block would make every
739 // push read the metadata branch and queue behind every comment.
740 if strings.HasPrefix(ref, "refs/heads/") && ref != metadata.Ref &&
741 next != "" && next != git.ZeroSHA && actor != "" {
742 branch := strings.TrimPrefix(ref, "refs/heads/")
743 if following.follows(branch) {
744 n, err := metadata.SyncBranchHead(git.Open(cfg.RepoDir), branch, next, actor)
745 switch {
746 case err != nil:
747 fmt.Fprintf(cfg.Out, "vole: could not move requests following %s: %v\n", branch, err)
748 case n > 0:
749 if err := auditLine(cfg.AuditPath, audit.Record{
750 Action: "request.follow",
751 Actor: actor,
752 Subject: repo,
753 Detail: fmt.Sprintf("%s -> %s (%d request(s))", ref, shortOrNone(next), n),
754 }); err != nil {
755 fmt.Fprintf(cfg.Out, "vole: audit log write failed: %v\n", err)
756 }
757 }
758 }
759 }
760 if cfg.SpoolDir == "" {
761 continue
762 }
763 event := runs.Event{
764 Repo: repo,
765 Ref: ref,
766 Old: old,
767 New: next,
768 At: time.Now().UTC().Format(time.RFC3339Nano),
769 }
770 if err := runs.WriteSpool(cfg.SpoolDir, event); err != nil {
771 fmt.Fprintf(cfg.Out, "vole: could not queue CI for %s: %v\n", ref, err)
772 }
773 }
774 if err := RefreshIndex(cfg); err != nil {
775 fmt.Fprintf(cfg.Out, "vole: could not refresh the index: %v\n", err)
776 }
777 return 0
778}
779
780// RefreshIndex brings the derived rows up to the metadata branch the push left
781// behind. It runs in post-receive, after the ref update and after a followed
782// request's head has moved, so a page or a search finds the work already applied.
783//
784// A failure is reported and ignored: the refs have moved and the push cannot be
785// failed now, and the next read still checks the branch and repairs the rows.
786func RefreshIndex(cfg Config) error {
787 if cfg.IndexDB == "" || cfg.RepoID == "" {
788 return nil
789 }
790 store, err := index.Open(cfg.IndexDB)
791 if err != nil {
792 return err
793 }
794 defer store.Close()
795 m := &repo.Meta{ID: cfg.RepoID, Dir: cfg.RepoDir}
796 if err := store.Ensure(m); err != nil {
797 return err
798 }
799 // A tag is a ref, not metadata: no metadata commit announces a release, so
800 // the feed's release events are refreshed from the refs on every push.
801 if err := store.RefreshTags(m); err != nil {
802 return err
803 }
804 // The default branch tip, its tree and its newest commits are what the
805 // landing and overview pages show. RefreshHead compares the tip first, so a
806 // metadata or tag push does not walk the tree or the history.
807 _, err = store.RefreshHead(m)
808 return err
809}
810
811// requestsFollow reports whether any open change request follows branch, which is
812// the question post-receive asks before it reads the metadata branch to move a
813// request's head.
814//
815// Every doubt answers yes. A missing, unreadable, or stale index means the caller
816// does the work, because skipping the sync leaves a request naming an old head and
817// nothing repairs that afterwards; doing it unnecessarily costs one metadata commit
818// that changes nothing.
819type follower struct {
820 cfg Config
821 store *index.Store
822 tried bool
823}
824
825// openFollower prepares the question a push asks once per branch it moved. The index
826// is opened at most once for the whole push, and not at all for a push that touched
827// no branch.
828func openFollower(cfg Config) *follower { return &follower{cfg: cfg} }
829
830func (f *follower) follows(branch string) bool {
831 if f.cfg.IndexDB == "" || f.cfg.RepoID == "" {
832 return true
833 }
834 if !f.tried {
835 f.tried = true
836 if store, err := index.Open(f.cfg.IndexDB); err == nil {
837 f.store = store
838 }
839 }
840 if f.store == nil {
841 return true
842 }
843 m := &repo.Meta{ID: f.cfg.RepoID, Dir: f.cfg.RepoDir}
844 follows, ok, err := f.store.RequestsFollowing(m, branch)
845 if err != nil || !ok {
846 return true
847 }
848 return follows
849}
850
851func (f *follower) close() {
852 if f.store != nil {
853 _ = f.store.Close()
854 }
855}
856
857// shortOrNone renders a sha for the log, naming the two states that are not a commit:
858// a ref being created and one being deleted.
859func shortOrNone(sha string) string {
860 if sha == "" || sha == git.ZeroSHA {
861 return "none"
862 }
863 return format.SHA(sha)
864}
865
866// openRuns opens the run queue for one gate check. The pre-receive hook is a
867// process that answers one push and exits, so the store is opened per check and not
868// closed: the close would come after the answer, and the process ends there anyway.
869// The server, which is long-lived, passes its own open handle instead.
870func openRuns(path string) func() (*runs.Store, error) {
871 if path == "" {
872 return nil
873 }
874 return func() (*runs.Store, error) { return runs.Open(path) }
875}
876