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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +1924 -1
M .mockery.yml +2 -0
| @@ -12,3 +12,5 @@ packages: | |||
| 12 | 12 | GitRepos: | |
| 13 | 13 | Clock: | |
| 14 | 14 | 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 ( | |||
| 13 | 13 | "os/exec" | |
| 14 | 14 | "path/filepath" | |
| 15 | 15 | "strings" | |
| 16 | + | "sync" | |
| 16 | 17 | "time" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -34,6 +35,9 @@ type Git struct { | |||
| 34 | 35 | bin string | |
| 35 | 36 | dataDir string | |
| 36 | 37 | env []string | |
| 38 | + | ||
| 39 | + | mu sync.Mutex // guards backend | |
| 40 | + | backend string // git-http-backend, resolved on first use | |
| 37 | 41 | } | |
| 38 | 42 | ||
| 39 | 43 | // 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 string | 35 | bin string | ||
| 35 | dataDir string | 36 | dataDir string | ||
| 36 | env []string | 37 | 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 { | |||
| 211 | 211 | defer func() { _ = f.Close() }() | |
| 212 | 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 | + | } | |
| @@ -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 @@ | |||
| 2 | 2 | ||
| 3 | 3 | // Package httpserver is the HTTP entry of ermite: the router, the security | |
| 4 | 4 | // 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 | 6 | package httpserver | |
| 7 | 7 | ||
| 8 | 8 | import ( | |
| @@ -24,12 +24,22 @@ const ShutdownTimeout = 10 * time.Second | |||
| 24 | 24 | // Check reports whether a dependency works. A nil result means healthy. | |
| 25 | 25 | 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 | 35 | // Config configures a Server. | |
| 28 | 36 | type Config struct { | |
| 29 | 37 | // Addr is the listen address, "host:port". | |
| 30 | 38 | Addr string | |
| 31 | 39 | // HealthChecks run on every GET /-/health; one failure answers 503. | |
| 32 | 40 | HealthChecks []Check | |
| 41 | + | // Routes are added to the router next to /-/health. | |
| 42 | + | Routes []Route | |
| 33 | 43 | } | |
| 34 | 44 | ||
| 35 | 45 | // Server is the HTTP server of ermite. | |
| @@ -44,6 +54,9 @@ func New(cfg Config) *Server { | |||
| 44 | 54 | // "GET" patterns also match HEAD; other methods get 405 with Allow, and | |
| 45 | 55 | // unknown paths get 404. | |
| 46 | 56 | mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks)) | |
| 57 | + | for _, r := range cfg.Routes { | |
| 58 | + | mux.Handle(r.Pattern, r.Handler) | |
| 59 | + | } | |
| 47 | 60 | return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))} | |
| 48 | 61 | } | |
| 49 | 62 | ||
| @@ -2,7 +2,7 @@ | |||||
| 2 | 2 | ||||
| 3 | // Package httpserver is the HTTP entry of ermite: the router, the security | 3 | // Package httpserver is the HTTP entry of ermite: the router, the security | ||
| 4 | // headers, the request log and the graceful shutdown. Routes are added here | 4 | // 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 httpserver | 6 | 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) error | 25 | 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 string | 38 | 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 []Check | 40 | 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, and | 54 | // "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 ( | |||
| 22 | 22 | ErrUnauthenticated = errors.New("unauthenticated") | |
| 23 | 23 | // ErrForbidden means the caller is known but may not do this. | |
| 24 | 24 | ErrForbidden = errors.New("forbidden") | |
| 25 | + | // ErrRejected means a push was refused by policy. | |
| 26 | + | ErrRejected = errors.New("rejected") | |
| 25 | 27 | // ErrConflict means a concurrent change prevented the operation. | |
| 26 | 28 | ErrConflict = errors.New("conflict") | |
| 27 | 29 | ) | |
| @@ -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 { | |||
| 48 | 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 | 123 | // Discard provides a mock function for the type GitRepos | |
| 52 | 124 | func (_mock *GitRepos) Discard(ctx context.Context, id ports.StagedID) error { | |
| 53 | 125 | ret := _mock.Called(ctx, id) | |
| @@ -165,6 +237,63 @@ func (_c *GitRepos_List_Call) RunAndReturn(run func(ctx context.Context) (ports. | |||
| 165 | 237 | 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 | 297 | // Publish provides a mock function for the type GitRepos | |
| 169 | 298 | func (_mock *GitRepos) Publish(ctx context.Context, id ports.StagedID, name domain.RepoName) error { | |
| 170 | 299 | ret := _mock.Called(ctx, id, name) | |
| @@ -279,6 +408,69 @@ func (_c *GitRepos_PurgeStaging_Call) RunAndReturn(run func(ctx context.Context) | |||
| 279 | 408 | 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 | 474 | // Stage provides a mock function for the type GitRepos | |
| 283 | 475 | func (_mock *GitRepos) Stage(ctx context.Context) (ports.StagedID, error) { | |
| 284 | 476 | 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 GitRepos | 123 | // 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 _c | 237 | 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 GitRepos | 297 | // 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 _c | 408 | 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 GitRepos | 474 | // 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 { | |||
| 60 | 60 | // Trash moves the repository directory to the trash, where it can be | |
| 61 | 61 | // recovered by hand. It returns domain.ErrNotFound if there is none. | |
| 62 | 62 | 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 | 93 | // TokenStore keeps the stored tokens and the index from secret hash to token. | |
| @@ -81,6 +109,17 @@ type TokenStore interface { | |||
| 81 | 109 | 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 | 123 | // Clock tells the time. | |
| 85 | 124 | type Clock interface { | |
| 86 | 125 | Now() time.Time | |
| @@ -60,6 +60,34 @@ type GitRepos interface { | |||||
| 60 | // Trash moves the repository directory to the trash, where it can be | 60 | // 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) error | 62 | 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) error | 109 | 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.Time | 125 | Now() time.Time | ||