// SPDX-License-Identifier: AGPL-3.0-or-later package gitcli_test import ( "context" "encoding/hex" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/adapters/gitcli" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" "bnema.dev/ermite/internal/readertest" ) func TestChangedWorkflow(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") base := f.Commit("base") f.Write(".github/workflows/ci.yml", "on: push\n") withWF := f.Commit("add workflow") f.Write("docs/x.md", "x\n") docs := f.Commit("docs") f.Write(".github/workflows/ci.yml", "on: [push]\n") edited := f.Commit("edit workflow") f.Write(".github/workflows/other.yml", "on: push\n") two := f.Commit("a second workflow") f.Remove(".github/workflows/ci.yml") oneLeft := f.Commit("remove one of two") f.Remove(".github/workflows/other.yml") removed := f.Commit("remove the last workflow") f.Write(".github/workflows-notes/a.yml", "x\n") lookalike := f.Commit("a look-alike directory") oid := func(s string) string { return s[:40] } up := func(old, new string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: "refs/heads/x"} } changed := func(ups ...domain.RefUpdate) string { p, err := g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) require.NoError(t, err) return p } assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(withWF))), "a file created") assert.Equal(t, "", changed(up(oid(withWF), oid(docs))), "another file changed") assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(docs), oid(edited))), "a file modified") 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") assert.Equal(t, "", changed(up(oid(oneLeft), oid(removed))), "the directory removed with its last file is allowed") assert.Equal(t, "", changed(up(oid(removed), oid(lookalike))), "only .github/workflows/ counts") assert.Equal(t, ".github/workflows/ci.yml", changed(up(domain.ZeroOID, oid(withWF))), "a new ref carries the workflows of its commit") assert.Equal(t, "", changed(up(domain.ZeroOID, oid(base))), "a new ref without workflows") assert.Equal(t, "", changed(up(oid(withWF), domain.ZeroOID)), "a deleted ref changes nothing") assert.Equal(t, ".github/workflows/ci.yml", changed(up(oid(base), oid(docs)), up(oid(docs), oid(edited))), "the first ref that changes one") assert.Equal(t, "", changed(), "no update") _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{up(oid(base), "1111111111111111111111111111111111111111")}, ports.PushObjects{}) assert.Error(t, err, "an object git does not have is a failure, not a pass") } func TestChangedWorkflowReadsTheQuarantineOfThePushOnly(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") f.Commit("base") objects := filepath.Join(f.Bare, "objects") up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}} for name, o := range map[string]ports.PushObjects{ "outside the repository": {Dir: filepath.Join(data, "elsewhere")}, "a relative path": {Dir: "objects/incoming"}, "parent of the objects": {Dir: f.Bare}, "a dotdot out of objects": {Dir: objects + "/../../other.git/objects"}, "an alternate outside": {Dir: objects + "/incoming-1", Alternates: "/tmp"}, "alternates without a dir": {Alternates: objects}, } { _, err := g.ChangedWorkflow(ctx, f.Name, up, o) assert.ErrorIs(t, err, domain.ErrInvalid, name) } // An existing directory inside the repository is accepted as a location // (the object is missing: a refusal of the object, not of the path). require.NoError(t, os.Mkdir(filepath.Join(objects, "incoming-1"), 0o700)) _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-1", Alternates: objects}) require.Error(t, err) assert.NotContains(t, err.Error(), "outside the repository") // One that does not exist is refused. _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/incoming-2"}) assert.ErrorIs(t, err, domain.ErrInvalid) } // A symbolic link in the way cannot take the objects of a push out of the // repository, and a data directory reached through links still works. func TestChangedWorkflowResolvesSymbolicLinks(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") f.Commit("base") up := []domain.RefUpdate{{Old: domain.ZeroOID, New: "1111111111111111111111111111111111111111", Ref: "refs/heads/x"}} objects := filepath.Join(f.Bare, "objects") // Inside objects/, a link to elsewhere is outside once resolved. elsewhere := t.TempDir() require.NoError(t, os.Symlink(elsewhere, filepath.Join(objects, "incoming-link"))) _, err := g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "incoming-link")}) assert.ErrorIs(t, err, domain.ErrInvalid, "a link out of objects/") _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: objects + "/pack", Alternates: filepath.Join(objects, "incoming-link")}) assert.ErrorIs(t, err, domain.ErrInvalid, "an alternate that is a link out of objects/") // A link to somewhere inside objects/ is fine. require.NoError(t, os.Mkdir(filepath.Join(objects, "q1"), 0o700)) require.NoError(t, os.Symlink(filepath.Join(objects, "q1"), filepath.Join(objects, "q-link"))) _, err = g.ChangedWorkflow(ctx, f.Name, up, ports.PushObjects{Dir: filepath.Join(objects, "q-link")}) require.Error(t, err) assert.NotContains(t, err.Error(), "outside the repository") } func TestChangedWorkflowThroughALinkedDataDirectory(t *testing.T) { _, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") f.Write("README", "r\n") base := f.Commit("base") f.Write(".github/workflows/ci.yml", "on: push\n") wf := f.Commit("workflow") // The server reaches the same data directory through a link, while git // (the hook) reports the real path: both must be accepted as inside. parent := t.TempDir() link := filepath.Join(parent, "data") require.NoError(t, os.Symlink(data, link)) bin, err := exec.LookPath("git") require.NoError(t, err) g2 := gitcli.New(gitcli.Config{Bin: bin, DataDir: link, Path: os.Getenv("PATH")}) t.Cleanup(func() { _ = g2.Close(ctx) }) q := filepath.Join(f.Bare, "objects", "incoming-9") require.NoError(t, os.Mkdir(q, 0o700)) for _, dir := range []string{q, filepath.Join(link, "repos", "proj.git", "objects", "incoming-9")} { p, err := g2.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: oidOf(t, base).String(), New: oidOf(t, wf).String(), Ref: "refs/heads/x"}}, ports.PushObjects{Dir: dir, Alternates: filepath.Join(link, "repos", "proj.git", "objects")}) require.NoError(t, err, dir) assert.Equal(t, ".github/workflows/ci.yml", p, dir) } } // treeOf writes a tree of the given entries ("mode type sha\tname") and returns it. func treeOf(t *testing.T, f *readertest.Fixture, entries ...string) string { t.Helper() cmd := exec.Command("git", "--git-dir="+f.Bare, "mktree") cmd.Stdin = strings.NewReader(strings.Join(entries, "\n") + "\n") out, err := cmd.CombinedOutput() require.NoError(t, err, string(out)) return strings.TrimSpace(string(out)) } func commitOf(t *testing.T, f *readertest.Fixture, tree string, parents ...string) string { t.Helper() args := []string{"--git-dir=" + f.Bare, "commit-tree", tree, "-m", "c"} for _, p := range parents { args = append(args, "-p", p) } cmd := exec.Command("git", args...) cmd.Env = []string{"GIT_AUTHOR_NAME=a", "GIT_AUTHOR_EMAIL=a@b", "GIT_COMMITTER_NAME=a", "GIT_COMMITTER_EMAIL=a@b"} out, err := cmd.CombinedOutput() require.NoError(t, err, string(out)) return strings.TrimSpace(string(out)) } // withWorkflows is a commit whose .github/workflows is the tree given. func withWorkflows(t *testing.T, f *readertest.Fixture, workflows string) string { t.Helper() gh := treeOf(t, f, "040000 tree "+workflows+"\tworkflows") return commitOf(t, f, treeOf(t, f, "040000 tree "+gh+"\t.github")) } // The trees of a push are the pusher's: a workflows directory that fans out // into ten million paths is answered with its first path, at once, by a git // that is stopped, without the diff ever being held in memory. func TestChangedWorkflowDoesNotBufferTheDiff(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) blob := f.WriteObject("blob", "x\n") level := treeOf(t, f, "100644 blob "+blob+"\tf") for range 7 { // ten entries per level, all the same subtree: 10^7 paths var entries []string for i := range 10 { entries = append(entries, fmt.Sprintf("040000 tree %s\td%d", level, i)) } level = treeOf(t, f, entries...) } amp := withWorkflows(t, f, level) var before, after runtime.MemStats runtime.GC() runtime.ReadMemStats(&before) start := time.Now() p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: amp, Ref: "refs/heads/x"}}, ports.PushObjects{}) took := time.Since(start) runtime.ReadMemStats(&after) require.NoError(t, err) assert.True(t, strings.HasPrefix(p, ".github/workflows/d"), p) assert.Less(t, took, 2*time.Second, "answered from the first path") assert.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(4<<20), "bounded memory") // And no git is left behind. out, _ := exec.Command("pgrep", "-f", "git --no-replace-objects.*diff-tree.*"+amp).CombinedOutput() assert.Empty(t, strings.TrimSpace(string(out)), "git was reaped") } // A path longer than the bound fails closed: the push is refused, not read on. func TestChangedWorkflowRefusesAnEndlessPath(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) level := treeOf(t, f, "100644 blob "+f.WriteObject("blob", "x\n")+"\tf") level = treeOf(t, f, "040000 tree "+level+"\t"+strings.Repeat("d", 5000)) // an entry name of 5000 bytes _, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: domain.ZeroOID, New: withWorkflows(t, f, level), Ref: "refs/heads/x"}}, ports.PushObjects{}) assert.ErrorIs(t, err, domain.ErrInvalid) } // A tag may point to a blob, a tree or a commit; only the commits and trees can // hold a workflow, and an object that is not there refuses the push. func TestChangedWorkflowPeelsAndClassifiesBothEnds(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") plain := f.Commit("plain") f.Write(".github/workflows/ci.yml", "on: push\n") wf := f.Commit("workflow") blob := f.WriteObject("blob", "hello\n") annotatedBlob := f.WriteObject("tag", "object "+blob+"\ntype blob\ntag b\ntagger a 1 +0000\n\nm\n") annotatedWF := f.WriteObject("tag", "object "+oidOf(t, wf).String()+"\ntype commit\ntag c\ntagger a 1 +0000\n\nm\n") rootTree := f.Git("rev-parse", wf+"^{tree}") ref := func(old, new string) []domain.RefUpdate { return []domain.RefUpdate{{Old: old, New: new, Ref: "refs/tags/t"}} } changed := func(ups []domain.RefUpdate) (string, error) { return g.ChangedWorkflow(ctx, f.Name, ups, ports.PushObjects{}) } for name, ups := range map[string][]domain.RefUpdate{ "create a lightweight tag on a blob": ref(domain.ZeroOID, blob), "create an annotated tag on a blob": ref(domain.ZeroOID, annotatedBlob), "a tag from a blob to another blob": ref(blob, annotatedBlob), "a tag from a commit with workflows to a blob": ref(oidOf(t, wf).String(), blob), } { p, err := changed(ups) require.NoError(t, err, name) assert.Empty(t, p, name) } for name, ups := range map[string][]domain.RefUpdate{ "a tag from a blob to a commit with workflows": ref(blob, oidOf(t, wf).String()), "a tag from an annotated blob to a commit with workflows": ref(annotatedBlob, oidOf(t, wf).String()), "an annotated tag on a commit with workflows": ref(domain.ZeroOID, annotatedWF), "a tag on a tree with workflows": ref(domain.ZeroOID, rootTree), "from a commit without to one with": ref(oidOf(t, plain).String(), annotatedWF), } { p, err := changed(ups) require.NoError(t, err, name) assert.Equal(t, ".github/workflows/ci.yml", p, name) } for name, ups := range map[string][]domain.RefUpdate{ "a missing new object": ref(domain.ZeroOID, "1111111111111111111111111111111111111111"), "a missing old object": ref("2222222222222222222222222222222222222222", oidOf(t, wf).String()), "a malformed object": ref(domain.ZeroOID, "ZZZZ"), } { _, err := changed(ups) assert.Error(t, err, name) } } // rawTree writes a tree object by hand: entries are "mode name hexid", in the // order given (git sorts them; the callers give them sorted). func rawTree(t *testing.T, f *readertest.Fixture, entries ...[3]string) string { t.Helper() var b strings.Builder for _, e := range entries { raw, err := hex.DecodeString(e[2]) require.NoError(t, err) b.WriteString(strings.TrimPrefix(e[0], "0") + " " + e[1] + "\x00") // git writes 40000, not 040000 b.Write(raw) } return f.WriteObject("tree", b.String()) } // What counts as .github/workflows is what the loader lists: a directory of // mode 040000 below a directory .github of mode 040000. The same tree id as a // gitlink, a symbolic link or a file is nothing, and turning it into a // directory activates it; the object id alone does not tell. func TestChangedWorkflowComparesTheModeOfTheEntries(t *testing.T) { g, data := newGit(t) ctx := context.Background() f := readertest.NewFixture(t, data, "proj") t.Cleanup(func() { _ = g.Close(ctx) }) f.Write("README", "r\n") f.Commit("base") yml := f.WriteObject("blob", "on: push\n") wfTree := rawTree(t, f, [3]string{"100644", "ci.yml", yml}) // the workflows link := f.WriteObject("blob", ".github/other") file := f.WriteObject("blob", "not a directory\n") readme := f.WriteObject("blob", "r\n") // A commit whose .github/workflows has the mode and id given. withWorkflows := func(mode, id string) string { gh := rawTree(t, f, [3]string{mode, "workflows", id}) return commitOf(t, f, rawTree(t, f, [3]string{"040000", ".github", gh})) } // A commit whose .github has the mode and id given. withGithub := func(mode, id string) string { return commitOf(t, f, rawTree(t, f, [3]string{mode, ".github", id})) } asDir := withWorkflows("040000", wfTree) asGitlink := withWorkflows("160000", wfTree) // the same id, a submodule asSymlink := withWorkflows("120000", link) asFile := withWorkflows("100644", file) none := commitOf(t, f, rawTree(t, f, [3]string{"100644", "README", readme})) ghDir := rawTree(t, f, [3]string{"040000", "workflows", wfTree}) githubDir := withGithub("040000", ghDir) githubGitlink := withGithub("160000", ghDir) // the same id, a submodule githubFile := withGithub("100644", file) githubLink := withGithub("120000", link) for name, c := range map[string]struct { old, new string want string }{ "gitlink to a directory, same id (the bypass)": {asGitlink, asDir, ".github/workflows/ci.yml"}, "a directory to a gitlink removes it": {asDir, asGitlink, ""}, "symlink to a directory": {asSymlink, asDir, ".github/workflows/ci.yml"}, "a directory to a symlink removes it": {asDir, asSymlink, ""}, "file to a directory": {asFile, asDir, ".github/workflows/ci.yml"}, "a directory to a file removes it": {asDir, asFile, ""}, "nothing to a directory": {none, asDir, ".github/workflows/ci.yml"}, "a directory to nothing": {asDir, none, ""}, "the same gitlink": {asGitlink, asGitlink, ""}, "gitlink to symlink": {asGitlink, asSymlink, ""}, ".github a gitlink to a directory, same id": {githubGitlink, githubDir, ".github/workflows/ci.yml"}, ".github a directory to a gitlink": {githubDir, githubGitlink, ""}, ".github a file to a directory": {githubFile, githubDir, ".github/workflows/ci.yml"}, ".github a symlink to a directory": {githubLink, githubDir, ".github/workflows/ci.yml"}, ".github a directory to a symlink": {githubDir, githubLink, ""}, "a new ref with a gitlink": {domain.ZeroOID, asGitlink, ""}, "a new ref with a symlink": {domain.ZeroOID, asSymlink, ""}, "a new ref with .github a gitlink": {domain.ZeroOID, githubGitlink, ""}, "a new ref with a directory": {domain.ZeroOID, asDir, ".github/workflows/ci.yml"}, } { p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: c.old, New: c.new, Ref: "refs/heads/x"}}, ports.PushObjects{}) require.NoError(t, err, name) assert.Equal(t, c.want, p, name) } // A tag that peels to the commit is judged the same way. tag := f.WriteObject("tag", "object "+asDir+"\ntype commit\ntag t\ntagger a 1 +0000\n\nm\n") p, err := g.ChangedWorkflow(ctx, f.Name, []domain.RefUpdate{{Old: asGitlink, New: tag, Ref: "refs/tags/t"}}, ports.PushObjects{}) require.NoError(t, err) assert.Equal(t, ".github/workflows/ci.yml", p) }