refactor(domain): own the workflow-change rule for job tokens in WorkflowChangeOf
The WorkflowGuard port becomes PushTreeReader with two git primitives, PushSubtrees and FirstDifferentEntry; gitcli keeps peeling, quarantine checks, bounded batched reads and streaming diffs.
- commit
- a1ec1af38a475e33b8da371075e9289decd1827e
- author
- brice · · 2026-10-19 19:37 UTC
- committer
- brice · · 2026-10-19 20:01 UTC
- parent
- ac21956
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +1150 -835
M internal/adapters/gitcli/export_test.go +0 -6
| @@ -110,15 +110,9 @@ func (g *Git) CatFileWaiting() int { | |||
| 110 | 110 | return n | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | - | // MaxPushedCommits is the bound of the commits of a push, for the tests. | |
| 114 | - | const MaxPushedCommits = maxPushedCommits | |
| 115 | - | ||
| 116 | 113 | // ParseRevList reads rev-list output with the bounds of the guard replaced, and | |
| 117 | 114 | // returns the number of commits kept. | |
| 118 | 115 | func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) { | |
| 119 | 116 | c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs) | |
| 120 | 117 | return len(c), err | |
| 121 | 118 | } | |
| 122 | - | ||
| 123 | - | // MaxPushedObjects is the bound of the ids of the commits of a push. | |
| 124 | - | const MaxPushedObjects = maxPushedObjects | |
| @@ -110,15 +110,9 @@ func (g *Git) CatFileWaiting() int { | |||||
| 110 | return n | 110 | return n | ||
| 111 | } | 111 | } | ||
| 112 | 112 | ||||
| 113 | - | // MaxPushedCommits is the bound of the commits of a push, for the tests. | |||
| 114 | - | const MaxPushedCommits = maxPushedCommits | |||
| 115 | - | ||||
| 116 | // ParseRevList reads rev-list output with the bounds of the guard replaced, and | 113 | // ParseRevList reads rev-list output with the bounds of the guard replaced, and | ||
| 117 | // returns the number of commits kept. | 114 | // returns the number of commits kept. | ||
| 118 | func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) { | 115 | func ParseRevList(r io.Reader, maxCommits, maxIDs int) (int, error) { | ||
| 119 | c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs) | 116 | c, err := parseRevList(bufio.NewReaderSize(r, 64*1024), maxCommits, maxIDs) | ||
| 120 | return len(c), err | 117 | return len(c), err | ||
| 121 | } | 118 | } | ||
| 122 | - | ||||
| 123 | - | // MaxPushedObjects is the bound of the ids of the commits of a push. | |||
| 124 | - | const MaxPushedObjects = maxPushedObjects | |||
A internal/adapters/gitcli/push_trees.go +400 -0
| @@ -0,0 +1,400 @@ | |||
| 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 | + | "io" | |
| 11 | + | "path/filepath" | |
| 12 | + | "strconv" | |
| 13 | + | "strings" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | var _ ports.PushTreeReader = (*Git)(nil) | |
| 20 | + | ||
| 21 | + | const ( | |
| 22 | + | maxDiffPath = 4096 | |
| 23 | + | maxRevListLine = 8192 | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // PushSubtrees peels push endpoints, walks new commits, and reads entries at path. | |
| 27 | + | func (g *Git) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |
| 28 | + | out := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Subtrees: map[string]domain.Subtree{}} | |
| 29 | + | dir, err := g.repoDir(repo) | |
| 30 | + | if err != nil { | |
| 31 | + | return out, err | |
| 32 | + | } | |
| 33 | + | extra, err := quarantineEnv(dir, objects) | |
| 34 | + | if err != nil { | |
| 35 | + | return out, err | |
| 36 | + | } | |
| 37 | + | var kept []domain.RefUpdate | |
| 38 | + | for _, up := range updates { | |
| 39 | + | if up.New != domain.ZeroOID { | |
| 40 | + | kept = append(kept, up) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | if len(kept) == 0 { | |
| 44 | + | return out, nil | |
| 45 | + | } | |
| 46 | + | return g.readPushSubtrees(ctx, dir, extra, kept, path, bounds, out) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // readPushSubtrees batches the endpoint and walked-object cat-file queries. | |
| 50 | + | func (g *Git) readPushSubtrees(ctx context.Context, dir string, extra []string, kept []domain.RefUpdate, path string, bounds domain.PushWalkBounds, out domain.PushSubtrees) (domain.PushSubtrees, error) { | |
| 51 | + | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |
| 52 | + | if err != nil { | |
| 53 | + | return out, err | |
| 54 | + | } | |
| 55 | + | tips := pushCommitTips(kept, types, out.Ends) | |
| 56 | + | if len(tips) != 0 { | |
| 57 | + | out.Commits, err = g.newCommits(ctx, dir, extra, tips, bounds) | |
| 58 | + | if err != nil { | |
| 59 | + | return out, err | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ids := commitAndParentIDs(out.Commits) | |
| 63 | + | ws, err := g.subtreesAt(ctx, dir, extra, ids, path) | |
| 64 | + | if err != nil { | |
| 65 | + | return out, err | |
| 66 | + | } | |
| 67 | + | for id, w := range ws { | |
| 68 | + | out.Subtrees[id] = w | |
| 69 | + | } | |
| 70 | + | ids = pushEndSubtreeIDs(kept, out.Ends) | |
| 71 | + | ws, err = g.subtreesAt(ctx, dir, extra, ids, path) | |
| 72 | + | if err != nil { | |
| 73 | + | return out, err | |
| 74 | + | } | |
| 75 | + | for id, w := range ws { | |
| 76 | + | out.Subtrees[id] = w | |
| 77 | + | } | |
| 78 | + | return out, nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // pushCommitTips translates peeled endpoint answers and deduplicates rev-list tips. | |
| 82 | + | func pushCommitTips(kept []domain.RefUpdate, types []string, ends map[string]domain.PushedEnd) []string { | |
| 83 | + | var tips []string | |
| 84 | + | seen := map[string]bool{} | |
| 85 | + | for i, up := range kept { | |
| 86 | + | for j, id := range []string{up.Old, up.New} { | |
| 87 | + | if id != domain.ZeroOID { | |
| 88 | + | ends[id] = domain.PushedEnd{Type: domain.ObjectType(types[2*i+j])} | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | if types[2*i+1] == "commit" && !seen[up.New] { | |
| 92 | + | seen[up.New] = true | |
| 93 | + | tips = append(tips, up.New) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | return tips | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // commitAndParentIDs deduplicates the object names for a cat-file batch. | |
| 100 | + | func commitAndParentIDs(commits []domain.PushedCommit) []string { | |
| 101 | + | var ids []string | |
| 102 | + | asked := map[string]bool{} | |
| 103 | + | for _, c := range commits { | |
| 104 | + | for _, id := range append([]domain.ObjectID{c.ID}, c.Parents...) { | |
| 105 | + | key := id.String() | |
| 106 | + | if !asked[key] { | |
| 107 | + | asked[key] = true | |
| 108 | + | ids = append(ids, key) | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return ids | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // pushEndSubtreeIDs omits blobs (not tree-ish) and includes HEAD only for creates. | |
| 116 | + | func pushEndSubtreeIDs(kept []domain.RefUpdate, ends map[string]domain.PushedEnd) []string { | |
| 117 | + | var ids []string | |
| 118 | + | for _, up := range kept { | |
| 119 | + | for _, id := range []string{up.Old, up.New} { | |
| 120 | + | if id != domain.ZeroOID && ends[id].Type != domain.ObjectBlob { | |
| 121 | + | ids = append(ids, id) | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | // HEAD is needed only for a new tree-ish ref. Keep it in the end batch. | |
| 126 | + | for _, up := range kept { | |
| 127 | + | if up.Old == domain.ZeroOID && ends[up.New].Type != domain.ObjectBlob { | |
| 128 | + | ids = append(ids, domain.HeadKey) | |
| 129 | + | break | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return ids | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // newCommits lists the commits reachable from the tips and from no ref | |
| 136 | + | // (`rev-list --parents <tips> --not --all`), at most bounds.Commits: more | |
| 137 | + | // fails closed. | |
| 138 | + | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string, bounds domain.PushWalkBounds) ([]domain.PushedCommit, error) { | |
| 139 | + | if err := revListLimitsError(bounds); err != nil { | |
| 140 | + | return nil, err | |
| 141 | + | } | |
| 142 | + | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(bounds.Commits+1)}, tips...), "--not", "--all")...) | |
| 143 | + | var out []domain.PushedCommit | |
| 144 | + | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 145 | + | var err error | |
| 146 | + | out, err = parseRevList(r, bounds.Commits, bounds.IDs) | |
| 147 | + | return err | |
| 148 | + | }) | |
| 149 | + | if err != nil { | |
| 150 | + | return nil, fmt.Errorf("commits of a push: %w", err) | |
| 151 | + | } | |
| 152 | + | return out, nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // revListLimitsError checks the numeric range of rev-list and parser limits. | |
| 156 | + | func revListLimitsError(bounds domain.PushWalkBounds) error { | |
| 157 | + | if bounds.Commits < 0 || bounds.IDs < 0 || bounds.Commits == int(^uint(0)>>1) { | |
| 158 | + | return fmt.Errorf("%w: invalid push walk bounds", domain.ErrInvalid) | |
| 159 | + | } | |
| 160 | + | return nil | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |
| 164 | + | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |
| 165 | + | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |
| 166 | + | // id that is not one is an error, and it is raised as soon as the line that | |
| 167 | + | // crosses it is read: nothing past it is read or kept. | |
| 168 | + | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]domain.PushedCommit, error) { | |
| 169 | + | var out []domain.PushedCommit | |
| 170 | + | ids := 0 | |
| 171 | + | for { | |
| 172 | + | line, err := r.ReadSlice('\n') | |
| 173 | + | switch { | |
| 174 | + | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |
| 175 | + | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |
| 176 | + | case errors.Is(err, io.EOF) && len(line) == 0: | |
| 177 | + | return out, nil | |
| 178 | + | case err != nil && !errors.Is(err, io.EOF): | |
| 179 | + | return nil, err | |
| 180 | + | } | |
| 181 | + | f := strings.Fields(string(line)) | |
| 182 | + | if len(f) == 0 { | |
| 183 | + | continue | |
| 184 | + | } | |
| 185 | + | if len(out) == maxCommits { | |
| 186 | + | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |
| 187 | + | } | |
| 188 | + | if ids += len(f); ids > maxIDs { | |
| 189 | + | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |
| 190 | + | } | |
| 191 | + | for _, id := range f { | |
| 192 | + | if _, err := domain.ParseObjectID(id); err != nil { | |
| 193 | + | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | id, _ := domain.ParseObjectID(f[0]) | |
| 197 | + | c := domain.PushedCommit{ID: id} | |
| 198 | + | for _, p := range f[1:] { | |
| 199 | + | id, _ := domain.ParseObjectID(p) | |
| 200 | + | c.Parents = append(c.Parents, id) | |
| 201 | + | } | |
| 202 | + | out = append(out, c) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // subtreesAt reads the mode, id and type of entries without traversing them. | |
| 207 | + | func (g *Git) subtreesAt(ctx context.Context, dir string, extra []string, ids []string, path string) (map[string]domain.Subtree, error) { | |
| 208 | + | out := make(map[string]domain.Subtree, len(ids)) | |
| 209 | + | if len(ids) == 0 { | |
| 210 | + | return out, nil | |
| 211 | + | } | |
| 212 | + | specs := make([]string, len(ids)) | |
| 213 | + | for i, id := range ids { | |
| 214 | + | specs[i] = id + ":" + path | |
| 215 | + | } | |
| 216 | + | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |
| 217 | + | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |
| 218 | + | for _, key := range ids { | |
| 219 | + | line, err := r.ReadSlice('\n') | |
| 220 | + | if err != nil { | |
| 221 | + | return fmt.Errorf("read object answers: %w", err) | |
| 222 | + | } | |
| 223 | + | f := strings.Fields(string(line)) | |
| 224 | + | if len(f) != 3 { | |
| 225 | + | continue | |
| 226 | + | } // missing entry | |
| 227 | + | mode, err := strconv.ParseUint(f[0], 8, 32) | |
| 228 | + | if err != nil { | |
| 229 | + | continue | |
| 230 | + | } | |
| 231 | + | id, err := domain.ParseObjectID(f[1]) | |
| 232 | + | if err != nil { | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | kind := domain.EntryKindOf(uint32(mode)) | |
| 236 | + | if kind == domain.EntryDirectory && (f[0] != "040000" || f[2] != "tree") { | |
| 237 | + | kind = "" | |
| 238 | + | } | |
| 239 | + | out[key] = domain.Subtree{Kind: kind, ID: id} | |
| 240 | + | } | |
| 241 | + | return nil | |
| 242 | + | }) | |
| 243 | + | if err != nil { | |
| 244 | + | return nil, fmt.Errorf("subtrees of a push: %w", err) | |
| 245 | + | } | |
| 246 | + | return out, nil | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // FirstDifferentEntry streams the first differing top-level entry of two trees. | |
| 250 | + | func (g *Git) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, oldID, newID domain.ObjectID) (string, error) { | |
| 251 | + | dir, err := g.repoDir(repo) | |
| 252 | + | if err != nil { | |
| 253 | + | return "", err | |
| 254 | + | } | |
| 255 | + | extra, err := quarantineEnv(dir, objects) | |
| 256 | + | if err != nil { | |
| 257 | + | return "", err | |
| 258 | + | } | |
| 259 | + | from := oldID.String() | |
| 260 | + | if from == "" { | |
| 261 | + | from = emptyTree | |
| 262 | + | } | |
| 263 | + | to := newID.String() | |
| 264 | + | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |
| 265 | + | var name string | |
| 266 | + | err = g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 267 | + | n, found, err := firstPath(r) | |
| 268 | + | name = n | |
| 269 | + | if err != nil || !found { | |
| 270 | + | return err // no name: git's exit status is checked, so a failure refuses | |
| 271 | + | } | |
| 272 | + | return errStop // the first name is all that is needed: stop git | |
| 273 | + | }) | |
| 274 | + | if err != nil { | |
| 275 | + | return "", fmt.Errorf("different tree entries %s..%s: %w", from, to, err) | |
| 276 | + | } | |
| 277 | + | return name, nil | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // firstPath reads the first NUL-terminated path of r; found is false at the end | |
| 281 | + | // of the output without one. A path longer than maxDiffPath is an error. | |
| 282 | + | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |
| 283 | + | var b []byte | |
| 284 | + | for { | |
| 285 | + | c, err := r.ReadByte() | |
| 286 | + | switch { | |
| 287 | + | case err != nil: | |
| 288 | + | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |
| 289 | + | case c == 0: | |
| 290 | + | return string(b), true, nil | |
| 291 | + | case len(b) >= maxDiffPath: | |
| 292 | + | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxDiffPath) | |
| 293 | + | } | |
| 294 | + | b = append(b, c) | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |
| 299 | + | // old (unless the ref is created) and the new end of each update and returns | |
| 300 | + | // the answers in that order, two per update, "" for the old end of a created | |
| 301 | + | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |
| 302 | + | // commit, a tree nor a blob is an error (the push is refused); with any other | |
| 303 | + | // suffix an answer "<input> missing" is kept as the answer. | |
| 304 | + | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |
| 305 | + | var in strings.Builder | |
| 306 | + | want := 0 | |
| 307 | + | for _, up := range ups { | |
| 308 | + | for _, oid := range []string{up.Old, up.New} { | |
| 309 | + | if oid == domain.ZeroOID { | |
| 310 | + | continue | |
| 311 | + | } | |
| 312 | + | if _, err := domain.ParseObjectID(oid); err != nil { | |
| 313 | + | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |
| 314 | + | } | |
| 315 | + | in.WriteString(oid + suffix + "\n") | |
| 316 | + | want++ | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | got := make([]string, 0, want) | |
| 320 | + | args := readArgs(dir, "cat-file", "--batch-check="+format) | |
| 321 | + | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |
| 322 | + | for range want { | |
| 323 | + | line, err := r.ReadSlice('\n') | |
| 324 | + | if err != nil { | |
| 325 | + | if errors.Is(err, bufio.ErrBufferFull) { | |
| 326 | + | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |
| 327 | + | } | |
| 328 | + | return fmt.Errorf("read object answers: %w", err) | |
| 329 | + | } | |
| 330 | + | got = append(got, strings.TrimSuffix(string(line), "\n")) | |
| 331 | + | } | |
| 332 | + | return nil | |
| 333 | + | }) | |
| 334 | + | if err != nil { | |
| 335 | + | return nil, fmt.Errorf("objects of a push: %w", err) | |
| 336 | + | } | |
| 337 | + | out := make([]string, 0, 2*len(ups)) | |
| 338 | + | i := 0 | |
| 339 | + | for _, up := range ups { | |
| 340 | + | for _, oid := range []string{up.Old, up.New} { | |
| 341 | + | if oid == domain.ZeroOID { | |
| 342 | + | out = append(out, "") | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if suffix == "^{}" { | |
| 346 | + | switch got[i] { | |
| 347 | + | case "commit", "tree", "blob": | |
| 348 | + | default: // "<input> missing", or a type that cannot be compared | |
| 349 | + | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | out = append(out, got[i]) | |
| 353 | + | i++ | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | return out, nil | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // quarantineEnv returns the environment that makes git read the objects of a | |
| 360 | + | // push in quarantine. Each directory must exist and, once symbolic links are | |
| 361 | + | // resolved, be inside the objects directory of the repository dir (itself | |
| 362 | + | // resolved): a hook request cannot send git to read anything else. The resolved | |
| 363 | + | // paths are the ones given to git. | |
| 364 | + | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |
| 365 | + | if o.Dir == "" && o.Alternates == "" { | |
| 366 | + | return nil, nil | |
| 367 | + | } | |
| 368 | + | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |
| 369 | + | if err != nil { | |
| 370 | + | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |
| 371 | + | } | |
| 372 | + | inside := func(p string) (string, bool) { | |
| 373 | + | if !filepath.IsAbs(p) { | |
| 374 | + | return "", false | |
| 375 | + | } | |
| 376 | + | real, err := filepath.EvalSymlinks(p) | |
| 377 | + | if err != nil { | |
| 378 | + | return "", false | |
| 379 | + | } | |
| 380 | + | rel, err := filepath.Rel(root, real) | |
| 381 | + | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 382 | + | } | |
| 383 | + | dir, ok := inside(o.Dir) | |
| 384 | + | if o.Dir == "" || !ok { | |
| 385 | + | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 386 | + | } | |
| 387 | + | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |
| 388 | + | if o.Alternates != "" { | |
| 389 | + | var alts []string | |
| 390 | + | for _, a := range filepath.SplitList(o.Alternates) { | |
| 391 | + | real, ok := inside(a) | |
| 392 | + | if !ok { | |
| 393 | + | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 394 | + | } | |
| 395 | + | alts = append(alts, real) | |
| 396 | + | } | |
| 397 | + | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |
| 398 | + | } | |
| 399 | + | return env, nil | |
| 400 | + | } | |
| @@ -0,0 +1,400 @@ | |||||
| 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 | + | "io" | |||
| 11 | + | "path/filepath" | |||
| 12 | + | "strconv" | |||
| 13 | + | "strings" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | var _ ports.PushTreeReader = (*Git)(nil) | |||
| 20 | + | ||||
| 21 | + | const ( | |||
| 22 | + | maxDiffPath = 4096 | |||
| 23 | + | maxRevListLine = 8192 | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // PushSubtrees peels push endpoints, walks new commits, and reads entries at path. | |||
| 27 | + | func (g *Git) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |||
| 28 | + | out := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Subtrees: map[string]domain.Subtree{}} | |||
| 29 | + | dir, err := g.repoDir(repo) | |||
| 30 | + | if err != nil { | |||
| 31 | + | return out, err | |||
| 32 | + | } | |||
| 33 | + | extra, err := quarantineEnv(dir, objects) | |||
| 34 | + | if err != nil { | |||
| 35 | + | return out, err | |||
| 36 | + | } | |||
| 37 | + | var kept []domain.RefUpdate | |||
| 38 | + | for _, up := range updates { | |||
| 39 | + | if up.New != domain.ZeroOID { | |||
| 40 | + | kept = append(kept, up) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | if len(kept) == 0 { | |||
| 44 | + | return out, nil | |||
| 45 | + | } | |||
| 46 | + | return g.readPushSubtrees(ctx, dir, extra, kept, path, bounds, out) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // readPushSubtrees batches the endpoint and walked-object cat-file queries. | |||
| 50 | + | func (g *Git) readPushSubtrees(ctx context.Context, dir string, extra []string, kept []domain.RefUpdate, path string, bounds domain.PushWalkBounds, out domain.PushSubtrees) (domain.PushSubtrees, error) { | |||
| 51 | + | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |||
| 52 | + | if err != nil { | |||
| 53 | + | return out, err | |||
| 54 | + | } | |||
| 55 | + | tips := pushCommitTips(kept, types, out.Ends) | |||
| 56 | + | if len(tips) != 0 { | |||
| 57 | + | out.Commits, err = g.newCommits(ctx, dir, extra, tips, bounds) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return out, err | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ids := commitAndParentIDs(out.Commits) | |||
| 63 | + | ws, err := g.subtreesAt(ctx, dir, extra, ids, path) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return out, err | |||
| 66 | + | } | |||
| 67 | + | for id, w := range ws { | |||
| 68 | + | out.Subtrees[id] = w | |||
| 69 | + | } | |||
| 70 | + | ids = pushEndSubtreeIDs(kept, out.Ends) | |||
| 71 | + | ws, err = g.subtreesAt(ctx, dir, extra, ids, path) | |||
| 72 | + | if err != nil { | |||
| 73 | + | return out, err | |||
| 74 | + | } | |||
| 75 | + | for id, w := range ws { | |||
| 76 | + | out.Subtrees[id] = w | |||
| 77 | + | } | |||
| 78 | + | return out, nil | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // pushCommitTips translates peeled endpoint answers and deduplicates rev-list tips. | |||
| 82 | + | func pushCommitTips(kept []domain.RefUpdate, types []string, ends map[string]domain.PushedEnd) []string { | |||
| 83 | + | var tips []string | |||
| 84 | + | seen := map[string]bool{} | |||
| 85 | + | for i, up := range kept { | |||
| 86 | + | for j, id := range []string{up.Old, up.New} { | |||
| 87 | + | if id != domain.ZeroOID { | |||
| 88 | + | ends[id] = domain.PushedEnd{Type: domain.ObjectType(types[2*i+j])} | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | if types[2*i+1] == "commit" && !seen[up.New] { | |||
| 92 | + | seen[up.New] = true | |||
| 93 | + | tips = append(tips, up.New) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | return tips | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // commitAndParentIDs deduplicates the object names for a cat-file batch. | |||
| 100 | + | func commitAndParentIDs(commits []domain.PushedCommit) []string { | |||
| 101 | + | var ids []string | |||
| 102 | + | asked := map[string]bool{} | |||
| 103 | + | for _, c := range commits { | |||
| 104 | + | for _, id := range append([]domain.ObjectID{c.ID}, c.Parents...) { | |||
| 105 | + | key := id.String() | |||
| 106 | + | if !asked[key] { | |||
| 107 | + | asked[key] = true | |||
| 108 | + | ids = append(ids, key) | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return ids | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // pushEndSubtreeIDs omits blobs (not tree-ish) and includes HEAD only for creates. | |||
| 116 | + | func pushEndSubtreeIDs(kept []domain.RefUpdate, ends map[string]domain.PushedEnd) []string { | |||
| 117 | + | var ids []string | |||
| 118 | + | for _, up := range kept { | |||
| 119 | + | for _, id := range []string{up.Old, up.New} { | |||
| 120 | + | if id != domain.ZeroOID && ends[id].Type != domain.ObjectBlob { | |||
| 121 | + | ids = append(ids, id) | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | // HEAD is needed only for a new tree-ish ref. Keep it in the end batch. | |||
| 126 | + | for _, up := range kept { | |||
| 127 | + | if up.Old == domain.ZeroOID && ends[up.New].Type != domain.ObjectBlob { | |||
| 128 | + | ids = append(ids, domain.HeadKey) | |||
| 129 | + | break | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return ids | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // newCommits lists the commits reachable from the tips and from no ref | |||
| 136 | + | // (`rev-list --parents <tips> --not --all`), at most bounds.Commits: more | |||
| 137 | + | // fails closed. | |||
| 138 | + | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string, bounds domain.PushWalkBounds) ([]domain.PushedCommit, error) { | |||
| 139 | + | if err := revListLimitsError(bounds); err != nil { | |||
| 140 | + | return nil, err | |||
| 141 | + | } | |||
| 142 | + | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(bounds.Commits+1)}, tips...), "--not", "--all")...) | |||
| 143 | + | var out []domain.PushedCommit | |||
| 144 | + | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 145 | + | var err error | |||
| 146 | + | out, err = parseRevList(r, bounds.Commits, bounds.IDs) | |||
| 147 | + | return err | |||
| 148 | + | }) | |||
| 149 | + | if err != nil { | |||
| 150 | + | return nil, fmt.Errorf("commits of a push: %w", err) | |||
| 151 | + | } | |||
| 152 | + | return out, nil | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // revListLimitsError checks the numeric range of rev-list and parser limits. | |||
| 156 | + | func revListLimitsError(bounds domain.PushWalkBounds) error { | |||
| 157 | + | if bounds.Commits < 0 || bounds.IDs < 0 || bounds.Commits == int(^uint(0)>>1) { | |||
| 158 | + | return fmt.Errorf("%w: invalid push walk bounds", domain.ErrInvalid) | |||
| 159 | + | } | |||
| 160 | + | return nil | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |||
| 164 | + | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |||
| 165 | + | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |||
| 166 | + | // id that is not one is an error, and it is raised as soon as the line that | |||
| 167 | + | // crosses it is read: nothing past it is read or kept. | |||
| 168 | + | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]domain.PushedCommit, error) { | |||
| 169 | + | var out []domain.PushedCommit | |||
| 170 | + | ids := 0 | |||
| 171 | + | for { | |||
| 172 | + | line, err := r.ReadSlice('\n') | |||
| 173 | + | switch { | |||
| 174 | + | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |||
| 175 | + | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |||
| 176 | + | case errors.Is(err, io.EOF) && len(line) == 0: | |||
| 177 | + | return out, nil | |||
| 178 | + | case err != nil && !errors.Is(err, io.EOF): | |||
| 179 | + | return nil, err | |||
| 180 | + | } | |||
| 181 | + | f := strings.Fields(string(line)) | |||
| 182 | + | if len(f) == 0 { | |||
| 183 | + | continue | |||
| 184 | + | } | |||
| 185 | + | if len(out) == maxCommits { | |||
| 186 | + | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |||
| 187 | + | } | |||
| 188 | + | if ids += len(f); ids > maxIDs { | |||
| 189 | + | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |||
| 190 | + | } | |||
| 191 | + | for _, id := range f { | |||
| 192 | + | if _, err := domain.ParseObjectID(id); err != nil { | |||
| 193 | + | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | id, _ := domain.ParseObjectID(f[0]) | |||
| 197 | + | c := domain.PushedCommit{ID: id} | |||
| 198 | + | for _, p := range f[1:] { | |||
| 199 | + | id, _ := domain.ParseObjectID(p) | |||
| 200 | + | c.Parents = append(c.Parents, id) | |||
| 201 | + | } | |||
| 202 | + | out = append(out, c) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // subtreesAt reads the mode, id and type of entries without traversing them. | |||
| 207 | + | func (g *Git) subtreesAt(ctx context.Context, dir string, extra []string, ids []string, path string) (map[string]domain.Subtree, error) { | |||
| 208 | + | out := make(map[string]domain.Subtree, len(ids)) | |||
| 209 | + | if len(ids) == 0 { | |||
| 210 | + | return out, nil | |||
| 211 | + | } | |||
| 212 | + | specs := make([]string, len(ids)) | |||
| 213 | + | for i, id := range ids { | |||
| 214 | + | specs[i] = id + ":" + path | |||
| 215 | + | } | |||
| 216 | + | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |||
| 217 | + | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |||
| 218 | + | for _, key := range ids { | |||
| 219 | + | line, err := r.ReadSlice('\n') | |||
| 220 | + | if err != nil { | |||
| 221 | + | return fmt.Errorf("read object answers: %w", err) | |||
| 222 | + | } | |||
| 223 | + | f := strings.Fields(string(line)) | |||
| 224 | + | if len(f) != 3 { | |||
| 225 | + | continue | |||
| 226 | + | } // missing entry | |||
| 227 | + | mode, err := strconv.ParseUint(f[0], 8, 32) | |||
| 228 | + | if err != nil { | |||
| 229 | + | continue | |||
| 230 | + | } | |||
| 231 | + | id, err := domain.ParseObjectID(f[1]) | |||
| 232 | + | if err != nil { | |||
| 233 | + | return err | |||
| 234 | + | } | |||
| 235 | + | kind := domain.EntryKindOf(uint32(mode)) | |||
| 236 | + | if kind == domain.EntryDirectory && (f[0] != "040000" || f[2] != "tree") { | |||
| 237 | + | kind = "" | |||
| 238 | + | } | |||
| 239 | + | out[key] = domain.Subtree{Kind: kind, ID: id} | |||
| 240 | + | } | |||
| 241 | + | return nil | |||
| 242 | + | }) | |||
| 243 | + | if err != nil { | |||
| 244 | + | return nil, fmt.Errorf("subtrees of a push: %w", err) | |||
| 245 | + | } | |||
| 246 | + | return out, nil | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | // FirstDifferentEntry streams the first differing top-level entry of two trees. | |||
| 250 | + | func (g *Git) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, oldID, newID domain.ObjectID) (string, error) { | |||
| 251 | + | dir, err := g.repoDir(repo) | |||
| 252 | + | if err != nil { | |||
| 253 | + | return "", err | |||
| 254 | + | } | |||
| 255 | + | extra, err := quarantineEnv(dir, objects) | |||
| 256 | + | if err != nil { | |||
| 257 | + | return "", err | |||
| 258 | + | } | |||
| 259 | + | from := oldID.String() | |||
| 260 | + | if from == "" { | |||
| 261 | + | from = emptyTree | |||
| 262 | + | } | |||
| 263 | + | to := newID.String() | |||
| 264 | + | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |||
| 265 | + | var name string | |||
| 266 | + | err = g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 267 | + | n, found, err := firstPath(r) | |||
| 268 | + | name = n | |||
| 269 | + | if err != nil || !found { | |||
| 270 | + | return err // no name: git's exit status is checked, so a failure refuses | |||
| 271 | + | } | |||
| 272 | + | return errStop // the first name is all that is needed: stop git | |||
| 273 | + | }) | |||
| 274 | + | if err != nil { | |||
| 275 | + | return "", fmt.Errorf("different tree entries %s..%s: %w", from, to, err) | |||
| 276 | + | } | |||
| 277 | + | return name, nil | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // firstPath reads the first NUL-terminated path of r; found is false at the end | |||
| 281 | + | // of the output without one. A path longer than maxDiffPath is an error. | |||
| 282 | + | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |||
| 283 | + | var b []byte | |||
| 284 | + | for { | |||
| 285 | + | c, err := r.ReadByte() | |||
| 286 | + | switch { | |||
| 287 | + | case err != nil: | |||
| 288 | + | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |||
| 289 | + | case c == 0: | |||
| 290 | + | return string(b), true, nil | |||
| 291 | + | case len(b) >= maxDiffPath: | |||
| 292 | + | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxDiffPath) | |||
| 293 | + | } | |||
| 294 | + | b = append(b, c) | |||
| 295 | + | } | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |||
| 299 | + | // old (unless the ref is created) and the new end of each update and returns | |||
| 300 | + | // the answers in that order, two per update, "" for the old end of a created | |||
| 301 | + | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |||
| 302 | + | // commit, a tree nor a blob is an error (the push is refused); with any other | |||
| 303 | + | // suffix an answer "<input> missing" is kept as the answer. | |||
| 304 | + | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |||
| 305 | + | var in strings.Builder | |||
| 306 | + | want := 0 | |||
| 307 | + | for _, up := range ups { | |||
| 308 | + | for _, oid := range []string{up.Old, up.New} { | |||
| 309 | + | if oid == domain.ZeroOID { | |||
| 310 | + | continue | |||
| 311 | + | } | |||
| 312 | + | if _, err := domain.ParseObjectID(oid); err != nil { | |||
| 313 | + | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |||
| 314 | + | } | |||
| 315 | + | in.WriteString(oid + suffix + "\n") | |||
| 316 | + | want++ | |||
| 317 | + | } | |||
| 318 | + | } | |||
| 319 | + | got := make([]string, 0, want) | |||
| 320 | + | args := readArgs(dir, "cat-file", "--batch-check="+format) | |||
| 321 | + | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |||
| 322 | + | for range want { | |||
| 323 | + | line, err := r.ReadSlice('\n') | |||
| 324 | + | if err != nil { | |||
| 325 | + | if errors.Is(err, bufio.ErrBufferFull) { | |||
| 326 | + | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |||
| 327 | + | } | |||
| 328 | + | return fmt.Errorf("read object answers: %w", err) | |||
| 329 | + | } | |||
| 330 | + | got = append(got, strings.TrimSuffix(string(line), "\n")) | |||
| 331 | + | } | |||
| 332 | + | return nil | |||
| 333 | + | }) | |||
| 334 | + | if err != nil { | |||
| 335 | + | return nil, fmt.Errorf("objects of a push: %w", err) | |||
| 336 | + | } | |||
| 337 | + | out := make([]string, 0, 2*len(ups)) | |||
| 338 | + | i := 0 | |||
| 339 | + | for _, up := range ups { | |||
| 340 | + | for _, oid := range []string{up.Old, up.New} { | |||
| 341 | + | if oid == domain.ZeroOID { | |||
| 342 | + | out = append(out, "") | |||
| 343 | + | continue | |||
| 344 | + | } | |||
| 345 | + | if suffix == "^{}" { | |||
| 346 | + | switch got[i] { | |||
| 347 | + | case "commit", "tree", "blob": | |||
| 348 | + | default: // "<input> missing", or a type that cannot be compared | |||
| 349 | + | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | out = append(out, got[i]) | |||
| 353 | + | i++ | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | return out, nil | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // quarantineEnv returns the environment that makes git read the objects of a | |||
| 360 | + | // push in quarantine. Each directory must exist and, once symbolic links are | |||
| 361 | + | // resolved, be inside the objects directory of the repository dir (itself | |||
| 362 | + | // resolved): a hook request cannot send git to read anything else. The resolved | |||
| 363 | + | // paths are the ones given to git. | |||
| 364 | + | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |||
| 365 | + | if o.Dir == "" && o.Alternates == "" { | |||
| 366 | + | return nil, nil | |||
| 367 | + | } | |||
| 368 | + | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |||
| 369 | + | if err != nil { | |||
| 370 | + | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |||
| 371 | + | } | |||
| 372 | + | inside := func(p string) (string, bool) { | |||
| 373 | + | if !filepath.IsAbs(p) { | |||
| 374 | + | return "", false | |||
| 375 | + | } | |||
| 376 | + | real, err := filepath.EvalSymlinks(p) | |||
| 377 | + | if err != nil { | |||
| 378 | + | return "", false | |||
| 379 | + | } | |||
| 380 | + | rel, err := filepath.Rel(root, real) | |||
| 381 | + | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |||
| 382 | + | } | |||
| 383 | + | dir, ok := inside(o.Dir) | |||
| 384 | + | if o.Dir == "" || !ok { | |||
| 385 | + | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 386 | + | } | |||
| 387 | + | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |||
| 388 | + | if o.Alternates != "" { | |||
| 389 | + | var alts []string | |||
| 390 | + | for _, a := range filepath.SplitList(o.Alternates) { | |||
| 391 | + | real, ok := inside(a) | |||
| 392 | + | if !ok { | |||
| 393 | + | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 394 | + | } | |||
| 395 | + | alts = append(alts, real) | |||
| 396 | + | } | |||
| 397 | + | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |||
| 398 | + | } | |||
| 399 | + | return env, nil | |||
| 400 | + | } | |||
R internal/adapters/gitcli/workflow_guard_test.go → internal/adapters/gitcli/push_trees_test.go +131 -217 renamed, 52% similar
| @@ -51,7 +51,7 @@ func TestChangedWorkflow(t *testing.T) { | |||
| 51 | 51 | return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} | |
| 52 | 52 | } | |
| 53 | 53 | changed := func(ups ...domain.RefUpdate) string { | |
| 54 | - | p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |
| 54 | + | p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{}) | |
| 55 | 55 | require.NoError(t, err) | |
| 56 | 56 | return p | |
| 57 | 57 | } | |
| @@ -68,11 +68,11 @@ func TestChangedWorkflow(t *testing.T) { | |||
| 68 | 68 | assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one") | |
| 69 | 69 | assert.Equal(t, "", changed(), "no update") | |
| 70 | 70 | ||
| 71 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) | |
| 71 | + | _, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) | |
| 72 | 72 | assert.Error(t, err, "an object git does not have is a failure, not a pass") | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | - | func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | |
| 75 | + | func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) { | |
| 76 | 76 | g, data := newGit(t) | |
| 77 | 77 | ctx := context.Background() | |
| 78 | 78 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -90,23 +90,23 @@ func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | |||
| 90 | 90 | "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, | |
| 91 | 91 | "alternates without a dir": {Alternates: objects}, | |
| 92 | 92 | } { | |
| 93 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, o) | |
| 93 | + | _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 94 | 94 | assert.ErrorIs(t, err, domain.ErrInvalid, name) | |
| 95 | 95 | } | |
| 96 | 96 | // An existing directory inside the repository is accepted as a location | |
| 97 | 97 | // (the object is missing: a refusal of the object, not of the path). | |
| 98 | 98 | require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) | |
| 99 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}) | |
| 99 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 100 | 100 | require.Error(t, err) | |
| 101 | 101 | assert.NotContains(t, err.Error(), "outside the repository") | |
| 102 | 102 | // One that does not exist is refused. | |
| 103 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}) | |
| 103 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 104 | 104 | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 107 | // A symbolic link in the way cannot take the objects of a push out of the | |
| 108 | 108 | // repository, and a data directory reached through links still works. | |
| 109 | - | func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | |
| 109 | + | func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) { | |
| 110 | 110 | g, data := newGit(t) | |
| 111 | 111 | ctx := context.Background() | |
| 112 | 112 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -119,20 +119,20 @@ func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | |||
| 119 | 119 | // Inside objects/, a link to elsewhere is outside once resolved. | |
| 120 | 120 | elsewhere := t.TempDir() | |
| 121 | 121 | require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) | |
| 122 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}) | |
| 122 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 123 | 123 | assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") | |
| 124 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}) | |
| 124 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 125 | 125 | assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") | |
| 126 | 126 | ||
| 127 | 127 | // A link to somewhere inside objects/ is fine. | |
| 128 | 128 | require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) | |
| 129 | 129 | require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) | |
| 130 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}) | |
| 130 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 131 | 131 | require.Error(t, err) | |
| 132 | 132 | assert.NotContains(t, err.Error(), "outside the repository") | |
| 133 | 133 | } | |
| 134 | 134 | ||
| 135 | - | func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | |
| 135 | + | func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) { | |
| 136 | 136 | _, data := newGit(t) | |
| 137 | 137 | ctx := context.Background() | |
| 138 | 138 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -152,10 +152,10 @@ func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | |||
| 152 | 152 | q := filepath.Join(f.Bare, "objects", "incoming-9") | |
| 153 | 153 | require.NoError(t, os.Mkdir(q, 0o700)) | |
| 154 | 154 | for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { | |
| 155 | - | p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, | |
| 156 | - | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}) | |
| 155 | + | p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, | |
| 156 | + | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 157 | 157 | require.NoError(t, err, dir) | |
| 158 | - | assert.Equal(t, ".github/workflows/ci.yml", p, dir) | |
| 158 | + | assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir) | |
| 159 | 159 | } | |
| 160 | 160 | } | |
| 161 | 161 | ||
| @@ -182,17 +182,7 @@ func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...strin | |||
| 182 | 182 | return strings.TrimSpace(string(out)) | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | // withWorkflows is a commit whose .github/workflows is the tree given. | |
| 186 | - | func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string { | |
| 187 | - | t.Helper() | |
| 188 | - | gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows") | |
| 189 | - | return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github")) | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // The trees of a push are the pusher's: a workflows directory that fans out | |
| 193 | - | // into ten million paths is answered with its first path, at once, by a git | |
| 194 | - | // that is stopped, without the diff ever being held in memory. | |
| 195 | - | func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |
| 185 | + | func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) { | |
| 196 | 186 | g, data := newGit(t) | |
| 197 | 187 | ctx := context.Background() | |
| 198 | 188 | f := readertest.NewFixture(t, data, "proj") | |
| @@ -206,17 +196,17 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||
| 206 | 196 | } | |
| 207 | 197 | level = treeOf(t, f, entries...) | |
| 208 | 198 | } | |
| 209 | - | amp := withWorkflows(t, f, level) | |
| 199 | + | amp := level | |
| 210 | 200 | ||
| 211 | 201 | var before, after runtime.MemStats | |
| 212 | 202 | runtime.GC() | |
| 213 | 203 | runtime.ReadMemStats(&before) | |
| 214 | 204 | start := time.Now() | |
| 215 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{}) | |
| 205 | + | p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) | |
| 216 | 206 | took := time.Since(start) | |
| 217 | 207 | runtime.ReadMemStats(&after) | |
| 218 | 208 | require.NoError(t, err) | |
| 219 | - | assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p) | |
| 209 | + | assert.True(t, strings.HasPrefix(p, "d"), p) | |
| 220 | 210 | assert.Less(t, took, 2*time.Second, "answered from the first path") | |
| 221 | 211 | assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") | |
| 222 | 212 | // And no git is left behind. | |
| @@ -225,69 +215,50 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||
| 225 | 215 | } | |
| 226 | 216 | ||
| 227 | 217 | // A path longer than the bound fails closed: the push is refused, not read on. | |
| 228 | - | func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) { | |
| 218 | + | func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) { | |
| 229 | 219 | g, data := newGit(t) | |
| 230 | 220 | ctx := context.Background() | |
| 231 | 221 | f := readertest.NewFixture(t, data, "proj") | |
| 232 | 222 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 233 | 223 | level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") | |
| 234 | 224 | level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes | |
| 235 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{}) | |
| 225 | + | _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) | |
| 236 | 226 | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 237 | 227 | } | |
| 238 | 228 | ||
| 239 | 229 | // A tag may point to a blob, a tree or a commit; only the commits and trees can | |
| 240 | 230 | // hold a workflow, and an object that is not there refuses the push. | |
| 241 | - | func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) { | |
| 231 | + | func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) { | |
| 242 | 232 | g, data := newGit(t) | |
| 243 | 233 | ctx := context.Background() | |
| 244 | 234 | f := readertest.NewFixture(t, data, "proj") | |
| 245 | 235 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 246 | 236 | f.Write("README", "r\n") | |
| 247 | - | plain := f.Commit("plain") | |
| 248 | - | f.Write(".github/workflows/ci.yml", "on: push\n") | |
| 249 | - | wf := f.Commit("workflow") | |
| 250 | - | f.Write(".github/workflows/ci.yml", "on: [push]\n") | |
| 251 | - | f.Commit("the default branch moves on to other workflows") // HEAD is not wf | |
| 237 | + | plain := oidOf(t, f.Commit("plain")).String() | |
| 252 | 238 | blob := f.WriteObject("blob", "hello\n") | |
| 253 | - | annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 254 | - | annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 255 | - | rootTree := f.Git("rev-parse", wf+"^{tree}") | |
| 256 | - | ref := func(old, new string) []domain.RefUpdate { | |
| 257 | - | return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}} | |
| 258 | - | } | |
| 259 | - | changed := func(ups []domain.RefUpdate) (string, error) { | |
| 260 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |
| 239 | + | tag := func(id, kind string) string { | |
| 240 | + | return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 261 | 241 | } | |
| 262 | - | for name, ups := range map[string][]domain.RefUpdate{ | |
| 263 | - | "create a lightweight tag on a blob": ref(domain.ZeroOID, blob), | |
| 264 | - | "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob), | |
| 265 | - | "a tag from a blob to another blob": ref(blob, annotatedBlob), | |
| 266 | - | "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob), | |
| 267 | - | } { | |
| 268 | - | p, err := changed(ups) | |
| 269 | - | require.NoError(t, err, name) | |
| 270 | - | assert.Empty(t, p, name) | |
| 271 | - | } | |
| 272 | - | for name, ups := range map[string][]domain.RefUpdate{ | |
| 273 | - | "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()), | |
| 274 | - | "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()), | |
| 275 | - | "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF), | |
| 276 | - | "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree), | |
| 277 | - | "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF), | |
| 278 | - | } { | |
| 279 | - | p, err := changed(ups) | |
| 280 | - | require.NoError(t, err, name) | |
| 281 | - | assert.Equal(t, ".github/workflows/ci.yml", p, name) | |
| 242 | + | tree := f.Git("rev-parse", plain+"^{tree}") | |
| 243 | + | for _, r := range []struct { | |
| 244 | + | id string | |
| 245 | + | kind domain.ObjectType | |
| 246 | + | }{{plain, domain.ObjectCommit}, {tag(plain, "commit"), domain.ObjectCommit}, {blob, domain.ObjectBlob}, {tag(blob, "blob"), domain.ObjectBlob}, {tree, domain.ObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.ObjectCommit}} { | |
| 247 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 248 | + | require.NoError(t, err) | |
| 249 | + | assert.Equal(t, r.kind, got.Ends[r.id].Type) | |
| 250 | + | if r.kind == domain.ObjectBlob { | |
| 251 | + | assert.NotContains(t, got.Subtrees, r.id) | |
| 252 | + | } | |
| 282 | 253 | } | |
| 283 | - | for name, ups := range map[string][]domain.RefUpdate{ | |
| 284 | - | "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"), | |
| 285 | - | "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()), | |
| 286 | - | "a malformed object": ref(domain.ZeroOID, "ZZZZ"), | |
| 287 | - | } { | |
| 288 | - | _, err := changed(ups) | |
| 289 | - | assert.Error(t, err, name) | |
| 254 | + | for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} { | |
| 255 | + | _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 256 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 290 | 257 | } | |
| 258 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 259 | + | require.NoError(t, err) | |
| 260 | + | assert.Empty(t, got.Ends) | |
| 261 | + | assert.Empty(t, got.Commits) | |
| 291 | 262 | } | |
| 292 | 263 | ||
| 293 | 264 | // rawTree writes a tree object by hand: entries are "mode name hexid", in the | |
| @@ -308,73 +279,35 @@ func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string { | |||
| 308 | 279 | // mode 040000 below a directory .github of mode 040000. The same tree id as a | |
| 309 | 280 | // gitlink, a symbolic link or a file is nothing, and turning it into a | |
| 310 | 281 | // directory activates it; the object id alone does not tell. | |
| 311 | - | func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) { | |
| 282 | + | func TestPushSubtreesEntryKinds(t *testing.T) { | |
| 312 | 283 | g, data := newGit(t) | |
| 313 | 284 | ctx := context.Background() | |
| 314 | 285 | f := readertest.NewFixture(t, data, "proj") | |
| 315 | 286 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 316 | - | f.Write("README", "r\n") | |
| 317 | - | f.Commit("base") | |
| 318 | - | yml := f.WriteObject("blob", "on: push\n") | |
| 319 | - | wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows | |
| 320 | - | link := f.WriteObject("blob", ".github/other") | |
| 321 | - | file := f.WriteObject("blob", "not a directory\n") | |
| 322 | - | readme := f.WriteObject("blob", "r\n") | |
| 323 | - | ||
| 324 | - | // A commit whose .github/workflows has the mode and id given. | |
| 325 | - | withWorkflows := func(mode, id string) string { | |
| 326 | - | gh := rawTree(t, f, [3]string{mode, "workflows", id}) | |
| 327 | - | return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh})) | |
| 328 | - | } | |
| 329 | - | // A commit whose .github has the mode and id given. | |
| 330 | - | withGithub := func(mode, id string) string { | |
| 331 | - | return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id})) | |
| 332 | - | } | |
| 333 | - | asDir := withWorkflows("040000", wfTree) | |
| 334 | - | asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule | |
| 335 | - | asSymlink := withWorkflows("120000", link) | |
| 336 | - | asFile := withWorkflows("100644", file) | |
| 337 | - | none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme})) | |
| 338 | - | ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree}) | |
| 339 | - | githubDir := withGithub("040000", ghDir) | |
| 340 | - | githubGitlink := withGithub("160000", ghDir) // the same id, a submodule | |
| 341 | - | githubFile := withGithub("100644", file) | |
| 342 | - | githubLink := withGithub("120000", link) | |
| 343 | - | ||
| 344 | - | for name, c := range map[string]struct { | |
| 345 | - | old, new string | |
| 346 | - | want string | |
| 347 | - | }{ | |
| 348 | - | "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"}, | |
| 349 | - | "a directory to a gitlink removes it": {asDir, asGitlink, ""}, | |
| 350 | - | "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"}, | |
| 351 | - | "a directory to a symlink removes it": {asDir, asSymlink, ""}, | |
| 352 | - | "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"}, | |
| 353 | - | "a directory to a file removes it": {asDir, asFile, ""}, | |
| 354 | - | "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"}, | |
| 355 | - | "a directory to nothing": {asDir, none, ""}, | |
| 356 | - | "the same gitlink": {asGitlink, asGitlink, ""}, | |
| 357 | - | "gitlink to symlink": {asGitlink, asSymlink, ""}, | |
| 358 | - | ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"}, | |
| 359 | - | ".github a directory to a gitlink": {githubDir, githubGitlink, ""}, | |
| 360 | - | ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"}, | |
| 361 | - | ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"}, | |
| 362 | - | ".github a directory to a symlink": {githubDir, githubLink, ""}, | |
| 363 | - | "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""}, | |
| 364 | - | "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""}, | |
| 365 | - | "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""}, | |
| 366 | - | "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"}, | |
| 367 | - | } { | |
| 368 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{}) | |
| 369 | - | require.NoError(t, err, name) | |
| 370 | - | assert.Equal(t, c.want, p, name) | |
| 287 | + | blob := f.WriteObject("blob", "x\n") | |
| 288 | + | tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob}) | |
| 289 | + | for _, r := range []struct { | |
| 290 | + | mode, id string | |
| 291 | + | kind domain.EntryKind | |
| 292 | + | }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} { | |
| 293 | + | t.Run(r.mode, func(t *testing.T) { | |
| 294 | + | gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id}) | |
| 295 | + | root := rawTree(t, f, [3]string{"040000", ".github", gh}) | |
| 296 | + | oid := commitOf(t, f, root) | |
| 297 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 298 | + | require.NoError(t, err) | |
| 299 | + | assert.Equal(t, r.kind, got.Subtrees[oid].Kind) | |
| 300 | + | assert.Equal(t, r.id, got.Subtrees[oid].ID.String()) | |
| 301 | + | assert.NotContains(t, got.Subtrees, domain.HeadKey) | |
| 302 | + | if r.mode != "040000" { | |
| 303 | + | root = rawTree(t, f, [3]string{r.mode, ".github", r.id}) | |
| 304 | + | oid = commitOf(t, f, root) | |
| 305 | + | got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 306 | + | require.NoError(t, err) | |
| 307 | + | assert.NotContains(t, got.Subtrees, oid) | |
| 308 | + | } | |
| 309 | + | }) | |
| 371 | 310 | } | |
| 372 | - | ||
| 373 | - | // A tag that peels to the commit is judged the same way. | |
| 374 | - | tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") | |
| 375 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{}) | |
| 376 | - | require.NoError(t, err) | |
| 377 | - | assert.Equal(t, ".github/workflows/ci.yml", p) | |
| 378 | 311 | } | |
| 379 | 312 | ||
| 380 | 313 | // A history for the rules on the commits a push brings: main holds workflows A, | |
| @@ -418,91 +351,37 @@ func (h *guardHistory) commit(workflows, _ string, parents ...string) string { | |||
| 418 | 351 | return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...) | |
| 419 | 352 | } | |
| 420 | 353 | ||
| 421 | - | func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) { | |
| 422 | - | g, data := newGit(t) | |
| 423 | - | ctx := context.Background() | |
| 424 | - | h := newGuardHistory(t, g, data) | |
| 425 | - | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 426 | - | f := h.f | |
| 427 | - | run := func(ups ...domain.RefUpdate) (string, error) { | |
| 428 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |
| 429 | - | } | |
| 430 | - | create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} } | |
| 431 | - | move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} } | |
| 432 | - | const ci = ".github/workflows/ci.yml" | |
| 433 | - | ||
| 434 | - | allowed := func(name string, ups ...domain.RefUpdate) { | |
| 435 | - | t.Helper() | |
| 436 | - | p, err := run(ups...) | |
| 437 | - | require.NoError(t, err, name) | |
| 438 | - | assert.Empty(t, p, name) | |
| 439 | - | } | |
| 440 | - | refused := func(name string, ups ...domain.RefUpdate) { | |
| 441 | - | t.Helper() | |
| 442 | - | p, err := run(ups...) | |
| 443 | - | require.NoError(t, err, name) | |
| 444 | - | assert.Equal(t, ci, p, name) | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | // The staging repro: a new branch off main with a commit that touches no workflow. | |
| 448 | - | allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m))) | |
| 449 | - | allowed("a new branch at the head of main", create("refs/heads/same", h.m)) | |
| 450 | - | allowed("a new tag at the head of main", create("refs/tags/v1", h.m)) | |
| 451 | - | allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", ""))) | |
| 452 | - | allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m)))) | |
| 453 | - | allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m))) | |
| 454 | - | ||
| 455 | - | // A pushed commit that changes them is refused wherever it goes. | |
| 456 | - | refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m))) | |
| 457 | - | refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", ""))) | |
| 458 | - | refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m))) | |
| 459 | - | n4 := h.commit("C", "", h.m) | |
| 460 | - | n5 := h.commit("A", "", n4) // reverts the change of the commit before | |
| 461 | - | refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5)) | |
| 462 | - | refused("a new branch through the same", create("refs/heads/through", n5)) | |
| 463 | - | n6 := h.commit("A", "", h.commit("C", "", h.o)) | |
| 464 | - | refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6))) | |
| 465 | - | ||
| 466 | - | // A ref cannot be moved onto existing commits with other workflows. | |
| 467 | - | refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o)) | |
| 468 | - | refused("a new tag at that commit", create("refs/tags/old", h.o)) | |
| 469 | - | refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o)) | |
| 470 | - | allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o))) | |
| 471 | - | allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o))) | |
| 472 | - | ||
| 473 | - | // Merges: a change only if it differs from every parent. | |
| 474 | - | allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o))) | |
| 475 | - | allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o))) | |
| 476 | - | refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o))) | |
| 477 | - | refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o)))) | |
| 478 | - | // One that takes the old side's workflows is a change of the ref, not of a commit. | |
| 479 | - | refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o))) | |
| 480 | - | ||
| 481 | - | // Several updates: the first refused refuses the push. | |
| 482 | - | refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m))) | |
| 483 | - | allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID)) | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | // With no default branch head, a new ref is compared with no workflows at all. | |
| 487 | - | func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) { | |
| 354 | + | func TestPushSubtreesCommitsAndParents(t *testing.T) { | |
| 488 | 355 | g, data := newGit(t) | |
| 489 | 356 | ctx := context.Background() | |
| 490 | 357 | h := newGuardHistory(t, g, data) | |
| 491 | 358 | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 492 | - | h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist | |
| 493 | - | run := func(to string) string { | |
| 494 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{}) | |
| 495 | - | require.NoError(t, err) | |
| 496 | - | return p | |
| 359 | + | side := h.commit("B", "", h.o) | |
| 360 | + | merge := h.commit("A", "", h.m, side) | |
| 361 | + | got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 362 | + | require.NoError(t, err) | |
| 363 | + | want := h.f.Git("rev-list", "--parents", merge, "--not", "--all") | |
| 364 | + | var lines []string | |
| 365 | + | for _, c := range got.Commits { | |
| 366 | + | line := c.ID.String() | |
| 367 | + | for _, p := range c.Parents { | |
| 368 | + | line += " " + p.String() | |
| 369 | + | } | |
| 370 | + | lines = append(lines, line) | |
| 497 | 371 | } | |
| 498 | - | assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows") | |
| 499 | - | plain := h.commit("", "") | |
| 500 | - | h.f.Git("update-ref", "refs/heads/plain", plain) | |
| 501 | - | assert.Empty(t, run(plain), "an existing commit without") | |
| 372 | + | assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n")) | |
| 373 | + | assert.Len(t, got.Commits, 2) | |
| 374 | + | assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String()) | |
| 375 | + | assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String()) | |
| 376 | + | // Any directory path is accepted; the reader has no workflow path policy. | |
| 377 | + | other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds()) | |
| 378 | + | require.NoError(t, err) | |
| 379 | + | assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind) | |
| 380 | + | assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates") | |
| 502 | 381 | } | |
| 503 | 382 | ||
| 504 | 383 | // The commits of a push are bounded: more than the bound fails closed. | |
| 505 | - | func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | |
| 384 | + | func TestPushSubtreesBoundsThePushedCommits(t *testing.T) { | |
| 506 | 385 | g, data := newGit(t) | |
| 507 | 386 | ctx := context.Background() | |
| 508 | 387 | h := newGuardHistory(t, g, data) | |
| @@ -525,10 +404,10 @@ func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | |||
| 525 | 404 | } | |
| 526 | 405 | // Plain commits carry the workflows of main (the fast-import tree has none, | |
| 527 | 406 | // so the chain is "removing" them): allowed, whatever their number, up to the bound. | |
| 528 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{}) | |
| 407 | + | p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) | |
| 529 | 408 | require.NoError(t, err) | |
| 530 | - | assert.Empty(t, p) | |
| 531 | - | _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{}) | |
| 409 | + | assert.Len(t, p.Commits, 20) | |
| 410 | + | _, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) | |
| 532 | 411 | assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push") | |
| 533 | 412 | } | |
| 534 | 413 | ||
| @@ -565,18 +444,18 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||
| 565 | 444 | b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small | |
| 566 | 445 | } | |
| 567 | 446 | b.WriteString("\n") | |
| 568 | - | r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())} | |
| 569 | - | kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | |
| 447 | + | r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())} | |
| 448 | + | kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) | |
| 570 | 449 | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 571 | 450 | assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length") | |
| 572 | 451 | assert.Zero(t, kept, "nothing is returned with the error") | |
| 573 | - | total := gitcli.MaxPushedCommits * len(b.String()) | |
| 452 | + | total := domain.MaxPushedCommits * len(b.String()) | |
| 574 | 453 | assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total) | |
| 575 | 454 | assert.Less(t, r.read*20, total) | |
| 576 | 455 | ||
| 577 | 456 | // Within the bounds the same lines are kept: 100 commits of 198 parents. | |
| 578 | 457 | r = &countingReader{lines: 100, line: []byte(b.String())} | |
| 579 | - | kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | |
| 458 | + | kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) | |
| 580 | 459 | require.NoError(t, err) | |
| 581 | 460 | assert.Equal(t, 100, kept) | |
| 582 | 461 | ||
| @@ -590,3 +469,38 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||
| 590 | 469 | _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000) | |
| 591 | 470 | assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound") | |
| 592 | 471 | } | |
| 472 | + | ||
| 473 | + | func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |
| 474 | + | t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 475 | + | if err != nil { | |
| 476 | + | return "", err | |
| 477 | + | } | |
| 478 | + | c, found, err := domain.WorkflowChangeOf(ups, t) | |
| 479 | + | if err != nil || !found { | |
| 480 | + | return "", err | |
| 481 | + | } | |
| 482 | + | entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New) | |
| 483 | + | if err != nil { | |
| 484 | + | return "", err | |
| 485 | + | } | |
| 486 | + | if entry == "" { | |
| 487 | + | return domain.WorkflowDir, nil | |
| 488 | + | } | |
| 489 | + | return domain.WorkflowDir + "/" + entry, nil | |
| 490 | + | } | |
| 491 | + | ||
| 492 | + | func TestFirstDifferentEntry(t *testing.T) { | |
| 493 | + | g, data := newGit(t) | |
| 494 | + | ctx := context.Background() | |
| 495 | + | f := readertest.NewFixture(t, data, "proj") | |
| 496 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |
| 497 | + | tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml")) | |
| 498 | + | entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree) | |
| 499 | + | require.NoError(t, err) | |
| 500 | + | assert.Equal(t, "ci.yml", entry) | |
| 501 | + | entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree) | |
| 502 | + | require.NoError(t, err) | |
| 503 | + | assert.Empty(t, entry) | |
| 504 | + | _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree) | |
| 505 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |
| 506 | + | } | |
| @@ -51,7 +51,7 @@ func TestChangedWorkflow(t *testing.T) { | |||||
| 51 | return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} | 51 | return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} | ||
| 52 | } | 52 | } | ||
| 53 | changed := func(ups ...domain.RefUpdate) string { | 53 | changed := func(ups ...domain.RefUpdate) string { | ||
| 54 | - | p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | 54 | + | p, err := changedWorkflow(g, ctx, f.Name, ups, ports.PushObjects{}) |
| 55 | require.NoError(t, err) | 55 | require.NoError(t, err) | ||
| 56 | return p | 56 | return p | ||
| 57 | } | 57 | } | ||
| @@ -68,11 +68,11 @@ func TestChangedWorkflow(t *testing.T) { | |||||
| 68 | 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, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one") | ||
| 69 | assert.Equal(t, "", changed(), "no update") | 69 | assert.Equal(t, "", changed(), "no update") | ||
| 70 | 70 | ||||
| 71 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) | 71 | + | _, err := changedWorkflow(g, ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) |
| 72 | assert.Error(t, err, "an object git does not have is a failure, not a pass") | 72 | assert.Error(t, err, "an object git does not have is a failure, not a pass") | ||
| 73 | } | 73 | } | ||
| 74 | 74 | ||||
| 75 | - | func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | 75 | + | func TestPushSubtreesReadsTheQuarantineOfThePushOnly(t *testing.T) { |
| 76 | g, data := newGit(t) | 76 | g, data := newGit(t) | ||
| 77 | ctx := context.Background() | 77 | ctx := context.Background() | ||
| 78 | f := readertest.NewFixture(t, data, "proj") | 78 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -90,23 +90,23 @@ func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { | |||||
| 90 | "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, | 90 | "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, | ||
| 91 | "alternates without a dir": {Alternates: objects}, | 91 | "alternates without a dir": {Alternates: objects}, | ||
| 92 | } { | 92 | } { | ||
| 93 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, o) | 93 | + | _, err := g.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 94 | assert.ErrorIs(t, err, domain.ErrInvalid, name) | 94 | assert.ErrorIs(t, err, domain.ErrInvalid, name) | ||
| 95 | } | 95 | } | ||
| 96 | // An existing directory inside the repository is accepted as a location | 96 | // An existing directory inside the repository is accepted as a location | ||
| 97 | // (the object is missing: a refusal of the object, not of the path). | 97 | // (the object is missing: a refusal of the object, not of the path). | ||
| 98 | require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) | 98 | require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) | ||
| 99 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}) | 99 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 100 | require.Error(t, err) | 100 | require.Error(t, err) | ||
| 101 | assert.NotContains(t, err.Error(), "outside the repository") | 101 | assert.NotContains(t, err.Error(), "outside the repository") | ||
| 102 | // One that does not exist is refused. | 102 | // One that does not exist is refused. | ||
| 103 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}) | 103 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 104 | assert.ErrorIs(t, err, domain.ErrInvalid) | 104 | assert.ErrorIs(t, err, domain.ErrInvalid) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | // A symbolic link in the way cannot take the objects of a push out of the | 107 | // A symbolic link in the way cannot take the objects of a push out of the | ||
| 108 | // repository, and a data directory reached through links still works. | 108 | // repository, and a data directory reached through links still works. | ||
| 109 | - | func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | 109 | + | func TestPushSubtreesResolvesSymbolicLinks(t *testing.T) { |
| 110 | g, data := newGit(t) | 110 | g, data := newGit(t) | ||
| 111 | ctx := context.Background() | 111 | ctx := context.Background() | ||
| 112 | f := readertest.NewFixture(t, data, "proj") | 112 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -119,20 +119,20 @@ func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { | |||||
| 119 | // Inside objects/, a link to elsewhere is outside once resolved. | 119 | // Inside objects/, a link to elsewhere is outside once resolved. | ||
| 120 | elsewhere := t.TempDir() | 120 | elsewhere := t.TempDir() | ||
| 121 | require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) | 121 | require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) | ||
| 122 | - | _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}) | 122 | + | _, err := g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 123 | assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") | 123 | assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") | ||
| 124 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}) | 124 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 125 | assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") | 125 | assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") | ||
| 126 | 126 | ||||
| 127 | // A link to somewhere inside objects/ is fine. | 127 | // A link to somewhere inside objects/ is fine. | ||
| 128 | require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) | 128 | require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) | ||
| 129 | require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) | 129 | require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) | ||
| 130 | - | _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}) | 130 | + | _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 131 | require.Error(t, err) | 131 | require.Error(t, err) | ||
| 132 | assert.NotContains(t, err.Error(), "outside the repository") | 132 | assert.NotContains(t, err.Error(), "outside the repository") | ||
| 133 | } | 133 | } | ||
| 134 | 134 | ||||
| 135 | - | func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | 135 | + | func TestPushSubtreesThroughALinkedDataDirectory(t *testing.T) { |
| 136 | _, data := newGit(t) | 136 | _, data := newGit(t) | ||
| 137 | ctx := context.Background() | 137 | ctx := context.Background() | ||
| 138 | f := readertest.NewFixture(t, data, "proj") | 138 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -152,10 +152,10 @@ func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { | |||||
| 152 | q := filepath.Join(f.Bare, "objects", "incoming-9") | 152 | q := filepath.Join(f.Bare, "objects", "incoming-9") | ||
| 153 | require.NoError(t, os.Mkdir(q, 0o700)) | 153 | require.NoError(t, os.Mkdir(q, 0o700)) | ||
| 154 | for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { | 154 | for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { | ||
| 155 | - | p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, | 155 | + | p, err := g2.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, |
| 156 | - | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}) | 156 | + | ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 157 | require.NoError(t, err, dir) | 157 | require.NoError(t, err, dir) | ||
| 158 | - | assert.Equal(t, ".github/workflows/ci.yml", p, dir) | 158 | + | assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, dir) |
| 159 | } | 159 | } | ||
| 160 | } | 160 | } | ||
| 161 | 161 | ||||
| @@ -182,17 +182,7 @@ func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...strin | |||||
| 182 | return strings.TrimSpace(string(out)) | 182 | return strings.TrimSpace(string(out)) | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | - | // withWorkflows is a commit whose .github/workflows is the tree given. | 185 | + | func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) { |
| 186 | - | func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string { | |||
| 187 | - | t.Helper() | |||
| 188 | - | gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows") | |||
| 189 | - | return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github")) | |||
| 190 | - | } | |||
| 191 | - | ||||
| 192 | - | // The trees of a push are the pusher's: a workflows directory that fans out | |||
| 193 | - | // into ten million paths is answered with its first path, at once, by a git | |||
| 194 | - | // that is stopped, without the diff ever being held in memory. | |||
| 195 | - | func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||
| 196 | g, data := newGit(t) | 186 | g, data := newGit(t) | ||
| 197 | ctx := context.Background() | 187 | ctx := context.Background() | ||
| 198 | f := readertest.NewFixture(t, data, "proj") | 188 | f := readertest.NewFixture(t, data, "proj") | ||
| @@ -206,17 +196,17 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||||
| 206 | } | 196 | } | ||
| 207 | level = treeOf(t, f, entries...) | 197 | level = treeOf(t, f, entries...) | ||
| 208 | } | 198 | } | ||
| 209 | - | amp := withWorkflows(t, f, level) | 199 | + | amp := level |
| 210 | 200 | ||||
| 211 | var before, after runtime.MemStats | 201 | var before, after runtime.MemStats | ||
| 212 | runtime.GC() | 202 | runtime.GC() | ||
| 213 | runtime.ReadMemStats(&before) | 203 | runtime.ReadMemStats(&before) | ||
| 214 | start := time.Now() | 204 | start := time.Now() | ||
| 215 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{}) | 205 | + | p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) |
| 216 | took := time.Since(start) | 206 | took := time.Since(start) | ||
| 217 | runtime.ReadMemStats(&after) | 207 | runtime.ReadMemStats(&after) | ||
| 218 | require.NoError(t, err) | 208 | require.NoError(t, err) | ||
| 219 | - | assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p) | 209 | + | assert.True(t, strings.HasPrefix(p, "d"), p) |
| 220 | assert.Less(t, took, 2*time.Second, "answered from the first path") | 210 | assert.Less(t, took, 2*time.Second, "answered from the first path") | ||
| 221 | assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") | 211 | assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") | ||
| 222 | // And no git is left behind. | 212 | // And no git is left behind. | ||
| @@ -225,69 +215,50 @@ func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { | |||||
| 225 | } | 215 | } | ||
| 226 | 216 | ||||
| 227 | // A path longer than the bound fails closed: the push is refused, not read on. | 217 | // A path longer than the bound fails closed: the push is refused, not read on. | ||
| 228 | - | func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) { | 218 | + | func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) { |
| 229 | g, data := newGit(t) | 219 | g, data := newGit(t) | ||
| 230 | ctx := context.Background() | 220 | ctx := context.Background() | ||
| 231 | f := readertest.NewFixture(t, data, "proj") | 221 | f := readertest.NewFixture(t, data, "proj") | ||
| 232 | t.Cleanup(func() { _ = g.Close(ctx) }) | 222 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 233 | level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") | 223 | level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") | ||
| 234 | level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes | 224 | level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes | ||
| 235 | - | _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{}) | 225 | + | _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level)) |
| 236 | assert.ErrorIs(t, err, domain.ErrInvalid) | 226 | assert.ErrorIs(t, err, domain.ErrInvalid) | ||
| 237 | } | 227 | } | ||
| 238 | 228 | ||||
| 239 | // A tag may point to a blob, a tree or a commit; only the commits and trees can | 229 | // A tag may point to a blob, a tree or a commit; only the commits and trees can | ||
| 240 | // hold a workflow, and an object that is not there refuses the push. | 230 | // hold a workflow, and an object that is not there refuses the push. | ||
| 241 | - | func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) { | 231 | + | func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) { |
| 242 | g, data := newGit(t) | 232 | g, data := newGit(t) | ||
| 243 | ctx := context.Background() | 233 | ctx := context.Background() | ||
| 244 | f := readertest.NewFixture(t, data, "proj") | 234 | f := readertest.NewFixture(t, data, "proj") | ||
| 245 | t.Cleanup(func() { _ = g.Close(ctx) }) | 235 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 246 | f.Write("README", "r\n") | 236 | f.Write("README", "r\n") | ||
| 247 | - | plain := f.Commit("plain") | 237 | + | plain := oidOf(t, f.Commit("plain")).String() |
| 248 | - | f.Write(".github/workflows/ci.yml", "on: push\n") | |||
| 249 | - | wf := f.Commit("workflow") | |||
| 250 | - | f.Write(".github/workflows/ci.yml", "on: [push]\n") | |||
| 251 | - | f.Commit("the default branch moves on to other workflows") // HEAD is not wf | |||
| 252 | blob := f.WriteObject("blob", "hello\n") | 238 | blob := f.WriteObject("blob", "hello\n") | ||
| 253 | - | annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n") | 239 | + | tag := func(id, kind string) string { |
| 254 | - | annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n") | 240 | + | return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") |
| 255 | - | rootTree := f.Git("rev-parse", wf+"^{tree}") | |||
| 256 | - | ref := func(old, new string) []domain.RefUpdate { | |||
| 257 | - | return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}} | |||
| 258 | - | } | |||
| 259 | - | changed := func(ups []domain.RefUpdate) (string, error) { | |||
| 260 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |||
| 261 | } | 241 | } | ||
| 262 | - | for name, ups := range map[string][]domain.RefUpdate{ | 242 | + | tree := f.Git("rev-parse", plain+"^{tree}") |
| 263 | - | "create a lightweight tag on a blob": ref(domain.ZeroOID, blob), | 243 | + | for _, r := range []struct { |
| 264 | - | "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob), | 244 | + | id string |
| 265 | - | "a tag from a blob to another blob": ref(blob, annotatedBlob), | 245 | + | kind domain.ObjectType |
| 266 | - | "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob), | 246 | + | }{{plain, domain.ObjectCommit}, {tag(plain, "commit"), domain.ObjectCommit}, {blob, domain.ObjectBlob}, {tag(blob, "blob"), domain.ObjectBlob}, {tree, domain.ObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.ObjectCommit}} { |
| 267 | - | } { | 247 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 268 | - | p, err := changed(ups) | 248 | + | require.NoError(t, err) |
| 269 | - | require.NoError(t, err, name) | 249 | + | assert.Equal(t, r.kind, got.Ends[r.id].Type) |
| 270 | - | assert.Empty(t, p, name) | 250 | + | if r.kind == domain.ObjectBlob { |
| 271 | - | } | 251 | + | assert.NotContains(t, got.Subtrees, r.id) |
| 272 | - | for name, ups := range map[string][]domain.RefUpdate{ | 252 | + | } |
| 273 | - | "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()), | |||
| 274 | - | "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()), | |||
| 275 | - | "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF), | |||
| 276 | - | "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree), | |||
| 277 | - | "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF), | |||
| 278 | - | } { | |||
| 279 | - | p, err := changed(ups) | |||
| 280 | - | require.NoError(t, err, name) | |||
| 281 | - | assert.Equal(t, ".github/workflows/ci.yml", p, name) | |||
| 282 | } | 253 | } | ||
| 283 | - | for name, ups := range map[string][]domain.RefUpdate{ | 254 | + | for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} { |
| 284 | - | "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"), | 255 | + | _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 285 | - | "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()), | 256 | + | assert.ErrorIs(t, err, domain.ErrInvalid) |
| 286 | - | "a malformed object": ref(domain.ZeroOID, "ZZZZ"), | |||
| 287 | - | } { | |||
| 288 | - | _, err := changed(ups) | |||
| 289 | - | assert.Error(t, err, name) | |||
| 290 | } | 257 | } | ||
| 258 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |||
| 259 | + | require.NoError(t, err) | |||
| 260 | + | assert.Empty(t, got.Ends) | |||
| 261 | + | assert.Empty(t, got.Commits) | |||
| 291 | } | 262 | } | ||
| 292 | 263 | ||||
| 293 | // rawTree writes a tree object by hand: entries are "mode name hexid", in the | 264 | // rawTree writes a tree object by hand: entries are "mode name hexid", in the | ||
| @@ -308,73 +279,35 @@ func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string { | |||||
| 308 | // mode 040000 below a directory .github of mode 040000. The same tree id as a | 279 | // mode 040000 below a directory .github of mode 040000. The same tree id as a | ||
| 309 | // gitlink, a symbolic link or a file is nothing, and turning it into a | 280 | // gitlink, a symbolic link or a file is nothing, and turning it into a | ||
| 310 | // directory activates it; the object id alone does not tell. | 281 | // directory activates it; the object id alone does not tell. | ||
| 311 | - | func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) { | 282 | + | func TestPushSubtreesEntryKinds(t *testing.T) { |
| 312 | g, data := newGit(t) | 283 | g, data := newGit(t) | ||
| 313 | ctx := context.Background() | 284 | ctx := context.Background() | ||
| 314 | f := readertest.NewFixture(t, data, "proj") | 285 | f := readertest.NewFixture(t, data, "proj") | ||
| 315 | t.Cleanup(func() { _ = g.Close(ctx) }) | 286 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 316 | - | f.Write("README", "r\n") | 287 | + | blob := f.WriteObject("blob", "x\n") |
| 317 | - | f.Commit("base") | 288 | + | tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob}) |
| 318 | - | yml := f.WriteObject("blob", "on: push\n") | 289 | + | for _, r := range []struct { |
| 319 | - | wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows | 290 | + | mode, id string |
| 320 | - | link := f.WriteObject("blob", ".github/other") | 291 | + | kind domain.EntryKind |
| 321 | - | file := f.WriteObject("blob", "not a directory\n") | 292 | + | }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} { |
| 322 | - | readme := f.WriteObject("blob", "r\n") | 293 | + | t.Run(r.mode, func(t *testing.T) { |
| 323 | - | 294 | + | gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id}) | |
| 324 | - | // A commit whose .github/workflows has the mode and id given. | 295 | + | root := rawTree(t, f, [3]string{"040000", ".github", gh}) |
| 325 | - | withWorkflows := func(mode, id string) string { | 296 | + | oid := commitOf(t, f, root) |
| 326 | - | gh := rawTree(t, f, [3]string{mode, "workflows", id}) | 297 | + | got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 327 | - | return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh})) | 298 | + | require.NoError(t, err) |
| 328 | - | } | 299 | + | assert.Equal(t, r.kind, got.Subtrees[oid].Kind) |
| 329 | - | // A commit whose .github has the mode and id given. | 300 | + | assert.Equal(t, r.id, got.Subtrees[oid].ID.String()) |
| 330 | - | withGithub := func(mode, id string) string { | 301 | + | assert.NotContains(t, got.Subtrees, domain.HeadKey) |
| 331 | - | return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id})) | 302 | + | if r.mode != "040000" { |
| 332 | - | } | 303 | + | root = rawTree(t, f, [3]string{r.mode, ".github", r.id}) |
| 333 | - | asDir := withWorkflows("040000", wfTree) | 304 | + | oid = commitOf(t, f, root) |
| 334 | - | asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule | 305 | + | got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 335 | - | asSymlink := withWorkflows("120000", link) | 306 | + | require.NoError(t, err) |
| 336 | - | asFile := withWorkflows("100644", file) | 307 | + | assert.NotContains(t, got.Subtrees, oid) |
| 337 | - | none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme})) | 308 | + | } |
| 338 | - | ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree}) | 309 | + | }) |
| 339 | - | githubDir := withGithub("040000", ghDir) | |||
| 340 | - | githubGitlink := withGithub("160000", ghDir) // the same id, a submodule | |||
| 341 | - | githubFile := withGithub("100644", file) | |||
| 342 | - | githubLink := withGithub("120000", link) | |||
| 343 | - | ||||
| 344 | - | for name, c := range map[string]struct { | |||
| 345 | - | old, new string | |||
| 346 | - | want string | |||
| 347 | - | }{ | |||
| 348 | - | "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"}, | |||
| 349 | - | "a directory to a gitlink removes it": {asDir, asGitlink, ""}, | |||
| 350 | - | "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"}, | |||
| 351 | - | "a directory to a symlink removes it": {asDir, asSymlink, ""}, | |||
| 352 | - | "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"}, | |||
| 353 | - | "a directory to a file removes it": {asDir, asFile, ""}, | |||
| 354 | - | "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"}, | |||
| 355 | - | "a directory to nothing": {asDir, none, ""}, | |||
| 356 | - | "the same gitlink": {asGitlink, asGitlink, ""}, | |||
| 357 | - | "gitlink to symlink": {asGitlink, asSymlink, ""}, | |||
| 358 | - | ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"}, | |||
| 359 | - | ".github a directory to a gitlink": {githubDir, githubGitlink, ""}, | |||
| 360 | - | ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"}, | |||
| 361 | - | ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"}, | |||
| 362 | - | ".github a directory to a symlink": {githubDir, githubLink, ""}, | |||
| 363 | - | "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""}, | |||
| 364 | - | "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""}, | |||
| 365 | - | "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""}, | |||
| 366 | - | "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"}, | |||
| 367 | - | } { | |||
| 368 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{}) | |||
| 369 | - | require.NoError(t, err, name) | |||
| 370 | - | assert.Equal(t, c.want, p, name) | |||
| 371 | } | 310 | } | ||
| 372 | - | ||||
| 373 | - | // A tag that peels to the commit is judged the same way. | |||
| 374 | - | tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n") | |||
| 375 | - | p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{}) | |||
| 376 | - | require.NoError(t, err) | |||
| 377 | - | assert.Equal(t, ".github/workflows/ci.yml", p) | |||
| 378 | } | 311 | } | ||
| 379 | 312 | ||||
| 380 | // A history for the rules on the commits a push brings: main holds workflows A, | 313 | // A history for the rules on the commits a push brings: main holds workflows A, | ||
| @@ -418,91 +351,37 @@ func (h *guardHistory) commit(workflows, _ string, parents ...string) string { | |||||
| 418 | return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...) | 351 | return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...) | ||
| 419 | } | 352 | } | ||
| 420 | 353 | ||||
| 421 | - | func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) { | 354 | + | func TestPushSubtreesCommitsAndParents(t *testing.T) { |
| 422 | - | g, data := newGit(t) | |||
| 423 | - | ctx := context.Background() | |||
| 424 | - | h := newGuardHistory(t, g, data) | |||
| 425 | - | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 426 | - | f := h.f | |||
| 427 | - | run := func(ups ...domain.RefUpdate) (string, error) { | |||
| 428 | - | return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) | |||
| 429 | - | } | |||
| 430 | - | create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} } | |||
| 431 | - | move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} } | |||
| 432 | - | const ci = ".github/workflows/ci.yml" | |||
| 433 | - | ||||
| 434 | - | allowed := func(name string, ups ...domain.RefUpdate) { | |||
| 435 | - | t.Helper() | |||
| 436 | - | p, err := run(ups...) | |||
| 437 | - | require.NoError(t, err, name) | |||
| 438 | - | assert.Empty(t, p, name) | |||
| 439 | - | } | |||
| 440 | - | refused := func(name string, ups ...domain.RefUpdate) { | |||
| 441 | - | t.Helper() | |||
| 442 | - | p, err := run(ups...) | |||
| 443 | - | require.NoError(t, err, name) | |||
| 444 | - | assert.Equal(t, ci, p, name) | |||
| 445 | - | } | |||
| 446 | - | ||||
| 447 | - | // The staging repro: a new branch off main with a commit that touches no workflow. | |||
| 448 | - | allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m))) | |||
| 449 | - | allowed("a new branch at the head of main", create("refs/heads/same", h.m)) | |||
| 450 | - | allowed("a new tag at the head of main", create("refs/tags/v1", h.m)) | |||
| 451 | - | allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", ""))) | |||
| 452 | - | allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m)))) | |||
| 453 | - | allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m))) | |||
| 454 | - | ||||
| 455 | - | // A pushed commit that changes them is refused wherever it goes. | |||
| 456 | - | refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m))) | |||
| 457 | - | refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", ""))) | |||
| 458 | - | refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m))) | |||
| 459 | - | n4 := h.commit("C", "", h.m) | |||
| 460 | - | n5 := h.commit("A", "", n4) // reverts the change of the commit before | |||
| 461 | - | refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5)) | |||
| 462 | - | refused("a new branch through the same", create("refs/heads/through", n5)) | |||
| 463 | - | n6 := h.commit("A", "", h.commit("C", "", h.o)) | |||
| 464 | - | refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6))) | |||
| 465 | - | ||||
| 466 | - | // A ref cannot be moved onto existing commits with other workflows. | |||
| 467 | - | refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o)) | |||
| 468 | - | refused("a new tag at that commit", create("refs/tags/old", h.o)) | |||
| 469 | - | refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o)) | |||
| 470 | - | allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o))) | |||
| 471 | - | allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o))) | |||
| 472 | - | ||||
| 473 | - | // Merges: a change only if it differs from every parent. | |||
| 474 | - | allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o))) | |||
| 475 | - | allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o))) | |||
| 476 | - | refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o))) | |||
| 477 | - | refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o)))) | |||
| 478 | - | // One that takes the old side's workflows is a change of the ref, not of a commit. | |||
| 479 | - | refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o))) | |||
| 480 | - | ||||
| 481 | - | // Several updates: the first refused refuses the push. | |||
| 482 | - | refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m))) | |||
| 483 | - | allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID)) | |||
| 484 | - | } | |||
| 485 | - | ||||
| 486 | - | // With no default branch head, a new ref is compared with no workflows at all. | |||
| 487 | - | func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) { | |||
| 488 | g, data := newGit(t) | 355 | g, data := newGit(t) | ||
| 489 | ctx := context.Background() | 356 | ctx := context.Background() | ||
| 490 | h := newGuardHistory(t, g, data) | 357 | h := newGuardHistory(t, g, data) | ||
| 491 | t.Cleanup(func() { _ = g.Close(ctx) }) | 358 | t.Cleanup(func() { _ = g.Close(ctx) }) | ||
| 492 | - | h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist | 359 | + | side := h.commit("B", "", h.o) |
| 493 | - | run := func(to string) string { | 360 | + | merge := h.commit("A", "", h.m, side) |
| 494 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{}) | 361 | + | got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 495 | - | require.NoError(t, err) | 362 | + | require.NoError(t, err) |
| 496 | - | return p | 363 | + | want := h.f.Git("rev-list", "--parents", merge, "--not", "--all") |
| 364 | + | var lines []string | |||
| 365 | + | for _, c := range got.Commits { | |||
| 366 | + | line := c.ID.String() | |||
| 367 | + | for _, p := range c.Parents { | |||
| 368 | + | line += " " + p.String() | |||
| 369 | + | } | |||
| 370 | + | lines = append(lines, line) | |||
| 497 | } | 371 | } | ||
| 498 | - | assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows") | 372 | + | assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n")) |
| 499 | - | plain := h.commit("", "") | 373 | + | assert.Len(t, got.Commits, 2) |
| 500 | - | h.f.Git("update-ref", "refs/heads/plain", plain) | 374 | + | assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String()) |
| 501 | - | assert.Empty(t, run(plain), "an existing commit without") | 375 | + | assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String()) |
| 376 | + | // Any directory path is accepted; the reader has no workflow path policy. | |||
| 377 | + | other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds()) | |||
| 378 | + | require.NoError(t, err) | |||
| 379 | + | assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind) | |||
| 380 | + | assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates") | |||
| 502 | } | 381 | } | ||
| 503 | 382 | ||||
| 504 | // The commits of a push are bounded: more than the bound fails closed. | 383 | // The commits of a push are bounded: more than the bound fails closed. | ||
| 505 | - | func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | 384 | + | func TestPushSubtreesBoundsThePushedCommits(t *testing.T) { |
| 506 | g, data := newGit(t) | 385 | g, data := newGit(t) | ||
| 507 | ctx := context.Background() | 386 | ctx := context.Background() | ||
| 508 | h := newGuardHistory(t, g, data) | 387 | h := newGuardHistory(t, g, data) | ||
| @@ -525,10 +404,10 @@ func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) { | |||||
| 525 | } | 404 | } | ||
| 526 | // Plain commits carry the workflows of main (the fast-import tree has none, | 405 | // Plain commits carry the workflows of main (the fast-import tree has none, | ||
| 527 | // so the chain is "removing" them): allowed, whatever their number, up to the bound. | 406 | // so the chain is "removing" them): allowed, whatever their number, up to the bound. | ||
| 528 | - | p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{}) | 407 | + | p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) |
| 529 | require.NoError(t, err) | 408 | require.NoError(t, err) | ||
| 530 | - | assert.Empty(t, p) | 409 | + | assert.Len(t, p.Commits, 20) |
| 531 | - | _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{}) | 410 | + | _, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80}) |
| 532 | assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push") | 411 | assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push") | ||
| 533 | } | 412 | } | ||
| 534 | 413 | ||||
| @@ -565,18 +444,18 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||||
| 565 | b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small | 444 | b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small | ||
| 566 | } | 445 | } | ||
| 567 | b.WriteString("\n") | 446 | b.WriteString("\n") | ||
| 568 | - | r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())} | 447 | + | r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())} |
| 569 | - | kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | 448 | + | kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) |
| 570 | require.ErrorIs(t, err, domain.ErrInvalid) | 449 | require.ErrorIs(t, err, domain.ErrInvalid) | ||
| 571 | assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length") | 450 | assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length") | ||
| 572 | assert.Zero(t, kept, "nothing is returned with the error") | 451 | assert.Zero(t, kept, "nothing is returned with the error") | ||
| 573 | - | total := gitcli.MaxPushedCommits * len(b.String()) | 452 | + | total := domain.MaxPushedCommits * len(b.String()) |
| 574 | assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total) | 453 | assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total) | ||
| 575 | assert.Less(t, r.read*20, total) | 454 | assert.Less(t, r.read*20, total) | ||
| 576 | 455 | ||||
| 577 | // Within the bounds the same lines are kept: 100 commits of 198 parents. | 456 | // Within the bounds the same lines are kept: 100 commits of 198 parents. | ||
| 578 | r = &countingReader{lines: 100, line: []byte(b.String())} | 457 | r = &countingReader{lines: 100, line: []byte(b.String())} | ||
| 579 | - | kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects) | 458 | + | kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs) |
| 580 | require.NoError(t, err) | 459 | require.NoError(t, err) | ||
| 581 | assert.Equal(t, 100, kept) | 460 | assert.Equal(t, 100, kept) | ||
| 582 | 461 | ||||
| @@ -590,3 +469,38 @@ func TestParseRevListRejectsManyParentsEarly(t *testing.T) { | |||||
| 590 | _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000) | 469 | _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000) | ||
| 591 | assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound") | 470 | assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound") | ||
| 592 | } | 471 | } | ||
| 472 | + | ||||
| 473 | + | func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |||
| 474 | + | t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |||
| 475 | + | if err != nil { | |||
| 476 | + | return "", err | |||
| 477 | + | } | |||
| 478 | + | c, found, err := domain.WorkflowChangeOf(ups, t) | |||
| 479 | + | if err != nil || !found { | |||
| 480 | + | return "", err | |||
| 481 | + | } | |||
| 482 | + | entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New) | |||
| 483 | + | if err != nil { | |||
| 484 | + | return "", err | |||
| 485 | + | } | |||
| 486 | + | if entry == "" { | |||
| 487 | + | return domain.WorkflowDir, nil | |||
| 488 | + | } | |||
| 489 | + | return domain.WorkflowDir + "/" + entry, nil | |||
| 490 | + | } | |||
| 491 | + | ||||
| 492 | + | func TestFirstDifferentEntry(t *testing.T) { | |||
| 493 | + | g, data := newGit(t) | |||
| 494 | + | ctx := context.Background() | |||
| 495 | + | f := readertest.NewFixture(t, data, "proj") | |||
| 496 | + | t.Cleanup(func() { _ = g.Close(ctx) }) | |||
| 497 | + | tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml")) | |||
| 498 | + | entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree) | |||
| 499 | + | require.NoError(t, err) | |||
| 500 | + | assert.Equal(t, "ci.yml", entry) | |||
| 501 | + | entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree) | |||
| 502 | + | require.NoError(t, err) | |||
| 503 | + | assert.Empty(t, entry) | |||
| 504 | + | _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree) | |||
| 505 | + | assert.ErrorIs(t, err, domain.ErrInvalid) | |||
| 506 | + | } | |||
D internal/adapters/gitcli/workflow_guard.go +0 -438
| @@ -1,438 +0,0 @@ | |||
| 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 | - | "io" | |
| 11 | - | "path/filepath" | |
| 12 | - | "strconv" | |
| 13 | - | "strings" | |
| 14 | - | ||
| 15 | - | "bnema.dev/ermite/internal/domain" | |
| 16 | - | "bnema.dev/ermite/internal/ports" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | var _ ports.WorkflowGuard = (*Git)(nil) | |
| 20 | - | ||
| 21 | - | // workflowsDir is where workflow files live, as a literal pathspec directory. | |
| 22 | - | const workflowsDir = ".github/workflows/" | |
| 23 | - | ||
| 24 | - | // maxWorkflowPath bounds the one path the guard reads from git: a longer one | |
| 25 | - | // fails closed (the push is refused), whatever the trees hold. | |
| 26 | - | const maxWorkflowPath = 4096 | |
| 27 | - | ||
| 28 | - | // Bounds of the walk over the commits a push brings: more than that, or a | |
| 29 | - | // commit with more parents than a line of rev-list holds, fails closed (the | |
| 30 | - | // push is refused), whatever the commits hold. maxPushedObjects counts every | |
| 31 | - | // commit and every parent listed, repeats included (so it bounds the distinct | |
| 32 | - | // ids too), and is enforced as the lines are read, before anything more is | |
| 33 | - | // kept: a few thousand commits with two hundred parents each are refused after | |
| 34 | - | // the first lines, not after they have been held. | |
| 35 | - | const ( | |
| 36 | - | maxPushedCommits = 10000 | |
| 37 | - | maxPushedObjects = 4 * maxPushedCommits | |
| 38 | - | maxRevListLine = 8192 | |
| 39 | - | ) | |
| 40 | - | ||
| 41 | - | // ChangedWorkflow tells whether a push changes the workflows of a repository. | |
| 42 | - | // It returns the path of the first change, "" for none. Deleting a ref changes | |
| 43 | - | // nothing, and a workflows directory that disappears is not a change. | |
| 44 | - | // | |
| 45 | - | // Two things are judged, as GitHub does for the token of a job (it refuses a | |
| 46 | - | // push when the commits pushed change workflow files, not when a new branch is | |
| 47 | - | // cut from a commit that has them): | |
| 48 | - | // | |
| 49 | - | // 1. Every commit the push brings (`rev-list <new> --not --all`, so the commits | |
| 50 | - | // no ref has yet) must leave .github/workflows as it found it: it is | |
| 51 | - | // compared with each of its parents (a root commit with nothing) and is a | |
| 52 | - | // change when it is present and differs from every one of them. A merge of | |
| 53 | - | // existing commits is thus no change; a side that brings a change is itself | |
| 54 | - | // a new commit, and judged as one. | |
| 55 | - | // 2. The directory a ref ends with must not be one it did not have: for an | |
| 56 | - | // update the final .github/workflows must be the old one or absent, for a | |
| 57 | - | // new ref the one of the default branch (HEAD) or absent. This keeps a job | |
| 58 | - | // token from moving a ref onto existing commits that carry other workflows | |
| 59 | - | // (an older commit, a commit of a branch with different ones). | |
| 60 | - | // | |
| 61 | - | // Presence is the directory the workflow loader lists (CI.workflowFiles, on both | |
| 62 | - | // git readers): .github and workflows both entries of mode 040000 and type | |
| 63 | - | // tree. A path through a gitlink (160000), a symbolic link (120000, which the | |
| 64 | - | // loader does not follow) or a file is no workflows, so the same tree id that is | |
| 65 | - | // no workflows as a gitlink and becomes workflows as a directory is a change, | |
| 66 | - | // which the object id alone would not tell. Mode and id of | |
| 67 | - | // "<commit>:.github/workflows" are what is compared: equal means the same | |
| 68 | - | // content, whatever it holds, so nothing is read. | |
| 69 | - | // | |
| 70 | - | // Nothing is walked recursively: the trees of a push are the pusher's, and a | |
| 71 | - | // recursive diff of trees that share subtrees fans out into more paths than the | |
| 72 | - | // push has bytes (git builds the whole diff before it prints a line, so reading | |
| 73 | - | // it as a stream would not help). The shallow diff that names the path reads one | |
| 74 | - | // directory, as large as its tree object, as a stream, up to its first name, and | |
| 75 | - | // git is stopped and reaped then; a name longer than maxWorkflowPath fails | |
| 76 | - | // closed. | |
| 77 | - | // | |
| 78 | - | // The ends of each update are peeled first (`cat-file --batch-check` on | |
| 79 | - | // `<oid>^{}`): a commit or a tree is compared, a blob (a tag may point to one) | |
| 80 | - | // holds no workflows; a missing or malformed object fails closed. Git reads the | |
| 81 | - | // objects of a push still in quarantine through the environment of the hook | |
| 82 | - | // (ports.PushObjects), accepted only inside the repository's objects directory. | |
| 83 | - | // | |
| 84 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business rule for which pushed commits and ref changes alter workflows; pending move to domain/usecase | |
| 85 | - | func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |
| 86 | - | dir, err := g.repoDir(repo) | |
| 87 | - | if err != nil { | |
| 88 | - | return "", err | |
| 89 | - | } | |
| 90 | - | extra, err := quarantineEnv(dir, objects) | |
| 91 | - | if err != nil { | |
| 92 | - | return "", err | |
| 93 | - | } | |
| 94 | - | kept := make([]domain.RefUpdate, 0, len(updates)) | |
| 95 | - | for _, up := range updates { | |
| 96 | - | if !up.IsDelete() { | |
| 97 | - | kept = append(kept, up) | |
| 98 | - | } | |
| 99 | - | } | |
| 100 | - | if len(kept) == 0 { | |
| 101 | - | return "", nil | |
| 102 | - | } | |
| 103 | - | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |
| 104 | - | if err != nil { | |
| 105 | - | return "", err | |
| 106 | - | } | |
| 107 | - | if p, err := g.changedByNewCommits(ctx, dir, extra, kept, types); p != "" || err != nil { | |
| 108 | - | return p, err | |
| 109 | - | } | |
| 110 | - | trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)") | |
| 111 | - | if err != nil { | |
| 112 | - | return "", err | |
| 113 | - | } | |
| 114 | - | headW, headRead := "", false | |
| 115 | - | for i, up := range kept { | |
| 116 | - | if types[2*i+1] == "blob" { | |
| 117 | - | continue // a blob holds no workflow, whatever it replaces | |
| 118 | - | } | |
| 119 | - | base := workflowsOf(types[2*i], trees[2*i]) | |
| 120 | - | if up.IsCreate() { | |
| 121 | - | if !headRead { | |
| 122 | - | w, err := g.workflowsAt(ctx, dir, extra, []string{"HEAD:" + strings.TrimSuffix(workflowsDir, "/")}) | |
| 123 | - | if err != nil { | |
| 124 | - | return "", err | |
| 125 | - | } | |
| 126 | - | headW, headRead = w["HEAD:"+strings.TrimSuffix(workflowsDir, "/")], true | |
| 127 | - | } | |
| 128 | - | base = headW // a new ref may end with the workflows of the default branch | |
| 129 | - | } | |
| 130 | - | newW := workflowsOf(types[2*i+1], trees[2*i+1]) | |
| 131 | - | if base == newW || newW == "" { | |
| 132 | - | continue // the same directory, or none left | |
| 133 | - | } | |
| 134 | - | return g.firstChanged(ctx, dir, extra, base, newW) | |
| 135 | - | } | |
| 136 | - | return "", nil | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | // changedByNewCommits judges the commits the updates bring (rule 1 of | |
| 140 | - | // ChangedWorkflow). | |
| 141 | - | func (g *Git) changedByNewCommits(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, types []string) (string, error) { | |
| 142 | - | var tips []string | |
| 143 | - | seen := map[string]bool{} | |
| 144 | - | for i, up := range ups { | |
| 145 | - | if types[2*i+1] == "commit" && !seen[up.New] { | |
| 146 | - | seen[up.New] = true | |
| 147 | - | tips = append(tips, up.New) | |
| 148 | - | } | |
| 149 | - | } | |
| 150 | - | if len(tips) == 0 { | |
| 151 | - | return "", nil | |
| 152 | - | } | |
| 153 | - | commits, err := g.newCommits(ctx, dir, extra, tips) | |
| 154 | - | if err != nil { | |
| 155 | - | return "", err | |
| 156 | - | } | |
| 157 | - | const suffix = ":.github/workflows" | |
| 158 | - | var specs []string | |
| 159 | - | asked := map[string]bool{} | |
| 160 | - | for _, c := range commits { | |
| 161 | - | for _, id := range append([]string{c.id}, c.parents...) { | |
| 162 | - | if !asked[id] { | |
| 163 | - | asked[id] = true | |
| 164 | - | specs = append(specs, id+suffix) | |
| 165 | - | } | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | ws, err := g.workflowsAt(ctx, dir, extra, specs) | |
| 169 | - | if err != nil { | |
| 170 | - | return "", err | |
| 171 | - | } | |
| 172 | - | for _, c := range commits { | |
| 173 | - | cur := ws[c.id+suffix] | |
| 174 | - | if cur == "" { | |
| 175 | - | continue // none: nothing new is active | |
| 176 | - | } | |
| 177 | - | first, same := "", false | |
| 178 | - | for i, p := range c.parents { | |
| 179 | - | if i == 0 { | |
| 180 | - | first = ws[p+suffix] | |
| 181 | - | } | |
| 182 | - | if ws[p+suffix] == cur { | |
| 183 | - | same = true | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | if !same { | |
| 187 | - | return g.firstChanged(ctx, dir, extra, first, cur) | |
| 188 | - | } | |
| 189 | - | } | |
| 190 | - | return "", nil | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | type pushedCommit struct { | |
| 194 | - | id string | |
| 195 | - | parents []string | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | // newCommits lists the commits reachable from the tips and from no ref | |
| 199 | - | // (`rev-list --parents <tips> --not --all`), at most maxPushedCommits: more | |
| 200 | - | // fails closed. | |
| 201 | - | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string) ([]pushedCommit, error) { | |
| 202 | - | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(maxPushedCommits+1)}, tips...), "--not", "--all")...) | |
| 203 | - | var out []pushedCommit | |
| 204 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 205 | - | var err error | |
| 206 | - | out, err = parseRevList(r, maxPushedCommits, maxPushedObjects) | |
| 207 | - | return err | |
| 208 | - | }) | |
| 209 | - | if err != nil { | |
| 210 | - | return nil, fmt.Errorf("commits of a push: %w", err) | |
| 211 | - | } | |
| 212 | - | return out, nil | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |
| 216 | - | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |
| 217 | - | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |
| 218 | - | // id that is not one is an error, and it is raised as soon as the line that | |
| 219 | - | // crosses it is read: nothing past it is read or kept. | |
| 220 | - | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]pushedCommit, error) { | |
| 221 | - | var out []pushedCommit | |
| 222 | - | ids := 0 | |
| 223 | - | for { | |
| 224 | - | line, err := r.ReadSlice('\n') | |
| 225 | - | switch { | |
| 226 | - | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |
| 227 | - | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |
| 228 | - | case errors.Is(err, io.EOF) && len(line) == 0: | |
| 229 | - | return out, nil | |
| 230 | - | case err != nil && !errors.Is(err, io.EOF): | |
| 231 | - | return nil, err | |
| 232 | - | } | |
| 233 | - | f := strings.Fields(string(line)) | |
| 234 | - | if len(f) == 0 { | |
| 235 | - | continue | |
| 236 | - | } | |
| 237 | - | if len(out) == maxCommits { | |
| 238 | - | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |
| 239 | - | } | |
| 240 | - | if ids += len(f); ids > maxIDs { | |
| 241 | - | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |
| 242 | - | } | |
| 243 | - | for _, id := range f { | |
| 244 | - | if _, err := domain.ParseObjectID(id); err != nil { | |
| 245 | - | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |
| 246 | - | } | |
| 247 | - | } | |
| 248 | - | out = append(out, pushedCommit{id: f[0], parents: f[1:]}) | |
| 249 | - | } | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | // workflowsAt returns, for each spec ("<rev>:.github/workflows"), the id of the | |
| 253 | - | // directory it names, or "" when there is none (see workflowsOf). | |
| 254 | - | func (g *Git) workflowsAt(ctx context.Context, dir string, extra []string, specs []string) (map[string]string, error) { | |
| 255 | - | out := make(map[string]string, len(specs)) | |
| 256 | - | if len(specs) == 0 { | |
| 257 | - | return out, nil | |
| 258 | - | } | |
| 259 | - | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |
| 260 | - | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |
| 261 | - | for _, spec := range specs { | |
| 262 | - | line, err := r.ReadSlice('\n') | |
| 263 | - | if err != nil { | |
| 264 | - | return fmt.Errorf("read object answers: %w", err) | |
| 265 | - | } | |
| 266 | - | out[spec] = workflowsOf("", strings.TrimSuffix(string(line), "\n")) | |
| 267 | - | } | |
| 268 | - | return nil | |
| 269 | - | }) | |
| 270 | - | if err != nil { | |
| 271 | - | return nil, fmt.Errorf("workflows of a push: %w", err) | |
| 272 | - | } | |
| 273 | - | return out, nil | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | // workflowsOf is the id of the tree that is the .github/workflows of a commit | |
| 277 | - | // or a tree, "" when there is none: the answer for "<it>:.github/workflows" must | |
| 278 | - | // be a tree of mode 040000, and the object peeled to a tree-ish, not a blob. | |
| 279 | - | func workflowsOf(objType, answer string) string { | |
| 280 | - | if objType == "blob" { | |
| 281 | - | return "" | |
| 282 | - | } | |
| 283 | - | mode, rest, _ := strings.Cut(answer, " ") | |
| 284 | - | id, typ, _ := strings.Cut(rest, " ") | |
| 285 | - | if mode != "040000" || typ != "tree" { | |
| 286 | - | return "" | |
| 287 | - | } | |
| 288 | - | return id | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | // firstChanged names the first top-level entry of .github/workflows that | |
| 292 | - | // differs between two trees (oldID may be "": none, the empty tree). | |
| 293 | - | func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) { | |
| 294 | - | from := oldID | |
| 295 | - | if from == "" { | |
| 296 | - | from = emptyTree | |
| 297 | - | } | |
| 298 | - | to := newID | |
| 299 | - | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |
| 300 | - | var name string | |
| 301 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |
| 302 | - | n, found, err := firstPath(r) | |
| 303 | - | name = n | |
| 304 | - | if err != nil || !found { | |
| 305 | - | return err // no name: git's exit status is checked, so a failure refuses | |
| 306 | - | } | |
| 307 | - | return errStop // the first name is all that is needed: stop git | |
| 308 | - | }) | |
| 309 | - | if err != nil { | |
| 310 | - | return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err) | |
| 311 | - | } | |
| 312 | - | if name == "" { | |
| 313 | - | return strings.TrimSuffix(workflowsDir, "/"), nil | |
| 314 | - | } | |
| 315 | - | return workflowsDir + name, nil | |
| 316 | - | } | |
| 317 | - | ||
| 318 | - | // firstPath reads the first NUL-terminated path of r; found is false at the end | |
| 319 | - | // of the output without one. A path longer than maxWorkflowPath is an error. | |
| 320 | - | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |
| 321 | - | var b []byte | |
| 322 | - | for { | |
| 323 | - | c, err := r.ReadByte() | |
| 324 | - | switch { | |
| 325 | - | case err != nil: | |
| 326 | - | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |
| 327 | - | case c == 0: | |
| 328 | - | return string(b), true, nil | |
| 329 | - | case len(b) >= maxWorkflowPath: | |
| 330 | - | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath) | |
| 331 | - | } | |
| 332 | - | b = append(b, c) | |
| 333 | - | } | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |
| 337 | - | // old (unless the ref is created) and the new end of each update and returns | |
| 338 | - | // the answers in that order, two per update, "" for the old end of a created | |
| 339 | - | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |
| 340 | - | // commit, a tree nor a blob is an error (the push is refused); with any other | |
| 341 | - | // suffix an answer "<input> missing" is kept as the answer. | |
| 342 | - | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |
| 343 | - | var in strings.Builder | |
| 344 | - | want := 0 | |
| 345 | - | for _, up := range ups { | |
| 346 | - | for _, oid := range []string{up.Old, up.New} { | |
| 347 | - | if oid == domain.ZeroOID { | |
| 348 | - | continue | |
| 349 | - | } | |
| 350 | - | if _, err := domain.ParseObjectID(oid); err != nil { | |
| 351 | - | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |
| 352 | - | } | |
| 353 | - | in.WriteString(oid + suffix + "\n") | |
| 354 | - | want++ | |
| 355 | - | } | |
| 356 | - | } | |
| 357 | - | got := make([]string, 0, want) | |
| 358 | - | args := readArgs(dir, "cat-file", "--batch-check="+format) | |
| 359 | - | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |
| 360 | - | for range want { | |
| 361 | - | line, err := r.ReadSlice('\n') | |
| 362 | - | if err != nil { | |
| 363 | - | if errors.Is(err, bufio.ErrBufferFull) { | |
| 364 | - | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |
| 365 | - | } | |
| 366 | - | return fmt.Errorf("read object answers: %w", err) | |
| 367 | - | } | |
| 368 | - | got = append(got, strings.TrimSuffix(string(line), "\n")) | |
| 369 | - | } | |
| 370 | - | return nil | |
| 371 | - | }) | |
| 372 | - | if err != nil { | |
| 373 | - | return nil, fmt.Errorf("objects of a push: %w", err) | |
| 374 | - | } | |
| 375 | - | out := make([]string, 0, 2*len(ups)) | |
| 376 | - | i := 0 | |
| 377 | - | for _, up := range ups { | |
| 378 | - | for _, oid := range []string{up.Old, up.New} { | |
| 379 | - | if oid == domain.ZeroOID { | |
| 380 | - | out = append(out, "") | |
| 381 | - | continue | |
| 382 | - | } | |
| 383 | - | if suffix == "^{}" { | |
| 384 | - | switch got[i] { | |
| 385 | - | case "commit", "tree", "blob": | |
| 386 | - | default: // "<input> missing", or a type that cannot be compared | |
| 387 | - | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |
| 388 | - | } | |
| 389 | - | } | |
| 390 | - | out = append(out, got[i]) | |
| 391 | - | i++ | |
| 392 | - | } | |
| 393 | - | } | |
| 394 | - | return out, nil | |
| 395 | - | } | |
| 396 | - | ||
| 397 | - | // quarantineEnv returns the environment that makes git read the objects of a | |
| 398 | - | // push in quarantine. Each directory must exist and, once symbolic links are | |
| 399 | - | // resolved, be inside the objects directory of the repository dir (itself | |
| 400 | - | // resolved): a hook request cannot send git to read anything else. The resolved | |
| 401 | - | // paths are the ones given to git. | |
| 402 | - | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |
| 403 | - | if o.Dir == "" && o.Alternates == "" { | |
| 404 | - | return nil, nil | |
| 405 | - | } | |
| 406 | - | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |
| 407 | - | if err != nil { | |
| 408 | - | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |
| 409 | - | } | |
| 410 | - | inside := func(p string) (string, bool) { | |
| 411 | - | if !filepath.IsAbs(p) { | |
| 412 | - | return "", false | |
| 413 | - | } | |
| 414 | - | real, err := filepath.EvalSymlinks(p) | |
| 415 | - | if err != nil { | |
| 416 | - | return "", false | |
| 417 | - | } | |
| 418 | - | rel, err := filepath.Rel(root, real) | |
| 419 | - | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |
| 420 | - | } | |
| 421 | - | dir, ok := inside(o.Dir) | |
| 422 | - | if o.Dir == "" || !ok { | |
| 423 | - | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 424 | - | } | |
| 425 | - | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |
| 426 | - | if o.Alternates != "" { | |
| 427 | - | var alts []string | |
| 428 | - | for _, a := range filepath.SplitList(o.Alternates) { | |
| 429 | - | real, ok := inside(a) | |
| 430 | - | if !ok { | |
| 431 | - | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |
| 432 | - | } | |
| 433 | - | alts = append(alts, real) | |
| 434 | - | } | |
| 435 | - | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |
| 436 | - | } | |
| 437 | - | return env, nil | |
| 438 | - | } | |
| @@ -1,438 +0,0 @@ | |||||
| 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 | - | "io" | |||
| 11 | - | "path/filepath" | |||
| 12 | - | "strconv" | |||
| 13 | - | "strings" | |||
| 14 | - | ||||
| 15 | - | "bnema.dev/ermite/internal/domain" | |||
| 16 | - | "bnema.dev/ermite/internal/ports" | |||
| 17 | - | ) | |||
| 18 | - | ||||
| 19 | - | var _ ports.WorkflowGuard = (*Git)(nil) | |||
| 20 | - | ||||
| 21 | - | // workflowsDir is where workflow files live, as a literal pathspec directory. | |||
| 22 | - | const workflowsDir = ".github/workflows/" | |||
| 23 | - | ||||
| 24 | - | // maxWorkflowPath bounds the one path the guard reads from git: a longer one | |||
| 25 | - | // fails closed (the push is refused), whatever the trees hold. | |||
| 26 | - | const maxWorkflowPath = 4096 | |||
| 27 | - | ||||
| 28 | - | // Bounds of the walk over the commits a push brings: more than that, or a | |||
| 29 | - | // commit with more parents than a line of rev-list holds, fails closed (the | |||
| 30 | - | // push is refused), whatever the commits hold. maxPushedObjects counts every | |||
| 31 | - | // commit and every parent listed, repeats included (so it bounds the distinct | |||
| 32 | - | // ids too), and is enforced as the lines are read, before anything more is | |||
| 33 | - | // kept: a few thousand commits with two hundred parents each are refused after | |||
| 34 | - | // the first lines, not after they have been held. | |||
| 35 | - | const ( | |||
| 36 | - | maxPushedCommits = 10000 | |||
| 37 | - | maxPushedObjects = 4 * maxPushedCommits | |||
| 38 | - | maxRevListLine = 8192 | |||
| 39 | - | ) | |||
| 40 | - | ||||
| 41 | - | // ChangedWorkflow tells whether a push changes the workflows of a repository. | |||
| 42 | - | // It returns the path of the first change, "" for none. Deleting a ref changes | |||
| 43 | - | // nothing, and a workflows directory that disappears is not a change. | |||
| 44 | - | // | |||
| 45 | - | // Two things are judged, as GitHub does for the token of a job (it refuses a | |||
| 46 | - | // push when the commits pushed change workflow files, not when a new branch is | |||
| 47 | - | // cut from a commit that has them): | |||
| 48 | - | // | |||
| 49 | - | // 1. Every commit the push brings (`rev-list <new> --not --all`, so the commits | |||
| 50 | - | // no ref has yet) must leave .github/workflows as it found it: it is | |||
| 51 | - | // compared with each of its parents (a root commit with nothing) and is a | |||
| 52 | - | // change when it is present and differs from every one of them. A merge of | |||
| 53 | - | // existing commits is thus no change; a side that brings a change is itself | |||
| 54 | - | // a new commit, and judged as one. | |||
| 55 | - | // 2. The directory a ref ends with must not be one it did not have: for an | |||
| 56 | - | // update the final .github/workflows must be the old one or absent, for a | |||
| 57 | - | // new ref the one of the default branch (HEAD) or absent. This keeps a job | |||
| 58 | - | // token from moving a ref onto existing commits that carry other workflows | |||
| 59 | - | // (an older commit, a commit of a branch with different ones). | |||
| 60 | - | // | |||
| 61 | - | // Presence is the directory the workflow loader lists (CI.workflowFiles, on both | |||
| 62 | - | // git readers): .github and workflows both entries of mode 040000 and type | |||
| 63 | - | // tree. A path through a gitlink (160000), a symbolic link (120000, which the | |||
| 64 | - | // loader does not follow) or a file is no workflows, so the same tree id that is | |||
| 65 | - | // no workflows as a gitlink and becomes workflows as a directory is a change, | |||
| 66 | - | // which the object id alone would not tell. Mode and id of | |||
| 67 | - | // "<commit>:.github/workflows" are what is compared: equal means the same | |||
| 68 | - | // content, whatever it holds, so nothing is read. | |||
| 69 | - | // | |||
| 70 | - | // Nothing is walked recursively: the trees of a push are the pusher's, and a | |||
| 71 | - | // recursive diff of trees that share subtrees fans out into more paths than the | |||
| 72 | - | // push has bytes (git builds the whole diff before it prints a line, so reading | |||
| 73 | - | // it as a stream would not help). The shallow diff that names the path reads one | |||
| 74 | - | // directory, as large as its tree object, as a stream, up to its first name, and | |||
| 75 | - | // git is stopped and reaped then; a name longer than maxWorkflowPath fails | |||
| 76 | - | // closed. | |||
| 77 | - | // | |||
| 78 | - | // The ends of each update are peeled first (`cat-file --batch-check` on | |||
| 79 | - | // `<oid>^{}`): a commit or a tree is compared, a blob (a tag may point to one) | |||
| 80 | - | // holds no workflows; a missing or malformed object fails closed. Git reads the | |||
| 81 | - | // objects of a push still in quarantine through the environment of the hook | |||
| 82 | - | // (ports.PushObjects), accepted only inside the repository's objects directory. | |||
| 83 | - | // | |||
| 84 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business rule for which pushed commits and ref changes alter workflows; pending move to domain/usecase | |||
| 85 | - | func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) { | |||
| 86 | - | dir, err := g.repoDir(repo) | |||
| 87 | - | if err != nil { | |||
| 88 | - | return "", err | |||
| 89 | - | } | |||
| 90 | - | extra, err := quarantineEnv(dir, objects) | |||
| 91 | - | if err != nil { | |||
| 92 | - | return "", err | |||
| 93 | - | } | |||
| 94 | - | kept := make([]domain.RefUpdate, 0, len(updates)) | |||
| 95 | - | for _, up := range updates { | |||
| 96 | - | if !up.IsDelete() { | |||
| 97 | - | kept = append(kept, up) | |||
| 98 | - | } | |||
| 99 | - | } | |||
| 100 | - | if len(kept) == 0 { | |||
| 101 | - | return "", nil | |||
| 102 | - | } | |||
| 103 | - | types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)") | |||
| 104 | - | if err != nil { | |||
| 105 | - | return "", err | |||
| 106 | - | } | |||
| 107 | - | if p, err := g.changedByNewCommits(ctx, dir, extra, kept, types); p != "" || err != nil { | |||
| 108 | - | return p, err | |||
| 109 | - | } | |||
| 110 | - | trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)") | |||
| 111 | - | if err != nil { | |||
| 112 | - | return "", err | |||
| 113 | - | } | |||
| 114 | - | headW, headRead := "", false | |||
| 115 | - | for i, up := range kept { | |||
| 116 | - | if types[2*i+1] == "blob" { | |||
| 117 | - | continue // a blob holds no workflow, whatever it replaces | |||
| 118 | - | } | |||
| 119 | - | base := workflowsOf(types[2*i], trees[2*i]) | |||
| 120 | - | if up.IsCreate() { | |||
| 121 | - | if !headRead { | |||
| 122 | - | w, err := g.workflowsAt(ctx, dir, extra, []string{"HEAD:" + strings.TrimSuffix(workflowsDir, "/")}) | |||
| 123 | - | if err != nil { | |||
| 124 | - | return "", err | |||
| 125 | - | } | |||
| 126 | - | headW, headRead = w["HEAD:"+strings.TrimSuffix(workflowsDir, "/")], true | |||
| 127 | - | } | |||
| 128 | - | base = headW // a new ref may end with the workflows of the default branch | |||
| 129 | - | } | |||
| 130 | - | newW := workflowsOf(types[2*i+1], trees[2*i+1]) | |||
| 131 | - | if base == newW || newW == "" { | |||
| 132 | - | continue // the same directory, or none left | |||
| 133 | - | } | |||
| 134 | - | return g.firstChanged(ctx, dir, extra, base, newW) | |||
| 135 | - | } | |||
| 136 | - | return "", nil | |||
| 137 | - | } | |||
| 138 | - | ||||
| 139 | - | // changedByNewCommits judges the commits the updates bring (rule 1 of | |||
| 140 | - | // ChangedWorkflow). | |||
| 141 | - | func (g *Git) changedByNewCommits(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, types []string) (string, error) { | |||
| 142 | - | var tips []string | |||
| 143 | - | seen := map[string]bool{} | |||
| 144 | - | for i, up := range ups { | |||
| 145 | - | if types[2*i+1] == "commit" && !seen[up.New] { | |||
| 146 | - | seen[up.New] = true | |||
| 147 | - | tips = append(tips, up.New) | |||
| 148 | - | } | |||
| 149 | - | } | |||
| 150 | - | if len(tips) == 0 { | |||
| 151 | - | return "", nil | |||
| 152 | - | } | |||
| 153 | - | commits, err := g.newCommits(ctx, dir, extra, tips) | |||
| 154 | - | if err != nil { | |||
| 155 | - | return "", err | |||
| 156 | - | } | |||
| 157 | - | const suffix = ":.github/workflows" | |||
| 158 | - | var specs []string | |||
| 159 | - | asked := map[string]bool{} | |||
| 160 | - | for _, c := range commits { | |||
| 161 | - | for _, id := range append([]string{c.id}, c.parents...) { | |||
| 162 | - | if !asked[id] { | |||
| 163 | - | asked[id] = true | |||
| 164 | - | specs = append(specs, id+suffix) | |||
| 165 | - | } | |||
| 166 | - | } | |||
| 167 | - | } | |||
| 168 | - | ws, err := g.workflowsAt(ctx, dir, extra, specs) | |||
| 169 | - | if err != nil { | |||
| 170 | - | return "", err | |||
| 171 | - | } | |||
| 172 | - | for _, c := range commits { | |||
| 173 | - | cur := ws[c.id+suffix] | |||
| 174 | - | if cur == "" { | |||
| 175 | - | continue // none: nothing new is active | |||
| 176 | - | } | |||
| 177 | - | first, same := "", false | |||
| 178 | - | for i, p := range c.parents { | |||
| 179 | - | if i == 0 { | |||
| 180 | - | first = ws[p+suffix] | |||
| 181 | - | } | |||
| 182 | - | if ws[p+suffix] == cur { | |||
| 183 | - | same = true | |||
| 184 | - | } | |||
| 185 | - | } | |||
| 186 | - | if !same { | |||
| 187 | - | return g.firstChanged(ctx, dir, extra, first, cur) | |||
| 188 | - | } | |||
| 189 | - | } | |||
| 190 | - | return "", nil | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | type pushedCommit struct { | |||
| 194 | - | id string | |||
| 195 | - | parents []string | |||
| 196 | - | } | |||
| 197 | - | ||||
| 198 | - | // newCommits lists the commits reachable from the tips and from no ref | |||
| 199 | - | // (`rev-list --parents <tips> --not --all`), at most maxPushedCommits: more | |||
| 200 | - | // fails closed. | |||
| 201 | - | func (g *Git) newCommits(ctx context.Context, dir string, extra []string, tips []string) ([]pushedCommit, error) { | |||
| 202 | - | args := readArgs(dir, append(append([]string{"rev-list", "--parents", "--max-count=" + strconv.Itoa(maxPushedCommits+1)}, tips...), "--not", "--all")...) | |||
| 203 | - | var out []pushedCommit | |||
| 204 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 205 | - | var err error | |||
| 206 | - | out, err = parseRevList(r, maxPushedCommits, maxPushedObjects) | |||
| 207 | - | return err | |||
| 208 | - | }) | |||
| 209 | - | if err != nil { | |||
| 210 | - | return nil, fmt.Errorf("commits of a push: %w", err) | |||
| 211 | - | } | |||
| 212 | - | return out, nil | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | // parseRevList reads the lines of `rev-list --parents` ("<commit> <parent>..."): | |||
| 216 | - | // at most maxCommits lines and maxIDs ids in all (a commit and each of its | |||
| 217 | - | // parents count, repeats too). A line over maxRevListLine, a limit crossed or an | |||
| 218 | - | // id that is not one is an error, and it is raised as soon as the line that | |||
| 219 | - | // crosses it is read: nothing past it is read or kept. | |||
| 220 | - | func parseRevList(r *bufio.Reader, maxCommits, maxIDs int) ([]pushedCommit, error) { | |||
| 221 | - | var out []pushedCommit | |||
| 222 | - | ids := 0 | |||
| 223 | - | for { | |||
| 224 | - | line, err := r.ReadSlice('\n') | |||
| 225 | - | switch { | |||
| 226 | - | case errors.Is(err, bufio.ErrBufferFull) || len(line) > maxRevListLine: | |||
| 227 | - | return nil, fmt.Errorf("%w: a pushed commit has too many parents", domain.ErrInvalid) | |||
| 228 | - | case errors.Is(err, io.EOF) && len(line) == 0: | |||
| 229 | - | return out, nil | |||
| 230 | - | case err != nil && !errors.Is(err, io.EOF): | |||
| 231 | - | return nil, err | |||
| 232 | - | } | |||
| 233 | - | f := strings.Fields(string(line)) | |||
| 234 | - | if len(f) == 0 { | |||
| 235 | - | continue | |||
| 236 | - | } | |||
| 237 | - | if len(out) == maxCommits { | |||
| 238 | - | return nil, fmt.Errorf("%w: the push brings more than %d commits", domain.ErrInvalid, maxCommits) | |||
| 239 | - | } | |||
| 240 | - | if ids += len(f); ids > maxIDs { | |||
| 241 | - | return nil, fmt.Errorf("%w: the push brings commits with more than %d ids (commits and parents) in all", domain.ErrInvalid, maxIDs) | |||
| 242 | - | } | |||
| 243 | - | for _, id := range f { | |||
| 244 | - | if _, err := domain.ParseObjectID(id); err != nil { | |||
| 245 | - | return nil, fmt.Errorf("%w: unexpected rev-list output", domain.ErrInvalid) | |||
| 246 | - | } | |||
| 247 | - | } | |||
| 248 | - | out = append(out, pushedCommit{id: f[0], parents: f[1:]}) | |||
| 249 | - | } | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | // workflowsAt returns, for each spec ("<rev>:.github/workflows"), the id of the | |||
| 253 | - | // directory it names, or "" when there is none (see workflowsOf). | |||
| 254 | - | func (g *Git) workflowsAt(ctx context.Context, dir string, extra []string, specs []string) (map[string]string, error) { | |||
| 255 | - | out := make(map[string]string, len(specs)) | |||
| 256 | - | if len(specs) == 0 { | |||
| 257 | - | return out, nil | |||
| 258 | - | } | |||
| 259 | - | args := readArgs(dir, "cat-file", "--batch-check=%(objectmode) %(objectname) %(objecttype)") | |||
| 260 | - | err := g.pipeWith(ctx, extra, strings.NewReader(strings.Join(specs, "\n")+"\n"), args, func(r *bufio.Reader) error { | |||
| 261 | - | for _, spec := range specs { | |||
| 262 | - | line, err := r.ReadSlice('\n') | |||
| 263 | - | if err != nil { | |||
| 264 | - | return fmt.Errorf("read object answers: %w", err) | |||
| 265 | - | } | |||
| 266 | - | out[spec] = workflowsOf("", strings.TrimSuffix(string(line), "\n")) | |||
| 267 | - | } | |||
| 268 | - | return nil | |||
| 269 | - | }) | |||
| 270 | - | if err != nil { | |||
| 271 | - | return nil, fmt.Errorf("workflows of a push: %w", err) | |||
| 272 | - | } | |||
| 273 | - | return out, nil | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | // workflowsOf is the id of the tree that is the .github/workflows of a commit | |||
| 277 | - | // or a tree, "" when there is none: the answer for "<it>:.github/workflows" must | |||
| 278 | - | // be a tree of mode 040000, and the object peeled to a tree-ish, not a blob. | |||
| 279 | - | func workflowsOf(objType, answer string) string { | |||
| 280 | - | if objType == "blob" { | |||
| 281 | - | return "" | |||
| 282 | - | } | |||
| 283 | - | mode, rest, _ := strings.Cut(answer, " ") | |||
| 284 | - | id, typ, _ := strings.Cut(rest, " ") | |||
| 285 | - | if mode != "040000" || typ != "tree" { | |||
| 286 | - | return "" | |||
| 287 | - | } | |||
| 288 | - | return id | |||
| 289 | - | } | |||
| 290 | - | ||||
| 291 | - | // firstChanged names the first top-level entry of .github/workflows that | |||
| 292 | - | // differs between two trees (oldID may be "": none, the empty tree). | |||
| 293 | - | func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) { | |||
| 294 | - | from := oldID | |||
| 295 | - | if from == "" { | |||
| 296 | - | from = emptyTree | |||
| 297 | - | } | |||
| 298 | - | to := newID | |||
| 299 | - | args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to) | |||
| 300 | - | var name string | |||
| 301 | - | err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error { | |||
| 302 | - | n, found, err := firstPath(r) | |||
| 303 | - | name = n | |||
| 304 | - | if err != nil || !found { | |||
| 305 | - | return err // no name: git's exit status is checked, so a failure refuses | |||
| 306 | - | } | |||
| 307 | - | return errStop // the first name is all that is needed: stop git | |||
| 308 | - | }) | |||
| 309 | - | if err != nil { | |||
| 310 | - | return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err) | |||
| 311 | - | } | |||
| 312 | - | if name == "" { | |||
| 313 | - | return strings.TrimSuffix(workflowsDir, "/"), nil | |||
| 314 | - | } | |||
| 315 | - | return workflowsDir + name, nil | |||
| 316 | - | } | |||
| 317 | - | ||||
| 318 | - | // firstPath reads the first NUL-terminated path of r; found is false at the end | |||
| 319 | - | // of the output without one. A path longer than maxWorkflowPath is an error. | |||
| 320 | - | func firstPath(r *bufio.Reader) (path string, found bool, err error) { | |||
| 321 | - | var b []byte | |||
| 322 | - | for { | |||
| 323 | - | c, err := r.ReadByte() | |||
| 324 | - | switch { | |||
| 325 | - | case err != nil: | |||
| 326 | - | return "", false, nil // the end of the output; a failing exit status is reported by pipe | |||
| 327 | - | case c == 0: | |||
| 328 | - | return string(b), true, nil | |||
| 329 | - | case len(b) >= maxWorkflowPath: | |||
| 330 | - | return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath) | |||
| 331 | - | } | |||
| 332 | - | b = append(b, c) | |||
| 333 | - | } | |||
| 334 | - | } | |||
| 335 | - | ||||
| 336 | - | // batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the | |||
| 337 | - | // old (unless the ref is created) and the new end of each update and returns | |||
| 338 | - | // the answers in that order, two per update, "" for the old end of a created | |||
| 339 | - | // ref. With suffix "^{}" an object that is missing, malformed or neither a | |||
| 340 | - | // commit, a tree nor a blob is an error (the push is refused); with any other | |||
| 341 | - | // suffix an answer "<input> missing" is kept as the answer. | |||
| 342 | - | func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) { | |||
| 343 | - | var in strings.Builder | |||
| 344 | - | want := 0 | |||
| 345 | - | for _, up := range ups { | |||
| 346 | - | for _, oid := range []string{up.Old, up.New} { | |||
| 347 | - | if oid == domain.ZeroOID { | |||
| 348 | - | continue | |||
| 349 | - | } | |||
| 350 | - | if _, err := domain.ParseObjectID(oid); err != nil { | |||
| 351 | - | return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err) | |||
| 352 | - | } | |||
| 353 | - | in.WriteString(oid + suffix + "\n") | |||
| 354 | - | want++ | |||
| 355 | - | } | |||
| 356 | - | } | |||
| 357 | - | got := make([]string, 0, want) | |||
| 358 | - | args := readArgs(dir, "cat-file", "--batch-check="+format) | |||
| 359 | - | err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error { | |||
| 360 | - | for range want { | |||
| 361 | - | line, err := r.ReadSlice('\n') | |||
| 362 | - | if err != nil { | |||
| 363 | - | if errors.Is(err, bufio.ErrBufferFull) { | |||
| 364 | - | return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid) | |||
| 365 | - | } | |||
| 366 | - | return fmt.Errorf("read object answers: %w", err) | |||
| 367 | - | } | |||
| 368 | - | got = append(got, strings.TrimSuffix(string(line), "\n")) | |||
| 369 | - | } | |||
| 370 | - | return nil | |||
| 371 | - | }) | |||
| 372 | - | if err != nil { | |||
| 373 | - | return nil, fmt.Errorf("objects of a push: %w", err) | |||
| 374 | - | } | |||
| 375 | - | out := make([]string, 0, 2*len(ups)) | |||
| 376 | - | i := 0 | |||
| 377 | - | for _, up := range ups { | |||
| 378 | - | for _, oid := range []string{up.Old, up.New} { | |||
| 379 | - | if oid == domain.ZeroOID { | |||
| 380 | - | out = append(out, "") | |||
| 381 | - | continue | |||
| 382 | - | } | |||
| 383 | - | if suffix == "^{}" { | |||
| 384 | - | switch got[i] { | |||
| 385 | - | case "commit", "tree", "blob": | |||
| 386 | - | default: // "<input> missing", or a type that cannot be compared | |||
| 387 | - | return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid) | |||
| 388 | - | } | |||
| 389 | - | } | |||
| 390 | - | out = append(out, got[i]) | |||
| 391 | - | i++ | |||
| 392 | - | } | |||
| 393 | - | } | |||
| 394 | - | return out, nil | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | // quarantineEnv returns the environment that makes git read the objects of a | |||
| 398 | - | // push in quarantine. Each directory must exist and, once symbolic links are | |||
| 399 | - | // resolved, be inside the objects directory of the repository dir (itself | |||
| 400 | - | // resolved): a hook request cannot send git to read anything else. The resolved | |||
| 401 | - | // paths are the ones given to git. | |||
| 402 | - | func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) { | |||
| 403 | - | if o.Dir == "" && o.Alternates == "" { | |||
| 404 | - | return nil, nil | |||
| 405 | - | } | |||
| 406 | - | root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects")) | |||
| 407 | - | if err != nil { | |||
| 408 | - | return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err) | |||
| 409 | - | } | |||
| 410 | - | inside := func(p string) (string, bool) { | |||
| 411 | - | if !filepath.IsAbs(p) { | |||
| 412 | - | return "", false | |||
| 413 | - | } | |||
| 414 | - | real, err := filepath.EvalSymlinks(p) | |||
| 415 | - | if err != nil { | |||
| 416 | - | return "", false | |||
| 417 | - | } | |||
| 418 | - | rel, err := filepath.Rel(root, real) | |||
| 419 | - | return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) | |||
| 420 | - | } | |||
| 421 | - | dir, ok := inside(o.Dir) | |||
| 422 | - | if o.Dir == "" || !ok { | |||
| 423 | - | return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 424 | - | } | |||
| 425 | - | env := []string{"GIT_OBJECT_DIRECTORY=" + dir} | |||
| 426 | - | if o.Alternates != "" { | |||
| 427 | - | var alts []string | |||
| 428 | - | for _, a := range filepath.SplitList(o.Alternates) { | |||
| 429 | - | real, ok := inside(a) | |||
| 430 | - | if !ok { | |||
| 431 | - | return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid) | |||
| 432 | - | } | |||
| 433 | - | alts = append(alts, real) | |||
| 434 | - | } | |||
| 435 | - | env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator))) | |||
| 436 | - | } | |||
| 437 | - | return env, nil | |||
| 438 | - | } | |||
M internal/app/forge.go +1 -1
| @@ -313,7 +313,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 313 | 313 | opts.ci(&ciCfg) | |
| 314 | 314 | } | |
| 315 | 315 | f.CI = usecase.NewCI(ctx, ciCfg) | |
| 316 | - | f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI()) | |
| 316 | + | f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI()) | |
| 317 | 317 | f.Repos.WithCIRunCleaner(f.CI) | |
| 318 | 318 | f.CI.Start() | |
| 319 | 319 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | |
| @@ -313,7 +313,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 313 | opts.ci(&ciCfg) | 313 | opts.ci(&ciCfg) | ||
| 314 | } | 314 | } | ||
| 315 | f.CI = usecase.NewCI(ctx, ciCfg) | 315 | f.CI = usecase.NewCI(ctx, ciCfg) | ||
| 316 | - | f.Pushes.WithWorkflowTrigger(f.CI).WithWorkflowGuard(git).WithRefTips(store.CI()) | 316 | + | f.Pushes.WithWorkflowTrigger(f.CI).WithPushTreeReader(git).WithRefTips(store.CI()) |
| 317 | f.Repos.WithCIRunCleaner(f.CI) | 317 | f.Repos.WithCIRunCleaner(f.CI) | ||
| 318 | f.CI.Start() | 318 | f.CI.Start() | ||
| 319 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | 319 | f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{ | ||
A internal/domain/ci_workflow_guard.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain | |
| 4 | + | ||
| 5 | + | import "fmt" | |
| 6 | + | ||
| 7 | + | // ObjectType is the type of a peeled Git object. | |
| 8 | + | type ObjectType string | |
| 9 | + | ||
| 10 | + | // Peeled object types accepted by the push tree reader. | |
| 11 | + | const ( | |
| 12 | + | ObjectCommit ObjectType = "commit" | |
| 13 | + | ObjectTree ObjectType = "tree" | |
| 14 | + | ObjectBlob ObjectType = "blob" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // PushWalkBounds bounds the walk over the commits of a push. | |
| 18 | + | type PushWalkBounds struct{ Commits, IDs int } | |
| 19 | + | ||
| 20 | + | // Bounds fail closed and count repeated parent IDs too. | |
| 21 | + | const ( | |
| 22 | + | MaxPushedCommits = 10000 | |
| 23 | + | MaxPushedIDs = 4 * MaxPushedCommits | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // DefaultPushWalkBounds returns the job-token push walk limits. | |
| 27 | + | func DefaultPushWalkBounds() PushWalkBounds { return PushWalkBounds{MaxPushedCommits, MaxPushedIDs} } | |
| 28 | + | ||
| 29 | + | // PushedEnd describes a peeled ref endpoint. | |
| 30 | + | type PushedEnd struct{ Type ObjectType } | |
| 31 | + | ||
| 32 | + | // Subtree is the kind and identity of an entry at a tree path. | |
| 33 | + | type Subtree struct { | |
| 34 | + | Kind EntryKind | |
| 35 | + | ID ObjectID | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // PushedCommit is a newly reachable commit with its ordered parents. | |
| 39 | + | type PushedCommit struct { | |
| 40 | + | ID ObjectID | |
| 41 | + | Parents []ObjectID | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // HeadKey identifies the default branch head in PushSubtrees.Subtrees. | |
| 45 | + | const HeadKey = "HEAD" | |
| 46 | + | ||
| 47 | + | // PushSubtrees contains endpoint types and entries keyed by original object ID | |
| 48 | + | // (or HeadKey), and newly reachable commits in rev-list order. Absent entries | |
| 49 | + | // mean there is no subtree at the requested path. | |
| 50 | + | type PushSubtrees struct { | |
| 51 | + | Ends map[string]PushedEnd | |
| 52 | + | Commits []PushedCommit | |
| 53 | + | Subtrees map[string]Subtree | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // WorkflowTreeChange is the first forbidden workflow-tree replacement. | |
| 57 | + | // A zero Old denotes no workflow directory. | |
| 58 | + | type WorkflowTreeChange struct{ Old, New ObjectID } | |
| 59 | + | ||
| 60 | + | // WorkflowChangeOf applies the job-token workflow policy. Every new commit must | |
| 61 | + | // keep the workflows of at least one parent; then each ref must keep its old | |
| 62 | + | // workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed. | |
| 63 | + | // New commits are judged before ref ends, in their supplied order. Missing ends | |
| 64 | + | // are a reader contract breach and fail closed with ErrInvalid. | |
| 65 | + | func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) { | |
| 66 | + | for _, up := range updates { | |
| 67 | + | if up.IsDelete() { | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | ids := []string{up.New} | |
| 71 | + | if !up.IsCreate() { | |
| 72 | + | ids = append(ids, up.Old) | |
| 73 | + | } | |
| 74 | + | for _, id := range ids { | |
| 75 | + | if _, ok := t.Ends[id]; !ok { | |
| 76 | + | return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | dirOf := func(key string) ObjectID { | |
| 81 | + | s := t.Subtrees[key] | |
| 82 | + | if s.Kind != EntryDirectory { | |
| 83 | + | return ObjectID{} | |
| 84 | + | } | |
| 85 | + | return s.ID | |
| 86 | + | } | |
| 87 | + | for _, c := range t.Commits { | |
| 88 | + | cur := dirOf(c.ID.String()) | |
| 89 | + | if cur.IsZero() { | |
| 90 | + | continue | |
| 91 | + | } | |
| 92 | + | var first ObjectID | |
| 93 | + | same := false | |
| 94 | + | for i, p := range c.Parents { | |
| 95 | + | w := dirOf(p.String()) | |
| 96 | + | if i == 0 { | |
| 97 | + | first = w | |
| 98 | + | } | |
| 99 | + | if w == cur { | |
| 100 | + | same = true | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if !same { | |
| 104 | + | return WorkflowTreeChange{first, cur}, true, nil | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | for _, up := range updates { | |
| 108 | + | if up.IsDelete() || t.Ends[up.New].Type == ObjectBlob { | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | cur := dirOf(up.New) | |
| 112 | + | if cur.IsZero() { | |
| 113 | + | continue | |
| 114 | + | } | |
| 115 | + | var base ObjectID | |
| 116 | + | if up.IsCreate() { | |
| 117 | + | base = dirOf(HeadKey) | |
| 118 | + | } else if t.Ends[up.Old].Type != ObjectBlob { | |
| 119 | + | base = dirOf(up.Old) | |
| 120 | + | } | |
| 121 | + | if base != cur { | |
| 122 | + | return WorkflowTreeChange{base, cur}, true, nil | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | return WorkflowTreeChange{}, false, nil | |
| 126 | + | } | |
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain | |||
| 4 | + | ||||
| 5 | + | import "fmt" | |||
| 6 | + | ||||
| 7 | + | // ObjectType is the type of a peeled Git object. | |||
| 8 | + | type ObjectType string | |||
| 9 | + | ||||
| 10 | + | // Peeled object types accepted by the push tree reader. | |||
| 11 | + | const ( | |||
| 12 | + | ObjectCommit ObjectType = "commit" | |||
| 13 | + | ObjectTree ObjectType = "tree" | |||
| 14 | + | ObjectBlob ObjectType = "blob" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // PushWalkBounds bounds the walk over the commits of a push. | |||
| 18 | + | type PushWalkBounds struct{ Commits, IDs int } | |||
| 19 | + | ||||
| 20 | + | // Bounds fail closed and count repeated parent IDs too. | |||
| 21 | + | const ( | |||
| 22 | + | MaxPushedCommits = 10000 | |||
| 23 | + | MaxPushedIDs = 4 * MaxPushedCommits | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | // DefaultPushWalkBounds returns the job-token push walk limits. | |||
| 27 | + | func DefaultPushWalkBounds() PushWalkBounds { return PushWalkBounds{MaxPushedCommits, MaxPushedIDs} } | |||
| 28 | + | ||||
| 29 | + | // PushedEnd describes a peeled ref endpoint. | |||
| 30 | + | type PushedEnd struct{ Type ObjectType } | |||
| 31 | + | ||||
| 32 | + | // Subtree is the kind and identity of an entry at a tree path. | |||
| 33 | + | type Subtree struct { | |||
| 34 | + | Kind EntryKind | |||
| 35 | + | ID ObjectID | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // PushedCommit is a newly reachable commit with its ordered parents. | |||
| 39 | + | type PushedCommit struct { | |||
| 40 | + | ID ObjectID | |||
| 41 | + | Parents []ObjectID | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // HeadKey identifies the default branch head in PushSubtrees.Subtrees. | |||
| 45 | + | const HeadKey = "HEAD" | |||
| 46 | + | ||||
| 47 | + | // PushSubtrees contains endpoint types and entries keyed by original object ID | |||
| 48 | + | // (or HeadKey), and newly reachable commits in rev-list order. Absent entries | |||
| 49 | + | // mean there is no subtree at the requested path. | |||
| 50 | + | type PushSubtrees struct { | |||
| 51 | + | Ends map[string]PushedEnd | |||
| 52 | + | Commits []PushedCommit | |||
| 53 | + | Subtrees map[string]Subtree | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // WorkflowTreeChange is the first forbidden workflow-tree replacement. | |||
| 57 | + | // A zero Old denotes no workflow directory. | |||
| 58 | + | type WorkflowTreeChange struct{ Old, New ObjectID } | |||
| 59 | + | ||||
| 60 | + | // WorkflowChangeOf applies the job-token workflow policy. Every new commit must | |||
| 61 | + | // keep the workflows of at least one parent; then each ref must keep its old | |||
| 62 | + | // workflows (HEAD's for a new ref). Disappearance and ref deletion are allowed. | |||
| 63 | + | // New commits are judged before ref ends, in their supplied order. Missing ends | |||
| 64 | + | // are a reader contract breach and fail closed with ErrInvalid. | |||
| 65 | + | func WorkflowChangeOf(updates []RefUpdate, t PushSubtrees) (WorkflowTreeChange, bool, error) { | |||
| 66 | + | for _, up := range updates { | |||
| 67 | + | if up.IsDelete() { | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | ids := []string{up.New} | |||
| 71 | + | if !up.IsCreate() { | |||
| 72 | + | ids = append(ids, up.Old) | |||
| 73 | + | } | |||
| 74 | + | for _, id := range ids { | |||
| 75 | + | if _, ok := t.Ends[id]; !ok { | |||
| 76 | + | return WorkflowTreeChange{}, false, fmt.Errorf("%w: missing push end %s", ErrInvalid, id) | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | dirOf := func(key string) ObjectID { | |||
| 81 | + | s := t.Subtrees[key] | |||
| 82 | + | if s.Kind != EntryDirectory { | |||
| 83 | + | return ObjectID{} | |||
| 84 | + | } | |||
| 85 | + | return s.ID | |||
| 86 | + | } | |||
| 87 | + | for _, c := range t.Commits { | |||
| 88 | + | cur := dirOf(c.ID.String()) | |||
| 89 | + | if cur.IsZero() { | |||
| 90 | + | continue | |||
| 91 | + | } | |||
| 92 | + | var first ObjectID | |||
| 93 | + | same := false | |||
| 94 | + | for i, p := range c.Parents { | |||
| 95 | + | w := dirOf(p.String()) | |||
| 96 | + | if i == 0 { | |||
| 97 | + | first = w | |||
| 98 | + | } | |||
| 99 | + | if w == cur { | |||
| 100 | + | same = true | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | if !same { | |||
| 104 | + | return WorkflowTreeChange{first, cur}, true, nil | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | for _, up := range updates { | |||
| 108 | + | if up.IsDelete() || t.Ends[up.New].Type == ObjectBlob { | |||
| 109 | + | continue | |||
| 110 | + | } | |||
| 111 | + | cur := dirOf(up.New) | |||
| 112 | + | if cur.IsZero() { | |||
| 113 | + | continue | |||
| 114 | + | } | |||
| 115 | + | var base ObjectID | |||
| 116 | + | if up.IsCreate() { | |||
| 117 | + | base = dirOf(HeadKey) | |||
| 118 | + | } else if t.Ends[up.Old].Type != ObjectBlob { | |||
| 119 | + | base = dirOf(up.Old) | |||
| 120 | + | } | |||
| 121 | + | if base != cur { | |||
| 122 | + | return WorkflowTreeChange{base, cur}, true, nil | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | return WorkflowTreeChange{}, false, nil | |||
| 126 | + | } | |||
A internal/domain/ci_workflow_guard_test.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package domain_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "bnema.dev/ermite/internal/domain" | |
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestWorkflowChangeOf(t *testing.T) { | |
| 15 | + | id := func(n int) domain.ObjectID { | |
| 16 | + | v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n)) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | return v | |
| 19 | + | } | |
| 20 | + | a, b, c, d := id(1), id(2), id(3), id(4) | |
| 21 | + | x, y, z := id(11), id(12), id(13) | |
| 22 | + | dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} } | |
| 23 | + | up := func(old, new domain.ObjectID) domain.RefUpdate { | |
| 24 | + | o, n := old.String(), new.String() | |
| 25 | + | if old.IsZero() { | |
| 26 | + | o = domain.ZeroOID | |
| 27 | + | } | |
| 28 | + | if new.IsZero() { | |
| 29 | + | n = domain.ZeroOID | |
| 30 | + | } | |
| 31 | + | return domain.RefUpdate{Old: o, New: n} | |
| 32 | + | } | |
| 33 | + | zero := domain.ObjectID{} | |
| 34 | + | commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit { | |
| 35 | + | return domain.PushedCommit{ID: v, Parents: parents} | |
| 36 | + | } | |
| 37 | + | type row struct { | |
| 38 | + | name string | |
| 39 | + | ups []domain.RefUpdate | |
| 40 | + | commits []domain.PushedCommit | |
| 41 | + | trees map[string]domain.Subtree | |
| 42 | + | blobs []domain.ObjectID | |
| 43 | + | want domain.WorkflowTreeChange | |
| 44 | + | found bool | |
| 45 | + | missing bool | |
| 46 | + | } | |
| 47 | + | change := func(old, new domain.ObjectID) domain.WorkflowTreeChange { | |
| 48 | + | return domain.WorkflowTreeChange{Old: old, New: new} | |
| 49 | + | } | |
| 50 | + | rows := []row{ | |
| 51 | + | {name: "no updates"}, | |
| 52 | + | {name: "delete", ups: []domain.RefUpdate{up(a, zero)}}, | |
| 53 | + | {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}}, | |
| 54 | + | {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true}, | |
| 55 | + | {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}}, | |
| 56 | + | {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}}, | |
| 57 | + | {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |
| 58 | + | {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 59 | + | {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}}, | |
| 60 | + | {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |
| 61 | + | {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |
| 62 | + | {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |
| 63 | + | {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}}, | |
| 64 | + | {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 65 | + | {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}}, | |
| 66 | + | {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |
| 67 | + | {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |
| 68 | + | {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}}, | |
| 69 | + | {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true}, | |
| 70 | + | {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true}, | |
| 71 | + | {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true}, | |
| 72 | + | {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true}, | |
| 73 | + | {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true}, | |
| 74 | + | {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |
| 75 | + | {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true}, | |
| 76 | + | {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |
| 77 | + | {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true}, | |
| 78 | + | } | |
| 79 | + | rows = append(rows, | |
| 80 | + | row{name: "new tree end with workflows", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 81 | + | row{name: "new tree end without workflows", ups: []domain.RefUpdate{up(zero, a)}}, | |
| 82 | + | row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}}, | |
| 83 | + | ) | |
| 84 | + | ||
| 85 | + | for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} { | |
| 86 | + | rows = append(rows, | |
| 87 | + | row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true}, | |
| 88 | + | row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}}, | |
| 89 | + | row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}}, | |
| 90 | + | ) | |
| 91 | + | } | |
| 92 | + | rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}}) | |
| 93 | + | for _, r := range rows { | |
| 94 | + | t.Run(r.name, func(t *testing.T) { | |
| 95 | + | data := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Commits: r.commits, Subtrees: r.trees} | |
| 96 | + | if !r.missing { | |
| 97 | + | for _, u := range r.ups { | |
| 98 | + | for _, oid := range []string{u.Old, u.New} { | |
| 99 | + | data.Ends[oid] = domain.PushedEnd{Type: domain.ObjectCommit} | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | if r.name == "new tree end with workflows" || r.name == "new tree end without workflows" { | |
| 104 | + | data.Ends[a.String()] = domain.PushedEnd{Type: domain.ObjectTree} | |
| 105 | + | } | |
| 106 | + | for _, b := range r.blobs { | |
| 107 | + | data.Ends[b.String()] = domain.PushedEnd{Type: domain.ObjectBlob} | |
| 108 | + | } | |
| 109 | + | got, found, err := domain.WorkflowChangeOf(r.ups, data) | |
| 110 | + | if r.missing { | |
| 111 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 112 | + | return | |
| 113 | + | } | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | assert.Equal(t, r.found, found) | |
| 116 | + | assert.Equal(t, r.want, got) | |
| 117 | + | }) | |
| 118 | + | } | |
| 119 | + | } | |
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package domain_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "bnema.dev/ermite/internal/domain" | |||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestWorkflowChangeOf(t *testing.T) { | |||
| 15 | + | id := func(n int) domain.ObjectID { | |||
| 16 | + | v, err := domain.ParseObjectID(fmt.Sprintf("%040x", n)) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | return v | |||
| 19 | + | } | |||
| 20 | + | a, b, c, d := id(1), id(2), id(3), id(4) | |||
| 21 | + | x, y, z := id(11), id(12), id(13) | |||
| 22 | + | dir := func(v domain.ObjectID) domain.Subtree { return domain.Subtree{Kind: domain.EntryDirectory, ID: v} } | |||
| 23 | + | up := func(old, new domain.ObjectID) domain.RefUpdate { | |||
| 24 | + | o, n := old.String(), new.String() | |||
| 25 | + | if old.IsZero() { | |||
| 26 | + | o = domain.ZeroOID | |||
| 27 | + | } | |||
| 28 | + | if new.IsZero() { | |||
| 29 | + | n = domain.ZeroOID | |||
| 30 | + | } | |||
| 31 | + | return domain.RefUpdate{Old: o, New: n} | |||
| 32 | + | } | |||
| 33 | + | zero := domain.ObjectID{} | |||
| 34 | + | commit := func(v domain.ObjectID, parents ...domain.ObjectID) domain.PushedCommit { | |||
| 35 | + | return domain.PushedCommit{ID: v, Parents: parents} | |||
| 36 | + | } | |||
| 37 | + | type row struct { | |||
| 38 | + | name string | |||
| 39 | + | ups []domain.RefUpdate | |||
| 40 | + | commits []domain.PushedCommit | |||
| 41 | + | trees map[string]domain.Subtree | |||
| 42 | + | blobs []domain.ObjectID | |||
| 43 | + | want domain.WorkflowTreeChange | |||
| 44 | + | found bool | |||
| 45 | + | missing bool | |||
| 46 | + | } | |||
| 47 | + | change := func(old, new domain.ObjectID) domain.WorkflowTreeChange { | |||
| 48 | + | return domain.WorkflowTreeChange{Old: old, New: new} | |||
| 49 | + | } | |||
| 50 | + | rows := []row{ | |||
| 51 | + | {name: "no updates"}, | |||
| 52 | + | {name: "delete", ups: []domain.RefUpdate{up(a, zero)}}, | |||
| 53 | + | {name: "new blob ignores even a misleading subtree", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{b}}, | |||
| 54 | + | {name: "old blob is not a directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, blobs: []domain.ObjectID{a}, want: change(zero, y), found: true}, | |||
| 55 | + | {name: "new ref at HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), a.String(): dir(x)}}, | |||
| 56 | + | {name: "new ref same directory as HEAD", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(x)}}, | |||
| 57 | + | {name: "new ref old workflows", ups: []domain.RefUpdate{up(zero, b)}, trees: map[string]domain.Subtree{domain.HeadKey: dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 58 | + | {name: "unborn HEAD", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 59 | + | {name: "HEAD has no workflows", ups: []domain.RefUpdate{up(zero, a)}}, | |||
| 60 | + | {name: "same workflow directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |||
| 61 | + | {name: "move back or remove one file from surviving directory", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 62 | + | {name: "directory disappears", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |||
| 63 | + | {name: "lookalike path absent", ups: []domain.RefUpdate{up(a, b)}}, | |||
| 64 | + | {name: "root with workflows", commits: []domain.PushedCommit{commit(a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 65 | + | {name: "root without workflows", commits: []domain.PushedCommit{commit(a)}}, | |||
| 66 | + | {name: "plain pushed commit", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x)}}, | |||
| 67 | + | {name: "pushed directory disappears", commits: []domain.PushedCommit{commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}}, | |||
| 68 | + | {name: "merge keeps second parent", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}}, | |||
| 69 | + | {name: "merge differs from all parents", commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z)}, want: change(x, z), found: true}, | |||
| 70 | + | {name: "merge new side changes workflows", commits: []domain.PushedCommit{commit(d, a, c), commit(c, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(z), d.String(): dir(x)}, want: change(y, z), found: true}, | |||
| 71 | + | {name: "merge adopts other parent refused by ref rule", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 72 | + | {name: "change then revert", ups: []domain.RefUpdate{up(a, c)}, commits: []domain.PushedCommit{commit(c, b), commit(b, a)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(x)}, want: change(y, x), found: true}, | |||
| 73 | + | {name: "change below plain tip", commits: []domain.PushedCommit{commit(d, c), commit(c, b)}, trees: map[string]domain.Subtree{b.String(): dir(x), c.String(): dir(y), d.String(): dir(y)}, want: change(x, y), found: true}, | |||
| 74 | + | {name: "all new commits before any ref", ups: []domain.RefUpdate{up(a, b)}, commits: []domain.PushedCommit{commit(d, c)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |||
| 75 | + | {name: "first ref wins", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(y), c.String(): dir(y), d.String(): dir(z)}, want: change(x, y), found: true}, | |||
| 76 | + | {name: "plain ref then changed ref", ups: []domain.RefUpdate{up(a, b), up(c, d)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): dir(x), c.String(): dir(y), d.String(): dir(z)}, want: change(y, z), found: true}, | |||
| 77 | + | {name: "missing ends fail closed", ups: []domain.RefUpdate{up(a, b)}, missing: true}, | |||
| 78 | + | } | |||
| 79 | + | rows = append(rows, | |||
| 80 | + | row{name: "new tree end with workflows", ups: []domain.RefUpdate{up(zero, a)}, trees: map[string]domain.Subtree{a.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 81 | + | row{name: "new tree end without workflows", ups: []domain.RefUpdate{up(zero, a)}}, | |||
| 82 | + | row{name: "new blob ref", ups: []domain.RefUpdate{up(zero, a)}, blobs: []domain.ObjectID{a}}, | |||
| 83 | + | ) | |||
| 84 | + | ||||
| 85 | + | for _, kind := range []domain.EntryKind{domain.EntrySubmodule, domain.EntrySymlink, domain.EntryFile, domain.EntryExecutable} { | |||
| 86 | + | rows = append(rows, | |||
| 87 | + | row{name: string(kind) + " to directory same id", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): dir(x)}, want: change(zero, x), found: true}, | |||
| 88 | + | row{name: "directory to " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): dir(x), b.String(): {Kind: kind, ID: x}}}, | |||
| 89 | + | row{name: "same " + string(kind), ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: kind, ID: x}, b.String(): {Kind: kind, ID: x}}}, | |||
| 90 | + | ) | |||
| 91 | + | } | |||
| 92 | + | rows = append(rows, row{name: "gitlink to symlink", ups: []domain.RefUpdate{up(a, b)}, trees: map[string]domain.Subtree{a.String(): {Kind: domain.EntrySubmodule, ID: x}, b.String(): {Kind: domain.EntrySymlink, ID: y}}}) | |||
| 93 | + | for _, r := range rows { | |||
| 94 | + | t.Run(r.name, func(t *testing.T) { | |||
| 95 | + | data := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Commits: r.commits, Subtrees: r.trees} | |||
| 96 | + | if !r.missing { | |||
| 97 | + | for _, u := range r.ups { | |||
| 98 | + | for _, oid := range []string{u.Old, u.New} { | |||
| 99 | + | data.Ends[oid] = domain.PushedEnd{Type: domain.ObjectCommit} | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | if r.name == "new tree end with workflows" || r.name == "new tree end without workflows" { | |||
| 104 | + | data.Ends[a.String()] = domain.PushedEnd{Type: domain.ObjectTree} | |||
| 105 | + | } | |||
| 106 | + | for _, b := range r.blobs { | |||
| 107 | + | data.Ends[b.String()] = domain.PushedEnd{Type: domain.ObjectBlob} | |||
| 108 | + | } | |||
| 109 | + | got, found, err := domain.WorkflowChangeOf(r.ups, data) | |||
| 110 | + | if r.missing { | |||
| 111 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 112 | + | return | |||
| 113 | + | } | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | assert.Equal(t, r.found, found) | |||
| 116 | + | assert.Equal(t, r.want, got) | |||
| 117 | + | }) | |||
| 118 | + | } | |||
| 119 | + | } | |||
M internal/ports/ci.go +41 -15
| @@ -407,21 +407,47 @@ type PushObjects struct { | |||
| 407 | 407 | Alternates string | |
| 408 | 408 | } | |
| 409 | 409 | ||
| 410 | - | // WorkflowGuard finds the changes of a push to the workflow files of a | |
| 411 | - | // repository. | |
| 412 | - | type WorkflowGuard interface { | |
| 413 | - | // ChangedWorkflow returns the first path under .github/workflows/ that the | |
| 414 | - | // updates change, "" for none. Two checks, as GitHub judges the token of a | |
| 415 | - | // job: (1) every commit the push brings (those no ref has yet) is compared | |
| 416 | - | // with each of its parents (a root commit with nothing) and is a change when | |
| 417 | - | // it has workflows that differ from every parent's; (2) the workflows a ref | |
| 418 | - | // ends with must be its old ones or none for an update, and the default | |
| 419 | - | // branch head's or none for a new ref (none when there is no head). A | |
| 420 | - | // workflows directory that disappears is no change, and a deleted ref changes | |
| 421 | - | // nothing. objects are those of the push in progress; a directory that is not | |
| 422 | - | // inside the repository is refused. A push beyond the bounds of the check | |
| 423 | - | // (commits, parents) is an error that matches domain.ErrInvalid. | |
| 424 | - | ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error) | |
| 410 | + | // PushTreeReader reads, for a push still in quarantine, the objects the | |
| 411 | + | // workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the | |
| 412 | + | // policy: it peels, classifies, walks and compares trees. | |
| 413 | + | type PushTreeReader interface { | |
| 414 | + | // PushSubtrees reads what a push brings, and the entry at path (a | |
| 415 | + | // directory path without leading or trailing slash, e.g. | |
| 416 | + | // domain.WorkflowDir) in each object concerned. | |
| 417 | + | // | |
| 418 | + | // Updates that delete (New == ZeroOID) have no objects: they are skipped. | |
| 419 | + | // For every other update, Old (unless ZeroOID) and New are peeled | |
| 420 | + | // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end | |
| 421 | + | // that is missing, malformed, or peels to neither commit, tree nor blob is | |
| 422 | + | // an error matching domain.ErrInvalid. | |
| 423 | + | // | |
| 424 | + | // Commits is `rev-list --parents <tips> --not --all` where tips are the | |
| 425 | + | // distinct New ends that peel to a commit: the commits no ref has yet, | |
| 426 | + | // each with its parents, in rev-list order. More than bounds.Commits | |
| 427 | + | // commits, more than bounds.IDs ids (commits and parents, repeats | |
| 428 | + | // included) or a commit whose parent line exceeds the adapter's line | |
| 429 | + | // buffer is an error matching domain.ErrInvalid, raised as soon as the | |
| 430 | + | // line crossing the bound is read. | |
| 431 | + | // | |
| 432 | + | // Subtrees holds the entry at path for every end, every commit, every | |
| 433 | + | // parent, and, when a create has a non-blob New, the default branch head | |
| 434 | + | // (key domain.HeadKey, absent when HEAD is unborn or has no such entry). An object that peels to a blob, or an | |
| 435 | + | // object with no entry at path (including a path that crosses a | |
| 436 | + | // gitlink, symlink or file), has no key. Kind is EntryDirectory only for | |
| 437 | + | // mode 040000 and type tree. | |
| 438 | + | // | |
| 439 | + | // objects are the quarantine directories of the push; one outside the | |
| 440 | + | // repository's objects directory (symlinks resolved) is an error matching | |
| 441 | + | // domain.ErrInvalid, checked before anything is read. | |
| 442 | + | PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) | |
| 443 | + | ||
| 444 | + | // FirstDifferentEntry returns the name of the first top-level entry that | |
| 445 | + | // differs between the trees old and new (`diff-tree --name-only -z | |
| 446 | + | // --no-renames`, read as a stream up to the first name, then git is | |
| 447 | + | // stopped). A zero old is the empty tree. It returns "" when nothing | |
| 448 | + | // differs. A name longer than the adapter's path bound is an error | |
| 449 | + | // matching domain.ErrInvalid. objects as above. | |
| 450 | + | FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects PushObjects, old, new domain.ObjectID) (name string, err error) | |
| 425 | 451 | } | |
| 426 | 452 | ||
| 427 | 453 | // --- inbound --- | |
| @@ -407,21 +407,47 @@ type PushObjects struct { | |||||
| 407 | Alternates string | 407 | Alternates string | ||
| 408 | } | 408 | } | ||
| 409 | 409 | ||||
| 410 | - | // WorkflowGuard finds the changes of a push to the workflow files of a | 410 | + | // PushTreeReader reads, for a push still in quarantine, the objects the |
| 411 | - | // repository. | 411 | + | // workflow policy (domain.WorkflowChangeOf) judges. It knows nothing of the |
| 412 | - | type WorkflowGuard interface { | 412 | + | // policy: it peels, classifies, walks and compares trees. |
| 413 | - | // ChangedWorkflow returns the first path under .github/workflows/ that the | 413 | + | type PushTreeReader interface { |
| 414 | - | // updates change, "" for none. Two checks, as GitHub judges the token of a | 414 | + | // PushSubtrees reads what a push brings, and the entry at path (a |
| 415 | - | // job: (1) every commit the push brings (those no ref has yet) is compared | 415 | + | // directory path without leading or trailing slash, e.g. |
| 416 | - | // with each of its parents (a root commit with nothing) and is a change when | 416 | + | // domain.WorkflowDir) in each object concerned. |
| 417 | - | // it has workflows that differ from every parent's; (2) the workflows a ref | 417 | + | // |
| 418 | - | // ends with must be its old ones or none for an update, and the default | 418 | + | // Updates that delete (New == ZeroOID) have no objects: they are skipped. |
| 419 | - | // branch head's or none for a new ref (none when there is no head). A | 419 | + | // For every other update, Old (unless ZeroOID) and New are peeled |
| 420 | - | // workflows directory that disappears is no change, and a deleted ref changes | 420 | + | // (`<oid>^{}`): the result is in Ends keyed by the oid as given. An end |
| 421 | - | // nothing. objects are those of the push in progress; a directory that is not | 421 | + | // that is missing, malformed, or peels to neither commit, tree nor blob is |
| 422 | - | // inside the repository is refused. A push beyond the bounds of the check | 422 | + | // an error matching domain.ErrInvalid. |
| 423 | - | // (commits, parents) is an error that matches domain.ErrInvalid. | 423 | + | // |
| 424 | - | ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects) (path string, err error) | 424 | + | // Commits is `rev-list --parents <tips> --not --all` where tips are the |
| 425 | + | // distinct New ends that peel to a commit: the commits no ref has yet, | |||
| 426 | + | // each with its parents, in rev-list order. More than bounds.Commits | |||
| 427 | + | // commits, more than bounds.IDs ids (commits and parents, repeats | |||
| 428 | + | // included) or a commit whose parent line exceeds the adapter's line | |||
| 429 | + | // buffer is an error matching domain.ErrInvalid, raised as soon as the | |||
| 430 | + | // line crossing the bound is read. | |||
| 431 | + | // | |||
| 432 | + | // Subtrees holds the entry at path for every end, every commit, every | |||
| 433 | + | // parent, and, when a create has a non-blob New, the default branch head | |||
| 434 | + | // (key domain.HeadKey, absent when HEAD is unborn or has no such entry). An object that peels to a blob, or an | |||
| 435 | + | // object with no entry at path (including a path that crosses a | |||
| 436 | + | // gitlink, symlink or file), has no key. Kind is EntryDirectory only for | |||
| 437 | + | // mode 040000 and type tree. | |||
| 438 | + | // | |||
| 439 | + | // objects are the quarantine directories of the push; one outside the | |||
| 440 | + | // repository's objects directory (symlinks resolved) is an error matching | |||
| 441 | + | // domain.ErrInvalid, checked before anything is read. | |||
| 442 | + | PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) | |||
| 443 | + | ||||
| 444 | + | // FirstDifferentEntry returns the name of the first top-level entry that | |||
| 445 | + | // differs between the trees old and new (`diff-tree --name-only -z | |||
| 446 | + | // --no-renames`, read as a stream up to the first name, then git is | |||
| 447 | + | // stopped). A zero old is the empty tree. It returns "" when nothing | |||
| 448 | + | // differs. A name longer than the adapter's path bound is an error | |||
| 449 | + | // matching domain.ErrInvalid. objects as above. | |||
| 450 | + | FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects PushObjects, old, new domain.ObjectID) (name string, err error) | |||
| 425 | } | 451 | } | ||
| 426 | 452 | ||||
| 427 | // --- inbound --- | 453 | // --- inbound --- | ||
A internal/ports/mocks/PushTreeReader.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 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 | + | // NewPushTreeReader creates a new instance of PushTreeReader. 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 NewPushTreeReader(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *PushTreeReader { | |
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |
| 22 | + | helper.Helper() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | mock := &PushTreeReader{} | |
| 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 | + | // PushTreeReader is an autogenerated mock type for the PushTreeReader type | |
| 39 | + | type PushTreeReader struct { | |
| 40 | + | mock.Mock | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type PushTreeReader_Expecter struct { | |
| 44 | + | mock *mock.Mock | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func (_m *PushTreeReader) EXPECT() *PushTreeReader_Expecter { | |
| 48 | + | return &PushTreeReader_Expecter{mock: &_m.Mock} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // FirstDifferentEntry provides a mock function for the type PushTreeReader | |
| 52 | + | func (_mock *PushTreeReader) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error) { | |
| 53 | + | ret := _mock.Called(ctx, repo, objects, old, new) | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for FirstDifferentEntry") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 string | |
| 60 | + | var r1 error | |
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) (string, error)); ok { | |
| 62 | + | return returnFunc(ctx, repo, objects, old, new) | |
| 63 | + | } | |
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) string); ok { | |
| 65 | + | r0 = returnFunc(ctx, repo, objects, old, new) | |
| 66 | + | } else { | |
| 67 | + | r0 = ret.Get(0).(string) | |
| 68 | + | } | |
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) error); ok { | |
| 70 | + | r1 = returnFunc(ctx, repo, objects, old, new) | |
| 71 | + | } else { | |
| 72 | + | r1 = ret.Error(1) | |
| 73 | + | } | |
| 74 | + | return r0, r1 | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // PushTreeReader_FirstDifferentEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FirstDifferentEntry' | |
| 78 | + | type PushTreeReader_FirstDifferentEntry_Call struct { | |
| 79 | + | *mock.Call | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // FirstDifferentEntry is a helper method to define mock.On call | |
| 83 | + | // - ctx context.Context | |
| 84 | + | // - repo domain.RepoName | |
| 85 | + | // - objects ports.PushObjects | |
| 86 | + | // - old domain.ObjectID | |
| 87 | + | // - new domain.ObjectID | |
| 88 | + | func (_e *PushTreeReader_Expecter) FirstDifferentEntry(ctx any, repo any, objects any, old any, new any) *PushTreeReader_FirstDifferentEntry_Call { | |
| 89 | + | return &PushTreeReader_FirstDifferentEntry_Call{Call: _e.mock.On("FirstDifferentEntry", ctx, repo, objects, old, new)} | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) Run(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID)) *PushTreeReader_FirstDifferentEntry_Call { | |
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 94 | + | var arg0 context.Context | |
| 95 | + | if args[0] != nil { | |
| 96 | + | arg0 = args[0].(context.Context) | |
| 97 | + | } | |
| 98 | + | var arg1 domain.RepoName | |
| 99 | + | if args[1] != nil { | |
| 100 | + | arg1 = args[1].(domain.RepoName) | |
| 101 | + | } | |
| 102 | + | var arg2 ports.PushObjects | |
| 103 | + | if args[2] != nil { | |
| 104 | + | arg2 = args[2].(ports.PushObjects) | |
| 105 | + | } | |
| 106 | + | var arg3 domain.ObjectID | |
| 107 | + | if args[3] != nil { | |
| 108 | + | arg3 = args[3].(domain.ObjectID) | |
| 109 | + | } | |
| 110 | + | var arg4 domain.ObjectID | |
| 111 | + | if args[4] != nil { | |
| 112 | + | arg4 = args[4].(domain.ObjectID) | |
| 113 | + | } | |
| 114 | + | run( | |
| 115 | + | arg0, | |
| 116 | + | arg1, | |
| 117 | + | arg2, | |
| 118 | + | arg3, | |
| 119 | + | arg4, | |
| 120 | + | ) | |
| 121 | + | }) | |
| 122 | + | return _c | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) Return(name string, err error) *PushTreeReader_FirstDifferentEntry_Call { | |
| 126 | + | _c.Call.Return(name, err) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error)) *PushTreeReader_FirstDifferentEntry_Call { | |
| 131 | + | _c.Call.Return(run) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // PushSubtrees provides a mock function for the type PushTreeReader | |
| 136 | + | func (_mock *PushTreeReader) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |
| 137 | + | ret := _mock.Called(ctx, repo, updates, objects, path, bounds) | |
| 138 | + | ||
| 139 | + | if len(ret) == 0 { | |
| 140 | + | panic("no return value specified for PushSubtrees") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | var r0 domain.PushSubtrees | |
| 144 | + | var r1 error | |
| 145 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) (domain.PushSubtrees, error)); ok { | |
| 146 | + | return returnFunc(ctx, repo, updates, objects, path, bounds) | |
| 147 | + | } | |
| 148 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) domain.PushSubtrees); ok { | |
| 149 | + | r0 = returnFunc(ctx, repo, updates, objects, path, bounds) | |
| 150 | + | } else { | |
| 151 | + | r0 = ret.Get(0).(domain.PushSubtrees) | |
| 152 | + | } | |
| 153 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) error); ok { | |
| 154 | + | r1 = returnFunc(ctx, repo, updates, objects, path, bounds) | |
| 155 | + | } else { | |
| 156 | + | r1 = ret.Error(1) | |
| 157 | + | } | |
| 158 | + | return r0, r1 | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // PushTreeReader_PushSubtrees_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSubtrees' | |
| 162 | + | type PushTreeReader_PushSubtrees_Call struct { | |
| 163 | + | *mock.Call | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | // PushSubtrees is a helper method to define mock.On call | |
| 167 | + | // - ctx context.Context | |
| 168 | + | // - repo domain.RepoName | |
| 169 | + | // - updates []domain.RefUpdate | |
| 170 | + | // - objects ports.PushObjects | |
| 171 | + | // - path string | |
| 172 | + | // - bounds domain.PushWalkBounds | |
| 173 | + | func (_e *PushTreeReader_Expecter) PushSubtrees(ctx any, repo any, updates any, objects any, path any, bounds any) *PushTreeReader_PushSubtrees_Call { | |
| 174 | + | return &PushTreeReader_PushSubtrees_Call{Call: _e.mock.On("PushSubtrees", ctx, repo, updates, objects, path, bounds)} | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (_c *PushTreeReader_PushSubtrees_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds)) *PushTreeReader_PushSubtrees_Call { | |
| 178 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 179 | + | var arg0 context.Context | |
| 180 | + | if args[0] != nil { | |
| 181 | + | arg0 = args[0].(context.Context) | |
| 182 | + | } | |
| 183 | + | var arg1 domain.RepoName | |
| 184 | + | if args[1] != nil { | |
| 185 | + | arg1 = args[1].(domain.RepoName) | |
| 186 | + | } | |
| 187 | + | var arg2 []domain.RefUpdate | |
| 188 | + | if args[2] != nil { | |
| 189 | + | arg2 = args[2].([]domain.RefUpdate) | |
| 190 | + | } | |
| 191 | + | var arg3 ports.PushObjects | |
| 192 | + | if args[3] != nil { | |
| 193 | + | arg3 = args[3].(ports.PushObjects) | |
| 194 | + | } | |
| 195 | + | var arg4 string | |
| 196 | + | if args[4] != nil { | |
| 197 | + | arg4 = args[4].(string) | |
| 198 | + | } | |
| 199 | + | var arg5 domain.PushWalkBounds | |
| 200 | + | if args[5] != nil { | |
| 201 | + | arg5 = args[5].(domain.PushWalkBounds) | |
| 202 | + | } | |
| 203 | + | run( | |
| 204 | + | arg0, | |
| 205 | + | arg1, | |
| 206 | + | arg2, | |
| 207 | + | arg3, | |
| 208 | + | arg4, | |
| 209 | + | arg5, | |
| 210 | + | ) | |
| 211 | + | }) | |
| 212 | + | return _c | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (_c *PushTreeReader_PushSubtrees_Call) Return(pushSubtrees domain.PushSubtrees, err error) *PushTreeReader_PushSubtrees_Call { | |
| 216 | + | _c.Call.Return(pushSubtrees, err) | |
| 217 | + | return _c | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (_c *PushTreeReader_PushSubtrees_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)) *PushTreeReader_PushSubtrees_Call { | |
| 221 | + | _c.Call.Return(run) | |
| 222 | + | return _c | |
| 223 | + | } | |
| @@ -0,0 +1,223 @@ | |||||
| 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 | + | // NewPushTreeReader creates a new instance of PushTreeReader. 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 NewPushTreeReader(t interface { | |||
| 18 | + | mock.TestingT | |||
| 19 | + | Cleanup(func()) | |||
| 20 | + | }) *PushTreeReader { | |||
| 21 | + | if helper, ok := t.(interface{ Helper() }); ok { | |||
| 22 | + | helper.Helper() | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | mock := &PushTreeReader{} | |||
| 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 | + | // PushTreeReader is an autogenerated mock type for the PushTreeReader type | |||
| 39 | + | type PushTreeReader struct { | |||
| 40 | + | mock.Mock | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type PushTreeReader_Expecter struct { | |||
| 44 | + | mock *mock.Mock | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func (_m *PushTreeReader) EXPECT() *PushTreeReader_Expecter { | |||
| 48 | + | return &PushTreeReader_Expecter{mock: &_m.Mock} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // FirstDifferentEntry provides a mock function for the type PushTreeReader | |||
| 52 | + | func (_mock *PushTreeReader) FirstDifferentEntry(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error) { | |||
| 53 | + | ret := _mock.Called(ctx, repo, objects, old, new) | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for FirstDifferentEntry") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 string | |||
| 60 | + | var r1 error | |||
| 61 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) (string, error)); ok { | |||
| 62 | + | return returnFunc(ctx, repo, objects, old, new) | |||
| 63 | + | } | |||
| 64 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) string); ok { | |||
| 65 | + | r0 = returnFunc(ctx, repo, objects, old, new) | |||
| 66 | + | } else { | |||
| 67 | + | r0 = ret.Get(0).(string) | |||
| 68 | + | } | |||
| 69 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, ports.PushObjects, domain.ObjectID, domain.ObjectID) error); ok { | |||
| 70 | + | r1 = returnFunc(ctx, repo, objects, old, new) | |||
| 71 | + | } else { | |||
| 72 | + | r1 = ret.Error(1) | |||
| 73 | + | } | |||
| 74 | + | return r0, r1 | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // PushTreeReader_FirstDifferentEntry_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FirstDifferentEntry' | |||
| 78 | + | type PushTreeReader_FirstDifferentEntry_Call struct { | |||
| 79 | + | *mock.Call | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // FirstDifferentEntry is a helper method to define mock.On call | |||
| 83 | + | // - ctx context.Context | |||
| 84 | + | // - repo domain.RepoName | |||
| 85 | + | // - objects ports.PushObjects | |||
| 86 | + | // - old domain.ObjectID | |||
| 87 | + | // - new domain.ObjectID | |||
| 88 | + | func (_e *PushTreeReader_Expecter) FirstDifferentEntry(ctx any, repo any, objects any, old any, new any) *PushTreeReader_FirstDifferentEntry_Call { | |||
| 89 | + | return &PushTreeReader_FirstDifferentEntry_Call{Call: _e.mock.On("FirstDifferentEntry", ctx, repo, objects, old, new)} | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) Run(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID)) *PushTreeReader_FirstDifferentEntry_Call { | |||
| 93 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 94 | + | var arg0 context.Context | |||
| 95 | + | if args[0] != nil { | |||
| 96 | + | arg0 = args[0].(context.Context) | |||
| 97 | + | } | |||
| 98 | + | var arg1 domain.RepoName | |||
| 99 | + | if args[1] != nil { | |||
| 100 | + | arg1 = args[1].(domain.RepoName) | |||
| 101 | + | } | |||
| 102 | + | var arg2 ports.PushObjects | |||
| 103 | + | if args[2] != nil { | |||
| 104 | + | arg2 = args[2].(ports.PushObjects) | |||
| 105 | + | } | |||
| 106 | + | var arg3 domain.ObjectID | |||
| 107 | + | if args[3] != nil { | |||
| 108 | + | arg3 = args[3].(domain.ObjectID) | |||
| 109 | + | } | |||
| 110 | + | var arg4 domain.ObjectID | |||
| 111 | + | if args[4] != nil { | |||
| 112 | + | arg4 = args[4].(domain.ObjectID) | |||
| 113 | + | } | |||
| 114 | + | run( | |||
| 115 | + | arg0, | |||
| 116 | + | arg1, | |||
| 117 | + | arg2, | |||
| 118 | + | arg3, | |||
| 119 | + | arg4, | |||
| 120 | + | ) | |||
| 121 | + | }) | |||
| 122 | + | return _c | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) Return(name string, err error) *PushTreeReader_FirstDifferentEntry_Call { | |||
| 126 | + | _c.Call.Return(name, err) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (_c *PushTreeReader_FirstDifferentEntry_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, objects ports.PushObjects, old domain.ObjectID, new domain.ObjectID) (string, error)) *PushTreeReader_FirstDifferentEntry_Call { | |||
| 131 | + | _c.Call.Return(run) | |||
| 132 | + | return _c | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // PushSubtrees provides a mock function for the type PushTreeReader | |||
| 136 | + | func (_mock *PushTreeReader) PushSubtrees(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |||
| 137 | + | ret := _mock.Called(ctx, repo, updates, objects, path, bounds) | |||
| 138 | + | ||||
| 139 | + | if len(ret) == 0 { | |||
| 140 | + | panic("no return value specified for PushSubtrees") | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | var r0 domain.PushSubtrees | |||
| 144 | + | var r1 error | |||
| 145 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) (domain.PushSubtrees, error)); ok { | |||
| 146 | + | return returnFunc(ctx, repo, updates, objects, path, bounds) | |||
| 147 | + | } | |||
| 148 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) domain.PushSubtrees); ok { | |||
| 149 | + | r0 = returnFunc(ctx, repo, updates, objects, path, bounds) | |||
| 150 | + | } else { | |||
| 151 | + | r0 = ret.Get(0).(domain.PushSubtrees) | |||
| 152 | + | } | |||
| 153 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.RefUpdate, ports.PushObjects, string, domain.PushWalkBounds) error); ok { | |||
| 154 | + | r1 = returnFunc(ctx, repo, updates, objects, path, bounds) | |||
| 155 | + | } else { | |||
| 156 | + | r1 = ret.Error(1) | |||
| 157 | + | } | |||
| 158 | + | return r0, r1 | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // PushTreeReader_PushSubtrees_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PushSubtrees' | |||
| 162 | + | type PushTreeReader_PushSubtrees_Call struct { | |||
| 163 | + | *mock.Call | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | // PushSubtrees is a helper method to define mock.On call | |||
| 167 | + | // - ctx context.Context | |||
| 168 | + | // - repo domain.RepoName | |||
| 169 | + | // - updates []domain.RefUpdate | |||
| 170 | + | // - objects ports.PushObjects | |||
| 171 | + | // - path string | |||
| 172 | + | // - bounds domain.PushWalkBounds | |||
| 173 | + | func (_e *PushTreeReader_Expecter) PushSubtrees(ctx any, repo any, updates any, objects any, path any, bounds any) *PushTreeReader_PushSubtrees_Call { | |||
| 174 | + | return &PushTreeReader_PushSubtrees_Call{Call: _e.mock.On("PushSubtrees", ctx, repo, updates, objects, path, bounds)} | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func (_c *PushTreeReader_PushSubtrees_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds)) *PushTreeReader_PushSubtrees_Call { | |||
| 178 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 179 | + | var arg0 context.Context | |||
| 180 | + | if args[0] != nil { | |||
| 181 | + | arg0 = args[0].(context.Context) | |||
| 182 | + | } | |||
| 183 | + | var arg1 domain.RepoName | |||
| 184 | + | if args[1] != nil { | |||
| 185 | + | arg1 = args[1].(domain.RepoName) | |||
| 186 | + | } | |||
| 187 | + | var arg2 []domain.RefUpdate | |||
| 188 | + | if args[2] != nil { | |||
| 189 | + | arg2 = args[2].([]domain.RefUpdate) | |||
| 190 | + | } | |||
| 191 | + | var arg3 ports.PushObjects | |||
| 192 | + | if args[3] != nil { | |||
| 193 | + | arg3 = args[3].(ports.PushObjects) | |||
| 194 | + | } | |||
| 195 | + | var arg4 string | |||
| 196 | + | if args[4] != nil { | |||
| 197 | + | arg4 = args[4].(string) | |||
| 198 | + | } | |||
| 199 | + | var arg5 domain.PushWalkBounds | |||
| 200 | + | if args[5] != nil { | |||
| 201 | + | arg5 = args[5].(domain.PushWalkBounds) | |||
| 202 | + | } | |||
| 203 | + | run( | |||
| 204 | + | arg0, | |||
| 205 | + | arg1, | |||
| 206 | + | arg2, | |||
| 207 | + | arg3, | |||
| 208 | + | arg4, | |||
| 209 | + | arg5, | |||
| 210 | + | ) | |||
| 211 | + | }) | |||
| 212 | + | return _c | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (_c *PushTreeReader_PushSubtrees_Call) Return(pushSubtrees domain.PushSubtrees, err error) *PushTreeReader_PushSubtrees_Call { | |||
| 216 | + | _c.Call.Return(pushSubtrees, err) | |||
| 217 | + | return _c | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | func (_c *PushTreeReader_PushSubtrees_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error)) *PushTreeReader_PushSubtrees_Call { | |||
| 221 | + | _c.Call.Return(run) | |||
| 222 | + | return _c | |||
| 223 | + | } | |||
D internal/ports/mocks/WorkflowGuard.go +0 -127
| @@ -1,127 +0,0 @@ | |||
| 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 | - | } | |
| @@ -1,127 +0,0 @@ | |||||
| 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/usecase/pushes.go +30 -14
| @@ -26,7 +26,7 @@ type Pushes struct { | |||
| 26 | 26 | auth ports.Revalidator | |
| 27 | 27 | fresh ports.RepoFreshener // nil: the reader needs no telling | |
| 28 | 28 | ci ports.WorkflowTrigger // nil: no CI | |
| 29 | - | wf ports.WorkflowGuard // nil: pushes by job tokens are refused | |
| 29 | + | trees ports.PushTreeReader // nil: pushes by job tokens are refused | |
| 30 | 30 | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | |
| 31 | 31 | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | |
| 32 | 32 | } | |
| @@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes { | |||
| 73 | 73 | return u | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | - | // WithWorkflowGuard lets Check tell whether a push changes workflow files, which | |
| 76 | + | // WithPushTreeReader lets Check tell whether a push changes workflow files, which | |
| 77 | 77 | // a push by a job token may not do. Without it a push by a job token is | |
| 78 | 78 | // refused, as it cannot be judged. Call it before the first push; it returns u. | |
| 79 | - | func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes { | |
| 80 | - | u.wf = g | |
| 79 | + | func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes { | |
| 80 | + | u.trees = g | |
| 81 | 81 | return u | |
| 82 | 82 | } | |
| 83 | 83 | ||
| @@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||
| 199 | 199 | const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission" | |
| 200 | 200 | ||
| 201 | 201 | func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error { | |
| 202 | - | if u.wf == nil { | |
| 202 | + | if u.trees == nil { | |
| 203 | 203 | return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected) | |
| 204 | 204 | } | |
| 205 | - | path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects) | |
| 206 | - | switch { | |
| 207 | - | case errors.Is(err, domain.ErrInvalid): | |
| 208 | - | // A push the guard cannot judge within its bounds: refused, with the reason. | |
| 205 | + | t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) | |
| 206 | + | if err != nil { | |
| 207 | + | return workflowReadError(name, err) | |
| 208 | + | } | |
| 209 | + | c, found, err := domain.WorkflowChangeOf(updates, t) | |
| 210 | + | if err != nil { | |
| 209 | 211 | return fmt.Errorf("%w: %v", domain.ErrRejected, err) | |
| 210 | - | case err != nil: | |
| 211 | - | return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err) | |
| 212 | - | case path != "": | |
| 213 | - | return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path) | |
| 214 | 212 | } | |
| 215 | - | return nil | |
| 213 | + | if !found { | |
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New) | |
| 217 | + | if err != nil { | |
| 218 | + | return workflowReadError(name, err) | |
| 219 | + | } | |
| 220 | + | path := domain.WorkflowDir | |
| 221 | + | if entry != "" { | |
| 222 | + | path += "/" + entry | |
| 223 | + | } | |
| 224 | + | return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func workflowReadError(name domain.RepoName, err error) error { | |
| 228 | + | if errors.Is(err, domain.ErrInvalid) { | |
| 229 | + | return fmt.Errorf("%w: %v", domain.ErrRejected, err) | |
| 230 | + | } | |
| 231 | + | return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err) | |
| 216 | 232 | } | |
| 217 | 233 | ||
| 218 | 234 | // Record is the post-receive step of an accepted push: it sets pushed_at and active_at, | |
| @@ -26,7 +26,7 @@ type Pushes struct { | |||||
| 26 | auth ports.Revalidator | 26 | auth ports.Revalidator | ||
| 27 | fresh ports.RepoFreshener // nil: the reader needs no telling | 27 | fresh ports.RepoFreshener // nil: the reader needs no telling | ||
| 28 | ci ports.WorkflowTrigger // nil: no CI | 28 | ci ports.WorkflowTrigger // nil: no CI | ||
| 29 | - | wf ports.WorkflowGuard // nil: pushes by job tokens are refused | 29 | + | trees ports.PushTreeReader // nil: pushes by job tokens are refused |
| 30 | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | 30 | tips ports.CIRefTipStore // nil: who pushes a ref is not recorded | ||
| 31 | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | 31 | mergeab ports.MergeabilityTrigger // nil: mergeability is not tracked | ||
| 32 | } | 32 | } | ||
| @@ -73,11 +73,11 @@ func (u *Pushes) WithRefTips(t ports.CIRefTipStore) *Pushes { | |||||
| 73 | return u | 73 | return u | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | - | // WithWorkflowGuard lets Check tell whether a push changes workflow files, which | 76 | + | // WithPushTreeReader lets Check tell whether a push changes workflow files, which |
| 77 | // a push by a job token may not do. Without it a push by a job token is | 77 | // a push by a job token may not do. Without it a push by a job token is | ||
| 78 | // refused, as it cannot be judged. Call it before the first push; it returns u. | 78 | // refused, as it cannot be judged. Call it before the first push; it returns u. | ||
| 79 | - | func (u *Pushes) WithWorkflowGuard(g ports.WorkflowGuard) *Pushes { | 79 | + | func (u *Pushes) WithPushTreeReader(g ports.PushTreeReader) *Pushes { |
| 80 | - | u.wf = g | 80 | + | u.trees = g |
| 81 | return u | 81 | return u | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| @@ -199,20 +199,36 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo | |||||
| 199 | const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission" | 199 | const workflowRefusal = "refusing to allow a GitHub App to create or update workflow `%s` without `workflows` permission" | ||
| 200 | 200 | ||||
| 201 | func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error { | 201 | func (u *Pushes) checkWorkflows(ctx context.Context, name domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) error { | ||
| 202 | - | if u.wf == nil { | 202 | + | if u.trees == nil { |
| 203 | return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected) | 203 | return fmt.Errorf("%w: the token of a job may not push here", domain.ErrRejected) | ||
| 204 | } | 204 | } | ||
| 205 | - | path, err := u.wf.ChangedWorkflow(ctx, name, updates, objects) | 205 | + | t, err := u.trees.PushSubtrees(ctx, name, updates, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds()) |
| 206 | - | switch { | 206 | + | if err != nil { |
| 207 | - | case errors.Is(err, domain.ErrInvalid): | 207 | + | return workflowReadError(name, err) |
| 208 | - | // A push the guard cannot judge within its bounds: refused, with the reason. | 208 | + | } |
| 209 | + | c, found, err := domain.WorkflowChangeOf(updates, t) | |||
| 210 | + | if err != nil { | |||
| 209 | return fmt.Errorf("%w: %v", domain.ErrRejected, err) | 211 | return fmt.Errorf("%w: %v", domain.ErrRejected, err) | ||
| 210 | - | case err != nil: | |||
| 211 | - | return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err) | |||
| 212 | - | case path != "": | |||
| 213 | - | return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path) | |||
| 214 | } | 212 | } | ||
| 215 | - | return nil | 213 | + | if !found { |
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | entry, err := u.trees.FirstDifferentEntry(ctx, name, objects, c.Old, c.New) | |||
| 217 | + | if err != nil { | |||
| 218 | + | return workflowReadError(name, err) | |||
| 219 | + | } | |||
| 220 | + | path := domain.WorkflowDir | |||
| 221 | + | if entry != "" { | |||
| 222 | + | path += "/" + entry | |||
| 223 | + | } | |||
| 224 | + | return fmt.Errorf("%w: "+workflowRefusal, domain.ErrRejected, path) | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func workflowReadError(name domain.RepoName, err error) error { | |||
| 228 | + | if errors.Is(err, domain.ErrInvalid) { | |||
| 229 | + | return fmt.Errorf("%w: %v", domain.ErrRejected, err) | |||
| 230 | + | } | |||
| 231 | + | return fmt.Errorf("find the workflows changed by a push to %s: %w", name, err) | |||
| 216 | } | 232 | } | ||
| 217 | 233 | ||||
| 218 | // Record is the post-receive step of an accepted push: it sets pushed_at and active_at, | 234 | // Record is the post-receive step of an accepted push: it sets pushed_at and active_at, | ||
M internal/usecase/pushes_test.go +79 -17
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | "errors" | |
| 8 | 8 | "fmt" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| @@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re | |||
| 440 | 441 | return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) | |
| 441 | 442 | } | |
| 442 | 443 | ||
| 443 | - | type fakeGuard struct { | |
| 444 | - | path string | |
| 445 | - | err error | |
| 446 | - | got []domain.RefUpdate | |
| 447 | - | objs ports.PushObjects | |
| 444 | + | type fakePushTrees struct { | |
| 445 | + | path string | |
| 446 | + | err error | |
| 447 | + | diffErr error | |
| 448 | + | got []domain.RefUpdate | |
| 449 | + | objs ports.PushObjects | |
| 450 | + | bounds domain.PushWalkBounds | |
| 451 | + | subtreePath string | |
| 452 | + | calls int | |
| 453 | + | old, new domain.ObjectID | |
| 454 | + | data *domain.PushSubtrees | |
| 455 | + | change bool | |
| 448 | 456 | } | |
| 449 | 457 | ||
| 450 | - | func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) { | |
| 451 | - | g.got, g.objs = ups, o | |
| 452 | - | return g.path, g.err | |
| 458 | + | func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { | |
| 459 | + | g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds | |
| 460 | + | if g.data != nil { | |
| 461 | + | return *g.data, g.err | |
| 462 | + | } | |
| 463 | + | t := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Subtrees: map[string]domain.Subtree{}} | |
| 464 | + | for _, up := range ups { | |
| 465 | + | t.Ends[up.Old] = domain.PushedEnd{Type: domain.ObjectCommit} | |
| 466 | + | t.Ends[up.New] = domain.PushedEnd{Type: domain.ObjectCommit} | |
| 467 | + | if g.path != "" || g.change { | |
| 468 | + | id, _ := domain.ParseObjectID(up.New) | |
| 469 | + | t.Subtrees[up.New] = domain.Subtree{Kind: domain.EntryDirectory, ID: id} | |
| 470 | + | } | |
| 471 | + | } | |
| 472 | + | return t, g.err | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func (g *fakePushTrees) FirstDifferentEntry(_ context.Context, _ domain.RepoName, _ ports.PushObjects, old, new domain.ObjectID) (string, error) { | |
| 476 | + | g.calls++ | |
| 477 | + | g.old, g.new = old, new | |
| 478 | + | return strings.TrimPrefix(g.path, domain.WorkflowDir+"/"), g.diffErr | |
| 453 | 479 | } | |
| 454 | 480 | ||
| 455 | 481 | // A push by the token of a job is judged on the workflows it changes, and by it | |
| @@ -464,10 +490,10 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||
| 464 | 490 | job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}}) | |
| 465 | 491 | readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp}) | |
| 466 | 492 | ||
| 467 | - | build := func(t *testing.T, g *fakeGuard) *pushFixture { | |
| 493 | + | build := func(t *testing.T, g *fakePushTrees) *pushFixture { | |
| 468 | 494 | f := newPushFixture(t) | |
| 469 | 495 | if g != nil { | |
| 470 | - | f.pushes.WithWorkflowGuard(g) | |
| 496 | + | f.pushes.WithPushTreeReader(g) | |
| 471 | 497 | } | |
| 472 | 498 | f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe() | |
| 473 | 499 | return f | |
| @@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||
| 475 | 501 | ||
| 476 | 502 | t.Run("no workflow touched", func(t *testing.T) { | |
| 477 | 503 | t.Parallel() | |
| 478 | - | g := &fakeGuard{} | |
| 504 | + | g := &fakePushTrees{} | |
| 479 | 505 | f := build(t, g) | |
| 480 | 506 | require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) | |
| 481 | 507 | assert.Equal(t, ups, g.got, "every ref is judged") | |
| 482 | 508 | assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine") | |
| 509 | + | assert.Equal(t, domain.WorkflowDir, g.subtreePath) | |
| 510 | + | assert.Equal(t, domain.DefaultPushWalkBounds(), g.bounds) | |
| 511 | + | assert.Zero(t, g.calls) | |
| 483 | 512 | }) | |
| 484 | 513 | t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) { | |
| 485 | 514 | t.Parallel() | |
| 486 | - | f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"}) | |
| 515 | + | f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml"}) | |
| 487 | 516 | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 488 | 517 | require.ErrorIs(t, err, domain.ErrRejected) | |
| 489 | 518 | assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission") | |
| 490 | 519 | }) | |
| 520 | + | t.Run("empty differing entry reports the directory and uses the domain pair once", func(t *testing.T) { | |
| 521 | + | g := &fakePushTrees{change: true} | |
| 522 | + | f := build(t, g) | |
| 523 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 524 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 525 | + | assert.Contains(t, err.Error(), "workflow `.github/workflows`") | |
| 526 | + | assert.Equal(t, 1, g.calls) | |
| 527 | + | assert.True(t, g.old.IsZero()) | |
| 528 | + | assert.Equal(t, oidB, g.new.String()) | |
| 529 | + | }) | |
| 530 | + | t.Run("diff invalid is rejected with reason", func(t *testing.T) { | |
| 531 | + | g := &fakePushTrees{change: true, diffErr: fmt.Errorf("%w: path too long", domain.ErrInvalid)} | |
| 532 | + | f := build(t, g) | |
| 533 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 534 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 535 | + | assert.Contains(t, err.Error(), "path too long") | |
| 536 | + | assert.Equal(t, 1, g.calls) | |
| 537 | + | }) | |
| 538 | + | t.Run("diff I/O failure retains its class", func(t *testing.T) { | |
| 539 | + | boom := errors.New("diff broke") | |
| 540 | + | g := &fakePushTrees{change: true, diffErr: boom} | |
| 541 | + | f := build(t, g) | |
| 542 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 543 | + | require.ErrorIs(t, err, boom) | |
| 544 | + | assert.NotErrorIs(t, err, domain.ErrRejected) | |
| 545 | + | }) | |
| 546 | + | t.Run("missing endpoint is rejected before diff", func(t *testing.T) { | |
| 547 | + | g := &fakePushTrees{data: &domain.PushSubtrees{}} | |
| 548 | + | f := build(t, g) | |
| 549 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 550 | + | require.ErrorIs(t, err, domain.ErrRejected) | |
| 551 | + | assert.Zero(t, g.calls) | |
| 552 | + | }) | |
| 491 | 553 | t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) { | |
| 492 | 554 | t.Parallel() | |
| 493 | - | f := build(t, &fakeGuard{err: errors.New("git broke")}) | |
| 555 | + | f := build(t, &fakePushTrees{err: errors.New("git broke")}) | |
| 494 | 556 | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 495 | 557 | require.Error(t, err) | |
| 496 | 558 | assert.NotErrorIs(t, err, domain.ErrRejected) | |
| 497 | 559 | }) | |
| 498 | 560 | t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) { | |
| 499 | 561 | t.Parallel() | |
| 500 | - | f := build(t, &fakeGuard{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) | |
| 562 | + | f := build(t, &fakePushTrees{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) | |
| 501 | 563 | err := f.pushes.Check(ctx, job, n, ups, objs) | |
| 502 | 564 | require.ErrorIs(t, err, domain.ErrRejected) | |
| 503 | 565 | assert.Contains(t, err.Error(), "more than 10000 commits") | |
| @@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||
| 509 | 571 | }) | |
| 510 | 572 | t.Run("a job token without contents: write cannot push at all", func(t *testing.T) { | |
| 511 | 573 | t.Parallel() | |
| 512 | - | f := build(t, &fakeGuard{}) | |
| 574 | + | f := build(t, &fakePushTrees{}) | |
| 513 | 575 | assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) | |
| 514 | 576 | }) | |
| 515 | 577 | t.Run("the owner is not asked", func(t *testing.T) { | |
| 516 | 578 | t.Parallel() | |
| 517 | - | g := &fakeGuard{path: ".github/workflows/ci.yml"} | |
| 579 | + | g := &fakePushTrees{path: ".github/workflows/ci.yml"} | |
| 518 | 580 | f := build(t, g) | |
| 519 | 581 | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) | |
| 520 | 582 | assert.Nil(t, g.got) | |
| 521 | 583 | }) | |
| 522 | 584 | t.Run("a job token never creates a repository on push", func(t *testing.T) { | |
| 523 | 585 | t.Parallel() | |
| 524 | - | f := build(t, &fakeGuard{}) | |
| 586 | + | f := build(t, &fakePushTrees{}) | |
| 525 | 587 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | |
| 526 | 588 | assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound) | |
| 527 | 589 | }) | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "context" | 6 | "context" | ||
| 7 | "errors" | 7 | "errors" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "strings" | |||
| 9 | "testing" | 10 | "testing" | ||
| 10 | "time" | 11 | "time" | ||
| 11 | 12 | ||||
| @@ -440,16 +441,41 @@ func (f *pushFixture) check(ctx context.Context, p domain.Principal, n domain.Re | |||||
| 440 | return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) | 441 | return f.pushes.Check(ctx, p, n, ups, ports.PushObjects{}) | ||
| 441 | } | 442 | } | ||
| 442 | 443 | ||||
| 443 | - | type fakeGuard struct { | 444 | + | type fakePushTrees struct { |
| 444 | - | path string | 445 | + | path string |
| 445 | - | err error | 446 | + | err error |
| 446 | - | got []domain.RefUpdate | 447 | + | diffErr error |
| 447 | - | objs ports.PushObjects | 448 | + | got []domain.RefUpdate |
| 449 | + | objs ports.PushObjects | |||
| 450 | + | bounds domain.PushWalkBounds | |||
| 451 | + | subtreePath string | |||
| 452 | + | calls int | |||
| 453 | + | old, new domain.ObjectID | |||
| 454 | + | data *domain.PushSubtrees | |||
| 455 | + | change bool | |||
| 448 | } | 456 | } | ||
| 449 | 457 | ||||
| 450 | - | func (g *fakeGuard) ChangedWorkflow(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects) (string, error) { | 458 | + | func (g *fakePushTrees) PushSubtrees(_ context.Context, _ domain.RepoName, ups []domain.RefUpdate, o ports.PushObjects, path string, bounds domain.PushWalkBounds) (domain.PushSubtrees, error) { |
| 451 | - | g.got, g.objs = ups, o | 459 | + | g.got, g.objs, g.subtreePath, g.bounds = ups, o, path, bounds |
| 452 | - | return g.path, g.err | 460 | + | if g.data != nil { |
| 461 | + | return *g.data, g.err | |||
| 462 | + | } | |||
| 463 | + | t := domain.PushSubtrees{Ends: map[string]domain.PushedEnd{}, Subtrees: map[string]domain.Subtree{}} | |||
| 464 | + | for _, up := range ups { | |||
| 465 | + | t.Ends[up.Old] = domain.PushedEnd{Type: domain.ObjectCommit} | |||
| 466 | + | t.Ends[up.New] = domain.PushedEnd{Type: domain.ObjectCommit} | |||
| 467 | + | if g.path != "" || g.change { | |||
| 468 | + | id, _ := domain.ParseObjectID(up.New) | |||
| 469 | + | t.Subtrees[up.New] = domain.Subtree{Kind: domain.EntryDirectory, ID: id} | |||
| 470 | + | } | |||
| 471 | + | } | |||
| 472 | + | return t, g.err | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func (g *fakePushTrees) FirstDifferentEntry(_ context.Context, _ domain.RepoName, _ ports.PushObjects, old, new domain.ObjectID) (string, error) { | |||
| 476 | + | g.calls++ | |||
| 477 | + | g.old, g.new = old, new | |||
| 478 | + | return strings.TrimPrefix(g.path, domain.WorkflowDir+"/"), g.diffErr | |||
| 453 | } | 479 | } | ||
| 454 | 480 | ||||
| 455 | // A push by the token of a job is judged on the workflows it changes, and by it | 481 | // A push by the token of a job is judged on the workflows it changes, and by it | ||
| @@ -464,10 +490,10 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||||
| 464 | job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}}) | 490 | job := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j1", Repo: n, ExpiresAt: exp, Permissions: map[string]string{"contents": "write"}}) | ||
| 465 | readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp}) | 491 | readOnlyJob := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j2", Repo: n, ExpiresAt: exp}) | ||
| 466 | 492 | ||||
| 467 | - | build := func(t *testing.T, g *fakeGuard) *pushFixture { | 493 | + | build := func(t *testing.T, g *fakePushTrees) *pushFixture { |
| 468 | f := newPushFixture(t) | 494 | f := newPushFixture(t) | ||
| 469 | if g != nil { | 495 | if g != nil { | ||
| 470 | - | f.pushes.WithWorkflowGuard(g) | 496 | + | f.pushes.WithPushTreeReader(g) |
| 471 | } | 497 | } | ||
| 472 | f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe() | 498 | f.cirepo.EXPECT().GetRepoToken(ctx, mock.Anything).Return(domain.CIRepoToken{ID: "j1", ExpiresAt: exp}, nil).Maybe() | ||
| 473 | return f | 499 | return f | ||
| @@ -475,29 +501,65 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||||
| 475 | 501 | ||||
| 476 | t.Run("no workflow touched", func(t *testing.T) { | 502 | t.Run("no workflow touched", func(t *testing.T) { | ||
| 477 | t.Parallel() | 503 | t.Parallel() | ||
| 478 | - | g := &fakeGuard{} | 504 | + | g := &fakePushTrees{} |
| 479 | f := build(t, g) | 505 | f := build(t, g) | ||
| 480 | require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) | 506 | require.NoError(t, f.pushes.Check(ctx, job, n, ups, objs)) | ||
| 481 | assert.Equal(t, ups, g.got, "every ref is judged") | 507 | assert.Equal(t, ups, g.got, "every ref is judged") | ||
| 482 | assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine") | 508 | assert.Equal(t, objs, g.objs, "from the objects of the push in quarantine") | ||
| 509 | + | assert.Equal(t, domain.WorkflowDir, g.subtreePath) | |||
| 510 | + | assert.Equal(t, domain.DefaultPushWalkBounds(), g.bounds) | |||
| 511 | + | assert.Zero(t, g.calls) | |||
| 483 | }) | 512 | }) | ||
| 484 | t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) { | 513 | t.Run("a workflow touched refuses the whole push with GitHub's text", func(t *testing.T) { | ||
| 485 | t.Parallel() | 514 | t.Parallel() | ||
| 486 | - | f := build(t, &fakeGuard{path: ".github/workflows/ci.yml"}) | 515 | + | f := build(t, &fakePushTrees{path: ".github/workflows/ci.yml"}) |
| 487 | err := f.pushes.Check(ctx, job, n, ups, objs) | 516 | err := f.pushes.Check(ctx, job, n, ups, objs) | ||
| 488 | require.ErrorIs(t, err, domain.ErrRejected) | 517 | require.ErrorIs(t, err, domain.ErrRejected) | ||
| 489 | assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission") | 518 | assert.Contains(t, err.Error(), "refusing to allow a GitHub App to create or update workflow `.github/workflows/ci.yml` without `workflows` permission") | ||
| 490 | }) | 519 | }) | ||
| 520 | + | t.Run("empty differing entry reports the directory and uses the domain pair once", func(t *testing.T) { | |||
| 521 | + | g := &fakePushTrees{change: true} | |||
| 522 | + | f := build(t, g) | |||
| 523 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 524 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 525 | + | assert.Contains(t, err.Error(), "workflow `.github/workflows`") | |||
| 526 | + | assert.Equal(t, 1, g.calls) | |||
| 527 | + | assert.True(t, g.old.IsZero()) | |||
| 528 | + | assert.Equal(t, oidB, g.new.String()) | |||
| 529 | + | }) | |||
| 530 | + | t.Run("diff invalid is rejected with reason", func(t *testing.T) { | |||
| 531 | + | g := &fakePushTrees{change: true, diffErr: fmt.Errorf("%w: path too long", domain.ErrInvalid)} | |||
| 532 | + | f := build(t, g) | |||
| 533 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 534 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 535 | + | assert.Contains(t, err.Error(), "path too long") | |||
| 536 | + | assert.Equal(t, 1, g.calls) | |||
| 537 | + | }) | |||
| 538 | + | t.Run("diff I/O failure retains its class", func(t *testing.T) { | |||
| 539 | + | boom := errors.New("diff broke") | |||
| 540 | + | g := &fakePushTrees{change: true, diffErr: boom} | |||
| 541 | + | f := build(t, g) | |||
| 542 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 543 | + | require.ErrorIs(t, err, boom) | |||
| 544 | + | assert.NotErrorIs(t, err, domain.ErrRejected) | |||
| 545 | + | }) | |||
| 546 | + | t.Run("missing endpoint is rejected before diff", func(t *testing.T) { | |||
| 547 | + | g := &fakePushTrees{data: &domain.PushSubtrees{}} | |||
| 548 | + | f := build(t, g) | |||
| 549 | + | err := f.pushes.Check(ctx, job, n, ups, objs) | |||
| 550 | + | require.ErrorIs(t, err, domain.ErrRejected) | |||
| 551 | + | assert.Zero(t, g.calls) | |||
| 552 | + | }) | |||
| 491 | t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) { | 553 | t.Run("a failure to tell refuses it, not as a policy", func(t *testing.T) { | ||
| 492 | t.Parallel() | 554 | t.Parallel() | ||
| 493 | - | f := build(t, &fakeGuard{err: errors.New("git broke")}) | 555 | + | f := build(t, &fakePushTrees{err: errors.New("git broke")}) |
| 494 | err := f.pushes.Check(ctx, job, n, ups, objs) | 556 | err := f.pushes.Check(ctx, job, n, ups, objs) | ||
| 495 | require.Error(t, err) | 557 | require.Error(t, err) | ||
| 496 | assert.NotErrorIs(t, err, domain.ErrRejected) | 558 | assert.NotErrorIs(t, err, domain.ErrRejected) | ||
| 497 | }) | 559 | }) | ||
| 498 | t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) { | 560 | t.Run("a push beyond the guard's bounds is refused as a policy, with the reason", func(t *testing.T) { | ||
| 499 | t.Parallel() | 561 | t.Parallel() | ||
| 500 | - | f := build(t, &fakeGuard{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) | 562 | + | f := build(t, &fakePushTrees{err: fmt.Errorf("%w: the push brings more than 10000 commits", domain.ErrInvalid)}) |
| 501 | err := f.pushes.Check(ctx, job, n, ups, objs) | 563 | err := f.pushes.Check(ctx, job, n, ups, objs) | ||
| 502 | require.ErrorIs(t, err, domain.ErrRejected) | 564 | require.ErrorIs(t, err, domain.ErrRejected) | ||
| 503 | assert.Contains(t, err.Error(), "more than 10000 commits") | 565 | assert.Contains(t, err.Error(), "more than 10000 commits") | ||
| @@ -509,19 +571,19 @@ func TestPushesCheckRefusesWorkflowChangesOfJobTokens(t *testing.T) { | |||||
| 509 | }) | 571 | }) | ||
| 510 | t.Run("a job token without contents: write cannot push at all", func(t *testing.T) { | 572 | t.Run("a job token without contents: write cannot push at all", func(t *testing.T) { | ||
| 511 | t.Parallel() | 573 | t.Parallel() | ||
| 512 | - | f := build(t, &fakeGuard{}) | 574 | + | f := build(t, &fakePushTrees{}) |
| 513 | assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) | 575 | assert.ErrorIs(t, f.pushes.Check(ctx, readOnlyJob, n, ups, objs), domain.ErrRejected) | ||
| 514 | }) | 576 | }) | ||
| 515 | t.Run("the owner is not asked", func(t *testing.T) { | 577 | t.Run("the owner is not asked", func(t *testing.T) { | ||
| 516 | t.Parallel() | 578 | t.Parallel() | ||
| 517 | - | g := &fakeGuard{path: ".github/workflows/ci.yml"} | 579 | + | g := &fakePushTrees{path: ".github/workflows/ci.yml"} |
| 518 | f := build(t, g) | 580 | f := build(t, g) | ||
| 519 | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) | 581 | require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, ups, objs)) | ||
| 520 | assert.Nil(t, g.got) | 582 | assert.Nil(t, g.got) | ||
| 521 | }) | 583 | }) | ||
| 522 | t.Run("a job token never creates a repository on push", func(t *testing.T) { | 584 | t.Run("a job token never creates a repository on push", func(t *testing.T) { | ||
| 523 | t.Parallel() | 585 | t.Parallel() | ||
| 524 | - | f := build(t, &fakeGuard{}) | 586 | + | f := build(t, &fakePushTrees{}) |
| 525 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | 587 | f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound) | ||
| 526 | assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound) | 588 | assert.ErrorIs(t, f.pushes.Open(ctx, job, n, true), domain.ErrNotFound) | ||
| 527 | }) | 589 | }) | ||