1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli
4
5import (
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
19var _ ports.WorkflowGuard = (*Git)(nil)
20
21// workflowsDir is where workflow files live, as a literal pathspec directory.
22const 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.
26const 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.
35const (
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 reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's
85func (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).
141func (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
193type 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.
201func (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.
220func 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).
254func (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.
279func 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).
293func (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.
320func 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.
342func (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.
402func 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}