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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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 { | |||
| 35 | 35 | // error kills the process and is returned. Without a deadline in ctx the | |
| 36 | 36 | // process gets commandTimeout. | |
| 37 | 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 | 44 | if _, ok := ctx.Deadline(); !ok { | |
| 39 | 45 | var cancel context.CancelFunc | |
| 40 | 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 | 56 | defer cancel() | |
| 51 | 57 | ||
| 52 | 58 | 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...) | |
| 54 | 60 | cmd.Dir = g.dataDir | |
| 55 | 61 | cmd.WaitDelay = 5 * time.Second | |
| 62 | + | cmd.Stdin = stdin | |
| 56 | 63 | stderr := &tailBuffer{max: 4096} | |
| 57 | 64 | cmd.Stderr = stderr | |
| 58 | 65 | 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 the | 35 | // 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.CancelFunc | 45 | 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.env | 59 | + | cmd.Env = append(append(make([]string, 0, len(g.env)+len(extra)), g.env...), extra...) |
| 54 | cmd.Dir = g.dataDir | 60 | cmd.Dir = g.dataDir | ||
| 55 | cmd.WaitDelay = 5 * time.Second | 61 | cmd.WaitDelay = 5 * time.Second | ||
| 62 | + | cmd.Stdin = stdin | |||
| 56 | stderr := &tailBuffer{max: 4096} | 63 | stderr := &tailBuffer{max: 4096} | ||
| 57 | cmd.Stderr = stderr | 64 | 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 | |||
| 351 | 351 | if obj.info.Type != "tree" { | |
| 352 | 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 | 359 | treeID, err := domain.ParseObjectID(obj.info.ID) | |
| 355 | 360 | if err != nil { | |
| 356 | 361 | 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{}, err | 361 | 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 ( | |||
| 12 | 12 | "net" | |
| 13 | 13 | "strings" | |
| 14 | 14 | "time" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 15 | 17 | ) | |
| 16 | 18 | ||
| 17 | 19 | // hookDialTimeout bounds connecting to the socket and the whole exchange. | |
| @@ -31,8 +33,8 @@ var ErrHookRejected = errors.New("push refused") | |||
| 31 | 33 | // or erroring server, or a refusal all return an error (git then rejects the | |
| 32 | 34 | // push). For post-receive the push already happened: failures are reported on | |
| 33 | 35 | // 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) | |
| 36 | 38 | if err == nil { | |
| 37 | 39 | return nil | |
| 38 | 40 | } | |
| @@ -43,7 +45,7 @@ func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, s | |||
| 43 | 45 | 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 | 49 | if hook != HookPreReceive && hook != HookPostReceive { | |
| 48 | 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 | 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 | 73 | if err != nil { | |
| 72 | 74 | return err | |
| 73 | 75 | } | |
| @@ -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 the | 33 | // 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 on | 34 | // 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 nil | 39 | return nil | ||
| 38 | } | 40 | } | ||
| @@ -43,7 +45,7 @@ func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, s | |||||
| 43 | return err | 45 | 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 err | 74 | 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) { | |||
| 210 | 210 | rlog := log.WithField("repo", entry.repo.String()) | |
| 211 | 211 | switch req.Hook { | |
| 212 | 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 | 214 | switch { | |
| 215 | 215 | case err == nil: | |
| 216 | 216 | 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) { | |||
| 122 | 122 | t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) { | |
| 123 | 123 | updates := []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}} | |
| 124 | 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 | 126 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 127 | 127 | assert.True(t, r.OK) | |
| 128 | 128 | ||
| @@ -133,13 +133,13 @@ func TestHookProtocol(t *testing.T) { | |||
| 133 | 133 | }) | |
| 134 | 134 | ||
| 135 | 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 | 137 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`) | |
| 138 | 138 | assert.True(t, r.OK) | |
| 139 | 139 | }) | |
| 140 | 140 | ||
| 141 | 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 | 143 | Return(errors.Join(domain.ErrRejected, errors.New("protected branch"))).Once() | |
| 144 | 144 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 145 | 145 | assert.False(t, r.OK) | |
| @@ -147,7 +147,7 @@ func TestHookProtocol(t *testing.T) { | |||
| 147 | 147 | }) | |
| 148 | 148 | ||
| 149 | 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 | 151 | Return(errors.New("disk /secret/path exploded")).Once() | |
| 152 | 152 | r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`) | |
| 153 | 153 | assert.False(t, r.OK) | |
| @@ -292,22 +292,22 @@ func TestRunHook(t *testing.T) { | |||
| 292 | 292 | hooks.EXPECT().Check(mock.Anything, mock.Anything, repoName(t, "alpha"), []domain.RefUpdate{ | |
| 293 | 293 | {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}, | |
| 294 | 294 | {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"})) | |
| 297 | 297 | assert.Empty(t, errOut.String()) | |
| 298 | 298 | }) | |
| 299 | 299 | ||
| 300 | 300 | t.Run("a ref name with spaces survives", func(t *testing.T) { | |
| 301 | 301 | hooks.EXPECT().Record(mock.Anything, mock.Anything, repoName(t, "alpha"), | |
| 302 | 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 | 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 | 308 | Return(errors.Join(domain.ErrRejected, errors.New("nope"))).Once() | |
| 309 | 309 | 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 | 311 | require.ErrorIs(t, err, githttp.ErrHookRejected) | |
| 312 | 312 | assert.Contains(t, errOut.String(), "rejected") | |
| 313 | 313 | }) | |
| @@ -320,12 +320,12 @@ func TestRunHook(t *testing.T) { | |||
| 320 | 320 | "unknown token": {sock, "unknown"}, | |
| 321 | 321 | } { | |
| 322 | 322 | 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 | 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 | 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 | 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 | 348 | cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) | |
| 349 | 349 | defer cancel() | |
| 350 | 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 | 352 | assert.ErrorIs(t, err, githttp.ErrHookRejected) | |
| 353 | 353 | assert.Less(t, time.Since(start), 5*time.Second) | |
| 354 | 354 | }) | |
| 355 | 355 | ||
| 356 | 356 | t.Run("post-receive never fails the push", func(t *testing.T) { | |
| 357 | 357 | 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 | 359 | assert.Contains(t, errOut.String(), "post-receive") | |
| 360 | 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 | 362 | assert.NotEmpty(t, errOut.String()) | |
| 363 | 363 | }) | |
| 364 | 364 | } | |
| @@ -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.Buffer | 309 | 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.Buffer | 322 | 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.Buffer | 357 | 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 { | |||
| 28 | 28 | Token string `json:"token"` | |
| 29 | 29 | Hook string `json:"hook"` | |
| 30 | 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 | 38 | // 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) { | |||
| 330 | 330 | Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt), | |
| 331 | 331 | AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3 | |
| 332 | 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 | 335 | if j.HasToken() { | |
| 336 | 336 | pb.TokenHash = j.TokenHash[:] | |
| @@ -360,7 +360,7 @@ func decodeJob(item kv.Item) (domain.CIJob, error) { | |||
| 360 | 360 | TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()), | |
| 361 | 361 | AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()), | |
| 362 | 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 | 365 | j.CancelReason = decodeCancelReason(pb.GetCancelReason()) | |
| 366 | 366 | 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 3 | 331 | 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 { | |||
| 62 | 62 | func encodeRepoToken(t domain.CIRepoToken) []byte { | |
| 63 | 63 | return mustMarshal(&storagepb.CIRepoToken{ | |
| 64 | 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 | 78 | t := domain.CIRepoToken{ | |
| 79 | 79 | ID: pb.GetId(), Repo: repo, RunID: pb.GetRunId(), RunAttempt: pb.GetRunAttempt(), JobID: pb.GetJobId(), | |
| 80 | 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 | 83 | copy(t.Hash[:], pb.GetHash()) | |
| 84 | 84 | 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, nil | 84 | return t, nil | ||
M internal/adapters/kvstore/ci_token_test.go +21 -0
| @@ -193,3 +193,24 @@ func TestCIRunSourceKeepsThePusher(t *testing.T) { | |||
| 193 | 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 | + | } | |
| @@ -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 { | |||
| 221 | 221 | Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes, | |
| 222 | 222 | Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref, GithubToken: j.GithubToken, | |
| 223 | 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 | ||
| @@ -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 | |||
| 248 | 248 | opts.ci(&ciCfg) | |
| 249 | 249 | } | |
| 250 | 250 | f.CI = usecase.NewCI(ctx, ciCfg) | |
| 251 | - | f.Pushes.WithWorkflowTrigger(f.CI) | |
| 251 | + | f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git) | |
| 252 | 252 | f.Repos.WithCIRunCleaner(f.CI) | |
| 253 | 253 | f.CI.Start() | |
| 254 | 254 | 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 ( | |||
| 8 | 8 | "github.com/spf13/cobra" | |
| 9 | 9 | ||
| 10 | 10 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // newHookCmd is the hidden command that git's receive hooks run (through the | |
| @@ -30,7 +31,8 @@ func newHookCmd() *cobra.Command { | |||
| 30 | 31 | SilenceUsage: true, | |
| 31 | 32 | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 32 | 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 | } | |
| @@ -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 the | 14 | // 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 { | |||
| 270 | 270 | // "environment:"), "" for none. Set when the job is planned; the secrets | |
| 271 | 271 | // and variables of the environment are read when the job is assigned. | |
| 272 | 272 | 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 | 279 | // MaxParallel is the max-parallel of the strategy, 0 for no limit. | |
| 274 | 280 | MaxParallel int | |
| 275 | 281 | Outputs map[string]string | |
| @@ -270,6 +270,12 @@ type CIJob struct { | |||||
| 270 | // "environment:"), "" for none. Set when the job is planned; the secrets | 270 | // "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 string | 272 | 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 int | 280 | MaxParallel int | ||
| 275 | Outputs map[string]string | 281 | 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 { | |||
| 41 | 41 | // Repo and nothing else. | |
| 42 | 42 | Archive bool | |
| 43 | 43 | // 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 | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | // Expired reports whether the token is no longer valid at now. | |
| @@ -56,10 +59,12 @@ const RepoTokenGrace = 5 * time.Minute | |||
| 56 | 59 | // RepoTokenPrincipal returns the principal of the holder of a job repository | |
| 57 | 60 | // token. | |
| 58 | 61 | // | |
| 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. | |
| 63 | 68 | // | |
| 64 | 69 | // An archive token holds no scope at all: it is the grant to download one | |
| 65 | 70 | // 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 | 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 | 85 | Repos: []RepoName{t.Repo}, ExpiresAt: t.ExpiresAt, CreatedAt: t.CreatedAt, | |
| 81 | 86 | }) | |
| 82 | 87 | } | |
| @@ -41,9 +41,12 @@ type CIRepoToken struct { | |||||
| 41 | // Repo and nothing else. | 41 | // Repo and nothing else. | ||
| 42 | Archive bool | 42 | 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 string | 44 | + | Sha string |
| 45 | - | ExpiresAt time.Time | 45 | + | // Permissions are the permissions of the job's own token (key to level, see |
| 46 | - | CreatedAt time.Time | 46 | + | // 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 repository | 59 | // 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 one | 62 | + | // 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 applies | 63 | + | // 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 writes | 64 | + | // 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 one | 69 | // 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 endpoint | 70 | // 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 { | |||
| 166 | 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 | 176 | // ExpiresAt returns the expiry of a stored token; zero means none. | |
| 170 | 177 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | |
| 171 | 178 | ||
| @@ -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 { | |||
| 259 | 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 | 284 | // --- inbound --- | |
| 263 | 285 | ||
| 264 | 286 | // RegisterRunnerParams describe a runner to register. | |
| @@ -299,6 +321,9 @@ type RunnerJob struct { | |||
| 299 | 321 | Secrets map[string]string | |
| 300 | 322 | // Trusted is the trusted flag of the run: secrets were allowed. | |
| 301 | 323 | 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 | 329 | // 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]string | 321 | 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 bool | 323 | 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 job | 329 | // 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 ( | |||
| 8 | 8 | "context" | |
| 9 | 9 | ||
| 10 | 10 | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 11 | 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 | 51 | // 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) | |
| 53 | 54 | ||
| 54 | 55 | if len(ret) == 0 { | |
| 55 | 56 | panic("no return value specified for Check") | |
| 56 | 57 | } | |
| 57 | 58 | ||
| 58 | 59 | 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) | |
| 61 | 62 | } else { | |
| 62 | 63 | r0 = ret.Error(0) | |
| 63 | 64 | } | |
| @@ -74,11 +75,12 @@ type PushHookService_Check_Call struct { | |||
| 74 | 75 | // - p domain.Principal | |
| 75 | 76 | // - name domain.RepoName | |
| 76 | 77 | // - 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)} | |
| 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 | 84 | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | 85 | var arg0 context.Context | |
| 84 | 86 | if args[0] != nil { | |
| @@ -96,11 +98,16 @@ func (_c *PushHookService_Check_Call) Run(run func(ctx context.Context, p domain | |||
| 96 | 98 | if args[3] != nil { | |
| 97 | 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 | 105 | run( | |
| 100 | 106 | arg0, | |
| 101 | 107 | arg1, | |
| 102 | 108 | arg2, | |
| 103 | 109 | arg3, | |
| 110 | + | arg4, | |
| 104 | 111 | ) | |
| 105 | 112 | }) | |
| 106 | 113 | return _c | |
| @@ -111,7 +118,7 @@ func (_c *PushHookService_Check_Call) Return(err error) *PushHookService_Check_C | |||
| 111 | 118 | 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 | 122 | _c.Call.Return(run) | |
| 116 | 123 | return _c | |
| 117 | 124 | } | |
| @@ -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 PushHookService | 51 | // 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 error | 59 | 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.Principal | 75 | // - p domain.Principal | ||
| 75 | // - name domain.RepoName | 76 | // - name domain.RepoName | ||
| 76 | // - updates []domain.RefUpdate | 77 | // - 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.Context | 85 | 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 _c | 113 | return _c | ||
| @@ -111,7 +118,7 @@ func (_c *PushHookService_Check_Call) Return(err error) *PushHookService_Check_C | |||||
| 111 | return _c | 118 | 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 _c | 123 | 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 { | |||
| 190 | 190 | type PushHookService interface { | |
| 191 | 191 | // Check decides a push before Git applies it; an error matching | |
| 192 | 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) 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 | |
| 194 | 195 | // Record handles an accepted push. | |
| 195 | 196 | Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error | |
| 196 | 197 | } | |
| @@ -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 matching | 191 | // 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) 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 | |||
| 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) error | 196 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "encoding/hex" | |
| 7 | 8 | "errors" | |
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| @@ -46,6 +47,7 @@ func Conformance(t *testing.T, mk Maker) { | |||
| 46 | 47 | {"HostileRevisionAndPathStayLiteral", testHostileRevisionAndPathStayLiteral}, | |
| 47 | 48 | {"ResolveRevisionObjectIDBeatsRefs", testResolveRevisionObjectIDBeatsRefs}, | |
| 48 | 49 | {"GetTreeIsBounded", testGetTreeIsBounded}, | |
| 50 | + | {"GetTreeRefusesAGitlinkToATree", testGetTreeRefusesAGitlinkToATree}, | |
| 49 | 51 | {"ListPaths", testListPaths}, | |
| 50 | 52 | } { | |
| 51 | 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 | 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 | + | } | |
| @@ -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 { | |||
| 63 | 63 | // first push: repo:write on the name, and no branch prefixes. A token limited | |
| 64 | 64 | // to some branches may push to a repository that exists; it never makes one. | |
| 65 | 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 | 69 | // 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 limited | 63 | // 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 revoking | 69 | // 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 { | |||
| 347 | 347 | if !ready { | |
| 348 | 348 | continue | |
| 349 | 349 | } | |
| 350 | - | rs.planJob(ctx, wj, insts[0], needs, st) | |
| 350 | + | rs.planJob(ctx, wf, wj, insts[0], needs, st) | |
| 351 | 351 | changed = true | |
| 352 | 352 | } | |
| 353 | 353 | } | |
| @@ -381,7 +381,7 @@ func (rs *runState) promote() { | |||
| 381 | 381 | // plan evaluates a job whose needs have ended: its condition, its strategy and | |
| 382 | 382 | // the parts of every instance the server decides (name, labels, timeout, | |
| 383 | 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 | 385 | rec := rs.jobs[idx] | |
| 386 | 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 | 428 | fail(err.Error()) | |
| 429 | 429 | 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 | 432 | if err != nil { | |
| 433 | 433 | fail(err.Error()) | |
| 434 | 434 | return | |
| @@ -456,7 +456,7 @@ func (rs *runState) skippedName(ctx context.Context, wj *workflow.Job, env templ | |||
| 456 | 456 | } | |
| 457 | 457 | ||
| 458 | 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 | 460 | j := base | |
| 461 | 461 | j.Combo = i | |
| 462 | 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 | 506 | return j, err | |
| 507 | 507 | } | |
| 508 | 508 | j.Environment = environment | |
| 509 | + | j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |
| 509 | 510 | j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout | |
| 510 | 511 | j.MatrixJSON = compactJSON(expr.ToJSON(matrix)) | |
| 511 | 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 | 559 | 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 | + | } | |
| @@ -347,7 +347,7 @@ func (rs *runState) advance(ctx context.Context) error { | |||||
| 347 | if !ready { | 347 | if !ready { | ||
| 348 | continue | 348 | continue | ||
| 349 | } | 349 | } | ||
| 350 | - | rs.planJob(ctx, wj, insts[0], needs, st) | 350 | + | rs.planJob(ctx, wf, wj, insts[0], needs, st) |
| 351 | changed = true | 351 | 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 and | 381 | // 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 | return | 429 | 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 | return | 434 | 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 := base | 460 | j := base | ||
| 461 | j.Combo = i | 461 | 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, err | 506 | return j, err | ||
| 507 | } | 507 | } | ||
| 508 | j.Environment = environment | 508 | j.Environment = environment | ||
| 509 | + | j.Permissions = domain.ResolvePermissions(permissionSpec(wf.Permissions), permissionSpec(wj.Permissions)) | |||
| 509 | j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout | 510 | 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), nil | 559 | 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 | |||
| 54 | 54 | t := c.repoTokenExpiry(at) | |
| 55 | 55 | t.ID, t.Hash, t.Repo = newRepoTokenID(), domain.HashTokenSecret(secret), repo | |
| 56 | 56 | 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 | 63 | rs.tokens = append(rs.tokens, t) | |
| 58 | 64 | return secret | |
| 59 | 65 | } | |
| @@ -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), repo | 55 | 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, now | 56 | 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 secret | 64 | 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 | |||
| 246 | 246 | Job: j, Token: token, GithubToken: ghToken, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref, | |
| 247 | 247 | WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML, | |
| 248 | 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 | ||
| @@ -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 { | |||
| 47 | 47 | // OnPush queues the refs of a push or a merge for planning and returns at once; | |
| 48 | 48 | // a worker plans them. A full queue drops the trigger, logged at error: a push | |
| 49 | 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 | 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 | 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 | 67 | // merging. For the same reason the run is trusted (gets secrets) only when the | |
| 59 | 68 | // merger is: the displayed actor is not the one that decides. | |
| 60 | 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 | 75 | c.enqueue(ctx, repo, updates, authorOf(domain.Owner()), domain.PusherOf(merger), domain.TrustedActor(merger)) | |
| 62 | 76 | } | |
| 63 | 77 | ||
| @@ -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 push | 48 | // 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 the | 67 | // 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 { | |||
| 29 | 29 | auth ports.Revalidator | |
| 30 | 30 | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 31 | 31 | ci ports.WorkflowTrigger // nil: no CI | |
| 32 | + | wf ports.WorkflowGuard // nil: pushes by job tokens are refused | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | 35 | var ( | |
| @@ -57,6 +58,14 @@ func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes { | |||
| 57 | 58 | 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 | 69 | // Open decides whether the principal may push to the repository and, with | |
| 61 | 70 | // create, creates it when it does not exist yet (private, published as | |
| 62 | 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 | 129 | // The token of the push was valid when the request began, but a push can run | |
| 121 | 130 | // for long: it is re-validated here, so a token revoked or expired meanwhile | |
| 122 | 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 | 140 | if err := u.auth.Revalidate(ctx, p); err != nil { | |
| 125 | 141 | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 126 | 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 | 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 | 160 | log := zerowrap.FromCtx(ctx) | |
| 140 | 161 | log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked") | |
| 141 | 162 | 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 | 184 | // Record is the post-receive step of an accepted push: it sets pushed_at, | |
| 145 | 185 | // repairs HEAD when it points to a missing branch, tells the reader that the | |
| 146 | 186 | // repository changed, queues the workflows the push triggers, publishes a | |
| @@ -29,6 +29,7 @@ type Pushes struct { | |||||
| 29 | auth ports.Revalidator | 29 | auth ports.Revalidator | ||
| 30 | fresh ports.RepoFreshener // nil: the reader needs no telling | 30 | fresh ports.RepoFreshener // nil: the reader needs no telling | ||
| 31 | ci ports.WorkflowTrigger // nil: no CI | 31 | 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 u | 58 | 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, with | 69 | // Open decides whether the principal may push to the repository and, with | ||
| 61 | // create, creates it when it does not exist yet (private, published as | 70 | // 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 run | 129 | // 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 meanwhile | 130 | // 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 nil | 162 | 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 the | 185 | // repairs HEAD when it points to a missing branch, tells the reader that the | ||
| 146 | // repository changed, queues the workflows the push triggers, publishes a | 186 | // 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 { | |||
| 24 | 24 | bus *mocks.EventBus | |
| 25 | 25 | clock *mocks.Clock | |
| 26 | 26 | tokens *mocks.TokenStore | |
| 27 | + | cirepo *mocks.CIRepoTokenStore | |
| 27 | 28 | sched *mocks.MaintenanceScheduler | |
| 28 | 29 | pushes *usecase.Pushes | |
| 29 | 30 | } | |
| @@ -36,11 +37,12 @@ func newPushFixture(t *testing.T) *pushFixture { | |||
| 36 | 37 | bus: mocks.NewEventBus(t), | |
| 37 | 38 | clock: mocks.NewClock(t), | |
| 38 | 39 | tokens: mocks.NewTokenStore(t), | |
| 40 | + | cirepo: mocks.NewCIRepoTokenStore(t), | |
| 39 | 41 | sched: mocks.NewMaintenanceScheduler(t), | |
| 40 | 42 | } | |
| 41 | 43 | f.clock.EXPECT().Now().Return(now).Maybe() | |
| 42 | 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 | 46 | f.pushes = usecase.NewPushes(f.meta, repos, f.git, f.bus, f.clock, f.sched, auth) | |
| 45 | 47 | return f | |
| 46 | 48 | } | |
| @@ -158,28 +160,28 @@ func TestPushesCheck(t *testing.T) { | |||
| 158 | 160 | t.Run("root is always valid and needs no lookup", func(t *testing.T) { | |
| 159 | 161 | t.Parallel() | |
| 160 | 162 | 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 | 166 | t.Run("a writer whose token still exists", func(t *testing.T) { | |
| 165 | 167 | t.Parallel() | |
| 166 | 168 | f := newPushFixture(t) | |
| 167 | 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 | 173 | t.Run("a token revoked during the push is rejected", func(t *testing.T) { | |
| 172 | 174 | t.Parallel() | |
| 173 | 175 | f := newPushFixture(t) | |
| 174 | 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 | 180 | t.Run("a token that expired during the push is rejected", func(t *testing.T) { | |
| 179 | 181 | t.Parallel() | |
| 180 | 182 | f := newPushFixture(t) | |
| 181 | 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 | 187 | t.Run("a store failure fails closed", func(t *testing.T) { | |
| @@ -187,7 +189,7 @@ func TestPushesCheck(t *testing.T) { | |||
| 187 | 189 | f := newPushFixture(t) | |
| 188 | 190 | boom := errors.New("boom") | |
| 189 | 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 | 193 | require.ErrorIs(t, err, boom) | |
| 192 | 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 | 198 | t.Parallel() | |
| 197 | 199 | f := newPushFixture(t) | |
| 198 | 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 | 204 | t.Run("anonymous is rejected", func(t *testing.T) { | |
| 203 | 205 | t.Parallel() | |
| 204 | 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 | 251 | t.Parallel() | |
| 250 | 252 | f := newPushFixture(t) | |
| 251 | 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 | 255 | if tt.refused == "" { | |
| 254 | 256 | require.NoError(t, err) | |
| 255 | 257 | return | |
| @@ -264,7 +266,7 @@ func TestPushesCheckBranchPrefixes(t *testing.T) { | |||
| 264 | 266 | f := newPushFixture(t) | |
| 265 | 267 | f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil) | |
| 266 | 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 | 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 | 274 | t.Run("root pushes anywhere", func(t *testing.T) { | |
| 273 | 275 | t.Parallel() | |
| 274 | 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 | 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 | 282 | f := newPushFixture(t) | |
| 281 | 283 | f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil) | |
| 282 | 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 | 286 | require.ErrorIs(t, err, domain.ErrRejected) | |
| 285 | 287 | assert.Contains(t, err.Error(), "no write access") | |
| 286 | 288 | }) | |
| @@ -436,3 +438,88 @@ func TestPushesDefaultBranchRule(t *testing.T) { | |||
| 436 | 438 | ||
| 437 | 439 | // mockAny matches any argument of a mocked call. | |
| 438 | 440 | 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.EventBus | 24 | bus *mocks.EventBus | ||
| 25 | clock *mocks.Clock | 25 | clock *mocks.Clock | ||
| 26 | tokens *mocks.TokenStore | 26 | tokens *mocks.TokenStore | ||
| 27 | + | cirepo *mocks.CIRepoTokenStore | |||
| 27 | sched *mocks.MaintenanceScheduler | 28 | sched *mocks.MaintenanceScheduler | ||
| 28 | pushes *usecase.Pushes | 29 | 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 f | 47 | 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 call | 162 | 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 | return | 257 | 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.Anything | 440 | 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 { | |||
| 202 | 202 | // The evaluated environment: of the job, set when the job is planned; empty | |
| 203 | 203 | // for none. Its secrets and variables are read when the job is assigned. | |
| 204 | 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 | 211 | // CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or | |
| @@ -256,6 +260,11 @@ message CIRepoToken { | |||
| 256 | 260 | google.protobuf.Timestamp created_at = 9; | |
| 257 | 261 | // The commit an archive token downloads. | |
| 258 | 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 | 270 | // 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; empty | 202 | // 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, or | 211 | // 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 run | 270 | // CIActionResolution is the resolution of an action reference for a run | ||