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.
The roster is a database, not a repository
427c47611f7db09e52618a0f2d05291a8641b2d0 · Keenan Nemetz · 17d ago · parent 0f442b1c
The roster is a database, not a repository data/system.git is gone. Accounts, every name they have held and their SSH keys live in data/users.db, next to the other authoritative store (runs.db) and the disposable index. users id, name, status, created user_names every name ever held -- primary key, so a name is issued once user_keys fingerprint, type, line -- primary key, so a key has one owner Two invariants that used to be parse-time checks are now table keys, and a roster change is one transaction instead of a commit, a CAS retry and a tree walk. The snapshot, the five-second reload and the audit line per change are unchanged, so `vole user add-key` still applies without a restart and revocation is still immediate. Clients no longer clone the roster, because there is nothing to clone. The one view a client needs -- active id and name -- is served by a `vole-roster` SSH verb over the key it already pushes with, so name resolution gained no endpoint and no second credential. `vole request new --author keenan` now resolves through that verb (TestRequestCLIResolvesNamesOverSSH: the request landed with author_id = the account id, and an unknown name fails before pushing). A WAL database cannot be backed up with cp, so `vole user backup <path>` takes a consistent copy with VACUUM INTO (TestRosterIsOneDatabaseFile opens the copy and resolves a renamed account and its key from it). This replaces the "identity survives without a side database" criterion, which is withdrawn rather than left looking unmet: the roster is now exactly one file, and the backup is what proves it is not lost with the index. Verified through the binary: init -> create -> add-key -> rename -> list -> backup, the restored copy listing the same account, former name and key; then scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
internal/identity/identity_test.go +91−47
| @@ -3,31 +3,28 @@ package identity | |||
| 3 | 3 | import ( | |
| 4 | 4 | "crypto/ed25519" | |
| 5 | 5 | "crypto/rand" | |
| 6 | "fmt" | ||
| 7 | 6 | "os" | |
| 8 | "os/exec" | ||
| 9 | 7 | "path/filepath" | |
| 10 | 8 | "strings" | |
| 11 | 9 | "sync" | |
| 12 | 10 | "testing" | |
| 13 | "time" | ||
| 14 | 11 | ||
| 15 | "github.com/rodent-software/vole/internal/gitx" | ||
| 16 | 12 | "github.com/rodent-software/vole/internal/id" | |
| 17 | 13 | "golang.org/x/crypto/ssh" | |
| 18 | 14 | ) | |
| 19 | 15 | ||
| 20 | 16 | func newStore(t *testing.T) *Store { | |
| 21 | 17 | t.Helper() | |
| 22 | dir := filepath.Join(t.TempDir(), "system.git") | ||
| 23 | if err := Init(dir); err != nil { | ||
| 18 | path := filepath.Join(t.TempDir(), "users.db") | ||
| 19 | if err := Init(path); err != nil { | ||
| 24 | 20 | t.Fatal(err) | |
| 25 | 21 | } | |
| 26 | s, err := Open(dir) | ||
| 22 | s, err := Open(path) | ||
| 27 | 23 | if err != nil { | |
| 28 | 24 | t.Fatal(err) | |
| 29 | 25 | } | |
| 30 | s.AuditPath = filepath.Join(filepath.Dir(dir), "audit.log") | ||
| 26 | t.Cleanup(func() { s.Close() }) | ||
| 27 | s.AuditPath = filepath.Join(filepath.Dir(path), "audit.log") | ||
| 31 | 28 | return s | |
| 32 | 29 | } | |
| 33 | 30 | ||
| @@ -51,9 +48,9 @@ func TestEmptyRosterLoads(t *testing.T) { | |||
| 51 | 48 | } | |
| 52 | 49 | } | |
| 53 | 50 | ||
| 54 | func TestMissingRepositoryIsAnError(t *testing.T) { | ||
| 55 | if _, err := Open(filepath.Join(t.TempDir(), "nope")); err == nil { | ||
| 56 | t.Fatal("Open succeeded on a missing repository") | ||
| 51 | func TestMissingDatabaseIsAnError(t *testing.T) { | ||
| 52 | if _, err := Open(filepath.Join(t.TempDir(), "nope.db")); err == nil { | ||
| 53 | t.Fatal("Open succeeded on a missing database") | ||
| 57 | 54 | } | |
| 58 | 55 | } | |
| 59 | 56 | ||
| @@ -96,10 +93,11 @@ func TestMultipleKeysPerAccount(t *testing.T) { | |||
| 96 | 93 | } | |
| 97 | 94 | fps = append(fps, fp) | |
| 98 | 95 | } | |
| 99 | reopened, err := Open(s.Dir()) | ||
| 96 | reopened, err := Open(s.Path()) | ||
| 100 | 97 | if err != nil { | |
| 101 | 98 | t.Fatal(err) | |
| 102 | 99 | } | |
| 100 | defer reopened.Close() | ||
| 103 | 101 | if got := len(reopened.Snapshot().ByName["keenan"].Keys); got != 3 { | |
| 104 | 102 | t.Fatalf("keys after reopen = %d, want 3", got) | |
| 105 | 103 | } | |
| @@ -131,33 +129,59 @@ func TestDuplicateKeyAcrossAccountsRejected(t *testing.T) { | |||
| 131 | 129 | } | |
| 132 | 130 | } | |
| 133 | 131 | ||
| 134 | // TestDuplicateKeyInGitFailsStartup covers a hand-written commit: two accounts | ||
| 135 | // claiming one key is ambiguous, so the roster must refuse to load. | ||
| 136 | func TestDuplicateKeyInGitFailsStartup(t *testing.T) { | ||
| 132 | // TestOneKeyBelongsToOneAccount is now a database constraint rather than a | ||
| 133 | // parse-time check: user_keys is keyed by fingerprint, so a hand-written second | ||
| 134 | // row cannot exist. | ||
| 135 | func TestOneKeyBelongsToOneAccount(t *testing.T) { | ||
| 137 | 136 | s := newStore(t) | |
| 138 | if _, err := s.Create("alice", "test"); err != nil { | ||
| 139 | t.Fatal(err) | ||
| 137 | for _, n := range []string{"alice", "bob"} { | ||
| 138 | if _, err := s.Create(n, "test"); err != nil { | ||
| 139 | t.Fatal(err) | ||
| 140 | } | ||
| 140 | 141 | } | |
| 141 | line, _ := pubLine(t) | ||
| 142 | line, fp := pubLine(t) | ||
| 142 | 143 | if _, err := s.AddKey("alice", line, "test"); err != nil { | |
| 143 | 144 | t.Fatal(err) | |
| 144 | 145 | } | |
| 145 | // Commit a second account by hand, bypassing AddKey. | ||
| 146 | g := gitx.Open(s.Dir()) | ||
| 147 | newID, err := id.New(time.Now()) | ||
| 146 | bob, err := s.Find("bob") | ||
| 148 | 147 | if err != nil { | |
| 149 | 148 | t.Fatal(err) | |
| 150 | 149 | } | |
| 151 | bad := fmt.Sprintf(`{"name":"bob","status":"active","created":"2026-10-22T00:00:00Z","keys":[{"fingerprint":"%s","type":"ssh-ed25519","line":%q}]}`, | ||
| 152 | ssh.FingerprintSHA256(mustParse(t, line)), line) | ||
| 153 | if _, err := gitx.CommitFiles(g, Ref, func(files map[string][]byte) error { | ||
| 154 | files[Dir+"/"+newID+metaName] = []byte(bad) | ||
| 155 | return nil | ||
| 156 | }, "hand-written", "test"); err != nil { | ||
| 150 | raw, err := openDB(s.Path()) | ||
| 151 | if err != nil { | ||
| 157 | 152 | t.Fatal(err) | |
| 158 | 153 | } | |
| 159 | if _, err := Open(g.Dir); err == nil { | ||
| 160 | t.Fatal("ambiguous roster loaded; it must refuse to start") | ||
| 154 | defer raw.Close() | ||
| 155 | if _, err := raw.Exec(`INSERT INTO user_keys(fingerprint, user_id, type, line) VALUES(?,?,'ssh-ed25519',?)`, | ||
| 156 | fp, bob.ID, line); err == nil { | ||
| 157 | t.Fatal("a second account was allowed to claim a registered key") | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | // TestCorruptRosterFailsStartup: a roster that does not parse must refuse to | ||
| 162 | // load, and a running store must keep its previous snapshot rather than boot | ||
| 163 | // with a partial roster. | ||
| 164 | func TestCorruptRosterFailsStartup(t *testing.T) { | ||
| 165 | s := newStore(t) | ||
| 166 | if _, err := s.Create("alice", "test"); err != nil { | ||
| 167 | t.Fatal(err) | ||
| 168 | } | ||
| 169 | raw, err := openDB(s.Path()) | ||
| 170 | if err != nil { | ||
| 171 | t.Fatal(err) | ||
| 172 | } | ||
| 173 | if _, err := raw.Exec(`UPDATE users SET name='Not A Name'`); err != nil { | ||
| 174 | t.Fatal(err) | ||
| 175 | } | ||
| 176 | raw.Close() | ||
| 177 | if _, err := Open(s.Path()); err == nil { | ||
| 178 | t.Fatal("a corrupt roster loaded; it must refuse to start") | ||
| 179 | } | ||
| 180 | if err := s.Reload(); err == nil { | ||
| 181 | t.Fatal("Reload accepted a corrupt roster") | ||
| 182 | } | ||
| 183 | if _, ok := s.Snapshot().ByName["alice"]; !ok { | ||
| 184 | t.Fatal("a failed reload discarded the working snapshot") | ||
| 161 | 185 | } | |
| 162 | 186 | } | |
| 163 | 187 | ||
| @@ -270,8 +294,8 @@ func TestInvalidNamesRefused(t *testing.T) { | |||
| 270 | 294 | } | |
| 271 | 295 | } | |
| 272 | 296 | ||
| 273 | // TestConcurrentAddsAllLand exercises the CAS tree helper: the roster is a git | ||
| 274 | // ref, so concurrent writers must retry rather than lose each other's keys. | ||
| 297 | // TestConcurrentAddsAllLand: key adds from concurrent goroutines each commit their | ||
| 298 | // own row, so none may be lost to a read-modify-write race. | ||
| 275 | 299 | func TestConcurrentAddsAllLand(t *testing.T) { | |
| 276 | 300 | s := newStore(t) | |
| 277 | 301 | if _, err := s.Create("keenan", "test"); err != nil { | |
| @@ -332,37 +356,57 @@ func TestAuditLogRecordsChanges(t *testing.T) { | |||
| 332 | 356 | } | |
| 333 | 357 | } | |
| 334 | 358 | ||
| 335 | func TestRosterHistoryIsInGit(t *testing.T) { | ||
| 359 | // TestRosterIsOneDatabaseFile proves the backup story: the roster is a single | ||
| 360 | // SQLite file, and Backup takes a consistent copy of it without a system repo or | ||
| 361 | // a directory of key files beside it. | ||
| 362 | func TestRosterIsOneDatabaseFile(t *testing.T) { | ||
| 336 | 363 | s := newStore(t) | |
| 337 | 364 | a, err := s.Create("keenan", "test") | |
| 338 | 365 | if err != nil { | |
| 339 | 366 | t.Fatal(err) | |
| 340 | 367 | } | |
| 368 | line, fp := pubLine(t) | ||
| 369 | if _, err := s.AddKey(a.ID, line, "test"); err != nil { | ||
| 370 | t.Fatal(err) | ||
| 371 | } | ||
| 341 | 372 | if _, err := s.Rename(a.ID, "robert", "test"); err != nil { | |
| 342 | 373 | t.Fatal(err) | |
| 343 | 374 | } | |
| 344 | g := gitx.Open(s.Dir()) | ||
| 345 | log, err := g.Log(Ref, 10) | ||
| 375 | |||
| 376 | copied := filepath.Join(t.TempDir(), "users.db") | ||
| 377 | if err := s.Backup(copied); err != nil { | ||
| 378 | t.Fatal(err) | ||
| 379 | } | ||
| 380 | other, err := Open(copied) | ||
| 346 | 381 | if err != nil { | |
| 347 | 382 | t.Fatal(err) | |
| 348 | 383 | } | |
| 349 | if len(log) < 2 { | ||
| 350 | t.Fatalf("commits = %d, want at least 2", len(log)) | ||
| 384 | defer other.Close() | ||
| 385 | if got, ok := other.ResolveID("robert"); !ok || got != a.ID { | ||
| 386 | t.Fatalf("copied roster resolves robert to %q,%v; want %s", got, ok, a.ID) | ||
| 351 | 387 | } | |
| 352 | // HEAD must be main, or `git log` in the roster shows an unborn branch. | ||
| 353 | if _, err := exec.Command("git", "-C", s.Dir(), "rev-parse", "--abbrev-ref", "HEAD").Output(); err != nil { | ||
| 354 | t.Fatalf("roster HEAD is not resolvable: %v", err) | ||
| 388 | if _, _, ok := other.Lookup(fp); !ok { | ||
| 389 | t.Fatal("copied roster lost the key") | ||
| 355 | 390 | } | |
| 356 | if !strings.Contains(log[0].Subject, "rename") { | ||
| 357 | t.Fatalf("newest commit does not describe the rename: %q", log[0].Subject) | ||
| 391 | if want := []string{"keenan"}; !equal(other.Snapshot().ByID[a.ID].Former, want) { | ||
| 392 | t.Fatalf("copied roster former names = %v, want %v", other.Snapshot().ByID[a.ID].Former, want) | ||
| 393 | } | ||
| 394 | |||
| 395 | for _, absent := range []string{"system.git", "trusted_keys"} { | ||
| 396 | if _, err := os.Stat(filepath.Join(filepath.Dir(s.Path()), absent)); err == nil { | ||
| 397 | t.Fatalf("%s exists; the roster is supposed to be one database file", absent) | ||
| 398 | } | ||
| 358 | 399 | } | |
| 359 | 400 | } | |
| 360 | 401 | ||
| 361 | func mustParse(t *testing.T, line string) ssh.PublicKey { | ||
| 362 | t.Helper() | ||
| 363 | pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) | ||
| 364 | if err != nil { | ||
| 365 | t.Fatal(err) | ||
| 402 | func equal(got, want []string) bool { | ||
| 403 | if len(got) != len(want) { | ||
| 404 | return false | ||
| 405 | } | ||
| 406 | for i := range got { | ||
| 407 | if got[i] != want[i] { | ||
| 408 | return false | ||
| 409 | } | ||
| 366 | 410 | } | |
| 367 | return pub | ||
| 411 | return true | ||
| 368 | 412 | } | |