| 1 | //go:build linux |
| 2 | |
| 3 | package hostbox |
| 4 | |
| 5 | import ( |
| 6 | "crypto/sha256" |
| 7 | "database/sql" |
| 8 | "encoding/hex" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "net/url" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "syscall" |
| 16 | "testing" |
| 17 | "time" |
| 18 | ) |
| 19 | |
| 20 | // plan9Fix2Schema rewrites a legacy file's schema on one connection, as an |
| 21 | // agent holding the file could. |
| 22 | func plan9Fix2Schema(t *testing.T, path string, stmts ...string) { |
| 23 | t.Helper() |
| 24 | db, err := sql.Open("sqlite", "file:"+url.PathEscape(path)+"?_pragma=busy_timeout(5000)") |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | defer db.Close() |
| 29 | db.SetMaxOpenConns(1) |
| 30 | for _, stmt := range append([]string{"PRAGMA writable_schema=ON"}, stmts...) { |
| 31 | if _, err = db.Exec(stmt); err != nil { |
| 32 | t.Fatal(stmt, err) |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | // I2-1: no expression in an agent-supplied schema is evaluated. A CHECK that |
| 38 | // would allocate beyond SQLite's limit proves it: evaluating it fails with |
| 39 | // "string or blob too big", so the refusal must come from the schema |
| 40 | // allowlist, before integrity_check reads a row. |
| 41 | func TestPlan9Fix2MigrationRefusesExpensiveCheckBeforeEvaluation(t *testing.T) { |
| 42 | s, _, in := journalFixture(t) |
| 43 | legacy := plan9Fix1Legacy(t, in) |
| 44 | plan9Fix2Schema(t, filepath.Join(legacy, "launches.db"), |
| 45 | "UPDATE sqlite_schema SET sql='CREATE TABLE launches (identity TEXT PRIMARY KEY, body BLOB NOT NULL, CHECK(length(hex(zeroblob(2000000000)))>0))' WHERE name='launches'") |
| 46 | err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept) |
| 47 | if !errors.Is(err, ErrTypedJournal) { |
| 48 | t.Fatalf("planted CHECK accepted: %v", err) |
| 49 | } |
| 50 | if strings.Contains(err.Error(), "too big") { |
| 51 | t.Fatalf("planted CHECK was evaluated before the schema was refused: %v", err) |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // I2-1 and m-6: a table entry with root page zero is a virtual table or view |
| 56 | // whatever its text says; it is refused even when the text matches. |
| 57 | func TestPlan9Fix2MigrationRefusesRootpageZero(t *testing.T) { |
| 58 | s, _, in := journalFixture(t) |
| 59 | legacy := plan9Fix1Legacy(t, in) |
| 60 | plan9Fix2Schema(t, filepath.Join(legacy, "inventory.db"), "UPDATE sqlite_schema SET rootpage=0 WHERE name='journal_inventory_versions'") |
| 61 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrTypedJournal) { |
| 62 | t.Fatalf("root page zero accepted: %v", err) |
| 63 | } |
| 64 | if got, err := s.Launches.HasHost(t.Context(), in.Target.Host); err != nil || got { |
| 65 | t.Fatalf("import committed: %v %v", got, err) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // I2-1: each allowed table must carry exactly the text the legacy code wrote. |
| 70 | func TestPlan9Fix2MigrationRefusesAlteredTableText(t *testing.T) { |
| 71 | s, _, in := journalFixture(t) |
| 72 | legacy := plan9Fix1Legacy(t, in) |
| 73 | plan9Fix2Schema(t, filepath.Join(legacy, "launches.db"), |
| 74 | "UPDATE sqlite_schema SET sql='CREATE TABLE launches (identity TEXT PRIMARY KEY, body BLOB NOT NULL )' WHERE name='launches'") |
| 75 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrTypedJournal) { |
| 76 | t.Fatalf("altered table text accepted: %v", err) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // m-8: the marker binds content. A metadata-only change (ctime, mtime) to an |
| 81 | // imported legacy file needs neither quiescence nor a refusal. |
| 82 | func TestPlan9Fix2MarkerIgnoresMetadataOnlyChange(t *testing.T) { |
| 83 | s, _, in := journalFixture(t) |
| 84 | legacy := plan9Fix1Legacy(t, in) |
| 85 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | later := time.Now().Add(time.Hour) |
| 89 | for _, name := range []string{"launches.db", "inventory.db"} { |
| 90 | path := filepath.Join(legacy, name) |
| 91 | if err := os.Chtimes(path, later, later); err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | if err := os.Chmod(path, 0640); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | } |
| 98 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil); err != nil { |
| 99 | t.Fatalf("metadata-only change refused: %v", err) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // m-8: a marker written before the marker had a binding (4f342ec7, files and |
| 104 | // rows both empty after the column upgrade) is accepted when every legacy row |
| 105 | // is one root already holds; the binding is then recorded, and later legacy |
| 106 | // writes are refused as before. |
| 107 | func TestPlan9Fix2MigrationAcceptsUnboundMarker(t *testing.T) { |
| 108 | s, _, in := journalFixture(t) |
| 109 | legacy := plan9Fix1Legacy(t, in) |
| 110 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if _, err := s.Launches.db.Exec("UPDATE journal_migration SET files='', rows=''"); err != nil { |
| 114 | t.Fatal(err) |
| 115 | } |
| 116 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { |
| 117 | t.Fatalf("unbound marker refused: %v", err) |
| 118 | } |
| 119 | var files, rows string |
| 120 | if err := s.Launches.db.QueryRow("SELECT files,rows FROM journal_migration").Scan(&files, &rows); err != nil || files == "" || rows == "" { |
| 121 | t.Fatalf("binding not recorded: %q %q %v", files, rows, err) |
| 122 | } |
| 123 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil); err != nil { |
| 124 | t.Fatalf("bound marker refused: %v", err) |
| 125 | } |
| 126 | old, err := OpenLaunchJournal(filepath.Join(legacy, "launches.db")) |
| 127 | if err != nil { |
| 128 | t.Fatal(err) |
| 129 | } |
| 130 | second := in |
| 131 | second.Target.Session = 2 |
| 132 | if err = old.Create(t.Context(), second); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | old.Close() |
| 136 | if err = s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrChanged) { |
| 137 | t.Fatalf("post-marker legacy write accepted: %v", err) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // m-8: an unbound marker is not a licence to skip history root never held. |
| 142 | func TestPlan9Fix2UnboundMarkerRefusesRowsRootNeverHeld(t *testing.T) { |
| 143 | s, _, in := journalFixture(t) |
| 144 | legacy := plan9Fix1Legacy(t, in) |
| 145 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | if _, err := s.Launches.db.Exec("UPDATE journal_migration SET files='', rows=''"); err != nil { |
| 149 | t.Fatal(err) |
| 150 | } |
| 151 | old, err := OpenLaunchJournal(filepath.Join(legacy, "launches.db")) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | second := in |
| 156 | second.Target.Session = 2 |
| 157 | if err = old.Create(t.Context(), second); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | old.Close() |
| 161 | if err = s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrChanged) { |
| 162 | t.Fatalf("unbound marker skipped unheld history: %v", err) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // m-8: a marker from 618f3a8a binds device, inode and times beside the |
| 167 | // digest. Only its digests are compared. |
| 168 | func TestPlan9Fix2MigrationAcceptsMetadataBoundMarker(t *testing.T) { |
| 169 | s, _, in := journalFixture(t) |
| 170 | legacy := plan9Fix1Legacy(t, in) |
| 171 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | var parts []string |
| 175 | for _, name := range []string{"launches.db", "launches.db-wal", "inventory.db", "inventory.db-wal"} { |
| 176 | raw, err := os.ReadFile(filepath.Join(legacy, name)) |
| 177 | if errors.Is(err, os.ErrNotExist) { |
| 178 | parts = append(parts, name+":-") |
| 179 | continue |
| 180 | } |
| 181 | if err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | st, _ := os.Stat(filepath.Join(legacy, name)) |
| 185 | sys := st.Sys().(*syscall.Stat_t) |
| 186 | parts = append(parts, fmt.Sprintf("%s:%d:%d:%d:%d.%d:%d.%d:%s", name, sys.Dev, sys.Ino, st.Size(), sys.Mtim.Sec, sys.Mtim.Nsec, sys.Ctim.Sec+1, sys.Ctim.Nsec, plan9Fix2Digest(raw))) |
| 187 | } |
| 188 | if _, err := s.Launches.db.Exec("UPDATE journal_migration SET files=?", strings.Join(parts, "/")); err != nil { |
| 189 | t.Fatal(err) |
| 190 | } |
| 191 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil); err != nil { |
| 192 | t.Fatalf("618f3a8a marker with unchanged content refused: %v", err) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // m-10: the imported rows come from exactly the bytes the marker names. A |
| 197 | // legacy file swapped for the copy and restored afterwards must refuse. |
| 198 | func TestPlan9Fix2MigrationBindsCopiedBytes(t *testing.T) { |
| 199 | s, _, in := journalFixture(t) |
| 200 | legacy := plan9Fix1Legacy(t, in) |
| 201 | other := plan9Fix1Legacy(t, in) |
| 202 | second := in |
| 203 | second.Target.Session = 2 |
| 204 | old, err := OpenLaunchJournal(filepath.Join(other, "launches.db")) |
| 205 | if err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if err = old.Create(t.Context(), second); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | old.Close() |
| 212 | original, err := os.ReadFile(filepath.Join(legacy, "launches.db")) |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | swapped, err := os.ReadFile(filepath.Join(other, "launches.db")) |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | restore := legacyCopyHook |
| 221 | t.Cleanup(func() { legacyCopyHook = restore }) |
| 222 | legacyCopyHook = func(name string, copied bool) { |
| 223 | if name != "launches.db" { |
| 224 | return |
| 225 | } |
| 226 | body := swapped |
| 227 | if copied { |
| 228 | body = original |
| 229 | } |
| 230 | if err := os.WriteFile(filepath.Join(legacy, name), body, 0600); err != nil { |
| 231 | t.Error(err) |
| 232 | } |
| 233 | } |
| 234 | if err = s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrChanged) { |
| 235 | t.Fatalf("copy of different bytes imported: %v", err) |
| 236 | } |
| 237 | if _, err = s.Launches.Read(t.Context(), second.Target); !errors.Is(err, sql.ErrNoRows) { |
| 238 | t.Fatalf("swapped row imported: %v", err) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // I2-3: after the marker, legacy files under the agent's directory never stop |
| 243 | // root's startup. Planted files are ignored and reported; nothing is quiesced. |
| 244 | func TestPlan9Fix2StartupIgnoresLegacyFilesPlantedAfterMarker(t *testing.T) { |
| 245 | s, _, in := journalFixture(t) |
| 246 | legacy := t.TempDir() |
| 247 | if alert, err := s.CheckLegacyMigration(t.Context(), legacy); err != nil || alert != nil { |
| 248 | t.Fatalf("fresh startup: %v %v", alert, err) |
| 249 | } |
| 250 | // The agent plants a full legacy pair after the marker. |
| 251 | planted := plan9Fix1Legacy(t, in) |
| 252 | for _, name := range []string{"launches.db", "inventory.db"} { |
| 253 | raw, err := os.ReadFile(filepath.Join(planted, name)) |
| 254 | if err != nil { |
| 255 | t.Fatal(err) |
| 256 | } |
| 257 | if err = os.WriteFile(filepath.Join(legacy, name), raw, 0600); err != nil { |
| 258 | t.Fatal(err) |
| 259 | } |
| 260 | } |
| 261 | alert, err := s.CheckLegacyMigration(t.Context(), legacy) |
| 262 | if err != nil { |
| 263 | t.Fatalf("planted legacy files stopped root: %v", err) |
| 264 | } |
| 265 | if alert == nil { |
| 266 | t.Fatal("planted legacy files not reported") |
| 267 | } |
| 268 | if got, err := s.Launches.HasHost(t.Context(), in.Target.Host); err != nil || got { |
| 269 | t.Fatalf("planted rows imported: %v %v", got, err) |
| 270 | } |
| 271 | // Garbage and special files are ignored the same way. |
| 272 | if err = os.Remove(filepath.Join(legacy, "launches.db")); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | if err = syscall.Mkfifo(filepath.Join(legacy, "launches.db"), 0600); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | if alert, err = s.CheckLegacyMigration(t.Context(), legacy); err != nil || alert == nil { |
| 279 | t.Fatalf("planted fifo: %v %v", alert, err) |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // I2-3: unmigrated legacy history is not silently dropped: startup refuses |
| 284 | // until the operator runs the offline migration. |
| 285 | func TestPlan9Fix2StartupRefusesUnmigratedLegacy(t *testing.T) { |
| 286 | s, _, in := journalFixture(t) |
| 287 | legacy := plan9Fix1Legacy(t, in) |
| 288 | if _, err := s.CheckLegacyMigration(t.Context(), legacy); !errors.Is(err, ErrLegacyJournalMigration) { |
| 289 | t.Fatalf("unmigrated legacy journals accepted at startup: %v", err) |
| 290 | } |
| 291 | var n int |
| 292 | if err := s.Launches.db.QueryRow("SELECT count(*) FROM journal_migration").Scan(&n); err != nil || n != 0 { |
| 293 | t.Fatalf("marker written over unmigrated history: %d %v", n, err) |
| 294 | } |
| 295 | if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { |
| 296 | t.Fatal(err) |
| 297 | } |
| 298 | if alert, err := s.CheckLegacyMigration(t.Context(), legacy); err != nil || alert != nil { |
| 299 | t.Fatalf("migrated startup: %v %v", alert, err) |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | func plan9Fix2Digest(raw []byte) string { |
| 304 | sum := sha256.Sum256(raw) |
| 305 | return hex.EncodeToString(sum[:]) |
| 306 | } |
| 307 | |