detached dccd128

feat(ci): job token permissions and workflow push guard (#29)

commit
dccd128399286405b92daf0b3917bce9b7da2dea
author
brice · · 2026-10-14 23:30 UTC
committer
bnema · · 2026-10-14 23:30 UTC
parent
0633e44
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.12.0
34 files · +1884 -101
M gen/ermite/storage/ci.pb.go +74 -39 generated file: collapsed
M internal/adapters/gitcli/pipe.go +8 -1
@@ -35,6 +35,12 @@ func readArgs(dir string, args ...string) []string {
3535 // error kills the process and is returned. Without a deadline in ctx the
3636 // process gets commandTimeout.
3737 func (g *Git) pipe(ctx context.Context, args []string, fn func(*bufio.Reader) error) error {
38+ return g.pipeWith(ctx, nil, nil, args, fn)
39+}
40+
41+// pipeWith is pipe with extra environment variables after the isolated ones
42+// (the quarantine of a push) and, when stdin is not nil, the standard input.
43+func (g *Git) pipeWith(ctx context.Context, extra []string, stdin io.Reader, args []string, fn func(*bufio.Reader) error) error {
3844 if _, ok := ctx.Deadline(); !ok {
3945 var cancel context.CancelFunc
4046 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -50,9 +56,10 @@ func (g *Git) pipe(ctx context.Context, args []string, fn func(*bufio.Reader) er
5056 defer cancel()
5157
5258 cmd := exec.CommandContext(ctx, g.bin, args...)
53- cmd.Env = g.env
59+ cmd.Env = append(append(make([]string, 0, len(g.env)+len(extra)), g.env...), extra...)
5460 cmd.Dir = g.dataDir
5561 cmd.WaitDelay = 5 * time.Second
62+ cmd.Stdin = stdin
5663 stderr := &tailBuffer{max: 4096}
5764 cmd.Stderr = stderr
5865 stdout, err := cmd.StdoutPipe()
@@ -35,6 +35,12 @@ func readArgs(dir string, args ...string) []string {
35 // error kills the process and is returned. Without a deadline in ctx the35 // error kills the process and is returned. Without a deadline in ctx the
36 // process gets commandTimeout.36 // process gets commandTimeout.
37 func (g *Git) pipe(ctx context.Context, args []string, fn func(*bufio.Reader) error) error {37 func (g *Git) pipe(ctx context.Context, args []string, fn func(*bufio.Reader) error) error {
38+ return g.pipeWith(ctx, nil, nil, args, fn)
39+}
40+
41+// pipeWith is pipe with extra environment variables after the isolated ones
42+// (the quarantine of a push) and, when stdin is not nil, the standard input.
43+func (g *Git) pipeWith(ctx context.Context, extra []string, stdin io.Reader, args []string, fn func(*bufio.Reader) error) error {
38 if _, ok := ctx.Deadline(); !ok {44 if _, ok := ctx.Deadline(); !ok {
39 var cancel context.CancelFunc45 var cancel context.CancelFunc
40 ctx, cancel = context.WithTimeout(ctx, commandTimeout)46 ctx, cancel = context.WithTimeout(ctx, commandTimeout)
@@ -50,9 +56,10 @@ func (g *Git) pipe(ctx context.Context, args []string, fn func(*bufio.Reader) er
50 defer cancel()56 defer cancel()
51 57
52 cmd := exec.CommandContext(ctx, g.bin, args...)58 cmd := exec.CommandContext(ctx, g.bin, args...)
53- cmd.Env = g.env59+ cmd.Env = append(append(make([]string, 0, len(g.env)+len(extra)), g.env...), extra...)
54 cmd.Dir = g.dataDir60 cmd.Dir = g.dataDir
55 cmd.WaitDelay = 5 * time.Second61 cmd.WaitDelay = 5 * time.Second
62+ cmd.Stdin = stdin
56 stderr := &tailBuffer{max: 4096}63 stderr := &tailBuffer{max: 4096}
57 cmd.Stderr = stderr64 cmd.Stderr = stderr
58 stdout, err := cmd.StdoutPipe()65 stdout, err := cmd.StdoutPipe()
M internal/adapters/gitcli/read.go +5 -0
@@ -351,6 +351,11 @@ func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revi
351351 if obj.info.Type != "tree" {
352352 return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotDirectory)
353353 }
354+ if !path.IsRoot() && obj.info.Mode != 0o040000 {
355+ // A submodule entry whose commit id names a tree the repository has:
356+ // no directory of this repository, as for ermite-git.
357+ return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotFound)
358+ }
354359 treeID, err := domain.ParseObjectID(obj.info.ID)
355360 if err != nil {
356361 return domain.Tree{}, err
@@ -351,6 +351,11 @@ func (g *Git) GetTree(ctx context.Context, repo domain.RepoName, rev domain.Revi
351 if obj.info.Type != "tree" {351 if obj.info.Type != "tree" {
352 return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotDirectory)352 return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotDirectory)
353 }353 }
354+ if !path.IsRoot() && obj.info.Mode != 0o040000 {
355+ // A submodule entry whose commit id names a tree the repository has:
356+ // no directory of this repository, as for ermite-git.
357+ return domain.Tree{}, fmt.Errorf("tree %q of %q: %w", path, repo, domain.ErrNotFound)
358+ }
354 treeID, err := domain.ParseObjectID(obj.info.ID)359 treeID, err := domain.ParseObjectID(obj.info.ID)
355 if err != nil {360 if err != nil {
356 return domain.Tree{}, err361 return domain.Tree{}, err
A internal/adapters/gitcli/workflow_guard.go +260 -0
@@ -0,0 +1,260 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "path/filepath"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+var _ ports.WorkflowGuard = (*Git)(nil)
18+
19+// workflowsDir is where workflow files live, as a literal pathspec directory.
20+const workflowsDir = ".github/workflows/"
21+
22+// maxWorkflowPath bounds the one path the guard reads from git: a longer one
23+// fails closed (the push is refused), whatever the trees hold.
24+const maxWorkflowPath = 4096
25+
26+// ChangedWorkflow tells whether a push creates or changes a file under
27+// .github/workflows/. For each update that does not delete its ref, the
28+// directory .github/workflows of the old commit (none for a new ref) is
29+// compared with the one of the new commit: the same tree id is the same
30+// content, whatever it holds, so nothing is read; a different one, or one that
31+// appears, is a change, and the first top-level entry that differs names it. A
32+// new ref is thus compared with nothing: any workflow it carries counts, as on
33+// GitHub. A directory that disappears is not a change (as a deleted ref is
34+// not).
35+//
36+// What counts as the directory is exactly what the workflow loader
37+// (CI.workflowFiles, on both git readers) lists: .github and workflows both
38+// entries of mode 040000 and type tree. A path through a gitlink (160000), a
39+// symbolic link (120000, which the loader does not follow) or a file is no
40+// workflows, so the same tree id that is no workflows as a gitlink and becomes
41+// workflows as a directory is a change, which the object id alone would not
42+// tell (`cat-file` mode and type of "<commit>:.github/workflows").
43+//
44+// The comparison never walks the trees recursively: the trees of a push are the
45+// pusher's, and a recursive diff of trees that share subtrees fans out into
46+// more paths than the push has bytes (git builds the whole diff before it
47+// prints a line, so reading it as a stream would not help). The shallow diff
48+// that names the path reads one directory, as large as its tree object, as a
49+// stream, up to its first path, and git is stopped and reaped then; a path
50+// longer than maxWorkflowPath fails closed.
51+//
52+// Both ends are peeled first (`cat-file --batch-check` on `<oid>^{}`): a
53+// commit or a tree is compared, a blob (a tag may point to one) holds no
54+// workflows; a missing or malformed object fails closed. Git reads the objects
55+// of a push still in quarantine through the environment of the hook
56+// (ports.PushObjects), accepted only inside the repository's objects directory.
57+//
58+//hexcheck:ignore suspicious-business-logic-in-adapter reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's
59+func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
60+ dir, err := g.repoDir(repo)
61+ if err != nil {
62+ return "", err
63+ }
64+ extra, err := quarantineEnv(dir, objects)
65+ if err != nil {
66+ return "", err
67+ }
68+ kept := make([]domain.RefUpdate, 0, len(updates))
69+ for _, up := range updates {
70+ if !up.IsDelete() {
71+ kept = append(kept, up)
72+ }
73+ }
74+ if len(kept) == 0 {
75+ return "", nil
76+ }
77+ types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)")
78+ if err != nil {
79+ return "", err
80+ }
81+ trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)")
82+ if err != nil {
83+ return "", err
84+ }
85+ for i := range kept {
86+ if types[2*i+1] == "blob" {
87+ continue // a blob holds no workflow, whatever it replaces
88+ }
89+ oldW, newW := workflowsOf(types[2*i], trees[2*i]), workflowsOf(types[2*i+1], trees[2*i+1])
90+ if oldW == newW || newW == "" {
91+ continue // the same directory, or none left
92+ }
93+ return g.firstChanged(ctx, dir, extra, oldW, newW)
94+ }
95+ return "", nil
96+}
97+
98+// workflowsOf is the id of the tree that is the .github/workflows of a commit
99+// or a tree, "" when there is none: the answer for "<it>:.github/workflows" must
100+// be a tree of mode 040000, and the object peeled to a tree-ish, not a blob.
101+func workflowsOf(objType, answer string) string {
102+ if objType == "blob" {
103+ return ""
104+ }
105+ mode, rest, _ := strings.Cut(answer, " ")
106+ id, typ, _ := strings.Cut(rest, " ")
107+ if mode != "040000" || typ != "tree" {
108+ return ""
109+ }
110+ return id
111+}
112+
113+// firstChanged names the first top-level entry of .github/workflows that
114+// differs between two trees (oldID may be "": none, the empty tree).
115+func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) {
116+ from := oldID
117+ if from == "" {
118+ from = emptyTree
119+ }
120+ to := newID
121+ args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to)
122+ var name string
123+ err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
124+ n, found, err := firstPath(r)
125+ name = n
126+ if err != nil || !found {
127+ return err // no name: git's exit status is checked, so a failure refuses
128+ }
129+ return errStop // the first name is all that is needed: stop git
130+ })
131+ if err != nil {
132+ return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err)
133+ }
134+ if name == "" {
135+ return strings.TrimSuffix(workflowsDir, "/"), nil
136+ }
137+ return workflowsDir + name, nil
138+}
139+
140+// firstPath reads the first NUL-terminated path of r; found is false at the end
141+// of the output without one. A path longer than maxWorkflowPath is an error.
142+func firstPath(r *bufio.Reader) (path string, found bool, err error) {
143+ var b []byte
144+ for {
145+ c, err := r.ReadByte()
146+ switch {
147+ case err != nil:
148+ return "", false, nil // the end of the output; a failing exit status is reported by pipe
149+ case c == 0:
150+ return string(b), true, nil
151+ case len(b) >= maxWorkflowPath:
152+ return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath)
153+ }
154+ b = append(b, c)
155+ }
156+}
157+
158+// batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the
159+// old (unless the ref is created) and the new end of each update and returns
160+// the answers in that order, two per update, "" for the old end of a created
161+// ref. With suffix "^{}" an object that is missing, malformed or neither a
162+// commit, a tree nor a blob is an error (the push is refused); with any other
163+// suffix an answer "<input> missing" is kept as the answer.
164+func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) {
165+ var in strings.Builder
166+ want := 0
167+ for _, up := range ups {
168+ for _, oid := range []string{up.Old, up.New} {
169+ if oid == domain.ZeroOID {
170+ continue
171+ }
172+ if _, err := domain.ParseObjectID(oid); err != nil {
173+ return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err)
174+ }
175+ in.WriteString(oid + suffix + "\n")
176+ want++
177+ }
178+ }
179+ got := make([]string, 0, want)
180+ args := readArgs(dir, "cat-file", "--batch-check="+format)
181+ err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error {
182+ for range want {
183+ line, err := r.ReadSlice('\n')
184+ if err != nil {
185+ if errors.Is(err, bufio.ErrBufferFull) {
186+ return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid)
187+ }
188+ return fmt.Errorf("read object answers: %w", err)
189+ }
190+ got = append(got, strings.TrimSuffix(string(line), "\n"))
191+ }
192+ return nil
193+ })
194+ if err != nil {
195+ return nil, fmt.Errorf("objects of a push: %w", err)
196+ }
197+ out := make([]string, 0, 2*len(ups))
198+ i := 0
199+ for _, up := range ups {
200+ for _, oid := range []string{up.Old, up.New} {
201+ if oid == domain.ZeroOID {
202+ out = append(out, "")
203+ continue
204+ }
205+ if suffix == "^{}" {
206+ switch got[i] {
207+ case "commit", "tree", "blob":
208+ default: // "<input> missing", or a type that cannot be compared
209+ return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid)
210+ }
211+ }
212+ out = append(out, got[i])
213+ i++
214+ }
215+ }
216+ return out, nil
217+}
218+
219+// quarantineEnv returns the environment that makes git read the objects of a
220+// push in quarantine. Each directory must exist and, once symbolic links are
221+// resolved, be inside the objects directory of the repository dir (itself
222+// resolved): a hook request cannot send git to read anything else. The resolved
223+// paths are the ones given to git.
224+func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) {
225+ if o.Dir == "" && o.Alternates == "" {
226+ return nil, nil
227+ }
228+ root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects"))
229+ if err != nil {
230+ return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err)
231+ }
232+ inside := func(p string) (string, bool) {
233+ if !filepath.IsAbs(p) {
234+ return "", false
235+ }
236+ real, err := filepath.EvalSymlinks(p)
237+ if err != nil {
238+ return "", false
239+ }
240+ rel, err := filepath.Rel(root, real)
241+ return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
242+ }
243+ dir, ok := inside(o.Dir)
244+ if o.Dir == "" || !ok {
245+ return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid)
246+ }
247+ env := []string{"GIT_OBJECT_DIRECTORY=" + dir}
248+ if o.Alternates != "" {
249+ var alts []string
250+ for _, a := range filepath.SplitList(o.Alternates) {
251+ real, ok := inside(a)
252+ if !ok {
253+ return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid)
254+ }
255+ alts = append(alts, real)
256+ }
257+ env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator)))
258+ }
259+ return env, nil
260+}
@@ -0,0 +1,260 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "path/filepath"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+var _ ports.WorkflowGuard = (*Git)(nil)
18+
19+// workflowsDir is where workflow files live, as a literal pathspec directory.
20+const workflowsDir = ".github/workflows/"
21+
22+// maxWorkflowPath bounds the one path the guard reads from git: a longer one
23+// fails closed (the push is refused), whatever the trees hold.
24+const maxWorkflowPath = 4096
25+
26+// ChangedWorkflow tells whether a push creates or changes a file under
27+// .github/workflows/. For each update that does not delete its ref, the
28+// directory .github/workflows of the old commit (none for a new ref) is
29+// compared with the one of the new commit: the same tree id is the same
30+// content, whatever it holds, so nothing is read; a different one, or one that
31+// appears, is a change, and the first top-level entry that differs names it. A
32+// new ref is thus compared with nothing: any workflow it carries counts, as on
33+// GitHub. A directory that disappears is not a change (as a deleted ref is
34+// not).
35+//
36+// What counts as the directory is exactly what the workflow loader
37+// (CI.workflowFiles, on both git readers) lists: .github and workflows both
38+// entries of mode 040000 and type tree. A path through a gitlink (160000), a
39+// symbolic link (120000, which the loader does not follow) or a file is no
40+// workflows, so the same tree id that is no workflows as a gitlink and becomes
41+// workflows as a directory is a change, which the object id alone would not
42+// tell (`cat-file` mode and type of "<commit>:.github/workflows").
43+//
44+// The comparison never walks the trees recursively: the trees of a push are the
45+// pusher's, and a recursive diff of trees that share subtrees fans out into
46+// more paths than the push has bytes (git builds the whole diff before it
47+// prints a line, so reading it as a stream would not help). The shallow diff
48+// that names the path reads one directory, as large as its tree object, as a
49+// stream, up to its first path, and git is stopped and reaped then; a path
50+// longer than maxWorkflowPath fails closed.
51+//
52+// Both ends are peeled first (`cat-file --batch-check` on `<oid>^{}`): a
53+// commit or a tree is compared, a blob (a tag may point to one) holds no
54+// workflows; a missing or malformed object fails closed. Git reads the objects
55+// of a push still in quarantine through the environment of the hook
56+// (ports.PushObjects), accepted only inside the repository's objects directory.
57+//
58+//hexcheck:ignore suspicious-business-logic-in-adapter reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's
59+func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
60+ dir, err := g.repoDir(repo)
61+ if err != nil {
62+ return "", err
63+ }
64+ extra, err := quarantineEnv(dir, objects)
65+ if err != nil {
66+ return "", err
67+ }
68+ kept := make([]domain.RefUpdate, 0, len(updates))
69+ for _, up := range updates {
70+ if !up.IsDelete() {
71+ kept = append(kept, up)
72+ }
73+ }
74+ if len(kept) == 0 {
75+ return "", nil
76+ }
77+ types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)")
78+ if err != nil {
79+ return "", err
80+ }
81+ trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)")
82+ if err != nil {
83+ return "", err
84+ }
85+ for i := range kept {
86+ if types[2*i+1] == "blob" {
87+ continue // a blob holds no workflow, whatever it replaces
88+ }
89+ oldW, newW := workflowsOf(types[2*i], trees[2*i]), workflowsOf(types[2*i+1], trees[2*i+1])
90+ if oldW == newW || newW == "" {
91+ continue // the same directory, or none left
92+ }
93+ return g.firstChanged(ctx, dir, extra, oldW, newW)
94+ }
95+ return "", nil
96+}
97+
98+// workflowsOf is the id of the tree that is the .github/workflows of a commit
99+// or a tree, "" when there is none: the answer for "<it>:.github/workflows" must
100+// be a tree of mode 040000, and the object peeled to a tree-ish, not a blob.
101+func workflowsOf(objType, answer string) string {
102+ if objType == "blob" {
103+ return ""
104+ }
105+ mode, rest, _ := strings.Cut(answer, " ")
106+ id, typ, _ := strings.Cut(rest, " ")
107+ if mode != "040000" || typ != "tree" {
108+ return ""
109+ }
110+ return id
111+}
112+
113+// firstChanged names the first top-level entry of .github/workflows that
114+// differs between two trees (oldID may be "": none, the empty tree).
115+func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) {
116+ from := oldID
117+ if from == "" {
118+ from = emptyTree
119+ }
120+ to := newID
121+ args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to)
122+ var name string
123+ err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
124+ n, found, err := firstPath(r)
125+ name = n
126+ if err != nil || !found {
127+ return err // no name: git's exit status is checked, so a failure refuses
128+ }
129+ return errStop // the first name is all that is needed: stop git
130+ })
131+ if err != nil {
132+ return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err)
133+ }
134+ if name == "" {
135+ return strings.TrimSuffix(workflowsDir, "/"), nil
136+ }
137+ return workflowsDir + name, nil
138+}
139+
140+// firstPath reads the first NUL-terminated path of r; found is false at the end
141+// of the output without one. A path longer than maxWorkflowPath is an error.
142+func firstPath(r *bufio.Reader) (path string, found bool, err error) {
143+ var b []byte
144+ for {
145+ c, err := r.ReadByte()
146+ switch {
147+ case err != nil:
148+ return "", false, nil // the end of the output; a failing exit status is reported by pipe
149+ case c == 0:
150+ return string(b), true, nil
151+ case len(b) >= maxWorkflowPath:
152+ return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath)
153+ }
154+ b = append(b, c)
155+ }
156+}
157+
158+// batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the
159+// old (unless the ref is created) and the new end of each update and returns
160+// the answers in that order, two per update, "" for the old end of a created
161+// ref. With suffix "^{}" an object that is missing, malformed or neither a
162+// commit, a tree nor a blob is an error (the push is refused); with any other
163+// suffix an answer "<input> missing" is kept as the answer.
164+func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) {
165+ var in strings.Builder
166+ want := 0
167+ for _, up := range ups {
168+ for _, oid := range []string{up.Old, up.New} {
169+ if oid == domain.ZeroOID {
170+ continue
171+ }
172+ if _, err := domain.ParseObjectID(oid); err != nil {
173+ return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err)
174+ }
175+ in.WriteString(oid + suffix + "\n")
176+ want++
177+ }
178+ }
179+ got := make([]string, 0, want)
180+ args := readArgs(dir, "cat-file", "--batch-check="+format)
181+ err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error {
182+ for range want {
183+ line, err := r.ReadSlice('\n')
184+ if err != nil {
185+ if errors.Is(err, bufio.ErrBufferFull) {
186+ return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid)
187+ }
188+ return fmt.Errorf("read object answers: %w", err)
189+ }
190+ got = append(got, strings.TrimSuffix(string(line), "\n"))
191+ }
192+ return nil
193+ })
194+ if err != nil {
195+ return nil, fmt.Errorf("objects of a push: %w", err)
196+ }
197+ out := make([]string, 0, 2*len(ups))
198+ i := 0
199+ for _, up := range ups {
200+ for _, oid := range []string{up.Old, up.New} {
201+ if oid == domain.ZeroOID {
202+ out = append(out, "")
203+ continue
204+ }
205+ if suffix == "^{}" {
206+ switch got[i] {
207+ case "commit", "tree", "blob":
208+ default: // "<input> missing", or a type that cannot be compared
209+ return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid)
210+ }
211+ }
212+ out = append(out, got[i])
213+ i++
214+ }
215+ }
216+ return out, nil
217+}
218+
219+// quarantineEnv returns the environment that makes git read the objects of a
220+// push in quarantine. Each directory must exist and, once symbolic links are
221+// resolved, be inside the objects directory of the repository dir (itself
222+// resolved): a hook request cannot send git to read anything else. The resolved
223+// paths are the ones given to git.
224+func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) {
225+ if o.Dir == "" && o.Alternates == "" {
226+ return nil, nil
227+ }
228+ root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects"))
229+ if err != nil {
230+ return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err)
231+ }
232+ inside := func(p string) (string, bool) {
233+ if !filepath.IsAbs(p) {
234+ return "", false
235+ }
236+ real, err := filepath.EvalSymlinks(p)
237+ if err != nil {
238+ return "", false
239+ }
240+ rel, err := filepath.Rel(root, real)
241+ return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
242+ }
243+ dir, ok := inside(o.Dir)
244+ if o.Dir == "" || !ok {
245+ return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid)
246+ }
247+ env := []string{"GIT_OBJECT_DIRECTORY=" + dir}
248+ if o.Alternates != "" {
249+ var alts []string
250+ for _, a := range filepath.SplitList(o.Alternates) {
251+ real, ok := inside(a)
252+ if !ok {
253+ return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid)
254+ }
255+ alts = append(alts, real)
256+ }
257+ env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator)))
258+ }
259+ return env, nil
260+}
A internal/adapters/gitcli/workflow_guard_test.go +375 -0
@@ -0,0 +1,375 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "encoding/hex"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "runtime"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/adapters/gitcli"
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/readertest"
24+)
25+
26+func TestChangedWorkflow(t *testing.T) {
27+ g, data := newGit(t)
28+ ctx := context.Background()
29+ f := readertest.NewFixture(t, data, "proj")
30+ t.Cleanup(func() { _ = g.Close(ctx) })
31+ f.Write("README", "r\n")
32+ base := f.Commit("base")
33+ f.Write(".github/workflows/ci.yml", "on: push\n")
34+ withWF := f.Commit("add workflow")
35+ f.Write("docs/x.md", "x\n")
36+ docs := f.Commit("docs")
37+ f.Write(".github/workflows/ci.yml", "on: [push]\n")
38+ edited := f.Commit("edit workflow")
39+ f.Write(".github/workflows/other.yml", "on: push\n")
40+ two := f.Commit("a second workflow")
41+ f.Remove(".github/workflows/ci.yml")
42+ oneLeft := f.Commit("remove one of two")
43+ f.Remove(".github/workflows/other.yml")
44+ removed := f.Commit("remove the last workflow")
45+ f.Write(".github/workflows-notes/a.yml", "x\n")
46+ lookalike := f.Commit("a look-alike directory")
47+ oid := func(s string) string { return s[:40] }
48+
49+ up := func(old, new string) domain.RefUpdate {
50+ return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
51+ }
52+ changed := func(ups ...domain.RefUpdate) string {
53+ p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
54+ require.NoError(t, err)
55+ return p
56+ }
57+
58+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(withWF))), "a file created")
59+ assert.Equal(t, "", changed(up(oid(withWF), oid(docs))), "another file changed")
60+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(docs), oid(edited))), "a file modified")
61+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(two), oid(oneLeft))), "a file removed from a directory that stays is a change too")
62+ assert.Equal(t, "", changed(up(oid(oneLeft), oid(removed))), "the directory removed with its last file is allowed")
63+ assert.Equal(t, "", changed(up(oid(removed), oid(lookalike))), "only .github/workflows/ counts")
64+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(domain.ZeroOID, oid(withWF))), "a new ref carries the workflows of its commit")
65+ assert.Equal(t, "", changed(up(domain.ZeroOID, oid(base))), "a new ref without workflows")
66+ assert.Equal(t, "", changed(up(oid(withWF), domain.ZeroOID)), "a deleted ref changes nothing")
67+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")
68+ assert.Equal(t, "", changed(), "no update")
69+
70+ _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
71+ assert.Error(t, err, "an object git does not have is a failure, not a pass")
72+}
73+
74+func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
75+ g, data := newGit(t)
76+ ctx := context.Background()
77+ f := readertest.NewFixture(t, data, "proj")
78+ t.Cleanup(func() { _ = g.Close(ctx) })
79+ f.Write("README", "r\n")
80+ f.Commit("base")
81+ objects := filepath.Join(f.Bare, "objects")
82+ up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
83+
84+ for name, o := range map[string]ports.PushObjects{
85+ "outside the repository": {Dir: filepath.Join(data, "elsewhere")},
86+ "a relative path": {Dir: "objects/incoming"},
87+ "parent of the objects": {Dir: f.Bare},
88+ "a dotdot out of objects": {Dir: objects + "/../../other.git/objects"},
89+ "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},
90+ "alternates without a dir": {Alternates: objects},
91+ } {
92+ _, err := g.ChangedWorkflow(ctx, f.Name, up, o)
93+ assert.ErrorIs(t, err, domain.ErrInvalid, name)
94+ }
95+ // An existing directory inside the repository is accepted as a location
96+ // (the object is missing: a refusal of the object, not of the path).
97+ require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))
98+ _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects})
99+ require.Error(t, err)
100+ assert.NotContains(t, err.Error(), "outside the repository")
101+ // One that does not exist is refused.
102+ _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"})
103+ assert.ErrorIs(t, err, domain.ErrInvalid)
104+}
105+
106+// A symbolic link in the way cannot take the objects of a push out of the
107+// repository, and a data directory reached through links still works.
108+func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
109+ g, data := newGit(t)
110+ ctx := context.Background()
111+ f := readertest.NewFixture(t, data, "proj")
112+ t.Cleanup(func() { _ = g.Close(ctx) })
113+ f.Write("README", "r\n")
114+ f.Commit("base")
115+ up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
116+ objects := filepath.Join(f.Bare, "objects")
117+
118+ // Inside objects/, a link to elsewhere is outside once resolved.
119+ elsewhere := t.TempDir()
120+ require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))
121+ _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")})
122+ assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
123+ _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")})
124+ assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
125+
126+ // A link to somewhere inside objects/ is fine.
127+ require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
128+ require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))
129+ _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")})
130+ require.Error(t, err)
131+ assert.NotContains(t, err.Error(), "outside the repository")
132+}
133+
134+func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
135+ _, data := newGit(t)
136+ ctx := context.Background()
137+ f := readertest.NewFixture(t, data, "proj")
138+ f.Write("README", "r\n")
139+ base := f.Commit("base")
140+ f.Write(".github/workflows/ci.yml", "on: push\n")
141+ wf := f.Commit("workflow")
142+ // The server reaches the same data directory through a link, while git
143+ // (the hook) reports the real path: both must be accepted as inside.
144+ parent := t.TempDir()
145+ link := filepath.Join(parent, "data")
146+ require.NoError(t, os.Symlink(data, link))
147+ bin, err := exec.LookPath("git")
148+ require.NoError(t, err)
149+ g2 := gitcli.New(gitcli.Config{Bin: bin, DataDir: link, Path: os.Getenv("PATH")})
150+ t.Cleanup(func() { _ = g2.Close(ctx) })
151+ q := filepath.Join(f.Bare, "objects", "incoming-9")
152+ require.NoError(t, os.Mkdir(q, 0o700))
153+ for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {
154+ p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
155+ ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")})
156+ require.NoError(t, err, dir)
157+ assert.Equal(t, ".github/workflows/ci.yml", p, dir)
158+ }
159+}
160+
161+// treeOf writes a tree of the given entries ("mode type sha\tname") and returns it.
162+func treeOf(t *testing.T, f *readertest.Fixture, entries ...string) string {
163+ t.Helper()
164+ cmd := exec.Command("git", "--git-dir="+f.Bare, "mktree")
165+ cmd.Stdin = strings.NewReader(strings.Join(entries, "\n") + "\n")
166+ out, err := cmd.CombinedOutput()
167+ require.NoError(t, err, string(out))
168+ return strings.TrimSpace(string(out))
169+}
170+
171+func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...string) string {
172+ t.Helper()
173+ args := []string{"--git-dir=" + f.Bare, "commit-tree", tree, "-m", "c"}
174+ for _, p := range parents {
175+ args = append(args, "-p", p)
176+ }
177+ cmd := exec.Command("git", args...)
178+ cmd.Env = []string{"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@b", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@b"}
179+ out, err := cmd.CombinedOutput()
180+ require.NoError(t, err, string(out))
181+ return strings.TrimSpace(string(out))
182+}
183+
184+// withWorkflows is a commit whose .github/workflows is the tree given.
185+func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string {
186+ t.Helper()
187+ gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows")
188+ return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github"))
189+}
190+
191+// The trees of a push are the pusher's: a workflows directory that fans out
192+// into ten million paths is answered with its first path, at once, by a git
193+// that is stopped, without the diff ever being held in memory.
194+func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
195+ g, data := newGit(t)
196+ ctx := context.Background()
197+ f := readertest.NewFixture(t, data, "proj")
198+ t.Cleanup(func() { _ = g.Close(ctx) })
199+ blob := f.WriteObject("blob", "x\n")
200+ level := treeOf(t, f, "100644 blob "+blob+"\tf")
201+ for range 7 { // ten entries per level, all the same subtree: 10^7 paths
202+ var entries []string
203+ for i := range 10 {
204+ entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i))
205+ }
206+ level = treeOf(t, f, entries...)
207+ }
208+ amp := withWorkflows(t, f, level)
209+
210+ var before, after runtime.MemStats
211+ runtime.GC()
212+ runtime.ReadMemStats(&before)
213+ start := time.Now()
214+ p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{})
215+ took := time.Since(start)
216+ runtime.ReadMemStats(&after)
217+ require.NoError(t, err)
218+ assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)
219+ assert.Less(t, took, 2*time.Second, "answered from the first path")
220+ assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
221+ // And no git is left behind.
222+ out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput()
223+ assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped")
224+}
225+
226+// A path longer than the bound fails closed: the push is refused, not read on.
227+func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) {
228+ g, data := newGit(t)
229+ ctx := context.Background()
230+ f := readertest.NewFixture(t, data, "proj")
231+ t.Cleanup(func() { _ = g.Close(ctx) })
232+ level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
233+ level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
234+ _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{})
235+ assert.ErrorIs(t, err, domain.ErrInvalid)
236+}
237+
238+// A tag may point to a blob, a tree or a commit; only the commits and trees can
239+// hold a workflow, and an object that is not there refuses the push.
240+func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) {
241+ g, data := newGit(t)
242+ ctx := context.Background()
243+ f := readertest.NewFixture(t, data, "proj")
244+ t.Cleanup(func() { _ = g.Close(ctx) })
245+ f.Write("README", "r\n")
246+ plain := f.Commit("plain")
247+ f.Write(".github/workflows/ci.yml", "on: push\n")
248+ wf := f.Commit("workflow")
249+ blob := f.WriteObject("blob", "hello\n")
250+ annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n")
251+ annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")
252+ rootTree := f.Git("rev-parse", wf+"^{tree}")
253+ ref := func(old, new string) []domain.RefUpdate {
254+ return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}}
255+ }
256+ changed := func(ups []domain.RefUpdate) (string, error) {
257+ return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
258+ }
259+ for name, ups := range map[string][]domain.RefUpdate{
260+ "create a lightweight tag on a blob": ref(domain.ZeroOID, blob),
261+ "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob),
262+ "a tag from a blob to another blob": ref(blob, annotatedBlob),
263+ "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob),
264+ } {
265+ p, err := changed(ups)
266+ require.NoError(t, err, name)
267+ assert.Empty(t, p, name)
268+ }
269+ for name, ups := range map[string][]domain.RefUpdate{
270+ "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),
271+ "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
272+ "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),
273+ "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),
274+ "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),
275+ } {
276+ p, err := changed(ups)
277+ require.NoError(t, err, name)
278+ assert.Equal(t, ".github/workflows/ci.yml", p, name)
279+ }
280+ for name, ups := range map[string][]domain.RefUpdate{
281+ "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),
282+ "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),
283+ "a malformed object": ref(domain.ZeroOID, "ZZZZ"),
284+ } {
285+ _, err := changed(ups)
286+ assert.Error(t, err, name)
287+ }
288+}
289+
290+// rawTree writes a tree object by hand: entries are "mode name hexid", in the
291+// order given (git sorts them; the callers give them sorted).
292+func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
293+ t.Helper()
294+ var b strings.Builder
295+ for _, e := range entries {
296+ raw, err := hex.DecodeString(e[2])
297+ require.NoError(t, err)
298+ b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000
299+ b.Write(raw)
300+ }
301+ return f.WriteObject("tree", b.String())
302+}
303+
304+// What counts as .github/workflows is what the loader lists: a directory of
305+// mode 040000 below a directory .github of mode 040000. The same tree id as a
306+// gitlink, a symbolic link or a file is nothing, and turning it into a
307+// directory activates it; the object id alone does not tell.
308+func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {
309+ g, data := newGit(t)
310+ ctx := context.Background()
311+ f := readertest.NewFixture(t, data, "proj")
312+ t.Cleanup(func() { _ = g.Close(ctx) })
313+ f.Write("README", "r\n")
314+ f.Commit("base")
315+ yml := f.WriteObject("blob", "on: push\n")
316+ wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows
317+ link := f.WriteObject("blob", ".github/other")
318+ file := f.WriteObject("blob", "not a directory\n")
319+ readme := f.WriteObject("blob", "r\n")
320+
321+ // A commit whose .github/workflows has the mode and id given.
322+ withWorkflows := func(mode, id string) string {
323+ gh := rawTree(t, f, [3]string{mode, "workflows", id})
324+ return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))
325+ }
326+ // A commit whose .github has the mode and id given.
327+ withGithub := func(mode, id string) string {
328+ return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))
329+ }
330+ asDir := withWorkflows("040000", wfTree)
331+ asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule
332+ asSymlink := withWorkflows("120000", link)
333+ asFile := withWorkflows("100644", file)
334+ none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))
335+ ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})
336+ githubDir := withGithub("040000", ghDir)
337+ githubGitlink := withGithub("160000", ghDir) // the same id, a submodule
338+ githubFile := withGithub("100644", file)
339+ githubLink := withGithub("120000", link)
340+
341+ for name, c := range map[string]struct {
342+ old, new string
343+ want string
344+ }{
345+ "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"},
346+ "a directory to a gitlink removes it": {asDir, asGitlink, ""},
347+ "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"},
348+ "a directory to a symlink removes it": {asDir, asSymlink, ""},
349+ "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"},
350+ "a directory to a file removes it": {asDir, asFile, ""},
351+ "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"},
352+ "a directory to nothing": {asDir, none, ""},
353+ "the same gitlink": {asGitlink, asGitlink, ""},
354+ "gitlink to symlink": {asGitlink, asSymlink, ""},
355+ ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"},
356+ ".github a directory to a gitlink": {githubDir, githubGitlink, ""},
357+ ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"},
358+ ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"},
359+ ".github a directory to a symlink": {githubDir, githubLink, ""},
360+ "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""},
361+ "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""},
362+ "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""},
363+ "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"},
364+ } {
365+ p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{})
366+ require.NoError(t, err, name)
367+ assert.Equal(t, c.want, p, name)
368+ }
369+
370+ // A tag that peels to the commit is judged the same way.
371+ tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
372+ p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{})
373+ require.NoError(t, err)
374+ assert.Equal(t, ".github/workflows/ci.yml", p)
375+}
@@ -0,0 +1,375 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "encoding/hex"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "runtime"
13+ "strings"
14+ "testing"
15+ "time"
16+
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/adapters/gitcli"
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/readertest"
24+)
25+
26+func TestChangedWorkflow(t *testing.T) {
27+ g, data := newGit(t)
28+ ctx := context.Background()
29+ f := readertest.NewFixture(t, data, "proj")
30+ t.Cleanup(func() { _ = g.Close(ctx) })
31+ f.Write("README", "r\n")
32+ base := f.Commit("base")
33+ f.Write(".github/workflows/ci.yml", "on: push\n")
34+ withWF := f.Commit("add workflow")
35+ f.Write("docs/x.md", "x\n")
36+ docs := f.Commit("docs")
37+ f.Write(".github/workflows/ci.yml", "on: [push]\n")
38+ edited := f.Commit("edit workflow")
39+ f.Write(".github/workflows/other.yml", "on: push\n")
40+ two := f.Commit("a second workflow")
41+ f.Remove(".github/workflows/ci.yml")
42+ oneLeft := f.Commit("remove one of two")
43+ f.Remove(".github/workflows/other.yml")
44+ removed := f.Commit("remove the last workflow")
45+ f.Write(".github/workflows-notes/a.yml", "x\n")
46+ lookalike := f.Commit("a look-alike directory")
47+ oid := func(s string) string { return s[:40] }
48+
49+ up := func(old, new string) domain.RefUpdate {
50+ return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
51+ }
52+ changed := func(ups ...domain.RefUpdate) string {
53+ p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
54+ require.NoError(t, err)
55+ return p
56+ }
57+
58+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(withWF))), "a file created")
59+ assert.Equal(t, "", changed(up(oid(withWF), oid(docs))), "another file changed")
60+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(docs), oid(edited))), "a file modified")
61+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(two), oid(oneLeft))), "a file removed from a directory that stays is a change too")
62+ assert.Equal(t, "", changed(up(oid(oneLeft), oid(removed))), "the directory removed with its last file is allowed")
63+ assert.Equal(t, "", changed(up(oid(removed), oid(lookalike))), "only .github/workflows/ counts")
64+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(domain.ZeroOID, oid(withWF))), "a new ref carries the workflows of its commit")
65+ assert.Equal(t, "", changed(up(domain.ZeroOID, oid(base))), "a new ref without workflows")
66+ assert.Equal(t, "", changed(up(oid(withWF), domain.ZeroOID)), "a deleted ref changes nothing")
67+ assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")
68+ assert.Equal(t, "", changed(), "no update")
69+
70+ _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
71+ assert.Error(t, err, "an object git does not have is a failure, not a pass")
72+}
73+
74+func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
75+ g, data := newGit(t)
76+ ctx := context.Background()
77+ f := readertest.NewFixture(t, data, "proj")
78+ t.Cleanup(func() { _ = g.Close(ctx) })
79+ f.Write("README", "r\n")
80+ f.Commit("base")
81+ objects := filepath.Join(f.Bare, "objects")
82+ up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
83+
84+ for name, o := range map[string]ports.PushObjects{
85+ "outside the repository": {Dir: filepath.Join(data, "elsewhere")},
86+ "a relative path": {Dir: "objects/incoming"},
87+ "parent of the objects": {Dir: f.Bare},
88+ "a dotdot out of objects": {Dir: objects + "/../../other.git/objects"},
89+ "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},
90+ "alternates without a dir": {Alternates: objects},
91+ } {
92+ _, err := g.ChangedWorkflow(ctx, f.Name, up, o)
93+ assert.ErrorIs(t, err, domain.ErrInvalid, name)
94+ }
95+ // An existing directory inside the repository is accepted as a location
96+ // (the object is missing: a refusal of the object, not of the path).
97+ require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))
98+ _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects})
99+ require.Error(t, err)
100+ assert.NotContains(t, err.Error(), "outside the repository")
101+ // One that does not exist is refused.
102+ _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"})
103+ assert.ErrorIs(t, err, domain.ErrInvalid)
104+}
105+
106+// A symbolic link in the way cannot take the objects of a push out of the
107+// repository, and a data directory reached through links still works.
108+func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
109+ g, data := newGit(t)
110+ ctx := context.Background()
111+ f := readertest.NewFixture(t, data, "proj")
112+ t.Cleanup(func() { _ = g.Close(ctx) })
113+ f.Write("README", "r\n")
114+ f.Commit("base")
115+ up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
116+ objects := filepath.Join(f.Bare, "objects")
117+
118+ // Inside objects/, a link to elsewhere is outside once resolved.
119+ elsewhere := t.TempDir()
120+ require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))
121+ _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")})
122+ assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
123+ _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")})
124+ assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
125+
126+ // A link to somewhere inside objects/ is fine.
127+ require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
128+ require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))
129+ _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")})
130+ require.Error(t, err)
131+ assert.NotContains(t, err.Error(), "outside the repository")
132+}
133+
134+func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
135+ _, data := newGit(t)
136+ ctx := context.Background()
137+ f := readertest.NewFixture(t, data, "proj")
138+ f.Write("README", "r\n")
139+ base := f.Commit("base")
140+ f.Write(".github/workflows/ci.yml", "on: push\n")
141+ wf := f.Commit("workflow")
142+ // The server reaches the same data directory through a link, while git
143+ // (the hook) reports the real path: both must be accepted as inside.
144+ parent := t.TempDir()
145+ link := filepath.Join(parent, "data")
146+ require.NoError(t, os.Symlink(data, link))
147+ bin, err := exec.LookPath("git")
148+ require.NoError(t, err)
149+ g2 := gitcli.New(gitcli.Config{Bin: bin, DataDir: link, Path: os.Getenv("PATH")})
150+ t.Cleanup(func() { _ = g2.Close(ctx) })
151+ q := filepath.Join(f.Bare, "objects", "incoming-9")
152+ require.NoError(t, os.Mkdir(q, 0o700))
153+ for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {
154+ p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
155+ ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")})
156+ require.NoError(t, err, dir)
157+ assert.Equal(t, ".github/workflows/ci.yml", p, dir)
158+ }
159+}
160+
161+// treeOf writes a tree of the given entries ("mode type sha\tname") and returns it.
162+func treeOf(t *testing.T, f *readertest.Fixture, entries ...string) string {
163+ t.Helper()
164+ cmd := exec.Command("git", "--git-dir="+f.Bare, "mktree")
165+ cmd.Stdin = strings.NewReader(strings.Join(entries, "\n") + "\n")
166+ out, err := cmd.CombinedOutput()
167+ require.NoError(t, err, string(out))
168+ return strings.TrimSpace(string(out))
169+}
170+
171+func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...string) string {
172+ t.Helper()
173+ args := []string{"--git-dir=" + f.Bare, "commit-tree", tree, "-m", "c"}
174+ for _, p := range parents {
175+ args = append(args, "-p", p)
176+ }
177+ cmd := exec.Command("git", args...)
178+ cmd.Env = []string{"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@b", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@b"}
179+ out, err := cmd.CombinedOutput()
180+ require.NoError(t, err, string(out))
181+ return strings.TrimSpace(string(out))
182+}
183+
184+// withWorkflows is a commit whose .github/workflows is the tree given.
185+func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string {
186+ t.Helper()
187+ gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows")
188+ return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github"))
189+}
190+
191+// The trees of a push are the pusher's: a workflows directory that fans out
192+// into ten million paths is answered with its first path, at once, by a git
193+// that is stopped, without the diff ever being held in memory.
194+func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
195+ g, data := newGit(t)
196+ ctx := context.Background()
197+ f := readertest.NewFixture(t, data, "proj")
198+ t.Cleanup(func() { _ = g.Close(ctx) })
199+ blob := f.WriteObject("blob", "x\n")
200+ level := treeOf(t, f, "100644 blob "+blob+"\tf")
201+ for range 7 { // ten entries per level, all the same subtree: 10^7 paths
202+ var entries []string
203+ for i := range 10 {
204+ entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i))
205+ }
206+ level = treeOf(t, f, entries...)
207+ }
208+ amp := withWorkflows(t, f, level)
209+
210+ var before, after runtime.MemStats
211+ runtime.GC()
212+ runtime.ReadMemStats(&before)
213+ start := time.Now()
214+ p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{})
215+ took := time.Since(start)
216+ runtime.ReadMemStats(&after)
217+ require.NoError(t, err)
218+ assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)
219+ assert.Less(t, took, 2*time.Second, "answered from the first path")
220+ assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
221+ // And no git is left behind.
222+ out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput()
223+ assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped")
224+}
225+
226+// A path longer than the bound fails closed: the push is refused, not read on.
227+func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) {
228+ g, data := newGit(t)
229+ ctx := context.Background()
230+ f := readertest.NewFixture(t, data, "proj")
231+ t.Cleanup(func() { _ = g.Close(ctx) })
232+ level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
233+ level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
234+ _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{})
235+ assert.ErrorIs(t, err, domain.ErrInvalid)
236+}
237+
238+// A tag may point to a blob, a tree or a commit; only the commits and trees can
239+// hold a workflow, and an object that is not there refuses the push.
240+func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) {
241+ g, data := newGit(t)
242+ ctx := context.Background()
243+ f := readertest.NewFixture(t, data, "proj")
244+ t.Cleanup(func() { _ = g.Close(ctx) })
245+ f.Write("README", "r\n")
246+ plain := f.Commit("plain")
247+ f.Write(".github/workflows/ci.yml", "on: push\n")
248+ wf := f.Commit("workflow")
249+ blob := f.WriteObject("blob", "hello\n")
250+ annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n")
251+ annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")
252+ rootTree := f.Git("rev-parse", wf+"^{tree}")
253+ ref := func(old, new string) []domain.RefUpdate {
254+ return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}}
255+ }
256+ changed := func(ups []domain.RefUpdate) (string, error) {
257+ return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
258+ }
259+ for name, ups := range map[string][]domain.RefUpdate{
260+ "create a lightweight tag on a blob": ref(domain.ZeroOID, blob),
261+ "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob),
262+ "a tag from a blob to another blob": ref(blob, annotatedBlob),
263+ "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob),
264+ } {
265+ p, err := changed(ups)
266+ require.NoError(t, err, name)
267+ assert.Empty(t, p, name)
268+ }
269+ for name, ups := range map[string][]domain.RefUpdate{
270+ "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),
271+ "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
272+ "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),
273+ "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),
274+ "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),
275+ } {
276+ p, err := changed(ups)
277+ require.NoError(t, err, name)
278+ assert.Equal(t, ".github/workflows/ci.yml", p, name)
279+ }
280+ for name, ups := range map[string][]domain.RefUpdate{
281+ "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),
282+ "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),
283+ "a malformed object": ref(domain.ZeroOID, "ZZZZ"),
284+ } {
285+ _, err := changed(ups)
286+ assert.Error(t, err, name)
287+ }
288+}
289+
290+// rawTree writes a tree object by hand: entries are "mode name hexid", in the
291+// order given (git sorts them; the callers give them sorted).
292+func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
293+ t.Helper()
294+ var b strings.Builder
295+ for _, e := range entries {
296+ raw, err := hex.DecodeString(e[2])
297+ require.NoError(t, err)
298+ b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000
299+ b.Write(raw)
300+ }
301+ return f.WriteObject("tree", b.String())
302+}
303+
304+// What counts as .github/workflows is what the loader lists: a directory of
305+// mode 040000 below a directory .github of mode 040000. The same tree id as a
306+// gitlink, a symbolic link or a file is nothing, and turning it into a
307+// directory activates it; the object id alone does not tell.
308+func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {
309+ g, data := newGit(t)
310+ ctx := context.Background()
311+ f := readertest.NewFixture(t, data, "proj")
312+ t.Cleanup(func() { _ = g.Close(ctx) })
313+ f.Write("README", "r\n")
314+ f.Commit("base")
315+ yml := f.WriteObject("blob", "on: push\n")
316+ wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows
317+ link := f.WriteObject("blob", ".github/other")
318+ file := f.WriteObject("blob", "not a directory\n")
319+ readme := f.WriteObject("blob", "r\n")
320+
321+ // A commit whose .github/workflows has the mode and id given.
322+ withWorkflows := func(mode, id string) string {
323+ gh := rawTree(t, f, [3]string{mode, "workflows", id})
324+ return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))
325+ }
326+ // A commit whose .github has the mode and id given.
327+ withGithub := func(mode, id string) string {
328+ return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))
329+ }
330+ asDir := withWorkflows("040000", wfTree)
331+ asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule
332+ asSymlink := withWorkflows("120000", link)
333+ asFile := withWorkflows("100644", file)
334+ none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))
335+ ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})
336+ githubDir := withGithub("040000", ghDir)
337+ githubGitlink := withGithub("160000", ghDir) // the same id, a submodule
338+ githubFile := withGithub("100644", file)
339+ githubLink := withGithub("120000", link)
340+
341+ for name, c := range map[string]struct {
342+ old, new string
343+ want string
344+ }{
345+ "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"},
346+ "a directory to a gitlink removes it": {asDir, asGitlink, ""},
347+ "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"},
348+ "a directory to a symlink removes it": {asDir, asSymlink, ""},
349+ "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"},
350+ "a directory to a file removes it": {asDir, asFile, ""},
351+ "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"},
352+ "a directory to nothing": {asDir, none, ""},
353+ "the same gitlink": {asGitlink, asGitlink, ""},
354+ "gitlink to symlink": {asGitlink, asSymlink, ""},
355+ ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"},
356+ ".github a directory to a gitlink": {githubDir, githubGitlink, ""},
357+ ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"},
358+ ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"},
359+ ".github a directory to a symlink": {githubDir, githubLink, ""},
360+ "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""},
361+ "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""},
362+ "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""},
363+ "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"},
364+ } {
365+ p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{})
366+ require.NoError(t, err, name)
367+ assert.Equal(t, c.want, p, name)
368+ }
369+
370+ // A tag that peels to the commit is judged the same way.
371+ tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
372+ p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{})
373+ require.NoError(t, err)
374+ assert.Equal(t, ".github/workflows/ci.yml", p)
375+}
M internal/adapters/githttp/hookclient.go +6 -4
@@ -12,6 +12,8 @@ import (
1212 "net"
1313 "strings"
1414 "time"
15+
16+ "bnema.dev/ermite/internal/ports"
1517 )
1618
1719 // hookDialTimeout bounds connecting to the socket and the whole exchange.
@@ -31,8 +33,8 @@ var ErrHookRejected = errors.New("push refused")
3133 // or erroring server, or a refusal all return an error (git then rejects the
3234 // push). For post-receive the push already happened: failures are reported on
3335 // errOut and RunHook returns nil.
34-func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
35- err := callHook(ctx, hook, in, errOut, socket, token)
36+func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string, objects ports.PushObjects) error {
37+ err := callHook(ctx, hook, in, errOut, socket, token, objects)
3638 if err == nil {
3739 return nil
3840 }
@@ -43,7 +45,7 @@ func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, s
4345 return err
4446 }
4547
46-func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
48+func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string, objects ports.PushObjects) error {
4749 if hook != HookPreReceive && hook != HookPostReceive {
4850 return fmt.Errorf("unknown hook %q", hook)
4951 }
@@ -67,7 +69,7 @@ func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer,
6769 _ = conn.SetDeadline(dl)
6870 }
6971
70- req, err := encodeLine(HookRequest{Token: token, Hook: hook, Updates: updates})
72+ req, err := encodeLine(HookRequest{Token: token, Hook: hook, Updates: updates, ObjectsDir: objects.Dir, Alternates: objects.Alternates})
7173 if err != nil {
7274 return err
7375 }
@@ -12,6 +12,8 @@ import (
12 "net"12 "net"
13 "strings"13 "strings"
14 "time"14 "time"
15+
16+ "bnema.dev/ermite/internal/ports"
15 )17 )
16 18
17 // hookDialTimeout bounds connecting to the socket and the whole exchange.19 // hookDialTimeout bounds connecting to the socket and the whole exchange.
@@ -31,8 +33,8 @@ var ErrHookRejected = errors.New("push refused")
31 // or erroring server, or a refusal all return an error (git then rejects the33 // or erroring server, or a refusal all return an error (git then rejects the
32 // push). For post-receive the push already happened: failures are reported on34 // push). For post-receive the push already happened: failures are reported on
33 // errOut and RunHook returns nil.35 // errOut and RunHook returns nil.
34-func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {36+func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string, objects ports.PushObjects) error {
35- err := callHook(ctx, hook, in, errOut, socket, token)37+ err := callHook(ctx, hook, in, errOut, socket, token, objects)
36 if err == nil {38 if err == nil {
37 return nil39 return nil
38 }40 }
@@ -43,7 +45,7 @@ func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, s
43 return err45 return err
44 }46 }
45 47
46-func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {48+func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string, objects ports.PushObjects) error {
47 if hook != HookPreReceive && hook != HookPostReceive {49 if hook != HookPreReceive && hook != HookPostReceive {
48 return fmt.Errorf("unknown hook %q", hook)50 return fmt.Errorf("unknown hook %q", hook)
49 }51 }
@@ -67,7 +69,7 @@ func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer,
67 _ = conn.SetDeadline(dl)69 _ = conn.SetDeadline(dl)
68 }70 }
69 71
70- req, err := encodeLine(HookRequest{Token: token, Hook: hook, Updates: updates})72+ req, err := encodeLine(HookRequest{Token: token, Hook: hook, Updates: updates, ObjectsDir: objects.Dir, Alternates: objects.Alternates})
71 if err != nil {73 if err != nil {
72 return err74 return err
73 }75 }
M internal/adapters/githttp/hooks.go +1 -1
@@ -210,7 +210,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
210210 rlog := log.WithField("repo", entry.repo.String())
211211 switch req.Hook {
212212 case HookPreReceive:
213- err := s.hooks.Check(ctx, entry.principal, entry.repo, updates)
213+ err := s.hooks.Check(ctx, entry.principal, entry.repo, updates, ports.PushObjects{Dir: req.ObjectsDir, Alternates: req.Alternates})
214214 switch {
215215 case err == nil:
216216 s.reply(ctx, conn, HookReply{OK: true})
@@ -210,7 +210,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
210 rlog := log.WithField("repo", entry.repo.String())210 rlog := log.WithField("repo", entry.repo.String())
211 switch req.Hook {211 switch req.Hook {
212 case HookPreReceive:212 case HookPreReceive:
213- err := s.hooks.Check(ctx, entry.principal, entry.repo, updates)213+ err := s.hooks.Check(ctx, entry.principal, entry.repo, updates, ports.PushObjects{Dir: req.ObjectsDir, Alternates: req.Alternates})
214 switch {214 switch {
215 case err == nil:215 case err == nil:
216 s.reply(ctx, conn, HookReply{OK: true})216 s.reply(ctx, conn, HookReply{OK: true})
M internal/adapters/githttp/hooks_test.go +15 -15
@@ -122,7 +122,7 @@ func TestHookProtocol(t *testing.T) {
122122 t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
123123 updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
124124 hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }),
125- repoName(t, "alpha"), updates).Return(nil).Once()
125+ repoName(t, "alpha"), updates, ports.PushObjects{}).Return(nil).Once()
126126 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
127127 assert.True(t, r.OK)
128128
@@ -133,13 +133,13 @@ func TestHookProtocol(t *testing.T) {
133133 })
134134
135135 t.Run("a repository sent by the client is ignored", func(t *testing.T) {
136- hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once()
136+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything, mock.Anything).Return(nil).Once()
137137 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
138138 assert.True(t, r.OK)
139139 })
140140
141141 t.Run("refusal carries the message", func(t *testing.T) {
142- hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
142+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
143143 Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once()
144144 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
145145 assert.False(t, r.OK)
@@ -147,7 +147,7 @@ func TestHookProtocol(t *testing.T) {
147147 })
148148
149149 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
150- hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
150+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
151151 Return(errors.New("disk /secret/path exploded")).Once()
152152 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
153153 assert.False(t, r.OK)
@@ -292,22 +292,22 @@ func TestRunHook(t *testing.T) {
292292 hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{
293293 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
294294 {Old: oidA, New: oidB, Ref: "refs/heads/dev"},
295- }).Return(nil).Once()
296- require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
295+ }, ports.PushObjects{Dir: "/data/repos/alpha.git/objects/incoming-x"}).Return(nil).Once()
296+ require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok, ports.PushObjects{Dir: "/data/repos/alpha.git/objects/incoming-x"}))
297297 assert.Empty(t, errOut.String())
298298 })
299299
300300 t.Run("a ref name with spaces survives", func(t *testing.T) {
301301 hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"),
302302 []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once()
303- require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))
303+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok, ports.PushObjects{}))
304304 })
305305
306306 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
307- hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
307+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
308308 Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once()
309309 var errOut bytes.Buffer
310- err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
310+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok, ports.PushObjects{})
311311 require.ErrorIs(t, err, githttp.ErrHookRejected)
312312 assert.Contains(t, errOut.String(), "rejected")
313313 })
@@ -320,12 +320,12 @@ func TestRunHook(t *testing.T) {
320320 "unknown token": {sock, "unknown"},
321321 } {
322322 var errOut bytes.Buffer
323- err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1])
323+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1], ports.PushObjects{})
324324 assert.ErrorIs(t, err, githttp.ErrHookRejected, name)
325325 }
326- err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok)
326+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok, ports.PushObjects{})
327327 assert.ErrorIs(t, err, githttp.ErrHookRejected, "unreadable input")
328- assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok))
328+ assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok, ports.PushObjects{}))
329329 })
330330
331331 t.Run("a server that never answers is cut off by the context", func(t *testing.T) {
@@ -348,17 +348,17 @@ func TestRunHook(t *testing.T) {
348348 cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
349349 defer cancel()
350350 start := time.Now()
351- err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok)
351+ err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok, ports.PushObjects{})
352352 assert.ErrorIs(t, err, githttp.ErrHookRejected)
353353 assert.Less(t, time.Since(start), 5*time.Second)
354354 })
355355
356356 t.Run("post-receive never fails the push", func(t *testing.T) {
357357 var errOut bytes.Buffer
358- require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok))
358+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok, ports.PushObjects{}))
359359 assert.Contains(t, errOut.String(), "post-receive")
360360 errOut.Reset()
361- require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", ""))
361+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", "", ports.PushObjects{}))
362362 assert.NotEmpty(t, errOut.String())
363363 })
364364 }
@@ -122,7 +122,7 @@ func TestHookProtocol(t *testing.T) {
122 t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {122 t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
123 updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}123 updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}
124 hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }),124 hooks.EXPECT().Check(mock.Anything, mock.MatchedBy(func(got domain.Principal) bool { return got.TokenID() == "tok1" }),
125- repoName(t, "alpha"), updates).Return(nil).Once()125+ repoName(t, "alpha"), updates, ports.PushObjects{}).Return(nil).Once()
126 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)126 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
127 assert.True(t, r.OK)127 assert.True(t, r.OK)
128 128
@@ -133,13 +133,13 @@ func TestHookProtocol(t *testing.T) {
133 })133 })
134 134
135 t.Run("a repository sent by the client is ignored", func(t *testing.T) {135 t.Run("a repository sent by the client is ignored", func(t *testing.T) {
136- hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything).Return(nil).Once()136+ hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), mock.Anything, mock.Anything).Return(nil).Once()
137 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)137 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
138 assert.True(t, r.OK)138 assert.True(t, r.OK)
139 })139 })
140 140
141 t.Run("refusal carries the message", func(t *testing.T) {141 t.Run("refusal carries the message", func(t *testing.T) {
142- hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).142+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
143 Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once()143 Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once()
144 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)144 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
145 assert.False(t, r.OK)145 assert.False(t, r.OK)
@@ -147,7 +147,7 @@ func TestHookProtocol(t *testing.T) {
147 })147 })
148 148
149 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {149 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
150- hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).150+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
151 Return(errors.New("disk /secret/path exploded")).Once()151 Return(errors.New("disk /secret/path exploded")).Once()
152 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)152 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
153 assert.False(t, r.OK)153 assert.False(t, r.OK)
@@ -292,22 +292,22 @@ func TestRunHook(t *testing.T) {
292 hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{292 hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{
293 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},293 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
294 {Old: oidA, New: oidB, Ref: "refs/heads/dev"},294 {Old: oidA, New: oidB, Ref: "refs/heads/dev"},
295- }).Return(nil).Once()295+ }, ports.PushObjects{Dir: "/data/repos/alpha.git/objects/incoming-x"}).Return(nil).Once()
296- require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))296+ require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok, ports.PushObjects{Dir: "/data/repos/alpha.git/objects/incoming-x"}))
297 assert.Empty(t, errOut.String())297 assert.Empty(t, errOut.String())
298 })298 })
299 299
300 t.Run("a ref name with spaces survives", func(t *testing.T) {300 t.Run("a ref name with spaces survives", func(t *testing.T) {
301 hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"),301 hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"),
302 []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once()302 []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/a b"}}).Return(nil).Once()
303- require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))303+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok, ports.PushObjects{}))
304 })304 })
305 305
306 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {306 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
307- hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything).307+ hooks.EXPECT().Check(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
308 Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once()308 Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once()
309 var errOut bytes.Buffer309 var errOut bytes.Buffer
310- err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)310+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok, ports.PushObjects{})
311 require.ErrorIs(t, err, githttp.ErrHookRejected)311 require.ErrorIs(t, err, githttp.ErrHookRejected)
312 assert.Contains(t, errOut.String(), "rejected")312 assert.Contains(t, errOut.String(), "rejected")
313 })313 })
@@ -320,12 +320,12 @@ func TestRunHook(t *testing.T) {
320 "unknown token": {sock, "unknown"},320 "unknown token": {sock, "unknown"},
321 } {321 } {
322 var errOut bytes.Buffer322 var errOut bytes.Buffer
323- err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1])323+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1], ports.PushObjects{})
324 assert.ErrorIs(t, err, githttp.ErrHookRejected, name)324 assert.ErrorIs(t, err, githttp.ErrHookRejected, name)
325 }325 }
326- err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok)326+ err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok, ports.PushObjects{})
327 assert.ErrorIs(t, err, githttp.ErrHookRejected, "unreadable input")327 assert.ErrorIs(t, err, githttp.ErrHookRejected, "unreadable input")
328- assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok))328+ assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok, ports.PushObjects{}))
329 })329 })
330 330
331 t.Run("a server that never answers is cut off by the context", func(t *testing.T) {331 t.Run("a server that never answers is cut off by the context", func(t *testing.T) {
@@ -348,17 +348,17 @@ func TestRunHook(t *testing.T) {
348 cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)348 cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
349 defer cancel()349 defer cancel()
350 start := time.Now()350 start := time.Now()
351- err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok)351+ err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok, ports.PushObjects{})
352 assert.ErrorIs(t, err, githttp.ErrHookRejected)352 assert.ErrorIs(t, err, githttp.ErrHookRejected)
353 assert.Less(t, time.Since(start), 5*time.Second)353 assert.Less(t, time.Since(start), 5*time.Second)
354 })354 })
355 355
356 t.Run("post-receive never fails the push", func(t *testing.T) {356 t.Run("post-receive never fails the push", func(t *testing.T) {
357 var errOut bytes.Buffer357 var errOut bytes.Buffer
358- require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok))358+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok, ports.PushObjects{}))
359 assert.Contains(t, errOut.String(), "post-receive")359 assert.Contains(t, errOut.String(), "post-receive")
360 errOut.Reset()360 errOut.Reset()
361- require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", ""))361+ require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", "", ports.PushObjects{}))
362 assert.NotEmpty(t, errOut.String())362 assert.NotEmpty(t, errOut.String())
363 })363 })
364 }364 }
M internal/adapters/githttp/protocol.go +5 -0
@@ -28,6 +28,11 @@ type HookRequest struct {
2828 Token string `json:"token"`
2929 Hook string `json:"hook"`
3030 Updates []HookUpdate `json:"updates"`
31+ // ObjectsDir and Alternates are GIT_OBJECT_DIRECTORY and
32+ // GIT_ALTERNATE_OBJECT_DIRECTORIES of the hook: where the objects of a push
33+ // in quarantine are (pre-receive only).
34+ ObjectsDir string `json:"objects_dir,omitempty"`
35+ Alternates string `json:"alternates,omitempty"`
3136 }
3237
3338 // HookUpdate is one ref update, as git feeds it to the hook.
@@ -28,6 +28,11 @@ type HookRequest struct {
28 Token string `json:"token"`28 Token string `json:"token"`
29 Hook string `json:"hook"`29 Hook string `json:"hook"`
30 Updates []HookUpdate `json:"updates"`30 Updates []HookUpdate `json:"updates"`
31+ // ObjectsDir and Alternates are GIT_OBJECT_DIRECTORY and
32+ // GIT_ALTERNATE_OBJECT_DIRECTORIES of the hook: where the objects of a push
33+ // in quarantine are (pre-receive only).
34+ ObjectsDir string `json:"objects_dir,omitempty"`
35+ Alternates string `json:"alternates,omitempty"`
31 }36 }
32 37
33 // HookUpdate is one ref update, as git feeds it to the hook.38 // HookUpdate is one ref update, as git feeds it to the hook.
M internal/adapters/kvstore/ci.go +2 -2
@@ -330,7 +330,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
330330 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
331331 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
332332 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
333- CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment,
333+ CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment, Permissions: j.Permissions,
334334 }
335335 if j.HasToken() {
336336 pb.TokenHash = j.TokenHash[:]
@@ -360,7 +360,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
360360 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
361361 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
362362 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
363- Environment: pb.GetEnvironment(), Version: item.Version,
363+ Environment: pb.GetEnvironment(), Permissions: pb.GetPermissions(), Version: item.Version,
364364 }
365365 j.CancelReason = decodeCancelReason(pb.GetCancelReason())
366366 copy(j.TokenHash[:], pb.GetTokenHash())
@@ -330,7 +330,7 @@ func encodeJob(j domain.CIJob) ([]byte, error) {
330 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),330 Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
331 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3331 AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
332 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,332 TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
333- CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment,333+ CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned, Environment: j.Environment, Permissions: j.Permissions,
334 }334 }
335 if j.HasToken() {335 if j.HasToken() {
336 pb.TokenHash = j.TokenHash[:]336 pb.TokenHash = j.TokenHash[:]
@@ -360,7 +360,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) {
360 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),360 TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
361 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),361 AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
362 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),362 CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
363- Environment: pb.GetEnvironment(), Version: item.Version,363+ Environment: pb.GetEnvironment(), Permissions: pb.GetPermissions(), Version: item.Version,
364 }364 }
365 j.CancelReason = decodeCancelReason(pb.GetCancelReason())365 j.CancelReason = decodeCancelReason(pb.GetCancelReason())
366 copy(j.TokenHash[:], pb.GetTokenHash())366 copy(j.TokenHash[:], pb.GetTokenHash())
M internal/adapters/kvstore/ci_token.go +2 -2
@@ -62,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte {
6262 func encodeRepoToken(t domain.CIRepoToken) []byte {
6363 return mustMarshal(&storagepb.CIRepoToken{
6464 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
65- Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha,
65+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, Permissions: t.Permissions,
6666 })
6767 }
6868
@@ -78,7 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
7878 t := domain.CIRepoToken{
7979 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
8080 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
81- Sha: pb.GetSha(),
81+ Sha: pb.GetSha(), Permissions: pb.GetPermissions(),
8282 }
8383 copy(t.Hash[:], pb.GetHash())
8484 return t, nil
@@ -62,7 +62,7 @@ func resolutionKey(runID int64, attempt int32, key string) []byte {
62 func encodeRepoToken(t domain.CIRepoToken) []byte {62 func encodeRepoToken(t domain.CIRepoToken) []byte {
63 return mustMarshal(&storagepb.CIRepoToken{63 return mustMarshal(&storagepb.CIRepoToken{
64 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,64 Id: t.ID, Hash: t.Hash[:], Repo: t.Repo.String(), RunId: t.RunID, RunAttempt: t.RunAttempt, JobId: t.JobID,
65- Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha,65+ Archive: t.Archive, ExpiresAt: encodeTime(t.ExpiresAt), CreatedAt: encodeTime(t.CreatedAt), Sha: t.Sha, Permissions: t.Permissions,
66 })66 })
67 }67 }
68 68
@@ -78,7 +78,7 @@ func decodeRepoToken(val []byte) (domain.CIRepoToken, error) {
78 t := domain.CIRepoToken{78 t := domain.CIRepoToken{
79 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),79 ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(),
80 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),80 Archive: pb.GetArchive(), ExpiresAt: decodeTime(pb.GetExpiresAt()), CreatedAt: decodeTime(pb.GetCreatedAt()),
81- Sha: pb.GetSha(),81+ Sha: pb.GetSha(), Permissions: pb.GetPermissions(),
82 }82 }
83 copy(t.Hash[:], pb.GetHash())83 copy(t.Hash[:], pb.GetHash())
84 return t, nil84 return t, nil
M internal/adapters/kvstore/ci_token_test.go +21 -0
@@ -193,3 +193,24 @@ func TestCIRunSourceKeepsThePusher(t *testing.T) {
193193 assert.Equal(t, p, src.Pusher)
194194 }
195195 }
196+
197+func TestCIRepoTokenKeepsItsPermissions(t *testing.T) {
198+ ctx := context.Background()
199+ _, ci, n := ciRepo(t)
200+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
201+ require.NoError(t, err)
202+ ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}
203+ with := repoToken("p1", "ermitegh_with", ref, n, false)
204+ with.Permissions = map[string]string{"contents": "write", "metadata": "read"}
205+ without := repoToken("p2", "ermitegh_without", ref, n, false)
206+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{with, without}})
207+ require.NoError(t, err)
208+
209+ got, err := ci.GetRepoToken(ctx, "p1")
210+ require.NoError(t, err)
211+ assert.Equal(t, with.Permissions, got.Permissions)
212+ got, err = ci.FindRepoTokenByHash(ctx, without.Hash)
213+ require.NoError(t, err)
214+ assert.Empty(t, got.Permissions, "a token stored without permissions reads back as the default")
215+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, domain.RepoTokenPrincipal(got).Scopes())
216+}
@@ -193,3 +193,24 @@ func TestCIRunSourceKeepsThePusher(t *testing.T) {
193 assert.Equal(t, p, src.Pusher)193 assert.Equal(t, p, src.Pusher)
194 }194 }
195 }195 }
196+
197+func TestCIRepoTokenKeepsItsPermissions(t *testing.T) {
198+ ctx := context.Background()
199+ _, ci, n := ciRepo(t)
200+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
201+ require.NoError(t, err)
202+ ref := jobRefT{RunID: d.Run.ID, JobID: d.Jobs[0].ID}
203+ with := repoToken("p1", "ermitegh_with", ref, n, false)
204+ with.Permissions = map[string]string{"contents": "write", "metadata": "read"}
205+ without := repoToken("p2", "ermitegh_without", ref, n, false)
206+ _, err = ci.Apply(ctx, ports.CIWrite{RepoTokens: []domain.CIRepoToken{with, without}})
207+ require.NoError(t, err)
208+
209+ got, err := ci.GetRepoToken(ctx, "p1")
210+ require.NoError(t, err)
211+ assert.Equal(t, with.Permissions, got.Permissions)
212+ got, err = ci.FindRepoTokenByHash(ctx, without.Hash)
213+ require.NoError(t, err)
214+ assert.Empty(t, got.Permissions, "a token stored without permissions reads back as the default")
215+ assert.Equal(t, []domain.Scope{domain.ScopeRepoRead}, domain.RepoTokenPrincipal(got).Scopes())
216+}
M internal/adapters/rpc/ci_convert.go +1 -0
@@ -221,6 +221,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
221221 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
222222 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,
223223 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,
224+ Permissions: &pb.JobPermissions{Levels: j.Permissions},
224225 }
225226 }
226227
@@ -221,6 +221,7 @@ func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
221 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,221 Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
222 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,222 Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken,
223 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,223 Secrets: j.Secrets, Vars: j.Vars, Environment: j.Environment, Trusted: j.Trusted,
224+ Permissions: &pb.JobPermissions{Levels: j.Permissions},
224 }225 }
225 }226 }
226 227
A internal/app/ci_permissions_test.go +374 -0
@@ -0,0 +1,374 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/http"
7+ "strings"
8+ "testing"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// CI-3 D3 / AC3: the permissions of the job token, the push guard on workflow
19+// files and the rule that a job token's push starts no run.
20+
21+const permsWorkflow = `on: push
22+permissions:
23+ contents: write
24+jobs:
25+ wf:
26+ runs-on: ubuntu-latest
27+ steps:
28+ - run: echo wf
29+ readonly:
30+ runs-on: ubuntu-latest
31+ permissions:
32+ contents: read
33+ pull-requests: write
34+ steps:
35+ - run: echo ro
36+ none:
37+ runs-on: ubuntu-latest
38+ permissions: {}
39+ steps:
40+ - run: echo none
41+ all:
42+ runs-on: ubuntu-latest
43+ permissions: write-all
44+ steps:
45+ - run: echo all
46+`
47+
48+const defaultPermsWorkflow = "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
49+
50+// tokenPush pushes refspec to the repository with the job token, as a job that
51+// runs git push with the GITHUB_TOKEN does.
52+func (c *ciHarness) tokenPush(dir, token string, refspecs ...string) (string, error) {
53+ args := []string{"-c", "http.extraheader=" + checkoutHeader(token), "push", c.url("/owner/proj")}
54+ return c.git(dir, append(args, refspecs...)...)
55+}
56+
57+// commitIn commits a file in dir on its current branch.
58+func (c *ciHarness) commitIn(dir, path, content string) {
59+ c.t.Helper()
60+ c.writeFile(dir, path, []byte(content))
61+}
62+
63+// jobsOf collects the jobs of the one run by key.
64+func startedJobs(runner *fakeRunner, n int) map[string]*pb.Job {
65+ return runner.jobsByKey(n)
66+}
67+
68+func levelsOf(j *pb.Job) map[string]string { return j.GetPermissions().GetLevels() }
69+
70+func TestJobPermissionsAreResolvedStoredAndSent(t *testing.T) {
71+ c := newCIHarness(t, nil)
72+ runner := c.registerRunner("alpha", "ubuntu-latest")
73+ c.push("main", map[string]string{".github/workflows/p.yml": permsWorkflow}, "")
74+ jobs := startedJobs(runner, 4)
75+
76+ wf := levelsOf(jobs["wf"])
77+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "write"}, wf, "the workflow's permissions")
78+ assert.True(t, jobs["wf"].GetTrusted())
79+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "pull-requests": "write"}, levelsOf(jobs["readonly"]), "the job's replace the workflow's")
80+ assert.Equal(t, map[string]string{"metadata": "read"}, levelsOf(jobs["none"]), "permissions: {} is all none")
81+ assert.Equal(t, "write", levelsOf(jobs["all"])["issues"])
82+ assert.Equal(t, "write", levelsOf(jobs["all"])["contents"])
83+ assert.Equal(t, "read", levelsOf(jobs["all"])["models"], "write-all keeps models at read")
84+
85+ // The record keeps what the workflow asked for.
86+ stored, err := c.forge.store.CI().ListJobs(c.ctx, c.runs()[0].GetId(), 1)
87+ require.NoError(t, err)
88+ byKey := map[string]domain.CIJob{}
89+ for _, j := range stored {
90+ byKey[j.Key] = j
91+ }
92+ assert.Equal(t, "write", byKey["wf"].Permissions["contents"])
93+ assert.Equal(t, map[string]string{"metadata": "read"}, byKey["none"].Permissions)
94+
95+ // What each token may do on the repository.
96+ push := func(j *pb.Job, branch string) (string, error) {
97+ c.mustGit(c.wt, "checkout", "-q", "main")
98+ c.commitIn(c.wt, "n-"+branch+".txt", branch)
99+ return c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/main")
100+ }
101+ _, err = push(jobs["readonly"], "r")
102+ require.Error(t, err, "contents: read cannot push")
103+ _, err = push(jobs["none"], "n")
104+ require.Error(t, err, "contents: none cannot push")
105+ c.mustGit(c.wt, "reset", "-q", "--hard", "HEAD~2")
106+ out, err := c.clone(jobs["none"].GetGithubToken(), c.url("/owner/proj"))
107+ require.Error(t, err, "contents: none cannot even clone a private repository: "+out)
108+ out, err = c.clone(jobs["readonly"].GetGithubToken(), c.url("/owner/proj"))
109+ require.NoError(t, err, out)
110+ out, err = push(jobs["wf"], "w")
111+ require.NoError(t, err, out)
112+ out, err = push(jobs["all"], "a")
113+ require.NoError(t, err, out)
114+}
115+
116+func TestDefaultTokenCannotPush(t *testing.T) {
117+ c := newCIHarness(t, nil)
118+ runner := c.registerRunner("alpha", "ubuntu-latest")
119+ c.push("main", map[string]string{".github/workflows/p.yml": defaultPermsWorkflow}, "")
120+ job := runner.await()
121+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "packages": "read"}, levelsOf(job), "the restricted default")
122+
123+ c.commitIn(c.wt, "x.txt", "x")
124+ before := c.bareGit("proj", "rev-parse", "refs/heads/main")
125+ out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/main")
126+ require.Error(t, err)
127+ assert.Contains(t, out, "403")
128+ assert.Equal(t, before, c.bareGit("proj", "rev-parse", "refs/heads/main"))
129+ out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
130+ require.NoError(t, err, "it reads: "+out)
131+}
132+
133+func TestJobTokenWithContentsWritePushesButNotWorkflows(t *testing.T) {
134+ c := newCIHarness(t, nil)
135+ runner := c.registerRunner("alpha", "ubuntu-latest")
136+ wfYAML := "on:\n push:\n create:\npermissions:\n contents: write\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
137+ c.push("main", map[string]string{".github/workflows/p.yml": wfYAML}, "")
138+ c.push("feat", map[string]string{"feat.txt": "f"}, "")
139+ job := runner.await()
140+ tok := job.GetGithubToken()
141+ require.Equal(t, "write", levelsOf(job)["contents"])
142+ c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
143+ runsBefore := len(c.runs())
144+ ref := func(r string) string { return c.bareGit("proj", "rev-parse", "--verify", "-q", r) }
145+ refs := func() string { return c.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
146+
147+ // A commit on an existing branch is accepted.
148+ c.mustGit(c.wt, "checkout", "-q", "main")
149+ c.commitIn(c.wt, "notes.txt", "generated")
150+ out, err := c.tokenPush(c.wt, tok, "HEAD:refs/heads/main")
151+ require.NoError(t, err, out)
152+ assert.Equal(t, strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")), ref("refs/heads/main"))
153+
154+ // A new branch and a new tag are accepted when their commits carry no
155+ // workflow (a root commit with a README here).
156+ orphan := c.workTree("orphan", "README")
157+ out, err = c.tokenPush(orphan, tok, "orphan:refs/heads/orphan")
158+ require.NoError(t, err, out)
159+ require.NotEmpty(t, ref("refs/heads/orphan"))
160+ out, err = c.tokenPush(orphan, tok, "orphan:refs/tags/v0")
161+ require.NoError(t, err, out)
162+ require.NotEmpty(t, ref("refs/tags/v0"))
163+ // And the branch they made can be deleted, as can one that holds workflows.
164+ out, err = c.tokenPush(orphan, tok, ":refs/heads/orphan")
165+ require.NoError(t, err, out)
166+ out, err = c.tokenPush(c.wt, tok, ":refs/heads/feat")
167+ require.NoError(t, err, "deleting a branch changes no workflow: "+out)
168+
169+ // Refused: the whole push, whatever the ref, with GitHub's message.
170+ refusal := func(path string) string {
171+ return "refusing to allow a GitHub App to create or update workflow `" + path + "` without `workflows` permission"
172+ }
173+ before := refs()
174+ c.mustGit(c.wt, "checkout", "-q", "-B", "w", "main")
175+ c.commitIn(c.wt, ".github/workflows/new.yml", wfYAML) // create a file
176+ out, err = c.tokenPush(c.wt, tok, "w:refs/heads/w")
177+ require.Error(t, err)
178+ assert.Contains(t, out, refusal(".github/workflows/new.yml"))
179+ assert.Equal(t, before, refs())
180+
181+ c.mustGit(c.wt, "checkout", "-q", "-B", "m", "main")
182+ c.commitIn(c.wt, ".github/workflows/p.yml", wfYAML+"# changed\n") // modify one
183+ out, err = c.tokenPush(c.wt, tok, "m:refs/heads/main")
184+ require.Error(t, err, "an update of main that touches a workflow")
185+ assert.Contains(t, out, refusal(".github/workflows/p.yml"))
186+ out, err = c.tokenPush(c.wt, tok, "+m:refs/heads/main")
187+ require.Error(t, err, "forced too")
188+ assert.Contains(t, out, refusal(".github/workflows/p.yml"))
189+ assert.Equal(t, before, refs())
190+
191+ c.mustGit(c.wt, "checkout", "-q", "-B", "r", "main")
192+ c.writeFile(c.wt, "moved.yml", []byte(wfYAML))
193+ c.mustGit(c.wt, "mv", "moved.yml", ".github/workflows/moved.yml") // rename into
194+ c.mustGit(c.wt, "commit", "-q", "-m", "mv")
195+ out, err = c.tokenPush(c.wt, tok, "r:refs/heads/main")
196+ require.Error(t, err, "a rename into .github/workflows")
197+ assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
198+
199+ // A tag that points to a commit holding workflows (even an existing one).
200+ out, err = c.tokenPush(c.wt, tok, "main:refs/tags/v1")
201+ require.Error(t, err, "a tag on a commit with workflows: "+out)
202+ assert.Contains(t, out, "without `workflows` permission")
203+ // An annotated tag is judged by the commit it peels to.
204+ c.mustGit(c.wt, "tag", "-a", "-m", "release", "va", "main")
205+ out, err = c.tokenPush(c.wt, tok, "refs/tags/va:refs/tags/va")
206+ require.Error(t, err, "an annotated tag on a commit with workflows: "+out)
207+ assert.Contains(t, out, "without `workflows` permission")
208+ // A new branch off main carries the workflows too.
209+ out, err = c.tokenPush(c.wt, tok, "main:refs/heads/copy")
210+ require.Error(t, err)
211+ assert.Contains(t, out, "without `workflows` permission")
212+
213+ // One ref that touches a workflow refuses a push of several.
214+ c.mustGit(c.wt, "checkout", "-q", "-B", "ok", "main")
215+ c.commitIn(c.wt, "fine.txt", "fine")
216+ before = refs()
217+ out, err = c.tokenPush(c.wt, tok, "ok:refs/heads/main", "r:refs/heads/other")
218+ require.Error(t, err)
219+ assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
220+ assert.Equal(t, before, refs(), "nothing moved, not even the fine ref")
221+
222+ // The owner's token is not held to it.
223+ out, err = c.git(c.wt, "push", c.urlAuth(adminToken, "/proj.git"), "r:refs/heads/rworkflow")
224+ require.NoError(t, err, out)
225+
226+ // None of the pushes of the job token started a run; a push of the owner
227+ // does, and queues after them.
228+ c.push("sentinel", map[string]string{"s.txt": "s"}, "")
229+ c.waitFor("the owner's runs", func() bool { return len(c.runs()) >= runsBefore+2 })
230+ for _, r := range c.runs() {
231+ assert.NotEqual(t, "refs/heads/orphan", r.GetRef(), "no run for a job token's push")
232+ assert.NotEqual(t, "refs/tags/v0", r.GetRef())
233+ }
234+ assert.Len(t, c.runs(), runsBefore+2, "the owner's rworkflow and sentinel pushes only")
235+}
236+
237+func TestJobTokenCannotPushToAnotherRepository(t *testing.T) {
238+ c := newCIHarness(t, nil)
239+ c.seed("other", domain.VisibilityPublic, "a.txt")
240+ runner := c.registerRunner("alpha", "ubuntu-latest")
241+ c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions:\n contents: write\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
242+ job := runner.await()
243+ require.Equal(t, "write", levelsOf(job)["contents"])
244+ before := c.bareGit("other", "rev-parse", "refs/heads/main")
245+ wt := c.workTree("main", "b.txt")
246+ out, err := c.git(wt, "-c", "http.extraheader="+checkoutHeader(job.GetGithubToken()), "push", "--force", c.url("/owner/other"), "main:refs/heads/main")
247+ require.Error(t, err, out)
248+ assert.Equal(t, before, c.bareGit("other", "rev-parse", "refs/heads/main"))
249+ status, _, _ := c.get("/owner/other/info/refs?service=git-receive-pack", basic(job.GetGithubToken()))
250+ assert.Equal(t, http.StatusForbidden, status)
251+ _ = out
252+}
253+
254+// D1: the run of a token limited to branches is not trusted: whatever the
255+// workflow asks, its job token is read-only.
256+func TestBranchLimitedActorsJobTokenIsReadOnly(t *testing.T) {
257+ c := newCIHarness(t, nil)
258+ runner := c.registerRunner("alpha", "ubuntu-latest")
259+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/x/"}, "proj")
260+ c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
261+ owner := runner.await()
262+ assert.Equal(t, "write", levelsOf(owner)["contents"])
263+ runner.runJob(owner, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
264+
265+ c.mustGit(c.wt, "checkout", "-q", "main")
266+ c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
267+ job := runner.await()
268+ assert.False(t, job.GetTrusted())
269+ lv := levelsOf(job)
270+ assert.Equal(t, "read", lv["contents"], "write lowered to read")
271+ assert.Equal(t, "read", lv["issues"])
272+ assert.Equal(t, "read", lv["models"])
273+ assert.NotContains(t, lv, "id-token", "a permission with no read level is none")
274+ assert.NotContains(t, lv, "copilot-requests")
275+ for k, v := range lv {
276+ assert.NotEqual(t, "write", v, k)
277+ }
278+
279+ c.commitIn(c.wt, "evil.txt", "x")
280+ out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/agent/x/a")
281+ require.Error(t, err, "its token cannot push: "+out)
282+ out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
283+ require.NoError(t, err, "but it reads: "+out)
284+}
285+
286+// D1 / D3 on re-runs: the trust of a run is kept or lowered and the token of the
287+// new attempt follows it, whoever runs it again.
288+func TestRerunsNeverRaiseTheJobTokenPermissions(t *testing.T) {
289+ c := newCIHarness(t, nil)
290+ runner := c.registerRunner("alpha", "ubuntu-latest")
291+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/x/"}, "proj")
292+ wf := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
293+ rerun := func(api *clients, id int64) error {
294+ _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
295+ return err
296+ }
297+ byRef := func(ref string) *pb.Run {
298+ for _, r := range c.runs() {
299+ if r.GetRef() == ref {
300+ return r
301+ }
302+ }
303+ return nil
304+ }
305+ done := func(j *pb.Job) {
306+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
307+ }
308+ noPush := func(j *pb.Job, ref string) {
309+ t.Helper()
310+ assert.False(t, j.GetTrusted())
311+ assert.Equal(t, "read", levelsOf(j)["contents"])
312+ for k, v := range levelsOf(j) {
313+ assert.NotEqual(t, "write", v, k)
314+ }
315+ c.mustGit(c.wt, "checkout", "-q", "-B", "tmp", "main")
316+ c.commitIn(c.wt, "x-"+ref[len(ref)-1:]+".txt", "x")
317+ out, err := c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/"+ref)
318+ require.Error(t, err, "the token cannot push: "+out)
319+ }
320+
321+ // The owner's run: write-all, trusted.
322+ c.push("main", map[string]string{".github/workflows/p.yml": wf}, "")
323+ owner := runner.await()
324+ assert.True(t, owner.GetTrusted())
325+ assert.Equal(t, "write", levelsOf(owner)["contents"])
326+ done(owner)
327+ c.waitFor("owner run done", func() bool {
328+ r := byRef("refs/heads/main")
329+ return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
330+ })
331+ mainRun := byRef("refs/heads/main")
332+
333+ // The agent's run: untrusted, read-only, and the owner's re-run of it too.
334+ c.mustGit(c.wt, "checkout", "-q", "main")
335+ c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
336+ first := runner.await()
337+ noPush(first, "agent/x/a")
338+ done(first)
339+ c.waitFor("agent run done", func() bool {
340+ r := byRef("refs/heads/agent/x/a")
341+ return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
342+ })
343+ agentRun := byRef("refs/heads/agent/x/a")
344+ require.NoError(t, rerun(c.root, agentRun.GetId()))
345+ again := runner.await()
346+ assert.Equal(t, int32(2), again.GetAttempt())
347+ noPush(again, "agent/x/a")
348+ done(again)
349+
350+ // The agent runs the owner's run again: refused, as it is outside its
351+ // branches, and the run stays trusted with a token that writes.
352+ require.Error(t, rerun(c.api(agent), mainRun.GetId()))
353+ assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
354+
355+ // A run of the owner on a branch the agent may touch: the agent's re-run of
356+ // it is allowed, and its attempt is read-only.
357+ c.mustGit(c.wt, "checkout", "-q", "-B", "agent/x/own", "main")
358+ c.commitIn(c.wt, "own.txt", "own")
359+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x/own")
360+ ownerOnBranch := runner.await()
361+ assert.True(t, ownerOnBranch.GetTrusted())
362+ assert.Equal(t, "write", levelsOf(ownerOnBranch)["contents"])
363+ done(ownerOnBranch)
364+ c.waitFor("owner branch run done", func() bool {
365+ r := byRef("refs/heads/agent/x/own")
366+ return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
367+ })
368+ branchRun := byRef("refs/heads/agent/x/own")
369+ require.NoError(t, rerun(c.api(agent), branchRun.GetId()))
370+ lowered := runner.await()
371+ noPush(lowered, "agent/x/own")
372+ assert.False(t, c.getRun(branchRun.GetId()).GetRun().GetTrusted(), "the re-run is not trusted")
373+ done(lowered)
374+}
@@ -0,0 +1,374 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/http"
7+ "strings"
8+ "testing"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ pb "bnema.dev/ermite-api/gen/ermite"
15+ "bnema.dev/ermite/internal/domain"
16+)
17+
18+// CI-3 D3 / AC3: the permissions of the job token, the push guard on workflow
19+// files and the rule that a job token's push starts no run.
20+
21+const permsWorkflow = `on: push
22+permissions:
23+ contents: write
24+jobs:
25+ wf:
26+ runs-on: ubuntu-latest
27+ steps:
28+ - run: echo wf
29+ readonly:
30+ runs-on: ubuntu-latest
31+ permissions:
32+ contents: read
33+ pull-requests: write
34+ steps:
35+ - run: echo ro
36+ none:
37+ runs-on: ubuntu-latest
38+ permissions: {}
39+ steps:
40+ - run: echo none
41+ all:
42+ runs-on: ubuntu-latest
43+ permissions: write-all
44+ steps:
45+ - run: echo all
46+`
47+
48+const defaultPermsWorkflow = "on: push\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
49+
50+// tokenPush pushes refspec to the repository with the job token, as a job that
51+// runs git push with the GITHUB_TOKEN does.
52+func (c *ciHarness) tokenPush(dir, token string, refspecs ...string) (string, error) {
53+ args := []string{"-c", "http.extraheader=" + checkoutHeader(token), "push", c.url("/owner/proj")}
54+ return c.git(dir, append(args, refspecs...)...)
55+}
56+
57+// commitIn commits a file in dir on its current branch.
58+func (c *ciHarness) commitIn(dir, path, content string) {
59+ c.t.Helper()
60+ c.writeFile(dir, path, []byte(content))
61+}
62+
63+// jobsOf collects the jobs of the one run by key.
64+func startedJobs(runner *fakeRunner, n int) map[string]*pb.Job {
65+ return runner.jobsByKey(n)
66+}
67+
68+func levelsOf(j *pb.Job) map[string]string { return j.GetPermissions().GetLevels() }
69+
70+func TestJobPermissionsAreResolvedStoredAndSent(t *testing.T) {
71+ c := newCIHarness(t, nil)
72+ runner := c.registerRunner("alpha", "ubuntu-latest")
73+ c.push("main", map[string]string{".github/workflows/p.yml": permsWorkflow}, "")
74+ jobs := startedJobs(runner, 4)
75+
76+ wf := levelsOf(jobs["wf"])
77+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "write"}, wf, "the workflow's permissions")
78+ assert.True(t, jobs["wf"].GetTrusted())
79+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "pull-requests": "write"}, levelsOf(jobs["readonly"]), "the job's replace the workflow's")
80+ assert.Equal(t, map[string]string{"metadata": "read"}, levelsOf(jobs["none"]), "permissions: {} is all none")
81+ assert.Equal(t, "write", levelsOf(jobs["all"])["issues"])
82+ assert.Equal(t, "write", levelsOf(jobs["all"])["contents"])
83+ assert.Equal(t, "read", levelsOf(jobs["all"])["models"], "write-all keeps models at read")
84+
85+ // The record keeps what the workflow asked for.
86+ stored, err := c.forge.store.CI().ListJobs(c.ctx, c.runs()[0].GetId(), 1)
87+ require.NoError(t, err)
88+ byKey := map[string]domain.CIJob{}
89+ for _, j := range stored {
90+ byKey[j.Key] = j
91+ }
92+ assert.Equal(t, "write", byKey["wf"].Permissions["contents"])
93+ assert.Equal(t, map[string]string{"metadata": "read"}, byKey["none"].Permissions)
94+
95+ // What each token may do on the repository.
96+ push := func(j *pb.Job, branch string) (string, error) {
97+ c.mustGit(c.wt, "checkout", "-q", "main")
98+ c.commitIn(c.wt, "n-"+branch+".txt", branch)
99+ return c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/main")
100+ }
101+ _, err = push(jobs["readonly"], "r")
102+ require.Error(t, err, "contents: read cannot push")
103+ _, err = push(jobs["none"], "n")
104+ require.Error(t, err, "contents: none cannot push")
105+ c.mustGit(c.wt, "reset", "-q", "--hard", "HEAD~2")
106+ out, err := c.clone(jobs["none"].GetGithubToken(), c.url("/owner/proj"))
107+ require.Error(t, err, "contents: none cannot even clone a private repository: "+out)
108+ out, err = c.clone(jobs["readonly"].GetGithubToken(), c.url("/owner/proj"))
109+ require.NoError(t, err, out)
110+ out, err = push(jobs["wf"], "w")
111+ require.NoError(t, err, out)
112+ out, err = push(jobs["all"], "a")
113+ require.NoError(t, err, out)
114+}
115+
116+func TestDefaultTokenCannotPush(t *testing.T) {
117+ c := newCIHarness(t, nil)
118+ runner := c.registerRunner("alpha", "ubuntu-latest")
119+ c.push("main", map[string]string{".github/workflows/p.yml": defaultPermsWorkflow}, "")
120+ job := runner.await()
121+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "packages": "read"}, levelsOf(job), "the restricted default")
122+
123+ c.commitIn(c.wt, "x.txt", "x")
124+ before := c.bareGit("proj", "rev-parse", "refs/heads/main")
125+ out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/main")
126+ require.Error(t, err)
127+ assert.Contains(t, out, "403")
128+ assert.Equal(t, before, c.bareGit("proj", "rev-parse", "refs/heads/main"))
129+ out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
130+ require.NoError(t, err, "it reads: "+out)
131+}
132+
133+func TestJobTokenWithContentsWritePushesButNotWorkflows(t *testing.T) {
134+ c := newCIHarness(t, nil)
135+ runner := c.registerRunner("alpha", "ubuntu-latest")
136+ wfYAML := "on:\n push:\n create:\npermissions:\n contents: write\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
137+ c.push("main", map[string]string{".github/workflows/p.yml": wfYAML}, "")
138+ c.push("feat", map[string]string{"feat.txt": "f"}, "")
139+ job := runner.await()
140+ tok := job.GetGithubToken()
141+ require.Equal(t, "write", levelsOf(job)["contents"])
142+ c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
143+ runsBefore := len(c.runs())
144+ ref := func(r string) string { return c.bareGit("proj", "rev-parse", "--verify", "-q", r) }
145+ refs := func() string { return c.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
146+
147+ // A commit on an existing branch is accepted.
148+ c.mustGit(c.wt, "checkout", "-q", "main")
149+ c.commitIn(c.wt, "notes.txt", "generated")
150+ out, err := c.tokenPush(c.wt, tok, "HEAD:refs/heads/main")
151+ require.NoError(t, err, out)
152+ assert.Equal(t, strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD")), ref("refs/heads/main"))
153+
154+ // A new branch and a new tag are accepted when their commits carry no
155+ // workflow (a root commit with a README here).
156+ orphan := c.workTree("orphan", "README")
157+ out, err = c.tokenPush(orphan, tok, "orphan:refs/heads/orphan")
158+ require.NoError(t, err, out)
159+ require.NotEmpty(t, ref("refs/heads/orphan"))
160+ out, err = c.tokenPush(orphan, tok, "orphan:refs/tags/v0")
161+ require.NoError(t, err, out)
162+ require.NotEmpty(t, ref("refs/tags/v0"))
163+ // And the branch they made can be deleted, as can one that holds workflows.
164+ out, err = c.tokenPush(orphan, tok, ":refs/heads/orphan")
165+ require.NoError(t, err, out)
166+ out, err = c.tokenPush(c.wt, tok, ":refs/heads/feat")
167+ require.NoError(t, err, "deleting a branch changes no workflow: "+out)
168+
169+ // Refused: the whole push, whatever the ref, with GitHub's message.
170+ refusal := func(path string) string {
171+ return "refusing to allow a GitHub App to create or update workflow `" + path + "` without `workflows` permission"
172+ }
173+ before := refs()
174+ c.mustGit(c.wt, "checkout", "-q", "-B", "w", "main")
175+ c.commitIn(c.wt, ".github/workflows/new.yml", wfYAML) // create a file
176+ out, err = c.tokenPush(c.wt, tok, "w:refs/heads/w")
177+ require.Error(t, err)
178+ assert.Contains(t, out, refusal(".github/workflows/new.yml"))
179+ assert.Equal(t, before, refs())
180+
181+ c.mustGit(c.wt, "checkout", "-q", "-B", "m", "main")
182+ c.commitIn(c.wt, ".github/workflows/p.yml", wfYAML+"# changed\n") // modify one
183+ out, err = c.tokenPush(c.wt, tok, "m:refs/heads/main")
184+ require.Error(t, err, "an update of main that touches a workflow")
185+ assert.Contains(t, out, refusal(".github/workflows/p.yml"))
186+ out, err = c.tokenPush(c.wt, tok, "+m:refs/heads/main")
187+ require.Error(t, err, "forced too")
188+ assert.Contains(t, out, refusal(".github/workflows/p.yml"))
189+ assert.Equal(t, before, refs())
190+
191+ c.mustGit(c.wt, "checkout", "-q", "-B", "r", "main")
192+ c.writeFile(c.wt, "moved.yml", []byte(wfYAML))
193+ c.mustGit(c.wt, "mv", "moved.yml", ".github/workflows/moved.yml") // rename into
194+ c.mustGit(c.wt, "commit", "-q", "-m", "mv")
195+ out, err = c.tokenPush(c.wt, tok, "r:refs/heads/main")
196+ require.Error(t, err, "a rename into .github/workflows")
197+ assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
198+
199+ // A tag that points to a commit holding workflows (even an existing one).
200+ out, err = c.tokenPush(c.wt, tok, "main:refs/tags/v1")
201+ require.Error(t, err, "a tag on a commit with workflows: "+out)
202+ assert.Contains(t, out, "without `workflows` permission")
203+ // An annotated tag is judged by the commit it peels to.
204+ c.mustGit(c.wt, "tag", "-a", "-m", "release", "va", "main")
205+ out, err = c.tokenPush(c.wt, tok, "refs/tags/va:refs/tags/va")
206+ require.Error(t, err, "an annotated tag on a commit with workflows: "+out)
207+ assert.Contains(t, out, "without `workflows` permission")
208+ // A new branch off main carries the workflows too.
209+ out, err = c.tokenPush(c.wt, tok, "main:refs/heads/copy")
210+ require.Error(t, err)
211+ assert.Contains(t, out, "without `workflows` permission")
212+
213+ // One ref that touches a workflow refuses a push of several.
214+ c.mustGit(c.wt, "checkout", "-q", "-B", "ok", "main")
215+ c.commitIn(c.wt, "fine.txt", "fine")
216+ before = refs()
217+ out, err = c.tokenPush(c.wt, tok, "ok:refs/heads/main", "r:refs/heads/other")
218+ require.Error(t, err)
219+ assert.Contains(t, out, refusal(".github/workflows/moved.yml"))
220+ assert.Equal(t, before, refs(), "nothing moved, not even the fine ref")
221+
222+ // The owner's token is not held to it.
223+ out, err = c.git(c.wt, "push", c.urlAuth(adminToken, "/proj.git"), "r:refs/heads/rworkflow")
224+ require.NoError(t, err, out)
225+
226+ // None of the pushes of the job token started a run; a push of the owner
227+ // does, and queues after them.
228+ c.push("sentinel", map[string]string{"s.txt": "s"}, "")
229+ c.waitFor("the owner's runs", func() bool { return len(c.runs()) >= runsBefore+2 })
230+ for _, r := range c.runs() {
231+ assert.NotEqual(t, "refs/heads/orphan", r.GetRef(), "no run for a job token's push")
232+ assert.NotEqual(t, "refs/tags/v0", r.GetRef())
233+ }
234+ assert.Len(t, c.runs(), runsBefore+2, "the owner's rworkflow and sentinel pushes only")
235+}
236+
237+func TestJobTokenCannotPushToAnotherRepository(t *testing.T) {
238+ c := newCIHarness(t, nil)
239+ c.seed("other", domain.VisibilityPublic, "a.txt")
240+ runner := c.registerRunner("alpha", "ubuntu-latest")
241+ c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions:\n contents: write\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
242+ job := runner.await()
243+ require.Equal(t, "write", levelsOf(job)["contents"])
244+ before := c.bareGit("other", "rev-parse", "refs/heads/main")
245+ wt := c.workTree("main", "b.txt")
246+ out, err := c.git(wt, "-c", "http.extraheader="+checkoutHeader(job.GetGithubToken()), "push", "--force", c.url("/owner/other"), "main:refs/heads/main")
247+ require.Error(t, err, out)
248+ assert.Equal(t, before, c.bareGit("other", "rev-parse", "refs/heads/main"))
249+ status, _, _ := c.get("/owner/other/info/refs?service=git-receive-pack", basic(job.GetGithubToken()))
250+ assert.Equal(t, http.StatusForbidden, status)
251+ _ = out
252+}
253+
254+// D1: the run of a token limited to branches is not trusted: whatever the
255+// workflow asks, its job token is read-only.
256+func TestBranchLimitedActorsJobTokenIsReadOnly(t *testing.T) {
257+ c := newCIHarness(t, nil)
258+ runner := c.registerRunner("alpha", "ubuntu-latest")
259+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/x/"}, "proj")
260+ c.push("main", map[string]string{".github/workflows/p.yml": "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"}, "")
261+ owner := runner.await()
262+ assert.Equal(t, "write", levelsOf(owner)["contents"])
263+ runner.runJob(owner, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
264+
265+ c.mustGit(c.wt, "checkout", "-q", "main")
266+ c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
267+ job := runner.await()
268+ assert.False(t, job.GetTrusted())
269+ lv := levelsOf(job)
270+ assert.Equal(t, "read", lv["contents"], "write lowered to read")
271+ assert.Equal(t, "read", lv["issues"])
272+ assert.Equal(t, "read", lv["models"])
273+ assert.NotContains(t, lv, "id-token", "a permission with no read level is none")
274+ assert.NotContains(t, lv, "copilot-requests")
275+ for k, v := range lv {
276+ assert.NotEqual(t, "write", v, k)
277+ }
278+
279+ c.commitIn(c.wt, "evil.txt", "x")
280+ out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/heads/agent/x/a")
281+ require.Error(t, err, "its token cannot push: "+out)
282+ out, err = c.clone(job.GetGithubToken(), c.url("/owner/proj"))
283+ require.NoError(t, err, "but it reads: "+out)
284+}
285+
286+// D1 / D3 on re-runs: the trust of a run is kept or lowered and the token of the
287+// new attempt follows it, whoever runs it again.
288+func TestRerunsNeverRaiseTheJobTokenPermissions(t *testing.T) {
289+ c := newCIHarness(t, nil)
290+ runner := c.registerRunner("alpha", "ubuntu-latest")
291+ agent, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeCIWrite), []string{"agent/x/"}, "proj")
292+ wf := "on: push\npermissions: write-all\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n"
293+ rerun := func(api *clients, id int64) error {
294+ _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
295+ return err
296+ }
297+ byRef := func(ref string) *pb.Run {
298+ for _, r := range c.runs() {
299+ if r.GetRef() == ref {
300+ return r
301+ }
302+ }
303+ return nil
304+ }
305+ done := func(j *pb.Job) {
306+ runner.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil)
307+ }
308+ noPush := func(j *pb.Job, ref string) {
309+ t.Helper()
310+ assert.False(t, j.GetTrusted())
311+ assert.Equal(t, "read", levelsOf(j)["contents"])
312+ for k, v := range levelsOf(j) {
313+ assert.NotEqual(t, "write", v, k)
314+ }
315+ c.mustGit(c.wt, "checkout", "-q", "-B", "tmp", "main")
316+ c.commitIn(c.wt, "x-"+ref[len(ref)-1:]+".txt", "x")
317+ out, err := c.tokenPush(c.wt, j.GetGithubToken(), "HEAD:refs/heads/"+ref)
318+ require.Error(t, err, "the token cannot push: "+out)
319+ }
320+
321+ // The owner's run: write-all, trusted.
322+ c.push("main", map[string]string{".github/workflows/p.yml": wf}, "")
323+ owner := runner.await()
324+ assert.True(t, owner.GetTrusted())
325+ assert.Equal(t, "write", levelsOf(owner)["contents"])
326+ done(owner)
327+ c.waitFor("owner run done", func() bool {
328+ r := byRef("refs/heads/main")
329+ return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
330+ })
331+ mainRun := byRef("refs/heads/main")
332+
333+ // The agent's run: untrusted, read-only, and the owner's re-run of it too.
334+ c.mustGit(c.wt, "checkout", "-q", "main")
335+ c.pushAs(agent, "agent/x/a", map[string]string{"a.txt": "a"})
336+ first := runner.await()
337+ noPush(first, "agent/x/a")
338+ done(first)
339+ c.waitFor("agent run done", func() bool {
340+ r := byRef("refs/heads/agent/x/a")
341+ return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
342+ })
343+ agentRun := byRef("refs/heads/agent/x/a")
344+ require.NoError(t, rerun(c.root, agentRun.GetId()))
345+ again := runner.await()
346+ assert.Equal(t, int32(2), again.GetAttempt())
347+ noPush(again, "agent/x/a")
348+ done(again)
349+
350+ // The agent runs the owner's run again: refused, as it is outside its
351+ // branches, and the run stays trusted with a token that writes.
352+ require.Error(t, rerun(c.api(agent), mainRun.GetId()))
353+ assert.True(t, c.getRun(mainRun.GetId()).GetRun().GetTrusted())
354+
355+ // A run of the owner on a branch the agent may touch: the agent's re-run of
356+ // it is allowed, and its attempt is read-only.
357+ c.mustGit(c.wt, "checkout", "-q", "-B", "agent/x/own", "main")
358+ c.commitIn(c.wt, "own.txt", "own")
359+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "agent/x/own")
360+ ownerOnBranch := runner.await()
361+ assert.True(t, ownerOnBranch.GetTrusted())
362+ assert.Equal(t, "write", levelsOf(ownerOnBranch)["contents"])
363+ done(ownerOnBranch)
364+ c.waitFor("owner branch run done", func() bool {
365+ r := byRef("refs/heads/agent/x/own")
366+ return r != nil && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
367+ })
368+ branchRun := byRef("refs/heads/agent/x/own")
369+ require.NoError(t, rerun(c.api(agent), branchRun.GetId()))
370+ lowered := runner.await()
371+ noPush(lowered, "agent/x/own")
372+ assert.False(t, c.getRun(branchRun.GetId()).GetRun().GetTrusted(), "the re-run is not trusted")
373+ done(lowered)
374+}
M internal/app/forge.go +1 -1
@@ -248,7 +248,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
248248 opts.ci(&ciCfg)
249249 }
250250 f.CI = usecase.NewCI(ctx, ciCfg)
251- f.Pushes.WithWorkflowTrigger(f.CI)
251+ f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git)
252252 f.Repos.WithCIRunCleaner(f.CI)
253253 f.CI.Start()
254254 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
@@ -248,7 +248,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
248 opts.ci(&ciCfg)248 opts.ci(&ciCfg)
249 }249 }
250 f.CI = usecase.NewCI(ctx, ciCfg)250 f.CI = usecase.NewCI(ctx, ciCfg)
251- f.Pushes.WithWorkflowTrigger(f.CI)251+ f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git)
252 f.Repos.WithCIRunCleaner(f.CI)252 f.Repos.WithCIRunCleaner(f.CI)
253 f.CI.Start()253 f.CI.Start()
254 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{254 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
M internal/app/hook.go +3 -1
@@ -8,6 +8,7 @@ import (
88 "github.com/spf13/cobra"
99
1010 "bnema.dev/ermite/internal/adapters/githttp"
11+ "bnema.dev/ermite/internal/ports"
1112 )
1213
1314 // newHookCmd is the hidden command that git's receive hooks run (through the
@@ -30,7 +31,8 @@ func newHookCmd() *cobra.Command {
3031 SilenceUsage: true,
3132 RunE: func(cmd *cobra.Command, _ []string) error {
3233 return githttp.RunHook(cmd.Context(), name, cmd.InOrStdin(), cmd.ErrOrStderr(),
33- os.Getenv(githttp.EnvHookSocket), os.Getenv(githttp.EnvHookToken))
34+ os.Getenv(githttp.EnvHookSocket), os.Getenv(githttp.EnvHookToken),
35+ ports.PushObjects{Dir: os.Getenv("GIT_OBJECT_DIRECTORY"), Alternates: os.Getenv("GIT_ALTERNATE_OBJECT_DIRECTORIES")})
3436 },
3537 })
3638 }
@@ -8,6 +8,7 @@ import (
8 "github.com/spf13/cobra"8 "github.com/spf13/cobra"
9 9
10 "bnema.dev/ermite/internal/adapters/githttp"10 "bnema.dev/ermite/internal/adapters/githttp"
11+ "bnema.dev/ermite/internal/ports"
11 )12 )
12 13
13 // newHookCmd is the hidden command that git's receive hooks run (through the14 // newHookCmd is the hidden command that git's receive hooks run (through the
@@ -30,7 +31,8 @@ func newHookCmd() *cobra.Command {
30 SilenceUsage: true,31 SilenceUsage: true,
31 RunE: func(cmd *cobra.Command, _ []string) error {32 RunE: func(cmd *cobra.Command, _ []string) error {
32 return githttp.RunHook(cmd.Context(), name, cmd.InOrStdin(), cmd.ErrOrStderr(),33 return githttp.RunHook(cmd.Context(), name, cmd.InOrStdin(), cmd.ErrOrStderr(),
33- os.Getenv(githttp.EnvHookSocket), os.Getenv(githttp.EnvHookToken))34+ os.Getenv(githttp.EnvHookSocket), os.Getenv(githttp.EnvHookToken),
35+ ports.PushObjects{Dir: os.Getenv("GIT_OBJECT_DIRECTORY"), Alternates: os.Getenv("GIT_ALTERNATE_OBJECT_DIRECTORIES")})
34 },36 },
35 })37 })
36 }38 }
M internal/domain/ci.go +6 -0
@@ -270,6 +270,12 @@ type CIJob struct {
270270 // "environment:"), "" for none. Set when the job is planned; the secrets
271271 // and variables of the environment are read when the job is assigned.
272272 Environment string
273+ // Permissions are the permissions of the job's repository token as the
274+ // workflow and the job ask for them (ResolvePermissions), key to level;
275+ // set when the job is planned. Empty: the default (a job planned before
276+ // they were stored). The trust rule of the run is applied when the token
277+ // is issued (TokenPermissions), not here.
278+ Permissions map[string]string
273279 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
274280 MaxParallel int
275281 Outputs map[string]string
@@ -270,6 +270,12 @@ type CIJob struct {
270 // "environment:"), "" for none. Set when the job is planned; the secrets270 // "environment:"), "" for none. Set when the job is planned; the secrets
271 // and variables of the environment are read when the job is assigned.271 // and variables of the environment are read when the job is assigned.
272 Environment string272 Environment string
273+ // Permissions are the permissions of the job's repository token as the
274+ // workflow and the job ask for them (ResolvePermissions), key to level;
275+ // set when the job is planned. Empty: the default (a job planned before
276+ // they were stored). The trust rule of the run is applied when the token
277+ // is issued (TokenPermissions), not here.
278+ Permissions map[string]string
273 // MaxParallel is the max-parallel of the strategy, 0 for no limit.279 // MaxParallel is the max-parallel of the strategy, 0 for no limit.
274 MaxParallel int280 MaxParallel int
275 Outputs map[string]string281 Outputs map[string]string
A internal/domain/ci_permissions.go +174 -0
@@ -0,0 +1,174 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+// The permissions of a job repository token (CI-3, D3) are the keys and levels
6+// of the "permissions:" of a GitHub workflow. They are computed once, when the
7+// job is planned, and stored as a map from key to level; a key that is absent
8+// from the map is "none". What they allow on ermite is in RepoTokenScopes.
9+
10+// Permission levels, as a workflow spells them.
11+const (
12+ PermissionNone = "none"
13+ PermissionRead = "read"
14+ PermissionWrite = "write"
15+)
16+
17+// Shorthands of a "permissions:" that is a string.
18+const (
19+ PermissionsReadAll = "read-all"
20+ PermissionsWriteAll = "write-all"
21+)
22+
23+// Permission keys ermite acts on.
24+const (
25+ PermContents = "contents"
26+ PermPullRequests = "pull-requests"
27+ // PermMetadata is always read: a workflow cannot set it.
28+ PermMetadata = "metadata"
29+)
30+
31+// permissionKey describes a key of "permissions:" (workflow-v1.0.json,
32+// permissions-mapping).
33+type permissionKey struct {
34+ name string
35+ // levels tell what the key accepts besides none: "read", "write" or both.
36+ canRead, canWrite bool
37+ // writeAll is the level "write-all" grants.
38+ writeAll string
39+}
40+
41+// permissionKeys are the keys a workflow can set. "write-all" keeps models and
42+// vulnerability-alerts at read, as GitHub does; "read-all" grants read to the
43+// keys that have one.
44+var permissionKeys = []permissionKey{
45+ {"actions", true, true, PermissionWrite},
46+ {"artifact-metadata", true, true, PermissionWrite},
47+ {"attestations", true, true, PermissionWrite},
48+ {"checks", true, true, PermissionWrite},
49+ {"code-quality", true, true, PermissionWrite},
50+ {PermContents, true, true, PermissionWrite},
51+ {"copilot-requests", false, true, PermissionWrite},
52+ {"deployments", true, true, PermissionWrite},
53+ {"discussions", true, true, PermissionWrite},
54+ {"drives", true, true, PermissionWrite},
55+ {"id-token", false, true, PermissionWrite},
56+ {"issues", true, true, PermissionWrite},
57+ {"models", true, false, PermissionRead},
58+ {"packages", true, true, PermissionWrite},
59+ {"pages", true, true, PermissionWrite},
60+ {PermPullRequests, true, true, PermissionWrite},
61+ {"repository-projects", true, true, PermissionWrite},
62+ {"security-events", true, true, PermissionWrite},
63+ {"statuses", true, true, PermissionWrite},
64+ {"vulnerability-alerts", true, false, PermissionRead},
65+}
66+
67+func findPermissionKey(name string) (permissionKey, bool) {
68+ for _, k := range permissionKeys {
69+ if k.name == name {
70+ return k, true
71+ }
72+ }
73+ return permissionKey{}, false
74+}
75+
76+// PermissionSpec is a "permissions:" as written in a workflow or a job: a
77+// shorthand (read-all, write-all) or a level per key. A nil *PermissionSpec is
78+// no "permissions:" at all; an empty spec is "permissions: {}".
79+type PermissionSpec struct {
80+ All string
81+ Levels map[string]string
82+}
83+
84+// DefaultTokenPermissions are the permissions of a token whose workflow says
85+// nothing: GitHub's restricted default (ermite offers no permissive setting).
86+func DefaultTokenPermissions() map[string]string {
87+ return map[string]string{PermContents: PermissionRead, PermMetadata: PermissionRead, "packages": PermissionRead}
88+}
89+
90+// ResolvePermissions returns the permissions a job asks for: the job's
91+// "permissions:" replaces the workflow's as a whole (GitHub does not merge
92+// them), and metadata is always read. It returns nil when neither says anything:
93+// the job then gets the default (TokenPermissions). A key or level ermite does
94+// not know is none. The trust rule of the run is not applied here: it is applied
95+// when the token is issued (TokenPermissions), so that a re-run of the same job
96+// by another caller lowers the token without planning again.
97+func ResolvePermissions(workflow, job *PermissionSpec) map[string]string {
98+ spec := job
99+ if spec == nil {
100+ spec = workflow
101+ }
102+ if spec == nil {
103+ return nil
104+ }
105+ out := map[string]string{PermMetadata: PermissionRead}
106+ switch spec.All {
107+ case PermissionsReadAll:
108+ for _, k := range permissionKeys {
109+ if k.canRead {
110+ out[k.name] = PermissionRead
111+ }
112+ }
113+ case PermissionsWriteAll:
114+ for _, k := range permissionKeys {
115+ out[k.name] = k.writeAll
116+ }
117+ default:
118+ for name, level := range spec.Levels {
119+ k, ok := findPermissionKey(name)
120+ switch {
121+ case !ok, level == PermissionNone:
122+ case level == PermissionRead && k.canRead, level == PermissionWrite && k.canWrite:
123+ out[name] = level
124+ }
125+ }
126+ }
127+ return out
128+}
129+
130+// TokenPermissions returns the permissions a job token is issued with, given
131+// those stored with the job: the default when none are stored (a job planned
132+// before they existed), and, for a run that is not trusted (D1), every write
133+// lowered to read (to none for a key that has no read). It copies its input.
134+func TokenPermissions(stored map[string]string, trusted bool) map[string]string {
135+ if len(stored) == 0 {
136+ stored = DefaultTokenPermissions()
137+ }
138+ out := make(map[string]string, len(stored)+1)
139+ for name, level := range stored {
140+ if level == PermissionWrite && !trusted {
141+ if k, ok := findPermissionKey(name); ok && !k.canRead {
142+ continue
143+ }
144+ level = PermissionRead
145+ }
146+ if level == PermissionRead || level == PermissionWrite {
147+ out[name] = level
148+ }
149+ }
150+ out[PermMetadata] = PermissionRead
151+ return out
152+}
153+
154+// RepoTokenScopes maps the permissions of a job token to the scopes of the
155+// principal on the run's repository: contents read is repo:read, contents write
156+// adds repo:write, pull-requests write is mr:write. Never repo:admin, mr:merge,
157+// secrets, tokens or CI scopes, whatever the permissions say. Permissions
158+// without an effect on ermite grant nothing.
159+func RepoTokenScopes(perms map[string]string) []Scope {
160+ if len(perms) == 0 {
161+ perms = DefaultTokenPermissions()
162+ }
163+ var scopes []Scope
164+ switch perms[PermContents] {
165+ case PermissionWrite:
166+ scopes = append(scopes, ScopeRepoRead, ScopeRepoWrite)
167+ case PermissionRead:
168+ scopes = append(scopes, ScopeRepoRead)
169+ }
170+ if perms[PermPullRequests] == PermissionWrite {
171+ scopes = append(scopes, ScopeMRWrite)
172+ }
173+ return scopes
174+}
@@ -0,0 +1,174 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+// The permissions of a job repository token (CI-3, D3) are the keys and levels
6+// of the "permissions:" of a GitHub workflow. They are computed once, when the
7+// job is planned, and stored as a map from key to level; a key that is absent
8+// from the map is "none". What they allow on ermite is in RepoTokenScopes.
9+
10+// Permission levels, as a workflow spells them.
11+const (
12+ PermissionNone = "none"
13+ PermissionRead = "read"
14+ PermissionWrite = "write"
15+)
16+
17+// Shorthands of a "permissions:" that is a string.
18+const (
19+ PermissionsReadAll = "read-all"
20+ PermissionsWriteAll = "write-all"
21+)
22+
23+// Permission keys ermite acts on.
24+const (
25+ PermContents = "contents"
26+ PermPullRequests = "pull-requests"
27+ // PermMetadata is always read: a workflow cannot set it.
28+ PermMetadata = "metadata"
29+)
30+
31+// permissionKey describes a key of "permissions:" (workflow-v1.0.json,
32+// permissions-mapping).
33+type permissionKey struct {
34+ name string
35+ // levels tell what the key accepts besides none: "read", "write" or both.
36+ canRead, canWrite bool
37+ // writeAll is the level "write-all" grants.
38+ writeAll string
39+}
40+
41+// permissionKeys are the keys a workflow can set. "write-all" keeps models and
42+// vulnerability-alerts at read, as GitHub does; "read-all" grants read to the
43+// keys that have one.
44+var permissionKeys = []permissionKey{
45+ {"actions", true, true, PermissionWrite},
46+ {"artifact-metadata", true, true, PermissionWrite},
47+ {"attestations", true, true, PermissionWrite},
48+ {"checks", true, true, PermissionWrite},
49+ {"code-quality", true, true, PermissionWrite},
50+ {PermContents, true, true, PermissionWrite},
51+ {"copilot-requests", false, true, PermissionWrite},
52+ {"deployments", true, true, PermissionWrite},
53+ {"discussions", true, true, PermissionWrite},
54+ {"drives", true, true, PermissionWrite},
55+ {"id-token", false, true, PermissionWrite},
56+ {"issues", true, true, PermissionWrite},
57+ {"models", true, false, PermissionRead},
58+ {"packages", true, true, PermissionWrite},
59+ {"pages", true, true, PermissionWrite},
60+ {PermPullRequests, true, true, PermissionWrite},
61+ {"repository-projects", true, true, PermissionWrite},
62+ {"security-events", true, true, PermissionWrite},
63+ {"statuses", true, true, PermissionWrite},
64+ {"vulnerability-alerts", true, false, PermissionRead},
65+}
66+
67+func findPermissionKey(name string) (permissionKey, bool) {
68+ for _, k := range permissionKeys {
69+ if k.name == name {
70+ return k, true
71+ }
72+ }
73+ return permissionKey{}, false
74+}
75+
76+// PermissionSpec is a "permissions:" as written in a workflow or a job: a
77+// shorthand (read-all, write-all) or a level per key. A nil *PermissionSpec is
78+// no "permissions:" at all; an empty spec is "permissions: {}".
79+type PermissionSpec struct {
80+ All string
81+ Levels map[string]string
82+}
83+
84+// DefaultTokenPermissions are the permissions of a token whose workflow says
85+// nothing: GitHub's restricted default (ermite offers no permissive setting).
86+func DefaultTokenPermissions() map[string]string {
87+ return map[string]string{PermContents: PermissionRead, PermMetadata: PermissionRead, "packages": PermissionRead}
88+}
89+
90+// ResolvePermissions returns the permissions a job asks for: the job's
91+// "permissions:" replaces the workflow's as a whole (GitHub does not merge
92+// them), and metadata is always read. It returns nil when neither says anything:
93+// the job then gets the default (TokenPermissions). A key or level ermite does
94+// not know is none. The trust rule of the run is not applied here: it is applied
95+// when the token is issued (TokenPermissions), so that a re-run of the same job
96+// by another caller lowers the token without planning again.
97+func ResolvePermissions(workflow, job *PermissionSpec) map[string]string {
98+ spec := job
99+ if spec == nil {
100+ spec = workflow
101+ }
102+ if spec == nil {
103+ return nil
104+ }
105+ out := map[string]string{PermMetadata: PermissionRead}
106+ switch spec.All {
107+ case PermissionsReadAll:
108+ for _, k := range permissionKeys {
109+ if k.canRead {
110+ out[k.name] = PermissionRead
111+ }
112+ }
113+ case PermissionsWriteAll:
114+ for _, k := range permissionKeys {
115+ out[k.name] = k.writeAll
116+ }
117+ default:
118+ for name, level := range spec.Levels {
119+ k, ok := findPermissionKey(name)
120+ switch {
121+ case !ok, level == PermissionNone:
122+ case level == PermissionRead && k.canRead, level == PermissionWrite && k.canWrite:
123+ out[name] = level
124+ }
125+ }
126+ }
127+ return out
128+}
129+
130+// TokenPermissions returns the permissions a job token is issued with, given
131+// those stored with the job: the default when none are stored (a job planned
132+// before they existed), and, for a run that is not trusted (D1), every write
133+// lowered to read (to none for a key that has no read). It copies its input.
134+func TokenPermissions(stored map[string]string, trusted bool) map[string]string {
135+ if len(stored) == 0 {
136+ stored = DefaultTokenPermissions()
137+ }
138+ out := make(map[string]string, len(stored)+1)
139+ for name, level := range stored {
140+ if level == PermissionWrite && !trusted {
141+ if k, ok := findPermissionKey(name); ok && !k.canRead {
142+ continue
143+ }
144+ level = PermissionRead
145+ }
146+ if level == PermissionRead || level == PermissionWrite {
147+ out[name] = level
148+ }
149+ }
150+ out[PermMetadata] = PermissionRead
151+ return out
152+}
153+
154+// RepoTokenScopes maps the permissions of a job token to the scopes of the
155+// principal on the run's repository: contents read is repo:read, contents write
156+// adds repo:write, pull-requests write is mr:write. Never repo:admin, mr:merge,
157+// secrets, tokens or CI scopes, whatever the permissions say. Permissions
158+// without an effect on ermite grant nothing.
159+func RepoTokenScopes(perms map[string]string) []Scope {
160+ if len(perms) == 0 {
161+ perms = DefaultTokenPermissions()
162+ }
163+ var scopes []Scope
164+ switch perms[PermContents] {
165+ case PermissionWrite:
166+ scopes = append(scopes, ScopeRepoRead, ScopeRepoWrite)
167+ case PermissionRead:
168+ scopes = append(scopes, ScopeRepoRead)
169+ }
170+ if perms[PermPullRequests] == PermissionWrite {
171+ scopes = append(scopes, ScopeMRWrite)
172+ }
173+ return scopes
174+}
A internal/domain/ci_permissions_test.go +138 -0
@@ -0,0 +1,138 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+
11+ "bnema.dev/ermite/internal/domain"
12+)
13+
14+func spec(all string, kv ...string) *domain.PermissionSpec {
15+ s := &domain.PermissionSpec{All: all, Levels: map[string]string{}}
16+ for i := 0; i+1 < len(kv); i += 2 {
17+ s.Levels[kv[i]] = kv[i+1]
18+ }
19+ return s
20+}
21+
22+func TestResolvePermissions(t *testing.T) {
23+ t.Parallel()
24+ for _, tt := range []struct {
25+ name string
26+ workflow, job *domain.PermissionSpec
27+ want map[string]string // nil: nothing asked, the default applies
28+ }{
29+ {"nothing anywhere", nil, nil, nil},
30+ {"workflow only", spec("", "contents", "write"), nil, map[string]string{"metadata": "read", "contents": "write"}},
31+ {"job replaces the workflow, no merge", spec("", "contents", "write", "issues", "write"), spec("", "pull-requests", "read"),
32+ map[string]string{"metadata": "read", "pull-requests": "read"}},
33+ {"job alone", nil, spec("", "contents", "write"), map[string]string{"metadata": "read", "contents": "write"}},
34+ {"empty mapping is all none", spec("", "contents", "write"), spec(""), map[string]string{"metadata": "read"}},
35+ {"workflow {} with no job", spec(""), nil, map[string]string{"metadata": "read"}},
36+ {"none removes a key", spec("", "contents", "none", "packages", "read"), nil, map[string]string{"metadata": "read", "packages": "read"}},
37+ {"read-all", spec("read-all"), nil, map[string]string{
38+ "metadata": "read", "actions": "read", "artifact-metadata": "read", "attestations": "read", "checks": "read", "code-quality": "read",
39+ "contents": "read", "deployments": "read", "discussions": "read", "drives": "read", "issues": "read", "models": "read",
40+ "packages": "read", "pages": "read", "pull-requests": "read", "repository-projects": "read", "security-events": "read",
41+ "statuses": "read", "vulnerability-alerts": "read"}},
42+ {"write-all", nil, spec("write-all"), map[string]string{
43+ "metadata": "read", "actions": "write", "artifact-metadata": "write", "attestations": "write", "checks": "write", "code-quality": "write",
44+ "contents": "write", "copilot-requests": "write", "deployments": "write", "discussions": "write", "drives": "write", "id-token": "write",
45+ "issues": "write", "models": "read", "packages": "write", "pages": "write", "pull-requests": "write", "repository-projects": "write",
46+ "security-events": "write", "statuses": "write", "vulnerability-alerts": "read"}},
47+ {"job read-all over workflow write-all", spec("write-all"), spec("read-all"), nil}, // checked below by key
48+ {"a level a key cannot have is none", spec("", "models", "write", "id-token", "read", "contents", "read"), nil,
49+ map[string]string{"metadata": "read", "contents": "read"}},
50+ {"unknown keys and levels are none", spec("", "bogus", "write", "contents", "admin"), nil, map[string]string{"metadata": "read"}},
51+ {"id-token write", spec("", "id-token", "write"), nil, map[string]string{"metadata": "read", "id-token": "write"}},
52+ } {
53+ got := domain.ResolvePermissions(tt.workflow, tt.job)
54+ if tt.name == "job read-all over workflow write-all" {
55+ assert.Equal(t, "read", got["contents"], tt.name)
56+ assert.Equal(t, "read", got["issues"], tt.name)
57+ assert.Equal(t, "read", got["models"], tt.name)
58+ assert.NotContains(t, got, "id-token", tt.name)
59+ continue
60+ }
61+ assert.Equal(t, tt.want, got, tt.name)
62+ }
63+}
64+
65+func TestTokenPermissionsTrust(t *testing.T) {
66+ t.Parallel()
67+ def := map[string]string{"contents": "read", "metadata": "read", "packages": "read"}
68+ assert.Equal(t, def, domain.TokenPermissions(nil, true), "absent: the restricted default")
69+ assert.Equal(t, def, domain.TokenPermissions(nil, false))
70+ assert.Equal(t, def, domain.TokenPermissions(map[string]string{}, true), "empty is absent")
71+
72+ asked := map[string]string{"metadata": "read", "contents": "write", "issues": "write", "id-token": "write", "packages": "read", "models": "read"}
73+ assert.Equal(t, asked, domain.TokenPermissions(asked, true), "trusted: as asked")
74+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "issues": "read", "packages": "read", "models": "read"},
75+ domain.TokenPermissions(asked, false), "untrusted: every write is read, id-token (no read) is none")
76+
77+ in := map[string]string{"contents": "write"}
78+ _ = domain.TokenPermissions(in, false)
79+ assert.Equal(t, map[string]string{"contents": "write"}, in, "the input is not changed")
80+ assert.Equal(t, "read", domain.TokenPermissions(map[string]string{"metadata": "none"}, true)["metadata"], "metadata is always read")
81+}
82+
83+func TestRepoTokenScopes(t *testing.T) {
84+ t.Parallel()
85+ for _, tt := range []struct {
86+ name string
87+ perms map[string]string
88+ want []domain.Scope
89+ }{
90+ {"absent is the default", nil, []domain.Scope{domain.ScopeRepoRead}},
91+ {"contents read", map[string]string{"contents": "read"}, []domain.Scope{domain.ScopeRepoRead}},
92+ {"contents write", map[string]string{"contents": "write"}, []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}},
93+ {"contents none", map[string]string{"metadata": "read", "packages": "read"}, nil},
94+ {"pull-requests write only", map[string]string{"pull-requests": "write"}, []domain.Scope{domain.ScopeMRWrite}},
95+ {"pull-requests read", map[string]string{"contents": "read", "pull-requests": "read"}, []domain.Scope{domain.ScopeRepoRead}},
96+ {"both", map[string]string{"contents": "write", "pull-requests": "write"}, []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite}},
97+ {"write-all adds no other scope", domain.ResolvePermissions(spec("write-all"), nil), []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite}},
98+ } {
99+ assert.Equal(t, tt.want, domain.RepoTokenScopes(tt.perms), tt.name)
100+ }
101+}
102+
103+func TestRepoTokenPrincipalFollowsItsPermissions(t *testing.T) {
104+ t.Parallel()
105+ repo, _ := domain.ParseRepoName("proj")
106+ other, _ := domain.ParseRepoName("other")
107+ exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
108+ tok := func(perms map[string]string) domain.Principal {
109+ return domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab12", Repo: repo, ExpiresAt: exp, Permissions: perms})
110+ }
111+
112+ w := tok(map[string]string{"contents": "write", "pull-requests": "write", "actions": "write", "issues": "write", "id-token": "write"})
113+ assert.True(t, w.Allows(domain.ScopeRepoRead, repo))
114+ assert.True(t, w.Allows(domain.ScopeRepoWrite, repo))
115+ assert.True(t, w.Allows(domain.ScopeMRWrite, repo))
116+ assert.False(t, w.Allows(domain.ScopeRepoWrite, other), "one repository only")
117+ for _, s := range []domain.Scope{domain.ScopeRepoAdmin, domain.ScopeMRMerge, domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister, domain.ScopeTokenAdmin} {
118+ assert.False(t, w.HasScope(s), "never "+string(s))
119+ }
120+ assert.False(t, w.HasOwnerRights())
121+ assert.False(t, w.IsBranchLimited())
122+ assert.True(t, w.ViaJobToken())
123+
124+ none := tok(map[string]string{"metadata": "read"})
125+ assert.False(t, none.Allows(domain.ScopeRepoRead, repo), "contents: none reads nothing")
126+ assert.True(t, none.ViaJobToken())
127+
128+ assert.True(t, tok(nil).Allows(domain.ScopeRepoRead, repo))
129+ assert.False(t, tok(nil).Allows(domain.ScopeRepoWrite, repo))
130+
131+ arch := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, Archive: true, Sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ExpiresAt: exp})
132+ assert.True(t, arch.ViaJobToken())
133+
134+ assert.False(t, domain.Root().ViaJobToken())
135+ assert.False(t, domain.Owner().ViaJobToken())
136+ assert.False(t, domain.Anonymous().ViaJobToken())
137+ assert.False(t, domain.TokenPrincipal(domain.Token{ID: "0123456789abcdef"}).ViaJobToken())
138+}
@@ -0,0 +1,138 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+
11+ "bnema.dev/ermite/internal/domain"
12+)
13+
14+func spec(all string, kv ...string) *domain.PermissionSpec {
15+ s := &domain.PermissionSpec{All: all, Levels: map[string]string{}}
16+ for i := 0; i+1 < len(kv); i += 2 {
17+ s.Levels[kv[i]] = kv[i+1]
18+ }
19+ return s
20+}
21+
22+func TestResolvePermissions(t *testing.T) {
23+ t.Parallel()
24+ for _, tt := range []struct {
25+ name string
26+ workflow, job *domain.PermissionSpec
27+ want map[string]string // nil: nothing asked, the default applies
28+ }{
29+ {"nothing anywhere", nil, nil, nil},
30+ {"workflow only", spec("", "contents", "write"), nil, map[string]string{"metadata": "read", "contents": "write"}},
31+ {"job replaces the workflow, no merge", spec("", "contents", "write", "issues", "write"), spec("", "pull-requests", "read"),
32+ map[string]string{"metadata": "read", "pull-requests": "read"}},
33+ {"job alone", nil, spec("", "contents", "write"), map[string]string{"metadata": "read", "contents": "write"}},
34+ {"empty mapping is all none", spec("", "contents", "write"), spec(""), map[string]string{"metadata": "read"}},
35+ {"workflow {} with no job", spec(""), nil, map[string]string{"metadata": "read"}},
36+ {"none removes a key", spec("", "contents", "none", "packages", "read"), nil, map[string]string{"metadata": "read", "packages": "read"}},
37+ {"read-all", spec("read-all"), nil, map[string]string{
38+ "metadata": "read", "actions": "read", "artifact-metadata": "read", "attestations": "read", "checks": "read", "code-quality": "read",
39+ "contents": "read", "deployments": "read", "discussions": "read", "drives": "read", "issues": "read", "models": "read",
40+ "packages": "read", "pages": "read", "pull-requests": "read", "repository-projects": "read", "security-events": "read",
41+ "statuses": "read", "vulnerability-alerts": "read"}},
42+ {"write-all", nil, spec("write-all"), map[string]string{
43+ "metadata": "read", "actions": "write", "artifact-metadata": "write", "attestations": "write", "checks": "write", "code-quality": "write",
44+ "contents": "write", "copilot-requests": "write", "deployments": "write", "discussions": "write", "drives": "write", "id-token": "write",
45+ "issues": "write", "models": "read", "packages": "write", "pages": "write", "pull-requests": "write", "repository-projects": "write",
46+ "security-events": "write", "statuses": "write", "vulnerability-alerts": "read"}},
47+ {"job read-all over workflow write-all", spec("write-all"), spec("read-all"), nil}, // checked below by key
48+ {"a level a key cannot have is none", spec("", "models", "write", "id-token", "read", "contents", "read"), nil,
49+ map[string]string{"metadata": "read", "contents": "read"}},
50+ {"unknown keys and levels are none", spec("", "bogus", "write", "contents", "admin"), nil, map[string]string{"metadata": "read"}},
51+ {"id-token write", spec("", "id-token", "write"), nil, map[string]string{"metadata": "read", "id-token": "write"}},
52+ } {
53+ got := domain.ResolvePermissions(tt.workflow, tt.job)
54+ if tt.name == "job read-all over workflow write-all" {
55+ assert.Equal(t, "read", got["contents"], tt.name)
56+ assert.Equal(t, "read", got["issues"], tt.name)
57+ assert.Equal(t, "read", got["models"], tt.name)
58+ assert.NotContains(t, got, "id-token", tt.name)
59+ continue
60+ }
61+ assert.Equal(t, tt.want, got, tt.name)
62+ }
63+}
64+
65+func TestTokenPermissionsTrust(t *testing.T) {
66+ t.Parallel()
67+ def := map[string]string{"contents": "read", "metadata": "read", "packages": "read"}
68+ assert.Equal(t, def, domain.TokenPermissions(nil, true), "absent: the restricted default")
69+ assert.Equal(t, def, domain.TokenPermissions(nil, false))
70+ assert.Equal(t, def, domain.TokenPermissions(map[string]string{}, true), "empty is absent")
71+
72+ asked := map[string]string{"metadata": "read", "contents": "write", "issues": "write", "id-token": "write", "packages": "read", "models": "read"}
73+ assert.Equal(t, asked, domain.TokenPermissions(asked, true), "trusted: as asked")
74+ assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "issues": "read", "packages": "read", "models": "read"},
75+ domain.TokenPermissions(asked, false), "untrusted: every write is read, id-token (no read) is none")
76+
77+ in := map[string]string{"contents": "write"}
78+ _ = domain.TokenPermissions(in, false)
79+ assert.Equal(t, map[string]string{"contents": "write"}, in, "the input is not changed")
80+ assert.Equal(t, "read", domain.TokenPermissions(map[string]string{"metadata": "none"}, true)["metadata"], "metadata is always read")
81+}
82+
83+func TestRepoTokenScopes(t *testing.T) {
84+ t.Parallel()
85+ for _, tt := range []struct {
86+ name string
87+ perms map[string]string
88+ want []domain.Scope
89+ }{
90+ {"absent is the default", nil, []domain.Scope{domain.ScopeRepoRead}},
91+ {"contents read", map[string]string{"contents": "read"}, []domain.Scope{domain.ScopeRepoRead}},
92+ {"contents write", map[string]string{"contents": "write"}, []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}},
93+ {"contents none", map[string]string{"metadata": "read", "packages": "read"}, nil},
94+ {"pull-requests write only", map[string]string{"pull-requests": "write"}, []domain.Scope{domain.ScopeMRWrite}},
95+ {"pull-requests read", map[string]string{"contents": "read", "pull-requests": "read"}, []domain.Scope{domain.ScopeRepoRead}},
96+ {"both", map[string]string{"contents": "write", "pull-requests": "write"}, []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite}},
97+ {"write-all adds no other scope", domain.ResolvePermissions(spec("write-all"), nil), []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite}},
98+ } {
99+ assert.Equal(t, tt.want, domain.RepoTokenScopes(tt.perms), tt.name)
100+ }
101+}
102+
103+func TestRepoTokenPrincipalFollowsItsPermissions(t *testing.T) {
104+ t.Parallel()
105+ repo, _ := domain.ParseRepoName("proj")
106+ other, _ := domain.ParseRepoName("other")
107+ exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
108+ tok := func(perms map[string]string) domain.Principal {
109+ return domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab12", Repo: repo, ExpiresAt: exp, Permissions: perms})
110+ }
111+
112+ w := tok(map[string]string{"contents": "write", "pull-requests": "write", "actions": "write", "issues": "write", "id-token": "write"})
113+ assert.True(t, w.Allows(domain.ScopeRepoRead, repo))
114+ assert.True(t, w.Allows(domain.ScopeRepoWrite, repo))
115+ assert.True(t, w.Allows(domain.ScopeMRWrite, repo))
116+ assert.False(t, w.Allows(domain.ScopeRepoWrite, other), "one repository only")
117+ for _, s := range []domain.Scope{domain.ScopeRepoAdmin, domain.ScopeMRMerge, domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister, domain.ScopeTokenAdmin} {
118+ assert.False(t, w.HasScope(s), "never "+string(s))
119+ }
120+ assert.False(t, w.HasOwnerRights())
121+ assert.False(t, w.IsBranchLimited())
122+ assert.True(t, w.ViaJobToken())
123+
124+ none := tok(map[string]string{"metadata": "read"})
125+ assert.False(t, none.Allows(domain.ScopeRepoRead, repo), "contents: none reads nothing")
126+ assert.True(t, none.ViaJobToken())
127+
128+ assert.True(t, tok(nil).Allows(domain.ScopeRepoRead, repo))
129+ assert.False(t, tok(nil).Allows(domain.ScopeRepoWrite, repo))
130+
131+ arch := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, Archive: true, Sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ExpiresAt: exp})
132+ assert.True(t, arch.ViaJobToken())
133+
134+ assert.False(t, domain.Root().ViaJobToken())
135+ assert.False(t, domain.Owner().ViaJobToken())
136+ assert.False(t, domain.Anonymous().ViaJobToken())
137+ assert.False(t, domain.TokenPrincipal(domain.Token{ID: "0123456789abcdef"}).ViaJobToken())
138+}
M internal/domain/ci_token.go +13 -8
@@ -41,9 +41,12 @@ type CIRepoToken struct {
4141 // Repo and nothing else.
4242 Archive bool
4343 // Sha is the commit an archive token is bound to; empty for the job's own.
44- Sha string
45- ExpiresAt time.Time
46- CreatedAt time.Time
44+ Sha string
45+ // Permissions are the permissions of the job's own token (key to level, see
46+ // TokenPermissions). Empty: the default, which reads the repository.
47+ Permissions map[string]string
48+ ExpiresAt time.Time
49+ CreatedAt time.Time
4750 }
4851
4952 // Expired reports whether the token is no longer valid at now.
@@ -56,10 +59,12 @@ const RepoTokenGrace = 5 * time.Minute
5659 // RepoTokenPrincipal returns the principal of the holder of a job repository
5760 // token.
5861 //
59-// The job's own token is shaped like a stored token (the scope repo:read on one
60-// repository, an expiry), so that every authorization rule of the forge applies
61-// to it unchanged: it reads that repository, even when private, and writes
62-// nothing.
62+// The job's own token is shaped like a stored token (scopes on one repository,
63+// an expiry), so that every authorization rule of the forge applies to it
64+// unchanged. Its scopes follow its permissions (RepoTokenScopes): by default it
65+// reads that repository, even when private, and writes nothing; with
66+// contents: write it also pushes to it. It has no branch prefixes: a run whose
67+// actor is limited to branches is not trusted and holds no write permission.
6368 //
6469 // An archive token holds no scope at all: it is the grant to download one
6570 // commit of one repository as an archive, which only the archive endpoint
@@ -76,7 +81,7 @@ func RepoTokenPrincipal(t CIRepoToken) Principal {
7681 }
7782 }
7883 return TokenPrincipal(Token{
79- ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},
84+ ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: RepoTokenScopes(t.Permissions),
8085 Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,
8186 })
8287 }
@@ -41,9 +41,12 @@ type CIRepoToken struct {
41 // Repo and nothing else.41 // Repo and nothing else.
42 Archive bool42 Archive bool
43 // Sha is the commit an archive token is bound to; empty for the job's own.43 // Sha is the commit an archive token is bound to; empty for the job's own.
44- Sha string44+ Sha string
45- ExpiresAt time.Time45+ // Permissions are the permissions of the job's own token (key to level, see
46- CreatedAt time.Time46+ // TokenPermissions). Empty: the default, which reads the repository.
47+ Permissions map[string]string
48+ ExpiresAt time.Time
49+ CreatedAt time.Time
47 }50 }
48 51
49 // Expired reports whether the token is no longer valid at now.52 // Expired reports whether the token is no longer valid at now.
@@ -56,10 +59,12 @@ const RepoTokenGrace = 5 * time.Minute
56 // RepoTokenPrincipal returns the principal of the holder of a job repository59 // RepoTokenPrincipal returns the principal of the holder of a job repository
57 // token.60 // token.
58 //61 //
59-// The job's own token is shaped like a stored token (the scope repo:read on one62+// The job's own token is shaped like a stored token (scopes on one repository,
60-// repository, an expiry), so that every authorization rule of the forge applies63+// an expiry), so that every authorization rule of the forge applies to it
61-// to it unchanged: it reads that repository, even when private, and writes64+// unchanged. Its scopes follow its permissions (RepoTokenScopes): by default it
62-// nothing.65+// reads that repository, even when private, and writes nothing; with
66+// contents: write it also pushes to it. It has no branch prefixes: a run whose
67+// actor is limited to branches is not trusted and holds no write permission.
63 //68 //
64 // An archive token holds no scope at all: it is the grant to download one69 // An archive token holds no scope at all: it is the grant to download one
65 // commit of one repository as an archive, which only the archive endpoint70 // commit of one repository as an archive, which only the archive endpoint
@@ -76,7 +81,7 @@ func RepoTokenPrincipal(t CIRepoToken) Principal {
76 }81 }
77 }82 }
78 return TokenPrincipal(Token{83 return TokenPrincipal(Token{
79- ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: []Scope{ScopeRepoRead},84+ ID: repoTokenIDPrefix + t.ID, Label: "ci job", Scopes: RepoTokenScopes(t.Permissions),
80 Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,85 Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt,
81 })86 })
82 }87 }
M internal/domain/principal.go +7 -0
@@ -166,6 +166,13 @@ func (p Principal) AllowsRef(ref string) bool {
166166 return p.CoversBranchPrefix(name)
167167 }
168168
169+// ViaJobToken reports whether p is the holder of a job repository token (the
170+// GITHUB_TOKEN of a CI job, or an action archive token): what it does must not
171+// start CI runs, and it may not change workflows (CI-3, D3).
172+func (p Principal) ViaJobToken() bool {
173+ return (p.kind == kindToken || p.kind == kindArchive) && IsRepoTokenID(p.tokenID)
174+}
175+
169176 // ExpiresAt returns the expiry of a stored token; zero means none.
170177 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
171178
@@ -166,6 +166,13 @@ func (p Principal) AllowsRef(ref string) bool {
166 return p.CoversBranchPrefix(name)166 return p.CoversBranchPrefix(name)
167 }167 }
168 168
169+// ViaJobToken reports whether p is the holder of a job repository token (the
170+// GITHUB_TOKEN of a CI job, or an action archive token): what it does must not
171+// start CI runs, and it may not change workflows (CI-3, D3).
172+func (p Principal) ViaJobToken() bool {
173+ return (p.kind == kindToken || p.kind == kindArchive) && IsRepoTokenID(p.tokenID)
174+}
175+
169 // ExpiresAt returns the expiry of a stored token; zero means none.176 // ExpiresAt returns the expiry of a stored token; zero means none.
170 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }177 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
171 178
M internal/ports/ci.go +25 -0
@@ -259,6 +259,28 @@ type CIGit interface {
259259 CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
260260 }
261261
262+// PushObjects tells where the objects of a push in progress are. Git receives a
263+// push into a quarantine directory and moves the objects into the repository
264+// only when pre-receive accepts it, so the objects a pre-receive check reads are
265+// not in the repository yet. Both fields are what git's own environment holds
266+// in the hook (GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES). The
267+// zero value is a push with nothing in quarantine.
268+type PushObjects struct {
269+ Dir string
270+ Alternates string
271+}
272+
273+// WorkflowGuard finds the changes of a push to the workflow files of a
274+// repository.
275+type WorkflowGuard interface {
276+ // ChangedWorkflow returns the first path under .github/workflows/ that
277+ // the updates create or change, from the old tree of each ref (the empty tree
278+ // for a new ref) to its new one. A deleted ref changes nothing. "" means
279+ // none. objects are those of the push in progress; a directory that is not
280+ // inside the repository is refused.
281+ ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error)
282+}
283+
262284 // --- inbound ---
263285
264286 // RegisterRunnerParams describe a runner to register.
@@ -299,6 +321,9 @@ type RunnerJob struct {
299321 Secrets map[string]string
300322 // Trusted is the trusted flag of the run: secrets were allowed.
301323 Trusted bool
324+ // Permissions are the permissions of GithubToken (key to level), after the
325+ // trust rule: no write when the run is not trusted.
326+ Permissions map[string]string
302327 }
303328
304329 // String describes the job without its secrets and tokens, so that a job
@@ -259,6 +259,28 @@ type CIGit interface {
259 CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)259 CommitsOffBranches(ctx context.Context, repo domain.RepoName, head domain.ObjectID, limit int) (commits []domain.Commit, more bool, err error)
260 }260 }
261 261
262+// PushObjects tells where the objects of a push in progress are. Git receives a
263+// push into a quarantine directory and moves the objects into the repository
264+// only when pre-receive accepts it, so the objects a pre-receive check reads are
265+// not in the repository yet. Both fields are what git's own environment holds
266+// in the hook (GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES). The
267+// zero value is a push with nothing in quarantine.
268+type PushObjects struct {
269+ Dir string
270+ Alternates string
271+}
272+
273+// WorkflowGuard finds the changes of a push to the workflow files of a
274+// repository.
275+type WorkflowGuard interface {
276+ // ChangedWorkflow returns the first path under .github/workflows/ that
277+ // the updates create or change, from the old tree of each ref (the empty tree
278+ // for a new ref) to its new one. A deleted ref changes nothing. "" means
279+ // none. objects are those of the push in progress; a directory that is not
280+ // inside the repository is refused.
281+ ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error)
282+}
283+
262 // --- inbound ---284 // --- inbound ---
263 285
264 // RegisterRunnerParams describe a runner to register.286 // RegisterRunnerParams describe a runner to register.
@@ -299,6 +321,9 @@ type RunnerJob struct {
299 Secrets map[string]string321 Secrets map[string]string
300 // Trusted is the trusted flag of the run: secrets were allowed.322 // Trusted is the trusted flag of the run: secrets were allowed.
301 Trusted bool323 Trusted bool
324+ // Permissions are the permissions of GithubToken (key to level), after the
325+ // trust rule: no write when the run is not trusted.
326+ Permissions map[string]string
302 }327 }
303 328
304 // String describes the job without its secrets and tokens, so that a job329 // String describes the job without its secrets and tokens, so that a job
M internal/ports/mocks/PushHookService.go +15 -8
@@ -8,6 +8,7 @@ import (
88 "context"
99
1010 "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
1112 mock "github.com/stretchr/testify/mock"
1213 )
1314
@@ -48,16 +49,16 @@ func (_m *PushHookService) EXPECT() *PushHookService_Expecter {
4849 }
4950
5051 // Check provides a mock function for the type PushHookService
51-func (_mock *PushHookService) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
52- ret := _mock.Called(ctx, p, name, updates)
52+func (_mock *PushHookService) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
53+ ret := _mock.Called(ctx, p, name, updates, objects)
5354
5455 if len(ret) == 0 {
5556 panic("no return value specified for Check")
5657 }
5758
5859 var r0 error
59- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok {
60- r0 = returnFunc(ctx, p, name, updates)
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok {
61+ r0 = returnFunc(ctx, p, name, updates, objects)
6162 } else {
6263 r0 = ret.Error(0)
6364 }
@@ -74,11 +75,12 @@ type PushHookService_Check_Call struct {
7475 // - p domain.Principal
7576 // - name domain.RepoName
7677 // - updates []domain.RefUpdate
77-func (_e *PushHookService_Expecter) Check(ctx any, p any, name any, updates any) *PushHookService_Check_Call {
78- return &PushHookService_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)}
78+// - objects ports.PushObjects
79+func (_e *PushHookService_Expecter) Check(ctx any, p any, name any, updates any, objects any) *PushHookService_Check_Call {
80+ return &PushHookService_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates, objects)}
7981 }
8082
81-func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHookService_Check_Call {
83+func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *PushHookService_Check_Call {
8284 _c.Call.Run(func(args mock.Arguments) {
8385 var arg0 context.Context
8486 if args[0] != nil {
@@ -96,11 +98,16 @@ func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain
9698 if args[3] != nil {
9799 arg3 = args[3].([]domain.RefUpdate)
98100 }
101+ var arg4 ports.PushObjects
102+ if args[4] != nil {
103+ arg4 = args[4].(ports.PushObjects)
104+ }
99105 run(
100106 arg0,
101107 arg1,
102108 arg2,
103109 arg3,
110+ arg4,
104111 )
105112 })
106113 return _c
@@ -111,7 +118,7 @@ func (_c *PushHookService_Check_Call) Return(err error) *PushHookService_Check_C
111118 return _c
112119 }
113120
114-func (_c *PushHookService_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHookService_Check_Call {
121+func (_c *PushHookService_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error) *PushHookService_Check_Call {
115122 _c.Call.Return(run)
116123 return _c
117124 }
@@ -8,6 +8,7 @@ import (
8 "context"8 "context"
9 9
10 "bnema.dev/ermite/internal/domain"10 "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
11 mock "github.com/stretchr/testify/mock"12 mock "github.com/stretchr/testify/mock"
12 )13 )
13 14
@@ -48,16 +49,16 @@ func (_m *PushHookService) EXPECT() *PushHookService_Expecter {
48 }49 }
49 50
50 // Check provides a mock function for the type PushHookService51 // Check provides a mock function for the type PushHookService
51-func (_mock *PushHookService) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {52+func (_mock *PushHookService) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
52- ret := _mock.Called(ctx, p, name, updates)53+ ret := _mock.Called(ctx, p, name, updates, objects)
53 54
54 if len(ret) == 0 {55 if len(ret) == 0 {
55 panic("no return value specified for Check")56 panic("no return value specified for Check")
56 }57 }
57 58
58 var r0 error59 var r0 error
59- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate) error); ok {60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok {
60- r0 = returnFunc(ctx, p, name, updates)61+ r0 = returnFunc(ctx, p, name, updates, objects)
61 } else {62 } else {
62 r0 = ret.Error(0)63 r0 = ret.Error(0)
63 }64 }
@@ -74,11 +75,12 @@ type PushHookService_Check_Call struct {
74 // - p domain.Principal75 // - p domain.Principal
75 // - name domain.RepoName76 // - name domain.RepoName
76 // - updates []domain.RefUpdate77 // - updates []domain.RefUpdate
77-func (_e *PushHookService_Expecter) Check(ctx any, p any, name any, updates any) *PushHookService_Check_Call {78+// - objects ports.PushObjects
78- return &PushHookService_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates)}79+func (_e *PushHookService_Expecter) Check(ctx any, p any, name any, updates any, objects any) *PushHookService_Check_Call {
80+ return &PushHookService_Check_Call{Call: _e.mock.On("Check", ctx, p, name, updates, objects)}
79 }81 }
80 82
81-func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate)) *PushHookService_Check_Call {83+func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *PushHookService_Check_Call {
82 _c.Call.Run(func(args mock.Arguments) {84 _c.Call.Run(func(args mock.Arguments) {
83 var arg0 context.Context85 var arg0 context.Context
84 if args[0] != nil {86 if args[0] != nil {
@@ -96,11 +98,16 @@ func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain
96 if args[3] != nil {98 if args[3] != nil {
97 arg3 = args[3].([]domain.RefUpdate)99 arg3 = args[3].([]domain.RefUpdate)
98 }100 }
101+ var arg4 ports.PushObjects
102+ if args[4] != nil {
103+ arg4 = args[4].(ports.PushObjects)
104+ }
99 run(105 run(
100 arg0,106 arg0,
101 arg1,107 arg1,
102 arg2,108 arg2,
103 arg3,109 arg3,
110+ arg4,
104 )111 )
105 })112 })
106 return _c113 return _c
@@ -111,7 +118,7 @@ func (_c *PushHookService_Check_Call) Return(err error) *PushHookService_Check_C
111 return _c118 return _c
112 }119 }
113 120
114-func (_c *PushHookService_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error) *PushHookService_Check_Call {121+func (_c *PushHookService_Check_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error) *PushHookService_Check_Call {
115 _c.Call.Return(run)122 _c.Call.Return(run)
116 return _c123 return _c
117 }124 }
A internal/ports/mocks/WorkflowGuard.go +127 -0
@@ -0,0 +1,127 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewWorkflowGuard creates a new instance of WorkflowGuard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewWorkflowGuard(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *WorkflowGuard {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &WorkflowGuard{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// WorkflowGuard is an autogenerated mock type for the WorkflowGuard type
39+type WorkflowGuard struct {
40+ mock.Mock
41+}
42+
43+type WorkflowGuard_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *WorkflowGuard) EXPECT() *WorkflowGuard_Expecter {
48+ return &WorkflowGuard_Expecter{mock: &_m.Mock}
49+}
50+
51+// ChangedWorkflow provides a mock function for the type WorkflowGuard
52+func (_mock *WorkflowGuard) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
53+ ret := _mock.Called(ctx, repo, updates, objects)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for ChangedWorkflow")
57+ }
58+
59+ var r0 string
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) (string, error)); ok {
62+ return returnFunc(ctx, repo, updates, objects)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) string); ok {
65+ r0 = returnFunc(ctx, repo, updates, objects)
66+ } else {
67+ r0 = ret.Get(0).(string)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok {
70+ r1 = returnFunc(ctx, repo, updates, objects)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// WorkflowGuard_ChangedWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedWorkflow'
78+type WorkflowGuard_ChangedWorkflow_Call struct {
79+ *mock.Call
80+}
81+
82+// ChangedWorkflow is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - updates []domain.RefUpdate
86+// - objects ports.PushObjects
87+func (_e *WorkflowGuard_Expecter) ChangedWorkflow(ctx any, repo any, updates any, objects any) *WorkflowGuard_ChangedWorkflow_Call {
88+ return &WorkflowGuard_ChangedWorkflow_Call{Call: _e.mock.On("ChangedWorkflow", ctx, repo, updates, objects)}
89+}
90+
91+func (_c *WorkflowGuard_ChangedWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *WorkflowGuard_ChangedWorkflow_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.RepoName
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.RepoName)
100+ }
101+ var arg2 []domain.RefUpdate
102+ if args[2] != nil {
103+ arg2 = args[2].([]domain.RefUpdate)
104+ }
105+ var arg3 ports.PushObjects
106+ if args[3] != nil {
107+ arg3 = args[3].(ports.PushObjects)
108+ }
109+ run(
110+ arg0,
111+ arg1,
112+ arg2,
113+ arg3,
114+ )
115+ })
116+ return _c
117+}
118+
119+func (_c *WorkflowGuard_ChangedWorkflow_Call) Return(path string, err error) *WorkflowGuard_ChangedWorkflow_Call {
120+ _c.Call.Return(path, err)
121+ return _c
122+}
123+
124+func (_c *WorkflowGuard_ChangedWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error)) *WorkflowGuard_ChangedWorkflow_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
@@ -0,0 +1,127 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewWorkflowGuard creates a new instance of WorkflowGuard. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewWorkflowGuard(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *WorkflowGuard {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &WorkflowGuard{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// WorkflowGuard is an autogenerated mock type for the WorkflowGuard type
39+type WorkflowGuard struct {
40+ mock.Mock
41+}
42+
43+type WorkflowGuard_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *WorkflowGuard) EXPECT() *WorkflowGuard_Expecter {
48+ return &WorkflowGuard_Expecter{mock: &_m.Mock}
49+}
50+
51+// ChangedWorkflow provides a mock function for the type WorkflowGuard
52+func (_mock *WorkflowGuard) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
53+ ret := _mock.Called(ctx, repo, updates, objects)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for ChangedWorkflow")
57+ }
58+
59+ var r0 string
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) (string, error)); ok {
62+ return returnFunc(ctx, repo, updates, objects)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) string); ok {
65+ r0 = returnFunc(ctx, repo, updates, objects)
66+ } else {
67+ r0 = ret.Get(0).(string)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects) error); ok {
70+ r1 = returnFunc(ctx, repo, updates, objects)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// WorkflowGuard_ChangedWorkflow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedWorkflow'
78+type WorkflowGuard_ChangedWorkflow_Call struct {
79+ *mock.Call
80+}
81+
82+// ChangedWorkflow is a helper method to define mock.On call
83+// - ctx context.Context
84+// - repo domain.RepoName
85+// - updates []domain.RefUpdate
86+// - objects ports.PushObjects
87+func (_e *WorkflowGuard_Expecter) ChangedWorkflow(ctx any, repo any, updates any, objects any) *WorkflowGuard_ChangedWorkflow_Call {
88+ return &WorkflowGuard_ChangedWorkflow_Call{Call: _e.mock.On("ChangedWorkflow", ctx, repo, updates, objects)}
89+}
90+
91+func (_c *WorkflowGuard_ChangedWorkflow_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects)) *WorkflowGuard_ChangedWorkflow_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.RepoName
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.RepoName)
100+ }
101+ var arg2 []domain.RefUpdate
102+ if args[2] != nil {
103+ arg2 = args[2].([]domain.RefUpdate)
104+ }
105+ var arg3 ports.PushObjects
106+ if args[3] != nil {
107+ arg3 = args[3].(ports.PushObjects)
108+ }
109+ run(
110+ arg0,
111+ arg1,
112+ arg2,
113+ arg3,
114+ )
115+ })
116+ return _c
117+}
118+
119+func (_c *WorkflowGuard_ChangedWorkflow_Call) Return(path string, err error) *WorkflowGuard_ChangedWorkflow_Call {
120+ _c.Call.Return(path, err)
121+ return _c
122+}
123+
124+func (_c *WorkflowGuard_ChangedWorkflow_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error)) *WorkflowGuard_ChangedWorkflow_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
M internal/ports/services.go +2 -1
@@ -190,7 +190,8 @@ type PushService interface {
190190 type PushHookService interface {
191191 // Check decides a push before Git applies it; an error matching
192192 // domain.ErrRejected refuses it, any other error refuses it as a failure.
193- Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
193+ // objects are the objects of the push in progress (the quarantine of git).
194+ Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects PushObjects) error
194195 // Record handles an accepted push.
195196 Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
196197 }
@@ -190,7 +190,8 @@ type PushService interface {
190 type PushHookService interface {190 type PushHookService interface {
191 // Check decides a push before Git applies it; an error matching191 // Check decides a push before Git applies it; an error matching
192 // domain.ErrRejected refuses it, any other error refuses it as a failure.192 // domain.ErrRejected refuses it, any other error refuses it as a failure.
193- Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error193+ // objects are the objects of the push in progress (the quarantine of git).
194+ Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects PushObjects) error
194 // Record handles an accepted push.195 // Record handles an accepted push.
195 Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error196 Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error
196 }197 }
M internal/readertest/conformance.go +36 -0
@@ -4,6 +4,7 @@ package readertest
44
55 import (
66 "context"
7+ "encoding/hex"
78 "errors"
89 "fmt"
910 "io"
@@ -46,6 +47,7 @@ func Conformance(t *testing.T, mk Maker) {
4647 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
4748 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
4849 {"GetTreeIsBounded", testGetTreeIsBounded},
50+ {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},
4951 {"ListPaths", testListPaths},
5052 } {
5153 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
@@ -809,3 +811,37 @@ func testListPaths(t *testing.T, mk Maker) {
809811 assert.True(t, list.Truncated)
810812 })
811813 }
814+
815+// A tree entry of mode 160000 (a submodule) names a commit of another
816+// repository. When its id is that of a tree this repository has, it still is no
817+// directory of it: both readers answer not found, and the same id as a real
818+// directory lists.
819+func testGetTreeRefusesAGitlinkToATree(t *testing.T, mk Maker) {
820+ ctx := context.Background()
821+ g, data := mk(t)
822+ f := NewFixture(t, data, "gitlink")
823+ f.Write("README", "r\n")
824+ f.Write("dir/f.txt", "f\n")
825+ f.Commit("dir")
826+ dirID := f.Git("rev-parse", "HEAD:dir")
827+ raw := func(entries ...[3]string) string {
828+ var b strings.Builder
829+ for _, e := range entries {
830+ id, err := hex.DecodeString(e[2])
831+ require.NoError(t, err)
832+ b.WriteString(e[0] + " " + e[1] + "\x00")
833+ b.Write(id)
834+ }
835+ return f.WriteObject("tree", b.String())
836+ }
837+ both := raw([3]string{"40000", "asdir", dirID}, [3]string{"160000", "sub", dirID}) // the same id, two modes
838+ commit := f.Git("commit-tree", both, "-m", "gitlink")
839+ f.Git("update-ref", "refs/heads/main", commit)
840+
841+ tree, err := g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "asdir"), false)
842+ require.NoError(t, err)
843+ require.Len(t, tree.Entries, 1)
844+ assert.Equal(t, "f.txt", tree.Entries[0].Name)
845+ _, err = g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "sub"), false)
846+ assert.ErrorIs(t, err, domain.ErrNotFound, "a gitlink is no directory, whatever it names")
847+}
@@ -4,6 +4,7 @@ package readertest
4 4
5 import (5 import (
6 "context"6 "context"
7+ "encoding/hex"
7 "errors"8 "errors"
8 "fmt"9 "fmt"
9 "io"10 "io"
@@ -46,6 +47,7 @@ func Conformance(t *testing.T, mk Maker) {
46 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},47 {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral},
47 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},48 {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs},
48 {"GetTreeIsBounded", testGetTreeIsBounded},49 {"GetTreeIsBounded", testGetTreeIsBounded},
50+ {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree},
49 {"ListPaths", testListPaths},51 {"ListPaths", testListPaths},
50 } {52 } {
51 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })53 t.Run(tc.name, func(t *testing.T) { tc.fn(t, mk) })
@@ -809,3 +811,37 @@ func testListPaths(t *testing.T, mk Maker) {
809 assert.True(t, list.Truncated)811 assert.True(t, list.Truncated)
810 })812 })
811 }813 }
814+
815+// A tree entry of mode 160000 (a submodule) names a commit of another
816+// repository. When its id is that of a tree this repository has, it still is no
817+// directory of it: both readers answer not found, and the same id as a real
818+// directory lists.
819+func testGetTreeRefusesAGitlinkToATree(t *testing.T, mk Maker) {
820+ ctx := context.Background()
821+ g, data := mk(t)
822+ f := NewFixture(t, data, "gitlink")
823+ f.Write("README", "r\n")
824+ f.Write("dir/f.txt", "f\n")
825+ f.Commit("dir")
826+ dirID := f.Git("rev-parse", "HEAD:dir")
827+ raw := func(entries ...[3]string) string {
828+ var b strings.Builder
829+ for _, e := range entries {
830+ id, err := hex.DecodeString(e[2])
831+ require.NoError(t, err)
832+ b.WriteString(e[0] + " " + e[1] + "\x00")
833+ b.Write(id)
834+ }
835+ return f.WriteObject("tree", b.String())
836+ }
837+ both := raw([3]string{"40000", "asdir", dirID}, [3]string{"160000", "sub", dirID}) // the same id, two modes
838+ commit := f.Git("commit-tree", both, "-m", "gitlink")
839+ f.Git("update-ref", "refs/heads/main", commit)
840+
841+ tree, err := g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "asdir"), false)
842+ require.NoError(t, err)
843+ require.Len(t, tree.Entries, 1)
844+ assert.Equal(t, "f.txt", tree.Entries[0].Name)
845+ _, err = g.GetTree(ctx, f.Name, Rev(t, commit), TreePath(t, "sub"), false)
846+ assert.ErrorIs(t, err, domain.ErrNotFound, "a gitlink is no directory, whatever it names")
847+}
M internal/usecase/access.go +1 -1
@@ -63,7 +63,7 @@ func canWrite(p domain.Principal, name domain.RepoName) bool {
6363 // first push: repo:write on the name, and no branch prefixes. A token limited
6464 // to some branches may push to a repository that exists; it never makes one.
6565 func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
66- return canWrite(p, name) && !p.IsBranchLimited()
66+ return canWrite(p, name) && !p.IsBranchLimited() && !p.ViaJobToken()
6767 }
6868
6969 // canManageTokens is the access rule for creating, listing and revoking
@@ -63,7 +63,7 @@ func canWrite(p domain.Principal, name domain.RepoName) bool {
63 // first push: repo:write on the name, and no branch prefixes. A token limited63 // first push: repo:write on the name, and no branch prefixes. A token limited
64 // to some branches may push to a repository that exists; it never makes one.64 // to some branches may push to a repository that exists; it never makes one.
65 func canCreateRepo(p domain.Principal, name domain.RepoName) bool {65 func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
66- return canWrite(p, name) && !p.IsBranchLimited()66+ return canWrite(p, name) && !p.IsBranchLimited() && !p.ViaJobToken()
67 }67 }
68 68
69 // canManageTokens is the access rule for creating, listing and revoking69 // canManageTokens is the access rule for creating, listing and revoking
M internal/usecase/ci_plan.go +17 -4
@@ -347,7 +347,7 @@ func (rs *runState) advance(ctx context.Context) error {
347347 if !ready {
348348 continue
349349 }
350- rs.planJob(ctx, wj, insts[0], needs, st)
350+ rs.planJob(ctx, wf, wj, insts[0], needs, st)
351351 changed = true
352352 }
353353 }
@@ -381,7 +381,7 @@ func (rs *runState) promote() {
381381 // plan evaluates a job whose needs have ended: its condition, its strategy and
382382 // the parts of every instance the server decides (name, labels, timeout,
383383 // contexts). The record at idx becomes the first instance.
384-func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, needs *expr.Dictionary, st workflow.StatusFuncs) {
384+func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, needs *expr.Dictionary, st workflow.StatusFuncs) {
385385 rec := rs.jobs[idx]
386386 fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }
387387
@@ -428,7 +428,7 @@ func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, need
428428 fail(err.Error())
429429 return
430430 }
431- inst, err := rs.instance(ctx, wj, plan, rec, i, menv, needs)
431+ inst, err := rs.instance(ctx, wf, wj, plan, rec, i, menv, needs)
432432 if err != nil {
433433 fail(err.Error())
434434 return
@@ -456,7 +456,7 @@ func (rs *runState) skippedName(ctx context.Context, wj *workflow.Job, env templ
456456 }
457457
458458 // instance builds the planned record of combination i of a job.
459-func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workflow.Strategy, base domain.CIJob, i int, env template.Env, needs *expr.Dictionary) (domain.CIJob, error) {
459+func (rs *runState) instance(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, plan *workflow.Strategy, base domain.CIJob, i int, env template.Env, needs *expr.Dictionary) (domain.CIJob, error) {
460460 j := base
461461 j.Combo = i
462462 j.ID = domain.NewJobID(rs.run.ID, base.Index, i)
@@ -506,6 +506,7 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
506506 return j, err
507507 }
508508 j.Environment = environment
509+ j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
509510 j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout
510511 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
511512 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
@@ -557,3 +558,15 @@ func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, v
557558 }
558559 return string(b), nil
559560 }
561+
562+// permissionSpec reads the "permissions:" of a workflow or a job, nil for none.
563+func permissionSpec(p *workflow.Permissions) *domain.PermissionSpec {
564+ if p == nil {
565+ return nil
566+ }
567+ spec := &domain.PermissionSpec{All: p.All, Levels: make(map[string]string, len(p.Scopes))}
568+ for _, sc := range p.Scopes {
569+ spec.Levels[sc.Scope] = sc.Level
570+ }
571+ return spec
572+}
@@ -347,7 +347,7 @@ func (rs *runState) advance(ctx context.Context) error {
347 if !ready {347 if !ready {
348 continue348 continue
349 }349 }
350- rs.planJob(ctx, wj, insts[0], needs, st)350+ rs.planJob(ctx, wf, wj, insts[0], needs, st)
351 changed = true351 changed = true
352 }352 }
353 }353 }
@@ -381,7 +381,7 @@ func (rs *runState) promote() {
381 // plan evaluates a job whose needs have ended: its condition, its strategy and381 // plan evaluates a job whose needs have ended: its condition, its strategy and
382 // the parts of every instance the server decides (name, labels, timeout,382 // the parts of every instance the server decides (name, labels, timeout,
383 // contexts). The record at idx becomes the first instance.383 // contexts). The record at idx becomes the first instance.
384-func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, needs *expr.Dictionary, st workflow.StatusFuncs) {384+func (rs *runState) planJob(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, idx int, needs *expr.Dictionary, st workflow.StatusFuncs) {
385 rec := rs.jobs[idx]385 rec := rs.jobs[idx]
386 fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }386 fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }
387 387
@@ -428,7 +428,7 @@ func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, need
428 fail(err.Error())428 fail(err.Error())
429 return429 return
430 }430 }
431- inst, err := rs.instance(ctx, wj, plan, rec, i, menv, needs)431+ inst, err := rs.instance(ctx, wf, wj, plan, rec, i, menv, needs)
432 if err != nil {432 if err != nil {
433 fail(err.Error())433 fail(err.Error())
434 return434 return
@@ -456,7 +456,7 @@ func (rs *runState) skippedName(ctx context.Context, wj *workflow.Job, env templ
456 }456 }
457 457
458 // instance builds the planned record of combination i of a job.458 // instance builds the planned record of combination i of a job.
459-func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workflow.Strategy, base domain.CIJob, i int, env template.Env, needs *expr.Dictionary) (domain.CIJob, error) {459+func (rs *runState) instance(ctx context.Context, wf *workflow.Workflow, wj *workflow.Job, plan *workflow.Strategy, base domain.CIJob, i int, env template.Env, needs *expr.Dictionary) (domain.CIJob, error) {
460 j := base460 j := base
461 j.Combo = i461 j.Combo = i
462 j.ID = domain.NewJobID(rs.run.ID, base.Index, i)462 j.ID = domain.NewJobID(rs.run.ID, base.Index, i)
@@ -506,6 +506,7 @@ func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workfl
506 return j, err506 return j, err
507 }507 }
508 j.Environment = environment508 j.Environment = environment
509+ j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions))
509 j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout510 j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout
510 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))511 j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
511 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)512 j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
@@ -557,3 +558,15 @@ func contextsJSON(github, needs, strategy *expr.Dictionary, matrix expr.Value, v
557 }558 }
558 return string(b), nil559 return string(b), nil
559 }560 }
561+
562+// permissionSpec reads the "permissions:" of a workflow or a job, nil for none.
563+func permissionSpec(p *workflow.Permissions) *domain.PermissionSpec {
564+ if p == nil {
565+ return nil
566+ }
567+ spec := &domain.PermissionSpec{All: p.All, Levels: make(map[string]string, len(p.Scopes))}
568+ for _, sc := range p.Scopes {
569+ spec.Levels[sc.Scope] = sc.Level
570+ }
571+ return spec
572+}
M internal/usecase/ci_repotoken.go +6 -0
@@ -54,6 +54,12 @@ func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha
5454 t := c.repoTokenExpiry(at)
5555 t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
5656 t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now
57+ if sha == "" {
58+ // The job's own token: what it may do on the run's repository follows
59+ // from the permissions of the job, lowered to read when the run is not
60+ // trusted (D1, D3).
61+ t.Permissions = domain.TokenPermissions(j.Permissions, rs.run.Trusted)
62+ }
5763 rs.tokens = append(rs.tokens, t)
5864 return secret
5965 }
@@ -54,6 +54,12 @@ func (rs *runState) issueRepoTokenFor(j *domain.CIJob, repo domain.RepoName, sha
54 t := c.repoTokenExpiry(at)54 t := c.repoTokenExpiry(at)
55 t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo55 t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo
56 t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now56 t.RunID, t.RunAttempt, t.JobID, t.Archive, t.Sha, t.CreatedAt = j.RunID, j.RunAttempt, j.ID, sha != "", sha, now
57+ if sha == "" {
58+ // The job's own token: what it may do on the run's repository follows
59+ // from the permissions of the job, lowered to read when the run is not
60+ // trusted (D1, D3).
61+ t.Permissions = domain.TokenPermissions(j.Permissions, rs.run.Trusted)
62+ }
57 rs.tokens = append(rs.tokens, t)63 rs.tokens = append(rs.tokens, t)
58 return secret64 return secret
59 }65 }
M internal/usecase/ci_runner.go +1 -0
@@ -246,6 +246,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett
246246 Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
247247 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
248248 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
249+ Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
249250 }
250251 }
251252
@@ -246,6 +246,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, token, ghToken string, sett
246 Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,246 Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
247 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,247 WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
248 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,248 Environment: j.Environment, Vars: settings.Vars, Secrets: settings.Secrets, Trusted: rs.run.Trusted,
249+ Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
249 }250 }
250 }251 }
251 252
M internal/usecase/ci_trigger.go +14 -0
@@ -47,7 +47,16 @@ type ciTrigger struct {
4747 // OnPush queues the refs of a push or a merge for planning and returns at once;
4848 // a worker plans them. A full queue drops the trigger, logged at error: a push
4949 // is never slowed down or failed by CI.
50+//
51+// A push made with a job token starts nothing, as on GitHub (events created
52+// with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
53+// from running itself again.
5054 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
55+ if pusher.ViaJobToken() {
56+ log := zerowrap.FromCtx(ctx)
57+ log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
58+ return
59+ }
5160 c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
5261 }
5362
@@ -58,6 +67,11 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
5867 // merging. For the same reason the run is trusted (gets secrets) only when the
5968 // merger is: the displayed actor is not the one that decides.
6069 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
70+ if merger.ViaJobToken() {
71+ log := zerowrap.FromCtx(ctx)
72+ log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
73+ return
74+ }
6175 c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
6276 }
6377
@@ -47,7 +47,16 @@ type ciTrigger struct {
47 // OnPush queues the refs of a push or a merge for planning and returns at once;47 // OnPush queues the refs of a push or a merge for planning and returns at once;
48 // a worker plans them. A full queue drops the trigger, logged at error: a push48 // a worker plans them. A full queue drops the trigger, logged at error: a push
49 // is never slowed down or failed by CI.49 // is never slowed down or failed by CI.
50+//
51+// A push made with a job token starts nothing, as on GitHub (events created
52+// with GITHUB_TOKEN start no run): that is what stops a workflow that pushes
53+// from running itself again.
50 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {54 func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
55+ if pusher.ViaJobToken() {
56+ log := zerowrap.FromCtx(ctx)
57+ log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("push by a job token starts no run")
58+ return
59+ }
51 c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))60 c.enqueue(ctx, repo, updates, authorOf(pusher), domain.PusherOf(pusher), domain.TrustedActor(pusher))
52 }61 }
53 62
@@ -58,6 +67,11 @@ func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.
58 // merging. For the same reason the run is trusted (gets secrets) only when the67 // merging. For the same reason the run is trusted (gets secrets) only when the
59 // merger is: the displayed actor is not the one that decides.68 // merger is: the displayed actor is not the one that decides.
60 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {69 func (c *CI) OnMerge(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, merger domain.Principal) {
70+ if merger.ViaJobToken() {
71+ log := zerowrap.FromCtx(ctx)
72+ log.Debug().Str("repo", repo.String()).Int("refs", len(updates)).Msg("merge by a job token starts no run")
73+ return
74+ }
61 c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))75 c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger))
62 }76 }
63 77
M internal/usecase/pushes.go +41 -1
@@ -29,6 +29,7 @@ type Pushes struct {
2929 auth ports.Revalidator
3030 fresh ports.RepoFreshener // nil: the reader needs no telling
3131 ci ports.WorkflowTrigger // nil: no CI
32+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
3233 }
3334
3435 var (
@@ -57,6 +58,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
5758 return u
5859 }
5960
61+// WithWorkflowGuard lets Check tell whether a push changes workflow files, which
62+// a push by a job token may not do. Without it a push by a job token is
63+// refused, as it cannot be judged. Call it before the first push; it returns u.
64+func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {
65+ u.wf = g
66+ return u
67+}
68+
6069 // Open decides whether the principal may push to the repository and, with
6170 // create, creates it when it does not exist yet (private, published as
6271 // RepoCreated by Repos.Create). create is for the first request of a push only.
@@ -120,7 +129,14 @@ func openExisting(p domain.Principal, repo domain.Repo) error {
120129 // The token of the push was valid when the request began, but a push can run
121130 // for long: it is re-validated here, so a token revoked or expired meanwhile
122131 // stops the push before Git applies it. A failure to tell refuses it too.
123-func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
132+//
133+// The token of a CI job (GITHUB_TOKEN with contents: write) may not change
134+// workflows: a push that creates or changes a file under .github/workflows/ on
135+// any ref, branch or tag, is refused as a whole (CI-3, D3). A new ref counts
136+// every workflow its commit holds, as on GitHub; deleting a ref is not a
137+// change. objects locate the objects of the push, which are not in the
138+// repository yet.
139+func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
124140 if err := u.auth.Revalidate(ctx, p); err != nil {
125141 if errors.Is(err, domain.ErrUnauthenticated) {
126142 return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
@@ -136,11 +152,35 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
136152 domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
137153 }
138154 }
155+ if p.ViaJobToken() {
156+ if err := u.checkWorkflows(ctx, name, updates, objects); err != nil {
157+ return err
158+ }
159+ }
139160 log := zerowrap.FromCtx(ctx)
140161 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
141162 return nil
142163 }
143164
165+// workflowRefusal is GitHub's refusal of a push by the token of a job that
166+// changes a workflow (a push to a branch or a tag; the token lacks the
167+// "workflows" permission, which no job token can have).
168+const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
169+
170+func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
171+ if u.wf == nil {
172+ return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
173+ }
174+ path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)
175+ switch {
176+ case err != nil:
177+ return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
178+ case path != "":
179+ return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
180+ }
181+ return nil
182+}
183+
144184 // Record is the post-receive step of an accepted push: it sets pushed_at,
145185 // repairs HEAD when it points to a missing branch, tells the reader that the
146186 // repository changed, queues the workflows the push triggers, publishes a
@@ -29,6 +29,7 @@ type Pushes struct {
29 auth ports.Revalidator29 auth ports.Revalidator
30 fresh ports.RepoFreshener // nil: the reader needs no telling30 fresh ports.RepoFreshener // nil: the reader needs no telling
31 ci ports.WorkflowTrigger // nil: no CI31 ci ports.WorkflowTrigger // nil: no CI
32+ wf ports.WorkflowGuard // nil: pushes by job tokens are refused
32 }33 }
33 34
34 var (35 var (
@@ -57,6 +58,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
57 return u58 return u
58 }59 }
59 60
61+// WithWorkflowGuard lets Check tell whether a push changes workflow files, which
62+// a push by a job token may not do. Without it a push by a job token is
63+// refused, as it cannot be judged. Call it before the first push; it returns u.
64+func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes {
65+ u.wf = g
66+ return u
67+}
68+
60 // Open decides whether the principal may push to the repository and, with69 // Open decides whether the principal may push to the repository and, with
61 // create, creates it when it does not exist yet (private, published as70 // create, creates it when it does not exist yet (private, published as
62 // RepoCreated by Repos.Create). create is for the first request of a push only.71 // RepoCreated by Repos.Create). create is for the first request of a push only.
@@ -120,7 +129,14 @@ func openExisting(p domain.Principal, repo domain.Repo) error {
120 // The token of the push was valid when the request began, but a push can run129 // The token of the push was valid when the request began, but a push can run
121 // for long: it is re-validated here, so a token revoked or expired meanwhile130 // for long: it is re-validated here, so a token revoked or expired meanwhile
122 // stops the push before Git applies it. A failure to tell refuses it too.131 // stops the push before Git applies it. A failure to tell refuses it too.
123-func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {132+//
133+// The token of a CI job (GITHUB_TOKEN with contents: write) may not change
134+// workflows: a push that creates or changes a file under .github/workflows/ on
135+// any ref, branch or tag, is refused as a whole (CI-3, D3). A new ref counts
136+// every workflow its commit holds, as on GitHub; deleting a ref is not a
137+// change. objects locate the objects of the push, which are not in the
138+// repository yet.
139+func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
124 if err := u.auth.Revalidate(ctx, p); err != nil {140 if err := u.auth.Revalidate(ctx, p); err != nil {
125 if errors.Is(err, domain.ErrUnauthenticated) {141 if errors.Is(err, domain.ErrUnauthenticated) {
126 return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)142 return fmt.Errorf("%w: the token is no longer valid", domain.ErrRejected)
@@ -136,11 +152,35 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
136 domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))152 domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
137 }153 }
138 }154 }
155+ if p.ViaJobToken() {
156+ if err := u.checkWorkflows(ctx, name, updates, objects); err != nil {
157+ return err
158+ }
159+ }
139 log := zerowrap.FromCtx(ctx)160 log := zerowrap.FromCtx(ctx)
140 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")161 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
141 return nil162 return nil
142 }163 }
143 164
165+// workflowRefusal is GitHub's refusal of a push by the token of a job that
166+// changes a workflow (a push to a branch or a tag; the token lacks the
167+// "workflows" permission, which no job token can have).
168+const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission"
169+
170+func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error {
171+ if u.wf == nil {
172+ return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected)
173+ }
174+ path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects)
175+ switch {
176+ case err != nil:
177+ return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err)
178+ case path != "":
179+ return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path)
180+ }
181+ return nil
182+}
183+
144 // Record is the post-receive step of an accepted push: it sets pushed_at,184 // Record is the post-receive step of an accepted push: it sets pushed_at,
145 // repairs HEAD when it points to a missing branch, tells the reader that the185 // repairs HEAD when it points to a missing branch, tells the reader that the
146 // repository changed, queues the workflows the push triggers, publishes a186 // repository changed, queues the workflows the push triggers, publishes a
M internal/usecase/pushes_test.go +99 -12
@@ -24,6 +24,7 @@ type pushFixture struct {
2424 bus *mocks.EventBus
2525 clock *mocks.Clock
2626 tokens *mocks.TokenStore
27+ cirepo *mocks.CIRepoTokenStore
2728 sched *mocks.MaintenanceScheduler
2829 pushes *usecase.Pushes
2930 }
@@ -36,11 +37,12 @@ func newPushFixture(t *testing.T) *pushFixture {
3637 bus: mocks.NewEventBus(t),
3738 clock: mocks.NewClock(t),
3839 tokens: mocks.NewTokenStore(t),
40+ cirepo: mocks.NewCIRepoTokenStore(t),
3941 sched: mocks.NewMaintenanceScheduler(t),
4042 }
4143 f.clock.EXPECT().Now().Return(now).Maybe()
4244 repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
43- auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)
45+ auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock).WithRepoTokens(f.cirepo)
4446 f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
4547 return f
4648 }
@@ -158,28 +160,28 @@ func TestPushesCheck(t *testing.T) {
158160 t.Run("root is always valid and needs no lookup", func(t *testing.T) {
159161 t.Parallel()
160162 f := newPushFixture(t) // the token store mock expects no call
161- require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups))
163+ require.NoError(t, f.check(ctx, domain.Root(), n, ups))
162164 })
163165
164166 t.Run("a writer whose token still exists", func(t *testing.T) {
165167 t.Parallel()
166168 f := newPushFixture(t)
167169 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
168- require.NoError(t, f.pushes.Check(ctx, writer, n, ups))
170+ require.NoError(t, f.check(ctx, writer, n, ups))
169171 })
170172
171173 t.Run("a token revoked during the push is rejected", func(t *testing.T) {
172174 t.Parallel()
173175 f := newPushFixture(t)
174176 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, domain.ErrNotFound)
175- assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
177+ assert.ErrorIs(t, f.check(ctx, writer, n, ups), domain.ErrRejected)
176178 })
177179
178180 t.Run("a token that expired during the push is rejected", func(t *testing.T) {
179181 t.Parallel()
180182 f := newPushFixture(t)
181183 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: now.Add(-time.Second)}, nil)
182- assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)
184+ assert.ErrorIs(t, f.check(ctx, writer, n, ups), domain.ErrRejected)
183185 })
184186
185187 t.Run("a store failure fails closed", func(t *testing.T) {
@@ -187,7 +189,7 @@ func TestPushesCheck(t *testing.T) {
187189 f := newPushFixture(t)
188190 boom := errors.New("boom")
189191 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, boom)
190- err := f.pushes.Check(ctx, writer, n, ups)
192+ err := f.check(ctx, writer, n, ups)
191193 require.ErrorIs(t, err, boom)
192194 assert.NotErrorIs(t, err, domain.ErrRejected, "an internal failure, not a refusal")
193195 })
@@ -196,13 +198,13 @@ func TestPushesCheck(t *testing.T) {
196198 t.Parallel()
197199 f := newPushFixture(t)
198200 f.tokens.EXPECT().Get(ctx, "r1").Return(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)}, nil)
199- assert.ErrorIs(t, f.pushes.Check(ctx, reader, n, ups), domain.ErrRejected)
201+ assert.ErrorIs(t, f.check(ctx, reader, n, ups), domain.ErrRejected)
200202 })
201203
202204 t.Run("anonymous is rejected", func(t *testing.T) {
203205 t.Parallel()
204206 f := newPushFixture(t)
205- assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
207+ assert.ErrorIs(t, f.check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
206208 })
207209 }
208210
@@ -249,7 +251,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
249251 t.Parallel()
250252 f := newPushFixture(t)
251253 f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252- err := f.pushes.Check(ctx, limited, n, tt.updates)
254+ err := f.check(ctx, limited, n, tt.updates)
253255 if tt.refused == "" {
254256 require.NoError(t, err)
255257 return
@@ -264,7 +266,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
264266 f := newPushFixture(t)
265267 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266268 free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267- require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
269+ require.NoError(t, f.check(ctx, free, n, []domain.RefUpdate{
268270 up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269271 }))
270272 })
@@ -272,7 +274,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
272274 t.Run("root pushes anywhere", func(t *testing.T) {
273275 t.Parallel()
274276 f := newPushFixture(t)
275- require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
277+ require.NoError(t, f.check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276278 })
277279
278280 t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
@@ -280,7 +282,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
280282 f := newPushFixture(t)
281283 f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282284 p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283- err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
285+ err := f.check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284286 require.ErrorIs(t, err, domain.ErrRejected)
285287 assert.Contains(t, err.Error(), "no write access")
286288 })
@@ -436,3 +438,88 @@ func TestPushesDefaultBranchRule(t *testing.T) {
436438
437439 // mockAny matches any argument of a mocked call.
438440 var mockAny = mock.Anything
441+
442+// check is Pushes.Check for a push with nothing in quarantine.
443+func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.RepoName, ups []domain.RefUpdate) error {
444+ return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{})
445+}
446+
447+type fakeGuard struct {
448+ path string
449+ err error
450+ got []domain.RefUpdate
451+ objs ports.PushObjects
452+}
453+
454+func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) {
455+ g.got, g.objs = ups, o
456+ return g.path, g.err
457+}
458+
459+// A push by the token of a job is judged on the workflows it changes, and by it
460+// alone: every other principal is not asked.
461+func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
462+ t.Parallel()
463+ ctx := context.Background()
464+ n := name(t, "tools")
465+ ups := []domain.RefUpdate{{Old: oidA, New: oidB, Ref: "refs/heads/main"}, {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"}}
466+ objs := ports.PushObjects{Dir: "/d/repos/tools.git/objects/incoming-1"}
467+ exp := now.Add(time.Hour)
468+ job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}})
469+ readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp})
470+
471+ build := func(t *testing.T, g *fakeGuard) *pushFixture {
472+ f := newPushFixture(t)
473+ if g != nil {
474+ f.pushes.WithWorkflowGuard(g)
475+ }
476+ f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe()
477+ return f
478+ }
479+
480+ t.Run("no workflow touched", func(t *testing.T) {
481+ t.Parallel()
482+ g := &fakeGuard{}
483+ f := build(t, g)
484+ require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs))
485+ assert.Equal(t, ups, g.got, "every ref is judged")
486+ assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine")
487+ })
488+ t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) {
489+ t.Parallel()
490+ f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"})
491+ err := f.pushes.Check(ctx, job, n, ups, objs)
492+ require.ErrorIs(t, err, domain.ErrRejected)
493+ assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission")
494+ })
495+ t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) {
496+ t.Parallel()
497+ f := build(t, &fakeGuard{err: errors.New("git broke")})
498+ err := f.pushes.Check(ctx, job, n, ups, objs)
499+ require.Error(t, err)
500+ assert.NotErrorIs(t, err, domain.ErrRejected)
501+ })
502+ t.Run("without a guard a job token cannot be judged: refused", func(t *testing.T) {
503+ t.Parallel()
504+ f := build(t, nil)
505+ assert.ErrorIs(t, f.pushes.Check(ctx, job, n, ups, objs), domain.ErrRejected)
506+ })
507+ t.Run("a job token without contents: write cannot push at all", func(t *testing.T) {
508+ t.Parallel()
509+ f := build(t, &fakeGuard{})
510+ assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected)
511+ })
512+ t.Run("the owner is not asked", func(t *testing.T) {
513+ t.Parallel()
514+ g := &fakeGuard{path: ".github/workflows/ci.yml"}
515+ f := build(t, g)
516+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs))
517+ assert.Nil(t, g.got)
518+ })
519+ t.Run("a job token never creates a repository on push", func(t *testing.T) {
520+ t.Parallel()
521+ f := build(t, &fakeGuard{})
522+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
523+ assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound)
524+ })
525+}
@@ -24,6 +24,7 @@ type pushFixture struct {
24 bus *mocks.EventBus24 bus *mocks.EventBus
25 clock *mocks.Clock25 clock *mocks.Clock
26 tokens *mocks.TokenStore26 tokens *mocks.TokenStore
27+ cirepo *mocks.CIRepoTokenStore
27 sched *mocks.MaintenanceScheduler28 sched *mocks.MaintenanceScheduler
28 pushes *usecase.Pushes29 pushes *usecase.Pushes
29 }30 }
@@ -36,11 +37,12 @@ func newPushFixture(t *testing.T) *pushFixture {
36 bus: mocks.NewEventBus(t),37 bus: mocks.NewEventBus(t),
37 clock: mocks.NewClock(t),38 clock: mocks.NewClock(t),
38 tokens: mocks.NewTokenStore(t),39 tokens: mocks.NewTokenStore(t),
40+ cirepo: mocks.NewCIRepoTokenStore(t),
39 sched: mocks.NewMaintenanceScheduler(t),41 sched: mocks.NewMaintenanceScheduler(t),
40 }42 }
41 f.clock.EXPECT().Now().Return(now).Maybe()43 f.clock.EXPECT().Now().Return(now).Maybe()
42 repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)44 repos := usecase.NewRepos(f.meta, f.git, f.bus, f.clock)
43- auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock)45+ auth := usecase.NewAuth(domain.HashTokenSecret(rootSecret), f.tokens, f.clock).WithRepoTokens(f.cirepo)
44 f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)46 f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth)
45 return f47 return f
46 }48 }
@@ -158,28 +160,28 @@ func TestPushesCheck(t *testing.T) {
158 t.Run("root is always valid and needs no lookup", func(t *testing.T) {160 t.Run("root is always valid and needs no lookup", func(t *testing.T) {
159 t.Parallel()161 t.Parallel()
160 f := newPushFixture(t) // the token store mock expects no call162 f := newPushFixture(t) // the token store mock expects no call
161- require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups))163+ require.NoError(t, f.check(ctx, domain.Root(), n, ups))
162 })164 })
163 165
164 t.Run("a writer whose token still exists", func(t *testing.T) {166 t.Run("a writer whose token still exists", func(t *testing.T) {
165 t.Parallel()167 t.Parallel()
166 f := newPushFixture(t)168 f := newPushFixture(t)
167 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)169 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
168- require.NoError(t, f.pushes.Check(ctx, writer, n, ups))170+ require.NoError(t, f.check(ctx, writer, n, ups))
169 })171 })
170 172
171 t.Run("a token revoked during the push is rejected", func(t *testing.T) {173 t.Run("a token revoked during the push is rejected", func(t *testing.T) {
172 t.Parallel()174 t.Parallel()
173 f := newPushFixture(t)175 f := newPushFixture(t)
174 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, domain.ErrNotFound)176 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, domain.ErrNotFound)
175- assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)177+ assert.ErrorIs(t, f.check(ctx, writer, n, ups), domain.ErrRejected)
176 })178 })
177 179
178 t.Run("a token that expired during the push is rejected", func(t *testing.T) {180 t.Run("a token that expired during the push is rejected", func(t *testing.T) {
179 t.Parallel()181 t.Parallel()
180 f := newPushFixture(t)182 f := newPushFixture(t)
181 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: now.Add(-time.Second)}, nil)183 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: now.Add(-time.Second)}, nil)
182- assert.ErrorIs(t, f.pushes.Check(ctx, writer, n, ups), domain.ErrRejected)184+ assert.ErrorIs(t, f.check(ctx, writer, n, ups), domain.ErrRejected)
183 })185 })
184 186
185 t.Run("a store failure fails closed", func(t *testing.T) {187 t.Run("a store failure fails closed", func(t *testing.T) {
@@ -187,7 +189,7 @@ func TestPushesCheck(t *testing.T) {
187 f := newPushFixture(t)189 f := newPushFixture(t)
188 boom := errors.New("boom")190 boom := errors.New("boom")
189 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, boom)191 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{}, boom)
190- err := f.pushes.Check(ctx, writer, n, ups)192+ err := f.check(ctx, writer, n, ups)
191 require.ErrorIs(t, err, boom)193 require.ErrorIs(t, err, boom)
192 assert.NotErrorIs(t, err, domain.ErrRejected, "an internal failure, not a refusal")194 assert.NotErrorIs(t, err, domain.ErrRejected, "an internal failure, not a refusal")
193 })195 })
@@ -196,13 +198,13 @@ func TestPushesCheck(t *testing.T) {
196 t.Parallel()198 t.Parallel()
197 f := newPushFixture(t)199 f := newPushFixture(t)
198 f.tokens.EXPECT().Get(ctx, "r1").Return(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)}, nil)200 f.tokens.EXPECT().Get(ctx, "r1").Return(domain.Token{ID: "r1", Scopes: scopes(domain.ScopeRepoRead)}, nil)
199- assert.ErrorIs(t, f.pushes.Check(ctx, reader, n, ups), domain.ErrRejected)201+ assert.ErrorIs(t, f.check(ctx, reader, n, ups), domain.ErrRejected)
200 })202 })
201 203
202 t.Run("anonymous is rejected", func(t *testing.T) {204 t.Run("anonymous is rejected", func(t *testing.T) {
203 t.Parallel()205 t.Parallel()
204 f := newPushFixture(t)206 f := newPushFixture(t)
205- assert.ErrorIs(t, f.pushes.Check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)207+ assert.ErrorIs(t, f.check(ctx, domain.Anonymous(), n, ups), domain.ErrRejected)
206 })208 })
207 }209 }
208 210
@@ -249,7 +251,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
249 t.Parallel()251 t.Parallel()
250 f := newPushFixture(t)252 f := newPushFixture(t)
251 f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)253 f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252- err := f.pushes.Check(ctx, limited, n, tt.updates)254+ err := f.check(ctx, limited, n, tt.updates)
253 if tt.refused == "" {255 if tt.refused == "" {
254 require.NoError(t, err)256 require.NoError(t, err)
255 return257 return
@@ -264,7 +266,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
264 f := newPushFixture(t)266 f := newPushFixture(t)
265 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)267 f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266 free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})268 free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267- require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{269+ require.NoError(t, f.check(ctx, free, n, []domain.RefUpdate{
268 up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),270 up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269 }))271 }))
270 })272 })
@@ -272,7 +274,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
272 t.Run("root pushes anywhere", func(t *testing.T) {274 t.Run("root pushes anywhere", func(t *testing.T) {
273 t.Parallel()275 t.Parallel()
274 f := newPushFixture(t)276 f := newPushFixture(t)
275- require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))277+ require.NoError(t, f.check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276 })278 })
277 279
278 t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {280 t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
@@ -280,7 +282,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) {
280 f := newPushFixture(t)282 f := newPushFixture(t)
281 f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)283 f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282 p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})284 p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283- err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})285+ err := f.check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284 require.ErrorIs(t, err, domain.ErrRejected)286 require.ErrorIs(t, err, domain.ErrRejected)
285 assert.Contains(t, err.Error(), "no write access")287 assert.Contains(t, err.Error(), "no write access")
286 })288 })
@@ -436,3 +438,88 @@ func TestPushesDefaultBranchRule(t *testing.T) {
436 438
437 // mockAny matches any argument of a mocked call.439 // mockAny matches any argument of a mocked call.
438 var mockAny = mock.Anything440 var mockAny = mock.Anything
441+
442+// check is Pushes.Check for a push with nothing in quarantine.
443+func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.RepoName, ups []domain.RefUpdate) error {
444+ return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{})
445+}
446+
447+type fakeGuard struct {
448+ path string
449+ err error
450+ got []domain.RefUpdate
451+ objs ports.PushObjects
452+}
453+
454+func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) {
455+ g.got, g.objs = ups, o
456+ return g.path, g.err
457+}
458+
459+// A push by the token of a job is judged on the workflows it changes, and by it
460+// alone: every other principal is not asked.
461+func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) {
462+ t.Parallel()
463+ ctx := context.Background()
464+ n := name(t, "tools")
465+ ups := []domain.RefUpdate{{Old: oidA, New: oidB, Ref: "refs/heads/main"}, {Old: domain.ZeroOID, New: oidB, Ref: "refs/tags/v1"}}
466+ objs := ports.PushObjects{Dir: "/d/repos/tools.git/objects/incoming-1"}
467+ exp := now.Add(time.Hour)
468+ job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}})
469+ readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp})
470+
471+ build := func(t *testing.T, g *fakeGuard) *pushFixture {
472+ f := newPushFixture(t)
473+ if g != nil {
474+ f.pushes.WithWorkflowGuard(g)
475+ }
476+ f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe()
477+ return f
478+ }
479+
480+ t.Run("no workflow touched", func(t *testing.T) {
481+ t.Parallel()
482+ g := &fakeGuard{}
483+ f := build(t, g)
484+ require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs))
485+ assert.Equal(t, ups, g.got, "every ref is judged")
486+ assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine")
487+ })
488+ t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) {
489+ t.Parallel()
490+ f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"})
491+ err := f.pushes.Check(ctx, job, n, ups, objs)
492+ require.ErrorIs(t, err, domain.ErrRejected)
493+ assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission")
494+ })
495+ t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) {
496+ t.Parallel()
497+ f := build(t, &fakeGuard{err: errors.New("git broke")})
498+ err := f.pushes.Check(ctx, job, n, ups, objs)
499+ require.Error(t, err)
500+ assert.NotErrorIs(t, err, domain.ErrRejected)
501+ })
502+ t.Run("without a guard a job token cannot be judged: refused", func(t *testing.T) {
503+ t.Parallel()
504+ f := build(t, nil)
505+ assert.ErrorIs(t, f.pushes.Check(ctx, job, n, ups, objs), domain.ErrRejected)
506+ })
507+ t.Run("a job token without contents: write cannot push at all", func(t *testing.T) {
508+ t.Parallel()
509+ f := build(t, &fakeGuard{})
510+ assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected)
511+ })
512+ t.Run("the owner is not asked", func(t *testing.T) {
513+ t.Parallel()
514+ g := &fakeGuard{path: ".github/workflows/ci.yml"}
515+ f := build(t, g)
516+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs))
517+ assert.Nil(t, g.got)
518+ })
519+ t.Run("a job token never creates a repository on push", func(t *testing.T) {
520+ t.Parallel()
521+ f := build(t, &fakeGuard{})
522+ f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
523+ assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound)
524+ })
525+}
M proto/ermite/storage/ci.proto +9 -0
@@ -202,6 +202,10 @@ message CIJob {
202202 // The evaluated environment: of the job, set when the job is planned; empty
203203 // for none. Its secrets and variables are read when the job is assigned.
204204 string environment = 41;
205+ // The effective permissions of the job's repository token (D3), computed when
206+ // the job is planned: permission key to "read", "write" or "none". Absent on
207+ // a job planned before the field existed: the restricted default.
208+ map<string, string> permissions = 42;
205209 }
206210
207211 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or
@@ -256,6 +260,11 @@ message CIRepoToken {
256260 google.protobuf.Timestamp created_at = 9;
257261 // The commit an archive token downloads.
258262 string sha = 10;
263+ // The permissions of a job's own token (key to "read", "write" or "none");
264+ // what it may do on its repository follows from them (domain.RepoTokenPrincipal).
265+ // Absent: the restricted default, read-only, as tokens issued before the
266+ // field existed (an older binary reads a newer token as read-only too).
267+ map<string, string> permissions = 11;
259268 }
260269
261270 // CIActionResolution is the resolution of an action reference for a run
@@ -202,6 +202,10 @@ message CIJob {
202 // The evaluated environment: of the job, set when the job is planned; empty202 // The evaluated environment: of the job, set when the job is planned; empty
203 // for none. Its secrets and variables are read when the job is assigned.203 // for none. Its secrets and variables are read when the job is assigned.
204 string environment = 41;204 string environment = 41;
205+ // The effective permissions of the job's repository token (D3), computed when
206+ // the job is planned: permission key to "read", "write" or "none". Absent on
207+ // a job planned before the field existed: the restricted default.
208+ map<string, string> permissions = 42;
205 }209 }
206 210
207 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or211 // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or
@@ -256,6 +260,11 @@ message CIRepoToken {
256 google.protobuf.Timestamp created_at = 9;260 google.protobuf.Timestamp created_at = 9;
257 // The commit an archive token downloads.261 // The commit an archive token downloads.
258 string sha = 10;262 string sha = 10;
263+ // The permissions of a job's own token (key to "read", "write" or "none");
264+ // what it may do on its repository follows from them (domain.RepoTokenPrincipal).
265+ // Absent: the restricted default, read-only, as tokens issued before the
266+ // field existed (an older binary reads a newer token as read-only too).
267+ map<string, string> permissions = 11;
259 }268 }
260 269
261 // CIActionResolution is the resolution of an action reference for a run270 // CIActionResolution is the resolution of an action reference for a run

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 42

↑↓ moveenter openesc close