1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "time"
11
12 "github.com/bnema/zerowrap"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// Pushes are the use cases around a Git push.
19type Pushes struct {
20 meta ports.RepoMetaStore
21 repos *Repos
22 git ports.GitRepos
23 bus ports.EventBus
24 clock ports.Clock
25 maint ports.MaintenanceScheduler
26 auth ports.Revalidator
27 fresh ports.RepoFreshener // nil: the reader needs no telling
28 ci ports.WorkflowTrigger // nil: no CI
29 wf ports.WorkflowGuard // nil: pushes by job tokens are refused
30 tips ports.CIRefTipStore // nil: who pushes a ref is not recorded
31}
32
33var (
34 _ ports.PushService = (*Pushes)(nil)
35 _ ports.PushHookService = (*Pushes)(nil)
36)
37
38// NewPushes builds the push use cases. Every argument is required: auth
39// re-validates the token of a push when its pre-receive hook runs, maint
40// queues the maintenance that follows a push.
41func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus ports.EventBus, clock ports.Clock, maint ports.MaintenanceScheduler, auth ports.Revalidator) *Pushes {
42 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
43}
44
45// WithFreshener makes Record tell the reader that a repository changed, so that
46// it serves the push at once. Call it before the first push; it returns u.
47func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
48 u.fresh = f
49 return u
50}
51
52// WithWorkflowTrigger makes Record start the workflows that match the push.
53// Call it before the first push; it returns u.
54func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
55 u.ci = t
56 return u
57}
58
59// WithRefTips makes Check record who is about to move each branch and tag
60// (domain.CIRefTip), before Git moves it. Call it before the first push; it
61// returns u.
62func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes {
63 u.tips = t
64 return u
65}
66
67// WithWorkflowGuard lets Check tell whether a push changes workflow files, which
68// a push by a job token may not do. Without it a push by a job token is
69// refused, as it cannot be judged. Call it before the first push; it returns u.
70func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {
71 u.wf = g
72 return u
73}
74
75// Open decides whether the principal may push to the repository and, with
76// create, creates it when it does not exist yet (private, published as
77// RepoCreated by Repos.Create). create is for the first request of a push only.
78//
79// Anonymous callers get domain.ErrUnauthenticated. A repository the caller
80// cannot read and cannot write, or a missing one it may not create, answers
81// domain.ErrNotFound, so that a private repository never shows itself. One it
82// can read but not write answers domain.ErrForbidden.
83func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) error {
84 if p.IsAnonymous() {
85 return domain.ErrUnauthenticated
86 }
87 repo, err := u.meta.Get(ctx, name)
88 switch {
89 case err == nil:
90 return openExisting(p, repo)
91 case !errors.Is(err, domain.ErrNotFound):
92 return fmt.Errorf("get repository %q: %w", name, err)
93 }
94
95 if !create || !canCreateRepo(p, name) {
96 return domain.ErrNotFound
97 }
98 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
99 switch {
100 case err == nil:
101 return nil // Create published RepoCreated
102 case errors.Is(err, domain.ErrAlreadyExists):
103 // A concurrent first push won the race: this one pushes to its repository.
104 repo, gerr := u.meta.Get(ctx, name)
105 if gerr != nil {
106 return fmt.Errorf("get repository %q: %w", name, gerr)
107 }
108 return openExisting(p, repo)
109 }
110 return err
111}
112
113func openExisting(p domain.Principal, repo domain.Repo) error {
114 switch {
115 case canWrite(p, repo.Name):
116 return nil
117 case canRead(p, repo):
118 return domain.ErrForbidden
119 }
120 return domain.ErrNotFound
121}
122
123// Check is the pre-receive decision. It accepts a push of a principal that
124// may write the repository. A token limited to branch prefixes may only update
125// (create, move or delete) branches under them: the first ref outside them
126// refuses the whole push, tags and every other ref included. Further rules
127// (protected branches, limits) will refuse here too, by returning an error
128// that matches domain.ErrRejected.
129//
130// The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no
131// symbolic ref under refs/heads besides HEAD (ermite never creates one and
132// receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees
133// every ref of the push, so a tag or any other ref is judged like a branch.
134//
135// The token of the push was valid when the request began, but a push can run
136// for long: it is re-validated here, so a token revoked or expired meanwhile
137// stops the push before Git applies it. A failure to tell refuses it too.
138//
139// The token of a CI job (GITHUB_TOKEN with contents: write) may not change
140// workflows (CI-3, D3). The push is refused as a whole, on any ref, branch or
141// tag, when a commit it brings changes .github/workflows (it has workflows that
142// differ from those of every parent; removing them is allowed), or when a ref
143// would end with workflows other than it had: an update with other than the old
144// ones, a new ref with other than those of the default branch head (or any, when
145// there is no head). A new branch cut from existing commits that change nothing
146// is therefore allowed; deleting a ref is not a change. objects locate the
147// objects of the push, which are not in the repository yet.
148func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
149 if err := u.auth.Revalidate(ctx, p); err != nil {
150 if errors.Is(err, domain.ErrUnauthenticated) {
151 return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
152 }
153 return fmt.Errorf("revalidate token: %w", err)
154 }
155 if !canWrite(p, name) {
156 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
157 }
158 for _, up := range updates {
159 // refs/pull/* belong to ermite (the head and the test merge of the merge
160 // requests): nobody pushes there, whatever the token, the workflow
161 // guard included.
162 if domain.IsPullRefName(up.Ref) {
163 return fmt.Errorf("%w: %s is maintained by ermite: refs/pull/* cannot be pushed", domain.ErrRejected, up.Ref)
164 }
165 if !p.AllowsRef(up.Ref) {
166 return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
167 domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
168 }
169 }
170 if p.ViaJobToken() {
171 if err := u.checkWorkflows(ctx, name, updates, objects); err != nil {
172 return err
173 }
174 }
175 // Last, once the push is accepted: who pushes the branches and tags is
176 // recorded before Git moves them, so that a tip never has the record of an
177 // earlier pusher at the same commit (domain.CIRefTip). A record that cannot
178 // be written refuses the push, as it cannot be run later with a known trust.
179 if err := recordRefTips(ctx, u.tips, name, updates, domain.AuthorName(p), domain.PusherOf(p), domain.TrustedActor(p)); err != nil {
180 return err
181 }
182 log := zerowrap.FromCtx(ctx)
183 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
184 return nil
185}
186
187// workflowRefusal is GitHub's refusal of a push by the token of a job that
188// changes a workflow (a push to a branch or a tag; the token lacks the
189// "workflows" permission, which no job token can have).
190const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
191
192func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
193 if u.wf == nil {
194 return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
195 }
196 path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)
197 switch {
198 case errors.Is(err, domain.ErrInvalid):
199 // A push the guard cannot judge within its bounds: refused, with the reason.
200 return fmt.Errorf("%w: %v", domain.ErrRejected, err)
201 case err != nil:
202 return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
203 case path != "":
204 return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
205 }
206 return nil
207}
208
209// Record is the post-receive step of an accepted push: it sets pushed_at and active_at,
210// repairs HEAD when it points to a missing branch, tells the reader that the
211// repository changed, queues the workflows the push triggers, publishes a
212// PushEvent and schedules maintenance. Every
213// step runs even when an earlier one fails; the failures are returned together
214// and the push itself is not affected.
215func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
216 log := zerowrap.FromCtx(ctx)
217 now := u.clock.Now().UTC()
218 var errs []error
219
220 repo, err := touchPushedAt(ctx, u.meta, name, now)
221 if err != nil {
222 errs = append(errs, err)
223 }
224 if err := u.fixDefaultBranch(ctx, name, updates); err != nil {
225 errs = append(errs, err)
226 }
227 if u.fresh != nil {
228 u.fresh.Refresh(ctx, name)
229 }
230 if u.ci != nil {
231 u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
232 }
233 u.bus.Publish(ctx, domain.PushEvent{
234 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
235 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
236 })
237 u.maint.Schedule(ctx, name)
238 log.Info().
239 Str("repo", name.String()).Int("refs", len(updates)).
240 Str("principal", p.Label()).Str("token_id", p.TokenID()).
241 Msg("push received")
242 return errors.Join(errs...)
243}
244
245// touchPushedAt stores pushed_at and active_at, preserving later timestamps
246// and retrying when another write got in between.
247// A repository deleted meanwhile is not an error. It returns the repository
248// as stored after mutation, or the zero value when it is unknown. A push
249// (Pushes.Record) and a merge, which moves a branch too, both record through it.
250func touchPushedAt(ctx context.Context, meta ports.RepoMetaStore, name domain.RepoName, now time.Time) (domain.Repo, error) {
251 repo, err := meta.Mutate(ctx, name, func(repo *domain.Repo) (bool, error) {
252 changed := false
253 if now.After(repo.PushedAt) {
254 repo.PushedAt = now
255 changed = true
256 }
257 if now.After(repo.ActiveAt) {
258 repo.ActiveAt = now
259 changed = true
260 }
261 return changed, nil
262 })
263 if errors.Is(err, domain.ErrNotFound) {
264 return domain.Repo{}, nil
265 }
266 if err != nil {
267 return repo, fmt.Errorf("update repository %q: %w", name, err)
268 }
269 return repo, nil
270}
271
272// fixDefaultBranch points HEAD to a branch that exists when it points to a
273// missing one: main when it was pushed, else the first pushed branch.
274func (u *Pushes) fixDefaultBranch(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate) error {
275 branch, exists, err := u.git.DefaultBranch(ctx, name)
276 if errors.Is(err, domain.ErrNotFound) {
277 return nil // deleted meanwhile
278 }
279 if err != nil {
280 return fmt.Errorf("read default branch of %q: %w", name, err)
281 }
282 if exists {
283 return nil
284 }
285 target := ""
286 for _, up := range updates {
287 b, ok := strings.CutPrefix(up.Ref, "refs/heads/")
288 if !ok || up.IsDelete() {
289 continue
290 }
291 if b == "main" {
292 target = b
293 break
294 }
295 if target == "" {
296 target = b
297 }
298 }
299 if target == "" || target == branch {
300 return nil
301 }
302 err = u.git.SetDefaultBranch(ctx, name, target)
303 if errors.Is(err, domain.ErrNotFound) {
304 return nil // deleted meanwhile
305 }
306 if err != nil {
307 return fmt.Errorf("set default branch of %q to %q: %w", name, target, err)
308 }
309 log := zerowrap.FromCtx(ctx)
310 log.Info().Str("repo", name.String()).Str("branch", target).Msg("default branch set")
311 return nil
312}