1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githttp_test
4
5import (
6 "bufio"
7 "bytes"
8 "context"
9 "encoding/json"
10 "errors"
11 "net"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "strings"
16 "sync"
17 "testing"
18 "time"
19
20 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/require"
22
23 "bnema.dev/ermite/internal/adapters/githttp"
24 "bnema.dev/ermite/internal/domain"
25)
26
27const (
28 oidA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
29 oidB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
30)
31
32type call struct {
33 hook string
34 p domain.Principal
35 repo domain.RepoName
36 updates []domain.RefUpdate
37}
38
39type fakeHooks struct {
40 mu sync.Mutex
41 calls []call
42 checkErr error
43 recErr error
44 // onRecord, when set, runs inside Record with its context.
45 onRecord func(ctx context.Context)
46}
47
48func (f *fakeHooks) Check(_ context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
49 f.mu.Lock()
50 defer f.mu.Unlock()
51 f.calls = append(f.calls, call{"check", p, n, u})
52 return f.checkErr
53}
54
55func (f *fakeHooks) Record(ctx context.Context, p domain.Principal, n domain.RepoName, u []domain.RefUpdate) error {
56 f.mu.Lock()
57 f.calls = append(f.calls, call{"record", p, n, u})
58 err, on := f.recErr, f.onRecord
59 f.mu.Unlock()
60 if on != nil {
61 on(ctx)
62 }
63 return err
64}
65
66func (f *fakeHooks) last() call {
67 f.mu.Lock()
68 defer f.mu.Unlock()
69 return f.calls[len(f.calls)-1]
70}
71
72func repoName(t *testing.T, s string) domain.RepoName {
73 t.Helper()
74 n, err := domain.ParseRepoName(s)
75 require.NoError(t, err)
76 return n
77}
78
79// startHooks serves a hook socket in a short temporary directory.
80func startHooks(t *testing.T, h githttp.PushHooks) (*githttp.HookServer, string) {
81 t.Helper()
82 dir, err := os.MkdirTemp("", "hk")
83 require.NoError(t, err)
84 t.Cleanup(func() { _ = os.RemoveAll(dir) })
85 sock := filepath.Join(dir, "hook.sock")
86 s := githttp.NewHookServer(sock, h)
87 require.NoError(t, s.Listen(context.Background()))
88 ctx, cancel := context.WithCancel(context.Background())
89 done := make(chan error, 1)
90 go func() { done <- s.Serve(ctx) }()
91 t.Cleanup(func() {
92 cancel()
93 select {
94 case err := <-done:
95 assert.NoError(t, err)
96 case <-time.After(5 * time.Second):
97 t.Error("hook server did not stop")
98 }
99 })
100 return s, sock
101}
102
103// raw sends one line and reads the reply line.
104func raw(t *testing.T, sock, line string) githttp.HookReply {
105 t.Helper()
106 conn, err := net.Dial("unix", sock)
107 require.NoError(t, err)
108 defer func() { _ = conn.Close() }()
109 _, err = conn.Write([]byte(line + "\n"))
110 require.NoError(t, err)
111 _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
112 reply, err := bufio.NewReader(conn).ReadBytes('\n')
113 require.NoError(t, err)
114 var r githttp.HookReply
115 require.NoError(t, json.Unmarshal(reply, &r), string(reply))
116 return r
117}
118
119func TestHookSocketMode(t *testing.T) {
120 _, sock := startHooks(t, &fakeHooks{})
121 fi, err := os.Stat(sock)
122 require.NoError(t, err)
123 assert.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
124 assert.NotZero(t, fi.Mode()&os.ModeSocket)
125}
126
127func TestHookServerReplacesStaleSocketFile(t *testing.T) {
128 dir, err := os.MkdirTemp("", "hk")
129 require.NoError(t, err)
130 defer func() { _ = os.RemoveAll(dir) }()
131 sock := filepath.Join(dir, "hook.sock")
132 require.NoError(t, os.WriteFile(sock, []byte("stale"), 0o600))
133 s := githttp.NewHookServer(sock, &fakeHooks{})
134 require.NoError(t, s.Listen(context.Background()))
135 s.Close(context.Background())
136}
137
138func TestHookServerRejectsTooLongSocketPath(t *testing.T) {
139 s := githttp.NewHookServer("/"+strings.Repeat("a", 120)+"/hook.sock", &fakeHooks{})
140 err := s.Listen(context.Background())
141 require.Error(t, err)
142 assert.Contains(t, err.Error(), "too long")
143}
144
145func TestHookProtocol(t *testing.T) {
146 fh := &fakeHooks{}
147 srv, sock := startHooks(t, fh)
148 p := domain.TokenPrincipal(domain.Token{ID: "tok1", Scopes: []domain.Scope{domain.ScopeRepoWrite}})
149 tokenA, releaseA := srv.Register(context.Background(), repoName(t, "alpha"), p)
150 tokenB, releaseB := srv.Register(context.Background(), repoName(t, "beta"), domain.Root())
151 assert.NotEqual(t, tokenA, tokenB)
152 assert.Len(t, tokenA, 64)
153
154 upd := `[{"old":"` + domain.ZeroOID + `","new":"` + oidA + `","ref":"refs/heads/main"}]`
155
156 t.Run("pre-receive is attributed to the repository and principal of its token", func(t *testing.T) {
157 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
158 assert.True(t, r.OK)
159 c := fh.last()
160 assert.Equal(t, "check", c.hook)
161 assert.Equal(t, repoName(t, "alpha"), c.repo)
162 assert.Equal(t, "tok1", c.p.TokenID())
163 assert.Equal(t, []domain.RefUpdate{{Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"}}, c.updates)
164
165 r = raw(t, sock, `{"token":"`+tokenB+`","hook":"post-receive","updates":`+upd+`}`)
166 assert.True(t, r.OK)
167 c = fh.last()
168 assert.Equal(t, "record", c.hook)
169 assert.Equal(t, repoName(t, "beta"), c.repo)
170 assert.True(t, c.p.IsRoot())
171 })
172
173 t.Run("a repository sent by the client is ignored", func(t *testing.T) {
174 raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","repo":"beta","updates":[]}`)
175 assert.Equal(t, repoName(t, "alpha"), fh.last().repo)
176 })
177
178 t.Run("refusal carries the message", func(t *testing.T) {
179 fh.checkErr = errors.Join(domain.ErrRejected, errors.New("protected branch"))
180 defer func() { fh.checkErr = nil }()
181 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
182 assert.False(t, r.OK)
183 assert.Contains(t, r.Message, "rejected")
184 })
185
186 t.Run("an internal error fails closed without leaking details", func(t *testing.T) {
187 fh.checkErr = errors.New("disk /secret/path exploded")
188 defer func() { fh.checkErr = nil }()
189 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":`+upd+`}`)
190 assert.False(t, r.OK)
191 assert.NotContains(t, r.Message, "/secret/path")
192 })
193
194 t.Run("post-receive failure is still ok for git, with a note", func(t *testing.T) {
195 fh.recErr = errors.New("boom")
196 defer func() { fh.recErr = nil }()
197 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"post-receive","updates":`+upd+`}`)
198 assert.True(t, r.OK)
199 assert.NotEmpty(t, r.Message)
200 })
201
202 t.Run("unknown, empty and released tokens are refused", func(t *testing.T) {
203 for _, tok := range []string{"nope", ""} {
204 r := raw(t, sock, `{"token":"`+tok+`","hook":"pre-receive","updates":[]}`)
205 assert.False(t, r.OK, tok)
206 }
207 releaseA()
208 before := len(fh.calls)
209 r := raw(t, sock, `{"token":"`+tokenA+`","hook":"pre-receive","updates":[]}`)
210 assert.False(t, r.OK, "released token")
211 assert.Contains(t, r.Message, "token")
212 assert.Len(t, fh.calls, before, "no use case ran")
213 releaseB()
214 })
215
216 t.Run("malformed requests are refused", func(t *testing.T) {
217 tok, rel := srv.Register(context.Background(), repoName(t, "gamma"), domain.Root())
218 defer rel()
219 before := len(fh.calls)
220 for _, line := range []string{
221 `not json`,
222 `{}`,
223 `{"token":"` + tok + `","hook":"update","updates":[]}`,
224 `{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"zz","new":"` + oidA + `","ref":"refs/heads/x"}]}`,
225 `{"token":"` + tok + `","hook":"pre-receive","updates":[{"old":"` + oidA + `","new":"` + oidA + `","ref":""}]}`,
226 } {
227 r := raw(t, sock, line)
228 assert.False(t, r.OK, line)
229 }
230 assert.Len(t, fh.calls, before)
231 })
232
233 t.Run("a connection that closes without a request does not hurt", func(t *testing.T) {
234 conn, err := net.Dial("unix", sock)
235 require.NoError(t, err)
236 _ = conn.Close()
237 r := raw(t, sock, `{"token":"x","hook":"pre-receive","updates":[]}`)
238 assert.False(t, r.OK)
239 })
240}
241
242// A shutdown while a push is being recorded must not drop the recording: the
243// push is already in the repository.
244func TestHookRecordSurvivesShutdown(t *testing.T) {
245 fh := &fakeHooks{}
246 started, proceed := make(chan struct{}), make(chan struct{})
247 var cancelled, hasDeadline bool
248 var ctxMu sync.Mutex
249 fh.onRecord = func(ctx context.Context) {
250 close(started)
251 <-proceed
252 ctxMu.Lock()
253 defer ctxMu.Unlock()
254 cancelled = ctx.Err() != nil
255 _, hasDeadline = ctx.Deadline()
256 }
257
258 dir, err := os.MkdirTemp("", "hk")
259 require.NoError(t, err)
260 t.Cleanup(func() { _ = os.RemoveAll(dir) })
261 sock := filepath.Join(dir, "hook.sock")
262 srv := githttp.NewHookServer(sock, fh)
263 require.NoError(t, srv.Listen(context.Background()))
264 ctx, cancel := context.WithCancel(context.Background())
265 done := make(chan error, 1)
266 go func() { done <- srv.Serve(ctx) }()
267
268 tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())
269 defer rel()
270 replies := make(chan githttp.HookReply, 1)
271 go func() {
272 replies <- raw(t, sock, `{"token":"`+tok+`","hook":"post-receive","updates":[]}`)
273 }()
274 <-started
275 cancel() // the server is shutting down while Record runs
276 close(proceed)
277
278 select {
279 case r := <-replies:
280 assert.True(t, r.OK)
281 case <-time.After(5 * time.Second):
282 t.Fatal("no reply")
283 }
284 select {
285 case err := <-done:
286 assert.NoError(t, err)
287 case <-time.After(5 * time.Second):
288 t.Fatal("hook server did not stop")
289 }
290 ctxMu.Lock()
291 defer ctxMu.Unlock()
292 assert.False(t, cancelled, "Record is not cancelled by the shutdown")
293 assert.True(t, hasDeadline, "but it is bounded")
294}
295
296func TestHookConcurrentTokens(t *testing.T) {
297 fh := &fakeHooks{}
298 srv, sock := startHooks(t, fh)
299 var wg sync.WaitGroup
300 for i := range 16 {
301 wg.Add(1)
302 go func() {
303 defer wg.Done()
304 name := repoName(t, "r"+string(rune('a'+i)))
305 tok, rel := srv.Register(context.Background(), name, domain.Root())
306 defer rel()
307 r := raw(t, sock, `{"token":"`+tok+`","hook":"post-receive","updates":[{"old":"`+domain.ZeroOID+`","new":"`+oidB+`","ref":"refs/heads/x"}]}`)
308 assert.True(t, r.OK)
309 }()
310 }
311 wg.Wait()
312 seen := map[domain.RepoName]bool{}
313 for _, c := range fh.calls {
314 seen[c.repo] = true
315 }
316 assert.Len(t, seen, 16)
317}
318
319func TestRunHook(t *testing.T) {
320 ctx := context.Background()
321 fh := &fakeHooks{}
322 srv, sock := startHooks(t, fh)
323 tok, rel := srv.Register(context.Background(), repoName(t, "alpha"), domain.Root())
324 defer rel()
325 input := domain.ZeroOID + " " + oidA + " refs/heads/main\n" + oidA + " " + oidB + " refs/heads/dev\n"
326
327 t.Run("sends the lines git feeds the hook", func(t *testing.T) {
328 var errOut bytes.Buffer
329 require.NoError(t, githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok))
330 assert.Equal(t, []domain.RefUpdate{
331 {Old: domain.ZeroOID, New: oidA, Ref: "refs/heads/main"},
332 {Old: oidA, New: oidB, Ref: "refs/heads/dev"},
333 }, fh.last().updates)
334 assert.Empty(t, errOut.String())
335 })
336
337 t.Run("a ref name with spaces survives", func(t *testing.T) {
338 require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(domain.ZeroOID+" "+oidA+" refs/heads/a b\n"), &bytes.Buffer{}, sock, tok))
339 assert.Equal(t, "refs/heads/a b", fh.last().updates[0].Ref)
340 })
341
342 t.Run("pre-receive refusal prints the message and fails", func(t *testing.T) {
343 fh.checkErr = errors.Join(domain.ErrRejected, errors.New("nope"))
344 defer func() { fh.checkErr = nil }()
345 var errOut bytes.Buffer
346 err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, sock, tok)
347 require.ErrorIs(t, err, githttp.ErrHookRejected)
348 assert.Contains(t, errOut.String(), "rejected")
349 })
350
351 t.Run("pre-receive fails closed", func(t *testing.T) {
352 for name, args := range map[string][2]string{
353 "no socket": {"", tok},
354 "no token": {sock, ""},
355 "socket missing": {filepath.Join(t.TempDir(), "gone.sock"), tok},
356 "unknown token": {sock, "unknown"},
357 } {
358 var errOut bytes.Buffer
359 err := githttp.RunHook(ctx, "pre-receive", strings.NewReader(input), &errOut, args[0], args[1])
360 assert.ErrorIs(t, err, githttp.ErrHookRejected, name)
361 }
362 err := githttp.RunHook(ctx, "pre-receive", strings.NewReader("garbage\n"), &bytes.Buffer{}, sock, tok)
363 assert.ErrorIs(t, err, githttp.ErrHookRejected, "unreadable input")
364 assert.Error(t, githttp.RunHook(ctx, "update", strings.NewReader(input), &bytes.Buffer{}, sock, tok))
365 })
366
367 t.Run("a server that never answers is cut off by the context", func(t *testing.T) {
368 dir, err := os.MkdirTemp("", "hk")
369 require.NoError(t, err)
370 defer func() { _ = os.RemoveAll(dir) }()
371 silent := filepath.Join(dir, "s.sock")
372 ln, err := net.Listen("unix", silent)
373 require.NoError(t, err)
374 defer func() { _ = ln.Close() }()
375 go func() {
376 for {
377 c, err := ln.Accept()
378 if err != nil {
379 return
380 }
381 defer func() { _ = c.Close() }()
382 }
383 }()
384 cctx, cancel := context.WithTimeout(ctx, 200*time.Millisecond)
385 defer cancel()
386 start := time.Now()
387 err = githttp.RunHook(cctx, "pre-receive", strings.NewReader(input), &bytes.Buffer{}, silent, tok)
388 assert.ErrorIs(t, err, githttp.ErrHookRejected)
389 assert.Less(t, time.Since(start), 5*time.Second)
390 })
391
392 t.Run("post-receive never fails the push", func(t *testing.T) {
393 var errOut bytes.Buffer
394 require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, filepath.Join(t.TempDir(), "gone.sock"), tok))
395 assert.Contains(t, errOut.String(), "post-receive")
396 errOut.Reset()
397 require.NoError(t, githttp.RunHook(ctx, "post-receive", strings.NewReader(input), &errOut, "", ""))
398 assert.NotEmpty(t, errOut.String())
399 })
400}
401
402func TestWriteHookScripts(t *testing.T) {
403 dir := filepath.Join(t.TempDir(), "run", "hooks")
404 exe := "/opt/it's here/\"ermite\" $HOME `x`/ermite"
405 require.NoError(t, githttp.WriteHookScripts(dir, exe))
406
407 for _, hook := range []string{"pre-receive", "post-receive"} {
408 path := filepath.Join(dir, hook)
409 fi, err := os.Stat(path)
410 require.NoError(t, err)
411 assert.Equal(t, os.FileMode(0o755), fi.Mode().Perm())
412 body, err := os.ReadFile(path)
413 require.NoError(t, err)
414 assert.True(t, strings.HasPrefix(string(body), "#!/bin/sh\n"))
415 assert.Contains(t, string(body), "exec '/opt/it'\\''s here/\"ermite\" $HOME `x`/ermite' hook "+hook+"\n")
416 }
417 // Rewriting replaces the files and leaves nothing behind.
418 require.NoError(t, githttp.WriteHookScripts(dir, "/bin/true"))
419 entries, err := os.ReadDir(dir)
420 require.NoError(t, err)
421 assert.Len(t, entries, 2)
422
423 assert.Error(t, githttp.WriteHookScripts(dir, "relative/ermite"))
424 assert.Error(t, githttp.WriteHookScripts(dir, "/bin/er\nmite"))
425}
426
427// The shell really passes the awkward path and the arguments through.
428func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
429 base := filepath.Join(t.TempDir(), "it's a \"dir\" $X `y` ; z")
430 require.NoError(t, os.MkdirAll(base, 0o755))
431 exe := filepath.Join(base, "fake ermite")
432 out := filepath.Join(t.TempDir(), "args")
433 require.NoError(t, os.WriteFile(exe, []byte("#!/bin/sh\nprintf '%s|' \"$@\" > '"+out+"'\ncat > '"+out+".stdin'\n"), 0o755))
434 dir := t.TempDir()
435 require.NoError(t, githttp.WriteHookScripts(dir, exe))
436
437 cmd := exec.Command(filepath.Join(dir, "pre-receive"))
438 cmd.Stdin = strings.NewReader("line\n")
439 require.NoError(t, cmd.Run())
440 got, err := os.ReadFile(out)
441 require.NoError(t, err)
442 assert.Equal(t, "hook|pre-receive|", string(got))
443 in, err := os.ReadFile(out + ".stdin")
444 require.NoError(t, err)
445 assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
446}