workflow_guard.go RawHistoryFolder

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}

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close