v0.12.0 dccd128

workflow_guard_test.go RawHistoryFolder

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}

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 4

Tags 45

↑↓ moveenter openesc close