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 := changedWorkflow(g, 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 := changedWorkflow(g, 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 TestPushSubtreesReadsTheQuarantineOfThePushOnly(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.PushSubtrees(ctx, f.Name, up, o, domain.WorkflowDir, domain.DefaultPushWalkBounds())
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.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
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.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
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 TestPushSubtreesResolvesSymbolicLinks(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.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
123 assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/")
124 _, err = g.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
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.PushSubtrees(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
131 require.Error(t, err)
132 assert.NotContains(t, err.Error(), "outside the repository")
133}
134
135func TestPushSubtreesThroughALinkedDataDirectory(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.PushSubtrees(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")}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
157 require.NoError(t, err, dir)
158 assert.Equal(t, domain.EntryDirectory, p.Subtrees[oidOf(t, wf).String()].Kind, 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
185func TestFirstDifferentEntryDoesNotBufferTheDiff(t *testing.T) {
186 g, data := newGit(t)
187 ctx := context.Background()
188 f := readertest.NewFixture(t, data, "proj")
189 t.Cleanup(func() { _ = g.Close(ctx) })
190 blob := f.WriteObject("blob", "x\n")
191 level := treeOf(t, f, "100644 blob "+blob+"\tf")
192 for range 7 { // ten entries per level, all the same subtree: 10^7 paths
193 var entries []string
194 for i := range 10 {
195 entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i))
196 }
197 level = treeOf(t, f, entries...)
198 }
199 amp := level
200
201 var before, after runtime.MemStats
202 runtime.GC()
203 runtime.ReadMemStats(&before)
204 start := time.Now()
205 p, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
206 took := time.Since(start)
207 runtime.ReadMemStats(&after)
208 require.NoError(t, err)
209 assert.True(t, strings.HasPrefix(p, "d"), p)
210 assert.Less(t, took, 2*time.Second, "answered from the first path")
211 assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory")
212 // And no git is left behind.
213 out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput()
214 assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped")
215}
216
217// A path longer than the bound fails closed: the push is refused, not read on.
218func TestFirstDifferentEntryRefusesAnEndlessPath(t *testing.T) {
219 g, data := newGit(t)
220 ctx := context.Background()
221 f := readertest.NewFixture(t, data, "proj")
222 t.Cleanup(func() { _ = g.Close(ctx) })
223 level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf")
224 level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes
225 _, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, oidOf(t, level))
226 assert.ErrorIs(t, err, domain.ErrInvalid)
227}
228
229// A tag may point to a blob, a tree or a commit; only the commits and trees can
230// hold a workflow, and an object that is not there refuses the push.
231func TestPushSubtreesPeelsAndClassifiesBothEnds(t *testing.T) {
232 g, data := newGit(t)
233 ctx := context.Background()
234 f := readertest.NewFixture(t, data, "proj")
235 t.Cleanup(func() { _ = g.Close(ctx) })
236 f.Write("README", "r\n")
237 plain := oidOf(t, f.Commit("plain")).String()
238 blob := f.WriteObject("blob", "hello\n")
239 tag := func(id, kind string) string {
240 return f.WriteObject("tag", "object "+id+"\ntype "+kind+"\ntag t\ntagger a <a@b> 1 +0000\n\nm\n")
241 }
242 tree := f.Git("rev-parse", plain+"^{tree}")
243 for _, r := range []struct {
244 id string
245 kind domain.PushObjectType
246 }{{plain, domain.PushObjectCommit}, {tag(plain, "commit"), domain.PushObjectCommit}, {blob, domain.PushObjectBlob}, {tag(blob, "blob"), domain.PushObjectBlob}, {tree, domain.PushObjectTree}, {tag(tag(plain, "commit"), "tag"), domain.PushObjectCommit}} {
247 got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: r.id}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
248 require.NoError(t, err)
249 assert.Equal(t, r.kind, got.Ends[r.id])
250 if r.kind == domain.PushObjectBlob {
251 assert.NotContains(t, got.Subtrees, r.id)
252 }
253 }
254 for _, r := range []domain.RefUpdate{{Old: domain.ZeroOID, New: strings.Repeat("1", 40)}, {Old: strings.Repeat("2", 40), New: plain}, {Old: domain.ZeroOID, New: "ZZZZ"}} {
255 _, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{r}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
256 assert.ErrorIs(t, err, domain.ErrInvalid)
257 }
258 got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: "invalid but deleted", New: domain.ZeroOID}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
259 require.NoError(t, err)
260 assert.Empty(t, got.Ends)
261 assert.Empty(t, got.Commits)
262}
263
264// rawTree writes a tree object by hand: entries are "mode name hexid", in the
265// order given (git sorts them; the callers give them sorted).
266func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string {
267 t.Helper()
268 var b strings.Builder
269 for _, e := range entries {
270 raw, err := hex.DecodeString(e[2])
271 require.NoError(t, err)
272 b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000
273 b.Write(raw)
274 }
275 return f.WriteObject("tree", b.String())
276}
277
278// What counts as .github/workflows is what the loader lists: a directory of
279// mode 040000 below a directory .github of mode 040000. The same tree id as a
280// gitlink, a symbolic link or a file is nothing, and turning it into a
281// directory activates it; the object id alone does not tell.
282func TestPushSubtreesEntryKinds(t *testing.T) {
283 g, data := newGit(t)
284 ctx := context.Background()
285 f := readertest.NewFixture(t, data, "proj")
286 t.Cleanup(func() { _ = g.Close(ctx) })
287 blob := f.WriteObject("blob", "x\n")
288 tree := rawTree(t, f, [3]string{"100644", "ci.yml", blob})
289 for _, r := range []struct {
290 mode, id string
291 kind domain.EntryKind
292 }{{"040000", tree, domain.EntryDirectory}, {"160000", tree, domain.EntrySubmodule}, {"120000", blob, domain.EntrySymlink}, {"100644", blob, domain.EntryFile}, {"100755", blob, domain.EntryExecutable}} {
293 t.Run(r.mode, func(t *testing.T) {
294 gh := rawTree(t, f, [3]string{r.mode, "workflows", r.id})
295 root := rawTree(t, f, [3]string{"040000", ".github", gh})
296 oid := commitOf(t, f, root)
297 got, err := g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
298 require.NoError(t, err)
299 assert.Equal(t, r.kind, got.Subtrees[oid].Kind)
300 assert.Equal(t, r.id, got.Subtrees[oid].ID.String())
301 assert.NotContains(t, got.Subtrees, domain.HeadKey)
302 if r.mode != "040000" {
303 root = rawTree(t, f, [3]string{r.mode, ".github", r.id})
304 oid = commitOf(t, f, root)
305 got, err = g.PushSubtrees(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: oid}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
306 require.NoError(t, err)
307 assert.NotContains(t, got.Subtrees, oid)
308 }
309 })
310 }
311}
312
313// A history for the rules on the commits a push brings: main holds workflows A,
314// old (a branch) holds B, and the commits made here are in no ref yet, like
315// those of a push in quarantine.
316type guardHistory struct {
317 t *testing.T
318 f *readertest.Fixture
319 trees map[string]string // workflows name -> tree id
320 m, o string // main (A) and old (B)
321 extraN int
322 readme string
323}
324
325func newGuardHistory(t *testing.T, g *gitcli.Git, data string) *guardHistory {
326 t.Helper()
327 f := readertest.NewFixture(t, data, "proj")
328 h := &guardHistory{t: t, f: f, trees: map[string]string{}}
329 h.readme = f.WriteObject("blob", "r\n")
330 for _, n := range []string{"A", "B", "C", "D"} {
331 h.trees[n] = rawTree(t, f, [3]string{"100644", "ci.yml", f.WriteObject("blob", "on: push # "+n+"\n")})
332 }
333 h.m = h.commit("A", "")
334 h.o = h.commit("B", "")
335 f.Git("update-ref", "refs/heads/main", h.m)
336 f.Git("update-ref", "refs/heads/old", h.o)
337 return h
338}
339
340// commit makes a commit with the workflows named ("" for none), one more file
341// each time (so that no two commits share a tree by accident), and the parents.
342func (h *guardHistory) commit(workflows, _ string, parents ...string) string {
343 h.t.Helper()
344 h.extraN++
345 extra := h.f.WriteObject("blob", fmt.Sprintf("file %d\n", h.extraN))
346 entries := [][3]string{{"100644", "README", h.readme}, {"100644", fmt.Sprintf("x%d", h.extraN), extra}}
347 if workflows != "" {
348 gh := rawTree(h.t, h.f, [3]string{"040000", "workflows", h.trees[workflows]})
349 entries = append([][3]string{{"040000", ".github", gh}}, entries...)
350 }
351 return commitOf(h.t, h.f, rawTree(h.t, h.f, entries...), parents...)
352}
353
354func TestPushSubtreesCommitsAndParents(t *testing.T) {
355 g, data := newGit(t)
356 ctx := context.Background()
357 h := newGuardHistory(t, g, data)
358 t.Cleanup(func() { _ = g.Close(ctx) })
359 side := h.commit("B", "", h.o)
360 merge := h.commit("A", "", h.m, side)
361 got, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}, {Old: domain.ZeroOID, New: merge}}, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
362 require.NoError(t, err)
363 want := h.f.Git("rev-list", "--parents", merge, "--not", "--all")
364 var lines []string
365 for _, c := range got.Commits {
366 line := c.ID.String()
367 for _, p := range c.Parents {
368 line += " " + p.String()
369 }
370 lines = append(lines, line)
371 }
372 assert.Equal(t, strings.TrimSpace(want), strings.Join(lines, "\n"))
373 assert.Len(t, got.Commits, 2)
374 assert.Equal(t, h.trees["A"], got.Subtrees[domain.HeadKey].ID.String())
375 assert.Equal(t, h.trees["B"], got.Subtrees[h.o].ID.String())
376 // Any directory path is accepted; the reader has no workflow path policy.
377 other, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: merge}}, ports.PushObjects{}, "README", domain.DefaultPushWalkBounds())
378 require.NoError(t, err)
379 assert.Equal(t, domain.EntryFile, other.Subtrees[merge].Kind)
380 assert.NotContains(t, other.Subtrees, domain.HeadKey, "HEAD is lazy for non-creates")
381}
382
383// The commits of a push are bounded: more than the bound fails closed.
384func TestPushSubtreesBoundsThePushedCommits(t *testing.T) {
385 g, data := newGit(t)
386 ctx := context.Background()
387 h := newGuardHistory(t, g, data)
388 t.Cleanup(func() { _ = g.Close(ctx) })
389 // A chain of plain commits on main, made with fast-import in a ref that is
390 // removed afterwards: the objects stay, in no ref, like a push in quarantine.
391 chain := func(n int) string {
392 var b strings.Builder
393 fmt.Fprintf(&b, "reset refs/tmp/chain\nfrom %s\n", h.m)
394 for i := range n {
395 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)
396 }
397 cmd := exec.Command("git", "--git-dir="+h.f.Bare, "fast-import", "--quiet")
398 cmd.Stdin = strings.NewReader(b.String())
399 out, err := cmd.CombinedOutput()
400 require.NoError(t, err, string(out))
401 tip := h.f.Git("rev-parse", "refs/tmp/chain")
402 h.f.Git("update-ref", "-d", "refs/tmp/chain")
403 return tip
404 }
405 // Plain commits carry the workflows of main (the fast-import tree has none,
406 // so the chain is "removing" them): allowed, whatever their number, up to the bound.
407 p, err := g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(20), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
408 require.NoError(t, err)
409 assert.Len(t, p.Commits, 20)
410 _, err = g.PushSubtrees(ctx, h.f.Name, []domain.RefUpdate{{Old: h.m, New: chain(21), Ref: "refs/heads/main"}}, ports.PushObjects{}, domain.WorkflowDir, domain.PushWalkBounds{Commits: 20, IDs: 80})
411 assert.ErrorIs(t, err, domain.ErrInvalid, "more commits than the bound refuses the push")
412}
413
414// countingReader serves synthetic rev-list output and counts what was read.
415type countingReader struct {
416 lines, read int
417 line []byte
418 pending []byte
419 made int
420}
421
422func (c *countingReader) Read(p []byte) (int, error) {
423 if len(c.pending) == 0 {
424 if c.made == c.lines {
425 return 0, io.EOF
426 }
427 c.made++
428 c.pending = c.line
429 }
430 n := copy(p, c.pending)
431 c.pending = c.pending[n:]
432 c.read += n
433 return n, nil
434}
435
436// A hostile push: ten thousand commits of two hundred parents each is two
437// million ids. The bound is met after the first lines, and nothing more is read
438// or kept.
439func TestParseRevListRejectsManyParentsEarly(t *testing.T) {
440 id := func(n int) string { return fmt.Sprintf("%040x", n+1) }
441 var b strings.Builder
442 b.WriteString(id(0))
443 for i := range 198 {
444 b.WriteString(" " + id(1+i%3)) // few distinct parents, repeated: the distinct count alone would stay small
445 }
446 b.WriteString("\n")
447 r := &countingReader{lines: domain.MaxPushedCommits, line: []byte(b.String())}
448 kept, err := gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
449 require.ErrorIs(t, err, domain.ErrInvalid)
450 assert.Contains(t, err.Error(), "ids (commits and parents)", "refused by the bound on ids, not by the line length")
451 assert.Zero(t, kept, "nothing is returned with the error")
452 total := domain.MaxPushedCommits * len(b.String())
453 assert.Less(t, r.read, 2<<20, "read only the head of %d bytes", total)
454 assert.Less(t, r.read*20, total)
455
456 // Within the bounds the same lines are kept: 100 commits of 198 parents.
457 r = &countingReader{lines: 100, line: []byte(b.String())}
458 kept, err = gitcli.ParseRevList(r, domain.MaxPushedCommits, domain.MaxPushedIDs)
459 require.NoError(t, err)
460 assert.Equal(t, 100, kept)
461
462 // The other bounds and malformed output.
463 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+"\n"+id(1)+"\n"), 1, 100)
464 assert.ErrorIs(t, err, domain.ErrInvalid, "commits")
465 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+" "+id(1)+"\n"+id(2)+"\n"), 10, 2)
466 assert.ErrorIs(t, err, domain.ErrInvalid, "ids")
467 _, err = gitcli.ParseRevList(strings.NewReader("nonsense\n"), 10, 10)
468 assert.ErrorIs(t, err, domain.ErrInvalid, "an id that is none")
469 _, err = gitcli.ParseRevList(strings.NewReader(id(0)+strings.Repeat(" "+id(1), 300)+"\n"), 10, 1000)
470 assert.ErrorIs(t, err, domain.ErrInvalid, "a line over the bound")
471}
472
473func changedWorkflow(g *gitcli.Git, ctx context.Context, repo domain.RepoName, ups []domain.RefUpdate, objects ports.PushObjects) (string, error) {
474 t, err := g.PushSubtrees(ctx, repo, ups, objects, domain.WorkflowDir, domain.DefaultPushWalkBounds())
475 if err != nil {
476 return "", err
477 }
478 c, found, err := domain.WorkflowChangeOf(ups, t)
479 if err != nil || !found {
480 return "", err
481 }
482 entry, err := g.FirstDifferentEntry(ctx, repo, objects, c.Old, c.New)
483 if err != nil {
484 return "", err
485 }
486 if entry == "" {
487 return domain.WorkflowDir, nil
488 }
489 return domain.WorkflowDir + "/" + entry, nil
490}
491
492func TestFirstDifferentEntry(t *testing.T) {
493 g, data := newGit(t)
494 ctx := context.Background()
495 f := readertest.NewFixture(t, data, "proj")
496 t.Cleanup(func() { _ = g.Close(ctx) })
497 tree := oidOf(t, treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tci.yml"))
498 entry, err := g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, domain.ObjectID{}, tree)
499 require.NoError(t, err)
500 assert.Equal(t, "ci.yml", entry)
501 entry, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{}, tree, tree)
502 require.NoError(t, err)
503 assert.Empty(t, entry)
504 _, err = g.FirstDifferentEntry(ctx, f.Name, ports.PushObjects{Dir: t.TempDir()}, domain.ObjectID{}, tree)
505 assert.ErrorIs(t, err, domain.ErrInvalid)
506}
507
508func TestChangedWorkflowPushHistories(t *testing.T) {
509 g, data := newGit(t)
510 ctx := context.Background()
511 h := newGuardHistory(t, g, data)
512 t.Cleanup(func() { _ = g.Close(ctx) })
513 run := func(old, tip, want string) {
514 t.Helper()
515 got, err := changedWorkflow(g, ctx, h.f.Name, []domain.RefUpdate{{Old: old, New: tip, Ref: "refs/heads/pushed"}}, ports.PushObjects{})
516 require.NoError(t, err)
517 assert.Equal(t, want, got)
518 }
519 run(domain.ZeroOID, h.commit("A", "", h.m), "")
520 change := h.commit("C", "", h.m)
521 revert := h.commit("A", "", change)
522 run(h.m, revert, ".github/workflows/ci.yml")
523 run(domain.ZeroOID, h.commit("D", "", h.m, h.o), ".github/workflows/ci.yml")
524 h.f.Git("symbolic-ref", "HEAD", "refs/heads/nothing")
525 run(domain.ZeroOID, h.m, ".github/workflows/ci.yml")
526 ups := []domain.RefUpdate{{Old: domain.ZeroOID, New: h.m, Ref: "refs/tags/t"}}
527 trees, err := g.PushSubtrees(ctx, h.f.Name, ups, ports.PushObjects{}, domain.WorkflowDir, domain.DefaultPushWalkBounds())
528 require.NoError(t, err)
529 assert.NotContains(t, trees.Subtrees, domain.HeadKey)
530 plain := h.commit("", "")
531 h.f.Git("update-ref", "refs/heads/plain", plain)
532 run(domain.ZeroOID, plain, "")
533}