1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "context"
7 "crypto/rand"
8 "encoding/base64"
9 "encoding/hex"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "sync"
16 "syscall"
17 "testing"
18 "time"
19
20 "github.com/stretchr/testify/assert"
21 "github.com/stretchr/testify/require"
22
23 "bnema.dev/ermite/internal/domain"
24 "bnema.dev/ermite/internal/ports"
25)
26
27func scopes(s ...domain.Scope) []domain.Scope { return s }
28
29// publicRepo creates a public repository with one commit pushed by root.
30func (h *harness) publicRepo(name string, files ...string) {
31 h.t.Helper()
32 _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
33 require.NoError(h.t, err)
34 wt := h.workTree("main", files...)
35 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main")
36}
37
38// AC1: anonymous clone of a public repository works; a private one gives
39// 401 and then 404 with a valid unrelated token, like a missing repository.
40func TestAC1AnonymousCloneAndPrivateRepo(t *testing.T) {
41 h := newHarness(t, harnessOpts{})
42 h.publicRepo("pub", "README.md", "docs/a.txt")
43
44 // A private repository, pushed by root.
45 wt := h.workTree("main", "secret.txt")
46 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
47
48 dir := t.TempDir()
49 out, err := h.git(dir, "clone", "-q", h.url("/pub.git"), "pub")
50 require.NoError(t, err, out)
51 assert.FileExists(t, filepath.Join(dir, "pub", "docs", "a.txt"))
52 // The .git suffix is optional.
53 out, err = h.git(dir, "clone", "-q", h.url("/pub"), "pub2")
54 require.NoError(t, err, out)
55
56 // Private and missing look the same to an anonymous client: 401 + challenge.
57 for _, name := range []string{"priv", "nothing-here"} {
58 req, _ := http.NewRequest(http.MethodGet, h.url("/"+name+".git/info/refs?service=git-upload-pack"), nil)
59 resp, err := http.DefaultClient.Do(req)
60 require.NoError(t, err)
61 _ = resp.Body.Close()
62 assert.Equal(t, 401, resp.StatusCode, name)
63 assert.Equal(t, `Basic realm="ermite"`, resp.Header.Get("WWW-Authenticate"), name)
64 }
65 out, err = h.git(dir, "clone", "-q", h.url("/priv.git"), "nope")
66 require.Error(t, err)
67 assert.NotContains(t, out, "secret.txt")
68 assert.NoDirExists(t, filepath.Join(dir, "nope"))
69
70 // A valid token without access gets "not found", the same as a missing repository.
71 other := h.token(scopes(domain.ScopeRepoRead), "pub")
72 out, err = h.git(dir, "clone", "-q", h.urlAuth(other, "/priv.git"), "nope")
73 require.Error(t, err)
74 assert.Contains(t, out, "not found")
75 out2, err := h.git(dir, "clone", "-q", h.urlAuth(other, "/nothing-here.git"), "nope2")
76 require.Error(t, err)
77 assert.Contains(t, out2, "not found")
78
79 // A reader of the repository, and root, can clone it.
80 reader := h.token(scopes(domain.ScopeRepoRead), "priv")
81 for _, secret := range []string{reader, adminToken} {
82 d := t.TempDir()
83 out, err = h.git(d, "clone", "-q", h.urlAuth(secret, "/priv.git"), "c")
84 require.NoError(t, err, out)
85 assert.FileExists(t, filepath.Join(d, "c", "secret.txt"))
86 }
87 // A token with repo:read for every repository, passed as a Bearer header.
88 all := h.token(scopes(domain.ScopeRepoRead))
89 d := t.TempDir()
90 out, err = h.git(d, "-c", "http.extraHeader=Authorization: Bearer "+all, "clone", "-q", h.url("/priv.git"), "c")
91 require.NoError(t, err, out)
92}
93
94// Only the smart HTTP routes exist under a repository's .git URL; the rest of
95// the repository tree is the 404 page of the web.
96func TestRoutes(t *testing.T) {
97 h := newHarness(t, harnessOpts{})
98 h.publicRepo("pub", "a")
99
100 for _, path := range []string{
101 "/pub.git/HEAD",
102 "/pub.git/objects/info/packs",
103 "/pub.git/info/refs", // dumb protocol: no service
104 "/pub.git/info/refs?service=git-bogus",
105 "/pub.git/config",
106 "/pub/events/main", // no such page
107 "/-/nothing",
108 "/Bad.git/info/refs?service=git-upload-pack",
109 "/..%2f..%2fetc/info/refs?service=git-upload-pack",
110 } {
111 assert.Equal(t, 404, h.status("GET", path, nil), path)
112 }
113 // A POST no Git route owns falls to the web, whose gate refuses it (a
114 // browser POST needs an Origin and a session, ADR 0018).
115 assert.Equal(t, 403, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil))
116 // The web owns GET on every other path: a GET of a POST-only Git route
117 // is its 404 page, not a 405.
118 assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil))
119 assert.Equal(t, 200, h.status("GET", "/", nil), "the home page")
120 assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page")
121 // A browser POST needs an Origin and a session (ADR 0018): refused
122 // before the router sees it.
123 assert.Equal(t, 403, h.status("POST", "/", nil))
124 assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact")
125 assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil))
126 // Anonymous receive-pack is a challenge even for a public repository.
127 assert.Equal(t, 401, h.status("GET", "/pub.git/info/refs?service=git-receive-pack", nil))
128 assert.Equal(t, 401, h.status("POST", "/pub.git/git-receive-pack", nil))
129}
130
131// AC2: who may push.
132func TestAC2PushAuthorization(t *testing.T) {
133 h := newHarness(t, harnessOpts{})
134 wt := h.workTree("main", "a.txt")
135
136 // Root creates the repository.
137 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
138 require.NoError(t, err, out)
139
140 writer := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "proj")
141 writerAll := h.token(scopes(domain.ScopeRepoWrite))
142 reader := h.token(scopes(domain.ScopeRepoRead))
143 elsewhere := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), "other")
144
145 next := func(file string) {
146 require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644))
147 h.mustGit(wt, "add", file)
148 h.mustGit(wt, "commit", "-q", "-m", file)
149 }
150
151 for _, secret := range []string{writer, writerAll, adminToken} {
152 next("w-" + secret[len(secret)-6:])
153 out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/proj.git"), "main")
154 require.NoError(t, err, out)
155 }
156
157 // A reader is known but may not write: 403.
158 next("by-reader")
159 out, err = h.git(wt, "push", "-q", h.urlAuth(reader, "/proj.git"), "main")
160 require.Error(t, err)
161 assert.Contains(t, out, "403")
162
163 // A token limited to another repository cannot even see this private one.
164 out, err = h.git(wt, "push", "-q", h.urlAuth(elsewhere, "/proj.git"), "main")
165 require.Error(t, err)
166 assert.Contains(t, out, "not found")
167
168 // ... and it may not create a repository outside its list.
169 _, err = h.git(wt, "push", "-q", h.urlAuth(elsewhere, "/brand-new.git"), "main")
170 require.Error(t, err)
171 assert.NoDirExists(t, h.repoDir("brand-new"))
172
173 // Nothing from the refused pushes arrived.
174 assert.NotContains(t, h.bareGit("proj", "log", "--format=%s", "main"), "by-reader")
175
176 // Garbage credentials: 401.
177 out, err = h.git(wt, "push", "-q", h.urlAuth("ermite_not-a-token", "/proj.git"), "main")
178 require.Error(t, err)
179 assert.Contains(t, out, "Authentication failed")
180
181 // Expired token (written to the store directly: the use case refuses a past expiry).
182 expired := "ermite_expired-secret-0123456789"
183 require.NoError(t, h.forge.store.Tokens().Create(h.ctx, domain.Token{
184 ID: "expired1", Label: "old", Hash: domain.HashTokenSecret(expired),
185 Scopes: scopes(domain.ScopeRepoWrite), ExpiresAt: time.Now().Add(-time.Minute), CreatedAt: time.Now().Add(-time.Hour),
186 }))
187 out, err = h.git(wt, "push", "-q", h.urlAuth(expired, "/proj.git"), "main")
188 require.Error(t, err)
189 assert.Contains(t, out, "Authentication failed")
190
191 // Revoked token.
192 created, err := h.forge.Tokens.Create(h.ctx, domain.Root(), ports.CreateTokenParams{Label: "short", Scopes: scopes(domain.ScopeRepoWrite)})
193 require.NoError(t, err)
194 next("before-revoke")
195 out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main")
196 require.NoError(t, err, out)
197 require.NoError(t, h.forge.Tokens.Revoke(h.ctx, domain.Root(), created.Token.ID))
198 next("after-revoke")
199 out, err = h.git(wt, "push", "-q", h.urlAuth(created.Secret, "/proj.git"), "main")
200 require.Error(t, err)
201 assert.Contains(t, out, "Authentication failed")
202 assert.NotContains(t, h.bareGit("proj", "log", "--format=%s", "main"), "after-revoke")
203
204 // Anonymous pushes are a plain 401.
205 _, err = h.git(wt, "push", "-q", h.url("/proj.git"), "main")
206 require.Error(t, err)
207
208 // The token store index stays consistent with revocation (AC8).
209 _, err = h.forge.store.Tokens().FindByHash(h.ctx, domain.HashTokenSecret(created.Secret))
210 assert.ErrorIs(t, err, domain.ErrNotFound)
211 list, err := h.forge.Tokens.List(h.ctx, domain.Root())
212 require.NoError(t, err)
213 for _, tok := range list {
214 _, err := h.forge.store.Tokens().FindByHash(h.ctx, tok.Hash)
215 assert.NoError(t, err, tok.ID)
216 }
217}
218
219// AC3: first push creates a private repository with HEAD on the pushed branch.
220func TestAC3CreateOnPush(t *testing.T) {
221 h := newHarness(t, harnessOpts{})
222 events := h.forge.Bus.Subscribe(h.ctx)
223
224 // Only "dev" is pushed: HEAD (initially main) moves to it.
225 wt := h.workTree("dev", "a")
226 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/only-dev.git"), "dev")
227 require.NoError(t, err, out)
228 assert.Equal(t, "refs/heads/dev", h.bareGit("only-dev", "symbolic-ref", "HEAD"))
229
230 repo, err := h.forge.Repos.Get(h.ctx, domain.Root(), h.name("only-dev"))
231 require.NoError(t, err)
232 assert.Equal(t, domain.VisibilityPrivate, repo.Visibility)
233 assert.False(t, repo.PushedAt.IsZero(), "pushed_at is set by post-receive")
234 _, err = h.forge.Repos.Get(h.ctx, domain.Anonymous(), h.name("only-dev"))
235 assert.ErrorIs(t, err, domain.ErrNotFound, "private by default")
236
237 var created domain.RepoCreated
238 select {
239 case ev := <-events:
240 created = ev.(domain.RepoCreated)
241 case <-time.After(5 * time.Second):
242 t.Fatal("no RepoCreated event")
243 }
244 assert.Equal(t, h.name("only-dev"), created.Repo)
245 assert.Equal(t, domain.VisibilityPrivate, created.Visibility)
246
247 // Both main and dev are pushed: main is preferred.
248 wt2 := h.workTree("dev", "a")
249 h.mustGit(wt2, "branch", "main")
250 out, err = h.git(wt2, "push", "-q", h.urlAuth(adminToken, "/both.git"), "dev", "main")
251 require.NoError(t, err, out)
252 assert.Equal(t, "refs/heads/main", h.bareGit("both", "symbolic-ref", "HEAD"))
253
254 // An existing valid HEAD is left alone by later pushes.
255 h.mustGit(wt2, "checkout", "-q", "-b", "feature")
256 out, err = h.git(wt2, "push", "-q", h.urlAuth(adminToken, "/both.git"), "feature")
257 require.NoError(t, err, out)
258 assert.Equal(t, "refs/heads/main", h.bareGit("both", "symbolic-ref", "HEAD"))
259
260 // An invalid name: 404 and nothing created, by a token that could create.
261 before, err := h.forge.Repos.List(h.ctx, domain.Root())
262 require.NoError(t, err)
263 for _, bad := range []string{"/Upper.git", "/has%20space.git", "/-.git", "/robots.txt", "/a..b.git"} {
264 assert.Equal(t, 404, h.statusAuth("GET", bad+"/info/refs?service=git-receive-pack", adminToken), bad)
265 }
266 after, err := h.forge.Repos.List(h.ctx, domain.Root())
267 require.NoError(t, err)
268 assert.Equal(t, before, after)
269 entries, err := os.ReadDir(filepath.Join(h.data, "repos"))
270 require.NoError(t, err)
271 for _, e := range entries {
272 assert.NotContains(t, strings.ToLower(e.Name()), "upper")
273 }
274
275 // A fetch of an unknown repository never creates it, even for root.
276 assert.Equal(t, 404, h.statusAuth("GET", "/ghost.git/info/refs?service=git-upload-pack", adminToken))
277 assert.NoDirExists(t, h.repoDir("ghost"))
278 // Neither does the second request of a push.
279 assert.Equal(t, 404, h.statusAuth("POST", "/ghost.git/git-receive-pack", adminToken))
280 assert.NoDirExists(t, h.repoDir("ghost"))
281}
282
283// statusAuth is status with the token as Basic credentials.
284func (h *harness) statusAuth(method, path, secret string) int {
285 h.t.Helper()
286 return h.status(method, path, http.Header{"Authorization": {"Basic " + base64.StdEncoding.EncodeToString([]byte("x:"+secret))}})
287}
288
289// AC4: hooks fail closed, and post-receive records exactly what was pushed.
290func TestAC4HooksFailClosed(t *testing.T) {
291 h := newHarness(t, harnessOpts{})
292 wt := h.workTree("main", "a")
293 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
294 require.NoError(t, err, out)
295 before := h.bareGit("proj", "rev-parse", "main")
296
297 // The hook socket goes away: pre-receive cannot ask ermite and rejects.
298 h.forge.hooks.Close(h.ctx)
299 select {
300 case <-h.hooksDone:
301 case <-time.After(5 * time.Second):
302 t.Fatal("hook server did not stop")
303 }
304 require.NoError(t, os.WriteFile(filepath.Join(wt, "b"), []byte("b"), 0o644))
305 h.mustGit(wt, "add", "b")
306 h.mustGit(wt, "commit", "-q", "-m", "b")
307 out, err = h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
308 require.Error(t, err)
309 assert.Contains(t, out, "unreachable")
310 assert.Contains(t, out, "rejected")
311 assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"), "the ref did not move")
312}
313
314func TestAC4HooksUnknownTokenAndMissingBinary(t *testing.T) {
315 // The hook scripts point at a binary that does not exist: git cannot run
316 // the hook, so the push fails (it never succeeds unchecked).
317 h := newHarness(t, harnessOpts{hookExe: "/nonexistent/ermite"})
318 wt := h.workTree("main", "a")
319 out, err := h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
320 require.Error(t, err, out)
321 _, gerr := os.Stat(h.repoDir("proj"))
322 require.NoError(t, gerr, "the repository was created by the first request")
323 assert.Empty(t, h.bareGit("proj", "for-each-ref"), "nothing was accepted")
324}
325
326func TestAC4PostReceiveAttribution(t *testing.T) {
327 h := newHarness(t, harnessOpts{})
328 events := h.forge.Bus.Subscribe(h.ctx)
329
330 // Two repositories, pushed at the same time with different refs.
331 const n = 4
332 type job struct {
333 repo, branch string
334 wt string
335 }
336 var jobs []job
337 for i := range n {
338 repo := "repo" + string(rune('a'+i))
339 branch := "branch-" + string(rune('a'+i))
340 jobs = append(jobs, job{repo: repo, branch: branch, wt: h.workTree(branch, "f-"+repo)})
341 }
342 var wg sync.WaitGroup
343 for _, j := range jobs {
344 wg.Add(1)
345 go func() {
346 defer wg.Done()
347 out, err := h.git(j.wt, "push", "-q", h.urlAuth(adminToken, "/"+j.repo+".git"), j.branch)
348 assert.NoError(t, err, out)
349 }()
350 }
351 wg.Wait()
352
353 got := map[domain.RepoName]domain.PushEvent{}
354 deadline := time.After(10 * time.Second)
355 for len(got) < n {
356 select {
357 case ev := <-events:
358 if pe, ok := ev.(domain.PushEvent); ok {
359 require.NotContains(t, got, pe.Repo, "one event per push")
360 got[pe.Repo] = pe
361 }
362 case <-deadline:
363 t.Fatalf("got %d push events, want %d", len(got), n)
364 }
365 }
366 for _, j := range jobs {
367 pe := got[h.name(j.repo)]
368 require.Len(t, pe.Updates, 1, j.repo)
369 assert.Equal(t, "refs/heads/"+j.branch, pe.Updates[0].Ref, j.repo)
370 assert.Equal(t, domain.ZeroOID, pe.Updates[0].Old)
371 assert.Equal(t, h.bareGit(j.repo, "rev-parse", j.branch), pe.Updates[0].New, "the exact new object id")
372 repo, err := h.forge.Repos.Get(h.ctx, domain.Root(), h.name(j.repo))
373 require.NoError(t, err)
374 assert.False(t, repo.PushedAt.IsZero())
375 }
376
377 // A second push reports the old id, a deletion reports the zero id.
378 wt := jobs[0].wt
379 old := h.bareGit(jobs[0].repo, "rev-parse", jobs[0].branch)
380 require.NoError(t, os.WriteFile(filepath.Join(wt, "again"), []byte("x"), 0o644))
381 h.mustGit(wt, "add", "again")
382 h.mustGit(wt, "commit", "-q", "-m", "again")
383 h.mustGit(wt, "tag", "v1")
384 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/"+jobs[0].repo+".git"), jobs[0].branch, "v1")
385 require.NoError(t, err, out)
386 pe := waitPush(t, events)
387 require.Len(t, pe.Updates, 2)
388 byRef := map[string]domain.RefUpdate{}
389 for _, u := range pe.Updates {
390 byRef[u.Ref] = u
391 }
392 assert.Equal(t, old, byRef["refs/heads/"+jobs[0].branch].Old)
393 assert.True(t, byRef["refs/tags/v1"].IsCreate())
394
395 out, err = h.git(wt, "push", "-q", h.urlAuth(adminToken, "/"+jobs[0].repo+".git"), ":refs/tags/v1")
396 require.NoError(t, err, out)
397 pe = waitPush(t, events)
398 require.Len(t, pe.Updates, 1)
399 assert.True(t, pe.Updates[0].IsDelete())
400}
401
402func waitPush(t *testing.T, events <-chan domain.Event) domain.PushEvent {
403 t.Helper()
404 deadline := time.After(10 * time.Second)
405 for {
406 select {
407 case ev := <-events:
408 if pe, ok := ev.(domain.PushEvent); ok {
409 return pe
410 }
411 case <-deadline:
412 t.Fatal("no PushEvent")
413 }
414 }
415}
416
417// AC5: the failed-authentication limiter, with and without trusted proxies.
418func TestAC5Limiter(t *testing.T) {
419 t.Run("direct clients", func(t *testing.T) {
420 h := newHarness(t, harnessOpts{})
421 bad := func(hdr http.Header) int {
422 req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
423 req.SetBasicAuth("u", "ermite_wrong")
424 for k, v := range hdr {
425 req.Header[k] = v
426 }
427 resp, err := http.DefaultClient.Do(req)
428 require.NoError(t, err)
429 _ = resp.Body.Close()
430 return resp.StatusCode
431 }
432 for i := range 10 {
433 require.Equal(t, 401, bad(nil), "failure %d", i+1)
434 }
435 assert.Equal(t, 429, bad(nil), "the 11th request is blocked")
436 // Even good credentials are blocked while the address is.
437 assert.Equal(t, 429, h.statusAuth("GET", "/x.git/info/refs?service=git-upload-pack", adminToken))
438 assert.Equal(t, 429, h.status("GET", "/x.git/info/refs?service=git-upload-pack", nil), "anonymous too")
439 // X-Forwarded-For from an untrusted peer is ignored.
440 assert.Equal(t, 429, bad(http.Header{"X-Forwarded-For": {"198.51.100.9"}}))
441 // Non-Git routes are not limited.
442 assert.Equal(t, 200, h.status("GET", "/-/health", nil))
443
444 resp, err := http.Get(h.url("/x.git/info/refs?service=git-upload-pack"))
445 require.NoError(t, err)
446 _ = resp.Body.Close()
447 assert.NotEmpty(t, resp.Header.Get("Retry-After"))
448 })
449
450 t.Run("trusted proxy", func(t *testing.T) {
451 h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"})
452 bad := func(xff string) int {
453 req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
454 req.SetBasicAuth("u", "ermite_wrong")
455 req.Header.Set("X-Forwarded-For", xff)
456 resp, err := http.DefaultClient.Do(req)
457 require.NoError(t, err)
458 _ = resp.Body.Close()
459 return resp.StatusCode
460 }
461 // A spoofed left entry does not help: the right-most untrusted one counts.
462 for i := range 10 {
463 require.Equal(t, 401, bad("1.1.1."+string(rune('0'+i))+", 198.51.100.7"), "failure %d", i+1)
464 }
465 assert.Equal(t, 429, bad("2.2.2.2, 198.51.100.7"), "client 198.51.100.7 is blocked")
466 assert.Equal(t, 401, bad("198.51.100.8"), "another client behind the proxy is not")
467 req, _ := http.NewRequest(http.MethodGet, h.url("/x.git/info/refs?service=git-upload-pack"), nil)
468 req.Header.Set("X-Forwarded-For", "198.51.100.7")
469 resp, err := http.DefaultClient.Do(req)
470 require.NoError(t, err)
471 _ = resp.Body.Close()
472 assert.Equal(t, 429, resp.StatusCode)
473 })
474
475 t.Run("successes do not count", func(t *testing.T) {
476 h := newHarness(t, harnessOpts{})
477 for range 25 {
478 assert.Equal(t, 404, h.statusAuth("GET", "/x.git/info/refs?service=git-upload-pack", adminToken))
479 }
480 // Anonymous requests for a private/missing repo are challenges, not failures.
481 for range 25 {
482 assert.Equal(t, 401, h.status("GET", "/x.git/info/refs?service=git-upload-pack", nil))
483 }
484 })
485}
486
487// AC6: protocol v2 and partial clone.
488func TestAC6ProtocolV2AndPartialClone(t *testing.T) {
489 h := newHarness(t, harnessOpts{})
490 h.publicRepo("pub", "README.md", "src/main.go", "src/util.go")
491
492 dir := t.TempDir()
493 out, err := h.gitEnv([]string{"GIT_TRACE_PACKET=1"}, dir, "-c", "protocol.version=2", "clone", "-q", h.url("/pub.git"), "v2")
494 require.NoError(t, err, out)
495 assert.Contains(t, out, "version 2", "the server speaks protocol v2")
496 assert.Contains(t, out, "ls-refs")
497
498 // Protocol v0 still works for old clients.
499 out, err = h.git(dir, "-c", "protocol.version=0", "clone", "-q", h.url("/pub.git"), "v0")
500 require.NoError(t, err, out)
501
502 // A later fetch uses v2 as well.
503 wt := h.workTree("main", "x")
504 h.mustGit(wt, "push", "-q", "-f", h.urlAuth(adminToken, "/pub.git"), "main")
505 out, err = h.gitEnv([]string{"GIT_TRACE_PACKET=1"}, filepath.Join(dir, "v2"), "-c", "protocol.version=2", "fetch", "-q")
506 require.NoError(t, err, out)
507 assert.Contains(t, out, "fetch=")
508
509 // Partial clone: no blobs come down until they are needed.
510 out, err = h.git(dir, "-c", "protocol.version=2", "clone", "-q", "--no-checkout", "--filter=blob:none", h.url("/pub.git"), "partial")
511 require.NoError(t, err, out)
512 partial := filepath.Join(dir, "partial")
513 assert.Equal(t, "blob:none", strings.TrimSpace(h.mustGit(partial, "config", "remote.origin.partialclonefilter")))
514 missing := h.mustGit(partial, "rev-list", "--objects", "--missing=print", "--all")
515 assert.Contains(t, missing, "?", "blobs are missing after the clone")
516 // ... and are fetched on demand (the promisor remote is the same URL).
517 out, err = h.git(partial, "checkout", "-q", "main")
518 require.NoError(t, err, out)
519 assert.FileExists(t, filepath.Join(partial, "x"))
520}
521
522// AC7: tokens never appear in logs, nor in the environment of the hooks.
523func TestAC7NoSecretsInLogsOrHookEnvironment(t *testing.T) {
524 envDump := filepath.Join(t.TempDir(), "hookenv.txt")
525 bin := binary(t)
526 // A wrapper that records the environment of the hook, then runs the real hook.
527 wrapper := filepath.Join(t.TempDir(), "ermite-wrapper")
528 require.NoError(t, os.WriteFile(wrapper, []byte("#!/bin/sh\nenv >> '"+envDump+"'\nexec '"+bin+"' \"$@\"\n"), 0o755))
529 h := newHarness(t, harnessOpts{hookExe: wrapper})
530
531 stored := h.token(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite))
532 wt := h.workTree("main", "a")
533 for _, secret := range []string{stored, adminToken} {
534 out, err := h.git(wt, "push", "-q", "-f", h.urlAuth(secret, "/proj.git"), "main")
535 require.NoError(t, err, out)
536 }
537 // Bad credentials, a Bearer header, and a clone as well.
538 _, _ = h.git(wt, "push", "-q", h.urlAuth("ermite_secret-that-is-wrong", "/proj.git"), "main")
539 out, err := h.git(wt, "-c", "http.extraHeader=Authorization: Bearer "+stored, "push", "-q", "-f", h.url("/proj.git"), "main")
540 require.NoError(t, err, out)
541 out, err = h.git(t.TempDir(), "clone", "-q", h.urlAuth(stored, "/proj.git"), "c")
542 require.NoError(t, err, out)
543
544 logs := h.logs.String()
545 require.Contains(t, logs, "push received", "the logs of the pushes were captured")
546 secrets := []string{stored, adminToken, "ermite_secret-that-is-wrong"}
547 for _, s := range secrets {
548 basic := base64.StdEncoding.EncodeToString([]byte("user:" + s))
549 assert.NotContains(t, logs, s)
550 assert.NotContains(t, logs, basic)
551 assert.NotContains(t, logs, base64.StdEncoding.EncodeToString([]byte(s)))
552 }
553 assert.NotContains(t, strings.ToLower(logs), "authorization")
554 assert.NotContains(t, logs, "Basic ")
555 assert.NotContains(t, logs, "Bearer ")
556
557 dumped, err := os.ReadFile(envDump)
558 require.NoError(t, err)
559 require.Contains(t, string(dumped), "ERMITE_HOOK_TOKEN=", "the hooks ran with their token")
560 assert.Contains(t, string(dumped), "ERMITE_HOOK_SOCKET=")
561 for _, s := range secrets {
562 assert.NotContains(t, string(dumped), s, "the hook environment")
563 }
564 assert.NotContains(t, strings.ToUpper(string(dumped)), "HTTP_AUTHORIZATION")
565 assert.NotContains(t, strings.ToUpper(string(dumped)), "HTTP_COOKIE")
566}
567
568// AC8, through the wired use cases: no escalation and a consistent index.
569func TestAC8TokenCreationThroughTheForge(t *testing.T) {
570 h := newHarness(t, harnessOpts{})
571 ctx := context.Background()
572
573 admin, err := h.forge.Tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
574 Label: "limited admin", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")},
575 })
576 require.NoError(t, err)
577 p, err := h.forge.Auth.Authenticate(ctx, admin.Secret)
578 require.NoError(t, err)
579
580 _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoAdmin), Repos: []domain.RepoName{h.name("a")}})
581 assert.ErrorIs(t, err, domain.ErrForbidden, "scope escalation")
582 _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
583 assert.ErrorIs(t, err, domain.ErrForbidden, "repository list escalation")
584 _, err = h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("b")}})
585 assert.ErrorIs(t, err, domain.ErrForbidden, "another repository")
586 child, err := h.forge.Tokens.Create(ctx, p, ports.CreateTokenParams{Label: "child", Scopes: scopes(domain.ScopeRepoWrite), Repos: []domain.RepoName{h.name("a")}})
587 require.NoError(t, err)
588
589 // The child works on its repository, and revoking the index entry kills it at once.
590 wt := h.workTree("main", "f")
591 out, err := h.git(wt, "push", "-q", h.urlAuth(child.Secret, "/a.git"), "main")
592 require.NoError(t, err, out)
593 require.NoError(t, h.forge.Tokens.Revoke(ctx, p, child.Token.ID))
594 out, err = h.git(wt, "push", "-q", h.urlAuth(child.Secret, "/a.git"), "main")
595 require.Error(t, err, out)
596
597 list, err := h.forge.Tokens.List(ctx, domain.Root())
598 require.NoError(t, err)
599 require.Len(t, list, 1)
600 assert.Equal(t, admin.Token.ID, list[0].ID)
601 _, err = h.forge.store.Tokens().FindByHash(ctx, child.Token.Hash)
602 assert.ErrorIs(t, err, domain.ErrNotFound)
603}
604
605// Large pushes arrive chunked (git's http.postBuffer is 1 MiB): the handler
606// streams them to Git. Hooks, a path with quotes in the hook executable, and the
607// debounced maintenance are exercised on the way.
608func TestLargePushHookPathQuotingAndMaintenance(t *testing.T) {
609 dir := filepath.Join(t.TempDir(), "it's a \"dir\" with spaces")
610 require.NoError(t, os.MkdirAll(dir, 0o755))
611 exe := filepath.Join(dir, "ermite")
612 raw, err := os.ReadFile(binary(t))
613 require.NoError(t, err)
614 require.NoError(t, os.WriteFile(exe, raw, 0o755))
615
616 h := newHarness(t, harnessOpts{hookExe: exe, maintenance: 50 * time.Millisecond})
617 wt := h.workTree("main", "a")
618 big := make([]byte, 3<<20)
619 _, err = rand.Read(big)
620 require.NoError(t, err)
621 require.NoError(t, os.WriteFile(filepath.Join(wt, "big.bin"), big, 0o644))
622 h.mustGit(wt, "add", "big.bin")
623 h.mustGit(wt, "commit", "-q", "-m", "big")
624
625 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/big.git"), "main")
626 require.NoError(t, err, out)
627 assert.Equal(t, h.mustGit(wt, "rev-parse", "main"), h.mustGit(h.repoDir("big"), "rev-parse", "main"))
628
629 // Maintenance wrote a commit-graph after the debounce delay.
630 chain := filepath.Join(h.repoDir("big"), "objects", "info", "commit-graphs", "commit-graph-chain")
631 require.Eventually(t, func() bool { _, err := os.Stat(chain); return err == nil }, 10*time.Second, 20*time.Millisecond)
632
633 // The clone gets the big blob back intact.
634 d := t.TempDir()
635 out, err = h.git(d, "clone", "-q", h.urlAuth(adminToken, "/big.git"), "c")
636 require.NoError(t, err, out)
637 got, err := os.ReadFile(filepath.Join(d, "c", "big.bin"))
638 require.NoError(t, err)
639 assert.Equal(t, big, got)
640
641 // Hook scripts: 0755, exec the binary quoted.
642 for _, hook := range []string{"pre-receive", "post-receive"} {
643 fi, err := os.Stat(filepath.Join(h.data, "run", "hooks", hook))
644 require.NoError(t, err)
645 assert.Equal(t, os.FileMode(0o755), fi.Mode().Perm())
646 }
647 // Nothing was written into the repository itself.
648 entries, err := os.ReadDir(filepath.Join(h.repoDir("big"), "hooks"))
649 require.NoError(t, err)
650 for _, e := range entries {
651 assert.True(t, strings.HasSuffix(e.Name(), ".sample"), "hooks dir is untouched: %s", e.Name())
652 }
653 conf, err := os.ReadFile(filepath.Join(h.repoDir("big"), "config"))
654 require.NoError(t, err)
655 assert.NotContains(t, string(conf), "hooksPath")
656 assert.NotContains(t, string(conf), "receivepack")
657}
658
659// A chunked push has no declared length, so the limit cuts the body off while
660// it streams: the push fails and no ref moves. A small push, which git sends
661// with a Content-Length, is refused up front.
662func TestPushOverTheBodyLimitFailsAndMovesNoRef(t *testing.T) {
663 const limit = 256 << 10
664 h := newHarness(t, harnessOpts{maxBody: limit})
665 small := h.workTree("main", "a")
666 out, err := h.git(small, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
667 require.NoError(t, err, "a push under the limit works\n"+out)
668 before := h.bareGit("proj", "rev-parse", "main")
669
670 big := make([]byte, 3<<20) // over postBuffer: git sends it chunked
671 _, err = rand.Read(big)
672 require.NoError(t, err)
673 require.NoError(t, os.WriteFile(filepath.Join(small, "big.bin"), big, 0o644))
674 h.mustGit(small, "add", "big.bin")
675 h.mustGit(small, "commit", "-q", "-m", "big")
676
677 out, err = h.git(small, "push", h.urlAuth(adminToken, "/proj.git"), "main")
678 require.Error(t, err, "a chunked push over the limit fails\n"+out)
679 assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"), "no ref moved")
680
681 // A new branch of the same objects fails the same way and is not created.
682 h.mustGit(small, "branch", "other")
683 out, err = h.git(small, "push", h.urlAuth(adminToken, "/proj.git"), "other")
684 require.Error(t, err, out)
685 assert.Empty(t, h.bareGit("proj", "for-each-ref", "refs/heads/other"))
686
687 // With a declared length (postBuffer raised above the pack) it is a 413.
688 out, err = h.git(small, "-c", "http.postBuffer=104857600", "push", h.urlAuth(adminToken, "/proj.git"), "main")
689 require.Error(t, err, out)
690 assert.Contains(t, out, "413")
691 assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"))
692}
693
694// receive.fsckObjects is on: a push with a malformed object is refused, and
695// nothing is stored in the repository's own configuration to get there.
696func TestFsckRejectsMalformedObjects(t *testing.T) {
697 h := newHarness(t, harnessOpts{})
698 wt := h.workTree("main", "a")
699 out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
700 require.NoError(t, err, out)
701 before := h.bareGit("proj", "rev-parse", "main")
702
703 // A tree whose entries are not sorted: "b" before "a".
704 blob := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD:a"))
705 raw := make([]byte, 0, 64)
706 for _, name := range []string{"b", "a"} {
707 raw = append(raw, []byte("100644 "+name+"\x00")...)
708 oid, err := hex.DecodeString(blob)
709 require.NoError(t, err)
710 raw = append(raw, oid...)
711 }
712 treeFile := filepath.Join(t.TempDir(), "tree")
713 require.NoError(t, os.WriteFile(treeFile, raw, 0o644))
714 tree := strings.TrimSpace(h.mustGit(wt, "hash-object", "-w", "-t", "tree", "--literally", treeFile))
715 commit := strings.TrimSpace(h.mustGit(wt, "commit-tree", "-p", "HEAD", "-m", "malformed", tree))
716 h.mustGit(wt, "update-ref", "refs/heads/bad", commit)
717
718 out, err = h.git(wt, "push", h.urlAuth(adminToken, "/proj.git"), "bad")
719 require.Error(t, err, out)
720 assert.Equal(t, before, h.bareGit("proj", "rev-parse", "main"))
721 assert.Empty(t, h.bareGit("proj", "for-each-ref", "refs/heads/bad"), "the ref was not created")
722 conf, err := os.ReadFile(filepath.Join(h.repoDir("proj"), "config"))
723 require.NoError(t, err)
724 assert.NotContains(t, string(conf), "fsck")
725}
726
727// The built binary end to end: `ermite serve` as a process, its hook scripts
728// exec'ing the same binary, and a real git client pushing and cloning.
729func TestBuiltBinaryPushAndClone(t *testing.T) {
730 gitBin := requireGit(t)
731 data, err := os.MkdirTemp("", "ermite")
732 require.NoError(t, err)
733 t.Cleanup(func() { _ = os.RemoveAll(data) })
734 addr := freeAddr(t)
735
736 cmd := exec.Command(binary(t), "serve")
737 cmd.Env = append(baseEnv(),
738 "ERMITE_ADDR="+addr,
739 "ERMITE_DATA_DIR="+data,
740 "ERMITE_PUBLIC_URL=http://"+addr,
741 "ERMITE_ADMIN_TOKEN="+adminToken,
742 "ERMITE_GIT="+gitBin,
743 "ERMITE_LOG_LEVEL=debug",
744 )
745 var logs syncBuffer
746 cmd.Stdout, cmd.Stderr = &logs, &logs
747 require.NoError(t, cmd.Start())
748 exited := make(chan error, 1)
749 go func() { exited <- cmd.Wait() }()
750 t.Cleanup(func() { _ = cmd.Process.Kill() })
751
752 require.Eventually(t, func() bool {
753 resp, err := http.Get("http://" + addr + "/-/health")
754 if err != nil {
755 return false
756 }
757 _ = resp.Body.Close()
758 return resp.StatusCode == 200
759 }, 15*time.Second, 50*time.Millisecond, logs.String())
760
761 home := t.TempDir()
762 run := func(dir string, args ...string) string {
763 c := exec.Command("git", args...)
764 c.Dir = dir
765 c.Env = clientEnv(home)
766 out, err := c.CombinedOutput()
767 require.NoError(t, err, "git %v\n%s\nserver logs:\n%s", args, out, logs.String())
768 return string(out)
769 }
770 wt := t.TempDir()
771 run(wt, "init", "-q", "--initial-branch=main")
772 require.NoError(t, os.WriteFile(filepath.Join(wt, "hello.txt"), []byte("hello\n"), 0o644))
773 run(wt, "add", ".")
774 run(wt, "commit", "-q", "-m", "hello")
775 url := "http://u:" + adminToken + "@" + addr + "/bin-test.git"
776 run(wt, "push", "-q", url, "main")
777 cl := t.TempDir()
778 run(cl, "clone", "-q", url, "c")
779 assert.FileExists(t, filepath.Join(cl, "c", "hello.txt"))
780
781 // The hooks ran: post-receive recorded the push.
782 require.Eventually(t, func() bool { return strings.Contains(logs.String(), `"message":"push received"`) }, 5*time.Second, 20*time.Millisecond, logs.String())
783 assert.NotContains(t, logs.String(), adminToken)
784
785 // Hook scripts exec this very binary.
786 script, err := os.ReadFile(filepath.Join(data, "run", "hooks", "pre-receive"))
787 require.NoError(t, err)
788 assert.Contains(t, string(script), binary(t))
789
790 require.NoError(t, cmd.Process.Signal(syscall.SIGTERM))
791 select {
792 case err := <-exited:
793 require.NoError(t, err, logs.String())
794 case <-time.After(15 * time.Second):
795 t.Fatalf("no clean stop\n%s", logs.String())
796 }
797 assert.NoFileExists(t, filepath.Join(data, "run", "hook.sock"), "the socket is removed on stop")
798}
799
800// `ermite hook` is hidden, fails closed for pre-receive and never fails the
801// push for post-receive.
802func TestHookCommand(t *testing.T) {
803 bin := binary(t)
804 input := domainZero + " " + strings.Repeat("a", 40) + " refs/heads/main\n"
805
806 run := func(hook string, env ...string) (string, error) {
807 c := exec.Command(bin, "hook", hook)
808 c.Env = append(baseEnv(), env...)
809 c.Stdin = strings.NewReader(input)
810 out, err := c.CombinedOutput()
811 return string(out), err
812 }
813 out, err := run("pre-receive")
814 require.Error(t, err)
815 assert.Contains(t, out, "ERMITE_HOOK_SOCKET")
816 out, err = run("pre-receive", "ERMITE_HOOK_SOCKET=/nonexistent.sock", "ERMITE_HOOK_TOKEN=x")
817 require.Error(t, err)
818 assert.Contains(t, out, "unreachable")
819 out, err = run("post-receive", "ERMITE_HOOK_SOCKET=/nonexistent.sock", "ERMITE_HOOK_TOKEN=x")
820 require.NoError(t, err)
821 assert.Contains(t, out, "post-receive")
822
823 help, err := exec.Command(bin, "--help").CombinedOutput()
824 require.NoError(t, err)
825 assert.NotContains(t, string(help), "hook", "the command is hidden")
826}
827
828const domainZero = "0000000000000000000000000000000000000000"