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}