Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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