1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "encoding/json"
7 "slices"
8 "strings"
9
10 "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"
12)
13
14// The scope of a job in the cache the runner serves (CI-3 D8, CI-4 D4), sent as
15// Job.cache. Entries are keyed by repository and ref, as on GitHub: a job
16// restores from the ref of its run, then the base branch of a merge request,
17// then the default branch, and saves under the ref of its run.
18//
19// An entry carries the trust of the run that saved it, because restoring it
20// runs what it holds (node_modules, a build cache): a trusted run (secrets,
21// write token) must never restore what an untrusted one saved. The runner
22// treats the refs of Job.cache as names (it checks "refs/..." only), so the
23// server keeps two namespaces:
24//
25// - a trusted run saves under its ref, refs/heads/x, and restores trusted
26// entries only;
27// - an untrusted run saves under refs/untrusted/heads/x, never under the
28// default branch (not even in its own namespace: nothing saved there would
29// ever be restored by a run that should), and restores both kinds, its own
30// namespace first at each level.
31//
32// A pull_request_target run is on the default branch but runs for a merge
33// request: it saves nothing (the runner then sets ACTIONS_CACHE_MODE=read) and
34// restores the default branch alone, never the merge ref, whose entries the
35// pull_request runs of the request save from the head's code. GitHub makes
36// these runs read-only on the default branch too.
37//
38// Every runner that serves a cache (ermite-runner v0.3.0 and later) honours
39// Job.cache, which the server always sends; entries saved before it did were
40// saved under the plain refs whatever the trust of the run.
41
42// untrustedPrefix starts the refs the entries of untrusted runs are saved
43// under. No run is ever on such a ref: runs are on refs/heads, refs/tags and
44// refs/pull.
45const untrustedPrefix = "refs/untrusted/"
46
47// untrustedRef is the ref an untrusted run saves ref's entries under.
48func untrustedRef(ref string) string { return untrustedPrefix + strings.TrimPrefix(ref, "refs/") }
49
50// cacheLinks are the facts of the github context of a run that its cache scope
51// needs.
52type cacheLinks struct {
53 BaseRef string `json:"base_ref"`
54 Event struct {
55 Repository struct {
56 DefaultBranch string `json:"default_branch"`
57 } `json:"repository"`
58 } `json:"event"`
59}
60
61// cacheScope is the scope of the jobs of a run (an attempt: a re-run that
62// lowers the trust lowers the scope); githubJSON is the github context the run
63// was created with (domain.CIRunSource), so the default branch is the one of
64// that moment.
65func (c *CI) cacheScope(run domain.CIRun, githubJSON string) ports.JobCache {
66 var l cacheLinks
67 _ = json.Unmarshal([]byte(githubJSON), &l) // a context that does not read: no default branch known
68 sc := ports.JobCache{Repository: c.cfg.Owner + "/" + run.Repo.String()}
69 def := ""
70 if l.Event.Repository.DefaultBranch != "" {
71 def = "refs/heads/" + l.Event.Repository.DefaultBranch
72 }
73 read := func(ref string) {
74 if ref == "" {
75 return
76 }
77 if !run.Trusted && ref != def { // never saved under the default branch
78 sc.ReadRefs = appendNew(sc.ReadRefs, untrustedRef(ref))
79 }
80 sc.ReadRefs = appendNew(sc.ReadRefs, ref)
81 }
82 switch {
83 case run.Event == eventPullTarget:
84 // Read-only, the default branch only.
85 if def != "" {
86 sc.ReadRefs = []string{def}
87 }
88 return sc
89 case run.Trusted:
90 sc.WriteRef = run.Ref
91 case def != "" && run.Ref != def:
92 sc.WriteRef = untrustedRef(run.Ref)
93 }
94 read(run.Ref)
95 if l.BaseRef != "" {
96 read("refs/heads/" + l.BaseRef)
97 }
98 read(def)
99 return sc
100}
101
102// appendNew appends ref unless refs has it.
103func appendNew(refs []string, ref string) []string {
104 if slices.Contains(refs, ref) {
105 return refs
106 }
107 return append(refs, ref)
108}