detached 1ab78bb

feat(githttp): serve smart HTTP through git http-backend with Basic auth and a failed-login limiter

commit
1ab78bb54f51804a6519462999ed64a8c6216a79
author
brice · · 2026-10-10 07:10 UTC
parent
851827c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +1924 -1
M .mockery.yml +2 -0
@@ -12,3 +12,5 @@ packages:
1212 GitRepos:
1313 Clock:
1414 TokenStore:
15+ GitBackend:
16+ EventBus:
@@ -12,3 +12,5 @@ packages:
12 GitRepos:12 GitRepos:
13 Clock:13 Clock:
14 TokenStore:14 TokenStore:
15+ GitBackend:
16+ EventBus:
A internal/adapters/gitcli/backend.go +93 -0
@@ -0,0 +1,93 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+var _ ports.GitBackend = (*Git)(nil)
17+
18+// backendOwner is REMOTE_USER of a push: ermite authorizes before Git runs,
19+// so Git only needs to see a user.
20+const backendOwner = "owner"
21+
22+// HooksDir is the directory, below the data root, that holds the hook
23+// scripts every receive-pack runs.
24+func HooksDir(dataDir string) string { return filepath.Join(dataDir, "run", "hooks") }
25+
26+// HTTPBackend resolves git-http-backend through `git --exec-path` of the
27+// configured git (once), and builds its environment: the isolated one,
28+// GIT_PROJECT_ROOT=<data>/repos, GIT_HTTP_EXPORT_ALL and the configuration
29+// passed by GIT_CONFIG_COUNT/KEY/VALUE, so that nothing is written to the
30+// repositories:
31+//
32+// core.hooksPath=<data>/run/hooks
33+// receive.fsckObjects=true
34+// uploadpack.allowFilter=true
35+// http.receivepack=true (push only)
36+//
37+// A push also gets REMOTE_USER=owner.
38+func (g *Git) HTTPBackend(ctx context.Context, push bool) (ports.HTTPBackend, error) {
39+ path, err := g.backendPath(ctx)
40+ if err != nil {
41+ return ports.HTTPBackend{}, err
42+ }
43+ conf := [][2]string{
44+ {"core.hooksPath", HooksDir(g.dataDir)},
45+ {"receive.fsckObjects", "true"},
46+ {"uploadpack.allowFilter", "true"},
47+ }
48+ if push {
49+ conf = append(conf, [2]string{"http.receivepack", "true"})
50+ }
51+ env := append([]string(nil), g.env...)
52+ env = append(env,
53+ "GIT_PROJECT_ROOT="+g.reposDir(),
54+ "GIT_HTTP_EXPORT_ALL=1",
55+ "GIT_CONFIG_COUNT="+strconv.Itoa(len(conf)),
56+ )
57+ for i, kv := range conf {
58+ n := strconv.Itoa(i)
59+ env = append(env, "GIT_CONFIG_KEY_"+n+"="+kv[0], "GIT_CONFIG_VALUE_"+n+"="+kv[1])
60+ }
61+ if push {
62+ env = append(env, "REMOTE_USER="+backendOwner)
63+ }
64+ return ports.HTTPBackend{Path: path, Dir: g.reposDir(), Env: env}, nil
65+}
66+
67+// backendPath returns <git --exec-path>/git-http-backend, cached after the
68+// first success.
69+func (g *Git) backendPath(ctx context.Context) (string, error) {
70+ g.mu.Lock()
71+ defer g.mu.Unlock()
72+ if g.backend != "" {
73+ return g.backend, nil
74+ }
75+ out, err := g.run(ctx, "--exec-path")
76+ if err != nil {
77+ return "", fmt.Errorf("find git exec path: %w", err)
78+ }
79+ execPath := strings.TrimSpace(out)
80+ if execPath == "" {
81+ return "", fmt.Errorf("find git exec path: git printed nothing")
82+ }
83+ path := filepath.Join(execPath, "git-http-backend")
84+ fi, err := os.Stat(path)
85+ if err != nil {
86+ return "", fmt.Errorf("find git-http-backend: %w", err)
87+ }
88+ if fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
89+ return "", fmt.Errorf("find git-http-backend: %s is not an executable", path)
90+ }
91+ g.backend = path
92+ return path, nil
93+}
@@ -0,0 +1,93 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+var _ ports.GitBackend = (*Git)(nil)
17+
18+// backendOwner is REMOTE_USER of a push: ermite authorizes before Git runs,
19+// so Git only needs to see a user.
20+const backendOwner = "owner"
21+
22+// HooksDir is the directory, below the data root, that holds the hook
23+// scripts every receive-pack runs.
24+func HooksDir(dataDir string) string { return filepath.Join(dataDir, "run", "hooks") }
25+
26+// HTTPBackend resolves git-http-backend through `git --exec-path` of the
27+// configured git (once), and builds its environment: the isolated one,
28+// GIT_PROJECT_ROOT=<data>/repos, GIT_HTTP_EXPORT_ALL and the configuration
29+// passed by GIT_CONFIG_COUNT/KEY/VALUE, so that nothing is written to the
30+// repositories:
31+//
32+// core.hooksPath=<data>/run/hooks
33+// receive.fsckObjects=true
34+// uploadpack.allowFilter=true
35+// http.receivepack=true (push only)
36+//
37+// A push also gets REMOTE_USER=owner.
38+func (g *Git) HTTPBackend(ctx context.Context, push bool) (ports.HTTPBackend, error) {
39+ path, err := g.backendPath(ctx)
40+ if err != nil {
41+ return ports.HTTPBackend{}, err
42+ }
43+ conf := [][2]string{
44+ {"core.hooksPath", HooksDir(g.dataDir)},
45+ {"receive.fsckObjects", "true"},
46+ {"uploadpack.allowFilter", "true"},
47+ }
48+ if push {
49+ conf = append(conf, [2]string{"http.receivepack", "true"})
50+ }
51+ env := append([]string(nil), g.env...)
52+ env = append(env,
53+ "GIT_PROJECT_ROOT="+g.reposDir(),
54+ "GIT_HTTP_EXPORT_ALL=1",
55+ "GIT_CONFIG_COUNT="+strconv.Itoa(len(conf)),
56+ )
57+ for i, kv := range conf {
58+ n := strconv.Itoa(i)
59+ env = append(env, "GIT_CONFIG_KEY_"+n+"="+kv[0], "GIT_CONFIG_VALUE_"+n+"="+kv[1])
60+ }
61+ if push {
62+ env = append(env, "REMOTE_USER="+backendOwner)
63+ }
64+ return ports.HTTPBackend{Path: path, Dir: g.reposDir(), Env: env}, nil
65+}
66+
67+// backendPath returns <git --exec-path>/git-http-backend, cached after the
68+// first success.
69+func (g *Git) backendPath(ctx context.Context) (string, error) {
70+ g.mu.Lock()
71+ defer g.mu.Unlock()
72+ if g.backend != "" {
73+ return g.backend, nil
74+ }
75+ out, err := g.run(ctx, "--exec-path")
76+ if err != nil {
77+ return "", fmt.Errorf("find git exec path: %w", err)
78+ }
79+ execPath := strings.TrimSpace(out)
80+ if execPath == "" {
81+ return "", fmt.Errorf("find git exec path: git printed nothing")
82+ }
83+ path := filepath.Join(execPath, "git-http-backend")
84+ fi, err := os.Stat(path)
85+ if err != nil {
86+ return "", fmt.Errorf("find git-http-backend: %w", err)
87+ }
88+ if fi.IsDir() || fi.Mode().Perm()&0o111 == 0 {
89+ return "", fmt.Errorf("find git-http-backend: %s is not an executable", path)
90+ }
91+ g.backend = path
92+ return path, nil
93+}
A internal/adapters/gitcli/backend_test.go +160 -0
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/adapters/gitcli"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+func envMap(env []string) map[string]string {
21+ m := map[string]string{}
22+ for _, e := range env {
23+ k, v, _ := strings.Cut(e, "=")
24+ m[k] = v
25+ }
26+ return m
27+}
28+
29+func TestHTTPBackendEnvironment(t *testing.T) {
30+ ctx := context.Background()
31+ g, data := newGit(t)
32+
33+ read, err := g.HTTPBackend(ctx, false)
34+ require.NoError(t, err)
35+ execPath, err := exec.Command("git", "--exec-path").Output()
36+ require.NoError(t, err)
37+ assert.Equal(t, filepath.Join(strings.TrimSpace(string(execPath)), "git-http-backend"), read.Path)
38+ assert.Equal(t, filepath.Join(data, "repos"), read.Dir)
39+
40+ e := envMap(read.Env)
41+ assert.Equal(t, "1", e["GIT_CONFIG_NOSYSTEM"])
42+ assert.Equal(t, filepath.Join(data, "run", "git-home"), e["HOME"])
43+ assert.Equal(t, "0", e["GIT_TERMINAL_PROMPT"])
44+ assert.Equal(t, filepath.Join(data, "repos"), e["GIT_PROJECT_ROOT"])
45+ assert.Equal(t, "1", e["GIT_HTTP_EXPORT_ALL"])
46+ assert.NotContains(t, e, "REMOTE_USER", "a read is not a push")
47+
48+ conf := map[string]string{}
49+ for i := range 10 {
50+ k, ok := e["GIT_CONFIG_KEY_"+string(rune('0'+i))]
51+ if !ok {
52+ assert.Equal(t, string(rune('0'+i)), e["GIT_CONFIG_COUNT"], "count matches the keys")
53+ break
54+ }
55+ conf[k] = e["GIT_CONFIG_VALUE_"+string(rune('0'+i))]
56+ }
57+ assert.Equal(t, map[string]string{
58+ "core.hooksPath": filepath.Join(data, "run", "hooks"),
59+ "receive.fsckObjects": "true",
60+ "uploadpack.allowFilter": "true",
61+ }, conf)
62+
63+ push, err := g.HTTPBackend(ctx, true)
64+ require.NoError(t, err)
65+ pe := envMap(push.Env)
66+ assert.Equal(t, "owner", pe["REMOTE_USER"])
67+ assert.Equal(t, "4", pe["GIT_CONFIG_COUNT"])
68+ found := false
69+ for i := range 4 {
70+ if pe["GIT_CONFIG_KEY_"+string(rune('0'+i))] == "http.receivepack" {
71+ assert.Equal(t, "true", pe["GIT_CONFIG_VALUE_"+string(rune('0'+i))])
72+ found = true
73+ }
74+ }
75+ assert.True(t, found, "http.receivepack only for a push")
76+
77+ // Nothing from the caller's environment leaks.
78+ t.Setenv("GIT_DIR", "/leak")
79+ again, err := g.HTTPBackend(ctx, true)
80+ require.NoError(t, err)
81+ assert.NotContains(t, envMap(again.Env), "GIT_DIR")
82+
83+ // The slice is the caller's: appending does not corrupt the next call.
84+ _ = append(read.Env, "X=1")
85+ third, err := g.HTTPBackend(ctx, false)
86+ require.NoError(t, err)
87+ assert.NotContains(t, envMap(third.Env), "X")
88+}
89+
90+func TestHTTPBackendMissing(t *testing.T) {
91+ data := t.TempDir()
92+ script := writeScript(t, `case "$1" in --exec-path) echo "`+data+`/nowhere";; *) echo "git version 2.50.0";; esac`)
93+ g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
94+ _, err := g.HTTPBackend(context.Background(), false)
95+ require.Error(t, err)
96+ assert.Contains(t, err.Error(), "git-http-backend")
97+
98+ // A non-executable file is refused too.
99+ require.NoError(t, os.MkdirAll(filepath.Join(data, "exec"), 0o755))
100+ require.NoError(t, os.WriteFile(filepath.Join(data, "exec", "git-http-backend"), []byte("x"), 0o644))
101+ script = writeScript(t, `echo "`+data+`/exec"`)
102+ g = gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
103+ _, err = g.HTTPBackend(context.Background(), false)
104+ require.Error(t, err)
105+ assert.Contains(t, err.Error(), "not an executable")
106+}
107+
108+func TestDefaultBranchAndMaintain(t *testing.T) {
109+ ctx := context.Background()
110+ g, data := newGit(t)
111+ n := name(t, "tools")
112+ id, err := g.Stage(ctx)
113+ require.NoError(t, err)
114+ require.NoError(t, g.Publish(ctx, id, n))
115+ dir := filepath.Join(data, "repos", "tools.git")
116+
117+ branch, exists, err := g.DefaultBranch(ctx, n)
118+ require.NoError(t, err)
119+ assert.Equal(t, "main", branch)
120+ assert.False(t, exists, "no commit yet")
121+
122+ _, _, err = g.DefaultBranch(ctx, name(t, "ghost"))
123+ assert.ErrorIs(t, err, domain.ErrNotFound)
124+
125+ // Put a commit on "dev" through a work tree.
126+ wt := t.TempDir()
127+ env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
128+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
129+ run := func(args ...string) {
130+ cmd := exec.Command("git", args...)
131+ cmd.Dir, cmd.Env = wt, env
132+ out, err := cmd.CombinedOutput()
133+ require.NoError(t, err, string(out))
134+ }
135+ run("init", "-q", "--initial-branch=dev")
136+ require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte("x"), 0o644))
137+ run("add", "f")
138+ run("commit", "-q", "-m", "one")
139+ run("push", "-q", dir, "dev")
140+
141+ _, exists, err = g.DefaultBranch(ctx, n)
142+ require.NoError(t, err)
143+ assert.False(t, exists, "HEAD still points to main")
144+
145+ require.NoError(t, g.SetDefaultBranch(ctx, n, "dev"))
146+ branch, exists, err = g.DefaultBranch(ctx, n)
147+ require.NoError(t, err)
148+ assert.Equal(t, "dev", branch)
149+ assert.True(t, exists)
150+ assert.Equal(t, "refs/heads/dev", gitOut(t, dir, "symbolic-ref", "HEAD"))
151+
152+ for _, bad := range []string{"", "-x", "a b", "a..b"} {
153+ assert.Error(t, g.SetDefaultBranch(ctx, n, bad), bad)
154+ }
155+ assert.ErrorIs(t, g.SetDefaultBranch(ctx, name(t, "ghost"), "dev"), domain.ErrNotFound)
156+
157+ require.NoError(t, g.Maintain(ctx, n))
158+ assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain"))
159+ assert.ErrorIs(t, g.Maintain(ctx, name(t, "ghost")), domain.ErrNotFound)
160+}
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/adapters/gitcli"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+func envMap(env []string) map[string]string {
21+ m := map[string]string{}
22+ for _, e := range env {
23+ k, v, _ := strings.Cut(e, "=")
24+ m[k] = v
25+ }
26+ return m
27+}
28+
29+func TestHTTPBackendEnvironment(t *testing.T) {
30+ ctx := context.Background()
31+ g, data := newGit(t)
32+
33+ read, err := g.HTTPBackend(ctx, false)
34+ require.NoError(t, err)
35+ execPath, err := exec.Command("git", "--exec-path").Output()
36+ require.NoError(t, err)
37+ assert.Equal(t, filepath.Join(strings.TrimSpace(string(execPath)), "git-http-backend"), read.Path)
38+ assert.Equal(t, filepath.Join(data, "repos"), read.Dir)
39+
40+ e := envMap(read.Env)
41+ assert.Equal(t, "1", e["GIT_CONFIG_NOSYSTEM"])
42+ assert.Equal(t, filepath.Join(data, "run", "git-home"), e["HOME"])
43+ assert.Equal(t, "0", e["GIT_TERMINAL_PROMPT"])
44+ assert.Equal(t, filepath.Join(data, "repos"), e["GIT_PROJECT_ROOT"])
45+ assert.Equal(t, "1", e["GIT_HTTP_EXPORT_ALL"])
46+ assert.NotContains(t, e, "REMOTE_USER", "a read is not a push")
47+
48+ conf := map[string]string{}
49+ for i := range 10 {
50+ k, ok := e["GIT_CONFIG_KEY_"+string(rune('0'+i))]
51+ if !ok {
52+ assert.Equal(t, string(rune('0'+i)), e["GIT_CONFIG_COUNT"], "count matches the keys")
53+ break
54+ }
55+ conf[k] = e["GIT_CONFIG_VALUE_"+string(rune('0'+i))]
56+ }
57+ assert.Equal(t, map[string]string{
58+ "core.hooksPath": filepath.Join(data, "run", "hooks"),
59+ "receive.fsckObjects": "true",
60+ "uploadpack.allowFilter": "true",
61+ }, conf)
62+
63+ push, err := g.HTTPBackend(ctx, true)
64+ require.NoError(t, err)
65+ pe := envMap(push.Env)
66+ assert.Equal(t, "owner", pe["REMOTE_USER"])
67+ assert.Equal(t, "4", pe["GIT_CONFIG_COUNT"])
68+ found := false
69+ for i := range 4 {
70+ if pe["GIT_CONFIG_KEY_"+string(rune('0'+i))] == "http.receivepack" {
71+ assert.Equal(t, "true", pe["GIT_CONFIG_VALUE_"+string(rune('0'+i))])
72+ found = true
73+ }
74+ }
75+ assert.True(t, found, "http.receivepack only for a push")
76+
77+ // Nothing from the caller's environment leaks.
78+ t.Setenv("GIT_DIR", "/leak")
79+ again, err := g.HTTPBackend(ctx, true)
80+ require.NoError(t, err)
81+ assert.NotContains(t, envMap(again.Env), "GIT_DIR")
82+
83+ // The slice is the caller's: appending does not corrupt the next call.
84+ _ = append(read.Env, "X=1")
85+ third, err := g.HTTPBackend(ctx, false)
86+ require.NoError(t, err)
87+ assert.NotContains(t, envMap(third.Env), "X")
88+}
89+
90+func TestHTTPBackendMissing(t *testing.T) {
91+ data := t.TempDir()
92+ script := writeScript(t, `case "$1" in --exec-path) echo "`+data+`/nowhere";; *) echo "git version 2.50.0";; esac`)
93+ g := gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
94+ _, err := g.HTTPBackend(context.Background(), false)
95+ require.Error(t, err)
96+ assert.Contains(t, err.Error(), "git-http-backend")
97+
98+ // A non-executable file is refused too.
99+ require.NoError(t, os.MkdirAll(filepath.Join(data, "exec"), 0o755))
100+ require.NoError(t, os.WriteFile(filepath.Join(data, "exec", "git-http-backend"), []byte("x"), 0o644))
101+ script = writeScript(t, `echo "`+data+`/exec"`)
102+ g = gitcli.New(gitcli.Config{Bin: script, DataDir: data, Path: "/usr/bin:/bin"})
103+ _, err = g.HTTPBackend(context.Background(), false)
104+ require.Error(t, err)
105+ assert.Contains(t, err.Error(), "not an executable")
106+}
107+
108+func TestDefaultBranchAndMaintain(t *testing.T) {
109+ ctx := context.Background()
110+ g, data := newGit(t)
111+ n := name(t, "tools")
112+ id, err := g.Stage(ctx)
113+ require.NoError(t, err)
114+ require.NoError(t, g.Publish(ctx, id, n))
115+ dir := filepath.Join(data, "repos", "tools.git")
116+
117+ branch, exists, err := g.DefaultBranch(ctx, n)
118+ require.NoError(t, err)
119+ assert.Equal(t, "main", branch)
120+ assert.False(t, exists, "no commit yet")
121+
122+ _, _, err = g.DefaultBranch(ctx, name(t, "ghost"))
123+ assert.ErrorIs(t, err, domain.ErrNotFound)
124+
125+ // Put a commit on "dev" through a work tree.
126+ wt := t.TempDir()
127+ env := []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
128+ "GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@x", "GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@x"}
129+ run := func(args ...string) {
130+ cmd := exec.Command("git", args...)
131+ cmd.Dir, cmd.Env = wt, env
132+ out, err := cmd.CombinedOutput()
133+ require.NoError(t, err, string(out))
134+ }
135+ run("init", "-q", "--initial-branch=dev")
136+ require.NoError(t, os.WriteFile(filepath.Join(wt, "f"), []byte("x"), 0o644))
137+ run("add", "f")
138+ run("commit", "-q", "-m", "one")
139+ run("push", "-q", dir, "dev")
140+
141+ _, exists, err = g.DefaultBranch(ctx, n)
142+ require.NoError(t, err)
143+ assert.False(t, exists, "HEAD still points to main")
144+
145+ require.NoError(t, g.SetDefaultBranch(ctx, n, "dev"))
146+ branch, exists, err = g.DefaultBranch(ctx, n)
147+ require.NoError(t, err)
148+ assert.Equal(t, "dev", branch)
149+ assert.True(t, exists)
150+ assert.Equal(t, "refs/heads/dev", gitOut(t, dir, "symbolic-ref", "HEAD"))
151+
152+ for _, bad := range []string{"", "-x", "a b", "a..b"} {
153+ assert.Error(t, g.SetDefaultBranch(ctx, n, bad), bad)
154+ }
155+ assert.ErrorIs(t, g.SetDefaultBranch(ctx, name(t, "ghost"), "dev"), domain.ErrNotFound)
156+
157+ require.NoError(t, g.Maintain(ctx, n))
158+ assert.FileExists(t, filepath.Join(dir, "objects", "info", "commit-graphs", "commit-graph-chain"))
159+ assert.ErrorIs(t, g.Maintain(ctx, name(t, "ghost")), domain.ErrNotFound)
160+}
M internal/adapters/gitcli/git.go +4 -0
@@ -13,6 +13,7 @@ import (
1313 "os/exec"
1414 "path/filepath"
1515 "strings"
16+ "sync"
1617 "time"
1718 )
1819
@@ -34,6 +35,9 @@ type Git struct {
3435 bin string
3536 dataDir string
3637 env []string
38+
39+ mu sync.Mutex // guards backend
40+ backend string // git-http-backend, resolved on first use
3741 }
3842
3943 // New builds an adapter. It does not run git: call CheckVersion.
@@ -13,6 +13,7 @@ import (
13 "os/exec"13 "os/exec"
14 "path/filepath"14 "path/filepath"
15 "strings"15 "strings"
16+ "sync"
16 "time"17 "time"
17 )18 )
18 19
@@ -34,6 +35,9 @@ type Git struct {
34 bin string35 bin string
35 dataDir string36 dataDir string
36 env []string37 env []string
38+
39+ mu sync.Mutex // guards backend
40+ backend string // git-http-backend, resolved on first use
37 }41 }
38 42
39 // New builds an adapter. It does not run git: call CheckVersion.43 // New builds an adapter. It does not run git: call CheckVersion.
M internal/adapters/gitcli/repos.go +71 -0
@@ -211,3 +211,74 @@ func syncDir(dir string) error {
211211 defer func() { _ = f.Close() }()
212212 return f.Sync()
213213 }
214+
215+// repoDir returns repos/<name>.git, or domain.ErrNotFound if it is missing.
216+func (g *Git) repoDir(name domain.RepoName) (string, error) {
217+ dir := filepath.Join(g.reposDir(), name.String()+gitSuffix)
218+ fi, err := os.Stat(dir)
219+ if err != nil {
220+ if errors.Is(err, fs.ErrNotExist) {
221+ return "", domain.ErrNotFound
222+ }
223+ return "", fmt.Errorf("repository %q: %w", name, err)
224+ }
225+ if !fi.IsDir() {
226+ return "", domain.ErrNotFound
227+ }
228+ return dir, nil
229+}
230+
231+const headsPrefix = "refs/heads/"
232+
233+// DefaultBranch reads HEAD with `git symbolic-ref` and checks the branch with
234+// `git for-each-ref`.
235+func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string, bool, error) {
236+ dir, err := g.repoDir(name)
237+ if err != nil {
238+ return "", false, err
239+ }
240+ out, err := g.run(ctx, "-C", dir, "symbolic-ref", "--quiet", "HEAD")
241+ if err != nil {
242+ return "", false, fmt.Errorf("read HEAD of %q: %w", name, err)
243+ }
244+ ref := strings.TrimSpace(out)
245+ branch, ok := strings.CutPrefix(ref, headsPrefix)
246+ if !ok {
247+ return "", false, fmt.Errorf("HEAD of %q points outside refs/heads: %q", name, ref)
248+ }
249+ out, err = g.run(ctx, "-C", dir, "for-each-ref", "--count=1", "--format=%(refname)", ref)
250+ if err != nil {
251+ return "", false, fmt.Errorf("look up %s in %q: %w", ref, name, err)
252+ }
253+ return branch, strings.TrimSpace(out) == ref, nil
254+}
255+
256+// SetDefaultBranch runs `git symbolic-ref HEAD refs/heads/<branch>`.
257+func (g *Git) SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error {
258+ dir, err := g.repoDir(name)
259+ if err != nil {
260+ return err
261+ }
262+ if branch == "" || strings.HasPrefix(branch, "-") {
263+ return fmt.Errorf("invalid branch name %q", branch)
264+ }
265+ if _, err := g.run(ctx, "check-ref-format", "--branch", branch); err != nil {
266+ return fmt.Errorf("invalid branch name %q: %w", branch, err)
267+ }
268+ if _, err := g.run(ctx, "-C", dir, "symbolic-ref", "HEAD", headsPrefix+branch); err != nil {
269+ return fmt.Errorf("set HEAD of %q: %w", name, err)
270+ }
271+ return nil
272+}
273+
274+// Maintain runs `git commit-graph write --reachable --changed-paths --split`.
275+func (g *Git) Maintain(ctx context.Context, name domain.RepoName) error {
276+ dir, err := g.repoDir(name)
277+ if err != nil {
278+ return err
279+ }
280+ if _, err := g.run(ctx, "-C", dir, "commit-graph", "write", "--reachable", "--changed-paths", "--split"); err != nil {
281+ return fmt.Errorf("maintain %q: %w", name, err)
282+ }
283+ return nil
284+}
@@ -211,3 +211,74 @@ func syncDir(dir string) error {
211 defer func() { _ = f.Close() }()211 defer func() { _ = f.Close() }()
212 return f.Sync()212 return f.Sync()
213 }213 }
214+
215+// repoDir returns repos/<name>.git, or domain.ErrNotFound if it is missing.
216+func (g *Git) repoDir(name domain.RepoName) (string, error) {
217+ dir := filepath.Join(g.reposDir(), name.String()+gitSuffix)
218+ fi, err := os.Stat(dir)
219+ if err != nil {
220+ if errors.Is(err, fs.ErrNotExist) {
221+ return "", domain.ErrNotFound
222+ }
223+ return "", fmt.Errorf("repository %q: %w", name, err)
224+ }
225+ if !fi.IsDir() {
226+ return "", domain.ErrNotFound
227+ }
228+ return dir, nil
229+}
230+
231+const headsPrefix = "refs/heads/"
232+
233+// DefaultBranch reads HEAD with `git symbolic-ref` and checks the branch with
234+// `git for-each-ref`.
235+func (g *Git) DefaultBranch(ctx context.Context, name domain.RepoName) (string, bool, error) {
236+ dir, err := g.repoDir(name)
237+ if err != nil {
238+ return "", false, err
239+ }
240+ out, err := g.run(ctx, "-C", dir, "symbolic-ref", "--quiet", "HEAD")
241+ if err != nil {
242+ return "", false, fmt.Errorf("read HEAD of %q: %w", name, err)
243+ }
244+ ref := strings.TrimSpace(out)
245+ branch, ok := strings.CutPrefix(ref, headsPrefix)
246+ if !ok {
247+ return "", false, fmt.Errorf("HEAD of %q points outside refs/heads: %q", name, ref)
248+ }
249+ out, err = g.run(ctx, "-C", dir, "for-each-ref", "--count=1", "--format=%(refname)", ref)
250+ if err != nil {
251+ return "", false, fmt.Errorf("look up %s in %q: %w", ref, name, err)
252+ }
253+ return branch, strings.TrimSpace(out) == ref, nil
254+}
255+
256+// SetDefaultBranch runs `git symbolic-ref HEAD refs/heads/<branch>`.
257+func (g *Git) SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error {
258+ dir, err := g.repoDir(name)
259+ if err != nil {
260+ return err
261+ }
262+ if branch == "" || strings.HasPrefix(branch, "-") {
263+ return fmt.Errorf("invalid branch name %q", branch)
264+ }
265+ if _, err := g.run(ctx, "check-ref-format", "--branch", branch); err != nil {
266+ return fmt.Errorf("invalid branch name %q: %w", branch, err)
267+ }
268+ if _, err := g.run(ctx, "-C", dir, "symbolic-ref", "HEAD", headsPrefix+branch); err != nil {
269+ return fmt.Errorf("set HEAD of %q: %w", name, err)
270+ }
271+ return nil
272+}
273+
274+// Maintain runs `git commit-graph write --reachable --changed-paths --split`.
275+func (g *Git) Maintain(ctx context.Context, name domain.RepoName) error {
276+ dir, err := g.repoDir(name)
277+ if err != nil {
278+ return err
279+ }
280+ if _, err := g.run(ctx, "-C", dir, "commit-graph", "write", "--reachable", "--changed-paths", "--split"); err != nil {
281+ return fmt.Errorf("maintain %q: %w", name, err)
282+ }
283+ return nil
284+}
A internal/adapters/githttp/handler.go +436 -0
@@ -0,0 +1,436 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package githttp serves Git over smart HTTP: it routes, authenticates and
4+// authorizes a request, then lets `git http-backend` answer it through
5+// net/http/cgi. It also hosts the Unix-socket server that the push hooks call
6+// back into.
7+package githttp
8+
9+import (
10+ "context"
11+ "errors"
12+ "io"
13+ "log"
14+ "math"
15+ "net/http"
16+ "net/http/cgi"
17+ "net/netip"
18+ "os"
19+ "strconv"
20+ "strings"
21+ "time"
22+
23+ "github.com/bnema/zerowrap"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+)
28+
29+// Patterns are the ServeMux patterns the handler serves. Everything else
30+// under /<name>/ (dumb HTTP, HEAD, objects/...) is a 404 from the mux.
31+var Patterns = []string{
32+ "GET /{repo}/info/refs",
33+ "POST /{repo}/git-upload-pack",
34+ "POST /{repo}/git-receive-pack",
35+}
36+
37+// DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
38+// is zero.
39+const DefaultMaxBodyBytes int64 = 4 << 30
40+
41+// Names of the environment variables that carry the hook callback settings
42+// from ermite, through git, to the hook process.
43+const (
44+ // EnvHookSocket is the path of the hook Unix socket.
45+ EnvHookSocket = "ERMITE_HOOK_SOCKET"
46+ // EnvHookToken is the per-request hook token.
47+ EnvHookToken = "ERMITE_HOOK_TOKEN"
48+)
49+
50+const (
51+ serviceUpload = "git-upload-pack"
52+ serviceReceive = "git-receive-pack"
53+)
54+
55+// Authenticator turns the secret of a request into a principal. It returns
56+// domain.ErrUnauthenticated for a secret that is not valid. app.Auth fits.
57+type Authenticator interface {
58+ Authenticate(ctx context.Context, secret string) (domain.Principal, error)
59+}
60+
61+// Repos is the read side of the repository use cases. A repository the
62+// principal may not read is domain.ErrNotFound. app.Repos fits.
63+type Repos interface {
64+ Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)
65+}
66+
67+// Pushes opens a repository for a push: it is domain.ErrUnauthenticated for an
68+// anonymous caller, domain.ErrNotFound for a repository the caller may not
69+// write and cannot see (or that is missing and may not be created),
70+// domain.ErrForbidden for one it can see but not write. With create, a missing
71+// repository the principal may write is created. app.Pushes fits.
72+type Pushes interface {
73+ Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) (repo domain.Repo, created bool, err error)
74+}
75+
76+// HookTokens hands out the per-request tokens the hooks of a push present
77+// back to ermite, and the socket they present them on.
78+type HookTokens interface {
79+ // Register binds a new token to the repository and principal of a push.
80+ // release revokes it and must be called when the request ends.
81+ Register(repo domain.RepoName, p domain.Principal) (token string, release func())
82+ // Socket is the path of the hook socket.
83+ Socket() string
84+}
85+
86+// Config configures a Handler.
87+type Config struct {
88+ Auth Authenticator
89+ Repos Repos
90+ Pushes Pushes
91+ Backend ports.GitBackend
92+ // Hooks provides hook tokens for pushes. Nil runs pushes without hook
93+ // environment (tests of the handler alone).
94+ Hooks HookTokens
95+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
96+ TrustedProxies []netip.Prefix
97+ // Limiter counts failed authentications; nil builds a default one.
98+ Limiter *Limiter
99+ // TempDir holds request bodies that arrive chunked, which CGI cannot
100+ // stream. Empty uses the system temporary directory.
101+ TempDir string
102+ // MaxBodyBytes bounds a request body; 0 means DefaultMaxBodyBytes.
103+ MaxBodyBytes int64
104+}
105+
106+// Handler serves the smart HTTP routes of Patterns.
107+type Handler struct {
108+ cfg Config
109+ limiter *Limiter
110+ maxBody int64
111+}
112+
113+// New builds a Handler.
114+func New(cfg Config) *Handler {
115+ h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}
116+ if h.limiter == nil {
117+ h.limiter = NewLimiter(nil)
118+ }
119+ if h.maxBody <= 0 {
120+ h.maxBody = DefaultMaxBodyBytes
121+ }
122+ return h
123+}
124+
125+// route is a parsed Git request.
126+type route struct {
127+ repo string // the path segment, with any .git suffix
128+ suffix string // "info/refs", "git-upload-pack" or "git-receive-pack"
129+ service string // serviceUpload or serviceReceive
130+}
131+
132+func (rt route) push() bool { return rt.service == serviceReceive }
133+
134+// parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
135+// and /<repo>/git-receive-pack. info/refs without a service is the dumb
136+// protocol, which ermite does not serve.
137+func parseRoute(r *http.Request) (route, int) {
138+ seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
139+ if !ok || seg == "" {
140+ return route{}, http.StatusNotFound
141+ }
142+ rt := route{repo: seg, suffix: rest}
143+ switch rest {
144+ case "info/refs":
145+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
146+ return route{}, http.StatusMethodNotAllowed
147+ }
148+ switch svc := r.URL.Query().Get("service"); svc {
149+ case serviceUpload, serviceReceive:
150+ rt.service = svc
151+ default:
152+ return route{}, http.StatusNotFound
153+ }
154+ case serviceUpload, serviceReceive:
155+ if r.Method != http.MethodPost {
156+ return route{}, http.StatusMethodNotAllowed
157+ }
158+ rt.service = rest
159+ default:
160+ return route{}, http.StatusNotFound
161+ }
162+ return rt, 0
163+}
164+
165+// ServeHTTP implements http.Handler.
166+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
167+ start := time.Now()
168+ rec := &recorder{ResponseWriter: w}
169+ var rt route
170+ var name domain.RepoName
171+ var principal domain.Principal
172+ defer func() {
173+ log := zerowrap.FromCtx(r.Context())
174+ log.Debug().
175+ Str("repo", name.String()).
176+ Str("service", rt.service).
177+ Int("status", rec.code()).
178+ Dur("duration", time.Since(start)).
179+ Str("principal", principal.Label()).
180+ Str("token_id", principal.TokenID()).
181+ Msg("git request")
182+ }()
183+ w = rec
184+
185+ rt, status := parseRoute(r)
186+ if status != 0 {
187+ http.Error(w, http.StatusText(status), status)
188+ return
189+ }
190+ name, err := domain.ParseRepoPath(rt.repo)
191+ if err != nil {
192+ notFound(w)
193+ return
194+ }
195+
196+ ctx := r.Context()
197+ log := zerowrap.FromCtx(ctx)
198+ addr := ClientAddr(r, h.cfg.TrustedProxies)
199+ if blocked, wait := h.limiter.Blocked(addr); blocked {
200+ log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
201+ w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
202+ http.Error(w, "too many failed authentications", http.StatusTooManyRequests)
203+ return
204+ }
205+
206+ principal, err = h.authenticate(r, addr)
207+ if err != nil {
208+ if errors.Is(err, domain.ErrUnauthenticated) {
209+ challenge(w)
210+ return
211+ }
212+ log.Error().Err(err).Msg("authenticate git request")
213+ http.Error(w, "internal error", http.StatusInternalServerError)
214+ return
215+ }
216+
217+ if rt.push() {
218+ // Only the first request of a push may create the repository.
219+ _, _, err = h.cfg.Pushes.Open(ctx, principal, name, rt.suffix == "info/refs")
220+ } else {
221+ _, err = h.cfg.Repos.Get(ctx, principal, name)
222+ }
223+ if err != nil {
224+ h.denied(r.Context(), w, principal, err)
225+ return
226+ }
227+
228+ h.serveBackend(w, r, rt, name, principal)
229+}
230+
231+// authenticate reads the credentials of the request. No credentials is the
232+// anonymous principal; credentials that are not valid count as a failure of
233+// the client address and give domain.ErrUnauthenticated.
234+func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
235+ secret, present := credentials(r)
236+ if !present {
237+ return domain.Anonymous(), nil
238+ }
239+ p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
240+ if err != nil {
241+ if errors.Is(err, domain.ErrUnauthenticated) {
242+ h.limiter.Fail(addr)
243+ log := zerowrap.FromCtx(r.Context())
244+ log.Debug().Str("client", addr).Msg("invalid credentials")
245+ }
246+ return domain.Anonymous(), err
247+ }
248+ return p, nil
249+}
250+
251+// credentials extracts the token from an Authorization header: the password of
252+// Basic (the username is ignored) or a Bearer token. present is true when the
253+// header exists, even if it carries nothing usable, which then fails
254+// authentication.
255+func credentials(r *http.Request) (secret string, present bool) {
256+ auth := r.Header.Get("Authorization")
257+ if auth == "" {
258+ return "", false
259+ }
260+ if _, pass, ok := r.BasicAuth(); ok {
261+ return pass, true
262+ }
263+ if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") {
264+ return strings.TrimSpace(tok), true
265+ }
266+ return "", true
267+}
268+
269+// denied answers an authorization failure. A caller without valid
270+// credentials is asked for them wherever the repository does not show itself,
271+// which keeps a private repository and a missing one indistinguishable.
272+func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
273+ switch {
274+ case errors.Is(err, domain.ErrUnauthenticated):
275+ challenge(w)
276+ case errors.Is(err, domain.ErrNotFound):
277+ if p.IsAnonymous() {
278+ challenge(w)
279+ return
280+ }
281+ notFound(w)
282+ case errors.Is(err, domain.ErrForbidden):
283+ http.Error(w, "forbidden", http.StatusForbidden)
284+ default:
285+ log := zerowrap.FromCtx(ctx)
286+ log.Error().Err(err).Msg("authorize git request")
287+ http.Error(w, "internal error", http.StatusInternalServerError)
288+ }
289+}
290+
291+func challenge(w http.ResponseWriter) {
292+ w.Header().Set("WWW-Authenticate", `Basic realm="ermite"`)
293+ http.Error(w, "authentication required", http.StatusUnauthorized)
294+}
295+
296+func notFound(w http.ResponseWriter) { http.Error(w, "not found", http.StatusNotFound) }
297+
298+// recorder remembers the status code of a response and keeps Flush working.
299+type recorder struct {
300+ http.ResponseWriter
301+ status int
302+}
303+
304+func (r *recorder) WriteHeader(code int) {
305+ if r.status == 0 {
306+ r.status = code
307+ }
308+ r.ResponseWriter.WriteHeader(code)
309+}
310+
311+func (r *recorder) Write(b []byte) (int, error) {
312+ if r.status == 0 {
313+ r.status = http.StatusOK
314+ }
315+ return r.ResponseWriter.Write(b)
316+}
317+
318+func (r *recorder) code() int {
319+ if r.status == 0 {
320+ return http.StatusOK
321+ }
322+ return r.status
323+}
324+
325+// Unwrap lets http.ResponseController reach Flush.
326+func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
327+
328+// sensitiveHeaders never reach the Git process: CGI would put them in its
329+// environment, which the hooks inherit.
330+var sensitiveHeaders = []string{"Authorization", "Proxy-Authorization", "Cookie"}
331+
332+// serveBackend runs git http-backend for an authorized request.
333+func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal) {
334+ ctx := r.Context()
335+ logger := zerowrap.FromCtx(ctx)
336+
337+ be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push())
338+ if err != nil {
339+ logger.Error().Err(err).Msg("prepare git http-backend")
340+ http.Error(w, "internal error", http.StatusInternalServerError)
341+ return
342+ }
343+ env := be.Env
344+ if rt.suffix == serviceReceive && h.cfg.Hooks != nil {
345+ token, release := h.cfg.Hooks.Register(name, p)
346+ defer release()
347+ env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token)
348+ }
349+
350+ req, cleanup, status := h.backendRequest(w, r, rt, name)
351+ defer cleanup()
352+ if status != 0 {
353+ http.Error(w, http.StatusText(status), status)
354+ return
355+ }
356+
357+ (&cgi.Handler{
358+ Path: be.Path,
359+ Dir: be.Dir,
360+ Env: env,
361+ Stderr: logWriter{ctx: ctx, repo: name.String()},
362+ Logger: log.New(logWriter{ctx: ctx, repo: name.String(), warn: true}, "", 0),
363+ }).ServeHTTP(w, req)
364+}
365+
366+// backendRequest builds the request handed to CGI: the path names the
367+// directory of the repository, credentials are removed, and a body of unknown
368+// length (chunked, which CGI refuses) is spooled to a temporary file so that
369+// its length is known. status is non-zero when the request must be refused;
370+// cleanup is always safe to call.
371+func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName) (req *http.Request, cleanup func(), status int) {
372+ cleanup = func() {}
373+ req = r.Clone(r.Context())
374+ req.URL.Path = "/" + name.String() + ".git/" + rt.suffix
375+ req.URL.RawPath = ""
376+ for _, k := range sensitiveHeaders {
377+ req.Header.Del(k)
378+ }
379+ if r.ContentLength > h.maxBody {
380+ return nil, cleanup, http.StatusRequestEntityTooLarge
381+ }
382+ if r.ContentLength >= 0 && len(r.TransferEncoding) == 0 {
383+ req.Body = http.MaxBytesReader(w, r.Body, h.maxBody)
384+ return req, cleanup, 0
385+ }
386+
387+ f, err := os.CreateTemp(h.cfg.TempDir, "body-*")
388+ if err != nil {
389+ logger := zerowrap.FromCtx(r.Context())
390+ logger.Error().Err(err).Msg("spool request body")
391+ return nil, cleanup, http.StatusInternalServerError
392+ }
393+ cleanup = func() {
394+ _ = f.Close()
395+ _ = os.Remove(f.Name())
396+ }
397+ n, err := io.Copy(f, http.MaxBytesReader(w, r.Body, h.maxBody))
398+ if err != nil {
399+ var tooBig *http.MaxBytesError
400+ if errors.As(err, &tooBig) {
401+ return nil, cleanup, http.StatusRequestEntityTooLarge
402+ }
403+ return nil, cleanup, http.StatusBadRequest
404+ }
405+ if _, err := f.Seek(0, io.SeekStart); err != nil {
406+ logger := zerowrap.FromCtx(r.Context())
407+ logger.Error().Err(err).Msg("spool request body")
408+ return nil, cleanup, http.StatusInternalServerError
409+ }
410+ req.Body = f
411+ req.ContentLength = n
412+ req.TransferEncoding = nil
413+ return req, cleanup, 0
414+}
415+
416+// logWriter sends what the Git process (or the CGI layer) writes to its
417+// standard error to the logger of the request.
418+type logWriter struct {
419+ ctx context.Context
420+ repo string
421+ warn bool
422+}
423+
424+func (l logWriter) Write(b []byte) (int, error) {
425+ msg := strings.TrimSpace(string(b))
426+ if msg == "" {
427+ return len(b), nil
428+ }
429+ logger := zerowrap.FromCtx(l.ctx)
430+ if l.warn {
431+ logger.Warn().Str("repo", l.repo).Msg(msg)
432+ } else {
433+ logger.Debug().Str("repo", l.repo).Str("source", "git").Msg(msg)
434+ }
435+ return len(b), nil
436+}
@@ -0,0 +1,436 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package githttp serves Git over smart HTTP: it routes, authenticates and
4+// authorizes a request, then lets `git http-backend` answer it through
5+// net/http/cgi. It also hosts the Unix-socket server that the push hooks call
6+// back into.
7+package githttp
8+
9+import (
10+ "context"
11+ "errors"
12+ "io"
13+ "log"
14+ "math"
15+ "net/http"
16+ "net/http/cgi"
17+ "net/netip"
18+ "os"
19+ "strconv"
20+ "strings"
21+ "time"
22+
23+ "github.com/bnema/zerowrap"
24+
25+ "bnema.dev/ermite/internal/domain"
26+ "bnema.dev/ermite/internal/ports"
27+)
28+
29+// Patterns are the ServeMux patterns the handler serves. Everything else
30+// under /<name>/ (dumb HTTP, HEAD, objects/...) is a 404 from the mux.
31+var Patterns = []string{
32+ "GET /{repo}/info/refs",
33+ "POST /{repo}/git-upload-pack",
34+ "POST /{repo}/git-receive-pack",
35+}
36+
37+// DefaultMaxBodyBytes bounds a request body (a push) when Config.MaxBodyBytes
38+// is zero.
39+const DefaultMaxBodyBytes int64 = 4 << 30
40+
41+// Names of the environment variables that carry the hook callback settings
42+// from ermite, through git, to the hook process.
43+const (
44+ // EnvHookSocket is the path of the hook Unix socket.
45+ EnvHookSocket = "ERMITE_HOOK_SOCKET"
46+ // EnvHookToken is the per-request hook token.
47+ EnvHookToken = "ERMITE_HOOK_TOKEN"
48+)
49+
50+const (
51+ serviceUpload = "git-upload-pack"
52+ serviceReceive = "git-receive-pack"
53+)
54+
55+// Authenticator turns the secret of a request into a principal. It returns
56+// domain.ErrUnauthenticated for a secret that is not valid. app.Auth fits.
57+type Authenticator interface {
58+ Authenticate(ctx context.Context, secret string) (domain.Principal, error)
59+}
60+
61+// Repos is the read side of the repository use cases. A repository the
62+// principal may not read is domain.ErrNotFound. app.Repos fits.
63+type Repos interface {
64+ Get(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error)
65+}
66+
67+// Pushes opens a repository for a push: it is domain.ErrUnauthenticated for an
68+// anonymous caller, domain.ErrNotFound for a repository the caller may not
69+// write and cannot see (or that is missing and may not be created),
70+// domain.ErrForbidden for one it can see but not write. With create, a missing
71+// repository the principal may write is created. app.Pushes fits.
72+type Pushes interface {
73+ Open(ctx context.Context, p domain.Principal, name domain.RepoName, create bool) (repo domain.Repo, created bool, err error)
74+}
75+
76+// HookTokens hands out the per-request tokens the hooks of a push present
77+// back to ermite, and the socket they present them on.
78+type HookTokens interface {
79+ // Register binds a new token to the repository and principal of a push.
80+ // release revokes it and must be called when the request ends.
81+ Register(repo domain.RepoName, p domain.Principal) (token string, release func())
82+ // Socket is the path of the hook socket.
83+ Socket() string
84+}
85+
86+// Config configures a Handler.
87+type Config struct {
88+ Auth Authenticator
89+ Repos Repos
90+ Pushes Pushes
91+ Backend ports.GitBackend
92+ // Hooks provides hook tokens for pushes. Nil runs pushes without hook
93+ // environment (tests of the handler alone).
94+ Hooks HookTokens
95+ // TrustedProxies are the networks allowed to set X-Forwarded-For.
96+ TrustedProxies []netip.Prefix
97+ // Limiter counts failed authentications; nil builds a default one.
98+ Limiter *Limiter
99+ // TempDir holds request bodies that arrive chunked, which CGI cannot
100+ // stream. Empty uses the system temporary directory.
101+ TempDir string
102+ // MaxBodyBytes bounds a request body; 0 means DefaultMaxBodyBytes.
103+ MaxBodyBytes int64
104+}
105+
106+// Handler serves the smart HTTP routes of Patterns.
107+type Handler struct {
108+ cfg Config
109+ limiter *Limiter
110+ maxBody int64
111+}
112+
113+// New builds a Handler.
114+func New(cfg Config) *Handler {
115+ h := &Handler{cfg: cfg, limiter: cfg.Limiter, maxBody: cfg.MaxBodyBytes}
116+ if h.limiter == nil {
117+ h.limiter = NewLimiter(nil)
118+ }
119+ if h.maxBody <= 0 {
120+ h.maxBody = DefaultMaxBodyBytes
121+ }
122+ return h
123+}
124+
125+// route is a parsed Git request.
126+type route struct {
127+ repo string // the path segment, with any .git suffix
128+ suffix string // "info/refs", "git-upload-pack" or "git-receive-pack"
129+ service string // serviceUpload or serviceReceive
130+}
131+
132+func (rt route) push() bool { return rt.service == serviceReceive }
133+
134+// parseRoute recognizes /<repo>/info/refs?service=..., /<repo>/git-upload-pack
135+// and /<repo>/git-receive-pack. info/refs without a service is the dumb
136+// protocol, which ermite does not serve.
137+func parseRoute(r *http.Request) (route, int) {
138+ seg, rest, ok := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
139+ if !ok || seg == "" {
140+ return route{}, http.StatusNotFound
141+ }
142+ rt := route{repo: seg, suffix: rest}
143+ switch rest {
144+ case "info/refs":
145+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
146+ return route{}, http.StatusMethodNotAllowed
147+ }
148+ switch svc := r.URL.Query().Get("service"); svc {
149+ case serviceUpload, serviceReceive:
150+ rt.service = svc
151+ default:
152+ return route{}, http.StatusNotFound
153+ }
154+ case serviceUpload, serviceReceive:
155+ if r.Method != http.MethodPost {
156+ return route{}, http.StatusMethodNotAllowed
157+ }
158+ rt.service = rest
159+ default:
160+ return route{}, http.StatusNotFound
161+ }
162+ return rt, 0
163+}
164+
165+// ServeHTTP implements http.Handler.
166+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
167+ start := time.Now()
168+ rec := &recorder{ResponseWriter: w}
169+ var rt route
170+ var name domain.RepoName
171+ var principal domain.Principal
172+ defer func() {
173+ log := zerowrap.FromCtx(r.Context())
174+ log.Debug().
175+ Str("repo", name.String()).
176+ Str("service", rt.service).
177+ Int("status", rec.code()).
178+ Dur("duration", time.Since(start)).
179+ Str("principal", principal.Label()).
180+ Str("token_id", principal.TokenID()).
181+ Msg("git request")
182+ }()
183+ w = rec
184+
185+ rt, status := parseRoute(r)
186+ if status != 0 {
187+ http.Error(w, http.StatusText(status), status)
188+ return
189+ }
190+ name, err := domain.ParseRepoPath(rt.repo)
191+ if err != nil {
192+ notFound(w)
193+ return
194+ }
195+
196+ ctx := r.Context()
197+ log := zerowrap.FromCtx(ctx)
198+ addr := ClientAddr(r, h.cfg.TrustedProxies)
199+ if blocked, wait := h.limiter.Blocked(addr); blocked {
200+ log.Warn().Str("client", addr).Msg("too many failed authentications, request blocked")
201+ w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
202+ http.Error(w, "too many failed authentications", http.StatusTooManyRequests)
203+ return
204+ }
205+
206+ principal, err = h.authenticate(r, addr)
207+ if err != nil {
208+ if errors.Is(err, domain.ErrUnauthenticated) {
209+ challenge(w)
210+ return
211+ }
212+ log.Error().Err(err).Msg("authenticate git request")
213+ http.Error(w, "internal error", http.StatusInternalServerError)
214+ return
215+ }
216+
217+ if rt.push() {
218+ // Only the first request of a push may create the repository.
219+ _, _, err = h.cfg.Pushes.Open(ctx, principal, name, rt.suffix == "info/refs")
220+ } else {
221+ _, err = h.cfg.Repos.Get(ctx, principal, name)
222+ }
223+ if err != nil {
224+ h.denied(r.Context(), w, principal, err)
225+ return
226+ }
227+
228+ h.serveBackend(w, r, rt, name, principal)
229+}
230+
231+// authenticate reads the credentials of the request. No credentials is the
232+// anonymous principal; credentials that are not valid count as a failure of
233+// the client address and give domain.ErrUnauthenticated.
234+func (h *Handler) authenticate(r *http.Request, addr string) (domain.Principal, error) {
235+ secret, present := credentials(r)
236+ if !present {
237+ return domain.Anonymous(), nil
238+ }
239+ p, err := h.cfg.Auth.Authenticate(r.Context(), secret)
240+ if err != nil {
241+ if errors.Is(err, domain.ErrUnauthenticated) {
242+ h.limiter.Fail(addr)
243+ log := zerowrap.FromCtx(r.Context())
244+ log.Debug().Str("client", addr).Msg("invalid credentials")
245+ }
246+ return domain.Anonymous(), err
247+ }
248+ return p, nil
249+}
250+
251+// credentials extracts the token from an Authorization header: the password of
252+// Basic (the username is ignored) or a Bearer token. present is true when the
253+// header exists, even if it carries nothing usable, which then fails
254+// authentication.
255+func credentials(r *http.Request) (secret string, present bool) {
256+ auth := r.Header.Get("Authorization")
257+ if auth == "" {
258+ return "", false
259+ }
260+ if _, pass, ok := r.BasicAuth(); ok {
261+ return pass, true
262+ }
263+ if scheme, tok, ok := strings.Cut(auth, " "); ok && strings.EqualFold(scheme, "Bearer") {
264+ return strings.TrimSpace(tok), true
265+ }
266+ return "", true
267+}
268+
269+// denied answers an authorization failure. A caller without valid
270+// credentials is asked for them wherever the repository does not show itself,
271+// which keeps a private repository and a missing one indistinguishable.
272+func (h *Handler) denied(ctx context.Context, w http.ResponseWriter, p domain.Principal, err error) {
273+ switch {
274+ case errors.Is(err, domain.ErrUnauthenticated):
275+ challenge(w)
276+ case errors.Is(err, domain.ErrNotFound):
277+ if p.IsAnonymous() {
278+ challenge(w)
279+ return
280+ }
281+ notFound(w)
282+ case errors.Is(err, domain.ErrForbidden):
283+ http.Error(w, "forbidden", http.StatusForbidden)
284+ default:
285+ log := zerowrap.FromCtx(ctx)
286+ log.Error().Err(err).Msg("authorize git request")
287+ http.Error(w, "internal error", http.StatusInternalServerError)
288+ }
289+}
290+
291+func challenge(w http.ResponseWriter) {
292+ w.Header().Set("WWW-Authenticate", `Basic realm="ermite"`)
293+ http.Error(w, "authentication required", http.StatusUnauthorized)
294+}
295+
296+func notFound(w http.ResponseWriter) { http.Error(w, "not found", http.StatusNotFound) }
297+
298+// recorder remembers the status code of a response and keeps Flush working.
299+type recorder struct {
300+ http.ResponseWriter
301+ status int
302+}
303+
304+func (r *recorder) WriteHeader(code int) {
305+ if r.status == 0 {
306+ r.status = code
307+ }
308+ r.ResponseWriter.WriteHeader(code)
309+}
310+
311+func (r *recorder) Write(b []byte) (int, error) {
312+ if r.status == 0 {
313+ r.status = http.StatusOK
314+ }
315+ return r.ResponseWriter.Write(b)
316+}
317+
318+func (r *recorder) code() int {
319+ if r.status == 0 {
320+ return http.StatusOK
321+ }
322+ return r.status
323+}
324+
325+// Unwrap lets http.ResponseController reach Flush.
326+func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
327+
328+// sensitiveHeaders never reach the Git process: CGI would put them in its
329+// environment, which the hooks inherit.
330+var sensitiveHeaders = []string{"Authorization", "Proxy-Authorization", "Cookie"}
331+
332+// serveBackend runs git http-backend for an authorized request.
333+func (h *Handler) serveBackend(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName, p domain.Principal) {
334+ ctx := r.Context()
335+ logger := zerowrap.FromCtx(ctx)
336+
337+ be, err := h.cfg.Backend.HTTPBackend(ctx, rt.push())
338+ if err != nil {
339+ logger.Error().Err(err).Msg("prepare git http-backend")
340+ http.Error(w, "internal error", http.StatusInternalServerError)
341+ return
342+ }
343+ env := be.Env
344+ if rt.suffix == serviceReceive && h.cfg.Hooks != nil {
345+ token, release := h.cfg.Hooks.Register(name, p)
346+ defer release()
347+ env = append(env, EnvHookSocket+"="+h.cfg.Hooks.Socket(), EnvHookToken+"="+token)
348+ }
349+
350+ req, cleanup, status := h.backendRequest(w, r, rt, name)
351+ defer cleanup()
352+ if status != 0 {
353+ http.Error(w, http.StatusText(status), status)
354+ return
355+ }
356+
357+ (&cgi.Handler{
358+ Path: be.Path,
359+ Dir: be.Dir,
360+ Env: env,
361+ Stderr: logWriter{ctx: ctx, repo: name.String()},
362+ Logger: log.New(logWriter{ctx: ctx, repo: name.String(), warn: true}, "", 0),
363+ }).ServeHTTP(w, req)
364+}
365+
366+// backendRequest builds the request handed to CGI: the path names the
367+// directory of the repository, credentials are removed, and a body of unknown
368+// length (chunked, which CGI refuses) is spooled to a temporary file so that
369+// its length is known. status is non-zero when the request must be refused;
370+// cleanup is always safe to call.
371+func (h *Handler) backendRequest(w http.ResponseWriter, r *http.Request, rt route, name domain.RepoName) (req *http.Request, cleanup func(), status int) {
372+ cleanup = func() {}
373+ req = r.Clone(r.Context())
374+ req.URL.Path = "/" + name.String() + ".git/" + rt.suffix
375+ req.URL.RawPath = ""
376+ for _, k := range sensitiveHeaders {
377+ req.Header.Del(k)
378+ }
379+ if r.ContentLength > h.maxBody {
380+ return nil, cleanup, http.StatusRequestEntityTooLarge
381+ }
382+ if r.ContentLength >= 0 && len(r.TransferEncoding) == 0 {
383+ req.Body = http.MaxBytesReader(w, r.Body, h.maxBody)
384+ return req, cleanup, 0
385+ }
386+
387+ f, err := os.CreateTemp(h.cfg.TempDir, "body-*")
388+ if err != nil {
389+ logger := zerowrap.FromCtx(r.Context())
390+ logger.Error().Err(err).Msg("spool request body")
391+ return nil, cleanup, http.StatusInternalServerError
392+ }
393+ cleanup = func() {
394+ _ = f.Close()
395+ _ = os.Remove(f.Name())
396+ }
397+ n, err := io.Copy(f, http.MaxBytesReader(w, r.Body, h.maxBody))
398+ if err != nil {
399+ var tooBig *http.MaxBytesError
400+ if errors.As(err, &tooBig) {
401+ return nil, cleanup, http.StatusRequestEntityTooLarge
402+ }
403+ return nil, cleanup, http.StatusBadRequest
404+ }
405+ if _, err := f.Seek(0, io.SeekStart); err != nil {
406+ logger := zerowrap.FromCtx(r.Context())
407+ logger.Error().Err(err).Msg("spool request body")
408+ return nil, cleanup, http.StatusInternalServerError
409+ }
410+ req.Body = f
411+ req.ContentLength = n
412+ req.TransferEncoding = nil
413+ return req, cleanup, 0
414+}
415+
416+// logWriter sends what the Git process (or the CGI layer) writes to its
417+// standard error to the logger of the request.
418+type logWriter struct {
419+ ctx context.Context
420+ repo string
421+ warn bool
422+}
423+
424+func (l logWriter) Write(b []byte) (int, error) {
425+ msg := strings.TrimSpace(string(b))
426+ if msg == "" {
427+ return len(b), nil
428+ }
429+ logger := zerowrap.FromCtx(l.ctx)
430+ if l.warn {
431+ logger.Warn().Str("repo", l.repo).Msg(msg)
432+ } else {
433+ logger.Debug().Str("repo", l.repo).Str("source", "git").Msg(msg)
434+ }
435+ return len(b), nil
436+}
A internal/adapters/githttp/handler_test.go +391 -0
@@ -0,0 +1,391 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp_test
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/netip"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "testing"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "bnema.dev/ermite/internal/ports/mocks"
26+)
27+
28+type fakeAuth struct {
29+ mu sync.Mutex
30+ seen []string
31+}
32+
33+func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) {
34+ f.mu.Lock()
35+ f.seen = append(f.seen, secret)
36+ f.mu.Unlock()
37+ switch secret {
38+ case "root-secret":
39+ return domain.Root(), nil
40+ case "reader-secret":
41+ return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
42+ }
43+ return domain.Principal{}, domain.ErrUnauthenticated
44+}
45+
46+type fakeRepos struct{}
47+
48+func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
49+ switch n.String() {
50+ case "pub":
51+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
52+ case "priv":
53+ if p.Allows(domain.ScopeRepoRead, n) {
54+ return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
55+ }
56+ }
57+ return domain.Repo{}, domain.ErrNotFound
58+}
59+
60+type fakePushes struct {
61+ mu sync.Mutex
62+ calls []bool // the create flag of each Open
63+ err error
64+}
65+
66+func (f *fakePushes) Open(_ context.Context, p domain.Principal, n domain.RepoName, create bool) (domain.Repo, bool, error) {
67+ f.mu.Lock()
68+ defer f.mu.Unlock()
69+ f.calls = append(f.calls, create)
70+ if p.IsAnonymous() {
71+ return domain.Repo{}, false, domain.ErrUnauthenticated
72+ }
73+ return domain.Repo{Name: n}, false, f.err
74+}
75+
76+type fakeTokens struct {
77+ mu sync.Mutex
78+ released int
79+ repo domain.RepoName
80+}
81+
82+func (f *fakeTokens) Register(r domain.RepoName, _ domain.Principal) (string, func()) {
83+ f.mu.Lock()
84+ defer f.mu.Unlock()
85+ f.repo = r
86+ return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() }
87+}
88+func (f *fakeTokens) Socket() string { return "/run/hook.sock" }
89+
90+// cgiScript is a stand-in for git-http-backend: it answers with the
91+// environment and the body it received.
92+func cgiScript(t *testing.T) string {
93+ t.Helper()
94+ p := filepath.Join(t.TempDir(), "backend")
95+ body := "#!/bin/sh\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat\n"
96+ require.NoError(t, os.WriteFile(p, []byte(body), 0o755))
97+ return p
98+}
99+
100+type rig struct {
101+ h *githttp.Handler
102+ srv *httptest.Server
103+ pushes *fakePushes
104+ backend *mocks.GitBackend
105+ hooks *fakeTokens
106+ auth *fakeAuth
107+ tmp string
108+
109+ pushFlags []bool
110+ backendErr error
111+}
112+
113+func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
114+ t.Helper()
115+ r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}, tmp: t.TempDir()}
116+ cfg := githttp.Config{
117+ Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
118+ TempDir: r.tmp,
119+ }
120+ script := cgiScript(t)
121+ r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
122+ func(_ context.Context, push bool) (ports.HTTPBackend, error) {
123+ r.pushFlags = append(r.pushFlags, push)
124+ if r.backendErr != nil {
125+ return ports.HTTPBackend{}, r.backendErr
126+ }
127+ return ports.HTTPBackend{Path: script, Dir: filepath.Dir(script), Env: []string{"PATH=/usr/bin:/bin", "GIT_PROJECT_ROOT=/x"}}, nil
128+ }).Maybe()
129+ if mut != nil {
130+ mut(&cfg)
131+ }
132+ r.h = githttp.New(cfg)
133+ mux := http.NewServeMux()
134+ for _, p := range githttp.Patterns {
135+ mux.Handle(p, r.h)
136+ }
137+ r.srv = httptest.NewServer(mux)
138+ t.Cleanup(r.srv.Close)
139+ return r
140+}
141+
142+func (r *rig) do(t *testing.T, method, path, user, pass string, body io.Reader, hdr map[string]string) (int, string, http.Header) {
143+ t.Helper()
144+ req, err := http.NewRequest(method, r.srv.URL+path, body)
145+ require.NoError(t, err)
146+ if pass != "" {
147+ req.SetBasicAuth(user, pass)
148+ }
149+ for k, v := range hdr {
150+ req.Header.Set(k, v)
151+ }
152+ resp, err := http.DefaultClient.Do(req)
153+ require.NoError(t, err)
154+ defer func() { _ = resp.Body.Close() }()
155+ b, err := io.ReadAll(resp.Body)
156+ require.NoError(t, err)
157+ return resp.StatusCode, string(b), resp.Header
158+}
159+
160+func envOf(body string) map[string]string {
161+ head, _, _ := strings.Cut(body, "\n--body--\n")
162+ m := map[string]string{}
163+ for _, l := range strings.Split(head, "\n") {
164+ k, v, ok := strings.Cut(l, "=")
165+ if ok {
166+ m[k] = v
167+ }
168+ }
169+ return m
170+}
171+
172+func TestHandlerRoutesAndStatuses(t *testing.T) {
173+ r := newRig(t, nil)
174+ tests := []struct {
175+ name string
176+ method string
177+ path string
178+ pass string
179+ want int
180+ auth bool // expects a WWW-Authenticate challenge
181+ }{
182+ {"public, anonymous", "GET", "/pub.git/info/refs?service=git-upload-pack", "", 200, false},
183+ {"public without .git", "GET", "/pub/info/refs?service=git-upload-pack", "", 200, false},
184+ {"private, anonymous", "GET", "/priv.git/info/refs?service=git-upload-pack", "", 401, true},
185+ {"missing, anonymous", "GET", "/none.git/info/refs?service=git-upload-pack", "", 401, true},
186+ {"private, reader", "GET", "/priv.git/info/refs?service=git-upload-pack", "reader-secret", 200, false},
187+ {"private, wrong token", "GET", "/priv.git/info/refs?service=git-upload-pack", "bad", 401, true},
188+ {"missing, valid token", "GET", "/none.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
189+ {"invalid name", "GET", "/Bad.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
190+ {"name ending in .git.git", "GET", "/x.git.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
191+ {"no service", "GET", "/pub.git/info/refs", "", 404, false},
192+ {"unknown service", "GET", "/pub.git/info/refs?service=git-evil", "", 404, false},
193+ {"HEAD file", "GET", "/pub.git/HEAD", "", 404, false},
194+ {"objects", "GET", "/pub.git/objects/info/packs", "", 404, false},
195+ {"upload-pack wrong method", "GET", "/pub.git/git-upload-pack", "", 405, false},
196+ {"info/refs wrong method", "POST", "/pub.git/info/refs?service=git-upload-pack", "", 405, false},
197+ {"receive-pack, anonymous", "GET", "/pub.git/info/refs?service=git-receive-pack", "", 401, true},
198+ {"receive-pack, root", "GET", "/pub.git/info/refs?service=git-receive-pack", "root-secret", 200, false},
199+ }
200+ for _, tt := range tests {
201+ t.Run(tt.name, func(t *testing.T) {
202+ user := ""
203+ if tt.pass != "" {
204+ user = "whatever" // the username is ignored
205+ }
206+ code, _, hdr := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil)
207+ assert.Equal(t, tt.want, code)
208+ if tt.auth {
209+ assert.Equal(t, `Basic realm="ermite"`, hdr.Get("WWW-Authenticate"))
210+ } else {
211+ assert.Empty(t, hdr.Get("WWW-Authenticate"))
212+ }
213+ })
214+ }
215+}
216+
217+func TestHandlerPushAuthorizationAnswers(t *testing.T) {
218+ r := newRig(t, nil)
219+ get := func() int {
220+ code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
221+ return code
222+ }
223+ assert.Equal(t, 200, get())
224+ r.pushes.err = domain.ErrForbidden
225+ assert.Equal(t, 403, get())
226+ r.pushes.err = domain.ErrNotFound
227+ assert.Equal(t, 404, get())
228+ r.pushes.err = domain.ErrUnauthenticated
229+ assert.Equal(t, 401, get())
230+ r.pushes.err = errBoom{}
231+ assert.Equal(t, 500, get())
232+
233+ // Only the first request of a push may create.
234+ r.pushes.err = nil
235+ r.pushes.calls = nil
236+ r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
237+ r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"})
238+ assert.Equal(t, []bool{true, false}, r.pushes.calls)
239+ // Reads never reach the push use case.
240+ r.pushes.calls = nil
241+ r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
242+ assert.Empty(t, r.pushes.calls)
243+}
244+
245+type errBoom struct{}
246+
247+func (errBoom) Error() string { return "boom with /internal/detail" }
248+
249+func TestHandlerBackendEnvironment(t *testing.T) {
250+ r := newRig(t, nil)
251+ hdr := map[string]string{
252+ "Cookie": "session=secret",
253+ "Proxy-Authorization": "Basic proxy-should-not-leak",
254+ "Git-Protocol": "version=2",
255+ "Content-Type": "application/x-git-receive-pack-request",
256+ "X-Forwarded-For": "1.2.3.4",
257+ }
258+
259+ // A read: no hook environment.
260+ code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, hdr)
261+ require.Equal(t, 200, code)
262+ e := envOf(body)
263+ assert.Equal(t, "/pub.git/info/refs", e["PATH_INFO"])
264+ assert.Equal(t, "service=git-upload-pack", e["QUERY_STRING"])
265+ assert.Equal(t, "version=2", e["HTTP_GIT_PROTOCOL"])
266+ assert.Equal(t, "/x", e["GIT_PROJECT_ROOT"], "the backend environment is used")
267+ assert.NotContains(t, e, "ERMITE_HOOK_TOKEN")
268+ assert.NotContains(t, body, "should-not-leak")
269+ assert.NotContains(t, body, "root-secret")
270+ assert.NotContains(t, body, base64.StdEncoding.EncodeToString([]byte("u:root-secret")), "the Authorization header is not passed on")
271+ assert.NotContains(t, body, "session=secret")
272+ assert.NotContains(t, e, "HTTP_AUTHORIZATION")
273+ assert.NotContains(t, e, "HTTP_PROXY_AUTHORIZATION")
274+ assert.NotContains(t, e, "HTTP_COOKIE")
275+ assert.Equal(t, []bool{false}, r.pushFlags)
276+
277+ // A receive-pack POST gets the hook environment and releases the token.
278+ code, _, _ = r.do(t, "POST", "/Pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
279+ assert.Equal(t, 404, code, "invalid name")
280+ code, body, _ = r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
281+ require.Equal(t, 200, code)
282+ e = envOf(body)
283+ assert.Equal(t, "hook-token", e["ERMITE_HOOK_TOKEN"])
284+ assert.Equal(t, "/run/hook.sock", e["ERMITE_HOOK_SOCKET"])
285+ assert.Equal(t, "/pub.git/git-receive-pack", e["PATH_INFO"])
286+ assert.Equal(t, "8", e["CONTENT_LENGTH"])
287+ assert.True(t, strings.HasSuffix(body, "\n--body--\nPACKDATA"), "the body reaches the backend")
288+ assert.NotContains(t, body, "root-secret")
289+ assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends")
290+ assert.Equal(t, "pub", r.hooks.repo.String())
291+
292+ // The info/refs advertisement of a push does not register a hook token.
293+ r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
294+ assert.Equal(t, 1, r.hooks.released)
295+}
296+
297+func TestHandlerNormalizesRepositoryPath(t *testing.T) {
298+ r := newRig(t, nil)
299+ _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
300+ assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git")
301+}
302+
303+func TestHandlerChunkedBodyIsSpooled(t *testing.T) {
304+ r := newRig(t, nil)
305+ payload := strings.Repeat("x", 300_000)
306+ // An io.Reader of unknown length makes the client send chunked.
307+ body := io.MultiReader(strings.NewReader(payload))
308+ code, out, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", body, map[string]string{"Content-Type": "application/x-git-upload-pack-request"})
309+ require.Equal(t, 200, code)
310+ e := envOf(out)
311+ assert.Equal(t, "300000", e["CONTENT_LENGTH"], "the length is known to the backend")
312+ _, got, _ := strings.Cut(out, "\n--body--\n")
313+ assert.Equal(t, payload, got)
314+ entries, err := os.ReadDir(r.tmp)
315+ require.NoError(t, err)
316+ assert.Empty(t, entries, "spool files are removed")
317+}
318+
319+func TestHandlerBodyLimit(t *testing.T) {
320+ r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 })
321+ big := strings.Repeat("x", 5000)
322+ code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", strings.NewReader(big), nil)
323+ assert.Equal(t, http.StatusRequestEntityTooLarge, code, "declared length over the limit")
324+ code, _, _ = r.do(t, "POST", "/pub.git/git-upload-pack", "", "", io.MultiReader(strings.NewReader(big)), nil)
325+ assert.Equal(t, http.StatusRequestEntityTooLarge, code, "chunked body over the limit")
326+ code, _, _ = r.do(t, "POST", "/pub.git/git-upload-pack", "", "", strings.NewReader("ok"), nil)
327+ assert.Equal(t, 200, code)
328+ entries, err := os.ReadDir(r.tmp)
329+ require.NoError(t, err)
330+ assert.Empty(t, entries)
331+}
332+
333+func TestHandlerBackendFailure(t *testing.T) {
334+ r := newRig(t, nil)
335+ r.backendErr = io.ErrUnexpectedEOF
336+ code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
337+ assert.Equal(t, 500, code)
338+ assert.NotContains(t, body, "EOF", "no internal detail in the response")
339+}
340+
341+func TestHandlerLimiter(t *testing.T) {
342+ r := newRig(t, func(c *githttp.Config) { c.Limiter = githttp.NewLimiter(nil) })
343+ for i := range githttp.MaxFailures {
344+ code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil)
345+ require.Equal(t, 401, code, i)
346+ }
347+ code, _, hdr := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
348+ assert.Equal(t, 429, code)
349+ assert.NotEmpty(t, hdr.Get("Retry-After"))
350+ // A blocked request does not even reach the authenticator.
351+ r.auth.mu.Lock()
352+ n := len(r.auth.seen)
353+ r.auth.mu.Unlock()
354+ r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
355+ r.auth.mu.Lock()
356+ assert.Equal(t, n, len(r.auth.seen))
357+ r.auth.mu.Unlock()
358+}
359+
360+func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
361+ r := newRig(t, func(c *githttp.Config) {
362+ c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
363+ })
364+ for range githttp.MaxFailures {
365+ r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
366+ }
367+ code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
368+ assert.Equal(t, 429, code)
369+ code, _, _ = r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.2"})
370+ assert.Equal(t, 200, code)
371+}
372+
373+func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
374+ r := newRig(t, nil)
375+ path := "/priv.git/info/refs?service=git-upload-pack"
376+ code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"})
377+ assert.Equal(t, 200, code)
378+ code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
379+ assert.Equal(t, 200, code, "the scheme is case-insensitive")
380+ for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
381+ code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
382+ assert.Equal(t, 401, code, h)
383+ }
384+ // An empty password is a failed attempt, not anonymous access.
385+ req, _ := http.NewRequest("GET", r.srv.URL+"/pub.git/info/refs?service=git-upload-pack", nil)
386+ req.SetBasicAuth("u", "")
387+ resp, err := http.DefaultClient.Do(req)
388+ require.NoError(t, err)
389+ _ = resp.Body.Close()
390+ assert.Equal(t, 401, resp.StatusCode)
391+}
@@ -0,0 +1,391 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp_test
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "io"
9+ "net/http"
10+ "net/http/httptest"
11+ "net/netip"
12+ "os"
13+ "path/filepath"
14+ "strings"
15+ "sync"
16+ "testing"
17+
18+ "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
20+ "github.com/stretchr/testify/require"
21+
22+ "bnema.dev/ermite/internal/adapters/githttp"
23+ "bnema.dev/ermite/internal/domain"
24+ "bnema.dev/ermite/internal/ports"
25+ "bnema.dev/ermite/internal/ports/mocks"
26+)
27+
28+type fakeAuth struct {
29+ mu sync.Mutex
30+ seen []string
31+}
32+
33+func (f *fakeAuth) Authenticate(_ context.Context, secret string) (domain.Principal, error) {
34+ f.mu.Lock()
35+ f.seen = append(f.seen, secret)
36+ f.mu.Unlock()
37+ switch secret {
38+ case "root-secret":
39+ return domain.Root(), nil
40+ case "reader-secret":
41+ return domain.TokenPrincipal(domain.Token{ID: "rd", Scopes: []domain.Scope{domain.ScopeRepoRead}}), nil
42+ }
43+ return domain.Principal{}, domain.ErrUnauthenticated
44+}
45+
46+type fakeRepos struct{}
47+
48+func (fakeRepos) Get(_ context.Context, p domain.Principal, n domain.RepoName) (domain.Repo, error) {
49+ switch n.String() {
50+ case "pub":
51+ return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
52+ case "priv":
53+ if p.Allows(domain.ScopeRepoRead, n) {
54+ return domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}, nil
55+ }
56+ }
57+ return domain.Repo{}, domain.ErrNotFound
58+}
59+
60+type fakePushes struct {
61+ mu sync.Mutex
62+ calls []bool // the create flag of each Open
63+ err error
64+}
65+
66+func (f *fakePushes) Open(_ context.Context, p domain.Principal, n domain.RepoName, create bool) (domain.Repo, bool, error) {
67+ f.mu.Lock()
68+ defer f.mu.Unlock()
69+ f.calls = append(f.calls, create)
70+ if p.IsAnonymous() {
71+ return domain.Repo{}, false, domain.ErrUnauthenticated
72+ }
73+ return domain.Repo{Name: n}, false, f.err
74+}
75+
76+type fakeTokens struct {
77+ mu sync.Mutex
78+ released int
79+ repo domain.RepoName
80+}
81+
82+func (f *fakeTokens) Register(r domain.RepoName, _ domain.Principal) (string, func()) {
83+ f.mu.Lock()
84+ defer f.mu.Unlock()
85+ f.repo = r
86+ return "hook-token", func() { f.mu.Lock(); f.released++; f.mu.Unlock() }
87+}
88+func (f *fakeTokens) Socket() string { return "/run/hook.sock" }
89+
90+// cgiScript is a stand-in for git-http-backend: it answers with the
91+// environment and the body it received.
92+func cgiScript(t *testing.T) string {
93+ t.Helper()
94+ p := filepath.Join(t.TempDir(), "backend")
95+ body := "#!/bin/sh\nprintf 'Content-Type: text/plain\\r\\n\\r\\n'\nenv | sort\nprintf '\\n--body--\\n'\ncat\n"
96+ require.NoError(t, os.WriteFile(p, []byte(body), 0o755))
97+ return p
98+}
99+
100+type rig struct {
101+ h *githttp.Handler
102+ srv *httptest.Server
103+ pushes *fakePushes
104+ backend *mocks.GitBackend
105+ hooks *fakeTokens
106+ auth *fakeAuth
107+ tmp string
108+
109+ pushFlags []bool
110+ backendErr error
111+}
112+
113+func newRig(t *testing.T, mut func(*githttp.Config)) *rig {
114+ t.Helper()
115+ r := &rig{pushes: &fakePushes{}, backend: mocks.NewGitBackend(t), hooks: &fakeTokens{}, auth: &fakeAuth{}, tmp: t.TempDir()}
116+ cfg := githttp.Config{
117+ Auth: r.auth, Repos: fakeRepos{}, Pushes: r.pushes, Backend: r.backend, Hooks: r.hooks,
118+ TempDir: r.tmp,
119+ }
120+ script := cgiScript(t)
121+ r.backend.EXPECT().HTTPBackend(mock.Anything, mock.Anything).RunAndReturn(
122+ func(_ context.Context, push bool) (ports.HTTPBackend, error) {
123+ r.pushFlags = append(r.pushFlags, push)
124+ if r.backendErr != nil {
125+ return ports.HTTPBackend{}, r.backendErr
126+ }
127+ return ports.HTTPBackend{Path: script, Dir: filepath.Dir(script), Env: []string{"PATH=/usr/bin:/bin", "GIT_PROJECT_ROOT=/x"}}, nil
128+ }).Maybe()
129+ if mut != nil {
130+ mut(&cfg)
131+ }
132+ r.h = githttp.New(cfg)
133+ mux := http.NewServeMux()
134+ for _, p := range githttp.Patterns {
135+ mux.Handle(p, r.h)
136+ }
137+ r.srv = httptest.NewServer(mux)
138+ t.Cleanup(r.srv.Close)
139+ return r
140+}
141+
142+func (r *rig) do(t *testing.T, method, path, user, pass string, body io.Reader, hdr map[string]string) (int, string, http.Header) {
143+ t.Helper()
144+ req, err := http.NewRequest(method, r.srv.URL+path, body)
145+ require.NoError(t, err)
146+ if pass != "" {
147+ req.SetBasicAuth(user, pass)
148+ }
149+ for k, v := range hdr {
150+ req.Header.Set(k, v)
151+ }
152+ resp, err := http.DefaultClient.Do(req)
153+ require.NoError(t, err)
154+ defer func() { _ = resp.Body.Close() }()
155+ b, err := io.ReadAll(resp.Body)
156+ require.NoError(t, err)
157+ return resp.StatusCode, string(b), resp.Header
158+}
159+
160+func envOf(body string) map[string]string {
161+ head, _, _ := strings.Cut(body, "\n--body--\n")
162+ m := map[string]string{}
163+ for _, l := range strings.Split(head, "\n") {
164+ k, v, ok := strings.Cut(l, "=")
165+ if ok {
166+ m[k] = v
167+ }
168+ }
169+ return m
170+}
171+
172+func TestHandlerRoutesAndStatuses(t *testing.T) {
173+ r := newRig(t, nil)
174+ tests := []struct {
175+ name string
176+ method string
177+ path string
178+ pass string
179+ want int
180+ auth bool // expects a WWW-Authenticate challenge
181+ }{
182+ {"public, anonymous", "GET", "/pub.git/info/refs?service=git-upload-pack", "", 200, false},
183+ {"public without .git", "GET", "/pub/info/refs?service=git-upload-pack", "", 200, false},
184+ {"private, anonymous", "GET", "/priv.git/info/refs?service=git-upload-pack", "", 401, true},
185+ {"missing, anonymous", "GET", "/none.git/info/refs?service=git-upload-pack", "", 401, true},
186+ {"private, reader", "GET", "/priv.git/info/refs?service=git-upload-pack", "reader-secret", 200, false},
187+ {"private, wrong token", "GET", "/priv.git/info/refs?service=git-upload-pack", "bad", 401, true},
188+ {"missing, valid token", "GET", "/none.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
189+ {"invalid name", "GET", "/Bad.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
190+ {"name ending in .git.git", "GET", "/x.git.git/info/refs?service=git-upload-pack", "root-secret", 404, false},
191+ {"no service", "GET", "/pub.git/info/refs", "", 404, false},
192+ {"unknown service", "GET", "/pub.git/info/refs?service=git-evil", "", 404, false},
193+ {"HEAD file", "GET", "/pub.git/HEAD", "", 404, false},
194+ {"objects", "GET", "/pub.git/objects/info/packs", "", 404, false},
195+ {"upload-pack wrong method", "GET", "/pub.git/git-upload-pack", "", 405, false},
196+ {"info/refs wrong method", "POST", "/pub.git/info/refs?service=git-upload-pack", "", 405, false},
197+ {"receive-pack, anonymous", "GET", "/pub.git/info/refs?service=git-receive-pack", "", 401, true},
198+ {"receive-pack, root", "GET", "/pub.git/info/refs?service=git-receive-pack", "root-secret", 200, false},
199+ }
200+ for _, tt := range tests {
201+ t.Run(tt.name, func(t *testing.T) {
202+ user := ""
203+ if tt.pass != "" {
204+ user = "whatever" // the username is ignored
205+ }
206+ code, _, hdr := r.do(t, tt.method, tt.path, user, tt.pass, nil, nil)
207+ assert.Equal(t, tt.want, code)
208+ if tt.auth {
209+ assert.Equal(t, `Basic realm="ermite"`, hdr.Get("WWW-Authenticate"))
210+ } else {
211+ assert.Empty(t, hdr.Get("WWW-Authenticate"))
212+ }
213+ })
214+ }
215+}
216+
217+func TestHandlerPushAuthorizationAnswers(t *testing.T) {
218+ r := newRig(t, nil)
219+ get := func() int {
220+ code, _, _ := r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
221+ return code
222+ }
223+ assert.Equal(t, 200, get())
224+ r.pushes.err = domain.ErrForbidden
225+ assert.Equal(t, 403, get())
226+ r.pushes.err = domain.ErrNotFound
227+ assert.Equal(t, 404, get())
228+ r.pushes.err = domain.ErrUnauthenticated
229+ assert.Equal(t, 401, get())
230+ r.pushes.err = errBoom{}
231+ assert.Equal(t, 500, get())
232+
233+ // Only the first request of a push may create.
234+ r.pushes.err = nil
235+ r.pushes.calls = nil
236+ r.do(t, "GET", "/p.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
237+ r.do(t, "POST", "/p.git/git-receive-pack", "u", "root-secret", strings.NewReader("0000"), map[string]string{"Content-Type": "application/x-git-receive-pack-request"})
238+ assert.Equal(t, []bool{true, false}, r.pushes.calls)
239+ // Reads never reach the push use case.
240+ r.pushes.calls = nil
241+ r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
242+ assert.Empty(t, r.pushes.calls)
243+}
244+
245+type errBoom struct{}
246+
247+func (errBoom) Error() string { return "boom with /internal/detail" }
248+
249+func TestHandlerBackendEnvironment(t *testing.T) {
250+ r := newRig(t, nil)
251+ hdr := map[string]string{
252+ "Cookie": "session=secret",
253+ "Proxy-Authorization": "Basic proxy-should-not-leak",
254+ "Git-Protocol": "version=2",
255+ "Content-Type": "application/x-git-receive-pack-request",
256+ "X-Forwarded-For": "1.2.3.4",
257+ }
258+
259+ // A read: no hook environment.
260+ code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, hdr)
261+ require.Equal(t, 200, code)
262+ e := envOf(body)
263+ assert.Equal(t, "/pub.git/info/refs", e["PATH_INFO"])
264+ assert.Equal(t, "service=git-upload-pack", e["QUERY_STRING"])
265+ assert.Equal(t, "version=2", e["HTTP_GIT_PROTOCOL"])
266+ assert.Equal(t, "/x", e["GIT_PROJECT_ROOT"], "the backend environment is used")
267+ assert.NotContains(t, e, "ERMITE_HOOK_TOKEN")
268+ assert.NotContains(t, body, "should-not-leak")
269+ assert.NotContains(t, body, "root-secret")
270+ assert.NotContains(t, body, base64.StdEncoding.EncodeToString([]byte("u:root-secret")), "the Authorization header is not passed on")
271+ assert.NotContains(t, body, "session=secret")
272+ assert.NotContains(t, e, "HTTP_AUTHORIZATION")
273+ assert.NotContains(t, e, "HTTP_PROXY_AUTHORIZATION")
274+ assert.NotContains(t, e, "HTTP_COOKIE")
275+ assert.Equal(t, []bool{false}, r.pushFlags)
276+
277+ // A receive-pack POST gets the hook environment and releases the token.
278+ code, _, _ = r.do(t, "POST", "/Pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
279+ assert.Equal(t, 404, code, "invalid name")
280+ code, body, _ = r.do(t, "POST", "/pub.git/git-receive-pack", "u", "root-secret", strings.NewReader("PACKDATA"), hdr)
281+ require.Equal(t, 200, code)
282+ e = envOf(body)
283+ assert.Equal(t, "hook-token", e["ERMITE_HOOK_TOKEN"])
284+ assert.Equal(t, "/run/hook.sock", e["ERMITE_HOOK_SOCKET"])
285+ assert.Equal(t, "/pub.git/git-receive-pack", e["PATH_INFO"])
286+ assert.Equal(t, "8", e["CONTENT_LENGTH"])
287+ assert.True(t, strings.HasSuffix(body, "\n--body--\nPACKDATA"), "the body reaches the backend")
288+ assert.NotContains(t, body, "root-secret")
289+ assert.Equal(t, 1, r.hooks.released, "the hook token is revoked when the request ends")
290+ assert.Equal(t, "pub", r.hooks.repo.String())
291+
292+ // The info/refs advertisement of a push does not register a hook token.
293+ r.do(t, "GET", "/pub.git/info/refs?service=git-receive-pack", "u", "root-secret", nil, nil)
294+ assert.Equal(t, 1, r.hooks.released)
295+}
296+
297+func TestHandlerNormalizesRepositoryPath(t *testing.T) {
298+ r := newRig(t, nil)
299+ _, body, _ := r.do(t, "GET", "/pub/info/refs?service=git-upload-pack", "", "", nil, nil)
300+ assert.Equal(t, "/pub.git/info/refs", envOf(body)["PATH_INFO"], "the backend always sees <name>.git")
301+}
302+
303+func TestHandlerChunkedBodyIsSpooled(t *testing.T) {
304+ r := newRig(t, nil)
305+ payload := strings.Repeat("x", 300_000)
306+ // An io.Reader of unknown length makes the client send chunked.
307+ body := io.MultiReader(strings.NewReader(payload))
308+ code, out, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", body, map[string]string{"Content-Type": "application/x-git-upload-pack-request"})
309+ require.Equal(t, 200, code)
310+ e := envOf(out)
311+ assert.Equal(t, "300000", e["CONTENT_LENGTH"], "the length is known to the backend")
312+ _, got, _ := strings.Cut(out, "\n--body--\n")
313+ assert.Equal(t, payload, got)
314+ entries, err := os.ReadDir(r.tmp)
315+ require.NoError(t, err)
316+ assert.Empty(t, entries, "spool files are removed")
317+}
318+
319+func TestHandlerBodyLimit(t *testing.T) {
320+ r := newRig(t, func(c *githttp.Config) { c.MaxBodyBytes = 1000 })
321+ big := strings.Repeat("x", 5000)
322+ code, _, _ := r.do(t, "POST", "/pub.git/git-upload-pack", "", "", strings.NewReader(big), nil)
323+ assert.Equal(t, http.StatusRequestEntityTooLarge, code, "declared length over the limit")
324+ code, _, _ = r.do(t, "POST", "/pub.git/git-upload-pack", "", "", io.MultiReader(strings.NewReader(big)), nil)
325+ assert.Equal(t, http.StatusRequestEntityTooLarge, code, "chunked body over the limit")
326+ code, _, _ = r.do(t, "POST", "/pub.git/git-upload-pack", "", "", strings.NewReader("ok"), nil)
327+ assert.Equal(t, 200, code)
328+ entries, err := os.ReadDir(r.tmp)
329+ require.NoError(t, err)
330+ assert.Empty(t, entries)
331+}
332+
333+func TestHandlerBackendFailure(t *testing.T) {
334+ r := newRig(t, nil)
335+ r.backendErr = io.ErrUnexpectedEOF
336+ code, body, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, nil)
337+ assert.Equal(t, 500, code)
338+ assert.NotContains(t, body, "EOF", "no internal detail in the response")
339+}
340+
341+func TestHandlerLimiter(t *testing.T) {
342+ r := newRig(t, func(c *githttp.Config) { c.Limiter = githttp.NewLimiter(nil) })
343+ for i := range githttp.MaxFailures {
344+ code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, nil)
345+ require.Equal(t, 401, code, i)
346+ }
347+ code, _, hdr := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
348+ assert.Equal(t, 429, code)
349+ assert.NotEmpty(t, hdr.Get("Retry-After"))
350+ // A blocked request does not even reach the authenticator.
351+ r.auth.mu.Lock()
352+ n := len(r.auth.seen)
353+ r.auth.mu.Unlock()
354+ r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "root-secret", nil, nil)
355+ r.auth.mu.Lock()
356+ assert.Equal(t, n, len(r.auth.seen))
357+ r.auth.mu.Unlock()
358+}
359+
360+func TestHandlerLimiterTrustedProxyViaConfig(t *testing.T) {
361+ r := newRig(t, func(c *githttp.Config) {
362+ c.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.0/8")}
363+ })
364+ for range githttp.MaxFailures {
365+ r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "u", "wrong", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
366+ }
367+ code, _, _ := r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.1"})
368+ assert.Equal(t, 429, code)
369+ code, _, _ = r.do(t, "GET", "/pub.git/info/refs?service=git-upload-pack", "", "", nil, map[string]string{"X-Forwarded-For": "198.51.100.2"})
370+ assert.Equal(t, 200, code)
371+}
372+
373+func TestHandlerBearerAndMalformedAuthorization(t *testing.T) {
374+ r := newRig(t, nil)
375+ path := "/priv.git/info/refs?service=git-upload-pack"
376+ code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "Bearer reader-secret"})
377+ assert.Equal(t, 200, code)
378+ code, _, _ = r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": "bearer reader-secret"})
379+ assert.Equal(t, 200, code, "the scheme is case-insensitive")
380+ for _, h := range []string{"Basic !!!notbase64", "Digest abc", "Bearer", "garbage"} {
381+ code, _, _ := r.do(t, "GET", path, "", "", nil, map[string]string{"Authorization": h})
382+ assert.Equal(t, 401, code, h)
383+ }
384+ // An empty password is a failed attempt, not anonymous access.
385+ req, _ := http.NewRequest("GET", r.srv.URL+"/pub.git/info/refs?service=git-upload-pack", nil)
386+ req.SetBasicAuth("u", "")
387+ resp, err := http.DefaultClient.Do(req)
388+ require.NoError(t, err)
389+ _ = resp.Body.Close()
390+ assert.Equal(t, 401, resp.StatusCode)
391+}
A internal/adapters/githttp/limiter.go +166 -0
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "net"
7+ "net/http"
8+ "net/netip"
9+ "strings"
10+ "sync"
11+ "time"
12+)
13+
14+// Limiter defaults: 10 failed authentications in 10 minutes block the client
15+// address until the oldest failure leaves the window.
16+const (
17+ // MaxFailures is the number of failures in Window that blocks an address.
18+ MaxFailures = 10
19+ // Window is how long a failure counts.
20+ Window = 10 * time.Minute
21+
22+ // sweepAbove is the number of tracked addresses above which expired
23+ // entries are swept on the next failure.
24+ sweepAbove = 1024
25+ // maxTracked bounds memory: past it the entries with the oldest last
26+ // failure are dropped.
27+ maxTracked = 100_000
28+)
29+
30+// Limiter counts failed authentications per client address, in memory.
31+// It is safe for concurrent use.
32+type Limiter struct {
33+ now func() time.Time
34+
35+ mu sync.Mutex
36+ failures map[string][]time.Time // oldest first, all within Window
37+}
38+
39+// NewLimiter builds a limiter. now defaults to time.Now.
40+func NewLimiter(now func() time.Time) *Limiter {
41+ if now == nil {
42+ now = time.Now
43+ }
44+ return &Limiter{now: now, failures: make(map[string][]time.Time)}
45+}
46+
47+// Blocked reports whether addr has used up its failures, and how long until
48+// it may try again.
49+func (l *Limiter) Blocked(addr string) (bool, time.Duration) {
50+ now := l.now()
51+ l.mu.Lock()
52+ defer l.mu.Unlock()
53+ fs := l.prune(addr, now)
54+ if len(fs) < MaxFailures {
55+ return false, 0
56+ }
57+ // Free again when the failure that makes it MaxFailures leaves the window.
58+ wait := fs[len(fs)-MaxFailures].Add(Window).Sub(now)
59+ return true, max(wait, time.Second)
60+}
61+
62+// Fail records a failed authentication from addr.
63+func (l *Limiter) Fail(addr string) {
64+ now := l.now()
65+ l.mu.Lock()
66+ defer l.mu.Unlock()
67+ if len(l.failures) > sweepAbove {
68+ l.sweep(now)
69+ }
70+ fs := append(l.prune(addr, now), now)
71+ if len(fs) > MaxFailures*2 {
72+ fs = fs[len(fs)-MaxFailures*2:] // enough to compute the wait
73+ }
74+ l.failures[addr] = fs
75+}
76+
77+// prune drops the expired failures of addr and returns the rest. The caller
78+// holds mu.
79+func (l *Limiter) prune(addr string, now time.Time) []time.Time {
80+ fs := l.failures[addr]
81+ cut := now.Add(-Window)
82+ i := 0
83+ for i < len(fs) && !fs[i].After(cut) {
84+ i++
85+ }
86+ if i == len(fs) {
87+ delete(l.failures, addr)
88+ return nil
89+ }
90+ if i > 0 {
91+ fs = fs[i:]
92+ l.failures[addr] = fs
93+ }
94+ return fs
95+}
96+
97+// sweep removes every expired address, and the oldest ones when the table is
98+// still too large. The caller holds mu.
99+func (l *Limiter) sweep(now time.Time) {
100+ for addr := range l.failures {
101+ l.prune(addr, now)
102+ }
103+ for len(l.failures) >= maxTracked {
104+ var oldest string
105+ var at time.Time
106+ for addr, fs := range l.failures {
107+ if last := fs[len(fs)-1]; oldest == "" || last.Before(at) {
108+ oldest, at = addr, last
109+ }
110+ }
111+ delete(l.failures, oldest)
112+ }
113+}
114+
115+// Tracked returns how many addresses have failures on record, for tests.
116+func (l *Limiter) Tracked() int {
117+ l.mu.Lock()
118+ defer l.mu.Unlock()
119+ return len(l.failures)
120+}
121+
122+// ClientAddr returns the address a request is attributed to: the host of
123+// RemoteAddr, or, when that peer is in one of the trusted proxy networks, the
124+// right-most X-Forwarded-For entry that is not itself a trusted proxy. An
125+// untrusted peer cannot choose its address: its X-Forwarded-For is ignored.
126+// A header that does not parse falls back to the peer.
127+func ClientAddr(r *http.Request, trusted []netip.Prefix) string {
128+ host, _, err := net.SplitHostPort(r.RemoteAddr)
129+ if err != nil {
130+ host = r.RemoteAddr
131+ }
132+ peer, err := netip.ParseAddr(host)
133+ if err != nil {
134+ return host
135+ }
136+ peer = peer.Unmap()
137+ if len(trusted) == 0 || !isTrusted(peer, trusted) {
138+ return peer.String()
139+ }
140+ var entries []string
141+ for _, h := range r.Header.Values("X-Forwarded-For") {
142+ for e := range strings.SplitSeq(h, ",") {
143+ entries = append(entries, strings.TrimSpace(e))
144+ }
145+ }
146+ for i := len(entries) - 1; i >= 0; i-- {
147+ a, err := netip.ParseAddr(entries[i])
148+ if err != nil {
149+ return peer.String() // garbage from a trusted proxy: do not guess
150+ }
151+ a = a.Unmap()
152+ if !isTrusted(a, trusted) {
153+ return a.String()
154+ }
155+ }
156+ return peer.String()
157+}
158+
159+func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
160+ for _, p := range trusted {
161+ if p.Contains(a) {
162+ return true
163+ }
164+ }
165+ return false
166+}
@@ -0,0 +1,166 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp
4+
5+import (
6+ "net"
7+ "net/http"
8+ "net/netip"
9+ "strings"
10+ "sync"
11+ "time"
12+)
13+
14+// Limiter defaults: 10 failed authentications in 10 minutes block the client
15+// address until the oldest failure leaves the window.
16+const (
17+ // MaxFailures is the number of failures in Window that blocks an address.
18+ MaxFailures = 10
19+ // Window is how long a failure counts.
20+ Window = 10 * time.Minute
21+
22+ // sweepAbove is the number of tracked addresses above which expired
23+ // entries are swept on the next failure.
24+ sweepAbove = 1024
25+ // maxTracked bounds memory: past it the entries with the oldest last
26+ // failure are dropped.
27+ maxTracked = 100_000
28+)
29+
30+// Limiter counts failed authentications per client address, in memory.
31+// It is safe for concurrent use.
32+type Limiter struct {
33+ now func() time.Time
34+
35+ mu sync.Mutex
36+ failures map[string][]time.Time // oldest first, all within Window
37+}
38+
39+// NewLimiter builds a limiter. now defaults to time.Now.
40+func NewLimiter(now func() time.Time) *Limiter {
41+ if now == nil {
42+ now = time.Now
43+ }
44+ return &Limiter{now: now, failures: make(map[string][]time.Time)}
45+}
46+
47+// Blocked reports whether addr has used up its failures, and how long until
48+// it may try again.
49+func (l *Limiter) Blocked(addr string) (bool, time.Duration) {
50+ now := l.now()
51+ l.mu.Lock()
52+ defer l.mu.Unlock()
53+ fs := l.prune(addr, now)
54+ if len(fs) < MaxFailures {
55+ return false, 0
56+ }
57+ // Free again when the failure that makes it MaxFailures leaves the window.
58+ wait := fs[len(fs)-MaxFailures].Add(Window).Sub(now)
59+ return true, max(wait, time.Second)
60+}
61+
62+// Fail records a failed authentication from addr.
63+func (l *Limiter) Fail(addr string) {
64+ now := l.now()
65+ l.mu.Lock()
66+ defer l.mu.Unlock()
67+ if len(l.failures) > sweepAbove {
68+ l.sweep(now)
69+ }
70+ fs := append(l.prune(addr, now), now)
71+ if len(fs) > MaxFailures*2 {
72+ fs = fs[len(fs)-MaxFailures*2:] // enough to compute the wait
73+ }
74+ l.failures[addr] = fs
75+}
76+
77+// prune drops the expired failures of addr and returns the rest. The caller
78+// holds mu.
79+func (l *Limiter) prune(addr string, now time.Time) []time.Time {
80+ fs := l.failures[addr]
81+ cut := now.Add(-Window)
82+ i := 0
83+ for i < len(fs) && !fs[i].After(cut) {
84+ i++
85+ }
86+ if i == len(fs) {
87+ delete(l.failures, addr)
88+ return nil
89+ }
90+ if i > 0 {
91+ fs = fs[i:]
92+ l.failures[addr] = fs
93+ }
94+ return fs
95+}
96+
97+// sweep removes every expired address, and the oldest ones when the table is
98+// still too large. The caller holds mu.
99+func (l *Limiter) sweep(now time.Time) {
100+ for addr := range l.failures {
101+ l.prune(addr, now)
102+ }
103+ for len(l.failures) >= maxTracked {
104+ var oldest string
105+ var at time.Time
106+ for addr, fs := range l.failures {
107+ if last := fs[len(fs)-1]; oldest == "" || last.Before(at) {
108+ oldest, at = addr, last
109+ }
110+ }
111+ delete(l.failures, oldest)
112+ }
113+}
114+
115+// Tracked returns how many addresses have failures on record, for tests.
116+func (l *Limiter) Tracked() int {
117+ l.mu.Lock()
118+ defer l.mu.Unlock()
119+ return len(l.failures)
120+}
121+
122+// ClientAddr returns the address a request is attributed to: the host of
123+// RemoteAddr, or, when that peer is in one of the trusted proxy networks, the
124+// right-most X-Forwarded-For entry that is not itself a trusted proxy. An
125+// untrusted peer cannot choose its address: its X-Forwarded-For is ignored.
126+// A header that does not parse falls back to the peer.
127+func ClientAddr(r *http.Request, trusted []netip.Prefix) string {
128+ host, _, err := net.SplitHostPort(r.RemoteAddr)
129+ if err != nil {
130+ host = r.RemoteAddr
131+ }
132+ peer, err := netip.ParseAddr(host)
133+ if err != nil {
134+ return host
135+ }
136+ peer = peer.Unmap()
137+ if len(trusted) == 0 || !isTrusted(peer, trusted) {
138+ return peer.String()
139+ }
140+ var entries []string
141+ for _, h := range r.Header.Values("X-Forwarded-For") {
142+ for e := range strings.SplitSeq(h, ",") {
143+ entries = append(entries, strings.TrimSpace(e))
144+ }
145+ }
146+ for i := len(entries) - 1; i >= 0; i-- {
147+ a, err := netip.ParseAddr(entries[i])
148+ if err != nil {
149+ return peer.String() // garbage from a trusted proxy: do not guess
150+ }
151+ a = a.Unmap()
152+ if !isTrusted(a, trusted) {
153+ return a.String()
154+ }
155+ }
156+ return peer.String()
157+}
158+
159+func isTrusted(a netip.Addr, trusted []netip.Prefix) bool {
160+ for _, p := range trusted {
161+ if p.Contains(a) {
162+ return true
163+ }
164+ }
165+ return false
166+}
A internal/adapters/githttp/limiter_test.go +110 -0
@@ -0,0 +1,110 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp_test
4+
5+import (
6+ "net/http"
7+ "net/netip"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+
13+ "bnema.dev/ermite/internal/adapters/githttp"
14+)
15+
16+type fakeClock struct{ t time.Time }
17+
18+func (c *fakeClock) now() time.Time { return c.t }
19+
20+func TestLimiter(t *testing.T) {
21+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
22+ l := githttp.NewLimiter(clk.now)
23+
24+ for range githttp.MaxFailures - 1 {
25+ l.Fail("1.2.3.4")
26+ }
27+ blocked, _ := l.Blocked("1.2.3.4")
28+ assert.False(t, blocked, "9 failures do not block")
29+
30+ l.Fail("1.2.3.4")
31+ blocked, wait := l.Blocked("1.2.3.4")
32+ assert.True(t, blocked, "10 failures block")
33+ assert.Equal(t, githttp.Window, wait)
34+
35+ other, _ := l.Blocked("5.6.7.8")
36+ assert.False(t, other, "another address is not affected")
37+
38+ clk.t = clk.t.Add(githttp.Window - time.Second)
39+ blocked, wait = l.Blocked("1.2.3.4")
40+ assert.True(t, blocked)
41+ assert.Equal(t, time.Second, wait)
42+
43+ clk.t = clk.t.Add(time.Second)
44+ blocked, _ = l.Blocked("1.2.3.4")
45+ assert.False(t, blocked, "free once the window passed")
46+ assert.Zero(t, l.Tracked(), "expired entries are pruned")
47+}
48+
49+func TestLimiterSlidingWindow(t *testing.T) {
50+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
51+ l := githttp.NewLimiter(clk.now)
52+ for range 5 {
53+ l.Fail("a")
54+ }
55+ clk.t = clk.t.Add(githttp.Window - time.Minute)
56+ for range 5 {
57+ l.Fail("a")
58+ }
59+ blocked, wait := l.Blocked("a")
60+ assert.True(t, blocked)
61+ assert.Equal(t, time.Minute, wait, "free when the oldest of the last ten leaves")
62+
63+ clk.t = clk.t.Add(time.Minute)
64+ blocked, _ = l.Blocked("a")
65+ assert.False(t, blocked, "five old failures expired, five remain")
66+}
67+
68+func TestLimiterBoundsMemory(t *testing.T) {
69+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
70+ l := githttp.NewLimiter(clk.now)
71+ for range 5000 {
72+ l.Fail("x")
73+ }
74+ blocked, _ := l.Blocked("x")
75+ assert.True(t, blocked)
76+}
77+
78+func TestClientAddr(t *testing.T) {
79+ trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}
80+ tests := []struct {
81+ name string
82+ remote string
83+ xff []string
84+ trusted []netip.Prefix
85+ want string
86+ }{
87+ {"no proxies configured", "203.0.113.5:4000", []string{"1.1.1.1"}, nil, "203.0.113.5"},
88+ {"untrusted peer: header ignored", "203.0.113.5:4000", []string{"1.1.1.1"}, trusted, "203.0.113.5"},
89+ {"trusted peer, one entry", "10.1.2.3:4000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
90+ {"trusted peer, no header", "10.1.2.3:4000", nil, trusted, "10.1.2.3"},
91+ {"right-most untrusted entry wins over a spoofed left one", "10.1.2.3:4000", []string{"6.6.6.6, 198.51.100.7"}, trusted, "198.51.100.7"},
92+ {"trusted hops are skipped", "10.1.2.3:4000", []string{"198.51.100.7, 10.9.9.9, 10.8.8.8"}, trusted, "198.51.100.7"},
93+ {"several header lines", "10.1.2.3:4000", []string{"6.6.6.6", "198.51.100.7"}, trusted, "198.51.100.7"},
94+ {"all entries trusted", "10.1.2.3:4000", []string{"10.9.9.9"}, trusted, "10.1.2.3"},
95+ {"garbage entry falls back to the peer", "10.1.2.3:4000", []string{"not-an-ip"}, trusted, "10.1.2.3"},
96+ {"garbage left of a good entry is never reached", "10.1.2.3:4000", []string{"junk, 198.51.100.7"}, trusted, "198.51.100.7"},
97+ {"ipv6 peer", "[fd00::1]:4000", []string{"2001:db8::7"}, trusted, "2001:db8::7"},
98+ {"v4-mapped peer is unmapped", "[::ffff:10.1.2.3]:4000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
99+ {"remote without port", "203.0.113.5", nil, trusted, "203.0.113.5"},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ r := &http.Request{RemoteAddr: tt.remote, Header: http.Header{}}
104+ for _, v := range tt.xff {
105+ r.Header.Add("X-Forwarded-For", v)
106+ }
107+ assert.Equal(t, tt.want, githttp.ClientAddr(r, tt.trusted))
108+ })
109+ }
110+}
@@ -0,0 +1,110 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package githttp_test
4+
5+import (
6+ "net/http"
7+ "net/netip"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+
13+ "bnema.dev/ermite/internal/adapters/githttp"
14+)
15+
16+type fakeClock struct{ t time.Time }
17+
18+func (c *fakeClock) now() time.Time { return c.t }
19+
20+func TestLimiter(t *testing.T) {
21+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
22+ l := githttp.NewLimiter(clk.now)
23+
24+ for range githttp.MaxFailures - 1 {
25+ l.Fail("1.2.3.4")
26+ }
27+ blocked, _ := l.Blocked("1.2.3.4")
28+ assert.False(t, blocked, "9 failures do not block")
29+
30+ l.Fail("1.2.3.4")
31+ blocked, wait := l.Blocked("1.2.3.4")
32+ assert.True(t, blocked, "10 failures block")
33+ assert.Equal(t, githttp.Window, wait)
34+
35+ other, _ := l.Blocked("5.6.7.8")
36+ assert.False(t, other, "another address is not affected")
37+
38+ clk.t = clk.t.Add(githttp.Window - time.Second)
39+ blocked, wait = l.Blocked("1.2.3.4")
40+ assert.True(t, blocked)
41+ assert.Equal(t, time.Second, wait)
42+
43+ clk.t = clk.t.Add(time.Second)
44+ blocked, _ = l.Blocked("1.2.3.4")
45+ assert.False(t, blocked, "free once the window passed")
46+ assert.Zero(t, l.Tracked(), "expired entries are pruned")
47+}
48+
49+func TestLimiterSlidingWindow(t *testing.T) {
50+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
51+ l := githttp.NewLimiter(clk.now)
52+ for range 5 {
53+ l.Fail("a")
54+ }
55+ clk.t = clk.t.Add(githttp.Window - time.Minute)
56+ for range 5 {
57+ l.Fail("a")
58+ }
59+ blocked, wait := l.Blocked("a")
60+ assert.True(t, blocked)
61+ assert.Equal(t, time.Minute, wait, "free when the oldest of the last ten leaves")
62+
63+ clk.t = clk.t.Add(time.Minute)
64+ blocked, _ = l.Blocked("a")
65+ assert.False(t, blocked, "five old failures expired, five remain")
66+}
67+
68+func TestLimiterBoundsMemory(t *testing.T) {
69+ clk := &fakeClock{t: time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)}
70+ l := githttp.NewLimiter(clk.now)
71+ for range 5000 {
72+ l.Fail("x")
73+ }
74+ blocked, _ := l.Blocked("x")
75+ assert.True(t, blocked)
76+}
77+
78+func TestClientAddr(t *testing.T) {
79+ trusted := []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("fd00::/8")}
80+ tests := []struct {
81+ name string
82+ remote string
83+ xff []string
84+ trusted []netip.Prefix
85+ want string
86+ }{
87+ {"no proxies configured", "203.0.113.5:4000", []string{"1.1.1.1"}, nil, "203.0.113.5"},
88+ {"untrusted peer: header ignored", "203.0.113.5:4000", []string{"1.1.1.1"}, trusted, "203.0.113.5"},
89+ {"trusted peer, one entry", "10.1.2.3:4000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
90+ {"trusted peer, no header", "10.1.2.3:4000", nil, trusted, "10.1.2.3"},
91+ {"right-most untrusted entry wins over a spoofed left one", "10.1.2.3:4000", []string{"6.6.6.6, 198.51.100.7"}, trusted, "198.51.100.7"},
92+ {"trusted hops are skipped", "10.1.2.3:4000", []string{"198.51.100.7, 10.9.9.9, 10.8.8.8"}, trusted, "198.51.100.7"},
93+ {"several header lines", "10.1.2.3:4000", []string{"6.6.6.6", "198.51.100.7"}, trusted, "198.51.100.7"},
94+ {"all entries trusted", "10.1.2.3:4000", []string{"10.9.9.9"}, trusted, "10.1.2.3"},
95+ {"garbage entry falls back to the peer", "10.1.2.3:4000", []string{"not-an-ip"}, trusted, "10.1.2.3"},
96+ {"garbage left of a good entry is never reached", "10.1.2.3:4000", []string{"junk, 198.51.100.7"}, trusted, "198.51.100.7"},
97+ {"ipv6 peer", "[fd00::1]:4000", []string{"2001:db8::7"}, trusted, "2001:db8::7"},
98+ {"v4-mapped peer is unmapped", "[::ffff:10.1.2.3]:4000", []string{"198.51.100.7"}, trusted, "198.51.100.7"},
99+ {"remote without port", "203.0.113.5", nil, trusted, "203.0.113.5"},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ r := &http.Request{RemoteAddr: tt.remote, Header: http.Header{}}
104+ for _, v := range tt.xff {
105+ r.Header.Add("X-Forwarded-For", v)
106+ }
107+ assert.Equal(t, tt.want, githttp.ClientAddr(r, tt.trusted))
108+ })
109+ }
110+}
M internal/adapters/httpserver/server.go +14 -1
@@ -2,7 +2,7 @@
22
33 // Package httpserver is the HTTP entry of ermite: the router, the security
44 // headers, the request log and the graceful shutdown. Routes are added here
5-// as the other adapters arrive; for now it serves /-/health.
5+// as the other adapters arrive: /-/health and the routes given in Config.
66 package httpserver
77
88 import (
@@ -24,12 +24,22 @@ const ShutdownTimeout = 10 * time.Second
2424 // Check reports whether a dependency works. A nil result means healthy.
2525 type Check func(ctx context.Context) error
2626
27+// Route mounts a handler on the router.
28+type Route struct {
29+ // Pattern is a net/http.ServeMux pattern, such as "GET /{repo}/info/refs".
30+ // It must not overlap the /-/ routes of the server.
31+ Pattern string
32+ Handler http.Handler
33+}
34+
2735 // Config configures a Server.
2836 type Config struct {
2937 // Addr is the listen address, "host:port".
3038 Addr string
3139 // HealthChecks run on every GET /-/health; one failure answers 503.
3240 HealthChecks []Check
41+ // Routes are added to the router next to /-/health.
42+ Routes []Route
3343 }
3444
3545 // Server is the HTTP server of ermite.
@@ -44,6 +54,9 @@ func New(cfg Config) *Server {
4454 // "GET" patterns also match HEAD; other methods get 405 with Allow, and
4555 // unknown paths get 404.
4656 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
57+ for _, r := range cfg.Routes {
58+ mux.Handle(r.Pattern, r.Handler)
59+ }
4760 return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))}
4861 }
4962
@@ -2,7 +2,7 @@
2 2
3 // Package httpserver is the HTTP entry of ermite: the router, the security3 // Package httpserver is the HTTP entry of ermite: the router, the security
4 // headers, the request log and the graceful shutdown. Routes are added here4 // headers, the request log and the graceful shutdown. Routes are added here
5-// as the other adapters arrive; for now it serves /-/health.5+// as the other adapters arrive: /-/health and the routes given in Config.
6 package httpserver6 package httpserver
7 7
8 import (8 import (
@@ -24,12 +24,22 @@ const ShutdownTimeout = 10 * time.Second
24 // Check reports whether a dependency works. A nil result means healthy.24 // Check reports whether a dependency works. A nil result means healthy.
25 type Check func(ctx context.Context) error25 type Check func(ctx context.Context) error
26 26
27+// Route mounts a handler on the router.
28+type Route struct {
29+ // Pattern is a net/http.ServeMux pattern, such as "GET /{repo}/info/refs".
30+ // It must not overlap the /-/ routes of the server.
31+ Pattern string
32+ Handler http.Handler
33+}
34+
27 // Config configures a Server.35 // Config configures a Server.
28 type Config struct {36 type Config struct {
29 // Addr is the listen address, "host:port".37 // Addr is the listen address, "host:port".
30 Addr string38 Addr string
31 // HealthChecks run on every GET /-/health; one failure answers 503.39 // HealthChecks run on every GET /-/health; one failure answers 503.
32 HealthChecks []Check40 HealthChecks []Check
41+ // Routes are added to the router next to /-/health.
42+ Routes []Route
33 }43 }
34 44
35 // Server is the HTTP server of ermite.45 // Server is the HTTP server of ermite.
@@ -44,6 +54,9 @@ func New(cfg Config) *Server {
44 // "GET" patterns also match HEAD; other methods get 405 with Allow, and54 // "GET" patterns also match HEAD; other methods get 405 with Allow, and
45 // unknown paths get 404.55 // unknown paths get 404.
46 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))56 mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks))
57+ for _, r := range cfg.Routes {
58+ mux.Handle(r.Pattern, r.Handler)
59+ }
47 return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))}60 return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))}
48 }61 }
49 62
M internal/domain/errors.go +2 -0
@@ -22,6 +22,8 @@ var (
2222 ErrUnauthenticated = errors.New("unauthenticated")
2323 // ErrForbidden means the caller is known but may not do this.
2424 ErrForbidden = errors.New("forbidden")
25+ // ErrRejected means a push was refused by policy.
26+ ErrRejected = errors.New("rejected")
2527 // ErrConflict means a concurrent change prevented the operation.
2628 ErrConflict = errors.New("conflict")
2729 )
@@ -22,6 +22,8 @@ var (
22 ErrUnauthenticated = errors.New("unauthenticated")22 ErrUnauthenticated = errors.New("unauthenticated")
23 // ErrForbidden means the caller is known but may not do this.23 // ErrForbidden means the caller is known but may not do this.
24 ErrForbidden = errors.New("forbidden")24 ErrForbidden = errors.New("forbidden")
25+ // ErrRejected means a push was refused by policy.
26+ ErrRejected = errors.New("rejected")
25 // ErrConflict means a concurrent change prevented the operation.27 // ErrConflict means a concurrent change prevented the operation.
26 ErrConflict = errors.New("conflict")28 ErrConflict = errors.New("conflict")
27 )29 )
A internal/domain/event.go +78 -0
@@ -0,0 +1,78 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "regexp"
8+ "time"
9+)
10+
11+// ZeroOID is the object ID that stands for "no object" in a ref update.
12+const ZeroOID = "0000000000000000000000000000000000000000"
13+
14+// RefUpdate is one ref change of a push, as Git reports it to the hooks.
15+type RefUpdate struct {
16+ // Old is the previous object ID, ZeroOID when the ref is created.
17+ Old string
18+ // New is the new object ID, ZeroOID when the ref is deleted.
19+ New string
20+ // Ref is the full ref name, such as refs/heads/main.
21+ Ref string
22+}
23+
24+var oidRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
25+
26+// maxRefLen bounds a ref name.
27+const maxRefLen = 4096
28+
29+// NewRefUpdate validates the parts of a ref update as Git reports them: two
30+// 40-digit lowercase hexadecimal object IDs (sha1) and a non-empty ref name.
31+func NewRefUpdate(oldOID, newOID, ref string) (RefUpdate, error) {
32+ if !oidRE.MatchString(oldOID) || !oidRE.MatchString(newOID) {
33+ return RefUpdate{}, fmt.Errorf("invalid object id in the update of %q", ref)
34+ }
35+ if ref == "" || len(ref) > maxRefLen {
36+ return RefUpdate{}, fmt.Errorf("invalid ref name %q", ref)
37+ }
38+ return RefUpdate{Old: oldOID, New: newOID, Ref: ref}, nil
39+}
40+
41+// IsCreate reports whether the update creates the ref.
42+func (u RefUpdate) IsCreate() bool { return u.Old == ZeroOID }
43+
44+// IsDelete reports whether the update deletes the ref.
45+func (u RefUpdate) IsDelete() bool { return u.New == ZeroOID }
46+
47+// Event is something that happened in the forge, published on the event bus.
48+type Event interface {
49+ // EventKind names the kind of event.
50+ EventKind() string
51+}
52+
53+// RepoCreated is published when a repository comes into being.
54+type RepoCreated struct {
55+ Repo RepoName
56+ Visibility Visibility
57+ At time.Time
58+}
59+
60+// EventKind implements Event.
61+func (RepoCreated) EventKind() string { return "repo.created" }
62+
63+// PushEvent is published after a push has been accepted.
64+type PushEvent struct {
65+ Repo RepoName
66+ Updates []RefUpdate
67+ At time.Time
68+}
69+
70+// EventKind implements Event.
71+func (PushEvent) EventKind() string { return "repo.pushed" }
72+
73+// EventGap tells a subscriber that it missed events because it was too slow;
74+// it must refresh its whole view.
75+type EventGap struct{}
76+
77+// EventKind implements Event.
78+func (EventGap) EventKind() string { return "gap" }
@@ -0,0 +1,78 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "regexp"
8+ "time"
9+)
10+
11+// ZeroOID is the object ID that stands for "no object" in a ref update.
12+const ZeroOID = "0000000000000000000000000000000000000000"
13+
14+// RefUpdate is one ref change of a push, as Git reports it to the hooks.
15+type RefUpdate struct {
16+ // Old is the previous object ID, ZeroOID when the ref is created.
17+ Old string
18+ // New is the new object ID, ZeroOID when the ref is deleted.
19+ New string
20+ // Ref is the full ref name, such as refs/heads/main.
21+ Ref string
22+}
23+
24+var oidRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
25+
26+// maxRefLen bounds a ref name.
27+const maxRefLen = 4096
28+
29+// NewRefUpdate validates the parts of a ref update as Git reports them: two
30+// 40-digit lowercase hexadecimal object IDs (sha1) and a non-empty ref name.
31+func NewRefUpdate(oldOID, newOID, ref string) (RefUpdate, error) {
32+ if !oidRE.MatchString(oldOID) || !oidRE.MatchString(newOID) {
33+ return RefUpdate{}, fmt.Errorf("invalid object id in the update of %q", ref)
34+ }
35+ if ref == "" || len(ref) > maxRefLen {
36+ return RefUpdate{}, fmt.Errorf("invalid ref name %q", ref)
37+ }
38+ return RefUpdate{Old: oldOID, New: newOID, Ref: ref}, nil
39+}
40+
41+// IsCreate reports whether the update creates the ref.
42+func (u RefUpdate) IsCreate() bool { return u.Old == ZeroOID }
43+
44+// IsDelete reports whether the update deletes the ref.
45+func (u RefUpdate) IsDelete() bool { return u.New == ZeroOID }
46+
47+// Event is something that happened in the forge, published on the event bus.
48+type Event interface {
49+ // EventKind names the kind of event.
50+ EventKind() string
51+}
52+
53+// RepoCreated is published when a repository comes into being.
54+type RepoCreated struct {
55+ Repo RepoName
56+ Visibility Visibility
57+ At time.Time
58+}
59+
60+// EventKind implements Event.
61+func (RepoCreated) EventKind() string { return "repo.created" }
62+
63+// PushEvent is published after a push has been accepted.
64+type PushEvent struct {
65+ Repo RepoName
66+ Updates []RefUpdate
67+ At time.Time
68+}
69+
70+// EventKind implements Event.
71+func (PushEvent) EventKind() string { return "repo.pushed" }
72+
73+// EventGap tells a subscriber that it missed events because it was too slow;
74+// it must refresh its whole view.
75+type EventGap struct{}
76+
77+// EventKind implements Event.
78+func (EventGap) EventKind() string { return "gap" }
A internal/domain/event_test.go +52 -0
@@ -0,0 +1,52 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestNewRefUpdate(t *testing.T) {
16+ a, b := strings.Repeat("a", 40), strings.Repeat("b", 40)
17+
18+ u, err := domain.NewRefUpdate(domain.ZeroOID, a, "refs/heads/main")
19+ require.NoError(t, err)
20+ assert.True(t, u.IsCreate())
21+ assert.False(t, u.IsDelete())
22+
23+ u, err = domain.NewRefUpdate(a, domain.ZeroOID, "refs/tags/v1")
24+ require.NoError(t, err)
25+ assert.True(t, u.IsDelete())
26+
27+ u, err = domain.NewRefUpdate(a, b, "refs/heads/a b")
28+ require.NoError(t, err)
29+ assert.False(t, u.IsCreate() || u.IsDelete())
30+ assert.Equal(t, domain.RefUpdate{Old: a, New: b, Ref: "refs/heads/a b"}, u)
31+
32+ for name, args := range map[string][3]string{
33+ "short old": {"abc", b, "refs/heads/x"},
34+ "upper case": {strings.ToUpper(a), b, "refs/heads/x"},
35+ "non hex": {strings.Repeat("g", 40), b, "refs/heads/x"},
36+ "long new": {a, b + "0", "refs/heads/x"},
37+ "empty ref": {a, b, ""},
38+ "huge ref": {a, b, strings.Repeat("r", 5000)},
39+ "empty oid": {"", b, "refs/heads/x"},
40+ "sha256 oid": {strings.Repeat("a", 64), b, "refs/heads/x"},
41+ "newline oid": {a + "\n", b, "refs/heads/x"},
42+ } {
43+ _, err := domain.NewRefUpdate(args[0], args[1], args[2])
44+ assert.Error(t, err, name)
45+ }
46+}
47+
48+func TestEventKinds(t *testing.T) {
49+ assert.Equal(t, "repo.created", domain.RepoCreated{}.EventKind())
50+ assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())
51+ assert.Equal(t, "gap", domain.EventGap{}.EventKind())
52+}
@@ -0,0 +1,52 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestNewRefUpdate(t *testing.T) {
16+ a, b := strings.Repeat("a", 40), strings.Repeat("b", 40)
17+
18+ u, err := domain.NewRefUpdate(domain.ZeroOID, a, "refs/heads/main")
19+ require.NoError(t, err)
20+ assert.True(t, u.IsCreate())
21+ assert.False(t, u.IsDelete())
22+
23+ u, err = domain.NewRefUpdate(a, domain.ZeroOID, "refs/tags/v1")
24+ require.NoError(t, err)
25+ assert.True(t, u.IsDelete())
26+
27+ u, err = domain.NewRefUpdate(a, b, "refs/heads/a b")
28+ require.NoError(t, err)
29+ assert.False(t, u.IsCreate() || u.IsDelete())
30+ assert.Equal(t, domain.RefUpdate{Old: a, New: b, Ref: "refs/heads/a b"}, u)
31+
32+ for name, args := range map[string][3]string{
33+ "short old": {"abc", b, "refs/heads/x"},
34+ "upper case": {strings.ToUpper(a), b, "refs/heads/x"},
35+ "non hex": {strings.Repeat("g", 40), b, "refs/heads/x"},
36+ "long new": {a, b + "0", "refs/heads/x"},
37+ "empty ref": {a, b, ""},
38+ "huge ref": {a, b, strings.Repeat("r", 5000)},
39+ "empty oid": {"", b, "refs/heads/x"},
40+ "sha256 oid": {strings.Repeat("a", 64), b, "refs/heads/x"},
41+ "newline oid": {a + "\n", b, "refs/heads/x"},
42+ } {
43+ _, err := domain.NewRefUpdate(args[0], args[1], args[2])
44+ assert.Error(t, err, name)
45+ }
46+}
47+
48+func TestEventKinds(t *testing.T) {
49+ assert.Equal(t, "repo.created", domain.RepoCreated{}.EventKind())
50+ assert.Equal(t, "repo.pushed", domain.PushEvent{}.EventKind())
51+ assert.Equal(t, "gap", domain.EventGap{}.EventKind())
52+}
A internal/ports/mocks/GitBackend.go +114 -0
@@ -0,0 +1,114 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewGitBackend creates a new instance of GitBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewGitBackend(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *GitBackend {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &GitBackend{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// GitBackend is an autogenerated mock type for the GitBackend type
38+type GitBackend struct {
39+ mock.Mock
40+}
41+
42+type GitBackend_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *GitBackend) EXPECT() *GitBackend_Expecter {
47+ return &GitBackend_Expecter{mock: &_m.Mock}
48+}
49+
50+// HTTPBackend provides a mock function for the type GitBackend
51+func (_mock *GitBackend) HTTPBackend(ctx context.Context, push bool) (ports.HTTPBackend, error) {
52+ ret := _mock.Called(ctx, push)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for HTTPBackend")
56+ }
57+
58+ var r0 ports.HTTPBackend
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) (ports.HTTPBackend, error)); ok {
61+ return returnFunc(ctx, push)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) ports.HTTPBackend); ok {
64+ r0 = returnFunc(ctx, push)
65+ } else {
66+ r0 = ret.Get(0).(ports.HTTPBackend)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, bool) error); ok {
69+ r1 = returnFunc(ctx, push)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// GitBackend_HTTPBackend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HTTPBackend'
77+type GitBackend_HTTPBackend_Call struct {
78+ *mock.Call
79+}
80+
81+// HTTPBackend is a helper method to define mock.On call
82+// - ctx context.Context
83+// - push bool
84+func (_e *GitBackend_Expecter) HTTPBackend(ctx any, push any) *GitBackend_HTTPBackend_Call {
85+ return &GitBackend_HTTPBackend_Call{Call: _e.mock.On("HTTPBackend", ctx, push)}
86+}
87+
88+func (_c *GitBackend_HTTPBackend_Call) Run(run func(ctx context.Context, push bool)) *GitBackend_HTTPBackend_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 bool
95+ if args[1] != nil {
96+ arg1 = args[1].(bool)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *GitBackend_HTTPBackend_Call) Return(hTTPBackend ports.HTTPBackend, err error) *GitBackend_HTTPBackend_Call {
107+ _c.Call.Return(hTTPBackend, err)
108+ return _c
109+}
110+
111+func (_c *GitBackend_HTTPBackend_Call) RunAndReturn(run func(ctx context.Context, push bool) (ports.HTTPBackend, error)) *GitBackend_HTTPBackend_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
@@ -0,0 +1,114 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewGitBackend creates a new instance of GitBackend. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewGitBackend(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *GitBackend {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &GitBackend{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// GitBackend is an autogenerated mock type for the GitBackend type
38+type GitBackend struct {
39+ mock.Mock
40+}
41+
42+type GitBackend_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *GitBackend) EXPECT() *GitBackend_Expecter {
47+ return &GitBackend_Expecter{mock: &_m.Mock}
48+}
49+
50+// HTTPBackend provides a mock function for the type GitBackend
51+func (_mock *GitBackend) HTTPBackend(ctx context.Context, push bool) (ports.HTTPBackend, error) {
52+ ret := _mock.Called(ctx, push)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for HTTPBackend")
56+ }
57+
58+ var r0 ports.HTTPBackend
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) (ports.HTTPBackend, error)); ok {
61+ return returnFunc(ctx, push)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, bool) ports.HTTPBackend); ok {
64+ r0 = returnFunc(ctx, push)
65+ } else {
66+ r0 = ret.Get(0).(ports.HTTPBackend)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, bool) error); ok {
69+ r1 = returnFunc(ctx, push)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// GitBackend_HTTPBackend_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HTTPBackend'
77+type GitBackend_HTTPBackend_Call struct {
78+ *mock.Call
79+}
80+
81+// HTTPBackend is a helper method to define mock.On call
82+// - ctx context.Context
83+// - push bool
84+func (_e *GitBackend_Expecter) HTTPBackend(ctx any, push any) *GitBackend_HTTPBackend_Call {
85+ return &GitBackend_HTTPBackend_Call{Call: _e.mock.On("HTTPBackend", ctx, push)}
86+}
87+
88+func (_c *GitBackend_HTTPBackend_Call) Run(run func(ctx context.Context, push bool)) *GitBackend_HTTPBackend_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 bool
95+ if args[1] != nil {
96+ arg1 = args[1].(bool)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *GitBackend_HTTPBackend_Call) Return(hTTPBackend ports.HTTPBackend, err error) *GitBackend_HTTPBackend_Call {
107+ _c.Call.Return(hTTPBackend, err)
108+ return _c
109+}
110+
111+func (_c *GitBackend_HTTPBackend_Call) RunAndReturn(run func(ctx context.Context, push bool) (ports.HTTPBackend, error)) *GitBackend_HTTPBackend_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
M internal/ports/mocks/GitRepos.go +192 -0
@@ -48,6 +48,78 @@ func (_m *GitRepos) EXPECT() *GitRepos_Expecter {
4848 return &GitRepos_Expecter{mock: &_m.Mock}
4949 }
5050
51+// DefaultBranch provides a mock function for the type GitRepos
52+func (_mock *GitRepos) DefaultBranch(ctx context.Context, name domain.RepoName) (string, bool, error) {
53+ ret := _mock.Called(ctx, name)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for DefaultBranch")
57+ }
58+
59+ var r0 string
60+ var r1 bool
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (string, bool, error)); ok {
63+ return returnFunc(ctx, name)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) string); ok {
66+ r0 = returnFunc(ctx, name)
67+ } else {
68+ r0 = ret.Get(0).(string)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
71+ r1 = returnFunc(ctx, name)
72+ } else {
73+ r1 = ret.Get(1).(bool)
74+ }
75+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
76+ r2 = returnFunc(ctx, name)
77+ } else {
78+ r2 = ret.Error(2)
79+ }
80+ return r0, r1, r2
81+}
82+
83+// GitRepos_DefaultBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DefaultBranch'
84+type GitRepos_DefaultBranch_Call struct {
85+ *mock.Call
86+}
87+
88+// DefaultBranch is a helper method to define mock.On call
89+// - ctx context.Context
90+// - name domain.RepoName
91+func (_e *GitRepos_Expecter) DefaultBranch(ctx any, name any) *GitRepos_DefaultBranch_Call {
92+ return &GitRepos_DefaultBranch_Call{Call: _e.mock.On("DefaultBranch", ctx, name)}
93+}
94+
95+func (_c *GitRepos_DefaultBranch_Call) Run(run func(ctx context.Context, name domain.RepoName)) *GitRepos_DefaultBranch_Call {
96+ _c.Call.Run(func(args mock.Arguments) {
97+ var arg0 context.Context
98+ if args[0] != nil {
99+ arg0 = args[0].(context.Context)
100+ }
101+ var arg1 domain.RepoName
102+ if args[1] != nil {
103+ arg1 = args[1].(domain.RepoName)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *GitRepos_DefaultBranch_Call) Return(branch string, exists bool, err error) *GitRepos_DefaultBranch_Call {
114+ _c.Call.Return(branch, exists, err)
115+ return _c
116+}
117+
118+func (_c *GitRepos_DefaultBranch_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName) (string, bool, error)) *GitRepos_DefaultBranch_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
51123 // Discard provides a mock function for the type GitRepos
52124 func (_mock *GitRepos) Discard(ctx context.Context, id ports.StagedID) error {
53125 ret := _mock.Called(ctx, id)
@@ -165,6 +237,63 @@ func (_c *GitRepos_List_Call) RunAndReturn(run func(ctx context.Context) (ports.
165237 return _c
166238 }
167239
240+// Maintain provides a mock function for the type GitRepos
241+func (_mock *GitRepos) Maintain(ctx context.Context, name domain.RepoName) error {
242+ ret := _mock.Called(ctx, name)
243+
244+ if len(ret) == 0 {
245+ panic("no return value specified for Maintain")
246+ }
247+
248+ var r0 error
249+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) error); ok {
250+ r0 = returnFunc(ctx, name)
251+ } else {
252+ r0 = ret.Error(0)
253+ }
254+ return r0
255+}
256+
257+// GitRepos_Maintain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Maintain'
258+type GitRepos_Maintain_Call struct {
259+ *mock.Call
260+}
261+
262+// Maintain is a helper method to define mock.On call
263+// - ctx context.Context
264+// - name domain.RepoName
265+func (_e *GitRepos_Expecter) Maintain(ctx any, name any) *GitRepos_Maintain_Call {
266+ return &GitRepos_Maintain_Call{Call: _e.mock.On("Maintain", ctx, name)}
267+}
268+
269+func (_c *GitRepos_Maintain_Call) Run(run func(ctx context.Context, name domain.RepoName)) *GitRepos_Maintain_Call {
270+ _c.Call.Run(func(args mock.Arguments) {
271+ var arg0 context.Context
272+ if args[0] != nil {
273+ arg0 = args[0].(context.Context)
274+ }
275+ var arg1 domain.RepoName
276+ if args[1] != nil {
277+ arg1 = args[1].(domain.RepoName)
278+ }
279+ run(
280+ arg0,
281+ arg1,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *GitRepos_Maintain_Call) Return(err error) *GitRepos_Maintain_Call {
288+ _c.Call.Return(err)
289+ return _c
290+}
291+
292+func (_c *GitRepos_Maintain_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName) error) *GitRepos_Maintain_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
168297 // Publish provides a mock function for the type GitRepos
169298 func (_mock *GitRepos) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error {
170299 ret := _mock.Called(ctx, id, name)
@@ -279,6 +408,69 @@ func (_c *GitRepos_PurgeStaging_Call) RunAndReturn(run func(ctx context.Context)
279408 return _c
280409 }
281410
411+// SetDefaultBranch provides a mock function for the type GitRepos
412+func (_mock *GitRepos) SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error {
413+ ret := _mock.Called(ctx, name, branch)
414+
415+ if len(ret) == 0 {
416+ panic("no return value specified for SetDefaultBranch")
417+ }
418+
419+ var r0 error
420+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok {
421+ r0 = returnFunc(ctx, name, branch)
422+ } else {
423+ r0 = ret.Error(0)
424+ }
425+ return r0
426+}
427+
428+// GitRepos_SetDefaultBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDefaultBranch'
429+type GitRepos_SetDefaultBranch_Call struct {
430+ *mock.Call
431+}
432+
433+// SetDefaultBranch is a helper method to define mock.On call
434+// - ctx context.Context
435+// - name domain.RepoName
436+// - branch string
437+func (_e *GitRepos_Expecter) SetDefaultBranch(ctx any, name any, branch any) *GitRepos_SetDefaultBranch_Call {
438+ return &GitRepos_SetDefaultBranch_Call{Call: _e.mock.On("SetDefaultBranch", ctx, name, branch)}
439+}
440+
441+func (_c *GitRepos_SetDefaultBranch_Call) Run(run func(ctx context.Context, name domain.RepoName, branch string)) *GitRepos_SetDefaultBranch_Call {
442+ _c.Call.Run(func(args mock.Arguments) {
443+ var arg0 context.Context
444+ if args[0] != nil {
445+ arg0 = args[0].(context.Context)
446+ }
447+ var arg1 domain.RepoName
448+ if args[1] != nil {
449+ arg1 = args[1].(domain.RepoName)
450+ }
451+ var arg2 string
452+ if args[2] != nil {
453+ arg2 = args[2].(string)
454+ }
455+ run(
456+ arg0,
457+ arg1,
458+ arg2,
459+ )
460+ })
461+ return _c
462+}
463+
464+func (_c *GitRepos_SetDefaultBranch_Call) Return(err error) *GitRepos_SetDefaultBranch_Call {
465+ _c.Call.Return(err)
466+ return _c
467+}
468+
469+func (_c *GitRepos_SetDefaultBranch_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName, branch string) error) *GitRepos_SetDefaultBranch_Call {
470+ _c.Call.Return(run)
471+ return _c
472+}
473+
282474 // Stage provides a mock function for the type GitRepos
283475 func (_mock *GitRepos) Stage(ctx context.Context) (ports.StagedID, error) {
284476 ret := _mock.Called(ctx)
@@ -48,6 +48,78 @@ func (_m *GitRepos) EXPECT() *GitRepos_Expecter {
48 return &GitRepos_Expecter{mock: &_m.Mock}48 return &GitRepos_Expecter{mock: &_m.Mock}
49 }49 }
50 50
51+// DefaultBranch provides a mock function for the type GitRepos
52+func (_mock *GitRepos) DefaultBranch(ctx context.Context, name domain.RepoName) (string, bool, error) {
53+ ret := _mock.Called(ctx, name)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for DefaultBranch")
57+ }
58+
59+ var r0 string
60+ var r1 bool
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (string, bool, error)); ok {
63+ return returnFunc(ctx, name)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) string); ok {
66+ r0 = returnFunc(ctx, name)
67+ } else {
68+ r0 = ret.Get(0).(string)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) bool); ok {
71+ r1 = returnFunc(ctx, name)
72+ } else {
73+ r1 = ret.Get(1).(bool)
74+ }
75+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName) error); ok {
76+ r2 = returnFunc(ctx, name)
77+ } else {
78+ r2 = ret.Error(2)
79+ }
80+ return r0, r1, r2
81+}
82+
83+// GitRepos_DefaultBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DefaultBranch'
84+type GitRepos_DefaultBranch_Call struct {
85+ *mock.Call
86+}
87+
88+// DefaultBranch is a helper method to define mock.On call
89+// - ctx context.Context
90+// - name domain.RepoName
91+func (_e *GitRepos_Expecter) DefaultBranch(ctx any, name any) *GitRepos_DefaultBranch_Call {
92+ return &GitRepos_DefaultBranch_Call{Call: _e.mock.On("DefaultBranch", ctx, name)}
93+}
94+
95+func (_c *GitRepos_DefaultBranch_Call) Run(run func(ctx context.Context, name domain.RepoName)) *GitRepos_DefaultBranch_Call {
96+ _c.Call.Run(func(args mock.Arguments) {
97+ var arg0 context.Context
98+ if args[0] != nil {
99+ arg0 = args[0].(context.Context)
100+ }
101+ var arg1 domain.RepoName
102+ if args[1] != nil {
103+ arg1 = args[1].(domain.RepoName)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *GitRepos_DefaultBranch_Call) Return(branch string, exists bool, err error) *GitRepos_DefaultBranch_Call {
114+ _c.Call.Return(branch, exists, err)
115+ return _c
116+}
117+
118+func (_c *GitRepos_DefaultBranch_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName) (string, bool, error)) *GitRepos_DefaultBranch_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
122+
51 // Discard provides a mock function for the type GitRepos123 // Discard provides a mock function for the type GitRepos
52 func (_mock *GitRepos) Discard(ctx context.Context, id ports.StagedID) error {124 func (_mock *GitRepos) Discard(ctx context.Context, id ports.StagedID) error {
53 ret := _mock.Called(ctx, id)125 ret := _mock.Called(ctx, id)
@@ -165,6 +237,63 @@ func (_c *GitRepos_List_Call) RunAndReturn(run func(ctx context.Context) (ports.
165 return _c237 return _c
166 }238 }
167 239
240+// Maintain provides a mock function for the type GitRepos
241+func (_mock *GitRepos) Maintain(ctx context.Context, name domain.RepoName) error {
242+ ret := _mock.Called(ctx, name)
243+
244+ if len(ret) == 0 {
245+ panic("no return value specified for Maintain")
246+ }
247+
248+ var r0 error
249+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) error); ok {
250+ r0 = returnFunc(ctx, name)
251+ } else {
252+ r0 = ret.Error(0)
253+ }
254+ return r0
255+}
256+
257+// GitRepos_Maintain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Maintain'
258+type GitRepos_Maintain_Call struct {
259+ *mock.Call
260+}
261+
262+// Maintain is a helper method to define mock.On call
263+// - ctx context.Context
264+// - name domain.RepoName
265+func (_e *GitRepos_Expecter) Maintain(ctx any, name any) *GitRepos_Maintain_Call {
266+ return &GitRepos_Maintain_Call{Call: _e.mock.On("Maintain", ctx, name)}
267+}
268+
269+func (_c *GitRepos_Maintain_Call) Run(run func(ctx context.Context, name domain.RepoName)) *GitRepos_Maintain_Call {
270+ _c.Call.Run(func(args mock.Arguments) {
271+ var arg0 context.Context
272+ if args[0] != nil {
273+ arg0 = args[0].(context.Context)
274+ }
275+ var arg1 domain.RepoName
276+ if args[1] != nil {
277+ arg1 = args[1].(domain.RepoName)
278+ }
279+ run(
280+ arg0,
281+ arg1,
282+ )
283+ })
284+ return _c
285+}
286+
287+func (_c *GitRepos_Maintain_Call) Return(err error) *GitRepos_Maintain_Call {
288+ _c.Call.Return(err)
289+ return _c
290+}
291+
292+func (_c *GitRepos_Maintain_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName) error) *GitRepos_Maintain_Call {
293+ _c.Call.Return(run)
294+ return _c
295+}
296+
168 // Publish provides a mock function for the type GitRepos297 // Publish provides a mock function for the type GitRepos
169 func (_mock *GitRepos) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error {298 func (_mock *GitRepos) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error {
170 ret := _mock.Called(ctx, id, name)299 ret := _mock.Called(ctx, id, name)
@@ -279,6 +408,69 @@ func (_c *GitRepos_PurgeStaging_Call) RunAndReturn(run func(ctx context.Context)
279 return _c408 return _c
280 }409 }
281 410
411+// SetDefaultBranch provides a mock function for the type GitRepos
412+func (_mock *GitRepos) SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error {
413+ ret := _mock.Called(ctx, name, branch)
414+
415+ if len(ret) == 0 {
416+ panic("no return value specified for SetDefaultBranch")
417+ }
418+
419+ var r0 error
420+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) error); ok {
421+ r0 = returnFunc(ctx, name, branch)
422+ } else {
423+ r0 = ret.Error(0)
424+ }
425+ return r0
426+}
427+
428+// GitRepos_SetDefaultBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetDefaultBranch'
429+type GitRepos_SetDefaultBranch_Call struct {
430+ *mock.Call
431+}
432+
433+// SetDefaultBranch is a helper method to define mock.On call
434+// - ctx context.Context
435+// - name domain.RepoName
436+// - branch string
437+func (_e *GitRepos_Expecter) SetDefaultBranch(ctx any, name any, branch any) *GitRepos_SetDefaultBranch_Call {
438+ return &GitRepos_SetDefaultBranch_Call{Call: _e.mock.On("SetDefaultBranch", ctx, name, branch)}
439+}
440+
441+func (_c *GitRepos_SetDefaultBranch_Call) Run(run func(ctx context.Context, name domain.RepoName, branch string)) *GitRepos_SetDefaultBranch_Call {
442+ _c.Call.Run(func(args mock.Arguments) {
443+ var arg0 context.Context
444+ if args[0] != nil {
445+ arg0 = args[0].(context.Context)
446+ }
447+ var arg1 domain.RepoName
448+ if args[1] != nil {
449+ arg1 = args[1].(domain.RepoName)
450+ }
451+ var arg2 string
452+ if args[2] != nil {
453+ arg2 = args[2].(string)
454+ }
455+ run(
456+ arg0,
457+ arg1,
458+ arg2,
459+ )
460+ })
461+ return _c
462+}
463+
464+func (_c *GitRepos_SetDefaultBranch_Call) Return(err error) *GitRepos_SetDefaultBranch_Call {
465+ _c.Call.Return(err)
466+ return _c
467+}
468+
469+func (_c *GitRepos_SetDefaultBranch_Call) RunAndReturn(run func(ctx context.Context, name domain.RepoName, branch string) error) *GitRepos_SetDefaultBranch_Call {
470+ _c.Call.Return(run)
471+ return _c
472+}
473+
282 // Stage provides a mock function for the type GitRepos474 // Stage provides a mock function for the type GitRepos
283 func (_mock *GitRepos) Stage(ctx context.Context) (ports.StagedID, error) {475 func (_mock *GitRepos) Stage(ctx context.Context) (ports.StagedID, error) {
284 ret := _mock.Called(ctx)476 ret := _mock.Called(ctx)
M internal/ports/ports.go +39 -0
@@ -60,6 +60,34 @@ type GitRepos interface {
6060 // Trash moves the repository directory to the trash, where it can be
6161 // recovered by hand. It returns domain.ErrNotFound if there is none.
6262 Trash(ctx context.Context, name domain.RepoName) error
63+ // DefaultBranch returns the branch HEAD points to (without refs/heads/)
64+ // and whether that branch exists. It returns domain.ErrNotFound if there
65+ // is no such repository.
66+ DefaultBranch(ctx context.Context, name domain.RepoName) (branch string, exists bool, err error)
67+ // SetDefaultBranch points HEAD to the branch, which need not exist yet.
68+ SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error
69+ // Maintain refreshes the commit-graph of the repository.
70+ Maintain(ctx context.Context, name domain.RepoName) error
71+}
72+
73+// HTTPBackend tells how to run `git http-backend` for one request.
74+type HTTPBackend struct {
75+ // Path is the git-http-backend executable.
76+ Path string
77+ // Dir is the working directory of the process.
78+ Dir string
79+ // Env is the complete environment of the process: the isolated Git
80+ // environment, GIT_PROJECT_ROOT and the Git configuration of the request
81+ // (GIT_CONFIG_COUNT/KEY/VALUE). The caller may append to it.
82+ Env []string
83+}
84+
85+// GitBackend provides the Git side of smart HTTP.
86+type GitBackend interface {
87+ // HTTPBackend returns how to run git http-backend. For a push it also
88+ // enables http.receivepack and sets REMOTE_USER; a read request cannot
89+ // reach receive-pack.
90+ HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
6391 }
6492
6593 // TokenStore keeps the stored tokens and the index from secret hash to token.
@@ -81,6 +109,17 @@ type TokenStore interface {
81109 Touch(ctx context.Context, id string, at time.Time) error
82110 }
83111
112+// EventBus fans events out to in-process subscribers.
113+type EventBus interface {
114+ // Publish hands the event to every subscriber without blocking. A
115+ // subscriber that cannot keep up loses events and receives a
116+ // domain.EventGap.
117+ Publish(ctx context.Context, ev domain.Event)
118+ // Subscribe returns a channel of events published from now on. The
119+ // channel is closed when ctx ends.
120+ Subscribe(ctx context.Context) <-chan domain.Event
121+}
122+
84123 // Clock tells the time.
85124 type Clock interface {
86125 Now() time.Time
@@ -60,6 +60,34 @@ type GitRepos interface {
60 // Trash moves the repository directory to the trash, where it can be60 // Trash moves the repository directory to the trash, where it can be
61 // recovered by hand. It returns domain.ErrNotFound if there is none.61 // recovered by hand. It returns domain.ErrNotFound if there is none.
62 Trash(ctx context.Context, name domain.RepoName) error62 Trash(ctx context.Context, name domain.RepoName) error
63+ // DefaultBranch returns the branch HEAD points to (without refs/heads/)
64+ // and whether that branch exists. It returns domain.ErrNotFound if there
65+ // is no such repository.
66+ DefaultBranch(ctx context.Context, name domain.RepoName) (branch string, exists bool, err error)
67+ // SetDefaultBranch points HEAD to the branch, which need not exist yet.
68+ SetDefaultBranch(ctx context.Context, name domain.RepoName, branch string) error
69+ // Maintain refreshes the commit-graph of the repository.
70+ Maintain(ctx context.Context, name domain.RepoName) error
71+}
72+
73+// HTTPBackend tells how to run `git http-backend` for one request.
74+type HTTPBackend struct {
75+ // Path is the git-http-backend executable.
76+ Path string
77+ // Dir is the working directory of the process.
78+ Dir string
79+ // Env is the complete environment of the process: the isolated Git
80+ // environment, GIT_PROJECT_ROOT and the Git configuration of the request
81+ // (GIT_CONFIG_COUNT/KEY/VALUE). The caller may append to it.
82+ Env []string
83+}
84+
85+// GitBackend provides the Git side of smart HTTP.
86+type GitBackend interface {
87+ // HTTPBackend returns how to run git http-backend. For a push it also
88+ // enables http.receivepack and sets REMOTE_USER; a read request cannot
89+ // reach receive-pack.
90+ HTTPBackend(ctx context.Context, push bool) (HTTPBackend, error)
63 }91 }
64 92
65 // TokenStore keeps the stored tokens and the index from secret hash to token.93 // TokenStore keeps the stored tokens and the index from secret hash to token.
@@ -81,6 +109,17 @@ type TokenStore interface {
81 Touch(ctx context.Context, id string, at time.Time) error109 Touch(ctx context.Context, id string, at time.Time) error
82 }110 }
83 111
112+// EventBus fans events out to in-process subscribers.
113+type EventBus interface {
114+ // Publish hands the event to every subscriber without blocking. A
115+ // subscriber that cannot keep up loses events and receives a
116+ // domain.EventGap.
117+ Publish(ctx context.Context, ev domain.Event)
118+ // Subscribe returns a channel of events published from now on. The
119+ // channel is closed when ctx ends.
120+ Subscribe(ctx context.Context) <-chan domain.Event
121+}
122+
84 // Clock tells the time.123 // Clock tells the time.
85 type Clock interface {124 type Clock interface {
86 Now() time.Time125 Now() time.Time

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 45

↑↓ moveenter openesc close