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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

Phase 1: a working git remote with access control

653e0cfc227c992e5b26ea10057aa0b424b6ce4f · Keenan Nemetz · 18d ago · parent c5626f1b

Phase 1: a working git remote with access control

Vole is now a functioning forge foundation: one CGO-free binary that serves
real git over smart HTTP and enforces per-repo policy in pre-receive.

What is in:

- internal/transport drives `git http-backend` as a CGI subprocess. Chunked
  uploads are spooled to learn CONTENT_LENGTH (git's default client config
  produces them), spool files are cleaned on error and abort, and a janitor
  sweeps anything a crash left behind. Pushes are capped by size and
  serialised behind a semaphore, because Phase 0 measured 1.13 GB of git RSS
  for a single 1 GiB push.
- internal/acl stores roles on refs/vole/acl inside each repository, so
  access rules travel with a clone. One evaluation function is shared by the
  hook and the web layer.
- internal/hook enforces policy in pre-receive: authentication, write role,
  protected refs, per-blob size limits, and a refusal to accept pushes to
  refs/vole/* - without that last check a writer could push their own
  acl.json and grant themselves admin.
- internal/auth holds users, personal access tokens and web sessions in
  SQLite. Passwords use PBKDF2-SHA256; tokens are 256-bit random values and
  are stored as SHA-256 hashes, since there is no low-entropy secret to grind.
- internal/index is the derived read model, rebuildable from disk and refs.
- internal/repo owns the bare-repo store and the metadata ref CAS helper.
- internal/web is the browse surface: repo list, tree, blob, raw, history,
  login, and per-repo settings for access rules, rename and delete.
- internal/signing creates the instance ed25519 key at first boot for the
  Phase 7 work.

Every Phase 1 exit criterion has a test that would fail if it regressed:
clone/push against a real listener, read-only and anonymous rejection with the
ref proven unmoved, rebuild-from-refs compared against the actual acl ref, a
spool file proven absent after an aborted chunked upload, and two pushes
completing through a single-slot semaphore. scripts/ci.sh runs gofmt, vet,
build, a markdown link check and the race-enabled suite.

Verified by hand as well: a clean `vole init` -> user -> token -> push ->
clone cycle, web login and browse, and a 256 MiB chunked push killed
mid-flight leaving no ref, no spool file and no orphaned git processes.

internal/transport/transport_test.go +248

@@ -0,0 +1,248 @@
1 package transport
2
3 import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log/slog"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "strings"
14 "testing"
15
16 "github.com/rodent-software/vole/internal/acl"
17 "github.com/rodent-software/vole/internal/repo"
18 )
19
20 func TestRoute(t *testing.T) {
21 for _, c := range []struct {
22 path, name, rest string
23 ok bool
24 }{
25 {"/git/demo.git/info/refs", "demo", "info/refs", true},
26 {"/git/demo.git/git-receive-pack", "demo", "git-receive-pack", true},
27 {"/git/demo.git/git-upload-pack", "demo", "git-upload-pack", true},
28 {"/git/demo/info/refs", "", "", false},
29 {"/other/demo.git/info/refs", "", "", false},
30 {"/git/", "", "", false},
31 } {
32 name, rest, ok := Route(c.path)
33 if ok != c.ok || name != c.name || rest != c.rest {
34 t.Errorf("Route(%q)=(%q,%q,%v) want (%q,%q,%v)", c.path, name, rest, ok, c.name, c.rest, c.ok)
35 }
36 }
37 }
38
39 func TestClassify(t *testing.T) {
40 if op, need, err := classify("info/refs", "service=git-receive-pack"); err != nil || op != OpPush || need != acl.Write {
41 t.Fatalf("receive-pack info/refs: op=%v need=%v err=%v", op, need, err)
42 }
43 if op, need, err := classify("info/refs", "service=git-upload-pack"); err != nil || op != OpRead || need != acl.Read {
44 t.Fatalf("upload-pack info/refs: op=%v need=%v err=%v", op, need, err)
45 }
46 if _, _, err := classify("info/refs", ""); err == nil {
47 t.Fatal("dumb http should be refused explicitly")
48 }
49 if _, _, err := classify("HEAD", ""); err == nil {
50 t.Fatal("unknown path should be refused")
51 }
52 }
53
54 // TestSpoolJanitorRemovesOrphans proves a crash-leftover is cleaned up while a
55 // live upload is untouched.
56 func TestSpoolJanitorRemovesOrphans(t *testing.T) {
57 dir := t.TempDir()
58 s := NewSpooler(dir)
59
60 orphan := filepath.Join(dir, spoolPrefix+"orphan")
61 if err := os.WriteFile(orphan, []byte("leftover"), 0o600); err != nil {
62 t.Fatal(err)
63 }
64 live, err := s.Create()
65 if err != nil {
66 t.Fatal(err)
67 }
68 if _, err := live.WriteString("in flight"); err != nil {
69 t.Fatal(err)
70 }
71
72 n, err := s.Janitor()
73 if err != nil {
74 t.Fatal(err)
75 }
76 if n != 1 {
77 t.Fatalf("janitor removed %d files, want 1", n)
78 }
79 if _, err := os.Stat(orphan); !os.IsNotExist(err) {
80 t.Fatal("orphan survived the janitor")
81 }
82 if _, err := os.Stat(live.Name()); err != nil {
83 t.Fatal("janitor deleted a live spool file")
84 }
85 s.Release(live)
86 if _, err := os.Stat(live.Name()); !os.IsNotExist(err) {
87 t.Fatal("Release did not delete the spool file")
88 }
89 if n, _ := s.Janitor(); n != 0 {
90 t.Fatalf("janitor found %d files after release", n)
91 }
92 }
93
94 func testProxy(t *testing.T, maxPush int64) (*Proxy, *repo.Store, string) {
95 t.Helper()
96 root := t.TempDir()
97 st := repo.New(filepath.Join(root, "repos"), "/bin/true")
98 if _, err := st.Create("demo", "owner", ""); err != nil {
99 t.Fatal(err)
100 }
101 p := New(Config{
102 Root: filepath.Join(root, "repos"),
103 Open: st.Open,
104 Actor: func(u, pw string) (string, error) { return u, nil },
105 MaxPushBytes: maxPush,
106 SpoolDir: filepath.Join(root, "spool"),
107 Log: slog.New(slog.NewTextHandler(io.Discard, nil)),
108 })
109 return p, st, filepath.Join(root, "spool")
110 }
111
112 func TestDeclaredOversizeRejectedBeforeBody(t *testing.T) {
113 p, _, _ := testProxy(t, 64)
114 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader("payload"))
115 req.ContentLength = 128
116 // The repo's owner is authenticated as itself; the ACL grants admin.
117 req.SetBasicAuth("owner", "pw")
118 rec := httptest.NewRecorder()
119 p.ServeHTTP(rec, req)
120 if rec.Code != http.StatusRequestEntityTooLarge {
121 t.Fatalf("status=%d want 413", rec.Code)
122 }
123 }
124
125 // TestAbandonedChunkedUploadLeavesNoSpoolFile drives the cleanup path that a
126 // real client disconnect takes: the body read fails part way through.
127 func TestAbandonedChunkedUploadLeavesNoSpoolFile(t *testing.T) {
128 p, _, spoolDir := testProxy(t, 1<<20)
129 body := &failingReader{failAfter: 128}
130 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", body)
131 req.ContentLength = -1 // chunked: forces the spool path
132 req.SetBasicAuth("owner", "pw")
133 rec := httptest.NewRecorder()
134 p.ServeHTTP(rec, req)
135
136 ents, err := os.ReadDir(spoolDir)
137 if err != nil && !os.IsNotExist(err) {
138 t.Fatal(err)
139 }
140 for _, e := range ents {
141 if strings.HasPrefix(e.Name(), spoolPrefix) {
142 t.Fatalf("aborted upload left spool file %s", e.Name())
143 }
144 }
145 if rec.Code == http.StatusOK {
146 t.Fatal("aborted upload should not report success")
147 }
148 }
149
150 type failingReader struct{ failAfter int }
151
152 func (f *failingReader) Read(p []byte) (int, error) {
153 if f.failAfter <= 0 {
154 return 0, errors.New("client went away")
155 }
156 n := len(p)
157 if n > f.failAfter {
158 n = f.failAfter
159 }
160 buf := bytes.Repeat([]byte("x"), n)
161 copy(p, buf)
162 f.failAfter -= n
163 return n, nil
164 }
165
166 // TestChunkedUploadSpoolsToDiskThenServes checks that a chunked body is written
167 // to a temp file, gets a real CONTENT_LENGTH, and is cleaned up afterwards.
168 func TestChunkedUploadSpoolsToDiskThenServes(t *testing.T) {
169 p, _, spoolDir := testProxy(t, 1<<20)
170 payload := strings.Repeat("a", 4096)
171 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader(payload))
172 req.ContentLength = -1
173 req.SetBasicAuth("owner", "pw")
174 rec := httptest.NewRecorder()
175 p.ServeHTTP(rec, req)
176
177 if got := p.Stats()["spooled"]; got != int64(len(payload)) {
178 t.Fatalf("spooled=%d want %d", got, len(payload))
179 }
180 ents, _ := os.ReadDir(spoolDir)
181 for _, e := range ents {
182 if strings.HasPrefix(e.Name(), spoolPrefix) {
183 t.Fatalf("spool file not cleaned up: %s", e.Name())
184 }
185 }
186 }
187
188 // TestDisconnectedClientSkipsBackend: once the client is gone, do not burn a
189 // receive-pack on the upload, and still clean up the spool file.
190 func TestDisconnectedClientSkipsBackend(t *testing.T) {
191 p, _, spoolDir := testProxy(t, 1<<20)
192 ctx, cancel := context.WithCancel(context.Background())
193 cancel()
194 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader(strings.Repeat("a", 2048))).WithContext(ctx)
195 req.ContentLength = -1
196 req.SetBasicAuth("owner", "pw")
197 rec := httptest.NewRecorder()
198 p.ServeHTTP(rec, req)
199
200 if ent := readSpool(t, spoolDir); len(ent) != 0 {
201 t.Fatalf("spool not cleaned after disconnect: %v", ent)
202 }
203 }
204
205 func readSpool(t *testing.T, dir string) []string {
206 t.Helper()
207 ents, err := os.ReadDir(dir)
208 if err != nil && !os.IsNotExist(err) {
209 t.Fatal(err)
210 }
211 var out []string
212 for _, e := range ents {
213 if strings.HasPrefix(e.Name(), spoolPrefix) {
214 out = append(out, e.Name())
215 }
216 }
217 return out
218 }
219
220 func TestBadCredentialsAre401Not403(t *testing.T) {
221 p, _, _ := testProxy(t, 1<<20)
222 p.cfg.Actor = func(u, pw string) (string, error) { return "", errors.New("nope") }
223 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader("x"))
224 req.ContentLength = 1
225 req.SetBasicAuth("owner", "wrong")
226 rec := httptest.NewRecorder()
227 p.ServeHTTP(rec, req)
228 if rec.Code != http.StatusUnauthorized {
229 t.Fatalf("status=%d want 401 for bad credentials", rec.Code)
230 }
231 if body := rec.Body.String(); strings.Contains(body, "anonymous") {
232 t.Fatalf("message should not describe a bad-credential request as anonymous: %s", body)
233 }
234 }
235
236 func TestAnonymousPushChallenges(t *testing.T) {
237 p, _, _ := testProxy(t, 1<<20)
238 req := httptest.NewRequest(http.MethodPost, "/git/demo.git/git-receive-pack", strings.NewReader("x"))
239 req.ContentLength = 1
240 rec := httptest.NewRecorder()
241 p.ServeHTTP(rec, req)
242 if rec.Code != http.StatusUnauthorized {
243 t.Fatalf("status=%d want 401", rec.Code)
244 }
245 if rec.Header().Get("WWW-Authenticate") == "" {
246 t.Fatal("401 must carry a WWW-Authenticate header so git can prompt")
247 }
248 }