vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Move writes to SSH, drop web authentication, keep identity in git
a5f19b3c19861e080953096f934df25dc38beaf7 · Keenan Nemetz · 18d ago · parent 653e0cfc
Move writes to SSH, drop web authentication, keep identity in git The authenticated-HTTP design is gone rather than extended. Writes now require an SSH key, the web is read-only, and the user roster lives in git. What replaced what: - internal/sshserver is the only write transport. An embedded SSH server (golang.org/x/crypto/ssh) authenticates by public key and hands the principal to pre-receive through the environment, so the hook's policy code is unchanged. The exec surface is whitelisted to git-upload-pack and git-receive-pack, the repository argument is resolved through the store rather than concatenated, and everything else -- including `sh -c` -- is refused and asserted refused in a test. - internal/identity stores users on refs/heads/main in data/system.git. The directory listing is the table: users/<name>/keys/<sha>.pub. Writes are CAS commits, reads come from an in-memory snapshot so the SSH auth callback is a map lookup. Nothing stored is a secret, so a clone of system.git is a complete backup and git log on it is the audit trail. - internal/transport is now anonymous read-only: upload-pack behind a visibility check, 404 for private repositories, and no push path at all. - internal/web has no login, no sessions and no mutations. Deleted outright: internal/auth, the Basic-auth path, login/logout, web settings/rename/delete, chunked body spooling, the spool janitor, the push semaphore and the hand-rolled byte cap. The cap is now git's receive.maxInputSize per repo, which enforces it on every transport without buffering. Three things worth recording: - The CAS helper read the tree outside the retry loop and compared against a freshly-read old value, so a concurrent writer's commit could be silently discarded while the CAS "succeeded". A test with two stores adding eight keys at once found it losing half of them; read, mutate and compare now all happen inside the loop. - The roster moved from refs/vole/users to refs/heads/main because `git clone` only mirrors refs/heads/*: the documented backup command produced an instance with zero users. Storing on a branch also means git itself refuses to point the ref at a non-commit. - Exec refusals are answered on the channel rather than by rejecting the request. Rejecting made the client print "exec request failed on channel 0", so the reason was unreadable. Verified by hand as well as by test: init -> user -> add-key -> repo -> serve, push over SSH, anonymous HTTP clone, web browse, a private repo answering 404 on both anonymous surfaces while still cloning over SSH, a refused `sh -c` that prints why and executes nothing, and a plain clone of system.git yielding the whole roster with no side database present.
internal/server/integration_test.go +203−375
| @@ -1,26 +1,28 @@ | |||
| 1 | // End-to-end tests for Phase 1. These drive the real git binary against a real | ||
| 2 | // HTTP listener, because the thing being verified is the exit criteria: clone, | ||
| 3 | // push, refusal, and rebuild-from-refs. | ||
| 1 | // End-to-end tests for the read-only-web / SSH-write architecture. | ||
| 4 | 2 | package server_test | |
| 5 | 3 | ||
| 6 | 4 | import ( | |
| 7 | 5 | "bytes" | |
| 6 | "crypto/ed25519" | ||
| 7 | "crypto/rand" | ||
| 8 | "encoding/pem" | ||
| 8 | 9 | "fmt" | |
| 9 | 10 | "io" | |
| 10 | 11 | "log/slog" | |
| 12 | "net" | ||
| 11 | 13 | "net/http" | |
| 12 | "net/http/cookiejar" | ||
| 13 | 14 | "net/http/httptest" | |
| 14 | "net/url" | ||
| 15 | 15 | "os" | |
| 16 | 16 | "os/exec" | |
| 17 | 17 | "path/filepath" | |
| 18 | 18 | "strings" | |
| 19 | "sync" | ||
| 20 | 19 | "testing" | |
| 21 | "time" | ||
| 22 | 20 | ||
| 21 | "golang.org/x/crypto/ssh" | ||
| 22 | |||
| 23 | "github.com/rodent-software/vole/internal/acl" | ||
| 23 | 24 | "github.com/rodent-software/vole/internal/config" | |
| 25 | "github.com/rodent-software/vole/internal/identity" | ||
| 24 | 26 | "github.com/rodent-software/vole/internal/index" | |
| 25 | 27 | "github.com/rodent-software/vole/internal/server" | |
| 26 | 28 | ) | |
| @@ -28,7 +30,7 @@ import ( | |||
| 28 | 30 | var binPath string | |
| 29 | 31 | ||
| 30 | 32 | func TestMain(m *testing.M) { | |
| 31 | dir, err := os.MkdirTemp("", "vole-bin-") | ||
| 33 | dir, err := os.MkdirTemp("", "vole-int-bin-") | ||
| 32 | 34 | if err != nil { | |
| 33 | 35 | fmt.Fprintln(os.Stderr, err) | |
| 34 | 36 | os.Exit(1) | |
| @@ -45,66 +47,66 @@ func TestMain(m *testing.M) { | |||
| 45 | 47 | os.Exit(code) | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | // ---------- harness ---------- | ||
| 49 | |||
| 50 | 50 | type fixture struct { | |
| 51 | t *testing.T | ||
| 52 | cfg *config.Config | ||
| 53 | srv *server.Server | ||
| 54 | ts *httptest.Server | ||
| 55 | repo string | ||
| 56 | owner string | ||
| 57 | ownerTok string | ||
| 58 | reader string | ||
| 59 | readerTok string | ||
| 60 | writer string | ||
| 61 | writerTok string | ||
| 62 | closed bool | ||
| 51 | t *testing.T | ||
| 52 | cfg *config.Config | ||
| 53 | srv *server.Server | ||
| 54 | http *httptest.Server | ||
| 55 | sshAddr string | ||
| 56 | keyPath string | ||
| 57 | keyPub string | ||
| 58 | repo string | ||
| 63 | 59 | } | |
| 64 | 60 | ||
| 65 | func newFixture(t *testing.T, mutate func(*config.Config)) *fixture { | ||
| 61 | func newFixture(t *testing.T) *fixture { | ||
| 66 | 62 | t.Helper() | |
| 67 | 63 | cfg, err := config.Load("") | |
| 68 | 64 | if err != nil { | |
| 69 | 65 | t.Fatal(err) | |
| 70 | 66 | } | |
| 71 | 67 | cfg.DataDir = t.TempDir() | |
| 72 | cfg.SessionTTLDur = time.Hour | ||
| 73 | cfg.QueueTimeoutDur = 30 * time.Second | ||
| 74 | if mutate != nil { | ||
| 75 | mutate(cfg) | ||
| 68 | if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil { | ||
| 69 | t.Fatal(err) | ||
| 76 | 70 | } | |
| 77 | log := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) | ||
| 78 | srv, err := server.New(cfg, binPath, log) | ||
| 71 | if err := identity.Init(cfg.SystemRepo()); err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | pub, priv := keypair(t, filepath.Join(cfg.DataDir, "id_test")) | ||
| 75 | ident, err := identity.Open(cfg.SystemRepo()) | ||
| 79 | 76 | if err != nil { | |
| 80 | 77 | t.Fatal(err) | |
| 81 | 78 | } | |
| 82 | f := &fixture{ | ||
| 83 | t: t, cfg: cfg, srv: srv, | ||
| 84 | ts: httptest.NewServer(srv.Handler()), | ||
| 85 | repo: "demo", | ||
| 86 | owner: "owner", reader: "reader", writer: "writer", | ||
| 79 | if err := ident.Create("keenan"); err != nil { | ||
| 80 | t.Fatal(err) | ||
| 87 | 81 | } | |
| 88 | t.Cleanup(f.Close) | ||
| 89 | |||
| 90 | f.ownerTok = f.addUser(f.owner, "adminpw-123") | ||
| 91 | f.readerTok = f.addUser(f.reader, "readerpw-123") | ||
| 92 | f.writerTok = f.addUser(f.writer, "writerpw-123") | ||
| 93 | |||
| 94 | m, err := srv.Repos.Create(f.repo, f.owner, "demo repo") | ||
| 95 | if err != nil { | ||
| 82 | if _, err := ident.AddKey("keenan", pub, "test"); err != nil { | ||
| 96 | 83 | t.Fatal(err) | |
| 97 | 84 | } | |
| 98 | if err := srv.Index.Upsert(m); err != nil { | ||
| 85 | |||
| 86 | log := slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) | ||
| 87 | srv, err := server.New(cfg, binPath, log) | ||
| 88 | if err != nil { | ||
| 99 | 89 | t.Fatal(err) | |
| 100 | 90 | } | |
| 101 | a, err := m.ACL() | ||
| 91 | ln, err := net.Listen("tcp", "127.0.0.1:0") | ||
| 102 | 92 | if err != nil { | |
| 103 | 93 | t.Fatal(err) | |
| 104 | 94 | } | |
| 105 | _ = a.SetEntry(f.reader, "read") | ||
| 106 | _ = a.SetEntry(f.writer, "write") | ||
| 107 | if err := m.SetACL(a, "test"); err != nil { | ||
| 95 | go srv.SSH.Serve(ln) | ||
| 96 | ts := httptest.NewServer(srv.Handler()) | ||
| 97 | |||
| 98 | f := &fixture{ | ||
| 99 | t: t, cfg: cfg, srv: srv, http: ts, sshAddr: ln.Addr().String(), | ||
| 100 | keyPath: priv, keyPub: pub, repo: "demo", | ||
| 101 | } | ||
| 102 | t.Cleanup(func() { | ||
| 103 | ts.Close() | ||
| 104 | srv.SSH.Close() | ||
| 105 | srv.Close() | ||
| 106 | }) | ||
| 107 | |||
| 108 | m, err := srv.Repos.Create(f.repo, "keenan", "demo repo") | ||
| 109 | if err != nil { | ||
| 108 | 110 | t.Fatal(err) | |
| 109 | 111 | } | |
| 110 | 112 | if err := srv.Index.Upsert(m); err != nil { | |
| @@ -113,44 +115,43 @@ func newFixture(t *testing.T, mutate func(*config.Config)) *fixture { | |||
| 113 | 115 | return f | |
| 114 | 116 | } | |
| 115 | 117 | ||
| 116 | func (f *fixture) addUser(name, pw string) string { | ||
| 117 | f.t.Helper() | ||
| 118 | u, err := f.srv.Auth.CreateUser(name, pw) | ||
| 118 | func keypair(t *testing.T, path string) (pubLine, privPath string) { | ||
| 119 | t.Helper() | ||
| 120 | pub, priv, err := ed25519.GenerateKey(rand.Reader) | ||
| 119 | 121 | if err != nil { | |
| 120 | f.t.Fatal(err) | ||
| 122 | t.Fatal(err) | ||
| 121 | 123 | } | |
| 122 | tok, _, err := f.srv.Auth.CreateToken(u.ID, "test", "write", time.Hour) | ||
| 124 | blk, err := ssh.MarshalPrivateKey(priv, "test") | ||
| 123 | 125 | if err != nil { | |
| 124 | f.t.Fatal(err) | ||
| 126 | t.Fatal(err) | ||
| 125 | 127 | } | |
| 126 | return tok | ||
| 128 | if err := os.WriteFile(path, pem.EncodeToMemory(blk), 0o600); err != nil { | ||
| 129 | t.Fatal(err) | ||
| 130 | } | ||
| 131 | sshPub, err := ssh.NewPublicKey(pub) | ||
| 132 | if err != nil { | ||
| 133 | t.Fatal(err) | ||
| 134 | } | ||
| 135 | return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub))), path | ||
| 127 | 136 | } | |
| 128 | 137 | ||
| 129 | func (f *fixture) Close() { | ||
| 130 | if f.closed { | ||
| 131 | return | ||
| 132 | } | ||
| 133 | f.closed = true | ||
| 134 | f.ts.Close() | ||
| 135 | f.srv.Close() | ||
| 138 | func (f *fixture) sshURL(name string) string { | ||
| 139 | return "ssh://git@" + f.sshAddr + "/" + name + ".git" | ||
| 136 | 140 | } | |
| 137 | 141 | ||
| 138 | func (f *fixture) urlAs(tok string) string { | ||
| 139 | u, _ := url.Parse(f.ts.URL) | ||
| 140 | u.User = url.UserPassword("x", tok) | ||
| 141 | u.Path = "/git/" + f.repo + ".git" | ||
| 142 | return u.String() | ||
| 142 | func (f *fixture) httpURL(name string) string { | ||
| 143 | return f.http.URL + "/git/" + name + ".git" | ||
| 143 | 144 | } | |
| 144 | 145 | ||
| 145 | func gitRun(t *testing.T, dir string, args ...string) (string, string, error) { | ||
| 146 | t.Helper() | ||
| 146 | func (f *fixture) git(dir string, args ...string) (string, string, error) { | ||
| 147 | f.t.Helper() | ||
| 147 | 148 | cmd := exec.Command("git", args...) | |
| 148 | 149 | cmd.Dir = dir | |
| 149 | 150 | cmd.Env = append(os.Environ(), | |
| 150 | 151 | "GIT_TERMINAL_PROMPT=0", | |
| 151 | "GIT_ASKPASS=/bin/true", | ||
| 152 | 152 | "GIT_CONFIG_GLOBAL=/dev/null", | |
| 153 | 153 | "GIT_CONFIG_SYSTEM=/dev/null", | |
| 154 | "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR", | ||
| 154 | 155 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", | |
| 155 | 156 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", | |
| 156 | 157 | ) | |
| @@ -160,12 +161,11 @@ func gitRun(t *testing.T, dir string, args ...string) (string, string, error) { | |||
| 160 | 161 | return out.String(), errb.String(), err | |
| 161 | 162 | } | |
| 162 | 163 | ||
| 163 | // workRepo creates a local repo with one commit and returns its path. | ||
| 164 | 164 | func workRepo(t *testing.T, files map[string]string) string { | |
| 165 | 165 | t.Helper() | |
| 166 | 166 | dir := t.TempDir() | |
| 167 | if _, _, err := gitRun(t, dir, "init", "-q", "-b", "main"); err != nil { | ||
| 168 | t.Fatal(err) | ||
| 167 | if _, errs, err := runGit(t, dir, "init", "-q", "-b", "main"); err != nil { | ||
| 168 | t.Fatalf("init: %v: %s", err, errs) | ||
| 169 | 169 | } | |
| 170 | 170 | for name, content := range files { | |
| 171 | 171 | p := filepath.Join(dir, name) | |
| @@ -176,147 +176,145 @@ func workRepo(t *testing.T, files map[string]string) string { | |||
| 176 | 176 | t.Fatal(err) | |
| 177 | 177 | } | |
| 178 | 178 | } | |
| 179 | if _, _, err := gitRun(t, dir, "add", "-A"); err != nil { | ||
| 180 | t.Fatal(err) | ||
| 179 | if _, errs, err := runGit(t, dir, "add", "-A"); err != nil { | ||
| 180 | t.Fatalf("add: %v: %s", err, errs) | ||
| 181 | 181 | } | |
| 182 | if _, errs, err := gitRun(t, dir, "commit", "-q", "-m", "initial"); err != nil { | ||
| 182 | if _, errs, err := runGit(t, dir, "commit", "-qm", "initial"); err != nil { | ||
| 183 | 183 | t.Fatalf("commit: %v: %s", err, errs) | |
| 184 | 184 | } | |
| 185 | 185 | return dir | |
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | func (f *fixture) push(t *testing.T, work, refspec, tok string, extra ...string) (string, string, error) { | ||
| 188 | func runGit(t *testing.T, dir string, args ...string) (string, string, error) { | ||
| 189 | 189 | t.Helper() | |
| 190 | args := append([]string{"push"}, extra...) | ||
| 191 | args = append(args, f.urlAs(tok), refspec) | ||
| 192 | return gitRun(t, work, args...) | ||
| 190 | cmd := exec.Command("git", args...) | ||
| 191 | cmd.Dir = dir | ||
| 192 | cmd.Env = append(os.Environ(), | ||
| 193 | "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", | ||
| 194 | "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com", | ||
| 195 | "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com", | ||
| 196 | ) | ||
| 197 | var out, errb bytes.Buffer | ||
| 198 | cmd.Stdout, cmd.Stderr = &out, &errb | ||
| 199 | err := cmd.Run() | ||
| 200 | return out.String(), errb.String(), err | ||
| 193 | 201 | } | |
| 194 | 202 | ||
| 195 | func (f *fixture) serverRef(ref string) string { | ||
| 196 | out, _, err := gitRun(f.t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), "rev-parse", "--verify", "--quiet", ref) | ||
| 203 | func (f *fixture) publish(name string) { | ||
| 204 | f.t.Helper() | ||
| 205 | m, err := f.srv.Repos.Open(name) | ||
| 206 | if err != nil { | ||
| 207 | f.t.Fatal(err) | ||
| 208 | } | ||
| 209 | a, err := m.ACL() | ||
| 197 | 210 | if err != nil { | |
| 198 | return "" | ||
| 211 | f.t.Fatal(err) | ||
| 212 | } | ||
| 213 | a.Default = "read" | ||
| 214 | if err := m.SetACL(a, "test"); err != nil { | ||
| 215 | f.t.Fatal(err) | ||
| 216 | } | ||
| 217 | if err := f.srv.Index.Upsert(m); err != nil { | ||
| 218 | f.t.Fatal(err) | ||
| 199 | 219 | } | |
| 200 | return strings.TrimSpace(out) | ||
| 201 | 220 | } | |
| 202 | 221 | ||
| 203 | // ---------- tests ---------- | ||
| 222 | func (f *fixture) get(path string) (int, string) { | ||
| 223 | f.t.Helper() | ||
| 224 | resp, err := http.Get(f.http.URL + path) | ||
| 225 | if err != nil { | ||
| 226 | f.t.Fatal(err) | ||
| 227 | } | ||
| 228 | defer resp.Body.Close() | ||
| 229 | body, _ := io.ReadAll(resp.Body) | ||
| 230 | return resp.StatusCode, string(body) | ||
| 231 | } | ||
| 204 | 232 | ||
| 205 | func TestPushThenClone(t *testing.T) { | ||
| 206 | f := newFixture(t, nil) | ||
| 207 | work := workRepo(t, map[string]string{"readme.md": "# hello\n", "docs/a.txt": "a\n"}) | ||
| 233 | // TestPushOverSSHThenReadOverBothTransports is the core loop: writes need a key, | ||
| 234 | // reads do not. | ||
| 235 | func TestPushOverSSHThenReadOverBothTransports(t *testing.T) { | ||
| 236 | f := newFixture(t) | ||
| 237 | f.publish(f.repo) | ||
| 208 | 238 | ||
| 209 | if _, errs, err := f.push(t, work, "main:refs/heads/main", f.ownerTok); err != nil { | ||
| 210 | t.Fatalf("push failed: %v\n%s", err, errs) | ||
| 211 | } | ||
| 212 | if f.serverRef("refs/heads/main") == "" { | ||
| 213 | t.Fatal("server has no refs/heads/main after a successful push") | ||
| 239 | work := workRepo(t, map[string]string{"readme.md": "# hello from vole\n"}) | ||
| 240 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 241 | t.Fatalf("push: %v\n%s", err, errs) | ||
| 214 | 242 | } | |
| 215 | 243 | ||
| 216 | // A read-only user must be able to clone. | ||
| 244 | // Anonymous HTTP clone must work once the repo is public. | ||
| 217 | 245 | dst := filepath.Join(t.TempDir(), "clone") | |
| 218 | if _, errs, err := gitRun(t, "", "clone", "-q", f.urlAs(f.readerTok), dst); err != nil { | ||
| 219 | t.Fatalf("clone as reader failed: %v\n%s", err, errs) | ||
| 246 | if _, errs, err := runGit(t, "", "clone", "-q", f.httpURL(f.repo), dst); err != nil { | ||
| 247 | t.Fatalf("anonymous http clone: %v\n%s", err, errs) | ||
| 220 | 248 | } | |
| 221 | 249 | got, err := os.ReadFile(filepath.Join(dst, "readme.md")) | |
| 222 | 250 | if err != nil { | |
| 223 | 251 | t.Fatal(err) | |
| 224 | 252 | } | |
| 225 | if string(got) != "# hello\n" { | ||
| 253 | if string(got) != "# hello from vole\n" { | ||
| 226 | 254 | t.Fatalf("cloned content = %q", got) | |
| 227 | 255 | } | |
| 228 | } | ||
| 229 | |||
| 230 | func TestReadOnlyPushRejectedAndRefUnmoved(t *testing.T) { | ||
| 231 | f := newFixture(t, nil) | ||
| 232 | work := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 233 | if _, errs, err := f.push(t, work, "main:refs/heads/main", f.ownerTok); err != nil { | ||
| 234 | t.Fatalf("owner push failed: %s", errs) | ||
| 235 | } | ||
| 236 | before := f.serverRef("refs/heads/main") | ||
| 237 | 256 | ||
| 238 | if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("v2\n"), 0o644); err != nil { | ||
| 239 | t.Fatal(err) | ||
| 240 | } | ||
| 241 | if _, _, err := gitRun(t, work, "commit", "-aqm", "v2"); err != nil { | ||
| 242 | t.Fatal(err) | ||
| 257 | // The web shows it without any login. | ||
| 258 | if code, body := f.get("/r/" + f.repo); code != 200 || !strings.Contains(body, "readme.md") { | ||
| 259 | t.Fatalf("repo page: %d", code) | ||
| 243 | 260 | } | |
| 244 | _, errs, err := f.push(t, work, "main:refs/heads/main", f.readerTok) | ||
| 245 | if err == nil { | ||
| 246 | t.Fatal("read-only push succeeded") | ||
| 261 | if code, body := f.get("/r/" + f.repo + "/blob/main/readme.md"); code != 200 || !strings.Contains(body, "hello from vole") { | ||
| 262 | t.Fatalf("blob page: %d", code) | ||
| 247 | 263 | } | |
| 248 | if !strings.Contains(errs, "does not have write access") { | ||
| 249 | t.Fatalf("rejection reason not shown to the client:\n%s", errs) | ||
| 250 | } | ||
| 251 | if after := f.serverRef("refs/heads/main"); after != before { | ||
| 252 | t.Fatalf("ref moved on a rejected push: %s -> %s", before, after) | ||
| 264 | if code, _ := f.get("/"); code != 200 { | ||
| 265 | t.Fatalf("index: %d", code) | ||
| 253 | 266 | } | |
| 254 | 267 | } | |
| 255 | 268 | ||
| 256 | func TestAnonymousPushRejected(t *testing.T) { | ||
| 257 | f := newFixture(t, nil) | ||
| 258 | work := workRepo(t, map[string]string{"a.txt": "a\n"}) | ||
| 259 | u, _ := url.Parse(f.ts.URL) | ||
| 260 | u.Path = "/git/" + f.repo + ".git" | ||
| 261 | _, _, err := gitRun(t, work, "push", u.String(), "main:refs/heads/main") | ||
| 262 | if err == nil { | ||
| 263 | t.Fatal("anonymous push succeeded") | ||
| 264 | } | ||
| 265 | if f.serverRef("refs/heads/main") != "" { | ||
| 266 | t.Fatal("anonymous push created a ref") | ||
| 269 | // TestPrivateRepoIsInvisible covers the visibility rule: private means absent, | ||
| 270 | // not forbidden. | ||
| 271 | func TestPrivateRepoIsInvisible(t *testing.T) { | ||
| 272 | f := newFixture(t) | ||
| 273 | work := workRepo(t, map[string]string{"secret.md": "classified\n"}) | ||
| 274 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 275 | t.Fatalf("push: %v\n%s", err, errs) | ||
| 267 | 276 | } | |
| 268 | } | ||
| 269 | 277 | ||
| 270 | func TestMetadataRefPushRejected(t *testing.T) { | ||
| 271 | f := newFixture(t, nil) | ||
| 272 | work := workRepo(t, map[string]string{"acl.json": `{"version":1,"entries":{"attacker":"admin"}}`}) | ||
| 273 | _, errs, err := f.push(t, work, "main:refs/vole/acl", f.ownerTok, "--force") | ||
| 274 | if err == nil { | ||
| 275 | t.Fatal("owner was allowed to overwrite the ACL ref") | ||
| 278 | for _, path := range []string{"/r/" + f.repo, "/r/" + f.repo + "/blob/main/secret.md"} { | ||
| 279 | if code, _ := f.get(path); code != http.StatusNotFound { | ||
| 280 | t.Fatalf("%s: status %d, want 404", path, code) | ||
| 281 | } | ||
| 276 | 282 | } | |
| 277 | if !strings.Contains(errs, "server-owned") { | ||
| 278 | t.Fatalf("client not told why:\n%s", errs) | ||
| 283 | if _, body := f.get("/"); strings.Contains(body, f.repo) { | ||
| 284 | t.Fatal("private repo was listed on the index page") | ||
| 285 | } | ||
| 286 | // And anonymous git must not see it either. | ||
| 287 | if _, _, err := runGit(t, "", "clone", "x", f.httpURL(f.repo), filepath.Join(t.TempDir(), "p")); err == nil { | ||
| 288 | // Clone of an empty/nonexistent repo may exit 0 with a warning; check the | ||
| 289 | // HTTP layer directly instead. | ||
| 290 | resp, err := http.Get(f.http.URL + "/git/" + f.repo + ".git/info/refs?service=git-upload-pack") | ||
| 291 | if err == nil { | ||
| 292 | defer resp.Body.Close() | ||
| 293 | if resp.StatusCode != http.StatusNotFound { | ||
| 294 | t.Fatalf("anonymous http on a private repo: %d", resp.StatusCode) | ||
| 295 | } | ||
| 296 | } | ||
| 279 | 297 | } | |
| 280 | if f.serverRef("refs/vole/acl") == "" { | ||
| 281 | t.Fatal("the original ACL ref disappeared") | ||
| 298 | |||
| 299 | // The owner can still clone it over SSH, because that path is authenticated. | ||
| 300 | dst := filepath.Join(t.TempDir(), "own-clone") | ||
| 301 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), dst); err != nil { | ||
| 302 | t.Fatalf("owner ssh clone of a private repo: %v\n%s", err, errs) | ||
| 282 | 303 | } | |
| 283 | 304 | } | |
| 284 | 305 | ||
| 285 | func TestProtectedBranchRejectsWriter(t *testing.T) { | ||
| 286 | f := newFixture(t, nil) | ||
| 306 | func TestPushOverHTTPRefused(t *testing.T) { | ||
| 307 | f := newFixture(t) | ||
| 308 | f.publish(f.repo) | ||
| 287 | 309 | work := workRepo(t, map[string]string{"a.txt": "a\n"}) | |
| 288 | if _, errs, err := f.push(t, work, "main:refs/heads/main", f.ownerTok); err != nil { | ||
| 289 | t.Fatalf("owner push: %s", errs) | ||
| 290 | } | ||
| 291 | |||
| 292 | m, err := f.srv.Repos.Open(f.repo) | ||
| 293 | if err != nil { | ||
| 294 | t.Fatal(err) | ||
| 295 | } | ||
| 296 | a, _ := m.ACL() | ||
| 297 | a.Protected = []string{"refs/heads/main"} | ||
| 298 | if err := m.SetACL(a, "test"); err != nil { | ||
| 299 | t.Fatal(err) | ||
| 300 | } | ||
| 301 | |||
| 302 | late := workRepo(t, map[string]string{"b.txt": "b\n"}) | ||
| 303 | _, errs, err := f.push(t, late, "main:refs/heads/main", f.writerTok, "--force") | ||
| 304 | if err == nil { | ||
| 305 | t.Fatal("writer pushed a protected branch") | ||
| 306 | } | ||
| 307 | if !strings.Contains(errs, "protected") { | ||
| 308 | t.Fatalf("rejection reason not shown:\n%s", errs) | ||
| 310 | if _, _, err := runGit(t, work, "push", f.httpURL(f.repo), "main:refs/heads/main"); err == nil { | ||
| 311 | t.Fatal("push over HTTP succeeded") | ||
| 309 | 312 | } | |
| 310 | 313 | } | |
| 311 | 314 | ||
| 312 | // TestIndexRebuildReproducesState is the exit criterion that proves the index is | ||
| 313 | // derived: delete it, rebuild from disk and refs, compare. | ||
| 314 | func TestIndexRebuildReproducesState(t *testing.T) { | ||
| 315 | f := newFixture(t, nil) | ||
| 316 | work := workRepo(t, map[string]string{"readme.md": "hello\n"}) | ||
| 317 | if _, errs, err := f.push(t, work, "main:refs/heads/main", f.ownerTok); err != nil { | ||
| 318 | t.Fatalf("push: %s", errs) | ||
| 319 | } | ||
| 315 | func TestRebuildIndexAfterDelete(t *testing.T) { | ||
| 316 | f := newFixture(t) | ||
| 317 | f.publish(f.repo) | ||
| 320 | 318 | before, err := f.srv.Index.List() | |
| 321 | 319 | if err != nil { | |
| 322 | 320 | t.Fatal(err) | |
| @@ -325,20 +323,9 @@ func TestIndexRebuildReproducesState(t *testing.T) { | |||
| 325 | 323 | t.Fatalf("want 1 indexed repo, got %d", len(before)) | |
| 326 | 324 | } | |
| 327 | 325 | ||
| 328 | // Mutate the ACL through git only, so a rebuild must read the ref to see it. | ||
| 329 | m, err := f.srv.Repos.Open(f.repo) | ||
| 330 | if err != nil { | ||
| 331 | t.Fatal(err) | ||
| 332 | } | ||
| 333 | a, _ := m.ACL() | ||
| 334 | if err := a.SetEntry("newcomer", "read"); err != nil { | ||
| 335 | t.Fatal(err) | ||
| 336 | } | ||
| 337 | if err := m.SetACL(a, "test"); err != nil { | ||
| 338 | t.Fatal(err) | ||
| 339 | } | ||
| 340 | |||
| 341 | f.Close() | ||
| 326 | f.srv.SSH.Close() | ||
| 327 | f.http.Close() | ||
| 328 | f.srv.Close() | ||
| 342 | 329 | for _, p := range []string{f.cfg.IndexDB(), f.cfg.IndexDB() + "-wal", f.cfg.IndexDB() + "-shm"} { | |
| 343 | 330 | if err := os.Remove(p); err != nil && !os.IsNotExist(err) { | |
| 344 | 331 | t.Fatal(err) | |
| @@ -349,14 +336,11 @@ func TestIndexRebuildReproducesState(t *testing.T) { | |||
| 349 | 336 | if err := os.WriteFile(cfgPath, []byte(fmt.Sprintf("data_dir = %q\n", f.cfg.DataDir)), 0o644); err != nil { | |
| 350 | 337 | t.Fatal(err) | |
| 351 | 338 | } | |
| 352 | out, errs, err := execBinary(binPath, "index", "rebuild", "-config", cfgPath) | ||
| 339 | cmd := exec.Command(binPath, "index", "rebuild", "-config", cfgPath) | ||
| 340 | out, err := cmd.CombinedOutput() | ||
| 353 | 341 | if err != nil { | |
| 354 | t.Fatalf("rebuild: %v\n%s\n%s", err, out, errs) | ||
| 355 | } | ||
| 356 | if !strings.Contains(out, "1 repo") { | ||
| 357 | t.Fatalf("unexpected rebuild output: %s", out) | ||
| 342 | t.Fatalf("rebuild: %v\n%s", err, out) | ||
| 358 | 343 | } | |
| 359 | |||
| 360 | 344 | ix, err := index.Open(f.cfg.IndexDB()) | |
| 361 | 345 | if err != nil { | |
| 362 | 346 | t.Fatal(err) | |
| @@ -366,206 +350,50 @@ func TestIndexRebuildReproducesState(t *testing.T) { | |||
| 366 | 350 | if err != nil { | |
| 367 | 351 | t.Fatal(err) | |
| 368 | 352 | } | |
| 369 | if len(after) != 1 { | ||
| 370 | t.Fatalf("want 1 repo after rebuild, got %d", len(after)) | ||
| 371 | } | ||
| 372 | if after[0].Name != before[0].Name || after[0].ID != before[0].ID { | ||
| 373 | t.Fatalf("identity changed across rebuild: %+v vs %+v", before[0], after[0]) | ||
| 374 | } | ||
| 375 | if after[0].ACLPrincipals != before[0].ACLPrincipals+1 { | ||
| 376 | t.Fatalf("rebuild did not pick up the new ACL entry: %d -> %d", | ||
| 377 | before[0].ACLPrincipals, after[0].ACLPrincipals) | ||
| 378 | } | ||
| 379 | if after[0].ACLSHA == before[0].ACLSHA { | ||
| 380 | t.Fatal("ACL sha unchanged: rebuild is not reading the ref") | ||
| 381 | } | ||
| 382 | refSHA := f.serverRef("refs/vole/acl") | ||
| 383 | if after[0].ACLSHA != refSHA { | ||
| 384 | t.Fatalf("indexed acl sha %s != ref %s", after[0].ACLSHA, refSHA) | ||
| 385 | } | ||
| 386 | } | ||
| 387 | |||
| 388 | func TestPushOverSizeCapRejected(t *testing.T) { | ||
| 389 | f := newFixture(t, func(c *config.Config) { c.MaxPushBytes = 4096 }) | ||
| 390 | work := workRepo(t, map[string]string{"big.bin": incompressible(64 << 10)}) | ||
| 391 | _, _, err := f.push(t, work, "main:refs/heads/main", f.ownerTok) | ||
| 392 | if err == nil { | ||
| 393 | t.Fatal("push over the size cap succeeded") | ||
| 394 | } | ||
| 395 | if f.serverRef("refs/heads/main") != "" { | ||
| 396 | t.Fatal("oversized push created a ref") | ||
| 397 | } | ||
| 398 | } | ||
| 399 | |||
| 400 | // TestChunkedPushSucceedsAndLeavesNoSpool forces the chunked-upload path with a | ||
| 401 | // tiny postBuffer, then checks the spool directory is clean. | ||
| 402 | func TestChunkedPushSucceedsAndLeavesNoSpool(t *testing.T) { | ||
| 403 | f := newFixture(t, nil) | ||
| 404 | work := workRepo(t, map[string]string{"data.txt": strings.Repeat("chunk me\n", 40000)}) | ||
| 405 | _, errs, err := gitRun(t, work, "-c", "http.postBuffer=1", "push", f.urlAs(f.ownerTok), "main:refs/heads/main") | ||
| 406 | if err != nil { | ||
| 407 | t.Fatalf("chunked push failed: %v\n%s", err, errs) | ||
| 408 | } | ||
| 409 | if f.serverRef("refs/heads/main") == "" { | ||
| 410 | t.Fatal("chunked push did not land") | ||
| 411 | } | ||
| 412 | ents, err := os.ReadDir(f.cfg.SpoolDir()) | ||
| 413 | if err != nil && !os.IsNotExist(err) { | ||
| 414 | t.Fatal(err) | ||
| 415 | } | ||
| 416 | for _, e := range ents { | ||
| 417 | if strings.HasPrefix(e.Name(), "vole-spool-") { | ||
| 418 | t.Fatalf("spool file left behind: %s", e.Name()) | ||
| 419 | } | ||
| 420 | } | ||
| 421 | } | ||
| 422 | |||
| 423 | // TestConcurrentPushesQueueRatherThanFail runs two pushes against a proxy with a | ||
| 424 | // single push slot. Both must complete: the second waits, it does not 503. | ||
| 425 | func TestConcurrentPushesQueueRatherThanFail(t *testing.T) { | ||
| 426 | f := newFixture(t, func(c *config.Config) { c.PushConcurrency = 1 }) | ||
| 427 | w1 := workRepo(t, map[string]string{"one.txt": strings.Repeat("1", 50000)}) | ||
| 428 | w2 := workRepo(t, map[string]string{"two.txt": strings.Repeat("2", 50000)}) | ||
| 429 | |||
| 430 | var wg sync.WaitGroup | ||
| 431 | errs := make([]error, 2) | ||
| 432 | msgs := make([]string, 2) | ||
| 433 | for i, job := range []struct { | ||
| 434 | work string | ||
| 435 | ref string | ||
| 436 | }{{w1, "main:refs/heads/one"}, {w2, "main:refs/heads/two"}} { | ||
| 437 | wg.Add(1) | ||
| 438 | go func(i int, work, ref string) { | ||
| 439 | defer wg.Done() | ||
| 440 | _, m, err := f.push(t, work, ref, f.ownerTok) | ||
| 441 | errs[i], msgs[i] = err, m | ||
| 442 | }(i, job.work, job.ref) | ||
| 443 | } | ||
| 444 | wg.Wait() | ||
| 445 | for i, err := range errs { | ||
| 446 | if err != nil { | ||
| 447 | t.Fatalf("concurrent push %d failed: %v\n%s", i, err, msgs[i]) | ||
| 448 | } | ||
| 449 | } | ||
| 450 | if f.serverRef("refs/heads/one") == "" || f.serverRef("refs/heads/two") == "" { | ||
| 451 | t.Fatal("a queued push did not land") | ||
| 353 | if len(after) != len(before) || after[0].Name != before[0].Name || after[0].ID != before[0].ID { | ||
| 354 | t.Fatalf("rebuild mismatch: %+v vs %+v", before, after) | ||
| 452 | 355 | } | |
| 453 | 356 | } | |
| 454 | 357 | ||
| 455 | func TestWebLoginAndBrowse(t *testing.T) { | ||
| 456 | f := newFixture(t, nil) | ||
| 457 | work := workRepo(t, map[string]string{"readme.md": "# hi from vole\n"}) | ||
| 458 | if _, errs, err := f.push(t, work, "main:refs/heads/main", f.ownerTok); err != nil { | ||
| 358 | // TestIdentitySurvivesWithoutSideDatabase proves the roster really is git state: | ||
| 359 | // the whole auth store is two refs away from a fresh clone. | ||
| 360 | func TestIdentitySurvivesWithoutSideDatabase(t *testing.T) { | ||
| 361 | f := newFixture(t) | ||
| 362 | f.publish(f.repo) | ||
| 363 | work := workRepo(t, map[string]string{"readme.md": "x\n"}) | ||
| 364 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 459 | 365 | t.Fatalf("push: %s", errs) | |
| 460 | 366 | } | |
| 461 | 367 | ||
| 462 | jar, _ := cookiejar.New(nil) | ||
| 463 | client := &http.Client{ | ||
| 464 | Jar: jar, | ||
| 465 | CheckRedirect: func(req *http.Request, via []*http.Request) error { | ||
| 466 | return http.ErrUseLastResponse | ||
| 467 | }, | ||
| 468 | } | ||
| 469 | resp, err := client.Get(f.ts.URL + "/") | ||
| 470 | if err != nil { | ||
| 471 | t.Fatal(err) | ||
| 472 | } | ||
| 473 | resp.Body.Close() | ||
| 474 | if resp.StatusCode != http.StatusSeeOther { | ||
| 475 | t.Fatalf("unauthenticated index: status %d, want 303 to login", resp.StatusCode) | ||
| 368 | // Clone the system repo to a fresh location and read the roster from it. | ||
| 369 | dst := filepath.Join(t.TempDir(), "sys.git") | ||
| 370 | if out, err := exec.Command("git", "clone", "-q", "--bare", f.cfg.SystemRepo(), dst).CombinedOutput(); err != nil { | ||
| 371 | t.Fatalf("clone system repo: %v: %s", err, out) | ||
| 476 | 372 | } | |
| 477 | |||
| 478 | form := url.Values{"username": {f.owner}, "password": {"adminpw-123"}} | ||
| 479 | resp, err = client.PostForm(f.ts.URL+"/login", form) | ||
| 373 | ident, err := identity.Open(dst) | ||
| 480 | 374 | if err != nil { | |
| 481 | 375 | t.Fatal(err) | |
| 482 | 376 | } | |
| 483 | resp.Body.Close() | ||
| 484 | if resp.StatusCode != http.StatusSeeOther { | ||
| 485 | t.Fatalf("login: status %d", resp.StatusCode) | ||
| 377 | if _, ok := ident.Snapshot().Users["keenan"]; !ok { | ||
| 378 | t.Fatal("cloned system repo has no users") | ||
| 486 | 379 | } | |
| 487 | if len(jar.Cookies(mustURL(t, f.ts.URL))) == 0 { | ||
| 488 | t.Fatal("no session cookie was set") | ||
| 380 | if len(ident.Snapshot().ByFingerprint) != 1 { | ||
| 381 | t.Fatalf("cloned roster has %d keys", len(ident.Snapshot().ByFingerprint)) | ||
| 489 | 382 | } | |
| 490 | |||
| 491 | for _, c := range []struct{ path, want string }{ | ||
| 492 | {"/", "demo"}, | ||
| 493 | {"/r/demo", "Clone"}, | ||
| 494 | {"/r/demo/blob/main/readme.md", "hi from vole"}, | ||
| 495 | {"/r/demo/commits/main", "initial"}, | ||
| 496 | {"/r/demo/raw/main/readme.md", "# hi from vole"}, | ||
| 497 | } { | ||
| 498 | resp, err := client.Get(f.ts.URL + c.path) | ||
| 499 | if err != nil { | ||
| 500 | t.Fatal(err) | ||
| 501 | } | ||
| 502 | body, _ := io.ReadAll(resp.Body) | ||
| 503 | resp.Body.Close() | ||
| 504 | if resp.StatusCode != http.StatusOK { | ||
| 505 | t.Fatalf("%s: status %d", c.path, resp.StatusCode) | ||
| 506 | } | ||
| 507 | if !strings.Contains(string(body), c.want) { | ||
| 508 | t.Fatalf("%s: missing %q in body:\n%s", c.path, c.want, body) | ||
| 509 | } | ||
| 383 | if _, err := os.Stat(filepath.Join(f.cfg.DataDir, "auth.db")); err == nil { | ||
| 384 | t.Fatal("an auth.db exists; identity is supposed to live in git only") | ||
| 510 | 385 | } | |
| 511 | 386 | } | |
| 512 | 387 | ||
| 513 | func TestWebDeniesReadForStranger(t *testing.T) { | ||
| 514 | f := newFixture(t, nil) | ||
| 515 | strangerTok := f.addUser("stranger", "strangerpw-123") | ||
| 516 | _ = strangerTok | ||
| 517 | |||
| 518 | jar, _ := cookiejar.New(nil) | ||
| 519 | client := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { | ||
| 520 | return http.ErrUseLastResponse | ||
| 521 | }} | ||
| 522 | form := url.Values{"username": {"stranger"}, "password": {"strangerpw-123"}} | ||
| 523 | resp, err := client.PostForm(f.ts.URL+"/login", form) | ||
| 524 | if err != nil { | ||
| 525 | t.Fatal(err) | ||
| 526 | } | ||
| 527 | resp.Body.Close() | ||
| 528 | |||
| 529 | resp, err = client.Get(f.ts.URL + "/r/demo") | ||
| 530 | if err != nil { | ||
| 531 | t.Fatal(err) | ||
| 532 | } | ||
| 533 | body, _ := io.ReadAll(resp.Body) | ||
| 534 | resp.Body.Close() | ||
| 535 | if resp.StatusCode != http.StatusForbidden { | ||
| 536 | t.Fatalf("stranger got status %d, want 403", resp.StatusCode) | ||
| 537 | } | ||
| 538 | if !strings.Contains(string(body), "read access") { | ||
| 539 | t.Fatalf("unclear denial: %s", body) | ||
| 388 | func TestHealthzReportsUsers(t *testing.T) { | ||
| 389 | f := newFixture(t) | ||
| 390 | code, body := f.get("/healthz") | ||
| 391 | if code != 200 { | ||
| 392 | t.Fatalf("healthz: %d", code) | ||
| 540 | 393 | } | |
| 541 | } | ||
| 542 | |||
| 543 | // execBinary runs the vole CLI (not git). | ||
| 544 | func execBinary(bin string, args ...string) (string, string, error) { | ||
| 545 | cmd := exec.Command(bin, args...) | ||
| 546 | var out, errb bytes.Buffer | ||
| 547 | cmd.Stdout, cmd.Stderr = &out, &errb | ||
| 548 | err := cmd.Run() | ||
| 549 | return out.String(), errb.String(), err | ||
| 550 | } | ||
| 551 | |||
| 552 | // incompressible produces bytes that git cannot shrink, so size limits are | ||
| 553 | // actually exercised. | ||
| 554 | func incompressible(n int) string { | ||
| 555 | b := make([]byte, n) | ||
| 556 | state := uint32(0x9e3779b9) | ||
| 557 | for i := range b { | ||
| 558 | state = state*1664525 + 1013904223 | ||
| 559 | b[i] = byte(state >> 24) | ||
| 394 | if !strings.Contains(body, "users=1") { | ||
| 395 | t.Fatalf("healthz body: %q", body) | ||
| 560 | 396 | } | |
| 561 | return string(b) | ||
| 562 | 397 | } | |
| 563 | 398 | ||
| 564 | func mustURL(t *testing.T, raw string) *url.URL { | ||
| 565 | t.Helper() | ||
| 566 | u, err := url.Parse(raw) | ||
| 567 | if err != nil { | ||
| 568 | t.Fatal(err) | ||
| 569 | } | ||
| 570 | return u | ||
| 571 | } | ||
| 399 | var _ = acl.Read | ||