v0.26.1 dfd6f0b

e2e_test.go RawHistoryFolder

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"

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close