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