oficina / home Public

Add unmounted readonly Home database compatibility inspection

Paul Campbell pushed by paul@keymail.dev 79ab4a9d9aaef363d236c69f5ec36d6a089ddb94 parent 6bf0944
6 files changed, +1706 −0
  • internal/home/compatibility_linux.go +362 −0
  • internal/home/compatibility_linux_test.go +256 −0
  • internal/home/compatibility_profiles/credential.json +158 −0
  • internal/home/compatibility_profiles/directory.json +350 −0
  • internal/operator/readonly_linux.go +427 −0
  • internal/operator/readonly_linux_test.go +153 −0

CI passed — run details

diff --git a/internal/home/compatibility_linux.go b/internal/home/compatibility_linux.go
new file mode 100644
index 0000000..28136b6
--- /dev/null
+++ b/internal/home/compatibility_linux.go
@@ -0,0 +1,362 @@
+//go:build linux
+
+package home
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "strconv"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/internal/home/directorydata"
+ "amadan.net/oficina/home/internal/home/seal"
+ "amadan.net/oficina/home/internal/operator"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+// This component has no command or serving entry point. Only the future verified
+// launch/enrollment adapter may supply these expectations. Synthetic tests are
+// currently the sole caller; a non-nil callback is not platform authorization.
+type compatibilityConfig struct {
+ role, origin string
+ dek, pepper, signin, mac []byte
+ expectedKeys map[string]string
+ state compatibilityState
+ witness, generation string
+ team int64
+ verify func(context.Context, operator.ReadonlyExpectation) error
+}
+type compatibilityState struct {
+ Origin, Installation, Registry, Device, Head, KeyID string
+ Sequence, Completed int64
+}
+type schemaObject struct{ Type, Name, Table, SQL string }
+
+//go:embed compatibility_profiles/*.json
+var compatibilityProfiles embed.FS
+
+func compatibilitySchema(role string) *migrate.Set {
+ if role == "credential" {
+ return migrate.Merge(BootSchema, operator.CredentialSchema)
+ }
+ if role == "directory" {
+ return migrate.Merge(directorySchema, directorydata.OperatorSchema, operator.DirectorySchema)
+ }
+ return nil
+}
+
+// inspectCompatibilityDatabase implements only the database portion of the
+// approved checker. Config-facts, launch parsing and enrollment are not mounted.
+// It conservatively accepts initialized operator state only; a reviewed caller
+// for the separately enrolled empty baseline does not exist yet.
+func inspectCompatibilityDatabase(ctx context.Context, expected operator.ReadonlyExpectation, cfg compatibilityConfig) error {
+ schema := compatibilitySchema(cfg.role)
+ if schema == nil || !ValidPersonRef(cfg.witness) || !directorydata.ValidOrigin(cfg.origin) || cfg.verify == nil || cfg.state.Origin != cfg.origin || cfg.state.Sequence <= 0 || cfg.state.Completed <= 0 || !canonicalHash(cfg.state.Head) || cfg.state.Installation == "" || cfg.state.Registry == "" || cfg.state.Device == "" || cfg.state.KeyID == "" {
+ return operator.ErrRefused
+ }
+ keys := map[string][]byte{"dek": cfg.dek, "mac": cfg.mac}
+ if cfg.role == "credential" {
+ keys["pepper"] = cfg.pepper
+ keys["signin"] = cfg.signin
+ } else if len(cfg.pepper) != 0 || len(cfg.signin) != 0 {
+ return operator.ErrRefused
+ }
+ if len(cfg.expectedKeys) != len(keys) {
+ return operator.ErrRefused
+ }
+ for name, key := range keys {
+ if len(key) != 32 {
+ return operator.ErrRefused
+ }
+ h := sha256.Sum256(key)
+ if cfg.expectedKeys[name] != hex.EncodeToString(h[:]) {
+ return operator.ErrRefused
+ }
+ }
+ sealer, e := seal.New(cfg.dek)
+ if e != nil {
+ return operator.ErrRefused
+ }
+ raw, e := compatibilityProfiles.ReadFile("compatibility_profiles/" + cfg.role + ".json")
+ if e != nil {
+ return operator.ErrRefused
+ }
+ var profile []schemaObject
+ if json.Unmarshal(raw, &profile) != nil || len(profile) == 0 {
+ return operator.ErrRefused
+ }
+ return operator.InspectReadonly(ctx, expected, cfg.verify, func(ctx context.Context, c *sql.Conn) error {
+ if e := checkCompatibilitySchema(ctx, c, schema, profile); e != nil {
+ return e
+ }
+ prefix := cfg.role + "_operator"
+ var state compatibilityState
+ if c.QueryRowContext(ctx, "SELECT origin,installation,registry,device,sequence,head,completed_at,key_id FROM "+prefix+"_state WHERE singleton=1").Scan(&state.Origin, &state.Installation, &state.Registry, &state.Device, &state.Sequence, &state.Head, &state.Completed, &state.KeyID) != nil || state != cfg.state {
+ return operator.ErrRefused
+ }
+ budget := compatibilityBudget{}
+ if cfg.role == "credential" {
+ var ref string
+ if c.QueryRowContext(ctx, "SELECT person_ref FROM people WHERE person_ref=?", cfg.witness).Scan(&ref) != nil {
+ return operator.ErrRefused
+ }
+ if e := budget.scan(ctx, c, "SELECT person_ref,blind_index FROM people", func(rows *sql.Rows) error {
+ var ref, index string
+ if rows.Scan(&ref, &index) != nil || !ValidPersonRef(ref) || !hexHash(index) {
+ return operator.ErrRefused
+ }
+ return nil
+ }); e != nil {
+ return e
+ }
+ } else {
+ var generation, role string
+ if !canonicalHash(cfg.generation) || cfg.team <= 0 || c.QueryRowContext(ctx, "SELECT generation FROM roster_generation WHERE singleton=1").Scan(&generation) != nil || generation != cfg.generation {
+ return operator.ErrRefused
+ }
+ if c.QueryRowContext(ctx, "SELECT role FROM memberships WHERE team_id=? AND person_ref=?", cfg.team, cfg.witness).Scan(&role) != nil || role != "owner" {
+ return operator.ErrRefused
+ }
+ }
+ if e := budget.scan(ctx, c, "SELECT operation_id,action,sealed_result FROM "+prefix+"_results", func(rows *sql.Rows) error {
+ var id, action string
+ var ciphertext []byte
+ if rows.Scan(&id, &action, &ciphertext) != nil || !canonicalHash(id) || len(action) == 0 || len(action) > 64 {
+ return operator.ErrRefused
+ }
+ return budget.open(sealer, "operator.result/v1/"+state.Origin+"/"+id+"/"+action, ciphertext, 49180, func(raw []byte) bool { return len(raw) > 0 && json.Valid(raw) })
+ }); e != nil {
+ return e
+ }
+ if cfg.role == "credential" {
+ return budget.scan(ctx, c, "SELECT sealed_address FROM signin_links", func(rows *sql.Rows) error {
+ var ciphertext []byte
+ if rows.Scan(&ciphertext) != nil {
+ return operator.ErrRefused
+ }
+ return budget.open(sealer, "signin.address", ciphertext, 4096, func(raw []byte) bool { return len(raw) > 0 && utf8.Valid(raw) })
+ })
+ }
+ if e := budget.scan(ctx, c, "SELECT id,sqid,label,roster_epoch,sealed_name FROM teams", func(rows *sql.Rows) error {
+ var id, epoch int64
+ var sqid, label string
+ var ciphertext []byte
+ if rows.Scan(&id, &sqid, &label, &epoch, &ciphertext) != nil || id <= 0 || epoch <= 0 || !directorydata.ValidLabel(label) || len(sqid) == 0 || len(sqid) > 64 {
+ return operator.ErrRefused
+ }
+ for _, r := range sqid {
+ if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
+ return operator.ErrRefused
+ }
+ }
+ return budget.open(sealer, "directory.team-name/"+strconv.FormatInt(id, 10), ciphertext, 284, func(raw []byte) bool {
+ if len(raw) > 256 || !utf8.Valid(raw) || strings.TrimSpace(string(raw)) == "" {
+ return false
+ }
+ for _, r := range string(raw) {
+ if unicode.IsControl(r) {
+ return false
+ }
+ }
+ return true
+ })
+ }); e != nil {
+ return e
+ }
+ if e := budget.scan(ctx, c, "SELECT person_ref,role FROM memberships", func(rows *sql.Rows) error {
+ var ref, role string
+ if rows.Scan(&ref, &role) != nil || !ValidPersonRef(ref) || (role != "owner" && role != "member") {
+ return operator.ErrRefused
+ }
+ return nil
+ }); e != nil {
+ return e
+ }
+ if e := budget.scan(ctx, c, "SELECT t.id,t.sqid,t.label,i.app,i.origin,i.consumer_id FROM team_instances i JOIN teams t ON t.id=i.team_id", func(rows *sql.Rows) error {
+ var route directorydata.Route
+ if rows.Scan(&route.TeamID, &route.TeamSQID, &route.Label, &route.App, &route.Origin, &route.ConsumerID) != nil || !directoryRouteAllowed(route) || !consumerName(route.ConsumerID) {
+ return operator.ErrRefused
+ }
+ return nil
+ }); e != nil {
+ return e
+ }
+ for _, query := range []string{
+ "SELECT count(*) FROM roster_consumers r JOIN team_instances i ON i.consumer_id=r.consumer_id WHERE r.team_id!=i.team_id OR r.app!=i.app OR r.origin!=i.origin",
+ "SELECT count(*) FROM directory_operator_consumer_records r JOIN roster_operator_receipts p ON p.operation_id=r.operation_id WHERE r.generation!=p.generation OR r.sequence!=p.sequence OR r.record_digest!=p.record_digest OR r.consumer_id!=p.consumer_id",
+ } {
+ var bad int
+ if c.QueryRowContext(ctx, query).Scan(&bad) != nil || bad != 0 {
+ return operator.ErrRefused
+ }
+ }
+ for _, item := range []struct {
+ query, purpose string
+ limit int
+ path bool
+ }{
+ {"SELECT secret_hash,sealed_path FROM oficina_handoff_pending", "handoff.path.", 8192, true},
+ {"SELECT handle_hash,sealed_token FROM oficina_handoff_inbox", "handoff.token.", 16384, false},
+ } {
+ if e := budget.scan(ctx, c, item.query, func(rows *sql.Rows) error {
+ var hash string
+ var ciphertext []byte
+ if rows.Scan(&hash, &ciphertext) != nil || !hexHash(hash) {
+ return operator.ErrRefused
+ }
+ return budget.open(sealer, item.purpose+hash, ciphertext, item.limit, func(raw []byte) bool {
+ if !item.path {
+ return len(raw) > 0 && len(raw) <= 12288 && utf8.Valid(raw)
+ }
+ path := strings.TrimPrefix(string(raw), "oficina-handoff-fresh-v1:")
+ return handoff.SafePath(path)
+ })
+ }); e != nil {
+ return e
+ }
+ }
+ return nil
+ })
+}
+
+type compatibilityBudget struct{ rows, bytes int }
+
+func (b *compatibilityBudget) scan(ctx context.Context, c *sql.Conn, query string, visit func(*sql.Rows) error) error {
+ rows, e := c.QueryContext(ctx, query+" LIMIT 100001")
+ if e != nil {
+ return operator.ErrUnavailable
+ }
+ defer rows.Close()
+ for rows.Next() {
+ b.rows++
+ if b.rows > 100000 || ctx.Err() != nil {
+ return operator.ErrUnavailable
+ }
+ if e = visit(rows); e != nil {
+ return e
+ }
+ }
+ if rows.Err() != nil {
+ return operator.ErrUnavailable
+ }
+ return rows.Close()
+}
+func (b *compatibilityBudget) open(s *seal.Sealer, purpose string, ciphertext []byte, max int, valid func([]byte) bool) error {
+ b.bytes += len(ciphertext)
+ if len(ciphertext) < 29 || len(ciphertext) > max || b.bytes > 64<<20 {
+ return operator.ErrRefused
+ }
+ raw, e := s.Open(purpose, ciphertext)
+ if e != nil {
+ return operator.ErrRefused
+ }
+ defer clear(raw)
+ if !valid(raw) {
+ return operator.ErrRefused
+ }
+ return nil
+}
+func checkCompatibilitySchema(ctx context.Context, c *sql.Conn, set *migrate.Set, profile []schemaObject) error {
+ expected := map[string]string{}
+ for _, m := range set.All() {
+ if m.Fn != nil {
+ return operator.ErrRefused
+ }
+ expected[m.ID] = migrate.Checksum(m.SQL)
+ }
+ rows, e := c.QueryContext(ctx, "SELECT id,checksum FROM rastrillo_migrations LIMIT 1001")
+ if e != nil {
+ return operator.ErrRefused
+ }
+ for rows.Next() {
+ var id, sum string
+ if rows.Scan(&id, &sum) != nil || expected[id] == "" || expected[id] != sum {
+ rows.Close()
+ return operator.ErrRefused
+ }
+ delete(expected, id)
+ }
+ if rows.Err() != nil || rows.Close() != nil || len(expected) != 0 {
+ return operator.ErrRefused
+ }
+ objects := map[string]schemaObject{}
+ for _, p := range profile {
+ objects[p.Type+"/"+p.Name] = p
+ }
+ rows, e = c.QueryContext(ctx, "SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema LIMIT 1001")
+ if e != nil {
+ return operator.ErrRefused
+ }
+ for rows.Next() {
+ var p schemaObject
+ if rows.Scan(&p.Type, &p.Name, &p.Table, &p.SQL) != nil {
+ rows.Close()
+ return operator.ErrRefused
+ }
+ key := p.Type + "/" + p.Name
+ if objects[key] != p {
+ rows.Close()
+ return operator.ErrRefused
+ }
+ delete(objects, key)
+ }
+ if rows.Err() != nil || rows.Close() != nil || len(objects) != 0 {
+ return operator.ErrRefused
+ }
+ total := 0
+ for _, p := range profile {
+ if p.Type != "table" {
+ continue
+ }
+ var count int
+ query := "SELECT count(*) FROM (SELECT 1 FROM \"" + strings.ReplaceAll(p.Name, "\"", "\"\"") + "\" LIMIT 100001)"
+ if c.QueryRowContext(ctx, query).Scan(&count) != nil {
+ return operator.ErrRefused
+ }
+ total += count
+ if total > 100000 {
+ return operator.ErrUnavailable
+ }
+ }
+ rows, e = c.QueryContext(ctx, "PRAGMA foreign_key_check")
+ if e != nil {
+ return operator.ErrRefused
+ }
+ bad := rows.Next()
+ e = rows.Err()
+ closeErr := rows.Close()
+ if bad || e != nil || closeErr != nil {
+ return operator.ErrRefused
+ }
+ var n int
+ if c.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_lock LIMIT 1)").Scan(&n) != nil || n != 0 {
+ return operator.ErrRefused
+ }
+ if c.QueryRowContext(ctx, "SELECT count(*) FROM (SELECT 1 FROM _litestream_seq LIMIT 2)").Scan(&n) != nil || n > 1 {
+ return operator.ErrRefused
+ }
+ if n == 1 {
+ var valid int
+ if c.QueryRowContext(ctx, "SELECT typeof(id)='integer' AND id=1 AND typeof(seq)='integer' AND seq>=1 FROM _litestream_seq").Scan(&valid) != nil || valid != 1 {
+ return operator.ErrRefused
+ }
+ }
+ return nil
+}
+
+func canonicalHash(s string) bool {
+ raw, e := base64.RawURLEncoding.DecodeString(s)
+ return e == nil && len(raw) == 32 && base64.RawURLEncoding.EncodeToString(raw) == s
+}
+func hexHash(s string) bool {
+ raw, e := hex.DecodeString(s)
+ return e == nil && len(raw) == 32 && hex.EncodeToString(raw) == s
+}
diff --git a/internal/home/compatibility_linux_test.go b/internal/home/compatibility_linux_test.go
new file mode 100644
index 0000000..6529d8b
--- /dev/null
+++ b/internal/home/compatibility_linux_test.go
@@ -0,0 +1,256 @@
+//go:build linux
+
+package home
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/internal/home/seal"
+ "amadan.net/oficina/home/internal/operator"
+ "github.com/carlosframework/rastrillo/migrate"
+ "golang.org/x/sys/unix"
+)
+
+const replicationCompatibilityDDL = "CREATE TABLE IF NOT EXISTS _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER); CREATE TABLE IF NOT EXISTS _litestream_lock (id INTEGER);"
+
+func compatibilityFixture(t *testing.T, role string) *sql.DB {
+ t.Helper()
+ set := compatibilitySchema(role)
+ d, e := migrate.Replay(context.Background(), set.All())
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, q := range []string{migrate.LedgerDDL, replicationCompatibilityDDL} {
+ if _, e = d.Exec(q); e != nil {
+ t.Fatal(e)
+ }
+ }
+ for _, m := range set.All() {
+ if m.Fn != nil {
+ t.Fatal("unsupported Go migration")
+ }
+ if _, e = d.Exec("INSERT INTO rastrillo_migrations VALUES(?,?,?)", m.ID, "2026-01-01T00:00:00Z", migrate.Checksum(m.SQL)); e != nil {
+ t.Fatal(e)
+ }
+ }
+ return d
+}
+func readCompatibilityProfile(t *testing.T, d *sql.DB) []schemaObject {
+ t.Helper()
+ rows, e := d.Query("SELECT type,name,tbl_name,coalesce(sql,'') FROM sqlite_schema ORDER BY type,name")
+ if e != nil {
+ t.Fatal(e)
+ }
+ defer rows.Close()
+ var objects []schemaObject
+ for rows.Next() {
+ var p schemaObject
+ if e = rows.Scan(&p.Type, &p.Name, &p.Table, &p.SQL); e != nil {
+ t.Fatal(e)
+ }
+ objects = append(objects, p)
+ }
+ if rows.Err() != nil {
+ t.Fatal(rows.Err())
+ }
+ return objects
+}
+func TestCompatibilityFrozenProfiles(t *testing.T) {
+ for _, role := range []string{"credential", "directory"} {
+ t.Run(role, func(t *testing.T) {
+ d := compatibilityFixture(t, role)
+ defer d.Close()
+ raw, e := json.MarshalIndent(readCompatibilityProfile(t, d), "", " ")
+ if e != nil {
+ t.Fatal(e)
+ }
+ raw = append(raw, '\n')
+ path := "compatibility_profiles/" + role + ".json"
+ if os.Getenv("OFICINA_GENERATE_COMPATIBILITY_PROFILE") == "1" {
+ if e = os.WriteFile(path, raw, 0600); e != nil {
+ t.Fatal(e)
+ }
+ return
+ }
+ expected, e := compatibilityProfiles.ReadFile(path)
+ if e != nil || !bytes.Equal(raw, expected) {
+ t.Fatal("frozen selected-release schema changed; review and regenerate")
+ }
+ })
+ }
+}
+func compatibilityTestConfig(role string) compatibilityConfig {
+ cfg := compatibilityConfig{role: role, origin: "https://id.example.test", dek: bytes.Repeat([]byte{1}, 32), mac: bytes.Repeat([]byte{2}, 32), expectedKeys: map[string]string{}, witness: "pr_aaaaaaaaaaaaaaaaaaaaaaaaaa", generation: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{5}, 32)), team: 1}
+ if role == "credential" {
+ cfg.pepper = bytes.Repeat([]byte{3}, 32)
+ cfg.signin = bytes.Repeat([]byte{4}, 32)
+ } else {
+ cfg.origin = "https://home.example.test"
+ }
+ for name, key := range map[string][]byte{"dek": cfg.dek, "mac": cfg.mac, "pepper": cfg.pepper, "signin": cfg.signin} {
+ if len(key) == 0 {
+ continue
+ }
+ h := sha256.Sum256(key)
+ cfg.expectedKeys[name] = hex.EncodeToString(h[:])
+ }
+ cfg.state = compatibilityState{cfg.origin, "installation", "registry", "device", cfg.generation, "key", 1, 1}
+ cfg.verify = func(context.Context, operator.ReadonlyExpectation) error { return nil }
+ return cfg
+}
+func seedCompatibilityData(t *testing.T, d *sql.DB, cfg compatibilityConfig) {
+ t.Helper()
+ must := func(q string, args ...any) {
+ t.Helper()
+ if _, e := d.Exec(q, args...); e != nil {
+ t.Fatal(e)
+ }
+ }
+ must("INSERT INTO "+cfg.role+"_operator_state(singleton,origin,installation,registry,device,sequence,head,completed_at,key_id) VALUES(1,?,?,?,?,?,?,?,?)", cfg.origin, cfg.state.Installation, cfg.state.Registry, cfg.state.Device, cfg.state.Sequence, cfg.state.Head, cfg.state.Completed, cfg.state.KeyID)
+ s, _ := seal.New(cfg.dek)
+ result, _ := s.Seal("operator.result/v1/"+cfg.origin+"/"+cfg.generation+"/owner-seed", []byte(`{"ok":true}`))
+ must("INSERT INTO "+cfg.role+"_operator_results(operation_id,action,sequence,authorization_head,request_mac,sealed_result,completed_at,operator_ref) VALUES(?,?,1,?,?,?,1,'test')", cfg.generation, "owner-seed", cfg.generation, bytes.Repeat([]byte{7}, 32), result)
+ if cfg.role == "credential" {
+ h := sha256.Sum256([]byte("synthetic"))
+ must("INSERT INTO people VALUES(?,?,?)", cfg.witness, hex.EncodeToString(h[:]), "2026-01-01T00:00:00Z")
+ sealed, _ := s.Seal("signin.address", []byte("synthetic@example.invalid"))
+ must("INSERT INTO signin_links VALUES(?,?,?,?)", "hash", sealed, "signin", "2020-01-01T00:00:00Z")
+ } else {
+ sealed, _ := s.Seal("directory.team-name/1", []byte("Synthetic"))
+ must("INSERT INTO teams(id,sqid,label,sealed_name,created_at) VALUES(1,'abc','synthetic',?,1)", sealed)
+ must("INSERT INTO memberships VALUES(1,?,'owner')", cfg.witness)
+ must("INSERT INTO roster_generation VALUES(1,?)", cfg.generation)
+ hash := hex.EncodeToString(bytes.Repeat([]byte{6}, 32))
+ sealed, _ = s.Seal("handoff.path."+hash, []byte("oficina-handoff-fresh-v1:/account"))
+ must("INSERT INTO oficina_handoff_pending VALUES(?,?,?,1,2)", hash, hash, sealed)
+ sealed, _ = s.Seal("handoff.token."+hash, []byte("expired-synthetic-token"))
+ must("INSERT INTO oficina_handoff_inbox VALUES(?,?,?,1,2)", hash, hash, sealed)
+ }
+}
+func TestCompatibilityDatabaseSubprocess(t *testing.T) {
+ if os.Getenv("OFICINA_COMPATIBILITY_PROBE") == "1" {
+ var in struct {
+ Expected operator.ReadonlyExpectation
+ Role, Mode string
+ }
+ if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&in) != nil {
+ t.Fatal("input")
+ }
+ cfg := compatibilityTestConfig(in.Role)
+ switch in.Mode {
+ case "missing-proof":
+ cfg.verify = nil
+ case "missing-key":
+ delete(cfg.expectedKeys, "dek")
+ case "wrong-key":
+ cfg.dek = bytes.Repeat([]byte{9}, 32)
+ case "wrong-key-enrolled":
+ cfg.dek = bytes.Repeat([]byte{9}, 32)
+ sum := sha256.Sum256(cfg.dek)
+ cfg.expectedKeys["dek"] = hex.EncodeToString(sum[:])
+ case "wrong-state":
+ cfg.state.Sequence++
+ case "wrong-witness":
+ cfg.witness = "pr_bbbbbbbbbbbbbbbbbbbbbbbbbb"
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ e := inspectCompatibilityDatabase(ctx, in.Expected, cfg)
+ if in.Mode == "valid" {
+ if e != nil {
+ t.Fatal(e)
+ }
+ } else if e == nil {
+ t.Fatal("unsafe success", in.Mode)
+ }
+ return
+ }
+ for _, role := range []string{"credential", "directory"} {
+ for _, mode := range []string{"valid", "wrong-key-enrolled", "operator-ciphertext", "path-ciphertext", "missing-proof", "missing-key", "wrong-key", "wrong-state", "wrong-witness", "schema", "ledger", "ciphertext", "replication", "foreign-key", "empty-state"} {
+ t.Run(role+"/"+mode, func(t *testing.T) {
+ cfg := compatibilityTestConfig(role)
+ d := compatibilityFixture(t, role)
+ seedCompatibilityData(t, d, cfg)
+ q := ""
+ switch mode {
+ case "operator-ciphertext":
+ q = "UPDATE " + role + "_operator_results SET sealed_result=zeroblob(40)"
+ case "path-ciphertext":
+ if role == "directory" {
+ q = "UPDATE oficina_handoff_pending SET sealed_path=zeroblob(40)"
+ } else {
+ q = "UPDATE signin_links SET sealed_address=zeroblob(40)"
+ }
+ case "schema":
+ q = "CREATE TRIGGER unexpected AFTER DELETE ON " + role + "_operator_state BEGIN SELECT 1; END"
+ case "ledger":
+ q = "UPDATE rastrillo_migrations SET checksum='bad'"
+ case "replication":
+ q = "INSERT INTO _litestream_lock VALUES(1)"
+ case "empty-state":
+ q = "DELETE FROM " + role + "_operator_state"
+ case "ciphertext":
+ if role == "credential" {
+ q = "UPDATE signin_links SET sealed_address=zeroblob(40)"
+ } else {
+ q = "UPDATE oficina_handoff_inbox SET sealed_token=zeroblob(40)"
+ }
+ case "foreign-key":
+ if role == "directory" {
+ q = "INSERT INTO memberships VALUES(999,'pr_bbbbbbbbbbbbbbbbbbbbbbbbbb','member')"
+ } else {
+ q = "UPDATE people SET person_ref='broken'"
+ }
+ }
+ if q != "" {
+ if _, e := d.Exec(q); e != nil {
+ t.Fatal(e)
+ }
+ }
+ path := filepath.Join(t.TempDir(), "role.db")
+ if _, e := d.Exec("VACUUM INTO ?", path); e != nil {
+ t.Fatal(e)
+ }
+ if e := d.Close(); e != nil {
+ t.Fatal(e)
+ }
+ raw, e := os.ReadFile(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ h := sha256.Sum256(raw)
+ var st unix.Stat_t
+ if unix.Stat(path, &st) != nil {
+ t.Fatal("stat")
+ }
+ expected := operator.ReadonlyExpectation{Path: path, Device: uint64(st.Dev), Inode: st.Ino, Length: int64(len(raw)), SHA256: hex.EncodeToString(h[:])}
+ input, _ := json.Marshal(struct {
+ Expected operator.ReadonlyExpectation
+ Role, Mode string
+ }{expected, role, mode})
+ cmd := exec.Command(os.Args[0], "-test.run=^TestCompatibilityDatabaseSubprocess$")
+ cmd.Env = append(os.Environ(), "OFICINA_COMPATIBILITY_PROBE=1")
+ cmd.Stdin = bytes.NewReader(input)
+ if out, e := cmd.CombinedOutput(); e != nil {
+ t.Fatalf("child: %v %s", e, out)
+ }
+ after, e := os.ReadFile(path)
+ if e != nil || !bytes.Equal(raw, after) {
+ t.Fatal("database changed")
+ }
+ })
+ }
+ }
+}
diff --git a/internal/home/compatibility_profiles/credential.json b/internal/home/compatibility_profiles/credential.json
new file mode 100644
index 0000000..04d1f0b
--- /dev/null
+++ b/internal/home/compatibility_profiles/credential.json
@@ -0,0 +1,158 @@
+[
+ {
+ "Type": "index",
+ "Name": "idx_signin_links_expires_at",
+ "Table": "signin_links",
+ "SQL": "CREATE INDEX idx_signin_links_expires_at ON signin_links(expires_at)"
+ },
+ {
+ "Type": "index",
+ "Name": "passkey_credentials_subject",
+ "Table": "passkey_credentials",
+ "SQL": "CREATE INDEX passkey_credentials_subject\n ON passkey_credentials (subject)"
+ },
+ {
+ "Type": "index",
+ "Name": "passkey_recovery_codes_subject",
+ "Table": "passkey_recovery_codes",
+ "SQL": "CREATE INDEX passkey_recovery_codes_subject\n ON passkey_recovery_codes (subject)"
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_credential_operator_results_1",
+ "Table": "credential_operator_results",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_credential_operator_results_2",
+ "Table": "credential_operator_results",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_passkey_challenges_1",
+ "Table": "passkey_challenges",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_passkey_credentials_1",
+ "Table": "passkey_credentials",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_passkey_pending_1",
+ "Table": "passkey_pending",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_passkey_recovery_codes_1",
+ "Table": "passkey_recovery_codes",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_people_1",
+ "Table": "people",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_people_2",
+ "Table": "people",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_rastrillo_migrations_1",
+ "Table": "rastrillo_migrations",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_sessions_1",
+ "Table": "sessions",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_signin_links_1",
+ "Table": "signin_links",
+ "SQL": ""
+ },
+ {
+ "Type": "table",
+ "Name": "_litestream_lock",
+ "Table": "_litestream_lock",
+ "SQL": "CREATE TABLE _litestream_lock (id INTEGER)"
+ },
+ {
+ "Type": "table",
+ "Name": "_litestream_seq",
+ "Table": "_litestream_seq",
+ "SQL": "CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER)"
+ },
+ {
+ "Type": "table",
+ "Name": "credential_operator_results",
+ "Table": "credential_operator_results",
+ "SQL": "CREATE TABLE credential_operator_results (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n action TEXT NOT NULL,\n sequence INTEGER NOT NULL UNIQUE CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n authorization_head TEXT NOT NULL CHECK(length(authorization_head)=43),\n request_mac BLOB NOT NULL CHECK(typeof(request_mac)='blob' AND length(request_mac)=32),\n sealed_result BLOB NOT NULL CHECK(typeof(sealed_result)='blob' AND length(sealed_result) BETWEEN 29 AND 49180),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, operator_ref TEXT NOT NULL DEFAULT '')"
+ },
+ {
+ "Type": "table",
+ "Name": "credential_operator_state",
+ "Table": "credential_operator_state",
+ "SQL": "CREATE TABLE credential_operator_state (\n singleton INTEGER PRIMARY KEY CHECK(singleton=1),\n origin TEXT NOT NULL,\n installation TEXT NOT NULL,\n registry TEXT NOT NULL,\n device TEXT NOT NULL,\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n head TEXT NOT NULL CHECK(length(head)=43),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, key_id TEXT NOT NULL DEFAULT '')"
+ },
+ {
+ "Type": "table",
+ "Name": "passkey_challenges",
+ "Table": "passkey_challenges",
+ "SQL": "CREATE TABLE passkey_challenges (\n challenge TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "passkey_credentials",
+ "Table": "passkey_credentials",
+ "SQL": "CREATE TABLE passkey_credentials (\n id TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n public_key BLOB NOT NULL,\n sign_count INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "passkey_pending",
+ "Table": "passkey_pending",
+ "SQL": "CREATE TABLE passkey_pending (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n return_to TEXT NOT NULL DEFAULT '',\n expires_at TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "passkey_recovery_codes",
+ "Table": "passkey_recovery_codes",
+ "SQL": "CREATE TABLE passkey_recovery_codes (\n code_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n created_at TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "people",
+ "Table": "people",
+ "SQL": "CREATE TABLE people (\n person_ref TEXT PRIMARY KEY,\n blind_index TEXT NOT NULL UNIQUE, -- UNIQUE already indexes it; no second index\n created_at TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "rastrillo_migrations",
+ "Table": "rastrillo_migrations",
+ "SQL": "CREATE TABLE rastrillo_migrations (\n id TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL,\n checksum TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "sessions",
+ "Table": "sessions",
+ "SQL": "CREATE TABLE sessions (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n auth_time TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "signin_links",
+ "Table": "signin_links",
+ "SQL": "CREATE TABLE signin_links (\n token_hash TEXT PRIMARY KEY,\n sealed_address BLOB NOT NULL,\n purpose TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)"
+ }
+]
diff --git a/internal/home/compatibility_profiles/directory.json b/internal/home/compatibility_profiles/directory.json
new file mode 100644
index 0000000..a7b5c02
--- /dev/null
+++ b/internal/home/compatibility_profiles/directory.json
@@ -0,0 +1,350 @@
+[
+ {
+ "Type": "index",
+ "Name": "memberships_person",
+ "Table": "memberships",
+ "SQL": "CREATE INDEX memberships_person ON memberships(person_ref, team_id)"
+ },
+ {
+ "Type": "index",
+ "Name": "oficina_handoff_inbox_expiry",
+ "Table": "oficina_handoff_inbox",
+ "SQL": "CREATE INDEX oficina_handoff_inbox_expiry ON oficina_handoff_inbox(expires_at)"
+ },
+ {
+ "Type": "index",
+ "Name": "oficina_handoff_pending_expiry",
+ "Table": "oficina_handoff_pending",
+ "SQL": "CREATE INDEX oficina_handoff_pending_expiry ON oficina_handoff_pending(expires_at)"
+ },
+ {
+ "Type": "index",
+ "Name": "oficina_handoff_replay_expiry",
+ "Table": "oficina_handoff_replay",
+ "SQL": "CREATE INDEX oficina_handoff_replay_expiry ON oficina_handoff_replay(expires_at)"
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_directory_operator_consumer_records_1",
+ "Table": "directory_operator_consumer_records",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_directory_operator_consumer_records_2",
+ "Table": "directory_operator_consumer_records",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_directory_operator_owner_receipts_1",
+ "Table": "directory_operator_owner_receipts",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_directory_operator_owner_receipts_2",
+ "Table": "directory_operator_owner_receipts",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_directory_operator_owner_receipts_3",
+ "Table": "directory_operator_owner_receipts",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_directory_operator_results_1",
+ "Table": "directory_operator_results",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_directory_operator_results_2",
+ "Table": "directory_operator_results",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_memberships_1",
+ "Table": "memberships",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_oficina_handoff_inbox_1",
+ "Table": "oficina_handoff_inbox",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_oficina_handoff_inbox_2",
+ "Table": "oficina_handoff_inbox",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_oficina_handoff_pending_1",
+ "Table": "oficina_handoff_pending",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_oficina_handoff_pending_2",
+ "Table": "oficina_handoff_pending",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_oficina_handoff_replay_1",
+ "Table": "oficina_handoff_replay",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_rastrillo_migrations_1",
+ "Table": "rastrillo_migrations",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_roster_consumers_1",
+ "Table": "roster_consumers",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_roster_operator_receipts_1",
+ "Table": "roster_operator_receipts",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_roster_tokens_1",
+ "Table": "roster_tokens",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_roster_tokens_2",
+ "Table": "roster_tokens",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_sessions_1",
+ "Table": "sessions",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_team_instances_1",
+ "Table": "team_instances",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_team_instances_2",
+ "Table": "team_instances",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_team_instances_3",
+ "Table": "team_instances",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_teams_1",
+ "Table": "teams",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_teams_2",
+ "Table": "teams",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_verification_active_1",
+ "Table": "verification_active",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_verification_active_2",
+ "Table": "verification_active",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_verification_attempts_1",
+ "Table": "verification_attempts",
+ "SQL": ""
+ },
+ {
+ "Type": "index",
+ "Name": "sqlite_autoindex_verification_outcomes_1",
+ "Table": "verification_outcomes",
+ "SQL": ""
+ },
+ {
+ "Type": "table",
+ "Name": "_litestream_lock",
+ "Table": "_litestream_lock",
+ "SQL": "CREATE TABLE _litestream_lock (id INTEGER)"
+ },
+ {
+ "Type": "table",
+ "Name": "_litestream_seq",
+ "Table": "_litestream_seq",
+ "SQL": "CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY, seq INTEGER)"
+ },
+ {
+ "Type": "table",
+ "Name": "directory_operator_consumer_records",
+ "Table": "directory_operator_consumer_records",
+ "SQL": "CREATE TABLE directory_operator_consumer_records (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES roster_operator_receipts(operation_id),\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "directory_operator_owner_receipts",
+ "Table": "directory_operator_owner_receipts",
+ "SQL": "CREATE TABLE directory_operator_owner_receipts (\n issuer TEXT NOT NULL,\n owner_operation_id TEXT NOT NULL CHECK(length(owner_operation_id)=43),\n directory_operation_id TEXT NOT NULL UNIQUE REFERENCES directory_operator_results(operation_id) DEFERRABLE INITIALLY DEFERRED,\n team_id INTEGER NOT NULL UNIQUE REFERENCES teams(id),\n PRIMARY KEY(issuer,owner_operation_id)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "directory_operator_results",
+ "Table": "directory_operator_results",
+ "SQL": "CREATE TABLE directory_operator_results (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n action TEXT NOT NULL,\n sequence INTEGER NOT NULL UNIQUE CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n authorization_head TEXT NOT NULL CHECK(length(authorization_head)=43),\n request_mac BLOB NOT NULL CHECK(typeof(request_mac)='blob' AND length(request_mac)=32),\n sealed_result BLOB NOT NULL CHECK(typeof(sealed_result)='blob' AND length(sealed_result) BETWEEN 29 AND 49180),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, operator_ref TEXT NOT NULL DEFAULT '')"
+ },
+ {
+ "Type": "table",
+ "Name": "directory_operator_state",
+ "Table": "directory_operator_state",
+ "SQL": "CREATE TABLE directory_operator_state (\n singleton INTEGER PRIMARY KEY CHECK(singleton=1),\n origin TEXT NOT NULL,\n installation TEXT NOT NULL,\n registry TEXT NOT NULL,\n device TEXT NOT NULL,\n sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence\u003e0),\n head TEXT NOT NULL CHECK(length(head)=43),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0)\n, key_id TEXT NOT NULL DEFAULT '')"
+ },
+ {
+ "Type": "table",
+ "Name": "memberships",
+ "Table": "memberships",
+ "SQL": "CREATE TABLE memberships (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n person_ref TEXT NOT NULL CHECK(length(person_ref) = 29 AND substr(person_ref,1,3) = 'pr_' AND substr(person_ref,4) NOT GLOB '*[^a-z2-7]*'),\n role TEXT NOT NULL CHECK(role IN ('owner', 'member')),\n PRIMARY KEY(team_id, person_ref)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "oficina_handoff_clock",
+ "Table": "oficina_handoff_clock",
+ "SQL": "CREATE TABLE oficina_handoff_clock (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n observed INTEGER NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "oficina_handoff_inbox",
+ "Table": "oficina_handoff_inbox",
+ "SQL": "CREATE TABLE oficina_handoff_inbox (\n handle_hash TEXT PRIMARY KEY,\n jti_hash TEXT NOT NULL UNIQUE,\n sealed_token BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "oficina_handoff_pending",
+ "Table": "oficina_handoff_pending",
+ "SQL": "CREATE TABLE oficina_handoff_pending (\n secret_hash TEXT PRIMARY KEY,\n nonce_hash TEXT NOT NULL UNIQUE,\n sealed_path BLOB NOT NULL,\n created_at INTEGER NOT NULL,\n expires_at INTEGER NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "oficina_handoff_replay",
+ "Table": "oficina_handoff_replay",
+ "SQL": "CREATE TABLE oficina_handoff_replay (\n jti_hash TEXT PRIMARY KEY,\n expires_at INTEGER NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "rastrillo_migrations",
+ "Table": "rastrillo_migrations",
+ "SQL": "CREATE TABLE rastrillo_migrations (\n id TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL,\n checksum TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "roster_consumers",
+ "Table": "roster_consumers",
+ "SQL": "CREATE TABLE roster_consumers (\n consumer_id TEXT PRIMARY KEY REFERENCES team_instances(consumer_id) ON DELETE RESTRICT,\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK(enabled IN (0,1)),\n FOREIGN KEY(team_id,app) REFERENCES team_instances(team_id,app) ON DELETE RESTRICT\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "roster_generation",
+ "Table": "roster_generation",
+ "SQL": "CREATE TABLE roster_generation (\n singleton INTEGER PRIMARY KEY CHECK(singleton = 1),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*')\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "roster_operator_receipts",
+ "Table": "roster_operator_receipts",
+ "SQL": "CREATE TABLE roster_operator_receipts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id) BETWEEN 1 AND 128 AND operation_id NOT GLOB '*[^a-zA-Z0-9_-]*'),\n sequence INTEGER NOT NULL CHECK(typeof(sequence) = 'integer' AND sequence \u003e 0),\n operation TEXT NOT NULL CHECK(operation IN ('bootstrap','provision','rotate','disable','restore')),\n consumer_id TEXT CHECK(consumer_id IS NULL OR (length(consumer_id) BETWEEN 1 AND 128 AND consumer_id NOT GLOB '*[^a-zA-Z0-9_-]*')),\n generation TEXT NOT NULL CHECK(length(generation) = 43 AND generation NOT GLOB '*[^a-zA-Z0-9_-]*'),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest) = 'blob' AND length(record_digest) = 32),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at) = 'integer' AND completed_at \u003e 0)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "roster_tokens",
+ "Table": "roster_tokens",
+ "SQL": "CREATE TABLE roster_tokens (\n token_hash BLOB PRIMARY KEY CHECK(typeof(token_hash) = 'blob' AND length(token_hash) = 32),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id) ON DELETE RESTRICT,\n slot TEXT NOT NULL CHECK(slot IN ('current','previous')),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at) = 'integer' AND issued_at \u003e 0),\n expires_at INTEGER,\n UNIQUE(consumer_id,slot),\n CHECK((slot = 'current' AND expires_at IS NULL) OR\n (slot = 'previous' AND typeof(expires_at) = 'integer' AND expires_at \u003e issued_at AND expires_at - issued_at \u003c= 300))\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "sessions",
+ "Table": "sessions",
+ "SQL": "CREATE TABLE sessions (\n token_hash TEXT PRIMARY KEY,\n subject TEXT NOT NULL,\n method TEXT NOT NULL DEFAULT '',\n auth_time TEXT NOT NULL DEFAULT '',\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "sqlite_sequence",
+ "Table": "sqlite_sequence",
+ "SQL": "CREATE TABLE sqlite_sequence(name,seq)"
+ },
+ {
+ "Type": "table",
+ "Name": "team_instances",
+ "Table": "team_instances",
+ "SQL": "CREATE TABLE team_instances (\n team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,\n app TEXT NOT NULL,\n origin TEXT NOT NULL UNIQUE,\n consumer_id TEXT NOT NULL UNIQUE,\n state TEXT NOT NULL CHECK(state IN ('pending', 'live', 'disabled')),\n PRIMARY KEY(team_id, app)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "teams",
+ "Table": "teams",
+ "SQL": "CREATE TABLE teams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n sqid TEXT NOT NULL UNIQUE CHECK(length(sqid) BETWEEN 1 AND 64 AND sqid NOT GLOB '*[^a-zA-Z0-9]*'),\n label TEXT NOT NULL UNIQUE CHECK(length(label) BETWEEN 1 AND 63 AND label NOT GLOB '*[^a-z0-9-]*' AND substr(label,1,1) != '-' AND substr(label,-1,1) != '-'),\n sealed_name BLOB NOT NULL CHECK(typeof(sealed_name) = 'blob' AND length(sealed_name) BETWEEN 29 AND 284),\n created_at INTEGER NOT NULL CHECK(created_at \u003e 0),\n roster_epoch INTEGER NOT NULL DEFAULT 1 CHECK(typeof(roster_epoch) = 'integer' AND roster_epoch \u003e 0)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "verification_active",
+ "Table": "verification_active",
+ "SQL": "CREATE TABLE verification_active (\n consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),\n operation_id TEXT NOT NULL UNIQUE REFERENCES verification_attempts(operation_id)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "verification_attempts",
+ "Table": "verification_attempts",
+ "SQL": "CREATE TABLE verification_attempts (\n operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),\n consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id),\n team_id INTEGER NOT NULL REFERENCES teams(id),\n app TEXT NOT NULL,\n origin TEXT NOT NULL,\n generation TEXT NOT NULL CHECK(length(generation)=43),\n record_sequence INTEGER NOT NULL CHECK(typeof(record_sequence)='integer' AND record_sequence\u003e0),\n record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32),\n expected_epoch INTEGER NOT NULL CHECK(typeof(expected_epoch)='integer' AND expected_epoch\u003e0),\n owner_ref TEXT NOT NULL CHECK(length(owner_ref)=29),\n issued_at INTEGER NOT NULL CHECK(typeof(issued_at)='integer' AND issued_at\u003e0),\n expires_at INTEGER NOT NULL CHECK(typeof(expires_at)='integer' AND expires_at\u003eissued_at AND expires_at-issued_at\u003c=600),\n authorization_digest BLOB NOT NULL CHECK(typeof(authorization_digest)='blob' AND length(authorization_digest)=32)\n)"
+ },
+ {
+ "Type": "table",
+ "Name": "verification_outcomes",
+ "Table": "verification_outcomes",
+ "SQL": "CREATE TABLE verification_outcomes (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n attempt_id TEXT NOT NULL REFERENCES verification_attempts(operation_id),\n operator_operation_id TEXT NOT NULL CHECK(length(operator_operation_id)=43),\n kind TEXT NOT NULL CHECK(kind IN ('opened','expired','activated')),\n completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at\u003e0),\n UNIQUE(attempt_id,operator_operation_id,kind)\n)"
+ },
+ {
+ "Type": "trigger",
+ "Name": "memberships_epoch_delete",
+ "Table": "memberships",
+ "SQL": "CREATE TRIGGER memberships_epoch_delete AFTER DELETE ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = OLD.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = OLD.team_id;\nEND"
+ },
+ {
+ "Type": "trigger",
+ "Name": "memberships_epoch_insert",
+ "Table": "memberships",
+ "SQL": "CREATE TRIGGER memberships_epoch_insert AFTER INSERT ON memberships BEGIN\n SELECT CASE WHEN (SELECT roster_epoch FROM teams WHERE id = NEW.team_id) = 9223372036854775807 THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id = NEW.team_id;\nEND"
+ },
+ {
+ "Type": "trigger",
+ "Name": "memberships_epoch_update",
+ "Table": "memberships",
+ "SQL": "CREATE TRIGGER memberships_epoch_update AFTER UPDATE ON memberships BEGIN\n SELECT CASE WHEN EXISTS(SELECT 1 FROM teams WHERE id IN (OLD.team_id,NEW.team_id) AND roster_epoch = 9223372036854775807) THEN RAISE(ABORT,'roster epoch exhausted') END;\n UPDATE teams SET roster_epoch = roster_epoch + 1 WHERE id IN (OLD.team_id,NEW.team_id);\nEND"
+ }
+]
diff --git a/internal/operator/readonly_linux.go b/internal/operator/readonly_linux.go
new file mode 100644
index 0000000..3ef8066
--- /dev/null
+++ b/internal/operator/readonly_linux.go
@@ -0,0 +1,427 @@
+//go:build linux
+
+package operator
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "database/sql/driver"
+ "encoding/hex"
+ "io"
+ "net/url"
+ "os"
+ "sync"
+ "time"
+
+ "golang.org/x/sys/unix"
+ "modernc.org/sqlite"
+)
+
+// ReadonlyExpectation is an observation required by the unmounted checker.
+// It is not launch authority. Verify must check the independently enrolled and
+// platform-verified handoff; deriving expectations from this database is invalid.
+type ReadonlyExpectation struct {
+ Path string
+ Device, Inode uint64
+ Length int64
+ SHA256 string
+}
+
+// InspectReadonly runs only in the dedicated non-serving checker process, after
+// its launch descriptors are closed. Verify owns launch/enrollment verification;
+// no production implementation exists yet. The callback must not open files,
+// start goroutines, retain the connection, or perform network or serving work.
+// Success is withheld until driver closure and the final exact byte comparison.
+func InspectReadonly(ctx context.Context, expected ReadonlyExpectation, verify func(context.Context, ReadonlyExpectation) error, inspect func(context.Context, *sql.Conn) error) error {
+ return inspectReadonly(ctx, expected, verify, inspect, nil)
+}
+
+func inspectReadonly(ctx context.Context, expected ReadonlyExpectation, verify func(context.Context, ReadonlyExpectation) error, inspect func(context.Context, *sql.Conn) error, hook func(string)) (err error) {
+ deadline, bounded := ctx.Deadline()
+ if !bounded || !deadline.After(time.Now()) || verify == nil || inspect == nil || expected.Length <= 0 || expected.Length > 256<<20 || len(expected.SHA256) != 64 {
+ return ErrRefused
+ }
+ hash, e := hex.DecodeString(expected.SHA256)
+ if e != nil || hex.EncodeToString(hash) != expected.SHA256 || expected.Device == 0 || expected.Inode == 0 {
+ return ErrRefused
+ }
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ deadline, _ = ctx.Deadline()
+ if verify(ctx, expected) != nil {
+ return ErrRefused
+ }
+ pin, id, e := pinExisting(expected.Path)
+ if e != nil {
+ return e
+ }
+ defer func() {
+ if pin.Close() != nil {
+ err = ErrUnavailable
+ }
+ }()
+ if id != (fileIdentity{expected.Device, expected.Inode}) {
+ return ErrRefused
+ }
+ c := &readonlyConnector{path: expected.Path, pin: pin, identity: id, deadline: deadline, sqlite: &sqlite.Driver{}, hook: hook}
+ if c.pathMatches() != nil || hashReadonly(ctx, pin, expected) != nil {
+ return ErrRefused
+ }
+ d := sql.OpenDB(c)
+ d.SetMaxOpenConns(1)
+ d.SetMaxIdleConns(1)
+ defer func() {
+ if d.Close() != nil {
+ err = ErrUnavailable
+ }
+ }()
+ conn, e := d.Conn(ctx)
+ if e != nil {
+ return ErrUnavailable
+ }
+ defer conn.Close()
+ if _, e = conn.ExecContext(ctx, "PRAGMA query_only=ON"); e != nil {
+ return ErrUnavailable
+ }
+ if _, e = conn.ExecContext(ctx, "PRAGMA temp_store=MEMORY"); e != nil {
+ return ErrUnavailable
+ }
+ var temp int
+ if conn.QueryRowContext(ctx, "PRAGMA temp_store").Scan(&temp) != nil || temp != 2 {
+ return ErrRefused
+ }
+ if _, e = conn.ExecContext(ctx, "BEGIN"); e != nil {
+ return ErrUnavailable
+ }
+ e = inspect(ctx, conn)
+ if _, end := conn.ExecContext(ctx, "ROLLBACK"); end != nil {
+ return ErrUnavailable
+ }
+ if e != nil {
+ return ErrRefused
+ }
+ if conn.Close() != nil || d.Close() != nil {
+ return ErrUnavailable
+ }
+ if c.pathMatches() != nil || hashReadonly(ctx, pin, expected) != nil || verify(ctx, expected) != nil {
+ return ErrRefused
+ }
+ return nil
+}
+
+func hashReadonly(ctx context.Context, pin *os.File, expected ReadonlyExpectation) error {
+ info, e := pin.Stat()
+ if e != nil || info.Size() != expected.Length {
+ return ErrRefused
+ }
+ h := sha256.New()
+ buf := make([]byte, 64<<10)
+ for off := int64(0); off < expected.Length; {
+ if ctx.Err() != nil {
+ return ErrUnavailable
+ }
+ size := int64(len(buf))
+ if expected.Length-off < size {
+ size = expected.Length - off
+ }
+ n, e := pin.ReadAt(buf[:size], off)
+ if e != nil || n != int(size) {
+ return ErrUnavailable
+ }
+ h.Write(buf[:n])
+ off += int64(n)
+ }
+ if hex.EncodeToString(h.Sum(nil)) != expected.SHA256 {
+ return ErrRefused
+ }
+ info, e = pin.Stat()
+ if e != nil || info.Size() != expected.Length {
+ return ErrRefused
+ }
+ return nil
+}
+
+type readonlyConnector struct {
+ mu sync.Mutex
+ path string
+ pin *os.File
+ identity fileIdentity
+ deadline time.Time
+ sqlite *sqlite.Driver
+ active bool
+ hook func(string)
+}
+type readonlyConnection struct {
+ driver.Conn
+ owner *readonlyConnector
+ mainFD int
+ baseline map[int]descriptor
+ closed bool
+}
+
+func (c *readonlyConnector) Driver() driver.Driver { return c.sqlite }
+func (c *readonlyConnector) checkpoint(stage string) {
+ if c.hook != nil {
+ c.hook(stage)
+ }
+}
+func (c *readonlyConnector) pathMatches() error {
+ if !time.Now().Before(c.deadline) {
+ return ErrRefused
+ }
+ var s unix.Stat_t
+ if unix.Fstat(int(c.pin.Fd()), &s) != nil || (fileIdentity{uint64(s.Dev), s.Ino}) != c.identity || s.Nlink != 1 {
+ return ErrRefused
+ }
+ id, e := statExisting(c.path)
+ if e != nil || id != c.identity {
+ return ErrRefused
+ }
+ for _, suffix := range []string{"-wal", "-shm", "-journal"} {
+ _, e := os.Lstat(c.path + suffix)
+ if !os.IsNotExist(e) {
+ return ErrRefused
+ }
+ }
+ return nil
+}
+func (c *readonlyConnector) Connect(ctx context.Context) (driver.Conn, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.active || ctx.Err() != nil || c.pathMatches() != nil {
+ return nil, ErrRefused
+ }
+ baseline, e := descriptors()
+ if e != nil {
+ return nil, e
+ }
+ for _, d := range baseline {
+ if writable(d.flags) {
+ return nil, ErrRefused
+ }
+ }
+ c.checkpoint("before-driver")
+ u := url.URL{Scheme: "file", Path: c.path}
+ q := u.Query()
+ q.Set("mode", "ro")
+ q.Set("immutable", "1")
+ u.RawQuery = q.Encode()
+ native, e := c.sqlite.Open(u.String())
+ if e != nil {
+ return nil, ErrUnavailable
+ }
+ keep := false
+ defer func() {
+ if !keep {
+ native.Close()
+ }
+ }()
+ c.checkpoint("after-open")
+ all, e := descriptors()
+ if e != nil {
+ return nil, e
+ }
+ main := -1
+ for fd, d := range all {
+ if old, ok := baseline[fd]; ok {
+ if old != d {
+ return nil, ErrRefused
+ }
+ continue
+ }
+ if d.identity != c.identity || d.flags&unix.O_ACCMODE != unix.O_RDONLY || d.links != 1 || main != -1 {
+ return nil, ErrRefused
+ }
+ main = fd
+ }
+ if main < 0 {
+ return nil, ErrRefused
+ }
+ bound := &readonlyConnection{Conn: native, owner: c, mainFD: main, baseline: baseline}
+ if bound.check() != nil {
+ return nil, ErrRefused
+ }
+ query, ok := native.(driver.QueryerContext)
+ if !ok {
+ return nil, ErrRefused
+ }
+ rows, e := query.QueryContext(ctx, "PRAGMA database_list", nil)
+ if e != nil {
+ return nil, ErrUnavailable
+ }
+ values := make([]driver.Value, 3)
+ if rows.Next(values) != nil || values[1] != "main" || values[2] != c.path {
+ rows.Close()
+ return nil, ErrRefused
+ }
+ if rows.Next(values) != io.EOF {
+ rows.Close()
+ return nil, ErrRefused
+ }
+ if rows.Close() != nil || bound.check() != nil {
+ return nil, ErrUnavailable
+ }
+ c.active = true
+ keep = true
+ return bound, nil
+}
+func (c *readonlyConnection) check() error {
+ if c.closed || c.owner.pathMatches() != nil {
+ return ErrRefused
+ }
+ all, e := descriptors()
+ if e != nil {
+ return e
+ }
+ if len(all) != len(c.baseline)+1 {
+ return ErrRefused
+ }
+ for fd, old := range c.baseline {
+ if all[fd] != old {
+ return ErrRefused
+ }
+ }
+ main, ok := all[c.mainFD]
+ if !ok || main.identity != c.owner.identity || main.flags&unix.O_ACCMODE != unix.O_RDONLY || main.links != 1 {
+ return ErrRefused
+ }
+ return nil
+}
+func (c *readonlyConnection) Close() error {
+ c.owner.mu.Lock()
+ defer c.owner.mu.Unlock()
+ if c.closed {
+ return nil
+ }
+ c.closed = true
+ e := c.Conn.Close()
+ c.owner.active = false
+ return e
+}
+func (c *readonlyConnection) Prepare(string) (driver.Stmt, error) { return nil, ErrRefused }
+func (c *readonlyConnection) PrepareContext(context.Context, string) (driver.Stmt, error) {
+ return nil, ErrRefused
+}
+func (c *readonlyConnection) Begin() (driver.Tx, error) {
+ return c.BeginTx(context.Background(), driver.TxOptions{})
+}
+func (c *readonlyConnection) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
+ if c.check() != nil {
+ return nil, ErrRefused
+ }
+ x, ok := c.Conn.(driver.ConnBeginTx)
+ if !ok {
+ return nil, ErrRefused
+ }
+ tx, e := x.BeginTx(ctx, opts)
+ if e != nil {
+ return nil, e
+ }
+ if c.check() != nil {
+ tx.Rollback()
+ return nil, ErrUnavailable
+ }
+ return &readonlyTransaction{tx, c}, nil
+}
+func (c *readonlyConnection) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
+ if query != "ROLLBACK" && c.check() != nil {
+ return nil, ErrRefused
+ }
+ x, ok := c.Conn.(driver.ExecerContext)
+ if !ok {
+ return nil, ErrRefused
+ }
+ result, e := x.ExecContext(ctx, query, args)
+ if e != nil {
+ return nil, e
+ }
+ c.owner.checkpoint("after-exec")
+ if query != "ROLLBACK" && c.check() != nil {
+ return nil, ErrUnavailable
+ }
+ return result, nil
+}
+func (c *readonlyConnection) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
+ if c.check() != nil {
+ return nil, ErrRefused
+ }
+ x, ok := c.Conn.(driver.QueryerContext)
+ if !ok {
+ return nil, ErrRefused
+ }
+ rows, e := x.QueryContext(ctx, query, args)
+ if e != nil {
+ return nil, e
+ }
+ if c.check() != nil {
+ rows.Close()
+ return nil, ErrUnavailable
+ }
+ return &readonlyRows{rows, c}, nil
+}
+func (c *readonlyConnection) Ping(ctx context.Context) error {
+ if c.check() != nil {
+ return ErrRefused
+ }
+ p, ok := c.Conn.(driver.Pinger)
+ if !ok {
+ return ErrRefused
+ }
+ if e := p.Ping(ctx); e != nil {
+ return e
+ }
+ if c.check() != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
+func (c *readonlyConnection) ResetSession(ctx context.Context) error {
+ if c.check() != nil {
+ return ErrRefused
+ }
+ if r, ok := c.Conn.(driver.SessionResetter); ok {
+ return r.ResetSession(ctx)
+ }
+ return nil
+}
+func (c *readonlyConnection) IsValid() bool { return c.check() == nil }
+
+type readonlyRows struct {
+ driver.Rows
+ c *readonlyConnection
+}
+
+func (r *readonlyRows) Next(values []driver.Value) error {
+ if r.c.check() != nil {
+ return ErrRefused
+ }
+ e := r.Rows.Next(values)
+ if e != nil {
+ return e
+ }
+ if r.c.check() != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
+
+type readonlyTransaction struct {
+ driver.Tx
+ c *readonlyConnection
+}
+
+func (t *readonlyTransaction) Commit() error {
+ if t.c.check() != nil {
+ return ErrRefused
+ }
+ e := t.Tx.Commit()
+ t.c.owner.checkpoint("after-commit")
+ if e != nil {
+ return e
+ }
+ if t.c.check() != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
diff --git a/internal/operator/readonly_linux_test.go b/internal/operator/readonly_linux_test.go
new file mode 100644
index 0000000..e7f9e19
--- /dev/null
+++ b/internal/operator/readonly_linux_test.go
@@ -0,0 +1,153 @@
+//go:build linux
+
+package operator
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestReadonlySubprocess(t *testing.T) {
+ if os.Getenv("OFICINA_READONLY_PROBE") == "1" {
+ var input struct {
+ Expected ReadonlyExpectation
+ Mode string
+ }
+ if json.NewDecoder(io.LimitReader(os.Stdin, 16384)).Decode(&input) != nil {
+ t.Fatal("input")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ calls := 0
+ proof := func(context.Context, ReadonlyExpectation) error {
+ calls++
+ if input.Mode == "proof-revoked" && calls > 1 {
+ return ErrRefused
+ }
+ return nil
+ }
+ if input.Mode == "missing-proof" {
+ proof = nil
+ }
+ if input.Mode == "wrong-hash" {
+ input.Expected.SHA256 = string(bytes.Repeat([]byte("0"), 64))
+ }
+ if input.Mode == "wrong-inode" {
+ input.Expected.Inode++
+ }
+ if input.Mode == "expired" {
+ cancel()
+ }
+ if input.Mode == "unbounded" {
+ ctx = context.Background()
+ }
+ var extra *os.File
+ defer func() {
+ if extra != nil {
+ extra.Close()
+ }
+ }()
+ inspect := func(ctx context.Context, c *sql.Conn) error {
+ if input.Mode == "spill" {
+ var e error
+ extra, e = os.Create(input.Expected.Path + ".spill")
+ if e != nil {
+ return e
+ }
+ }
+ var count int
+ if e := c.QueryRowContext(ctx, "SELECT count(*) FROM changes").Scan(&count); e != nil {
+ return e
+ }
+ if count != 0 {
+ return ErrRefused
+ }
+ if input.Mode == "write" {
+ _, e := c.ExecContext(ctx, "INSERT INTO changes VALUES(1)")
+ return e
+ }
+ return nil
+ }
+ hook := func(stage string) {
+ if input.Mode == "replace-restore" {
+ if stage == "before-driver" {
+ if os.Rename(input.Expected.Path, input.Expected.Path+".saved") != nil || os.Rename(input.Expected.Path+".other", input.Expected.Path) != nil {
+ t.Fatal("swap")
+ }
+ }
+ if stage == "after-open" {
+ if os.Rename(input.Expected.Path, input.Expected.Path+".other") != nil || os.Rename(input.Expected.Path+".saved", input.Expected.Path) != nil {
+ t.Fatal("restore")
+ }
+ }
+ }
+ if input.Mode == "change-after-read" && stage == "after-exec" {
+ // Alter an unused SQLite header byte through an external process: no
+ // extra descriptor is admitted into the checker process.
+ cmd := exec.Command("python3", "-c", "import sys; f=open(sys.argv[1],'r+b'); f.seek(72); f.write(b'\\x01'); f.close()", input.Expected.Path)
+ if e := cmd.Run(); e != nil {
+ t.Fatal(e)
+ }
+ }
+ }
+ err := inspectReadonly(ctx, input.Expected, proof, inspect, hook)
+ if input.Mode == "valid" {
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else if err == nil {
+ t.Fatal("unsafe success", input.Mode)
+ }
+ return
+ }
+ for _, mode := range []string{"valid", "proof-revoked", "replace-restore", "missing-proof", "wrong-hash", "wrong-inode", "expired", "unbounded", "wal", "shm", "journal", "spill", "write", "change-after-read"} {
+ t.Run(mode, func(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "test.db")
+ seedOpenDB(t, path)
+ if mode == "replace-restore" {
+ seedOpenDB(t, path+".other")
+ }
+ raw, e := os.ReadFile(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ h := sha256.Sum256(raw)
+ id, e := statExisting(path)
+ if e != nil {
+ t.Fatal(e)
+ }
+ expected := ReadonlyExpectation{path, id.device, id.inode, int64(len(raw)), hex.EncodeToString(h[:])}
+ if mode == "wal" || mode == "shm" || mode == "journal" {
+ if e := os.WriteFile(path+"-"+mode, []byte("retained"), 0600); e != nil {
+ t.Fatal(e)
+ }
+ }
+ input, _ := json.Marshal(struct {
+ Expected ReadonlyExpectation
+ Mode string
+ }{expected, mode})
+ cmd := exec.Command(os.Args[0], "-test.run=^TestReadonlySubprocess$")
+ cmd.Env = append(os.Environ(), "OFICINA_READONLY_PROBE=1")
+ cmd.Stdin = bytes.NewReader(input)
+ if out, e := cmd.CombinedOutput(); e != nil {
+ t.Fatalf("child: %v %s", e, out)
+ }
+ if mode != "change-after-read" {
+ after, e := os.ReadFile(path)
+ if e != nil || !bytes.Equal(raw, after) {
+ t.Fatal("database bytes changed")
+ }
+ }
+ })
+ }
+}