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