// SPDX-License-Identifier: AGPL-3.0-or-later package gitremote_test import ( "context" "os" "os/exec" "path/filepath" "strconv" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/adapters/gitremote" "bnema.dev/ermite/internal/domain" ) func gitBin(t *testing.T) string { t.Helper() bin, err := exec.LookPath("git") if err != nil { t.Skip("git is not installed") } return bin } func run(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = []string{ "PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", } out, err := cmd.CombinedOutput() require.NoError(t, err, "git %s\n%s", strings.Join(args, " "), out) return strings.TrimSpace(string(out)) } // fixture builds /o/r with a branch, a lightweight tag and an annotated // tag, and returns the base as a file:// URL. func fixture(t *testing.T) (base, commit, tagObj string) { t.Helper() root := t.TempDir() repo := filepath.Join(root, "o", "r") require.NoError(t, os.MkdirAll(repo, 0o755)) run(t, repo, "init", "-q", "--initial-branch=main") require.NoError(t, os.WriteFile(filepath.Join(repo, "a"), []byte("a"), 0o644)) run(t, repo, "add", "a") run(t, repo, "commit", "-q", "-m", "a") commit = run(t, repo, "rev-parse", "HEAD") run(t, repo, "tag", "light") run(t, repo, "tag", "-a", "-m", "annotated", "v1") tagObj = run(t, repo, "rev-parse", "refs/tags/v1") return "file://" + root, commit, tagObj } func newRemote(t *testing.T, base string, mut func(*gitremote.Config)) *gitremote.Remote { t.Helper() cfg := gitremote.Config{Bin: gitBin(t), Path: os.Getenv("PATH"), Home: t.TempDir(), BaseURL: base, AllowedProtocols: "file"} if mut != nil { mut(&cfg) } return gitremote.New(cfg) } func TestListRefsPeelsAnnotatedTags(t *testing.T) { base, commit, tagObj := fixture(t) refs, err := newRemote(t, base, nil).ListRefs(context.Background(), "o", "r") require.NoError(t, err) byName := map[string][2]string{} for _, r := range refs { byName[r.Name] = [2]string{r.ID, r.Peeled} } assert.Equal(t, [2]string{commit, ""}, byName["refs/heads/main"]) assert.Equal(t, [2]string{commit, ""}, byName["refs/tags/light"]) assert.Equal(t, [2]string{tagObj, commit}, byName["refs/tags/v1"], "the annotated tag has its commit peeled") assert.Len(t, refs, 3) } // github.com answers a repository that does not exist, or that is private, with // a 404 that git reports as a missing repository or as a prompt it may not // show; both are "repository not found". Any other failure is unavailable. func TestListRefsMapsWhatGitSays(t *testing.T) { fake := func(stderr string) string { bin := filepath.Join(t.TempDir(), "git") require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\necho '"+stderr+"' >&2\nexit 128\n"), 0o755)) return bin } for _, tc := range []struct { stderr string want error }{ {"remote: Repository not found.\nfatal: repository 'https://github.com/o/x/' not found", domain.ErrNotFound}, {"fatal: could not read Username for 'https://github.com': terminal prompts disabled", domain.ErrNotFound}, {"fatal: unable to access 'https://github.com/o/r/': Could not resolve host: github.com", domain.ErrUnavailable}, {"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 503", domain.ErrUnavailable}, } { _, err := newRemote(t, "https://github.com", func(c *gitremote.Config) { c.Bin = fake(tc.stderr) }).ListRefs(context.Background(), "o", "r") assert.ErrorIs(t, err, tc.want, tc.stderr) } } func TestListRefsRefusesBadNames(t *testing.T) { r := newRemote(t, "file:///nonexistent", nil) for _, bad := range [][2]string{{"", "r"}, {"o", ""}, {"-o", "r"}, {"o", "r/x"}, {"o", "r s"}, {"..", "r"}, {"o", "r?x=1"}} { _, err := r.ListRefs(context.Background(), bad[0], bad[1]) assert.ErrorIs(t, err, domain.ErrNotFound, "%q/%q", bad[0], bad[1]) } } func TestListRefsBoundsTheOutput(t *testing.T) { base, _, _ := fixture(t) _, err := newRemote(t, base, func(c *gitremote.Config) { c.MaxOutput = 40 }).ListRefs(context.Background(), "o", "r") assert.ErrorIs(t, err, domain.ErrUnavailable) } func TestListRefsTimesOut(t *testing.T) { bin := filepath.Join(t.TempDir(), "git") require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755)) start := time.Now() _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 400*time.Millisecond }).ListRefs(context.Background(), "o", "r") assert.ErrorIs(t, err, domain.ErrUnavailable) // One overall deadline, both attempts and the clean-up inside it. assert.Less(t, time.Since(start), 1*time.Second) } // dead tells whether the process pid is gone or a zombie (not reaped yet, as // under a subreaper that does not wait): either way it runs no more. func dead(pid int) bool { raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") if err != nil { return true } // "pid (comm) S ...": comm may hold spaces and parentheses. s := string(raw) i := strings.LastIndexByte(s, ')') return i < 0 || len(s) < i+3 || s[i+2] == 'Z' || s[i+2] == 'X' } // git runs git-remote-https, the one that connects: when the time is up it is // killed too, and does not outlive the call. Each attempt starts one. func TestListRefsKillsTheHelpersOfGit(t *testing.T) { if _, err := os.Stat("/proc/self/stat"); err != nil { t.Skip("needs /proc") } dir := t.TempDir() pids := filepath.Join(dir, "pids") bin := filepath.Join(dir, "git") script := "#!/bin/sh\nsleep 30 &\necho $! >> '" + pids + "'\nwait\n" require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 600*time.Millisecond }).ListRefs(context.Background(), "o", "r") assert.ErrorIs(t, err, domain.ErrUnavailable) raw, rerr := os.ReadFile(pids) require.NoError(t, rerr) fields := strings.Fields(string(raw)) require.Len(t, fields, 2, "one helper per attempt") for _, f := range fields { pid, aerr := strconv.Atoi(f) require.NoError(t, aerr) assert.Eventually(t, func() bool { return dead(pid) }, 2*time.Second, 20*time.Millisecond, "the helper %d of git is still running", pid) } } // A helper that left the group (setsid) and keeps the output of git open // cannot hold the call past its Timeout: the clean-up is inside the budget. func TestListRefsStaysInsideTimeoutWithAHelperHoldingTheOutput(t *testing.T) { setsid, err := exec.LookPath("setsid") if err != nil { t.Skip("needs setsid") } dir := t.TempDir() pids := filepath.Join(dir, "pids") bin := filepath.Join(dir, "git") script := "#!/bin/sh\n'" + setsid + "' sleep 30 &\necho $! >> '" + pids + "'\nwait\n" require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) t.Cleanup(func() { raw, _ := os.ReadFile(pids) for _, f := range strings.Fields(string(raw)) { if pid, err := strconv.Atoi(f); err == nil { if p, err := os.FindProcess(pid); err == nil { _ = p.Kill() } } } }) const timeout = 400 * time.Millisecond r := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, timeout }) start := time.Now() _, err = r.ListRefs(context.Background(), "o", "r") assert.ErrorIs(t, err, domain.ErrUnavailable) // Generous for a loaded CI, still far below a clean-up outside the budget // (a full WaitDelay of seconds). assert.LessOrEqual(t, time.Since(start), timeout+250*time.Millisecond) } // The helper of a git that exits by itself does not outlive it either. func TestListRefsKillsTheHelpersOfAGitThatExited(t *testing.T) { if _, err := os.Stat("/proc/self/stat"); err != nil { t.Skip("needs /proc") } dir := t.TempDir() pidFile := filepath.Join(dir, "pid") bin := filepath.Join(dir, "git") script := "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\necho $! > '" + pidFile + "'\nexit 0\n" require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r") require.NoError(t, err) raw, rerr := os.ReadFile(pidFile) require.NoError(t, rerr) pid, aerr := strconv.Atoi(strings.TrimSpace(string(raw))) require.NoError(t, aerr) assert.Eventually(t, func() bool { return dead(pid) }, 2*time.Second, 20*time.Millisecond) } // A connection github.com never answers is run again within the same bound, // and the second one can succeed. func TestListRefsRunsAgainAfterATimeOut(t *testing.T) { dir := t.TempDir() bin := filepath.Join(dir, "git") id := strings.Repeat("a", domain.ObjectIDLen) script := "#!/bin/sh\nif [ ! -e '" + dir + "/seen' ]; then touch '" + dir + "/seen'; sleep 30; fi\nprintf '" + id + "\\trefs/heads/main\\n'\n" require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) start := time.Now() refs, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 1000*time.Millisecond }).ListRefs(context.Background(), "o", "r") require.NoError(t, err) require.Len(t, refs, 1) assert.Equal(t, "refs/heads/main", refs[0].Name) assert.Less(t, time.Since(start), 1500*time.Millisecond) } func TestListRefsStopsWhenTheCallerGivesUp(t *testing.T) { bin := filepath.Join(t.TempDir(), "git") require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755)) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() start := time.Now() _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(ctx, "o", "r") assert.ErrorIs(t, err, context.DeadlineExceeded) assert.Less(t, time.Since(start), 1500*time.Millisecond) } // git asks for no credentials and runs with no configuration of the machine: // the environment it gets is only what the adapter builds. func TestListRefsGitEnvironment(t *testing.T) { out := filepath.Join(t.TempDir(), "env") bin := filepath.Join(t.TempDir(), "git") script := "#!/bin/sh\nenv > '" + out + "'\nprintf '%s\\n' \"$*\" >> '" + out + "'\nexit 0\n" require.NoError(t, os.WriteFile(bin, []byte(script), 0o755)) t.Setenv("GITHUB_TOKEN", "secret-from-the-process") _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.AllowedProtocols = bin, "" }).ListRefs(context.Background(), "o", "r") require.NoError(t, err) b, err := os.ReadFile(out) require.NoError(t, err) env := string(b) assert.Contains(t, env, "GIT_TERMINAL_PROMPT=0") assert.Contains(t, env, "GIT_ALLOW_PROTOCOL=https") assert.NotContains(t, env, "secret-from-the-process") assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*") assert.Contains(t, env, "credential.helper=") } // GO-008: the proxy variables of ermite reach git, and nothing else of its // environment does. func TestListRefsForwardsTheProxyEnvironment(t *testing.T) { out := filepath.Join(t.TempDir(), "env") bin := filepath.Join(t.TempDir(), "git") require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755)) t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128") t.Setenv("no_proxy", "localhost,.internal") t.Setenv("HTTP_PROXY", "") t.Setenv("GITHUB_TOKEN", "not-for-git") proxy := gitremote.ProxyEnv(os.Getenv) assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out") _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r") require.NoError(t, err) b, err := os.ReadFile(out) require.NoError(t, err) env := string(b) assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128") assert.Contains(t, env, "no_proxy=localhost,.internal") assert.NotContains(t, env, "not-for-git") assert.NotContains(t, env, "HTTP_PROXY=") // Without Config.Proxy nothing is forwarded, whatever the process has. _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r") require.NoError(t, err) b, _ = os.ReadFile(out) assert.NotContains(t, string(b), "proxy.internal") }