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