| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "database/sql/driver" |
| 7 | _ "embed" |
| 8 | "encoding/json" |
| 9 | "log/slog" |
| 10 | |
| 11 | "amadan.net/rastrillo/rastrillo/migrate" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | legacyDiagnosticProfile = "legacy-directory-v1" |
| 16 | legacyDiagnosticMaxRows = 1000 |
| 17 | legacyDiagnosticNameBytes = 256 |
| 18 | legacyDiagnosticSQLBytes = 65536 |
| 19 | ) |
| 20 | |
| 21 | //go:embed diagnostic_profiles/legacy-directory-v1.json |
| 22 | var legacyDiagnosticJSON []byte |
| 23 | |
| 24 | type legacyDiagnosticObject struct{ Type, Name, Table, SQL string } |
| 25 | type legacyDiagnosticResult struct { |
| 26 | status string |
| 27 | occupied bool |
| 28 | } |
| 29 | |
| 30 | var legacyDiagnosticObjects = func() []legacyDiagnosticObject { |
| 31 | var objects []legacyDiagnosticObject |
| 32 | if err := json.Unmarshal(legacyDiagnosticJSON, &objects); err != nil { |
| 33 | panic(err) |
| 34 | } |
| 35 | return objects |
| 36 | }() |
| 37 | |
| 38 | func legacyDiagnosticMigrations() ([]migrate.Migration, bool) { |
| 39 | return uniqueLegacyMigrations(BootSchema.All(), (Runtime{role: "directory"}).Schema().All()) |
| 40 | } |
| 41 | |
| 42 | func uniqueLegacyMigrations(groups ...[]migrate.Migration) ([]migrate.Migration, bool) { |
| 43 | seen := map[string]migrate.Migration{} |
| 44 | var result []migrate.Migration |
| 45 | for _, group := range groups { |
| 46 | for _, m := range group { |
| 47 | if m.Fn != nil || m.ID == "" || m.SQL == "" { |
| 48 | return nil, false |
| 49 | } |
| 50 | if prior, exists := seen[m.ID]; exists { |
| 51 | if prior.SQL != m.SQL { |
| 52 | return nil, false |
| 53 | } |
| 54 | continue |
| 55 | } |
| 56 | seen[m.ID] = m |
| 57 | result = append(result, m) |
| 58 | } |
| 59 | } |
| 60 | return result, len(result) > 0 |
| 61 | } |
| 62 | |
| 63 | func logLegacySchemaDiagnostic(ctx context.Context, d *sql.DB, role string, logger *slog.Logger) { |
| 64 | if role != "directory" { |
| 65 | return |
| 66 | } |
| 67 | result := inspectLegacySchema(ctx, d) |
| 68 | fields := []any{"profile", legacyDiagnosticProfile, "result", result.status} |
| 69 | if result.status == "match" { |
| 70 | fields = append(fields, "legacy_credentials_present", result.occupied) |
| 71 | } |
| 72 | logger.Info("legacy schema diagnostic", fields...) |
| 73 | } |
| 74 | |
| 75 | func inspectLegacySchema(ctx context.Context, d *sql.DB) (result legacyDiagnosticResult) { |
| 76 | result.status = "unavailable" |
| 77 | if ctx == nil || d == nil || len(legacyDiagnosticObjects) == 0 { |
| 78 | return |
| 79 | } |
| 80 | conn, err := d.Conn(ctx) |
| 81 | if err != nil { |
| 82 | return |
| 83 | } |
| 84 | defer conn.Close() |
| 85 | if !beginLegacyDiagnostic(ctx, conn) { |
| 86 | return |
| 87 | } |
| 88 | defer func() { |
| 89 | if !rollbackLegacyDiagnostic(ctx, conn) { |
| 90 | result = legacyDiagnosticResult{status: "unavailable"} |
| 91 | } |
| 92 | }() |
| 93 | result = readLegacySchema(ctx, conn) |
| 94 | return |
| 95 | } |
| 96 | |
| 97 | func readLegacySchema(ctx context.Context, conn *sql.Conn) legacyDiagnosticResult { |
| 98 | unavailable := legacyDiagnosticResult{status: "unavailable"} |
| 99 | mismatch := legacyDiagnosticResult{status: "mismatch"} |
| 100 | migrations, ok := legacyDiagnosticMigrations() |
| 101 | if !ok { |
| 102 | return unavailable |
| 103 | } |
| 104 | expected := map[string]string{} |
| 105 | for _, m := range migrations { |
| 106 | expected[m.ID] = migrate.Checksum(m.SQL) |
| 107 | } |
| 108 | rows, err := conn.QueryContext(ctx, `SELECT |
| 109 | CASE WHEN typeof(id)='text' AND length(CAST(id AS BLOB))<=256 THEN id END, |
| 110 | CASE WHEN typeof(checksum)='text' AND length(CAST(checksum AS BLOB))=64 THEN checksum END |
| 111 | FROM rastrillo_migrations LIMIT 1001`) |
| 112 | if err != nil { |
| 113 | return unavailable |
| 114 | } |
| 115 | matched, count := true, 0 |
| 116 | for rows.Next() { |
| 117 | count++ |
| 118 | var id, sum sql.NullString |
| 119 | if rows.Scan(&id, &sum) != nil { |
| 120 | rows.Close() |
| 121 | return unavailable |
| 122 | } |
| 123 | if !id.Valid || !sum.Valid || count > legacyDiagnosticMaxRows || expected[id.String] != sum.String { |
| 124 | matched = false |
| 125 | } |
| 126 | delete(expected, id.String) |
| 127 | } |
| 128 | scanErr, closeErr := rows.Err(), rows.Close() |
| 129 | if scanErr != nil || closeErr != nil || ctx.Err() != nil { |
| 130 | return unavailable |
| 131 | } |
| 132 | if !matched || len(expected) != 0 { |
| 133 | return mismatch |
| 134 | } |
| 135 | objects := map[string]legacyDiagnosticObject{} |
| 136 | for _, object := range legacyDiagnosticObjects { |
| 137 | objects[object.Type+"/"+object.Name] = object |
| 138 | } |
| 139 | rows, err = conn.QueryContext(ctx, `SELECT |
| 140 | CASE WHEN typeof(type)='text' AND length(CAST(type AS BLOB))<=16 THEN type END, |
| 141 | CASE WHEN typeof(name)='text' AND length(CAST(name AS BLOB))<=256 THEN name END, |
| 142 | CASE WHEN typeof(tbl_name)='text' AND length(CAST(tbl_name AS BLOB))<=256 THEN tbl_name END, |
| 143 | CASE WHEN typeof(coalesce(sql,''))='text' AND length(CAST(coalesce(sql,'') AS BLOB))<=65536 THEN coalesce(sql,'') END |
| 144 | FROM sqlite_schema LIMIT 1001`) |
| 145 | if err != nil { |
| 146 | return unavailable |
| 147 | } |
| 148 | count = 0 |
| 149 | for rows.Next() { |
| 150 | count++ |
| 151 | var kind, name, table, statement sql.NullString |
| 152 | if rows.Scan(&kind, &name, &table, &statement) != nil { |
| 153 | rows.Close() |
| 154 | return unavailable |
| 155 | } |
| 156 | object := legacyDiagnosticObject{kind.String, name.String, table.String, statement.String} |
| 157 | key := kind.String + "/" + name.String |
| 158 | prior, exists := objects[key] |
| 159 | if !kind.Valid || !name.Valid || !table.Valid || !statement.Valid || count > legacyDiagnosticMaxRows || !exists || prior != object { |
| 160 | matched = false |
| 161 | } |
| 162 | delete(objects, key) |
| 163 | } |
| 164 | scanErr, closeErr = rows.Err(), rows.Close() |
| 165 | if scanErr != nil || closeErr != nil || ctx.Err() != nil { |
| 166 | return unavailable |
| 167 | } |
| 168 | if !matched || len(objects) != 0 { |
| 169 | return mismatch |
| 170 | } |
| 171 | var occupied bool |
| 172 | // Pending challenges and recovery material count too: none is disposable here. |
| 173 | err = conn.QueryRowContext(ctx, `SELECT |
| 174 | EXISTS(SELECT 1 FROM people) OR EXISTS(SELECT 1 FROM signin_links) OR |
| 175 | EXISTS(SELECT 1 FROM passkey_credentials) OR EXISTS(SELECT 1 FROM passkey_challenges) OR |
| 176 | EXISTS(SELECT 1 FROM passkey_pending) OR EXISTS(SELECT 1 FROM passkey_recovery_codes)`).Scan(&occupied) |
| 177 | if err != nil || ctx.Err() != nil { |
| 178 | return unavailable |
| 179 | } |
| 180 | return legacyDiagnosticResult{status: "match", occupied: occupied} |
| 181 | } |
| 182 | |
| 183 | type legacyDiagnosticConnection interface { |
| 184 | ExecContext(context.Context, string, ...any) (sql.Result, error) |
| 185 | Raw(func(any) error) error |
| 186 | } |
| 187 | |
| 188 | func rollbackLegacyDiagnostic(ctx context.Context, conn legacyDiagnosticConnection) bool { |
| 189 | if _, err := conn.ExecContext(context.WithoutCancel(ctx), "ROLLBACK"); err != nil { |
| 190 | conn.Raw(func(any) error { return driver.ErrBadConn }) |
| 191 | return false |
| 192 | } |
| 193 | return true |
| 194 | } |
| 195 | |
| 196 | func beginLegacyDiagnostic(ctx context.Context, conn legacyDiagnosticConnection) bool { |
| 197 | if _, err := conn.ExecContext(ctx, "BEGIN"); err != nil { |
| 198 | // The driver can report cancellation after SQLite has opened the transaction. |
| 199 | conn.Raw(func(any) error { return driver.ErrBadConn }) |
| 200 | return false |
| 201 | } |
| 202 | return true |
| 203 | } |
| 204 | |