//go:build linux package hostbox import ( "crypto/sha256" "database/sql" "encoding/hex" "errors" "fmt" "net/url" "os" "path/filepath" "strings" "syscall" "testing" "time" ) // plan9Fix2Schema rewrites a legacy file's schema on one connection, as an // agent holding the file could. func plan9Fix2Schema(t *testing.T, path string, stmts ...string) { t.Helper() db, err := sql.Open("sqlite", "file:"+url.PathEscape(path)+"?_pragma=busy_timeout(5000)") if err != nil { t.Fatal(err) } defer db.Close() db.SetMaxOpenConns(1) for _, stmt := range append([]string{"PRAGMA writable_schema=ON"}, stmts...) { if _, err = db.Exec(stmt); err != nil { t.Fatal(stmt, err) } } } // I2-1: no expression in an agent-supplied schema is evaluated. A CHECK that // would allocate beyond SQLite's limit proves it: evaluating it fails with // "string or blob too big", so the refusal must come from the schema // allowlist, before integrity_check reads a row. func TestPlan9Fix2MigrationRefusesExpensiveCheckBeforeEvaluation(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) plan9Fix2Schema(t, filepath.Join(legacy, "launches.db"), "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'") err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept) if !errors.Is(err, ErrTypedJournal) { t.Fatalf("planted CHECK accepted: %v", err) } if strings.Contains(err.Error(), "too big") { t.Fatalf("planted CHECK was evaluated before the schema was refused: %v", err) } } // I2-1 and m-6: a table entry with root page zero is a virtual table or view // whatever its text says; it is refused even when the text matches. func TestPlan9Fix2MigrationRefusesRootpageZero(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) plan9Fix2Schema(t, filepath.Join(legacy, "inventory.db"), "UPDATE sqlite_schema SET rootpage=0 WHERE name='journal_inventory_versions'") if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrTypedJournal) { t.Fatalf("root page zero accepted: %v", err) } if got, err := s.Launches.HasHost(t.Context(), in.Target.Host); err != nil || got { t.Fatalf("import committed: %v %v", got, err) } } // I2-1: each allowed table must carry exactly the text the legacy code wrote. func TestPlan9Fix2MigrationRefusesAlteredTableText(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) plan9Fix2Schema(t, filepath.Join(legacy, "launches.db"), "UPDATE sqlite_schema SET sql='CREATE TABLE launches (identity TEXT PRIMARY KEY, body BLOB NOT NULL )' WHERE name='launches'") if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrTypedJournal) { t.Fatalf("altered table text accepted: %v", err) } } // m-8: the marker binds content. A metadata-only change (ctime, mtime) to an // imported legacy file needs neither quiescence nor a refusal. func TestPlan9Fix2MarkerIgnoresMetadataOnlyChange(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { t.Fatal(err) } later := time.Now().Add(time.Hour) for _, name := range []string{"launches.db", "inventory.db"} { path := filepath.Join(legacy, name) if err := os.Chtimes(path, later, later); err != nil { t.Fatal(err) } if err := os.Chmod(path, 0640); err != nil { t.Fatal(err) } } if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil); err != nil { t.Fatalf("metadata-only change refused: %v", err) } } // m-8: a marker written before the marker had a binding (4f342ec7, files and // rows both empty after the column upgrade) is accepted when every legacy row // is one root already holds; the binding is then recorded, and later legacy // writes are refused as before. func TestPlan9Fix2MigrationAcceptsUnboundMarker(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { t.Fatal(err) } if _, err := s.Launches.db.Exec("UPDATE journal_migration SET files='', rows=''"); err != nil { t.Fatal(err) } if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { t.Fatalf("unbound marker refused: %v", err) } var files, rows string if err := s.Launches.db.QueryRow("SELECT files,rows FROM journal_migration").Scan(&files, &rows); err != nil || files == "" || rows == "" { t.Fatalf("binding not recorded: %q %q %v", files, rows, err) } if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil); err != nil { t.Fatalf("bound marker refused: %v", err) } old, err := OpenLaunchJournal(filepath.Join(legacy, "launches.db")) if err != nil { t.Fatal(err) } second := in second.Target.Session = 2 if err = old.Create(t.Context(), second); err != nil { t.Fatal(err) } old.Close() if err = s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrChanged) { t.Fatalf("post-marker legacy write accepted: %v", err) } } // m-8: an unbound marker is not a licence to skip history root never held. func TestPlan9Fix2UnboundMarkerRefusesRowsRootNeverHeld(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { t.Fatal(err) } if _, err := s.Launches.db.Exec("UPDATE journal_migration SET files='', rows=''"); err != nil { t.Fatal(err) } old, err := OpenLaunchJournal(filepath.Join(legacy, "launches.db")) if err != nil { t.Fatal(err) } second := in second.Target.Session = 2 if err = old.Create(t.Context(), second); err != nil { t.Fatal(err) } old.Close() if err = s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrChanged) { t.Fatalf("unbound marker skipped unheld history: %v", err) } } // m-8: a marker from 618f3a8a binds device, inode and times beside the // digest. Only its digests are compared. func TestPlan9Fix2MigrationAcceptsMetadataBoundMarker(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { t.Fatal(err) } var parts []string for _, name := range []string{"launches.db", "launches.db-wal", "inventory.db", "inventory.db-wal"} { raw, err := os.ReadFile(filepath.Join(legacy, name)) if errors.Is(err, os.ErrNotExist) { parts = append(parts, name+":-") continue } if err != nil { t.Fatal(err) } st, _ := os.Stat(filepath.Join(legacy, name)) sys := st.Sys().(*syscall.Stat_t) 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))) } if _, err := s.Launches.db.Exec("UPDATE journal_migration SET files=?", strings.Join(parts, "/")); err != nil { t.Fatal(err) } if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", nil, nil); err != nil { t.Fatalf("618f3a8a marker with unchanged content refused: %v", err) } } // m-10: the imported rows come from exactly the bytes the marker names. A // legacy file swapped for the copy and restored afterwards must refuse. func TestPlan9Fix2MigrationBindsCopiedBytes(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) other := plan9Fix1Legacy(t, in) second := in second.Target.Session = 2 old, err := OpenLaunchJournal(filepath.Join(other, "launches.db")) if err != nil { t.Fatal(err) } if err = old.Create(t.Context(), second); err != nil { t.Fatal(err) } old.Close() original, err := os.ReadFile(filepath.Join(legacy, "launches.db")) if err != nil { t.Fatal(err) } swapped, err := os.ReadFile(filepath.Join(other, "launches.db")) if err != nil { t.Fatal(err) } restore := legacyCopyHook t.Cleanup(func() { legacyCopyHook = restore }) legacyCopyHook = func(name string, copied bool) { if name != "launches.db" { return } body := swapped if copied { body = original } if err := os.WriteFile(filepath.Join(legacy, name), body, 0600); err != nil { t.Error(err) } } if err = s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); !errors.Is(err, ErrChanged) { t.Fatalf("copy of different bytes imported: %v", err) } if _, err = s.Launches.Read(t.Context(), second.Target); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("swapped row imported: %v", err) } } // I2-3: after the marker, legacy files under the agent's directory never stop // root's startup. Planted files are ignored and reported; nothing is quiesced. func TestPlan9Fix2StartupIgnoresLegacyFilesPlantedAfterMarker(t *testing.T) { s, _, in := journalFixture(t) legacy := t.TempDir() if alert, err := s.CheckLegacyMigration(t.Context(), legacy); err != nil || alert != nil { t.Fatalf("fresh startup: %v %v", alert, err) } // The agent plants a full legacy pair after the marker. planted := plan9Fix1Legacy(t, in) for _, name := range []string{"launches.db", "inventory.db"} { raw, err := os.ReadFile(filepath.Join(planted, name)) if err != nil { t.Fatal(err) } if err = os.WriteFile(filepath.Join(legacy, name), raw, 0600); err != nil { t.Fatal(err) } } alert, err := s.CheckLegacyMigration(t.Context(), legacy) if err != nil { t.Fatalf("planted legacy files stopped root: %v", err) } if alert == nil { t.Fatal("planted legacy files not reported") } if got, err := s.Launches.HasHost(t.Context(), in.Target.Host); err != nil || got { t.Fatalf("planted rows imported: %v %v", got, err) } // Garbage and special files are ignored the same way. if err = os.Remove(filepath.Join(legacy, "launches.db")); err != nil { t.Fatal(err) } if err = syscall.Mkfifo(filepath.Join(legacy, "launches.db"), 0600); err != nil { t.Fatal(err) } if alert, err = s.CheckLegacyMigration(t.Context(), legacy); err != nil || alert == nil { t.Fatalf("planted fifo: %v %v", alert, err) } } // I2-3: unmigrated legacy history is not silently dropped: startup refuses // until the operator runs the offline migration. func TestPlan9Fix2StartupRefusesUnmigratedLegacy(t *testing.T) { s, _, in := journalFixture(t) legacy := plan9Fix1Legacy(t, in) if _, err := s.CheckLegacyMigration(t.Context(), legacy); !errors.Is(err, ErrLegacyJournalMigration) { t.Fatalf("unmigrated legacy journals accepted at startup: %v", err) } var n int if err := s.Launches.db.QueryRow("SELECT count(*) FROM journal_migration").Scan(&n); err != nil || n != 0 { t.Fatalf("marker written over unmigrated history: %d %v", n, err) } if err := s.MigrateLegacy(t.Context(), legacy, "box", "enrollment", plan9Fix1Freeze, plan9Fix1Accept); err != nil { t.Fatal(err) } if alert, err := s.CheckLegacyMigration(t.Context(), legacy); err != nil || alert != nil { t.Fatalf("migrated startup: %v %v", alert, err) } } func plan9Fix2Digest(raw []byte) string { sum := sha256.Sum256(raw) return hex.EncodeToString(sum[:]) }