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.

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/hook/hook_test.go +172

@@ -0,0 +1,172 @@
1 package hook
2
3 import (
4 "bytes"
5 "strings"
6 "testing"
7
8 "github.com/rodent-software/vole/internal/acl"
9 "github.com/rodent-software/vole/internal/gitx"
10 "github.com/rodent-software/vole/internal/repo"
11 )
12
13 type fixture struct {
14 t *testing.T
15 m *repo.Meta
16 head string
17 }
18
19 func newFixture(t *testing.T, blob string) *fixture {
20 t.Helper()
21 root := t.TempDir()
22 st := repo.New(root, "/bin/true")
23 m, err := st.Create("demo", "owner", "")
24 if err != nil {
25 t.Fatal(err)
26 }
27 a, err := m.ACL()
28 if err != nil {
29 t.Fatal(err)
30 }
31 if err := a.SetEntry("writer", "write"); err != nil {
32 t.Fatal(err)
33 }
34 if err := a.SetEntry("reader", "read"); err != nil {
35 t.Fatal(err)
36 }
37 if err := m.SetACL(a, "test"); err != nil {
38 t.Fatal(err)
39 }
40
41 g := m.Git()
42 blobSHA, err := g.HashObject([]byte(blob))
43 if err != nil {
44 t.Fatal(err)
45 }
46 tree, err := g.MkTree([]gitx.Entry{{Mode: "100644", Type: "blob", SHA: blobSHA, Name: "readme.md"}})
47 if err != nil {
48 t.Fatal(err)
49 }
50 commit, err := g.CommitTree(tree, "", "initial")
51 if err != nil {
52 t.Fatal(err)
53 }
54 return &fixture{t: t, m: m, head: commit}
55 }
56
57 func (f *fixture) run(actor, ref string, maxBlob int64) (int, string) {
58 f.t.Helper()
59 var out bytes.Buffer
60 code := PreReceive(Config{
61 RepoDir: f.m.Dir,
62 Actor: actor,
63 MaxBlobBytes: maxBlob,
64 In: strings.NewReader(gitx.ZeroSHA + " " + f.head + " " + ref + "\n"),
65 Out: &out,
66 })
67 return code, out.String()
68 }
69
70 func (f *fixture) setACL(mut func(*acl.ACL)) {
71 f.t.Helper()
72 a, err := f.m.ACL()
73 if err != nil {
74 f.t.Fatal(err)
75 }
76 mut(a)
77 if err := f.m.SetACL(a, "test"); err != nil {
78 f.t.Fatal(err)
79 }
80 }
81
82 func TestOwnerCanPush(t *testing.T) {
83 f := newFixture(t, "hello\n")
84 if code, out := f.run("owner", "refs/heads/main", 0); code != 0 {
85 t.Fatalf("owner push rejected: %s", out)
86 }
87 if code, out := f.run("writer", "refs/heads/feature/x", 0); code != 0 {
88 t.Fatalf("writer push rejected: %s", out)
89 }
90 }
91
92 func TestReadOnlyCannotPush(t *testing.T) {
93 f := newFixture(t, "hello\n")
94 code, out := f.run("reader", "refs/heads/main", 0)
95 if code == 0 {
96 t.Fatal("read-only user was allowed to push")
97 }
98 if !strings.Contains(out, "does not have write access") {
99 t.Fatalf("message should name the reason, got: %s", out)
100 }
101 if !strings.Contains(out, "reader") {
102 t.Fatalf("message should name the actor, got: %s", out)
103 }
104 }
105
106 func TestAnonymousCannotPush(t *testing.T) {
107 f := newFixture(t, "hello\n")
108 code, out := f.run("", "refs/heads/main", 0)
109 if code == 0 {
110 t.Fatal("anonymous push was allowed")
111 }
112 if !strings.Contains(out, "must be authenticated") {
113 t.Fatalf("unclear message: %s", out)
114 }
115 }
116
117 // TestMetadataRefsAreServerOwned is the one that matters most: refs/vole/* holds
118 // the ACL itself, so a client that can write it can grant itself admin.
119 func TestMetadataRefsAreServerOwned(t *testing.T) {
120 f := newFixture(t, "hello\n")
121 for _, ref := range []string{"refs/vole/acl", "refs/vole/issues/000"} {
122 code, out := f.run("owner", ref, 0)
123 if code == 0 {
124 t.Fatalf("owner was allowed to push %s", ref)
125 }
126 if !strings.Contains(out, "server-owned") {
127 t.Fatalf("message for %s should explain ownership, got: %s", ref, out)
128 }
129 }
130 }
131
132 func TestProtectedBranch(t *testing.T) {
133 f := newFixture(t, "hello\n")
134 f.setACL(func(a *acl.ACL) { a.Protected = []string{"refs/heads/main"} })
135
136 if code, out := f.run("writer", "refs/heads/main", 0); code == 0 {
137 t.Fatal("writer was allowed to push a protected branch")
138 } else if !strings.Contains(out, "protected") {
139 t.Fatalf("message should mention protection: %s", out)
140 }
141 if code, out := f.run("writer", "refs/heads/topic", 0); code != 0 {
142 t.Fatalf("protection leaked to an unprotected ref: %s", out)
143 }
144 if code, out := f.run("owner", "refs/heads/main", 0); code != 0 {
145 t.Fatalf("admin must be able to push a protected branch: %s", out)
146 }
147 }
148
149 func TestOversizedBlobRejected(t *testing.T) {
150 f := newFixture(t, strings.Repeat("x", 4096))
151 code, out := f.run("owner", "refs/heads/main", 1024)
152 if code == 0 {
153 t.Fatal("oversized blob was accepted")
154 }
155 if !strings.Contains(out, "over the 1024 byte limit") {
156 t.Fatalf("message should state the limit: %s", out)
157 }
158 if code, out := f.run("owner", "refs/heads/main", 1<<20); code != 0 {
159 t.Fatalf("blob under the limit was rejected: %s", out)
160 }
161 }
162
163 func TestArbitraryRefsRejected(t *testing.T) {
164 f := newFixture(t, "hello\n")
165 code, out := f.run("owner", "refs/notes/commits", 0)
166 if code == 0 {
167 t.Fatal("push to an unrelated ref namespace was allowed")
168 }
169 if !strings.Contains(out, "only refs/heads") {
170 t.Fatalf("unclear message: %s", out)
171 }
172 }