1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitcli_test
4
5import (
6 "context"
7 "encoding/hex"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/require"
20
21 "bnema.dev/ermite/internal/adapters/gitcli"
22 "bnema.dev/ermite/internal/domain"
23 "bnema.dev/ermite/internal/ports"
24 "bnema.dev/ermite/internal/readertest"
25)
26
27func TestChangedWorkflow(t *testing.T) {
28 g, data := newGit(t)
29 ctx := context.Background()
30 f := readertest.NewFixture(t, data, "proj")
31 t.Cleanup(func() { _ = g.Close(ctx) })
32 f.Write("README", "r\n")
33 base := f.Commit("base")
34 f.Write(".github/workflows/ci.yml", "on: push\n")
35 withWF := f.Commit("add workflow")
36 f.Write("docs/x.md", "x\n")
37 docs := f.Commit("docs")
38 f.Write(".github/workflows/ci.yml", "on: [push]\n")
39 edited := f.Commit("edit workflow")
40 f.Write(".github/workflows/other.yml", "on: push\n")
41 two := f.Commit("a second workflow")
42 f.Remove(".github/workflows/ci.yml")
43 oneLeft := f.Commit("remove one of two")
44 f.Remove(".github/workflows/other.yml")
45 removed := f.Commit("remove the last workflow")
46 f.Write(".github/workflows-notes/a.yml", "x\n")
47 lookalike := f.Commit("a look-alike directory")
48 oid := func(s string) string { return s[:40] }
49
50 up := func(old, new string) domain.RefUpdate {
51 return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"}
52 }
53 changed := func(ups ...domain.RefUpdate) string {
54 p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
55 require.NoError(t, err)
56 return p
57 }
58
59 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(withWF))), "a file created")
60 assert.Equal(t, "", changed(up(oid(withWF), oid(docs))), "another file changed")
61 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(docs), oid(edited))), "a file modified")
62 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")
63 assert.Equal(t, "", changed(up(oid(oneLeft), oid(removed))), "the directory removed with its last file is allowed")
64 assert.Equal(t, "", changed(up(oid(removed), oid(lookalike))), "only .github/workflows/ counts")
65 assert.Equal(t, ".github/workflows/ci.yml", changed(up(domain.ZeroOID, oid(withWF))), "a new ref carries the workflows of its commit")
66 assert.Equal(t, "", changed(up(domain.ZeroOID, oid(base))), "a new ref without workflows")
67 assert.Equal(t, "", changed(up(oid(withWF), domain.ZeroOID)), "a deleted ref changes nothing")
68 assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one")
69 assert.Equal(t, "", changed(), "no update")
70
71 _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{})
72 assert.Error(t, err, "an object git does not have is a failure, not a pass")
73}
74
75func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) {
76 g, data := newGit(t)
77 ctx := context.Background()
78 f := readertest.NewFixture(t, data, "proj")
79 t.Cleanup(func() { _ = g.Close(ctx) })
80 f.Write("README", "r\n")
81 f.Commit("base")
82 objects := filepath.Join(f.Bare, "objects")
83 up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
84
85 for name, o := range map[string]ports.PushObjects{
86 "outside the repository": {Dir: filepath.Join(data, "elsewhere")},
87 "a relative path": {Dir: "objects/incoming"},
88 "parent of the objects": {Dir: f.Bare},
89 "a dotdot out of objects": {Dir: objects + "/../../other.git/objects"},
90 "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"},
91 "alternates without a dir": {Alternates: objects},
92 } {
93 _, err := g.ChangedWorkflow(ctx, f.Name, up, o)
94 assert.ErrorIs(t, err, domain.ErrInvalid, name)
95 }
96 // An existing directory inside the repository is accepted as a location
97 // (the object is missing: a refusal of the object, not of the path).
98 require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700))
99 _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects})
100 require.Error(t, err)
101 assert.NotContains(t, err.Error(), "outside the repository")
102 // One that does not exist is refused.
103 _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"})
104 assert.ErrorIs(t, err, domain.ErrInvalid)
105}
106
107// A symbolic link in the way cannot take the objects of a push out of the
108// repository, and a data directory reached through links still works.
109func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) {
110 g, data := newGit(t)
111 ctx := context.Background()
112 f := readertest.NewFixture(t, data, "proj")
113 t.Cleanup(func() { _ = g.Close(ctx) })
114 f.Write("README", "r\n")
115 f.Commit("base")
116 up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}}
117 objects := filepath.Join(f.Bare, "objects")
118
119 // Inside objects/, a link to elsewhere is outside once resolved.
120 elsewhere := t.TempDir()
121 require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link")))
122 _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")})
123 assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
124 _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")})
125 assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/")
126
127 // A link to somewhere inside objects/ is fine.
128 require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700))
129 require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link")))
130 _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")})
131 require.Error(t, err)
132 assert.NotContains(t, err.Error(), "outside the repository")
133}
134
135func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) {
136 _, data := newGit(t)
137 ctx := context.Background()
138 f := readertest.NewFixture(t, data, "proj")
139 f.Write("README", "r\n")
140 base := f.Commit("base")
141 f.Write(".github/workflows/ci.yml", "on: push\n")
142 wf := f.Commit("workflow")
143 // The server reaches the same data directory through a link, while git
144 // (the hook) reports the real path: both must be accepted as inside.
145 parent := t.TempDir()
146 link := filepath.Join(parent, "data")
147 require.NoError(t, os.Symlink(data, link))
148 bin, err := exec.LookPath("git")
149 require.NoError(t, err)
150 g2 := gitcli.New(gitcli.Config{Bin: bin, DataDir: link, Path: os.Getenv("PATH")})
151 t.Cleanup(func() { _ = g2.Close(ctx) })
152 q := filepath.Join(f.Bare, "objects", "incoming-9")
153 require.NoError(t, os.Mkdir(q, 0o700))
154 for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} {
155 p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}},
156 ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")})
157 require.NoError(t, err, dir)
158 assert.Equal(t, ".github/workflows/ci.yml", p, dir)
159 }
160}
161
162// treeOf writes a tree of the given entries ("mode type sha\tname") and returns it.
163func treeOf(t *testing.T, f *readertest.Fixture, entries ...string) string {
164 t.Helper()
165 cmd := exec.Command("git", "--git-dir="+f.Bare, "mktree")
166 cmd.Stdin = strings.NewReader(strings.Join(entries, "\n") + "\n")
167 out, err := cmd.CombinedOutput()
168 require.NoError(t, err, string(out))
169 return strings.TrimSpace(string(out))
170}
171
172func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...string) string {
173 t.Helper()
174 args := []string{"--git-dir=" + f.Bare, "commit-tree", tree, "-m", "c"}
175 for _, p := range parents {
176 args = append(args, "-p", p)
177 }
178 cmd := exec.Command("git", args...)
179 cmd.Env = []string{"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@b", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@b"}
180 out, err := cmd.CombinedOutput()
181 require.NoError(t, err, string(out))
182 return strings.TrimSpace(string(out))
183}
184
185// withWorkflows is a commit whose .github/workflows is the tree given.
186func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string {
187 t.Helper()
188 gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows")
189 return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github"))
190}
191
192// The trees of a push are the pusher's: a workflows directory that fans out
193// into ten million paths is answered with its first path, at once, by a git
194// that is stopped, without the diff ever being held in memory.
195func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) {
196 g, data := newGit(t)
197 ctx := context.Background()
198 f := readertest.NewFixture(t, data, "proj")
199 t.Cleanup(func() { _ = g.Close(ctx) })
200 blob := f.WriteObject("blob", "x\n")
201 level := treeOf(t, f, "100644 blob "+blob+"\tf")
202 for range 7 { // ten entries per level, all the same subtree: 10^7 paths
203 var entries []string
204 for i := range 10 {
205 entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i))
206 }
207 level = treeOf(t, f, entries...)
208 }
209 amp := withWorkflows(t, f, level)
210
211 var before, after runtime.MemStats
212 runtime.GC()
213 runtime.ReadMemStats(&before)
214 start := time.Now()
215 p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{})
216 took := time.Since(start)
217 runtime.ReadMemStats(&after)
218 require.NoError(t, err)
219 assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p)
220 assert.Less(t, took, 2*time.Second, "answered from the first path")
221 assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
222 // And no git is left behind.
223 out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput()
224 assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped")
225}
226
227// A path longer than the bound fails closed: the push is refused, not read on.
228func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) {
229 g, data := newGit(t)
230 ctx := context.Background()
231 f := readertest.NewFixture(t, data, "proj")
232 t.Cleanup(func() { _ = g.Close(ctx) })
233 level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
234 level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
235 _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{})
236 assert.ErrorIs(t, err, domain.ErrInvalid)
237}
238
239// A tag may point to a blob, a tree or a commit; only the commits and trees can
240// hold a workflow, and an object that is not there refuses the push.
241func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) {
242 g, data := newGit(t)
243 ctx := context.Background()
244 f := readertest.NewFixture(t, data, "proj")
245 t.Cleanup(func() { _ = g.Close(ctx) })
246 f.Write("README", "r\n")
247 plain := f.Commit("plain")
248 f.Write(".github/workflows/ci.yml", "on: push\n")
249 wf := f.Commit("workflow")
250 f.Write(".github/workflows/ci.yml", "on: [push]\n")
251 f.Commit("the default branch moves on to other workflows") // HEAD is not wf
252 blob := f.WriteObject("blob", "hello\n")
253 annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a <a@b> 1 +0000\n\nm\n")
254 annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a <a@b> 1 +0000\n\nm\n")
255 rootTree := f.Git("rev-parse", wf+"^{tree}")
256 ref := func(old, new string) []domain.RefUpdate {
257 return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}}
258 }
259 changed := func(ups []domain.RefUpdate) (string, error) {
260 return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
261 }
262 for name, ups := range map[string][]domain.RefUpdate{
263 "create a lightweight tag on a blob": ref(domain.ZeroOID, blob),
264 "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob),
265 "a tag from a blob to another blob": ref(blob, annotatedBlob),
266 "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob),
267 } {
268 p, err := changed(ups)
269 require.NoError(t, err, name)
270 assert.Empty(t, p, name)
271 }
272 for name, ups := range map[string][]domain.RefUpdate{
273 "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()),
274 "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()),
275 "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF),
276 "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree),
277 "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF),
278 } {
279 p, err := changed(ups)
280 require.NoError(t, err, name)
281 assert.Equal(t, ".github/workflows/ci.yml", p, name)
282 }
283 for name, ups := range map[string][]domain.RefUpdate{
284 "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"),
285 "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()),
286 "a malformed object": ref(domain.ZeroOID, "ZZZZ"),
287 } {
288 _, err := changed(ups)
289 assert.Error(t, err, name)
290 }
291}
292
293// rawTree writes a tree object by hand: entries are "mode name hexid", in the
294// order given (git sorts them; the callers give them sorted).
295func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
296 t.Helper()
297 var b strings.Builder
298 for _, e := range entries {
299 raw, err := hex.DecodeString(e[2])
300 require.NoError(t, err)
301 b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000
302 b.Write(raw)
303 }
304 return f.WriteObject("tree", b.String())
305}
306
307// What counts as .github/workflows is what the loader lists: a directory of
308// mode 040000 below a directory .github of mode 040000. The same tree id as a
309// gitlink, a symbolic link or a file is nothing, and turning it into a
310// directory activates it; the object id alone does not tell.
311func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) {
312 g, data := newGit(t)
313 ctx := context.Background()
314 f := readertest.NewFixture(t, data, "proj")
315 t.Cleanup(func() { _ = g.Close(ctx) })
316 f.Write("README", "r\n")
317 f.Commit("base")
318 yml := f.WriteObject("blob", "on: push\n")
319 wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows
320 link := f.WriteObject("blob", ".github/other")
321 file := f.WriteObject("blob", "not a directory\n")
322 readme := f.WriteObject("blob", "r\n")
323
324 // A commit whose .github/workflows has the mode and id given.
325 withWorkflows := func(mode, id string) string {
326 gh := rawTree(t, f, [3]string{mode, "workflows", id})
327 return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh}))
328 }
329 // A commit whose .github has the mode and id given.
330 withGithub := func(mode, id string) string {
331 return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id}))
332 }
333 asDir := withWorkflows("040000", wfTree)
334 asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule
335 asSymlink := withWorkflows("120000", link)
336 asFile := withWorkflows("100644", file)
337 none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme}))
338 ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree})
339 githubDir := withGithub("040000", ghDir)
340 githubGitlink := withGithub("160000", ghDir) // the same id, a submodule
341 githubFile := withGithub("100644", file)
342 githubLink := withGithub("120000", link)
343
344 for name, c := range map[string]struct {
345 old, new string
346 want string
347 }{
348 "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"},
349 "a directory to a gitlink removes it": {asDir, asGitlink, ""},
350 "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"},
351 "a directory to a symlink removes it": {asDir, asSymlink, ""},
352 "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"},
353 "a directory to a file removes it": {asDir, asFile, ""},
354 "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"},
355 "a directory to nothing": {asDir, none, ""},
356 "the same gitlink": {asGitlink, asGitlink, ""},
357 "gitlink to symlink": {asGitlink, asSymlink, ""},
358 ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"},
359 ".github a directory to a gitlink": {githubDir, githubGitlink, ""},
360 ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"},
361 ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"},
362 ".github a directory to a symlink": {githubDir, githubLink, ""},
363 "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""},
364 "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""},
365 "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""},
366 "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"},
367 } {
368 p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{})
369 require.NoError(t, err, name)
370 assert.Equal(t, c.want, p, name)
371 }
372
373 // A tag that peels to the commit is judged the same way.
374 tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
375 p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{})
376 require.NoError(t, err)
377 assert.Equal(t, ".github/workflows/ci.yml", p)
378}
379
380// A history for the rules on the commits a push brings: main holds workflows A,
381// old (a branch) holds B, and the commits made here are in no ref yet, like
382// those of a push in quarantine.
383type guardHistory struct {
384 t *testing.T
385 f *readertest.Fixture
386 trees map[string]string // workflows name -> tree id
387 m, o string // main (A) and old (B)
388 extraN int
389 readme string
390}
391
392func newGuardHistory(t *testing.T, g *gitcli.Git, data string) *guardHistory {
393 t.Helper()
394 f := readertest.NewFixture(t, data, "proj")
395 h := &guardHistory{t: t, f: f, trees: map[string]string{}}
396 h.readme = f.WriteObject("blob", "r\n")
397 for _, n := range []string{"A", "B", "C", "D"} {
398 h.trees[n] = rawTree(t, f, [3]string{"100644", "ci.yml", f.WriteObject("blob", "on: push # "+n+"\n")})
399 }
400 h.m = h.commit("A", "")
401 h.o = h.commit("B", "")
402 f.Git("update-ref", "refs/heads/main", h.m)
403 f.Git("update-ref", "refs/heads/old", h.o)
404 return h
405}
406
407// commit makes a commit with the workflows named ("" for none), one more file
408// each time (so that no two commits share a tree by accident), and the parents.
409func (h *guardHistory) commit(workflows, _ string, parents ...string) string {
410 h.t.Helper()
411 h.extraN++
412 extra := h.f.WriteObject("blob", fmt.Sprintf("file %d\n", h.extraN))
413 entries := [][3]string{{"100644", "README", h.readme}, {"100644", fmt.Sprintf("x%d", h.extraN), extra}}
414 if workflows != "" {
415 gh := rawTree(h.t, h.f, [3]string{"040000", "workflows", h.trees[workflows]})
416 entries = append([][3]string{{"040000", ".github", gh}}, entries...)
417 }
418 return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)
419}
420
421func TestChangedWorkflowJudgesThePushedCommits(t *testing.T) {
422 g, data := newGit(t)
423 ctx := context.Background()
424 h := newGuardHistory(t, g, data)
425 t.Cleanup(func() { _ = g.Close(ctx) })
426 f := h.f
427 run := func(ups ...domain.RefUpdate) (string, error) {
428 return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{})
429 }
430 create := func(ref, to string) domain.RefUpdate { return domain.RefUpdate{Old: domain.ZeroOID, New: to, Ref: ref} }
431 move := func(ref, from, to string) domain.RefUpdate { return domain.RefUpdate{Old: from, New: to, Ref: ref} }
432 const ci = ".github/workflows/ci.yml"
433
434 allowed := func(name string, ups ...domain.RefUpdate) {
435 t.Helper()
436 p, err := run(ups...)
437 require.NoError(t, err, name)
438 assert.Empty(t, p, name)
439 }
440 refused := func(name string, ups ...domain.RefUpdate) {
441 t.Helper()
442 p, err := run(ups...)
443 require.NoError(t, err, name)
444 assert.Equal(t, ci, p, name)
445 }
446
447 // The staging repro: a new branch off main with a commit that touches no workflow.
448 allowed("new branch off main with a plain commit", create("refs/heads/smoke", h.commit("A", "", h.m)))
449 allowed("a new branch at the head of main", create("refs/heads/same", h.m))
450 allowed("a new tag at the head of main", create("refs/tags/v1", h.m))
451 allowed("a new branch whose commits carry no workflows at all", create("refs/heads/bare", h.commit("", "")))
452 allowed("a fast-forward with plain commits", move("refs/heads/main", h.m, h.commit("A", "", h.commit("A", "", h.m))))
453 allowed("a commit that removes the workflows", move("refs/heads/main", h.m, h.commit("", "", h.m)))
454
455 // A pushed commit that changes them is refused wherever it goes.
456 refused("new branch with a commit that changes workflows", create("refs/heads/w", h.commit("C", "", h.m)))
457 refused("a root commit that carries workflows", create("refs/heads/root", h.commit("C", "")))
458 refused("an update with a commit that changes workflows", move("refs/heads/main", h.m, h.commit("C", "", h.m)))
459 n4 := h.commit("C", "", h.m)
460 n5 := h.commit("A", "", n4) // reverts the change of the commit before
461 refused("a fast-forward through a commit that changes them and a later one that reverts it", move("refs/heads/main", h.m, n5))
462 refused("a new branch through the same", create("refs/heads/through", n5))
463 n6 := h.commit("A", "", h.commit("C", "", h.o))
464 refused("a commit that changes them below plain ones, on an old base", create("refs/heads/below", h.commit("C", "", n6)))
465
466 // A ref cannot be moved onto existing commits with other workflows.
467 refused("a new branch at an old commit whose workflows differ from main", create("refs/heads/b", h.o))
468 refused("a new tag at that commit", create("refs/tags/old", h.o))
469 refused("an update moving back to an older commit with other workflows", move("refs/heads/main", h.m, h.o))
470 allowed("an update to a commit with the same workflows", move("refs/heads/old", h.o, h.commit("B", "", h.o)))
471 allowed("an update that moves a ref to a commit without workflows (removes them)", move("refs/heads/old", h.o, h.commit("", "", h.o)))
472
473 // Merges: a change only if it differs from every parent.
474 allowed("a merge of existing commits that keeps main's workflows", move("refs/heads/main", h.m, h.commit("A", "", h.m, h.o)))
475 allowed("a new branch with a merge keeping one parent's workflows", create("refs/heads/merge", h.commit("A", "", h.m, h.o)))
476 refused("a merge that differs from both parents", create("refs/heads/merge2", h.commit("D", "", h.m, h.o)))
477 refused("a merge of a pushed side that changes them (the side commit is itself new)", create("refs/heads/merge3", h.commit("A", "", h.m, h.commit("C", "", h.o))))
478 // One that takes the old side's workflows is a change of the ref, not of a commit.
479 refused("a merge taking the other parent's workflows, onto main", move("refs/heads/main", h.m, h.commit("B", "", h.m, h.o)))
480
481 // Several updates: the first refused refuses the push.
482 refused("a plain ref and a refused one", create("refs/heads/ok", h.commit("A", "", h.m)), create("refs/heads/w2", h.commit("C", "", h.m)))
483 allowed("deletes", move("refs/heads/old", h.o, domain.ZeroOID))
484}
485
486// With no default branch head, a new ref is compared with no workflows at all.
487func TestChangedWorkflowOfANewRefWithoutADefaultBranchHead(t *testing.T) {
488 g, data := newGit(t)
489 ctx := context.Background()
490 h := newGuardHistory(t, g, data)
491 t.Cleanup(func() { _ = g.Close(ctx) })
492 h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing") // the default branch does not exist
493 run := func(to string) string {
494 p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: to, Ref: "refs/tags/t"}}, ports.PushObjects{})
495 require.NoError(t, err)
496 return p
497 }
498 assert.Equal(t, ".github/workflows/ci.yml", run(h.m), "an existing commit with workflows")
499 plain := h.commit("", "")
500 h.f.Git("update-ref", "refs/heads/plain", plain)
501 assert.Empty(t, run(plain), "an existing commit without")
502}
503
504// The commits of a push are bounded: more than the bound fails closed.
505func TestChangedWorkflowBoundsThePushedCommits(t *testing.T) {
506 g, data := newGit(t)
507 ctx := context.Background()
508 h := newGuardHistory(t, g, data)
509 t.Cleanup(func() { _ = g.Close(ctx) })
510 // A chain of plain commits on main, made with fast-import in a ref that is
511 // removed afterwards: the objects stay, in no ref, like a push in quarantine.
512 chain := func(n int) string {
513 var b strings.Builder
514 fmt.Fprintf(&b, "reset refs/tmp/chain\nfrom %s\n", h.m)
515 for i := range n {
516 fmt.Fprintf(&b, "commit refs/tmp/chain\ncommitter a <a@b> %d +0000\ndata 1\nc\nM 100644 inline f.txt\ndata 2\n%d\n\n", 1000+i, i%10)
517 }
518 cmd := exec.Command("git", "--git-dir="+h.f.Bare, "fast-import", "--quiet")
519 cmd.Stdin = strings.NewReader(b.String())
520 out, err := cmd.CombinedOutput()
521 require.NoError(t, err, string(out))
522 tip := h.f.Git("rev-parse", "refs/tmp/chain")
523 h.f.Git("update-ref", "-d", "refs/tmp/chain")
524 return tip
525 }
526 // Plain commits carry the workflows of main (the fast-import tree has none,
527 // so the chain is "removing" them): allowed, whatever their number, up to the bound.
528 p, err := g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits), Ref: "refs/heads/main"}}, ports.PushObjects{})
529 require.NoError(t, err)
530 assert.Empty(t, p)
531 _, err = g.ChangedWorkflow(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(gitcli.MaxPushedCommits + 1), Ref: "refs/heads/main"}}, ports.PushObjects{})
532 assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")
533}
534
535// countingReader serves synthetic rev-list output and counts what was read.
536type countingReader struct {
537 lines, read int
538 line []byte
539 pending []byte
540 made int
541}
542
543func (c *countingReader) Read(p []byte) (int, error) {
544 if len(c.pending) == 0 {
545 if c.made == c.lines {
546 return 0, io.EOF
547 }
548 c.made++
549 c.pending = c.line
550 }
551 n := copy(p, c.pending)
552 c.pending = c.pending[n:]
553 c.read += n
554 return n, nil
555}
556
557// A hostile push: ten thousand commits of two hundred parents each is two
558// million ids. The bound is met after the first lines, and nothing more is read
559// or kept.
560func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
561 id := func(n int) string { return fmt.Sprintf("%040x", n+1) }
562 var b strings.Builder
563 b.WriteString(id(0))
564 for i := range 198 {
565 b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small
566 }
567 b.WriteString("\n")
568 r := &countingReader{lines: gitcli.MaxPushedCommits, line: []byte(b.String())}
569 kept, err := gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
570 require.ErrorIs(t, err, domain.ErrInvalid)
571 assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")
572 assert.Zero(t, kept, "nothing is returned with the error")
573 total := gitcli.MaxPushedCommits * len(b.String())
574 assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)
575 assert.Less(t, r.read*20, total)
576
577 // Within the bounds the same lines are kept: 100 commits of 198 parents.
578 r = &countingReader{lines: 100, line: []byte(b.String())}
579 kept, err = gitcli.ParseRevList(r, gitcli.MaxPushedCommits, gitcli.MaxPushedObjects)
580 require.NoError(t, err)
581 assert.Equal(t, 100, kept)
582
583 // The other bounds and malformed output.
584 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+"\n"+id(1)+"\n"), 1, 100)
585 assert.ErrorIs(t, err, domain.ErrInvalid, "commits")
586 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+" "+id(1)+"\n"+id(2)+"\n"), 10, 2)
587 assert.ErrorIs(t, err, domain.ErrInvalid, "ids")
588 _, err = gitcli.ParseRevList(strings.NewReader("nonsense\n"), 10, 10)
589 assert.ErrorIs(t, err, domain.ErrInvalid, "an id that is none")
590 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)
591 assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")
592}