1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "context"
7 "encoding/hex"
8 "fmt"
9 "os"
10 "os/exec"
11 "path/filepath"
12 "runtime"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19
20 "bnema.dev/ermite/internal/adapters/gitcli"
21 "bnema.dev/ermite/internal/domain"
22 "bnema.dev/ermite/internal/ports"
23 "bnema.dev/ermite/internal/readertest"
24)
25
26func TestChangedWorkflow(t *testing.T) {
27 g, data := newGit(t)
28 ctx := context.Background()
29 f := readertest.NewFixture(t, data, "proj")
30 t.Cleanup(func() { _ = g.Close(ctx) })
31 f.Write("README", "r\n")
32 base := f.Commit("base")
33 f.Write(".github/workflows/ci.yml", "on: push\n")
34 withWF := f.Commit("add workflow")
35 f.Write("docs/x.md", "x\n")
36 docs := f.Commit("docs")
37 f.Write(".github/workflows/ci.yml", "on: [push]\n")
38 edited := f.Commit("edit workflow")
39 f.Write(".github/workflows/other.yml", "on: push\n")
40 two := f.Commit("a second workflow")
41 f.Remove(".github/workflows/ci.yml")
42 oneLeft := f.Commit("remove one of two")
43 f.Remove(".github/workflows/other.yml")
44 removed := f.Commit("remove the last workflow")
45 f.Write(".github/workflows-notes/a.yml", "x\n")
46 lookalike := f.Commit("a look-alike directory")
47 oid := func(s string) string { return s[:40] }
48
49 up := func(old, new string) domain.RefUpdate {
50 return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
51 }
52 changed := func(ups ...domain.RefUpdate) string {
53 p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
54 require.NoError(t, err)
55 return p
56 }
57
58 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(withWF))), "a file created")
59 assert.Equal(t, "", changed(up(oid(withWF), oid(docs))), "another file changed")
60 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(docs), oid(edited))), "a file modified")
61 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(two), oid(oneLeft))), "a file removed from a directory that stays is a change too")
62 assert.Equal(t, "", changed(up(oid(oneLeft), oid(removed))), "the directory removed with its last file is allowed")
63 assert.Equal(t, "", changed(up(oid(removed), oid(lookalike))), "only .github/workflows/ counts")
64 assert.Equal(t, ".github/workflows/ci.yml", changed(up(domain.ZeroOID, oid(withWF))), "a new ref carries the workflows of its commit")
65 assert.Equal(t, "", changed(up(domain.ZeroOID, oid(base))), "a new ref without workflows")
66 assert.Equal(t, "", changed(up(oid(withWF), domain.ZeroOID)), "a deleted ref changes nothing")
67 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, "", changed(), "no update")
69
70 _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
71 assert.Error(t, err, "an object git does not have is a failure, not a pass")
72}
73
74func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
75 g, data := newGit(t)
76 ctx := context.Background()
77 f := readertest.NewFixture(t, data, "proj")
78 t.Cleanup(func() { _ = g.Close(ctx) })
79 f.Write("README", "r\n")
80 f.Commit("base")
81 objects := filepath.Join(f.Bare, "objects")
82 up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
83
84 for name, o := range map[string]ports.PushObjects{
85 "outside the repository": {Dir: filepath.Join(data, "elsewhere")},
86 "a relative path": {Dir: "objects/incoming"},
87 "parent of the objects": {Dir: f.Bare},
88 "a dotdot out of objects": {Dir: objects + "/../../other.git/objects"},
89 "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},
90 "alternates without a dir": {Alternates: objects},
91 } {
92 _, err := g.ChangedWorkflow(ctx, f.Name, up, o)
93 assert.ErrorIs(t, err, domain.ErrInvalid, name)
94 }
95 // An existing directory inside the repository is accepted as a location
96 // (the object is missing: a refusal of the object, not of the path).
97 require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))
98 _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects})
99 require.Error(t, err)
100 assert.NotContains(t, err.Error(), "outside the repository")
101 // One that does not exist is refused.
102 _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"})
103 assert.ErrorIs(t, err, domain.ErrInvalid)
104}
105
106// A symbolic link in the way cannot take the objects of a push out of the
107// repository, and a data directory reached through links still works.
108func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
109 g, data := newGit(t)
110 ctx := context.Background()
111 f := readertest.NewFixture(t, data, "proj")
112 t.Cleanup(func() { _ = g.Close(ctx) })
113 f.Write("README", "r\n")
114 f.Commit("base")
115 up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
116 objects := filepath.Join(f.Bare, "objects")
117
118 // Inside objects/, a link to elsewhere is outside once resolved.
119 elsewhere := t.TempDir()
120 require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))
121 _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")})
122 assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
123 _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")})
124 assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
125
126 // A link to somewhere inside objects/ is fine.
127 require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
128 require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))
129 _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")})
130 require.Error(t, err)
131 assert.NotContains(t, err.Error(), "outside the repository")
132}
133
134func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
135 _, data := newGit(t)
136 ctx := context.Background()
137 f := readertest.NewFixture(t, data, "proj")
138 f.Write("README", "r\n")
139 base := f.Commit("base")
140 f.Write(".github/workflows/ci.yml", "on: push\n")
141 wf := f.Commit("workflow")
142 // The server reaches the same data directory through a link, while git
143 // (the hook) reports the real path: both must be accepted as inside.
144 parent := t.TempDir()
145 link := filepath.Join(parent, "data")
146 require.NoError(t, os.Symlink(data, link))
147 bin, err := exec.LookPath("git")
148 require.NoError(t, err)
149 g2 := gitcli.New(gitcli.Config{Bin: bin, DataDir: link, Path: os.Getenv("PATH")})
150 t.Cleanup(func() { _ = g2.Close(ctx) })
151 q := filepath.Join(f.Bare, "objects", "incoming-9")
152 require.NoError(t, os.Mkdir(q, 0o700))
153 for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {
154 p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
155 ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")})
156 require.NoError(t, err, dir)
157 assert.Equal(t, ".github/workflows/ci.yml", p, dir)
158 }
159}
160
161// treeOf writes a tree of the given entries ("mode type sha\tname") and returns it.
162func treeOf(t *testing.T, f *readertest.Fixture, entries ...string) string {
163 t.Helper()
164 cmd := exec.Command("git", "--git-dir="+f.Bare, "mktree")
165 cmd.Stdin = strings.NewReader(strings.Join(entries, "\n") + "\n")
166 out, err := cmd.CombinedOutput()
167 require.NoError(t, err, string(out))
168 return strings.TrimSpace(string(out))
169}
170
171func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...string) string {
172 t.Helper()
173 args := []string{"--git-dir=" + f.Bare, "commit-tree", tree, "-m", "c"}
174 for _, p := range parents {
175 args = append(args, "-p", p)
176 }
177 cmd := exec.Command("git", args...)
178 cmd.Env = []string{"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@b", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@b"}
179 out, err := cmd.CombinedOutput()
180 require.NoError(t, err, string(out))
181 return strings.TrimSpace(string(out))
182}
183
184// withWorkflows is a commit whose .github/workflows is the tree given.
185func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string {
186 t.Helper()
187 gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows")
188 return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github"))
189}
190
191// The trees of a push are the pusher's: a workflows directory that fans out
192// into ten million paths is answered with its first path, at once, by a git
193// that is stopped, without the diff ever being held in memory.
194func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
195 g, data := newGit(t)
196 ctx := context.Background()
197 f := readertest.NewFixture(t, data, "proj")
198 t.Cleanup(func() { _ = g.Close(ctx) })
199 blob := f.WriteObject("blob", "x\n")
200 level := treeOf(t, f, "100644 blob "+blob+"\tf")
201 for range 7 { // ten entries per level, all the same subtree: 10^7 paths
202 var entries []string
203 for i := range 10 {
204 entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i))
205 }
206 level = treeOf(t, f, entries...)
207 }
208 amp := withWorkflows(t, f, level)
209
210 var before, after runtime.MemStats
211 runtime.GC()
212 runtime.ReadMemStats(&before)
213 start := time.Now()
214 p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{})
215 took := time.Since(start)
216 runtime.ReadMemStats(&after)
217 require.NoError(t, err)
218 assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)
219 assert.Less(t, took, 2*time.Second, "answered from the first path")
220 assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
221 // And no git is left behind.
222 out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput()
223 assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped")
224}
225
226// A path longer than the bound fails closed: the push is refused, not read on.
227func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) {
228 g, data := newGit(t)
229 ctx := context.Background()
230 f := readertest.NewFixture(t, data, "proj")
231 t.Cleanup(func() { _ = g.Close(ctx) })
232 level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
233 level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
234 _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{})
235 assert.ErrorIs(t, err, domain.ErrInvalid)
236}
237
238// A tag may point to a blob, a tree or a commit; only the commits and trees can
239// hold a workflow, and an object that is not there refuses the push.
240func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) {
241 g, data := newGit(t)
242 ctx := context.Background()
243 f := readertest.NewFixture(t, data, "proj")
244 t.Cleanup(func() { _ = g.Close(ctx) })
245 f.Write("README", "r\n")
246 plain := f.Commit("plain")
247 f.Write(".github/workflows/ci.yml", "on: push\n")
248 wf := f.Commit("workflow")
249 blob := f.WriteObject("blob", "hello\n")
250 annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n")
251 annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")
252 rootTree := f.Git("rev-parse", wf+"^{tree}")
253 ref := func(old, new string) []domain.RefUpdate {
254 return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}}
255 }
256 changed := func(ups []domain.RefUpdate) (string, error) {
257 return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
258 }
259 for name, ups := range map[string][]domain.RefUpdate{
260 "create a lightweight tag on a blob": ref(domain.ZeroOID, blob),
261 "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob),
262 "a tag from a blob to another blob": ref(blob, annotatedBlob),
263 "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob),
264 } {
265 p, err := changed(ups)
266 require.NoError(t, err, name)
267 assert.Empty(t, p, name)
268 }
269 for name, ups := range map[string][]domain.RefUpdate{
270 "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),
271 "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
272 "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),
273 "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),
274 "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),
275 } {
276 p, err := changed(ups)
277 require.NoError(t, err, name)
278 assert.Equal(t, ".github/workflows/ci.yml", p, name)
279 }
280 for name, ups := range map[string][]domain.RefUpdate{
281 "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),
282 "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),
283 "a malformed object": ref(domain.ZeroOID, "ZZZZ"),
284 } {
285 _, err := changed(ups)
286 assert.Error(t, err, name)
287 }
288}
289
290// rawTree writes a tree object by hand: entries are "mode name hexid", in the
291// order given (git sorts them; the callers give them sorted).
292func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
293 t.Helper()
294 var b strings.Builder
295 for _, e := range entries {
296 raw, err := hex.DecodeString(e[2])
297 require.NoError(t, err)
298 b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000
299 b.Write(raw)
300 }
301 return f.WriteObject("tree", b.String())
302}
303
304// What counts as .github/workflows is what the loader lists: a directory of
305// mode 040000 below a directory .github of mode 040000. The same tree id as a
306// gitlink, a symbolic link or a file is nothing, and turning it into a
307// directory activates it; the object id alone does not tell.
308func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {
309 g, data := newGit(t)
310 ctx := context.Background()
311 f := readertest.NewFixture(t, data, "proj")
312 t.Cleanup(func() { _ = g.Close(ctx) })
313 f.Write("README", "r\n")
314 f.Commit("base")
315 yml := f.WriteObject("blob", "on: push\n")
316 wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows
317 link := f.WriteObject("blob", ".github/other")
318 file := f.WriteObject("blob", "not a directory\n")
319 readme := f.WriteObject("blob", "r\n")
320
321 // A commit whose .github/workflows has the mode and id given.
322 withWorkflows := func(mode, id string) string {
323 gh := rawTree(t, f, [3]string{mode, "workflows", id})
324 return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))
325 }
326 // A commit whose .github has the mode and id given.
327 withGithub := func(mode, id string) string {
328 return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))
329 }
330 asDir := withWorkflows("040000", wfTree)
331 asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule
332 asSymlink := withWorkflows("120000", link)
333 asFile := withWorkflows("100644", file)
334 none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))
335 ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})
336 githubDir := withGithub("040000", ghDir)
337 githubGitlink := withGithub("160000", ghDir) // the same id, a submodule
338 githubFile := withGithub("100644", file)
339 githubLink := withGithub("120000", link)
340
341 for name, c := range map[string]struct {
342 old, new string
343 want string
344 }{
345 "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"},
346 "a directory to a gitlink removes it": {asDir, asGitlink, ""},
347 "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"},
348 "a directory to a symlink removes it": {asDir, asSymlink, ""},
349 "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"},
350 "a directory to a file removes it": {asDir, asFile, ""},
351 "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"},
352 "a directory to nothing": {asDir, none, ""},
353 "the same gitlink": {asGitlink, asGitlink, ""},
354 "gitlink to symlink": {asGitlink, asSymlink, ""},
355 ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"},
356 ".github a directory to a gitlink": {githubDir, githubGitlink, ""},
357 ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"},
358 ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"},
359 ".github a directory to a symlink": {githubDir, githubLink, ""},
360 "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""},
361 "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""},
362 "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""},
363 "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"},
364 } {
365 p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{})
366 require.NoError(t, err, name)
367 assert.Equal(t, c.want, p, name)
368 }
369
370 // A tag that peels to the commit is judged the same way.
371 tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
372 p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{})
373 require.NoError(t, err)
374 assert.Equal(t, ".github/workflows/ci.yml", p)
375}