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 4: access rules live in the instance database
ec5c519cb9fc4277baa8263cfd31891a95f1d7ac · Keenan Nemetz · 16d 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/hook_test.go +28−5
| @@ -2,24 +2,30 @@ package hook | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | "path/filepath" | ||
| 5 | 6 | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 9 | "github.com/rodent-software/vole/internal/access" | ||
| 8 | 10 | "github.com/rodent-software/vole/internal/acl" | |
| 9 | 11 | "github.com/rodent-software/vole/internal/gitx" | |
| 10 | 12 | "github.com/rodent-software/vole/internal/repo" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | type fixture struct { | |
| 14 | t *testing.T | ||
| 15 | m *repo.Meta | ||
| 16 | head string | ||
| 16 | t *testing.T | ||
| 17 | m *repo.Meta | ||
| 18 | head string | ||
| 19 | usersDB string | ||
| 17 | 20 | } | |
| 18 | 21 | ||
| 19 | 22 | func newFixture(t *testing.T, blob string) *fixture { | |
| 20 | 23 | t.Helper() | |
| 21 | 24 | root := t.TempDir() | |
| 22 | st := repo.New(root, "/bin/true") | ||
| 25 | // Access rules live in the instance database, so a fixture that pushes has to give | ||
| 26 | // the gate the same file the repository's rules were written to. | ||
| 27 | acls := testACLs(t, root) | ||
| 28 | st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls) | ||
| 23 | 29 | m, err := st.Create("demo", "owner", "") | |
| 24 | 30 | if err != nil { | |
| 25 | 31 | t.Fatal(err) | |
| @@ -51,7 +57,21 @@ func newFixture(t *testing.T, blob string) *fixture { | |||
| 51 | 57 | if err != nil { | |
| 52 | 58 | t.Fatal(err) | |
| 53 | 59 | } | |
| 54 | return &fixture{t: t, m: m, head: commit} | ||
| 60 | return &fixture{t: t, m: m, head: commit, usersDB: testUsersDB(root)} | ||
| 61 | } | ||
| 62 | |||
| 63 | // testUsersDB is the instance database a test fixture uses: one file holding the | ||
| 64 | // roster and the access rules, which is what the server has. | ||
| 65 | func testUsersDB(root string) string { return filepath.Join(root, "users.db") } | ||
| 66 | |||
| 67 | func testACLs(t *testing.T, root string) *access.Store { | ||
| 68 | t.Helper() | ||
| 69 | acls, err := access.Open(testUsersDB(root)) | ||
| 70 | if err != nil { | ||
| 71 | t.Fatal(err) | ||
| 72 | } | ||
| 73 | t.Cleanup(func() { acls.Close() }) | ||
| 74 | return acls | ||
| 55 | 75 | } | |
| 56 | 76 | ||
| 57 | 77 | func (f *fixture) run(actor, ref string, maxBlob int64) (int, string) { | |
| @@ -66,6 +86,9 @@ func (f *fixture) run(actor, ref string, maxBlob int64) (int, string) { | |||
| 66 | 86 | func (f *fixture) runWith(cfg Config, ref string) (int, string) { | |
| 67 | 87 | f.t.Helper() | |
| 68 | 88 | cfg.RepoDir = f.m.Dir | |
| 89 | if cfg.UsersDB == "" { | ||
| 90 | cfg.UsersDB = f.usersDB | ||
| 91 | } | ||
| 69 | 92 | var out bytes.Buffer | |
| 70 | 93 | cfg.In = strings.NewReader(gitx.ZeroSHA + " " + f.head + " " + ref + "\n") | |
| 71 | 94 | cfg.Out = &out | |