1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package gitremote_test
4
5import (
6 "context"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/adapters/gitremote"
19 "bnema.dev/ermite/internal/domain"
20)
21
22func gitBin(t *testing.T) string {
23 t.Helper()
24 bin, err := exec.LookPath("git")
25 if err != nil {
26 t.Skip("git is not installed")
27 }
28 return bin
29}
30
31func run(t *testing.T, dir string, args ...string) string {
32 t.Helper()
33 cmd := exec.Command("git", args...)
34 cmd.Dir = dir
35 cmd.Env = []string{
36 "PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1",
37 "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x",
38 }
39 out, err := cmd.CombinedOutput()
40 require.NoError(t, err, "git %s\n%s", strings.Join(args, " "), out)
41 return strings.TrimSpace(string(out))
42}
43
44// fixture builds <base>/o/r with a branch, a lightweight tag and an annotated
45// tag, and returns the base as a file:// URL.
46func fixture(t *testing.T) (base, commit, tagObj string) {
47 t.Helper()
48 root := t.TempDir()
49 repo := filepath.Join(root, "o", "r")
50 require.NoError(t, os.MkdirAll(repo, 0o755))
51 run(t, repo, "init", "-q", "--initial-branch=main")
52 require.NoError(t, os.WriteFile(filepath.Join(repo, "a"), []byte("a"), 0o644))
53 run(t, repo, "add", "a")
54 run(t, repo, "commit", "-q", "-m", "a")
55 commit = run(t, repo, "rev-parse", "HEAD")
56 run(t, repo, "tag", "light")
57 run(t, repo, "tag", "-a", "-m", "annotated", "v1")
58 tagObj = run(t, repo, "rev-parse", "refs/tags/v1")
59 return "file://" + root, commit, tagObj
60}
61
62func newRemote(t *testing.T, base string, mut func(*gitremote.Config)) *gitremote.Remote {
63 t.Helper()
64 cfg := gitremote.Config{Bin: gitBin(t), Path: os.Getenv("PATH"), Home: t.TempDir(), BaseURL: base, AllowedProtocols: "file"}
65 if mut != nil {
66 mut(&cfg)
67 }
68 return gitremote.New(cfg)
69}
70
71func TestListRefsPeelsAnnotatedTags(t *testing.T) {
72 base, commit, tagObj := fixture(t)
73 refs, err := newRemote(t, base, nil).ListRefs(context.Background(), "o", "r")
74 require.NoError(t, err)
75 byName := map[string][2]string{}
76 for _, r := range refs {
77 byName[r.Name] = [2]string{r.ID, r.Peeled}
78 }
79 assert.Equal(t, [2]string{commit, ""}, byName["refs/heads/main"])
80 assert.Equal(t, [2]string{commit, ""}, byName["refs/tags/light"])
81 assert.Equal(t, [2]string{tagObj, commit}, byName["refs/tags/v1"], "the annotated tag has its commit peeled")
82 assert.Len(t, refs, 3)
83}
84
85// github.com answers a repository that does not exist, or that is private, with
86// a 404 that git reports as a missing repository or as a prompt it may not
87// show; both are "repository not found". Any other failure is unavailable.
88func TestListRefsMapsWhatGitSays(t *testing.T) {
89 fake := func(stderr string) string {
90 bin := filepath.Join(t.TempDir(), "git")
91 require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\necho '"+stderr+"' >&2\nexit 128\n"), 0o755))
92 return bin
93 }
94 for _, tc := range []struct {
95 stderr string
96 want error
97 }{
98 {"remote: Repository not found.\nfatal: repository 'https://github.com/o/x/' not found", domain.ErrNotFound},
99 {"fatal: could not read Username for 'https://github.com': terminal prompts disabled", domain.ErrNotFound},
100 {"fatal: unable to access 'https://github.com/o/r/': Could not resolve host: github.com", domain.ErrUnavailable},
101 {"fatal: unable to access 'https://github.com/o/r/': The requested URL returned error: 503", domain.ErrUnavailable},
102 } {
103 _, err := newRemote(t, "https://github.com", func(c *gitremote.Config) { c.Bin = fake(tc.stderr) }).ListRefs(context.Background(), "o", "r")
104 assert.ErrorIs(t, err, tc.want, tc.stderr)
105 }
106}
107
108func TestListRefsRefusesBadNames(t *testing.T) {
109 r := newRemote(t, "file:///nonexistent", nil)
110 for _, bad := range [][2]string{{"", "r"}, {"o", ""}, {"-o", "r"}, {"o", "r/x"}, {"o", "r s"}, {"..", "r"}, {"o", "r?x=1"}} {
111 _, err := r.ListRefs(context.Background(), bad[0], bad[1])
112 assert.ErrorIs(t, err, domain.ErrNotFound, "%q/%q", bad[0], bad[1])
113 }
114}
115
116func TestListRefsBoundsTheOutput(t *testing.T) {
117 base, _, _ := fixture(t)
118 _, err := newRemote(t, base, func(c *gitremote.Config) { c.MaxOutput = 40 }).ListRefs(context.Background(), "o", "r")
119 assert.ErrorIs(t, err, domain.ErrUnavailable)
120}
121
122func TestListRefsTimesOut(t *testing.T) {
123 bin := filepath.Join(t.TempDir(), "git")
124 require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755))
125 start := time.Now()
126 _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 400*time.Millisecond }).ListRefs(context.Background(), "o", "r")
127 assert.ErrorIs(t, err, domain.ErrUnavailable)
128 // One overall deadline, both attempts and the clean-up inside it.
129 assert.Less(t, time.Since(start), 1*time.Second)
130}
131
132// dead tells whether the process pid is gone or a zombie (not reaped yet, as
133// under a subreaper that does not wait): either way it runs no more.
134func dead(pid int) bool {
135 raw, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
136 if err != nil {
137 return true
138 }
139 // "pid (comm) S ...": comm may hold spaces and parentheses.
140 s := string(raw)
141 i := strings.LastIndexByte(s, ')')
142 return i < 0 || len(s) < i+3 || s[i+2] == 'Z' || s[i+2] == 'X'
143}
144
145// git runs git-remote-https, the one that connects: when the time is up it is
146// killed too, and does not outlive the call. Each attempt starts one.
147func TestListRefsKillsTheHelpersOfGit(t *testing.T) {
148 if _, err := os.Stat("/proc/self/stat"); err != nil {
149 t.Skip("needs /proc")
150 }
151 dir := t.TempDir()
152 pids := filepath.Join(dir, "pids")
153 bin := filepath.Join(dir, "git")
154 script := "#!/bin/sh\nsleep 30 &\necho $! >> '" + pids + "'\nwait\n"
155 require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
156 _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 600*time.Millisecond }).ListRefs(context.Background(), "o", "r")
157 assert.ErrorIs(t, err, domain.ErrUnavailable)
158 raw, rerr := os.ReadFile(pids)
159 require.NoError(t, rerr)
160 fields := strings.Fields(string(raw))
161 require.Len(t, fields, 2, "one helper per attempt")
162 for _, f := range fields {
163 pid, aerr := strconv.Atoi(f)
164 require.NoError(t, aerr)
165 assert.Eventually(t, func() bool { return dead(pid) }, 2*time.Second, 20*time.Millisecond, "the helper %d of git is still running", pid)
166 }
167}
168
169// A helper that left the group (setsid) and keeps the output of git open
170// cannot hold the call past its Timeout: the clean-up is inside the budget.
171func TestListRefsStaysInsideTimeoutWithAHelperHoldingTheOutput(t *testing.T) {
172 setsid, err := exec.LookPath("setsid")
173 if err != nil {
174 t.Skip("needs setsid")
175 }
176 dir := t.TempDir()
177 pids := filepath.Join(dir, "pids")
178 bin := filepath.Join(dir, "git")
179 script := "#!/bin/sh\n'" + setsid + "' sleep 30 &\necho $! >> '" + pids + "'\nwait\n"
180 require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
181 t.Cleanup(func() {
182 raw, _ := os.ReadFile(pids)
183 for _, f := range strings.Fields(string(raw)) {
184 if pid, err := strconv.Atoi(f); err == nil {
185 if p, err := os.FindProcess(pid); err == nil {
186 _ = p.Kill()
187 }
188 }
189 }
190 })
191 const timeout = 400 * time.Millisecond
192 r := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, timeout })
193 start := time.Now()
194 _, err = r.ListRefs(context.Background(), "o", "r")
195 assert.ErrorIs(t, err, domain.ErrUnavailable)
196 // Generous for a loaded CI, still far below a clean-up outside the budget
197 // (a full WaitDelay of seconds).
198 assert.LessOrEqual(t, time.Since(start), timeout+250*time.Millisecond)
199}
200
201// The helper of a git that exits by itself does not outlive it either.
202func TestListRefsKillsTheHelpersOfAGitThatExited(t *testing.T) {
203 if _, err := os.Stat("/proc/self/stat"); err != nil {
204 t.Skip("needs /proc")
205 }
206 dir := t.TempDir()
207 pidFile := filepath.Join(dir, "pid")
208 bin := filepath.Join(dir, "git")
209 script := "#!/bin/sh\nsleep 30 >/dev/null 2>&1 &\necho $! > '" + pidFile + "'\nexit 0\n"
210 require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
211 _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
212 require.NoError(t, err)
213 raw, rerr := os.ReadFile(pidFile)
214 require.NoError(t, rerr)
215 pid, aerr := strconv.Atoi(strings.TrimSpace(string(raw)))
216 require.NoError(t, aerr)
217 assert.Eventually(t, func() bool { return dead(pid) }, 2*time.Second, 20*time.Millisecond)
218}
219
220// A connection github.com never answers is run again within the same bound,
221// and the second one can succeed.
222func TestListRefsRunsAgainAfterATimeOut(t *testing.T) {
223 dir := t.TempDir()
224 bin := filepath.Join(dir, "git")
225 id := strings.Repeat("a", domain.ObjectIDLen)
226 script := "#!/bin/sh\nif [ ! -e '" + dir + "/seen' ]; then touch '" + dir + "/seen'; sleep 30; fi\nprintf '" + id + "\\trefs/heads/main\\n'\n"
227 require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
228 start := time.Now()
229 refs, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin, c.Timeout = bin, 1000*time.Millisecond }).ListRefs(context.Background(), "o", "r")
230 require.NoError(t, err)
231 require.Len(t, refs, 1)
232 assert.Equal(t, "refs/heads/main", refs[0].Name)
233 assert.Less(t, time.Since(start), 1500*time.Millisecond)
234}
235
236func TestListRefsStopsWhenTheCallerGivesUp(t *testing.T) {
237 bin := filepath.Join(t.TempDir(), "git")
238 require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nsleep 30\n"), 0o755))
239 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
240 defer cancel()
241 start := time.Now()
242 _, err := newRemote(t, "file:///x", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(ctx, "o", "r")
243 assert.ErrorIs(t, err, context.DeadlineExceeded)
244 assert.Less(t, time.Since(start), 1500*time.Millisecond)
245}
246
247// git asks for no credentials and runs with no configuration of the machine:
248// the environment it gets is only what the adapter builds.
249func TestListRefsGitEnvironment(t *testing.T) {
250 out := filepath.Join(t.TempDir(), "env")
251 bin := filepath.Join(t.TempDir(), "git")
252 script := "#!/bin/sh\nenv > '" + out + "'\nprintf '%s\\n' \"$*\" >> '" + out + "'\nexit 0\n"
253 require.NoError(t, os.WriteFile(bin, []byte(script), 0o755))
254 t.Setenv("GITHUB_TOKEN", "secret-from-the-process")
255 _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.AllowedProtocols = bin, "" }).ListRefs(context.Background(), "o", "r")
256 require.NoError(t, err)
257 b, err := os.ReadFile(out)
258 require.NoError(t, err)
259 env := string(b)
260 assert.Contains(t, env, "GIT_TERMINAL_PROMPT=0")
261 assert.Contains(t, env, "GIT_ALLOW_PROTOCOL=https")
262 assert.NotContains(t, env, "secret-from-the-process")
263 assert.Contains(t, env, "ls-remote -- https://example.test/o/r refs/heads/* refs/tags/*")
264 assert.Contains(t, env, "credential.helper=")
265}
266
267// GO-008: the proxy variables of ermite reach git, and nothing else of its
268// environment does.
269func TestListRefsForwardsTheProxyEnvironment(t *testing.T) {
270 out := filepath.Join(t.TempDir(), "env")
271 bin := filepath.Join(t.TempDir(), "git")
272 require.NoError(t, os.WriteFile(bin, []byte("#!/bin/sh\nenv > '"+out+"'\nexit 0\n"), 0o755))
273 t.Setenv("HTTPS_PROXY", "http://proxy.internal:3128")
274 t.Setenv("no_proxy", "localhost,.internal")
275 t.Setenv("HTTP_PROXY", "")
276 t.Setenv("GITHUB_TOKEN", "not-for-git")
277
278 proxy := gitremote.ProxyEnv(os.Getenv)
279 assert.ElementsMatch(t, []string{"HTTPS_PROXY=http://proxy.internal:3128", "no_proxy=localhost,.internal"}, proxy, "empty ones are left out")
280
281 _, err := newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin, c.Proxy = bin, proxy }).ListRefs(context.Background(), "o", "r")
282 require.NoError(t, err)
283 b, err := os.ReadFile(out)
284 require.NoError(t, err)
285 env := string(b)
286 assert.Contains(t, env, "HTTPS_PROXY=http://proxy.internal:3128")
287 assert.Contains(t, env, "no_proxy=localhost,.internal")
288 assert.NotContains(t, env, "not-for-git")
289 assert.NotContains(t, env, "HTTP_PROXY=")
290
291 // Without Config.Proxy nothing is forwarded, whatever the process has.
292 _, err = newRemote(t, "https://example.test", func(c *gitremote.Config) { c.Bin = bin }).ListRefs(context.Background(), "o", "r")
293 require.NoError(t, err)
294 b, _ = os.ReadFile(out)
295 assert.NotContains(t, string(b), "proxy.internal")
296}