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/acl/acl_test.go +105

@@ -0,0 +1,105 @@
1 package acl
2
3 import "testing"
4
5 func TestRoleOrdering(t *testing.T) {
6 if !Admin.AtLeast(Write) || !Write.AtLeast(Read) || Read.AtLeast(Write) {
7 t.Fatal("role ordering is wrong")
8 }
9 for _, c := range []struct {
10 in string
11 want Role
12 ok bool
13 }{
14 {"none", None, true},
15 {"", None, true},
16 {"read", Read, true},
17 {"write", Write, true},
18 {"admin", Admin, true},
19 {"owner", Admin, true},
20 {"ADMIN", Admin, true},
21 {"root", None, false},
22 } {
23 got, err := ParseRole(c.in)
24 if c.ok != (err == nil) {
25 t.Fatalf("ParseRole(%q) err=%v want ok=%v", c.in, err, c.ok)
26 }
27 if c.ok && got != c.want {
28 t.Fatalf("ParseRole(%q)=%v want %v", c.in, got, c.want)
29 }
30 }
31 }
32
33 func TestRoleFor(t *testing.T) {
34 a := &ACL{Default: "none", Entries: map[string]string{
35 "owner": "admin", "dev": "write", "guest": "read",
36 }}
37 cases := []struct {
38 user string
39 want Role
40 }{
41 {"owner", Admin}, {"dev", Write}, {"guest", Read},
42 {"stranger", None}, {"", None},
43 }
44 for _, c := range cases {
45 if got := a.RoleFor(c.user); got != c.want {
46 t.Errorf("RoleFor(%q)=%v want %v", c.user, got, c.want)
47 }
48 }
49 }
50
51 func TestDefaultAppliesToUnknownUsers(t *testing.T) {
52 a := &ACL{Default: "read", Entries: map[string]string{"owner": "admin"}}
53 if !a.Can("anyone", Read) {
54 t.Fatal("default read should grant read to an unknown user")
55 }
56 if a.Can("anyone", Write) {
57 t.Fatal("default read must not grant write")
58 }
59 if !a.Can("owner", Write) {
60 t.Fatal("explicit entry should win")
61 }
62 }
63
64 func TestIsProtected(t *testing.T) {
65 a := &ACL{Protected: []string{"refs/heads/main", "refs/heads/release/*"}}
66 for ref, want := range map[string]bool{
67 "refs/heads/main": true,
68 "refs/heads/mainline": false,
69 "refs/heads/release/1.0": true,
70 "refs/heads/release": false,
71 "refs/heads/feature/x": false,
72 } {
73 if got := a.IsProtected(ref); got != want {
74 t.Errorf("IsProtected(%q)=%v want %v", ref, got, want)
75 }
76 }
77 }
78
79 func TestJSONRoundTrip(t *testing.T) {
80 a := Empty("keenan")
81 a.Protected = []string{"refs/heads/main"}
82 b, err := a.Bytes()
83 if err != nil {
84 t.Fatal(err)
85 }
86 got, err := Parse(b)
87 if err != nil {
88 t.Fatal(err)
89 }
90 if got.RoleFor("keenan") != Admin {
91 t.Fatalf("owner lost admin: %s", b)
92 }
93 if !got.IsProtected("refs/heads/main") {
94 t.Fatal("protection lost")
95 }
96 }
97
98 func TestParseRejectsBadRoles(t *testing.T) {
99 if _, err := Parse([]byte(`{"version":1,"default":"root"}`)); err == nil {
100 t.Fatal("expected error for unknown default role")
101 }
102 if _, err := Parse([]byte(`{"version":1,"entries":{"x":"king"}}`)); err == nil {
103 t.Fatal("expected error for unknown entry role")
104 }
105 }