1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10
11 "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"
13)
14
15// Who moves a branch or a tag (CI-3, D1; domain.CIRefTip). A scheduled run has
16// no pusher of its own: like GitHub's, it is the run of the last commit of the
17// default branch, and it takes the actor and the trust of whoever moved that
18// branch to that commit. A dispatched run is capped by the trust of the ref it
19// runs on. Without this a token limited to some branches could push the first
20// branch of an empty repository (which becomes the default branch) with a cron
21// workflow, and have it run as the owner with the owner's secrets.
22//
23// The record is written BEFORE the ref moves: by the pre-receive decision of a
24// push (Pushes.Check) and by a merge before it updates the target, under the
25// merge lock. Written after, a delayed write could leave an earlier pusher's
26// record on a tip moved back to the same commit (owner A, agent B, agent A).
27// Writing it is part of the decision: when it fails the push or the merge is
28// refused, never let through unrecorded. A push that is refused later leaves a
29// record that names an object the ref does not have, which is not trusted.
30
31// recordRefTips stores who is about to move the branches and tags of a push or a
32// merge, and removes the records of the refs it deletes. actor is what the runs
33// show, pusher and trusted are as for a push run. refs/pull/* is ermite's own
34// and has none.
35func recordRefTips(ctx context.Context, tips ports.CIRefTipStore, repo domain.RepoName, updates []domain.RefUpdate, actor string, pusher domain.CIPusher, trusted bool) error {
36 if tips == nil {
37 return nil
38 }
39 for _, u := range updates {
40 if !strings.HasPrefix(u.Ref, "refs/heads/") && !strings.HasPrefix(u.Ref, "refs/tags/") {
41 continue
42 }
43 var err error
44 if u.IsDelete() {
45 err = tips.DeleteRefTip(ctx, repo, u.Ref)
46 } else {
47 err = tips.PutRefTip(ctx, domain.CIRefTip{Repo: repo, Ref: u.Ref, Sha: u.New, Actor: actor, Pusher: pusher, Trusted: trusted})
48 }
49 if errors.Is(err, domain.ErrNotFound) {
50 continue // the repository is gone: nothing to record, nothing to run
51 }
52 if err != nil {
53 return fmt.Errorf("record who pushes %s: %w", u.Ref, err)
54 }
55 }
56 return nil
57}
58
59// refTip returns the record of a ref when it names the object the ref has now;
60// ok is false when there is none, it is for another object, or it cannot be
61// read (logged): the caller then treats the ref as not trusted.
62func (c *CI) refTip(ctx context.Context, repo domain.RepoName, ref string, objects ...domain.ObjectID) (domain.CIRefTip, bool) {
63 if c.cfg.RefTips == nil {
64 return domain.CIRefTip{}, false
65 }
66 t, err := c.cfg.RefTips.GetRefTip(ctx, repo, ref)
67 if err != nil {
68 if !errors.Is(err, domain.ErrNotFound) {
69 ctxLog(ctx).Error().Err(err).Str("repo", repo.String()).Str("ref", ref).Msg("ci: read who pushed a ref: it is not trusted")
70 }
71 return domain.CIRefTip{}, false
72 }
73 for _, o := range objects {
74 if !o.IsZero() && t.Sha == o.String() {
75 return t, true
76 }
77 }
78 return domain.CIRefTip{}, false
79}
80
81// scheduleActor returns the actor, the pusher and the trust of a scheduled run
82// on the tip of a branch: those of the record of the branch when it names this
83// very commit; otherwise the owner's name, no pusher and no trust.
84func (c *CI) scheduleActor(ctx context.Context, repo domain.RepoName, branch string, tip domain.ObjectID) (actor string, pusher domain.CIPusher, trusted bool) {
85 if t, ok := c.refTip(ctx, repo, "refs/heads/"+branch, tip); ok {
86 return t.Actor, t.Pusher, t.Trusted
87 }
88 return authorOf(domain.Owner()), domain.CIPusher{}, false
89}
90
91// refTrusted reports whether the ref was set to exactly the pinned object by a
92// trusted principal. The record must name the object the dispatch resolved (the
93// commit, or the tag object of an annotated tag), which the dispatch captured
94// together with the commit it runs: the refs are not read again here, so a tag
95// retargeted since cannot lend its new record to the old commit.
96func (c *CI) refTrusted(ctx context.Context, repo domain.RepoName, pin pinnedRef) bool {
97 t, ok := c.refTip(ctx, repo, pin.ref, pin.object)
98 return ok && t.Trusted
99}