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.

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 }