| 1 | //go:build linux |
| 2 | |
| 3 | package home |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/sha256" |
| 9 | "database/sql" |
| 10 | "encoding/hex" |
| 11 | "encoding/json" |
| 12 | "go/ast" |
| 13 | "go/parser" |
| 14 | "go/token" |
| 15 | "io/fs" |
| 16 | "os" |
| 17 | "path/filepath" |
| 18 | "sort" |
| 19 | "strings" |
| 20 | "testing" |
| 21 | |
| 22 | "amadan.net/rastrillo/rastrillo/migrate" |
| 23 | ) |
| 24 | |
| 25 | func TestSchemaCommitmentFrozenCatalog(t *testing.T) { |
| 26 | for _, id := range []string{"credential-current-v1", "directory-current-v1", "directory-historical-empty-v1"} { |
| 27 | t.Run(id, func(t *testing.T) { |
| 28 | role := strings.Split(id, "-")[0] |
| 29 | migrations := compatibilitySchema(role).All() |
| 30 | objectsRaw, err := compatibilityProfiles.ReadFile("compatibility_profiles/" + role + ".json") |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | empty := []string{} |
| 35 | if id == "directory-historical-empty-v1" { |
| 36 | objectsRaw = legacyDiagnosticJSON |
| 37 | var ok bool |
| 38 | migrations, ok = legacyDiagnosticMigrations() |
| 39 | if !ok { |
| 40 | t.Fatal("historical migration conflict") |
| 41 | } |
| 42 | empty = []string{"passkey_challenges", "passkey_credentials", "passkey_pending", "passkey_recovery_codes", "people", "signin_links"} |
| 43 | } |
| 44 | m := schemaCommitmentManifest{Version: 1, ID: id, Role: role, ValidationPolicy: 1, EmptyTables: empty, Migrations: []schemaCommitmentMigration{}} |
| 45 | if json.Unmarshal(objectsRaw, &m.Objects) != nil { |
| 46 | t.Fatal("frozen objects") |
| 47 | } |
| 48 | for _, migration := range migrations { |
| 49 | if migration.Fn != nil { |
| 50 | t.Fatal("Go migration cannot be committed") |
| 51 | } |
| 52 | m.Migrations = append(m.Migrations, schemaCommitmentMigration{migration.ID, migrate.Checksum(migration.SQL)}) |
| 53 | } |
| 54 | sort.Slice(m.Migrations, func(i, j int) bool { return m.Migrations[i].ID < m.Migrations[j].ID }) |
| 55 | sort.Slice(m.Objects, func(i, j int) bool { |
| 56 | a, b := m.Objects[i], m.Objects[j] |
| 57 | return a.Type < b.Type || a.Type == b.Type && a.Name < b.Name |
| 58 | }) |
| 59 | raw, err := json.MarshalIndent(m, "", " ") |
| 60 | if err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | raw = append(raw, '\n') |
| 64 | name := "admission_profiles/" + id + ".json" |
| 65 | if os.Getenv("OFICINA_GENERATE_ADMISSION_PROFILE") == "1" { |
| 66 | if err := os.WriteFile(name, raw, 0600); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | return |
| 70 | } |
| 71 | frozen, err := admissionProfiles.ReadFile(name) |
| 72 | if err != nil || !bytes.Equal(raw, frozen) { |
| 73 | t.Fatal("admission manifest differs from frozen schema/provenance") |
| 74 | } |
| 75 | if _, err := decodeSchemaCommitment(frozen); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | }) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func selectedSchema(t *testing.T, id string) schemaCommitment { |
| 83 | t.Helper() |
| 84 | raw, err := admissionProfiles.ReadFile("admission_profiles/" + id + ".json") |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | sum := sha256.Sum256(raw) |
| 89 | selected, err := loadSchemaCommitment(strings.Split(id, "-")[0], id, hex.EncodeToString(sum[:])) |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | return selected |
| 94 | } |
| 95 | |
| 96 | func TestSchemaCommitmentRefusesWireAndSelectionChanges(t *testing.T) { |
| 97 | selected := selectedSchema(t, "directory-historical-empty-v1") |
| 98 | raw, _ := admissionProfiles.ReadFile("admission_profiles/directory-historical-empty-v1.json") |
| 99 | for name, changed := range map[string][]byte{ |
| 100 | "newline": bytes.TrimSuffix(raw, []byte{'\n'}), |
| 101 | "whitespace": append([]byte{' '}, raw...), |
| 102 | "duplicate": bytes.Replace(raw, []byte(`"version": 1,`), []byte("\"version\": 1,\n \"version\": 1,"), 1), |
| 103 | "unknown": bytes.Replace(raw, []byte(`"version": 1,`), []byte(`"unknown": 1, "version": 1,`), 1), |
| 104 | "wrong-policy": bytes.Replace(raw, []byte(`"validation_policy": 1`), []byte(`"validation_policy": 2`), 1), |
| 105 | "trailing-json": append(append([]byte{}, raw...), []byte("{}\n")...), |
| 106 | } { |
| 107 | t.Run(name, func(t *testing.T) { |
| 108 | if _, err := decodeSchemaCommitment(changed); err == nil { |
| 109 | t.Fatal("accepted altered wire") |
| 110 | } |
| 111 | }) |
| 112 | } |
| 113 | for _, input := range [][3]string{ |
| 114 | {"credential", selected.manifest.ID, selected.digest}, |
| 115 | {"directory", "../directory-historical-empty-v1", selected.digest}, |
| 116 | {"directory", selected.manifest.ID, strings.Repeat("0", 64)}, |
| 117 | {"directory", "", selected.digest}, {"directory", selected.manifest.ID, ""}, |
| 118 | } { |
| 119 | if _, err := loadSchemaCommitment(input[0], input[1], input[2]); err == nil { |
| 120 | t.Fatal("accepted altered selection") |
| 121 | } |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | func checkSelectedSchema(t *testing.T, d *sql.DB, selected schemaCommitment) error { |
| 126 | t.Helper() |
| 127 | conn, err := d.Conn(t.Context()) |
| 128 | if err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | defer conn.Close() |
| 132 | if _, err := conn.ExecContext(t.Context(), "BEGIN IMMEDIATE"); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | result := checkSchemaCommitment(t.Context(), conn, selected) |
| 136 | if _, err := conn.ExecContext(t.Context(), "ROLLBACK"); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | return result |
| 140 | } |
| 141 | |
| 142 | func TestSchemaCommitmentReplayAndOccupancy(t *testing.T) { |
| 143 | for _, role := range []string{"credential", "directory"} { |
| 144 | d := compatibilityFixture(t, role) |
| 145 | if err := checkSelectedSchema(t, d, selectedSchema(t, role+"-current-v1")); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | d.Close() |
| 149 | } |
| 150 | selected := selectedSchema(t, "directory-historical-empty-v1") |
| 151 | for name, change := range map[string]string{ |
| 152 | "valid": "", |
| 153 | "people": "INSERT INTO people VALUES('person','index','time')", |
| 154 | "signin_links": "INSERT INTO signin_links VALUES('token',x'00','purpose','time')", |
| 155 | "passkey_credentials": "INSERT INTO passkey_credentials VALUES('id','person',x'00',0,'time')", |
| 156 | "passkey_challenges": "INSERT INTO passkey_challenges VALUES('challenge','person','purpose','time')", |
| 157 | "passkey_pending": "INSERT INTO passkey_pending VALUES('token','person','method','/','time')", |
| 158 | "passkey_recovery_codes": "INSERT INTO passkey_recovery_codes VALUES('code','person','time')", |
| 159 | "checksum": "UPDATE rastrillo_migrations SET checksum='wrong'", |
| 160 | "missing-migration": "DELETE FROM rastrillo_migrations WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)", |
| 161 | "extra-table": "CREATE TABLE unexpected(value TEXT)", |
| 162 | "extra-trigger": "CREATE TRIGGER unexpected AFTER DELETE ON people BEGIN SELECT 1; END", |
| 163 | "replication-lock": "INSERT INTO _litestream_lock VALUES(1)", |
| 164 | "replication-seq": "INSERT INTO _litestream_seq VALUES(2,1)", |
| 165 | "oversized-id": "UPDATE rastrillo_migrations SET id=printf('%300s',id) WHERE id=(SELECT id FROM rastrillo_migrations LIMIT 1)", |
| 166 | } { |
| 167 | t.Run(name, func(t *testing.T) { |
| 168 | d := legacyDiagnosticFixture(t, true, true) |
| 169 | if err := checkSelectedSchema(t, d.Writer(), selected); err != nil { |
| 170 | t.Fatal("baseline", err) |
| 171 | } |
| 172 | if change != "" { |
| 173 | if _, err := d.Writer().Exec(change); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | } |
| 177 | err := checkSelectedSchema(t, d.Writer(), selected) |
| 178 | if (name == "valid") != (err == nil) { |
| 179 | t.Fatalf("check %s: %v", name, err) |
| 180 | } |
| 181 | }) |
| 182 | } |
| 183 | d := legacyDiagnosticFixture(t, true, true) |
| 184 | if err := checkSelectedSchema(t, d.Writer(), selectedSchema(t, "directory-current-v1")); err == nil { |
| 185 | t.Fatal("fresh profile accepted historical database") |
| 186 | } |
| 187 | fresh := compatibilityFixture(t, "directory") |
| 188 | defer fresh.Close() |
| 189 | if err := checkSelectedSchema(t, fresh, selected); err == nil { |
| 190 | t.Fatal("historical profile accepted fresh database") |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | func TestSchemaCommitmentHasNoProductionCaller(t *testing.T) { |
| 195 | // Every reference outside the admission implementation is forbidden. This is |
| 196 | // stronger than following the current command, journal and checker roots. |
| 197 | root := "../.." |
| 198 | fileSet := token.NewFileSet() |
| 199 | definitions := map[string]bool{} |
| 200 | allowed := map[string]bool{} |
| 201 | for _, path := range []string{"internal/home/schema_commitment_linux.go", "internal/operator/schema_protocol.go", "internal/operator/schema_command_linux.go", "internal/provisioner/schema_plan_linux.go"} { |
| 202 | full := filepath.Clean(filepath.Join(root, path)) |
| 203 | allowed[full] = true |
| 204 | source, err := parser.ParseFile(fileSet, full, nil, 0) |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | for _, decl := range source.Decls { |
| 209 | switch d := decl.(type) { |
| 210 | case *ast.FuncDecl: |
| 211 | definitions[d.Name.Name] = true |
| 212 | case *ast.GenDecl: |
| 213 | for _, spec := range d.Specs { |
| 214 | switch s := spec.(type) { |
| 215 | case *ast.TypeSpec: |
| 216 | definitions[s.Name.Name] = true |
| 217 | case *ast.ValueSpec: |
| 218 | for _, n := range s.Names { |
| 219 | definitions[n.Name] = true |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| 227 | if err != nil { |
| 228 | return err |
| 229 | } |
| 230 | if d.IsDir() { |
| 231 | return nil |
| 232 | } |
| 233 | if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || allowed[filepath.Clean(path)] { |
| 234 | return nil |
| 235 | } |
| 236 | f, err := parser.ParseFile(fileSet, path, nil, 0) |
| 237 | if err != nil { |
| 238 | return err |
| 239 | } |
| 240 | ast.Inspect(f, func(n ast.Node) bool { |
| 241 | if id, ok := n.(*ast.Ident); ok && definitions[id.Name] { |
| 242 | t.Errorf("unmounted admission reference %s at %s", id.Name, fileSet.Position(id.Pos())) |
| 243 | } |
| 244 | return true |
| 245 | }) |
| 246 | return nil |
| 247 | }) |
| 248 | if err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | func TestSchemaCommitmentGoldenDigests(t *testing.T) { |
| 254 | for id, want := range map[string]string{ |
| 255 | "credential-current-v1": "db65d14c48ef762b692d8a74f158e46e5d6117518092274dcefe6a1319d01451", |
| 256 | "directory-current-v1": "344fca53f2cfc5eae5f1c05eb650c503c8af14cc98ec410b3146cfee8eebae02", |
| 257 | "directory-historical-empty-v1": "1313fb93488e25164ed127927725194dc07c524be6219beedc162b4ce3588309", |
| 258 | } { |
| 259 | if selectedSchema(t, id).digest != want { |
| 260 | t.Fatal("immutable manifest changed", id) |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | func TestSchemaCommitmentPreservesSessionsAndRejectsLaterOccupancy(t *testing.T) { |
| 266 | d := legacyDiagnosticFixture(t, true, true) |
| 267 | selected := selectedSchema(t, "directory-historical-empty-v1") |
| 268 | if _, err := d.Writer().Exec("INSERT INTO sessions VALUES('hash','opaque-person','method','auth-time','created','expires')"); err != nil { |
| 269 | t.Fatal(err) |
| 270 | } |
| 271 | if err := checkSelectedSchema(t, d.Writer(), selected); err != nil { |
| 272 | t.Fatal("shared session refused", err) |
| 273 | } |
| 274 | var subject string |
| 275 | if err := d.Writer().QueryRow("SELECT subject FROM sessions WHERE token_hash='hash'").Scan(&subject); err != nil || subject != "opaque-person" { |
| 276 | t.Fatal("session changed") |
| 277 | } |
| 278 | if _, err := d.Writer().Exec("INSERT INTO people VALUES('later','later','time')"); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | if err := checkSelectedSchema(t, d.Writer(), selected); err == nil { |
| 282 | t.Fatal("earlier successful check authorized later occupancy") |
| 283 | } |
| 284 | conn, err := d.Writer().Conn(t.Context()) |
| 285 | if err != nil { |
| 286 | t.Fatal(err) |
| 287 | } |
| 288 | defer conn.Close() |
| 289 | ctx, cancel := context.WithCancel(t.Context()) |
| 290 | cancel() |
| 291 | if err := checkSchemaCommitment(ctx, conn, selected); err == nil { |
| 292 | t.Fatal("cancelled check accepted") |
| 293 | } |
| 294 | } |
| 295 | |