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/server/integration_test.go +71−11

@@ -83,11 +83,11 @@ func newFixtureWith(t *testing.T, tweak func(*config.Config)) *fixture {
83 83 if err := os.MkdirAll(cfg.ReposDir(), 0o755); err != nil {
84 84 t.Fatal(err)
85 85 }
86 if err := identity.Init(cfg.SystemRepo()); err != nil {
86 if err := identity.Init(cfg.UsersDB()); err != nil {
87 87 t.Fatal(err)
88 88 }
89 89 pub, priv := keypair(t, filepath.Join(cfg.DataDir, "id_test"))
90 ident, err := identity.Open(cfg.SystemRepo())
90 ident, err := identity.Open(cfg.UsersDB())
91 91 if err != nil {
92 92 t.Fatal(err)
93 93 }
@@ -427,10 +427,10 @@ func TestRebuildIndexAfterDelete(t *testing.T) {
427 427 }
428 428 }
429 429
430 // TestRosterDirectoryIsTheWholeIdentity proves identity is nothing but a
431 // directory of public keys: copy it anywhere and the roster is complete, with no
432 // database and no side repository.
433 func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) {
430 // TestRosterIsOneDatabaseFile proves the backup story end to end: the roster is
431 // one SQLite file, a consistent copy of it is complete, and no system repository
432 // or key directory exists beside it.
433 func TestRosterIsOneDatabaseFile(t *testing.T) {
434 434 f := newFixture(t)
435 435 f.publish(f.repo)
436 436 work := workRepo(t, map[string]string{"readme.md": "x\n"})
@@ -438,14 +438,15 @@ func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) {
438 438 t.Fatalf("push: %s", errs)
439 439 }
440 440
441 copied := filepath.Join(t.TempDir(), "trusted_keys")
442 if out, err := exec.Command("cp", "-r", f.cfg.SystemRepo(), copied).CombinedOutput(); err != nil {
443 t.Fatalf("copy roster: %v: %s", err, out)
441 copied := filepath.Join(t.TempDir(), "users.db")
442 if err := f.srv.Identity.Backup(copied); err != nil {
443 t.Fatal(err)
444 444 }
445 445 ident, err := identity.Open(copied)
446 446 if err != nil {
447 447 t.Fatal(err)
448 448 }
449 defer ident.Close()
449 450 if _, ok := ident.Snapshot().ByName["keenan"]; !ok {
450 451 t.Fatal("copied roster has no users")
451 452 }
@@ -453,9 +454,9 @@ func TestRosterDirectoryIsTheWholeIdentity(t *testing.T) {
453 454 t.Fatalf("copied roster has %d keys", len(ident.Snapshot().ByKey))
454 455 }
455 456
456 for _, absent := range []string{"auth.db", "trusted_keys"} {
457 for _, absent := range []string{"auth.db", "system.git", "trusted_keys"} {
457 458 if _, err := os.Stat(filepath.Join(f.cfg.DataDir, absent)); err == nil {
458 t.Fatalf("%s exists; identity is supposed to be a keys directory only", absent)
459 t.Fatalf("%s exists; the roster is supposed to be one database file", absent)
459 460 }
460 461 }
461 462 }
@@ -758,6 +759,65 @@ func TestIssueCLIEndToEnd(t *testing.T) {
758 759 }
759 760 }
760 761
762 // TestRequestCLIResolvesNamesOverSSH: the roster is server-side SQLite, so a clone
763 // has no copy of it to read. `vole request new --author <name>` has to ask the
764 // instance over the SSH channel it pushes on, and the request it writes must carry
765 // the account id the server authenticated rather than the name it was given.
766 func TestRequestCLIResolvesNamesOverSSH(t *testing.T) {
767 f := newFixture(t)
768 f.publish(f.repo)
769 work := workRepo(t, map[string]string{"readme.md": "hi\n"})
770 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
771 t.Fatalf("push main: %s", errs)
772 }
773 clone := filepath.Join(t.TempDir(), "clone")
774 if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil {
775 t.Fatalf("clone: %s", errs)
776 }
777
778 run := func(args ...string) (string, string, error) {
779 cmd := exec.Command(binPath, args...)
780 cmd.Dir = clone
781 cmd.Env = append(os.Environ(),
782 "GIT_SSH_COMMAND=ssh -i "+f.keyPath+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
783 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
784 "USER=keenan")
785 var out, errb bytes.Buffer
786 cmd.Stdout, cmd.Stderr = &out, &errb
787 err := cmd.Run()
788 return out.String(), errb.String(), err
789 }
790
791 // A name the roster does not know fails before anything is pushed.
792 if _, errs, err := run("request", "new", "--title", "nobody", "-m", "x", "--author", "nobody"); err == nil {
793 t.Fatalf("an unknown author was accepted: %s", errs)
794 }
795
796 out, errs, err := run("request", "new", "--title", "Roster over SSH", "-m", "body", "--author", "keenan")
797 if err != nil {
798 t.Fatalf("request new: %v\n%s", err, errs)
799 }
800 fields := strings.Fields(out)
801 if len(fields) == 0 {
802 t.Fatalf("no request id printed: %q", out)
803 }
804 id := fields[0]
805
806 // What landed on the server names the account id, so the name was resolved
807 // from the roster rather than passed through.
808 raw, _, err := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
809 "show", "refs/heads/vole:requests/"+id+"/meta.json")
810 if err != nil {
811 t.Fatalf("read request from the server: %v\n%s", err, raw)
812 }
813 if !strings.Contains(raw, `"author": "keenan"`) {
814 t.Fatalf("request lost the author name:\n%s", raw)
815 }
816 if !strings.Contains(raw, `"author_id": "`+f.ownerID+`"`) {
817 t.Fatalf("request author_id is not the account id %s:\n%s", f.ownerID, raw)
818 }
819 }
820
761 821 func (f *fixture) serverIssues(t *testing.T) string {
762 822 t.Helper()
763 823 out, _, _ := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),