diff --git a/docs/sync.md b/docs/sync.md index cbca5822..17cc21bf 100644 --- a/docs/sync.md +++ b/docs/sync.md @@ -152,6 +152,34 @@ Recovery is remediation, not a missing-input discovery command: copy as a dry-run recovery input first. Retire backups only after explicitly accepting the recovered/restored state. +### Immutable connections and recovery source verification + +`storage.OpenImmutableReadOnly` opens an existing canonical main database with +SQLite `mode=ro&immutable=1`, preserving the database and its SQLite sidecars. +It rejects a nonempty WAL observed before opening, since committed frames may +still be outside the main file. Its caller supplies a fully checkpointed, +quiescent database in a trusted filesystem namespace for the connection's entire +lifetime. + +Recovery adds a distinct source-verification policy: WAL is absent or an empty +regular file; a leftover regular SHM is accepted in either case; every journal +namespace entry is rejected. File-set fingerprinting requires regular files, +including SHM, and checks content hashes and file identity before and after +immutable reads. Consequently WAL/SHM symlinks are rejected by recovery even +when an immutable connection alone could open the main file. A synthetic journal +entry can likewise be left untouched by the standalone opener while recovery +refuses the file set; that observation establishes a namespace-policy difference, +not a real hot-journal stale-read or data-loss reproduction. + +Dry-run applies these checks to both inputs. Apply reads the active input under +a SQLite writer reservation and revalidates its main file and preserved backup +around replacement; a distinct stranded input gets the immutable before/after +file-set checks. Fingerprints detect observed changes rather than reserve the +stranded writer. Keep its writers stopped throughout recovery, even when WAL is +empty or absent. The connection-level and recovery-level policies intentionally +remain separate. + + V20 preserves nullable native session identity as canonical recovery evidence. Compatible unknown ownership/project proof can be enriched, while contradictory diff --git a/internal/recovery/recovery.go b/internal/recovery/recovery.go index 53bf2e8a..8b1b49b2 100644 --- a/internal/recovery/recovery.go +++ b/internal/recovery/recovery.go @@ -189,6 +189,9 @@ func planRecoveryFromImmutablePaths(ctx context.Context, paths []resolvedPath, r return plan, diagnostics, nil } +// readImmutableRecoveryInput layers recovery's sidecar namespace and before/after +// file-set content/identity checks over storage's immutable connection. Callers +// keep stranded writers quiescent; apply reserves the active input separately. func readImmutableRecoveryInput(ctx context.Context, path string, dryRun bool, deps recoveryOperationDeps) (compat.RecoveryInput, error) { if err := deps.ensureSidecars(path); err != nil { return compat.RecoveryInput{}, recoveryOpenError(path, err, dryRun) @@ -934,7 +937,7 @@ func restoreBackup(activePath, backupPath string, backupSidecars []string, plann } func ensureSourceSidecarsSafe(dbPath string) error { - // Check WAL file status first to determine if -shm is safe + // An absent or empty regular WAL makes a leftover SHM eligible for inspection. walPath := dbPath + "-wal" walInfo, walErr := os.Lstat(walPath) walIsSafe := walErr != nil || (walInfo != nil && walInfo.Mode().IsRegular() && walInfo.Size() == 0) @@ -949,8 +952,8 @@ func ensureSourceSidecarsSafe(dbPath string) error { } return fmt.Errorf("%w: unexpected SQLite sidecar namespace entry %s", storage.ErrImmutableReadOnlyWALUnsafe, path) } - // For -shm and -journal files, tolerate them if the WAL is safe (zero bytes or absent) - // This matches the safety property: no active writer modifies the database if WAL has no pending changes + // SHM is eligible with an absent/empty WAL. File-set fingerprinting + // separately requires regular files and stable content/identity. if suffix == "-shm" && walIsSafe { continue } diff --git a/internal/recovery/recovery_test.go b/internal/recovery/recovery_test.go index 47a0a529..ce4bd8bc 100644 --- a/internal/recovery/recovery_test.go +++ b/internal/recovery/recovery_test.go @@ -1399,7 +1399,7 @@ func TestRecoverSQLiteSidecarPolicy(t *testing.T) { fromPath := filepath.Join(dir, "stranded.db") createRecoveryDB(t, activePath, []storage.IndexedMessage{{Ordinal: 0, Role: "user", Text: "active", UUID: "44444444-4444-4444-8444-444444444444", ContentType: "text"}}) createRecoveryDB(t, fromPath, []storage.IndexedMessage{{Ordinal: 0, Role: "assistant", Text: "stranded", UUID: "55555555-5555-4555-8555-555555555555", ContentType: "text"}}) - if err := os.WriteFile(activePath+"-journal", []byte("hot journal sentinel"), 0o600); err != nil { + if err := os.WriteFile(activePath+"-journal", []byte("synthetic journal namespace sentinel"), 0o600); err != nil { t.Fatalf("write rollback journal: %v", err) } @@ -1423,7 +1423,7 @@ func TestRecoverSQLiteSidecarPolicy(t *testing.T) { t.Fatalf("write empty wal: %v", err) } - // With the fix for issue #51, empty -shm is tolerated when -wal is zero bytes (no active writer) + // Empty WAL makes leftover regular SHM eligible; recovery also fingerprints the file set. report, err := Execute(context.Background(), Options{ActivePath: activePath, FromPath: fromPath, DryRun: true}) if err != nil { t.Fatalf("Execute error = %v, want success when -shm has no pending changes", err) @@ -1753,7 +1753,7 @@ func TestEnsureSourceSidecarsToleratesZeroByteWAL(t *testing.T) { // TestEnsureSourceSidecarsToleratesSHMWithZeroWAL verifies that ensureSourceSidecarsSafe // tolerates a -shm file when the -wal is zero bytes (issue #51 fix). -// The safety property is: if WAL has no pending changes, the database is not being actively written to. +// Quiescent inputs remain a prerequisite; an empty WAL alone is not a writer reservation. func TestEnsureSourceSidecarsToleratesSHMWithZeroWAL(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") @@ -1779,7 +1779,7 @@ func TestEnsureSourceSidecarsToleratesSHMWithZeroWAL(t *testing.T) { } } -// TestEnsureSourceSidecarsRejectsSHMWithoutWAL verifies that ensureSourceSidecarsSafe +// TestEnsureSourceSidecarsToleratesSHMWithoutWAL verifies that ensureSourceSidecarsSafe // tolerates a -shm file when the -wal is absent (issue #51 fix). func TestEnsureSourceSidecarsToleratesSHMWithoutWAL(t *testing.T) { dir := t.TempDir() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 34d392f6..a09b3cd9 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -394,10 +394,15 @@ func openReadOnlyContext(ctx context.Context, path string) (*Database, error) { return &Database{db: db, path: canonicalPath}, nil } -// OpenImmutableReadOnly opens an existing SQLite database without creating or -// touching SQLite sidecar files. It refuses non-empty WAL files because an -// immutable view can miss committed frames that are not checkpointed into the -// main database file. +// OpenImmutableReadOnly opens an existing canonical SQLite main database with +// mode=ro and immutable=1, preserving its bytes and SQLite sidecars. It refuses a +// non-empty WAL observed before opening because committed frames may not yet be +// checkpointed into the main file. +// +// Callers must supply a fully checkpointed, quiescent database in a trusted +// filesystem namespace for the connection's lifetime. Recovery additionally +// validates sidecar namespace entries and fingerprints file-set content and +// identity before and after reading; those are separate recovery guarantees. func OpenImmutableReadOnly(path string) (*Database, error) { return openImmutableReadOnlyContext(context.Background(), path) } diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go index 0e9958da..12a81be2 100644 --- a/internal/storage/storage_test.go +++ b/internal/storage/storage_test.go @@ -354,6 +354,49 @@ func TestOpenImmutableReadOnlyDoesNotCreateSidecars(t *testing.T) { assertStorageSidecarMissing(t, dbPath+"-shm") } +func TestOpenImmutableReadOnlyPreservesExistingSidecars(t *testing.T) { + for _, tt := range []struct { + name string + sidecars map[string][]byte + }{ + {name: "regular SHM", sidecars: map[string][]byte{"-shm": []byte("leftover SHM")}}, + {name: "empty WAL with SHM", sidecars: map[string][]byte{"-wal": nil, "-shm": []byte("leftover SHM")}}, + {name: "synthetic journal namespace", sidecars: map[string][]byte{"-journal": []byte("synthetic namespace entry, not a hot journal")}}, + } { + t.Run(tt.name, func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "immutable_sidecars.db") + writer := openTestDBWithLiveWAL(t, dbPath) + if err := writer.Close(); err != nil { + t.Fatalf("close checkpointed fixture: %v", err) + } + for suffix, data := range tt.sidecars { + if err := os.WriteFile(dbPath+suffix, data, 0o600); err != nil { + t.Fatalf("write sidecar %s: %v", suffix, err) + } + } + before := snapshotRecoveryDB(t, dbPath) + + immutable, err := OpenImmutableReadOnly(dbPath) + if err != nil { + t.Fatalf("OpenImmutableReadOnly checkpointed fixture: %v", err) + } + var count int + err = immutable.DB().QueryRow("SELECT COUNT(*) FROM search_items WHERE text = 'live WAL committed row'").Scan(&count) + closeErr := immutable.Close() + if err != nil { + t.Fatalf("read checkpointed row: %v", err) + } + if closeErr != nil { + t.Fatalf("close immutable fixture: %v", closeErr) + } + if count != 1 { + t.Fatalf("checkpointed row count = %d, want 1", count) + } + assertRecoveryDBSnapshot(t, dbPath, before) + }) + } +} + // TestOpenReadOnlyNonExistent verifies that opening a non-existent database fails. func TestOpenReadOnlyNonExistent(t *testing.T) { _, err := OpenReadOnly("/nonexistent/path/db.sqlite")