v0.12.0 dccd128

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	"path/filepath"
11	"strings"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15)
16
17var _ ports.WorkflowGuard = (*Git)(nil)
18
19// workflowsDir is where workflow files live, as a literal pathspec directory.
20const workflowsDir = ".github/workflows/"
21
22// maxWorkflowPath bounds the one path the guard reads from git: a longer one
23// fails closed (the push is refused), whatever the trees hold.
24const maxWorkflowPath = 4096
25
26// ChangedWorkflow tells whether a push creates or changes a file under
27// .github/workflows/. For each update that does not delete its ref, the
28// directory .github/workflows of the old commit (none for a new ref) is
29// compared with the one of the new commit: the same tree id is the same
30// content, whatever it holds, so nothing is read; a different one, or one that
31// appears, is a change, and the first top-level entry that differs names it. A
32// new ref is thus compared with nothing: any workflow it carries counts, as on
33// GitHub. A directory that disappears is not a change (as a deleted ref is
34// not).
35//
36// What counts as the directory is exactly what the workflow loader
37// (CI.workflowFiles, on both git readers) lists: .github and workflows both
38// entries of mode 040000 and type tree. A path through a gitlink (160000), a
39// symbolic link (120000, which the loader does not follow) or a file is no
40// workflows, so the same tree id that is no workflows as a gitlink and becomes
41// workflows as a directory is a change, which the object id alone would not
42// tell (`cat-file` mode and type of "<commit>:.github/workflows").
43//
44// The comparison never walks the trees recursively: the trees of a push are the
45// pusher's, and a recursive diff of trees that share subtrees fans out into
46// more paths than the push has bytes (git builds the whole diff before it
47// prints a line, so reading it as a stream would not help). The shallow diff
48// that names the path reads one directory, as large as its tree object, as a
49// stream, up to its first path, and git is stopped and reaped then; a path
50// longer than maxWorkflowPath fails closed.
51//
52// Both ends are peeled first (`cat-file --batch-check` on `<oid>^{}`): a
53// commit or a tree is compared, a blob (a tag may point to one) holds no
54// workflows; a missing or malformed object fails closed. Git reads the objects
55// of a push still in quarantine through the environment of the hook
56// (ports.PushObjects), accepted only inside the repository's objects directory.
57//
58//hexcheck:ignore suspicious-business-logic-in-adapter reads the object ids and the path git prints for the compared trees; the rule that refuses is the use case's
59func (g *Git) ChangedWorkflow(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, objects ports.PushObjects) (string, error) {
60	dir, err := g.repoDir(repo)
61	if err != nil {
62		return "", err
63	}
64	extra, err := quarantineEnv(dir, objects)
65	if err != nil {
66		return "", err
67	}
68	kept := make([]domain.RefUpdate, 0, len(updates))
69	for _, up := range updates {
70		if !up.IsDelete() {
71			kept = append(kept, up)
72		}
73	}
74	if len(kept) == 0 {
75		return "", nil
76	}
77	types, err := g.batchCheck(ctx, dir, extra, kept, "^{}", "%(objecttype)")
78	if err != nil {
79		return "", err
80	}
81	trees, err := g.batchCheck(ctx, dir, extra, kept, ":"+strings.TrimSuffix(workflowsDir, "/"), "%(objectmode) %(objectname) %(objecttype)")
82	if err != nil {
83		return "", err
84	}
85	for i := range kept {
86		if types[2*i+1] == "blob" {
87			continue // a blob holds no workflow, whatever it replaces
88		}
89		oldW, newW := workflowsOf(types[2*i], trees[2*i]), workflowsOf(types[2*i+1], trees[2*i+1])
90		if oldW == newW || newW == "" {
91			continue // the same directory, or none left
92		}
93		return g.firstChanged(ctx, dir, extra, oldW, newW)
94	}
95	return "", nil
96}
97
98// workflowsOf is the id of the tree that is the .github/workflows of a commit
99// or a tree, "" when there is none: the answer for "<it>:.github/workflows" must
100// be a tree of mode 040000, and the object peeled to a tree-ish, not a blob.
101func workflowsOf(objType, answer string) string {
102	if objType == "blob" {
103		return ""
104	}
105	mode, rest, _ := strings.Cut(answer, " ")
106	id, typ, _ := strings.Cut(rest, " ")
107	if mode != "040000" || typ != "tree" {
108		return ""
109	}
110	return id
111}
112
113// firstChanged names the first top-level entry of .github/workflows that
114// differs between two trees (oldID may be "": none, the empty tree).
115func (g *Git) firstChanged(ctx context.Context, dir string, extra []string, oldID, newID string) (string, error) {
116	from := oldID
117	if from == "" {
118		from = emptyTree
119	}
120	to := newID
121	args := readArgs(dir, "diff-tree", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, to)
122	var name string
123	err := g.pipeWith(ctx, extra, nil, args, func(r *bufio.Reader) error {
124		n, found, err := firstPath(r)
125		name = n
126		if err != nil || !found {
127			return err // no name: git's exit status is checked, so a failure refuses
128		}
129		return errStop // the first name is all that is needed: stop git
130	})
131	if err != nil {
132		return "", fmt.Errorf("changed workflows %s..%s: %w", from, to, err)
133	}
134	if name == "" {
135		return strings.TrimSuffix(workflowsDir, "/"), nil
136	}
137	return workflowsDir + name, nil
138}
139
140// firstPath reads the first NUL-terminated path of r; found is false at the end
141// of the output without one. A path longer than maxWorkflowPath is an error.
142func firstPath(r *bufio.Reader) (path string, found bool, err error) {
143	var b []byte
144	for {
145		c, err := r.ReadByte()
146		switch {
147		case err != nil:
148			return "", false, nil // the end of the output; a failing exit status is reported by pipe
149		case c == 0:
150			return string(b), true, nil
151		case len(b) >= maxWorkflowPath:
152			return "", false, fmt.Errorf("%w: a changed path is longer than %d bytes", domain.ErrInvalid, maxWorkflowPath)
153		}
154		b = append(b, c)
155	}
156}
157
158// batchCheck asks `cat-file --batch-check=<format>` about <oid><suffix> for the
159// old (unless the ref is created) and the new end of each update and returns
160// the answers in that order, two per update, "" for the old end of a created
161// ref. With suffix "^{}" an object that is missing, malformed or neither a
162// commit, a tree nor a blob is an error (the push is refused); with any other
163// suffix an answer "<input> missing" is kept as the answer.
164func (g *Git) batchCheck(ctx context.Context, dir string, extra []string, ups []domain.RefUpdate, suffix, format string) ([]string, error) {
165	var in strings.Builder
166	want := 0
167	for _, up := range ups {
168		for _, oid := range []string{up.Old, up.New} {
169			if oid == domain.ZeroOID {
170				continue
171			}
172			if _, err := domain.ParseObjectID(oid); err != nil {
173				return nil, fmt.Errorf("%w: %v", domain.ErrInvalid, err)
174			}
175			in.WriteString(oid + suffix + "\n")
176			want++
177		}
178	}
179	got := make([]string, 0, want)
180	args := readArgs(dir, "cat-file", "--batch-check="+format)
181	err := g.pipeWith(ctx, extra, strings.NewReader(in.String()), args, func(r *bufio.Reader) error {
182		for range want {
183			line, err := r.ReadSlice('\n')
184			if err != nil {
185				if errors.Is(err, bufio.ErrBufferFull) {
186					return fmt.Errorf("%w: unexpected cat-file output", domain.ErrInvalid)
187				}
188				return fmt.Errorf("read object answers: %w", err)
189			}
190			got = append(got, strings.TrimSuffix(string(line), "\n"))
191		}
192		return nil
193	})
194	if err != nil {
195		return nil, fmt.Errorf("objects of a push: %w", err)
196	}
197	out := make([]string, 0, 2*len(ups))
198	i := 0
199	for _, up := range ups {
200		for _, oid := range []string{up.Old, up.New} {
201			if oid == domain.ZeroOID {
202				out = append(out, "")
203				continue
204			}
205			if suffix == "^{}" {
206				switch got[i] {
207				case "commit", "tree", "blob":
208				default: // "<input> missing", or a type that cannot be compared
209					return nil, fmt.Errorf("%w: object %s of the push is missing or of an unexpected type", domain.ErrInvalid, oid)
210				}
211			}
212			out = append(out, got[i])
213			i++
214		}
215	}
216	return out, nil
217}
218
219// quarantineEnv returns the environment that makes git read the objects of a
220// push in quarantine. Each directory must exist and, once symbolic links are
221// resolved, be inside the objects directory of the repository dir (itself
222// resolved): a hook request cannot send git to read anything else. The resolved
223// paths are the ones given to git.
224func quarantineEnv(repoDir string, o ports.PushObjects) ([]string, error) {
225	if o.Dir == "" && o.Alternates == "" {
226		return nil, nil
227	}
228	root, err := filepath.EvalSymlinks(filepath.Join(repoDir, "objects"))
229	if err != nil {
230		return nil, fmt.Errorf("resolve the objects directory of %s: %w", repoDir, err)
231	}
232	inside := func(p string) (string, bool) {
233		if !filepath.IsAbs(p) {
234			return "", false
235		}
236		real, err := filepath.EvalSymlinks(p)
237		if err != nil {
238			return "", false
239		}
240		rel, err := filepath.Rel(root, real)
241		return real, err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
242	}
243	dir, ok := inside(o.Dir)
244	if o.Dir == "" || !ok {
245		return nil, fmt.Errorf("%w: the objects directory of the push is outside the repository", domain.ErrInvalid)
246	}
247	env := []string{"GIT_OBJECT_DIRECTORY=" + dir}
248	if o.Alternates != "" {
249		var alts []string
250		for _, a := range filepath.SplitList(o.Alternates) {
251			real, ok := inside(a)
252			if !ok {
253				return nil, fmt.Errorf("%w: an alternate objects directory of the push is outside the repository", domain.ErrInvalid)
254			}
255			alts = append(alts, real)
256		}
257		env = append(env, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+strings.Join(alts, string(filepath.ListSeparator)))
258	}
259	return env, nil
260}

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