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 4: access rules live in the instance database
ec5c519cb9fc4277baa8263cfd31891a95f1d7ac · Keenan Nemetz · 17d ago · parent 90fdaf39
Phase 4: access rules live in the instance database The ACL was one acl.json on refs/vole/acl: a ref only the server writes, read on every push and every request. It is configuration the instance owns, keyed by account id, and keeping it in git cost a commit, a compare-and-swap retry and a rev-parse per read to store a handful of rows — the argument that already moved the roster out of data/system.git, applied to the rules that name its accounts. It is now a row in data/users.db beside the roster, in the same file as the ids it refers to: - access.Store: repo_acl(repo_id, acl, updated, updated_by), one row per repository, keyed by the stable repository id so a rename moves nothing. - Meta.ACL reads the row; a repository with no row is deny-everything, exactly what a missing ref meant. - Meta.SetACL is one upsert, so SQLite orders concurrent edits and the CAS loop with its retries is gone. The author stays in the row and in data/audit.log, which is what the commit message used to say. - Deleting a repository forgets its rules, after the directory is gone. - repo.Store.ImportACLs reads refs/vole/acl once at startup for any repository without a row, so an existing instance keeps its rules. It never overwrites a row, and a second run imports nothing. - The index's acl_sha is a digest of the rules rather than the ACL commit id. - pre-receive reads the rules from VOLE_USERS_DB and makes GIT_DIR absolute before deriving the repository from it, since git hands a hook ".". What the ref bought — rules that travel with a clone — is what this gives up, deliberately: the rules are account ids, so a copy in a clone is unreadable without this instance's roster, and the backup that holds the accounts now holds them too. Measured: internal/repo imports rules from a real refs/vole/acl commit and is idempotent on a second run; internal/access covers round-trip, replacement, deletion and the audit line; the full suite — auth, site visibility, transport and pushes — runs against rules read from SQLite.
internal/hook/lfs_test.go +9−3
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "crypto/sha256" | |
| 6 | 6 | "encoding/hex" | |
| 7 | "path/filepath" | ||
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| @@ -17,7 +18,8 @@ import ( | |||
| 17 | 18 | // is enough to test what pre-receive sees at a pushed tip. | |
| 18 | 19 | func filesFixture(t *testing.T, files map[string][]byte) *fixture { | |
| 19 | 20 | t.Helper() | |
| 20 | st := repo.New(t.TempDir(), "/bin/true") | ||
| 21 | root := t.TempDir() | ||
| 22 | st := repo.New(filepath.Join(root, "repos"), "/bin/true", testACLs(t, root)) | ||
| 21 | 23 | m, err := st.Create("demo", "owner", "") | |
| 22 | 24 | if err != nil { | |
| 23 | 25 | t.Fatal(err) | |
| @@ -42,7 +44,7 @@ func filesFixture(t *testing.T, files map[string][]byte) *fixture { | |||
| 42 | 44 | if err != nil { | |
| 43 | 45 | t.Fatal(err) | |
| 44 | 46 | } | |
| 45 | return &fixture{t: t, m: m, head: commit} | ||
| 47 | return &fixture{t: t, m: m, head: commit, usersDB: testUsersDB(root)} | ||
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | 50 | func pointerFor(content []byte) (lfs.Pointer, []byte) { | |
| @@ -136,7 +138,8 @@ func TestTextFileIsNotWarnedAbout(t *testing.T) { | |||
| 136 | 138 | func TestDeletedPointerDoesNotBlockItsOwnRemoval(t *testing.T) { | |
| 137 | 139 | // A broken pointer already in the tree must be removable; otherwise the only | |
| 138 | 140 | // way out of the mistake is rewriting history. | |
| 139 | st := repo.New(t.TempDir(), "/bin/true") | ||
| 141 | root := t.TempDir() | ||
| 142 | st := repo.New(filepath.Join(root, "repos"), "/bin/true", testACLs(t, root)) | ||
| 140 | 143 | m, err := st.Create("demo", "owner", "") | |
| 141 | 144 | if err != nil { | |
| 142 | 145 | t.Fatal(err) | |
| @@ -188,6 +191,9 @@ func TestDeletedPointerDoesNotBlockItsOwnRemoval(t *testing.T) { | |||
| 188 | 191 | RepoDir: m.Dir, | |
| 189 | 192 | Actor: "writer", | |
| 190 | 193 | LFS: testStore(t), | |
| 194 | // The rules live in the instance database, so the gate needs it even when a | ||
| 195 | // test is only about pointers. | ||
| 196 | UsersDB: testUsersDB(root), | ||
| 191 | 197 | In: strings.NewReader(gitx.ZeroSHA + " " + c2 + " refs/heads/main\n"), | |
| 192 | 198 | Out: &out, | |
| 193 | 199 | }) | |