//go:build linux package hostbox import ( "context" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/url" "os" "path/filepath" "sort" "strings" "time" "github.com/carlosframework/platform/internal/hostguard" "golang.org/x/sys/unix" ) // ErrLegacyJournalMigration refuses root startup over legacy claim journals // that were never imported. Only the offline operator step imports them. var ErrLegacyJournalMigration = errors.New("legacy claim journals need the offline migration: run carlos system journal-migrate") // CheckLegacyMigration is root startup's only contact with the legacy // journals, which live in a directory the agent owns. It never opens them as // SQLite and never quiesces anything. With no legacy files and no marker (a // fresh box), it records the marker. Unimported legacy files refuse with // ErrLegacyJournalMigration. Once the marker exists, nothing the agent puts // there can stop root: legacy files that differ from the marked content are // ignored, and the returned alert says so. func (s *JournalService) CheckLegacyMigration(ctx context.Context, legacy string) (alert error, err error) { if s == nil || s.Launches == nil || !filepath.IsAbs(legacy) { return nil, ErrTypedJournal } unlock, err := lockJournalMigration(ctx, filepath.Join(filepath.Dir(s.Launches.path), "migration.lock")) if err != nil { return nil, err } defer unlock() source, markedFiles, _, marked, err := s.journalMigrationMarker(ctx) if err != nil { return nil, err } files, present, err := legacyJournalFiles(legacy) if marked { if source != legacy { return nil, ErrChanged } if err == nil && files == "absent" { err = os.ErrNotExist } if err != nil { return fmt.Errorf("legacy claim journal %s ignored after migration: %w", legacy, err), nil } if present == 0 || legacyContent(markedFiles) == files { return nil, nil } return fmt.Errorf("legacy claim journal %s changed after migration and is ignored: %w", legacy, ErrChanged), nil } if err != nil { return nil, err } if present != 0 { return nil, ErrLegacyJournalMigration } tx, err := s.Launches.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() var count int if err = tx.QueryRowContext(ctx, "SELECT (SELECT count(*) FROM launches)+(SELECT count(*) FROM stop_inventory)").Scan(&count); err != nil || count != 0 { return nil, errors.Join(err, ErrTypedJournal) } if _, err = tx.ExecContext(ctx, "INSERT INTO journal_migration(version,source,files,rows) VALUES(1,?,?,?)", legacy, files, legacyRowsDigest(nil, nil)); err != nil { return nil, err } return nil, tx.Commit() } // journalMigrationMarker creates or upgrades the marker table and reads it. func (s *JournalService) journalMigrationMarker(ctx context.Context) (source, files, rows string, marked bool, err error) { if _, err = s.Launches.db.ExecContext(ctx, "CREATE TABLE IF NOT EXISTS journal_migration(version INTEGER PRIMARY KEY CHECK(version=1), source TEXT NOT NULL, files TEXT NOT NULL DEFAULT '', rows TEXT NOT NULL DEFAULT '')"); err != nil { return } for _, column := range []string{"files", "rows"} { var n int if err = s.Launches.db.QueryRowContext(ctx, "SELECT count(*) FROM pragma_table_info('journal_migration') WHERE name=?", column).Scan(&n); err != nil { return } if n == 0 { if _, err = s.Launches.db.ExecContext(ctx, "ALTER TABLE journal_migration ADD COLUMN "+column+" TEXT NOT NULL DEFAULT ''"); err != nil { return } } } err = s.Launches.db.QueryRowContext(ctx, "SELECT source,files,rows FROM journal_migration WHERE version=1").Scan(&source, &files, &rows) if errors.Is(err, sql.ErrNoRows) { return "", "", "", false, nil } return source, files, rows, err == nil, err } // MigrateLegacy is the offline operator step (carlos system journal-migrate); // root startup only runs CheckLegacyMigration. Quiesce must prove the old // writers are stopped and held stopped for this call. Root never opens SQLite // on the old paths: bounded no-follow copies, including WAL, are made in the // protected directory, hashed as they are copied, and opened with an untrusted // schema, read-only. Every imported field is validated and re-marshalled; no // raw body is stored. The transaction imports both inventories and the marker // together. A crash before commit imports nothing; a crash afterward never // imports a stale legacy database again. The marker binds the digests of the // copied legacy bytes and of the rows it imported: if a rolled-back binary // later wrote rows there, this step refuses rather than silently ignoring // them. Legacy originals remain untouched. func (s *JournalService) MigrateLegacy(ctx context.Context, legacy, box, enrollment string, quiesce func(context.Context) (func(), error), verify func(context.Context, LaunchInventory, StopInventory, StopKernelJournal) error, onSuccess ...func(context.Context) error) (resultErr error) { if s == nil || s.Launches == nil || !filepath.IsAbs(legacy) || box == "" || enrollment == "" { return ErrTypedJournal } unlock, err := lockJournalMigration(ctx, filepath.Join(filepath.Dir(s.Launches.path), "migration.lock")) if err != nil { return err } defer unlock() defer func() { if resultErr == nil { for _, recover := range onSuccess { if recover != nil { resultErr = errors.Join(resultErr, recover(ctx)) } } } }() // These private snapshots are never authoritative before the SQL marker. // A restart can discard a pre-commit copy and take a fresh frozen snapshot. entries, err := os.ReadDir(filepath.Dir(s.Launches.path)) if err != nil { return err } for _, entry := range entries { if entry.IsDir() && strings.HasPrefix(entry.Name(), "migration-") { if err := os.RemoveAll(filepath.Join(filepath.Dir(s.Launches.path), entry.Name())); err != nil { return err } } } source, markedFiles, markedRows, marked, err := s.journalMigrationMarker(ctx) if err != nil { return err } files, present, err := legacyJournalFiles(legacy) if err != nil { return err } if present != 0 && present != 2 { return ErrTypedJournal } if marked { if source != legacy { return ErrChanged } // Unchanged legacy content, or legacy files removed after import: // nothing can have been written there since the marker. if present == 0 || legacyContent(markedFiles) == files { return nil } } if present == 2 { if quiesce == nil { return ErrTypedJournal } release, err := quiesce(ctx) if err != nil { return err } if release == nil { return ErrTypedJournal } defer release() // Stopping the old writers may itself checkpoint; the snapshot is of // the quiesced files. if files, present, err = legacyJournalFiles(legacy); err != nil { return err } if present != 2 { return ErrChanged } } // Prepare and verify bounded immutable records while no root SQLite // connection or write transaction is held. The offline flock and edge // exclusion remain held until the later atomic import finishes. launches, inventories, copied, err := readLegacyJournals(ctx, legacy, filepath.Dir(s.Launches.path), box, enrollment) if err != nil { return err } // A box that never ran the legacy claim writer has no legacy directory // at all (leo, 2026-10-19). The copy names each missing file instead of // "absent"; both say there is nothing to import. if files == "absent" && copied == legacyNoJournals { copied = files } // The rows came from exactly the bytes the identity names: the digests // are of the copies themselves, not of a later re-read. if copied != files { return fmt.Errorf("legacy journal changed while copied: %w", ErrChanged) } rows := legacyRowsDigest(launches, inventories) if marked { // The content changed after import. Only identical rows (a checkpoint) // keep the marker; new history from a rolled-back binary needs the // operator, never a silent skip or an unproven import. A marker from // before rows were bound (4f342ec7) is bound now, but only when root // already holds every legacy row. if markedRows == "" { if err = s.holdsLegacyRows(ctx, launches, inventories); err != nil { return fmt.Errorf("legacy claim journal %s changed after migration: %w", legacy, err) } } else if rows != markedRows { return fmt.Errorf("legacy claim journal %s changed after migration: %w", legacy, ErrChanged) } _, err = s.Launches.db.ExecContext(ctx, "UPDATE journal_migration SET files=?, rows=? WHERE version=1 AND rows=?", files, rows, markedRows) return err } var count int if err = s.Launches.db.QueryRowContext(ctx, "SELECT (SELECT count(*) FROM launches)+(SELECT count(*) FROM stop_inventory)").Scan(&count); err != nil || count != 0 { return errors.Join(err, ErrTypedJournal) } // Copied rows are untrusted until root's signed stop evidence binds both // representations. Unpublished, active or edited histories fail closed. // Budget one roster read plus a namespace list, receipt and signature per // prepared row. Shared namespaces are cached by the verifier. Each remote // read is separately bounded to 30 seconds; caller cancellation always wins. // The 10,000-row cap makes the worst batch budget 900,030 seconds. proofCtx, cancelProof := context.WithTimeout(ctx, time.Duration(1+3*len(launches))*30*time.Second) defer cancelProof() covered := map[string]bool{} for _, in := range launches { if verify == nil { return ErrLegacyJournalProof } inventory, ok := inventories[fmt.Sprintf("%s/%s/%d", in.Target.Host, in.Target.Incarnation, in.Target.Stamp)] if !ok { return ErrLegacyJournalProof } journal, err := launchStopKernelJournal(in, in.Target) if err != nil { return err } if err = ctx.Err(); err != nil { return err } err = verify(proofCtx, in, inventory, journal) if err == nil { err = proofCtx.Err() } if err != nil { return errors.Join(err, ErrLegacyJournalProof) } covered[fmt.Sprintf("%s/%s/%d", in.Target.Host, in.Target.Incarnation, in.Target.Stamp)] = true } if len(inventories) != len(covered) { return ErrLegacyJournalProof } if err = proofCtx.Err(); err != nil { return err } cancelProof() tx, err := s.Launches.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if err = tx.QueryRowContext(ctx, "SELECT (SELECT count(*) FROM launches)+(SELECT count(*) FROM stop_inventory)").Scan(&count); err != nil || count != 0 { return errors.Join(err, ErrTypedJournal) } for _, in := range launches { body, err := json.Marshal(in) if err != nil { return err } if _, err = tx.ExecContext(ctx, "INSERT INTO launches(identity,body) VALUES(?,?)", launchKey(in.Target), body); err != nil { return err } } txctx := context.WithValue(ctx, journalTransactionKey{}, tx) for _, in := range inventories { if err = s.Inventory.WriteStopInventory(txctx, in); err != nil { return err } } if _, err = tx.ExecContext(ctx, "INSERT INTO journal_migration(version,source,files,rows) VALUES(1,?,?,?)", legacy, files, rows); err != nil { return err } return tx.Commit() } // Legacy import bounds. Root's instance-control runs under MemoryMax=256M, so // no legacy file, row or cell may be read into memory before it is known to // fit. Files are capped at the 16 MiB import budget plus slack for SQLite // pages, WAL frames and free space. Cells are sized by SQL before any Scan. const ( legacyJournalFileMax = 64 << 20 legacyImportMax = 16 << 20 legacyRowMax = 1 << 20 legacyKeyMax = 4096 legacyRowsMax = 10000 ) // holdsLegacyRows proves every legacy row's key is already in root's journal. func (s *JournalService) holdsLegacyRows(ctx context.Context, launches []LaunchInventory, inventories map[string]StopInventory) error { for _, in := range launches { var n int if err := s.Launches.db.QueryRowContext(ctx, "SELECT count(*) FROM launches WHERE identity=?", launchKey(in.Target)).Scan(&n); err != nil || n != 1 { return errors.Join(err, ErrChanged) } } for _, in := range inventories { var n int if err := s.Launches.db.QueryRowContext(ctx, "SELECT count(*) FROM stop_inventory WHERE host=? AND incarnation=? AND stamp=?", in.Host, in.Incarnation, in.InstallationStamp).Scan(&n); err != nil || n != 1 { return errors.Join(err, ErrChanged) } } return nil } // legacyContent reduces a marker's file identity to names and content // digests. Markers from 618f3a8a also carry device, inode, size and times; // the digest is their last field. A metadata-only change is not a change. func legacyContent(files string) string { if files == "" || files == "absent" { return files } parts := strings.Split(files, "/") for i, part := range parts { fields := strings.Split(part, ":") parts[i] = fields[0] + ":" + fields[len(fields)-1] } return strings.Join(parts, "/") } // legacyJournalFiles names the exact legacy files by the SHA-256 of the bytes // of each database and WAL ("-" when absent). Only no-follow, single-link // regular files within the copy bound are read. func legacyJournalFiles(legacy string) (string, int, error) { dir, err := unix.Open(legacy, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if errors.Is(err, unix.ENOENT) { return "absent", 0, nil } if err != nil { return "", 0, err } defer unix.Close(dir) var parts []string present := 0 for _, name := range []string{"launches.db", "launches.db-wal", "inventory.db", "inventory.db-wal"} { fd, err := unix.Openat(dir, name, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) if errors.Is(err, unix.ENOENT) { parts = append(parts, name+":-") continue } if err != nil { return "", 0, err } f := os.NewFile(uintptr(fd), name) part, err := func() (string, error) { defer f.Close() var st unix.Stat_t if err := unix.Fstat(fd, &st); err != nil { return "", err } if st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Size > legacyJournalFileMax { return "", ErrTypedJournal } h := sha256.New() if n, err := io.Copy(h, io.LimitReader(f, legacyJournalFileMax+1)); err != nil || n != st.Size { return "", errors.Join(err, ErrChanged) } return fmt.Sprintf("%s:%x", name, h.Sum(nil)), nil }() if err != nil { return "", 0, err } if !strings.HasSuffix(name, "-wal") { present++ } parts = append(parts, part) } return strings.Join(parts, "/"), present, nil } // legacyNoJournals is readLegacyJournals' identity when neither legacy // journal nor its write-ahead log exists. const legacyNoJournals = "launches.db:-/launches.db-wal:-/inventory.db:-/inventory.db-wal:-" // legacyRowsDigest is the order-independent digest of the canonical rows. func legacyRowsDigest(launches []LaunchInventory, inventories map[string]StopInventory) string { var rows []string for _, in := range launches { body, _ := json.Marshal(in) rows = append(rows, "launch\x00"+launchKey(in.Target)+"\x00"+string(body)) } for key, in := range inventories { body, _ := json.Marshal(in) rows = append(rows, "inventory\x00"+key+"\x00"+string(body)) } sort.Strings(rows) sum := sha256.Sum256([]byte(strings.Join(rows, "\x01"))) return hex.EncodeToString(sum[:]) } // readLegacyJournals also returns the content identity of the bytes it copied, // in legacyJournalFiles' form. func readLegacyJournals(ctx context.Context, legacy, dest, box, enrollment string) ([]LaunchInventory, map[string]StopInventory, string, error) { var launches []LaunchInventory keys := map[string]bool{} inventories := map[string]StopInventory{} preparedBytes := 0 var identity []string for _, name := range []string{"launches.db", "inventory.db"} { copied, digests, cleanup, err := copyLegacyJournal(legacy, name, dest) if errors.Is(err, os.ErrNotExist) { identity = append(identity, name+":-", name+"-wal:-") continue } if err != nil { return nil, nil, "", err } identity = append(identity, digests...) err = func() error { defer cleanup() // The copy is agent-supplied bytes: no schema-defined SQL runs, no // statement writes, and the whole read is time bounded. The schema // must be exactly the legacy code's before integrity_check reads a // row, and integrity_check never evaluates a CHECK expression. readCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() db, err := sql.Open("sqlite", "file:"+url.PathEscape(copied)+"?_pragma=trusted_schema(0)&_pragma=query_only(1)&_pragma=ignore_check_constraints(1)&_pragma=busy_timeout(5000)") if err != nil { return err } defer db.Close() db.SetMaxOpenConns(1) tables := map[string]bool{"launches": true} if name == "inventory.db" { tables = map[string]bool{"stop_inventory": true, "journal_inventory_versions": true} } if err = checkLegacySchema(readCtx, db, tables); err != nil { return err } var result string if err = db.QueryRowContext(readCtx, "PRAGMA integrity_check").Scan(&result); err != nil || result != "ok" { return errors.Join(err, ErrTypedJournal) } // Size every scanned cell before Scan allocates it. octet_length // reads the stored length without loading the value. bounds := "SELECT count(*), coalesce(sum(octet_length(body)),0), coalesce(max(octet_length(body)),0), coalesce(max(octet_length(identity)),0), 0 FROM launches" if name == "inventory.db" { bounds = "SELECT count(*), coalesce(sum(octet_length(body)),0), coalesce(max(octet_length(body)),0), coalesce(max(max(octet_length(host)),max(octet_length(incarnation))),0), coalesce(sum(typeof(stamp)<>'integer'),0) FROM stop_inventory" } var count, total, row, key, untyped int64 if err = db.QueryRowContext(readCtx, bounds).Scan(&count, &total, &row, &key, &untyped); err != nil { return errors.Join(err, ErrTypedJournal) } if count > legacyRowsMax || row > legacyRowMax || key > legacyKeyMax || untyped != 0 || int64(preparedBytes)+total > legacyImportMax { return ErrTypedJournal } if name == "launches.db" { rows, err := db.QueryContext(readCtx, "SELECT identity,body FROM launches") if err != nil { return err } defer rows.Close() for rows.Next() { var key string var raw []byte if err = rows.Scan(&key, &raw); err != nil { return err } preparedBytes += len(raw) if preparedBytes > 16<<20 || len(launches) >= 10000 { return ErrTypedJournal } var in LaunchInventory if decodeStrict(raw, &in) != nil || validateImportedLaunch(in) != nil || key != launchKey(in.Target) || in.Target.Box != box || in.Target.Enrollment != enrollment || keys[key] { return ErrTypedJournal } keys[key] = true launches = append(launches, in) } return rows.Err() } rows, err := db.QueryContext(readCtx, "SELECT host,incarnation,stamp,body FROM stop_inventory") if err != nil { return err } defer rows.Close() for rows.Next() { var host, inc string var stamp uint64 var raw []byte if err = rows.Scan(&host, &inc, &stamp, &raw); err != nil { return err } preparedBytes += len(raw) if preparedBytes > 16<<20 || len(inventories) >= 10000 { return ErrTypedJournal } var in StopInventory if decodeStrict(raw, &in) != nil || validateImportedInventory(in) != nil || in.Host != host || in.Incarnation != inc || in.InstallationStamp != stamp || in.Box != box || in.Enrollment != enrollment { return ErrTypedJournal } key := fmt.Sprintf("%s/%s/%d", host, inc, stamp) if _, exists := inventories[key]; exists { return ErrTypedJournal } inventories[key] = in } return rows.Err() }() if err != nil { return nil, nil, "", err } } return launches, inventories, strings.Join(identity, "/"), nil } // legacyTableSQL is, byte for byte, the CREATE text each legacy table was // written with (SQLite stores it without IF NOT EXISTS). var legacyTableSQL = map[string]string{ "launches": "CREATE TABLE launches (identity TEXT PRIMARY KEY, body BLOB NOT NULL)", "stop_inventory": "CREATE TABLE stop_inventory(host TEXT NOT NULL,incarnation TEXT NOT NULL,stamp INTEGER NOT NULL,body BLOB NOT NULL,PRIMARY KEY(host,incarnation,stamp))", "journal_inventory_versions": "CREATE TABLE journal_inventory_versions(host TEXT NOT NULL, incarnation TEXT NOT NULL, stamp INTEGER NOT NULL, revision INTEGER NOT NULL CHECK(revision>0 AND typeof(revision)='integer'), request TEXT NOT NULL, PRIMARY KEY(host,incarnation,stamp))", } // checkLegacySchema admits only the named tables, each with exactly its legacy // CREATE text and a real root page, and their implicit primary key indexes, // which have no SQL text. Triggers, views, virtual tables (root page zero, // whatever the text says), expression indexes and any other schema object // refuse, so no schema-defined expression is left for a read to run. func checkLegacySchema(ctx context.Context, db *sql.DB, tables map[string]bool) error { rows, err := db.QueryContext(ctx, "SELECT type,name,tbl_name,rootpage,sql FROM sqlite_schema") if err != nil { return errors.Join(err, ErrTypedJournal) } defer rows.Close() var present []string for rows.Next() { var kind, name, table string var root sql.NullInt64 var text sql.NullString if err = rows.Scan(&kind, &name, &table, &root, &text); err != nil { return errors.Join(err, ErrTypedJournal) } switch { case !root.Valid || root.Int64 <= 0: return ErrTypedJournal case kind == "table" && tables[name] && name == table && text.Valid && text.String == legacyTableSQL[name]: present = append(present, name) case kind == "index" && tables[table] && strings.HasPrefix(name, "sqlite_autoindex_"+table+"_") && !text.Valid: default: return ErrTypedJournal } } if err = rows.Err(); err != nil { return err } // One connection: the schema rows are closed before each column query. rows.Close() for _, table := range present { var computed int if err = db.QueryRowContext(ctx, "SELECT count(*) FROM pragma_table_xinfo(?) WHERE hidden<>0", table).Scan(&computed); err != nil || computed != 0 { return errors.Join(err, ErrTypedJournal) } } return nil } func legacyToken(s string) bool { if s == "" || len(s) > 256 { return false } for _, r := range s { if r < 0x21 || r > 0x7e { return false } } return true } func legacyDigits(s string) bool { if s == "" || len(s) > 32 { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func legacyTarget(t hostguard.FenceTarget) bool { for _, s := range []string{t.Region, t.ObligationID, t.FenceID, t.Op, t.Phase, t.ClaimETag, t.DirectoryRevision} { if s != "" && !legacyToken(s) { return false } } return validLaunchOwner(t) && component(t.Host) && legacyToken(t.Incarnation) && legacyToken(t.Box) && legacyToken(t.Enrollment) && legacyToken(t.BootID) && legacyToken(t.ControllerID) } func legacyGroup(g LaunchGroup, identity bool) bool { if !managedName(g.RelativePath) || (g.InvocationID != "" && !legacyToken(g.InvocationID)) { return false } return identity == (g.Device != 0 && g.Inode != 0) && (identity || (g.Device == 0 && g.Inode == 0)) } // validateImportedLaunch accepts only a launch record root's own journal // methods could have produced. Every field is checked. func validateImportedLaunch(in LaunchInventory) error { if !legacyTarget(in.Target) || in.ControllerPID <= 0 || !legacyDigits(in.ProcessStart) || !in.Completed.Valid() || len(in.Groups) == 0 || len(in.Groups) > 4096 || len(in.Files) > 4096 || len(in.ProducerIDs) > 4096 || len(in.Reservations) > 4096 { return ErrTypedJournal } if in.NoState && len(in.Files) != 0 { return ErrTypedJournal } groups := map[string]bool{} for _, g := range in.Groups { if !legacyGroup(g, true) || groups[g.RelativePath] { return ErrTypedJournal } groups[g.RelativePath] = true } files := map[LaunchFile]bool{} for _, f := range in.Files { if f.Device == 0 || f.Inode == 0 || files[f] { return ErrTypedJournal } files[f] = true } producers := map[string]bool{} for _, p := range in.ProducerIDs { if !component(p) || !legacyToken(p) || producers[p] { return ErrTypedJournal } producers[p] = true } ids := map[string]bool{} for _, r := range in.Reservations { if !legacyToken(r.ID) || !legacyToken(r.Role) || !legacyToken(r.InvocationID) || !legacyToken(r.Inputs) || ids[r.ID] || !SameLaunchOwner(r.Target, in.Target) || !legacyTarget(r.Target) || r.Executable.Device == 0 || r.Executable.Inode == 0 || r.Group.InvocationID != r.InvocationID { return ErrTypedJournal } ids[r.ID] = true if r.Parent != (LaunchGroup{}) && !legacyGroup(r.Parent, true) { return ErrTypedJournal } switch r.State { case "intended": if !legacyGroup(r.Group, false) || r.PID != 0 || r.ProcessStart != "" { return ErrTypedJournal } case "reserved": if !legacyGroup(r.Group, true) || r.PID != 0 || r.ProcessStart != "" { return ErrTypedJournal } case "started", "settled": if !legacyGroup(r.Group, true) || r.PID < 0 || (r.PID == 0) != (r.ProcessStart == "") || (r.ProcessStart != "" && !legacyDigits(r.ProcessStart)) || (r.State == "started" && r.PID == 0) { return ErrTypedJournal } default: return ErrTypedJournal } } return nil } func validateImportedInventory(in StopInventory) error { if !component(in.Host) || !legacyToken(in.Incarnation) || in.InstallationStamp == 0 || !legacyToken(in.Box) || !legacyToken(in.Enrollment) || !legacyToken(in.DirectoryRevision) || (len(in.Entries) == 0 && !in.Complete) || len(in.Entries) > 4096 { return ErrTypedJournal } for _, e := range in.Entries { if !component(e.Kind) || !component(e.ArtifactID) || !component(e.ManagedRoot) || !managedName(e.RelativePath) || !legacyToken(e.Ownership) || (e.RowIdentity != "" && !legacyToken(e.RowIdentity)) || (e.Materialization != "intended" && e.Materialization != "materialized") { return ErrTypedJournal } } return nil } // legacyCopyHook lets tests act on a legacy file just before and just after // it is copied. var legacyCopyHook func(name string, copied bool) // copyLegacyJournal copies the database and its WAL, returning each file's // identity part ("name:sha256", or "name:-" for an absent WAL) computed from // the bytes written to the copy. func copyLegacyJournal(dir, name, dest string) (string, []string, func(), error) { // A directory fd anchors the source components. The quiescence boundary, // not a pair of pathname checks, makes the copied snapshot coherent. fd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) if err != nil { return "", nil, nil, err } defer unix.Close(fd) temp, err := os.MkdirTemp(dest, "migration-") if err != nil { return "", nil, nil, err } clean := func() { _ = os.RemoveAll(temp) } path := filepath.Join(temp, name) var digests []string for _, suffix := range []string{"", "-wal"} { if legacyCopyHook != nil { legacyCopyHook(name+suffix, false) } f, e := unix.Openat(fd, name+suffix, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0) if errors.Is(e, unix.ENOENT) && suffix != "" { digests = append(digests, name+suffix+":-") continue } if e != nil { clean() return "", nil, nil, e } source := os.NewFile(uintptr(f), name+suffix) var st unix.Stat_t e = unix.Fstat(f, &st) if e != nil || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || st.Size > legacyJournalFileMax { source.Close() clean() return "", nil, nil, ErrTypedJournal } target, e := os.OpenFile(path+suffix, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if e != nil { source.Close() clean() return "", nil, nil, e } h := sha256.New() n, e := io.Copy(io.MultiWriter(target, h), io.LimitReader(source, legacyJournalFileMax+1)) syncErr := target.Sync() closeErr := target.Close() source.Close() if e != nil || syncErr != nil || closeErr != nil || n != st.Size { clean() return "", nil, nil, fmt.Errorf("journal snapshot: %w", errors.Join(e, syncErr, closeErr, ErrChanged)) } digests = append(digests, fmt.Sprintf("%s:%x", name+suffix, h.Sum(nil))) if legacyCopyHook != nil { legacyCopyHook(name+suffix, true) } } return path, digests, clean, nil } func lockJournalMigration(ctx context.Context, path string) (func(), error) { fd, err := unix.Open(path, unix.O_RDWR|unix.O_CREAT|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0600) if err != nil { return nil, err } var st unix.Stat_t if err = unix.Fstat(fd, &st); err != nil || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Nlink != 1 || int(st.Uid) != os.Geteuid() { unix.Close(fd) return nil, errors.Join(err, ErrTypedJournal) } for { err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB) if err == nil { return func() { unix.Flock(fd, unix.LOCK_UN); unix.Close(fd) }, nil } if !errors.Is(err, unix.EWOULDBLOCK) { unix.Close(fd) return nil, err } select { case <-ctx.Done(): unix.Close(fd) return nil, ctx.Err() case <-time.After(20 * time.Millisecond): } } }