From 855e198347eebd76e5167e614671196206bb7820 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 17:44:55 +0900 Subject: [PATCH 1/4] Add atomic managed database rollback (#4857) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 + USER_GUIDE.md | 8 +- changelog.d/unreleased/4857.fixed.md | 26 + src/CodeIndex/Cli/CliFlagSchema.cs | 2 + src/CodeIndex/Cli/ConsoleUi.cs | 22 +- .../Cli/DbCommandRunner.ArgumentParser.cs | 27 +- .../Cli/DbCommandRunner.Checkpoints.cs | 15 +- .../DbCommandRunner.ManagedRestoreBackups.cs | 281 ++++++ src/CodeIndex/Cli/DbCommandRunner.Restore.cs | 50 +- .../Cli/DbCommandRunner.RestoreValidation.cs | 78 +- src/CodeIndex/Cli/DbCommandRunner.cs | 13 +- .../ExportImportCommandRunner.Contracts.cs | 7 + .../Cli/ExportImportCommandRunner.Import.cs | 79 +- ...portImportCommandRunner.ImportArguments.cs | 8 +- .../ExportImportCommandRunner.ImportOutput.cs | 19 +- .../Cli/ExportImportCommandRunner.cs | 4 +- src/CodeIndex/Cli/JsonOutputContracts.cs | 37 +- .../Cli/ManagedRestoreBackupStore.cs | 902 ++++++++++++++++++ tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 39 +- ...ExportImportCommandRunnerIssue3818Tests.cs | 2 +- .../ExportImportCommandRunnerTests.cs | 10 +- .../Issue4857ManagedRestoreBackupTests.cs | 488 ++++++++++ tests/CodeIndex.Tests/ProgramCliTests.cs | 8 +- 24 files changed, 2050 insertions(+), 83 deletions(-) create mode 100644 changelog.d/unreleased/4857.fixed.md create mode 100644 src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs create mode 100644 src/CodeIndex/Cli/ManagedRestoreBackupStore.cs create mode 100644 tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 8c5a01c8d..421eb67e1 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -50,7 +50,7 @@ Development contracts: | Read-only database queries | `cdidx status --db /artifacts/codeindex.db --read-only --json`; `cdidx search AuthService --db /artifacts/codeindex.db --immutable` | Query commands accept `--read-only` (alias `--immutable`) to open an existing CodeIndex database through SQLite's immutable read-only URI mode. Use this for CI artifacts, mounted caches, and sandboxes where creating or updating `codeindex.db-wal` / `codeindex.db-shm` sidecars is not allowed. | | Mutating commands | `index`, `backfill-fold`, `optimize`, `vacuum` | These require writable storage and reject read-only database opens. | | Reusable index artifact | `cdidx export codeindex.cdidx.zip`; `cdidx import codeindex.cdidx.zip --db `; `cdidx import codeindex.cdidx.zip --dry-run --json` | Run export after indexing and upload the archive. Export refuses an existing destination unless `--overwrite` is explicit, publishes atomically from an owner-only temporary file, and verifies POSIX mode `0600`. Successful export JSON adds final archive byte size and SHA-256 plus the complete immutable manifest while retaining the prior result fields. Consumers import before query commands, or use `--dry-run` / `--check` to validate the archive without replacing the destination DB. Use `--prune-paths` when the archive comes from another checkout and the restored DB should advertise the import target project root; imports targeting `.../.cdidx/codeindex.db` use the sibling project directory, while other DB paths fall back to the process current directory. The archive contains only `manifest.json` plus `codeindex.db`; import validates ZIP entry names through `ZipArchiveSafetyPolicy` and rejects absolute, parent-directory, backslash, NUL, non-canonical, duplicate, and extra entries before extraction. The manifest carries bounded summary/readiness metadata including row counts, readiness bits, writer/indexed-head metadata, schema contract stamps, and unknown-extension summary when available. Import validates manifest format, manifest `user_version`, `database_sha256`, present summary counts, and the embedded SQLite file as a CodeIndex database before replacing the destination DB. Import rejects archive `codeindex.db` entries whose compressed or uncompressed metadata exceeds 8 GiB, and the extraction stream is also capped at 8 GiB. | -| Maintenance checkpoint | `cdidx db checkpoint [--dry-run]`; `cdidx db checkpoints --list|--delete |--prune --keep [--dry-run]`; `cdidx db restore [--dry-run]`; `cdidx db restore-backups --list|--prune --keep [--dry-run]` | Checkpoint snapshots `codeindex.db` plus existing WAL/SHM sidecars before risky maintenance. Checkpoint creation `--dry-run` reports the source files and byte count without creating a directory. Checkpoint delete/prune and restore-backup prune require an explicit mutation action; their `--dry-run` forms make no filesystem changes and report the exact paths that would be deleted and retained. Checkpoint prune retains the newest `` valid snapshots, and skips all deletion if its bounded 1,000-directory scan is truncated. Restore `--dry-run` validates the bounded manifest, regular-file payload paths, required staging bytes, and destination free space without replacing the DB; a real restore runs the same validation first. Checkpoint manifests record only the database file name, not the local absolute DB path. Restore rolls back and keeps pre-restore files under `.restore-backup-/`. Checkpoints live next to the DB under `.checkpoints//`. `backfill-fold` creates an automatic checkpoint before it mutates rows unless `--no-checkpoint` is passed; automatic checkpoint names include a UTC timestamp plus random suffix to avoid same-millisecond collisions. | +| Maintenance checkpoint and managed rollback | `cdidx db checkpoint [--dry-run]`; `cdidx db checkpoints --list|--delete |--prune --keep [--dry-run]`; `cdidx db restore [--dry-run] [--no-backup]`; `cdidx db restore-backups --list|--prune --keep |--restore [--dry-run] [--no-backup]` | Checkpoint snapshots `codeindex.db` plus existing WAL/SHM sidecars before risky maintenance. Import and both restore forms create a consistent, verified managed SQLite rollback snapshot before replacing an existing DB unless `--no-backup` is explicit. Managed directories use `.restore-backup-/` and contain a bounded manifest plus one standalone database payload; the manifest records SHA-256, byte count, supported `user_version`, provenance, and an optional source identifier, but no local absolute source path. `restore-backups --list` exposes the ID and provenance while retaining legacy directory metadata; existing prune retention remains compatible. `restore-backups --restore ` revalidates the directory boundary, manifest, payload hash, schema, and combined staging/rollback free space, then performs an atomic replacement with transient rollback-on-failure. Its `--dry-run` reports every validation and planned backup without mutation. Checkpoint delete/prune and restore-backup prune require an explicit mutation action, and checkpoint prune skips all deletion if its bounded 1,000-directory scan is truncated. Checkpoints live under `.checkpoints//`. `backfill-fold` creates an automatic checkpoint before row mutation unless `--no-checkpoint` is passed. | | Binary compatibility | [COMPATIBILITY.md](COMPATIBILITY.md) | Database compatibility across `cdidx` binary upgrades and downgrades is documented there. Keep that policy updated whenever readiness bits, `codeindex_meta` contract stamps, or rebuild requirements change. | | Fold backfill preview and recovery | `backfill-fold --dry-run`; MCP `backfill_fold` with `dry_run: true` or `force: true` | Dry-run previews folded-key rows without mutating the DB or stamping FoldReady. MCP accepts the same preview and can force rewriting all folded keys when an operator needs to recover from suspicious fold metadata or row state even though the stored version/fingerprint appears current. Non-dry-run row rewrites are resumable after interruption: completed row updates remain durable, and final FoldReady metadata is stamped only after verification succeeds. MCP responses include `progress.rows_done`, `progress.rows_total`, and `progress.fraction` so clients can report and retry long backfills. | @@ -3242,7 +3242,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま | read-only database query | `cdidx status --db /artifacts/codeindex.db --read-only --json`; `cdidx search AuthService --db /artifacts/codeindex.db --immutable` | query コマンドは `--read-only`(alias: `--immutable`)を受け付け、既存の CodeIndex database を SQLite の immutable read-only URI mode で開けます。CI artifact、mounted cache、`codeindex.db-wal` / `codeindex.db-shm` sidecar を作成・更新できない sandbox で使います。 | | 変更系コマンド | `index`、`backfill-fold`、`optimize`、`vacuum` | 書き込み可能な storage を必要とし、read-only database open を拒否します。 | | 再利用可能な index artifact | `cdidx export codeindex.cdidx.zip`; `cdidx import codeindex.cdidx.zip --db `; `cdidx import codeindex.cdidx.zip --dry-run --json` | CI job では index 後に export して archive を upload します。export は `--overwrite` を明示しない限り既存 destination を拒否し、owner-only temporary file から atomic に publish して POSIX mode `0600` を検証します。export 成功時の JSON は従来 field を維持し、最終 archive の byte 数と SHA-256、完全で immutable な manifest を追加します。利用側は query コマンドの前に import でき、`--dry-run` / `--check` で destination DB を置き換えず archive を検証できます。別 checkout 由来の archive を import 先 project root として扱いたい場合は `--prune-paths` を使います。`.../.cdidx/codeindex.db` を import 先にした場合は sibling の project directory を使い、それ以外の DB path では process current directory に fallback します。archive は `manifest.json` と `codeindex.db` だけを含みます。import は ZIP entry 名を `ZipArchiveSafetyPolicy` で検証し、absolute path、parent-directory segment、backslash、NUL、non-canonical name、duplicate entry、extra entry を extraction 前に拒否します。manifest は row count、readiness bit、writer / indexed-head metadata、schema contract stamp、利用可能な unknown-extension summary などの bounded summary/readiness metadata を持ちます。import は manifest format、manifest `user_version`、`database_sha256`、存在する summary count、embedded SQLite file が CodeIndex database であることを検証してから destination DB を置き換えます。archive の `codeindex.db` entry は compressed / uncompressed metadata と extraction stream の双方で 8 GiB を上限に拒否されます。 | -| maintenance checkpoint | `cdidx db checkpoint [--dry-run]`; `cdidx db checkpoints --list|--delete |--prune --keep [--dry-run]`; `cdidx db restore [--dry-run]`; `cdidx db restore-backups --list|--prune --keep [--dry-run]` | 危険な maintenance の前に `codeindex.db` と既存 WAL/SHM sidecar の filesystem snapshot を作成し、restore で戻します。checkpoint 作成の `--dry-run` は directory を作らず、コピー元 file と byte 数を報告します。checkpoint の delete / prune と restore-backup の prune は明示的な変更 action を必要とし、それぞれの `--dry-run` は filesystem を変更せず、削除予定 path と保持予定 path を正確に報告します。checkpoint prune は新しい有効 snapshot を `` 件保持し、上限 1,000 directory の bounded scan が truncated になった場合は削除をすべて skip します。restore の `--dry-run` は DB を置き換えずに bounded manifest、regular-file payload path、staging に必要な byte 数、destination free space を検証し、実際の restore も先に同じ検証を実行します。checkpoint manifest は database file name だけを記録し、local absolute DB path は記録しません。checkpoint は DB の隣の `.checkpoints//` に置かれ、restore は pre-restore file を `.restore-backup-/` に保持します。`backfill-fold` は `--no-checkpoint` を渡さない限り、row mutation 前に automatic checkpoint を作ります。automatic checkpoint 名には UTC timestamp と random suffix が含まれ、同一 millisecond の衝突を避けます。 | +| maintenance checkpoint と managed rollback | `cdidx db checkpoint [--dry-run]`; `cdidx db checkpoints --list|--delete |--prune --keep [--dry-run]`; `cdidx db restore [--dry-run] [--no-backup]`; `cdidx db restore-backups --list|--prune --keep |--restore [--dry-run] [--no-backup]` | 危険な maintenance の前に `codeindex.db` と既存 WAL/SHM sidecar の checkpoint を作成できます。import と2種類の restore は、既存 DB を置き換える前に consistent かつ検証済みの managed SQLite rollback snapshot を既定で作成し、`--no-backup` を明示した場合だけ省略します。managed directory は `.restore-backup-/` で、bounded manifest と standalone database payload 1個を含みます。manifest は SHA-256、byte 数、対応する `user_version`、provenance、任意の source identifier を記録しますが、local absolute source path は記録しません。`restore-backups --list` は従来 directory metadata との互換性を維持しつつ ID と provenance を表示し、既存 prune retention もそのまま利用できます。`restore-backups --restore ` は directory 境界、manifest、payload hash、schema、staging と rollback を合わせた free space を再検証してから、失敗時の transient rollback を伴う atomic replacement を実行します。`--dry-run` は変更せず、すべての検証と作成予定 backup を報告します。checkpoint の delete / prune と restore-backup の prune は明示的な変更 action を必要とし、checkpoint prune の bounded scan が truncated の場合は削除をすべて skip します。checkpoint は `.checkpoints//` に置かれ、`backfill-fold` は `--no-checkpoint` がなければ row mutation 前に automatic checkpoint を作ります。 | | binary compatibility | [COMPATIBILITY.md](COMPATIBILITY.md) | `cdidx` binary の upgrade / downgrade をまたぐ database compatibility を記載します。readiness bit、`codeindex_meta` contract stamp、rebuild requirement を変える場合は、この policy も更新してください。 | | Fold backfill の preview / recovery | `backfill-fold --dry-run`; MCP `backfill_fold` の `dry_run: true` または `force: true` | dry-run は DB を変更せず FoldReady stamp も書かずに、rewrite 対象の folded-key row をプレビューします。MCP も同じ preview を受け付け、stored version / fingerprint が current に見える場合でも suspicious な fold metadata や row state を復旧するため `force: true` を受け付けます。non-dry-run rewrite は中断後に resume でき、完了済み row update は durable に残り、最終 FoldReady metadata は verification 成功後にだけ stamp されます。MCP response は `progress.rows_done`、`progress.rows_total`、`progress.fraction` を含みます。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index d9a55d59c..e56fbccde 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -642,6 +642,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - The `dotnet.yml` SDK setup has one conditional retry for transient SDK download failures. Keep the first attempt marked `continue-on-error` only while the retry is guarded by its failed outcome, so a second failure still fails the job. - `DbRecoveryTests.cs` Database corruption recovery and graceful degradation behavior. Filesystem setup failures for `cdidx index` (read-only DB files and unwritable DB parent directories) are covered in `IndexCommandRunnerTests.cs` so they exercise the same CLI JSON/stderr boundary users see. +- `Issue4857ManagedRestoreBackupTests.cs` + End-to-end regression coverage for automatic verified backups before import replacement, managed backup list/restore by ID, mutation-free dry runs, rollback after an injected post-move failure, corrupt payload and insufficient-space rejection, older supported schema stamps, corrupt import archives, and Windows exclusive-lock behavior. The fixture uses the SQLite-pool-sensitive collection because it resets process-wide free-space and replacement hooks in `finally`. - `JsonOutputSnapshotTests.cs`, `JsonOutputSnapshotHelper.cs` Golden-file regression fixtures for the CLI `--json` output contracts (issue #1548). Each test runs one command (`status`, `search`, `references`, `impact`, `excerpt`) against a deterministic in-memory fixture, normalizes volatile fields (timestamps, absolute paths, commit SHAs, FTS5 scores, SQLite page counts), and diffs against the matching file under `tests/CodeIndex.Tests/golden/`. Renames, removals, reordered arrays, or new keys fail the snapshot so the contract change is forced to land alongside an intentional golden update. See "JSON `--json` output snapshots" below for the update procedure. - `QueryCommandRunnerBatchIssue4723Tests.cs` @@ -1539,6 +1541,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" `dotnet.yml` と `release.yml` の Windows lane は、temp 固定と Defender 除外 setup をこのスクリプトで共有します。通常の `TMP` / `TEMP` は runner の高速な `RUNNER_TEMP\cdidx-temp` を使います。実行可能な plugin / hook / Git fixture だけは `USERPROFILE\cdidx-trusted-test-temp` を使い、current-user 限定の protected ACL と trusted な祖先 chain で production の executable-boundary contract を満たします。この専用 root は `CDIDX_TEST_TRUSTED_TEMP_ROOT` として helper へ渡します。Windows の実行時間を大きく増やすため、通常の SQLite / filesystem fixture を protected root へ移してはいけません。スクリプトは両 root を含む候補 path を正規化・重複排除し、残した各 path と reason を console および利用可能な場合は job summary へ監査表示してから、生成した string array を1回の `Add-MpPreference` 呼び出しで登録し、最後に Defender preference を読み戻して欠けた path があれば失敗します。この split、batching、audit、verification、または workflow 呼び出し contract を変更するときは `CiWorkflowTests` も更新してください。 - `DbRecoveryTests.cs` DB破損からの復旧とグレースフル劣化のテスト。`cdidx index` の filesystem setup failure(read-only DB file や書き込み不可の DB 親ディレクトリ)は、ユーザーが見る CLI JSON/stderr 境界を通すため `IndexCommandRunnerTests.cs` で扱います。 +- `Issue4857ManagedRestoreBackupTests.cs` + import の置換前に作成する自動検証済み backup、managed backup の list / ID 指定 restore、変更を伴わない dry-run、post-move failure 注入後の rollback、破損 payload と容量不足の拒否、対応する旧 schema stamp、破損 import archive、Windows の exclusive lock を end-to-end で検証します。process-wide の free-space hook と replacement hook を `finally` で戻すため、SQLite-pool-sensitive collection を使用します。 - `JsonOutputSnapshotTests.cs`、`JsonOutputSnapshotHelper.cs` CLI の `--json` 出力契約に対するゴールデンファイル回帰フィクスチャ (issue #1548)。各テストは `status` / `search` / `references` / `impact` / `excerpt` を決定的なインメモリ fixture に対して実行し、揺らぐフィールド(timestamp、絶対パス、commit SHA、FTS5 score、SQLite page count など)を正規化したうえで `tests/CodeIndex.Tests/golden/` 配下のファイルと差分比較します。フィールドの rename / 削除 / 並び替え / 新規追加が起きると snapshot が失敗するため、契約変更は意図的な golden 更新と同じ PR で揃えざるを得ません。更新手順は下記「JSON `--json` 出力 snapshot」を参照してください。 - `QueryCommandRunnerBatchIssue4723Tests.cs` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1e1e3651b..a570c4e93 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1196,13 +1196,15 @@ cdidx db schema --summary-only --json # counts without full SQ cdidx db schema --type table --name files --json # exact schema object projection cdidx db schema --limit 20 --max-sql-chars 4000 --exclude-internal --json cdidx db checkpoint before-prune --dry-run --json # preview snapshot files and bytes +cdidx db restore-backups --list --json # list managed backup IDs and provenance +cdidx db restore-backups --restore --dry-run --json # validate a selected backup without mutation ``` This opens the database read-only, runs SQLite's `PRAGMA integrity_check`, and prints whether the file is `ok` or lists the failures. Exit codes are stable for scripting: `0` clean, `2` (NotFound) when the file does not exist, `3` (DatabaseError) when corruption is detected. SQLite does not offer a general-purpose repair primitive — if the check fails, recover by rebuilding with `cdidx index --rebuild`. `db schema` keeps the current full schema dump by default for support bundles. Add `--summary-only` to return only object counts, combine `--type ` and `--name ` for an exact projection, and use `--limit`, `--max-sql-chars`, and `--exclude-internal` to keep schema diagnostics bounded. -`db checkpoint --dry-run` reports the DB/WAL/SHM files and total bytes that would be copied without creating the checkpoint directory. Running `db checkpoint` without `--dry-run` creates the snapshot next to the DB; `db restore ` replaces the DB and keeps a pre-restore backup directory. A checkpoint name must be a non-blank single file name of at most 128 characters: it cannot be `.` or `..`, contain a directory separator, or contain characters that the operating system rejects in file names. Invalid names are input errors (`E010_USAGE_ERROR`), not database or storage failures. +`db checkpoint --dry-run` reports the DB/WAL/SHM files and total bytes that would be copied without creating the checkpoint directory. Import, checkpoint restore, and managed-backup restore create a verified standalone rollback snapshot before replacing an existing DB. `db restore-backups --list` returns each managed ID and provenance; `--restore ` validates its bounded manifest, SHA-256, supported schema stamp, and free space before an atomic replacement that rolls back on failure. Add `--dry-run` to perform the same validations and preview the pre-restore backup without mutation. `--no-backup` is an explicit opt-out from creating rollback material and should be reserved for cases where losing the current DB is acceptable. A checkpoint name must be a non-blank single file name of at most 128 characters: it cannot be `.` or `..`, contain a directory separator, or contain characters that the operating system rejects in file names. Invalid names are input errors (`E010_USAGE_ERROR`), not database or storage failures. `cdidx optimize --dry-run --json` previews FTS5 maintenance without acquiring the index lock or changing the source DB/WAL/SHM files. The result includes DB/core-table/FTS sizes, page and freelist indicators, the incremental-write recommendation, current lock and readiness state, a previous-duration estimate when available, and the operations a real optimize would perform, including its repair-mode schema initialization or migration check. `object_sizes_measurement` distinguishes exact `dbstat` page bytes from the logical-payload fallback used when SQLite does not provide `dbstat`. @@ -4396,13 +4398,15 @@ cdidx db schema --summary-only --json # SQL 本文なしで件 cdidx db schema --type table --name files --json # schema object を exact に絞り込み cdidx db schema --limit 20 --max-sql-chars 4000 --exclude-internal --json cdidx db checkpoint before-prune --dry-run --json # snapshot 対象 file と byte 数を preview +cdidx db restore-backups --list --json # managed backup の ID と provenance を一覧表示 +cdidx db restore-backups --restore --dry-run --json # 選択した backup を変更せず検証 ``` DB を read-only で開いて SQLite の `PRAGMA integrity_check` を実行し、`ok` か、検出された破損行の一覧を出力します。終了コードは安定しており、`0` = 健全、`2` (NotFound) = ファイル無し、`3` (DatabaseError) = 破損検出です。SQLite には汎用的な修復プリミティブが無いため、チェックが失敗した場合は `cdidx index --rebuild` で再構築するのが推奨復旧手段です。 `db schema` は support bundle 向けに、既定では従来どおり full schema dump を維持します。`--summary-only` を付けると object 件数だけを返し、`--type ` と `--name ` を組み合わせると exact projection を適用できます。schema diagnostics を小さく保つには `--limit`、`--max-sql-chars`、`--exclude-internal` を使います。 -`db checkpoint --dry-run` は checkpoint directory を作らずに、コピー対象になる DB/WAL/SHM file と合計 byte 数を報告します。`--dry-run` なしの `db checkpoint` は DB の隣に snapshot を作り、`db restore ` は DB を置き換えて pre-restore backup directory を保持します。checkpoint 名は空白だけではない 128 文字以下の単一 file 名でなければならず、`.`、`..`、directory separator、または OS が file 名で拒否する文字は使用できません。不正な名前は database / storage 障害ではなく入力エラー (`E010_USAGE_ERROR`) として扱われます。 +`db checkpoint --dry-run` は checkpoint directory を作らずに、コピー対象になる DB/WAL/SHM file と合計 byte 数を報告します。import、checkpoint restore、managed-backup restore は既存 DB の置換前に検証済み standalone rollback snapshot を作成します。`db restore-backups --list` は managed ID と provenance を返し、`--restore ` は bounded manifest、SHA-256、対応する schema stamp、free space を検証してから、失敗時に rollback する atomic replacement を実行します。`--dry-run` を付けると、同じ検証と pre-restore backup の予定を DB 無変更で確認できます。`--no-backup` は rollback material 作成の明示的な opt-out であり、現在の DB を失ってもよい場合にだけ使用してください。checkpoint 名は空白だけではない 128 文字以下の単一 file 名でなければならず、`.`、`..`、directory separator、または OS が file 名で拒否する文字は使用できません。不正な名前は database / storage 障害ではなく入力エラー (`E010_USAGE_ERROR`) として扱われます。 `cdidx optimize --dry-run --json` は index lock を取得せず、source DB/WAL/SHM file も変更せずに FTS5 maintenance を preview します。結果には DB/core table/FTS の size、page と freelist の指標、incremental write に基づく推奨、現在の lock/readiness 状態、利用可能な場合は前回所要時間に基づく見積もり、repair mode での schema 初期化または migration の確認を含む、実際の optimize が行う操作が含まれます。`object_sizes_measurement` は、正確な `dbstat` page byte と、SQLite が `dbstat` を提供しない場合の logical-payload fallback を区別します。 diff --git a/changelog.d/unreleased/4857.fixed.md b/changelog.d/unreleased/4857.fixed.md new file mode 100644 index 000000000..f46475f07 --- /dev/null +++ b/changelog.d/unreleased/4857.fixed.md @@ -0,0 +1,26 @@ +--- +category: fixed +issues: + - 4857 +affected: + - src/CodeIndex/Cli/ManagedRestoreBackupStore.cs + - src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs + - src/CodeIndex/Cli/DbCommandRunner.Restore.cs + - src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs + - src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs + - src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs + - src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Database replacement now leaves verified rollback material that can be restored by ID (#4857)** — as a follow-up to #3410/#3818 and #4337/#4717, import and checkpoint restore create a managed standalone SQLite backup by default before replacing an existing database, while `--no-backup` remains an explicit opt-out. `db restore-backups --list` exposes bounded provenance metadata, and `--restore ` validates the manifest, SHA-256, supported schema, and free space before an atomic replacement with rollback-on-failure. Dry runs perform the same validations and preview every planned backup without mutation. + +## 日本語 + +- **database の置換前に検証済み rollback material を残し、ID 指定で復元できるようになりました (#4857)** — #3410/#3818 および #4337/#4717 の follow-up として、import と checkpoint restore は既存 database の置換前に managed standalone SQLite backup を既定で作成し、`--no-backup` は明示的な opt-out として利用できます。`db restore-backups --list` は上限付き provenance metadata を表示し、`--restore ` は manifest、SHA-256、対応 schema、free space を検証してから、失敗時 rollback を伴う atomic replacement を実行します。dry-run も同じ検証を行い、変更せずに作成予定 backup をすべて表示します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 881c5d1d0..0e398d81a 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -339,6 +339,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--title", ValuePlaceholder = "", Description = "Suggestions add: optional issue-draft title source", PrimaryCommands = Set("suggestions") }, new() { Name = "--evidence-path", ValuePlaceholder = "<path>", Description = "Suggestions add: repository-relative evidence path; repeat for multiple paths", PrimaryCommands = Set("suggestions") }, new() { Name = "--overwrite", Description = "Portable archive, report bundle, or suggestions export: atomically replace an existing output file", PrimaryCommands = Set("export", "report", "suggestions") }, + new() { Name = "--restore", ValuePlaceholder = "<id>", Description = "DB restore-backups: select a managed backup ID to validate and restore atomically", PrimaryCommands = Set("db") }, + new() { Name = "--no-backup", Description = "Import/DB restore: explicitly skip creating managed rollback material before replacement", PrimaryCommands = Set("import", "db") }, new() { Name = "--body", Description = "Include definition body snippets in JSON-capable result rows", PrimaryCommands = Set(BodyCommands) }, new() { Name = "--body-start", ValuePlaceholder = "<line>", Description = "Inspect: start definition body slice at this 1-based source line", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--body-lines", ValuePlaceholder = "<n>", Description = "Inspect: return at most this many definition body lines", PrimaryCommands = Set(InspectFieldCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 3555ddc6d..4aa389f85 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -117,15 +117,15 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("db", "cdidx db prune --dry-run|--apply [--db <path>] [--json]"), ("db", "cdidx db checkpoint [name<=128] [--dry-run] [--db <path>] [--json]"), ("db", "cdidx db checkpoints --list|--delete <name<=128>|--prune [--keep <n>] [--dry-run] [--db <path>] [--json]"), - ("db", "cdidx db restore <name<=128> [--dry-run] [--db <path>] [--json]"), - ("db", "cdidx db restore-backups --list|--prune [--keep <n>] [--dry-run] [--db <path>] [--json]"), + ("db", "cdidx db restore <name<=128> [--dry-run] [--no-backup] [--db <path>] [--json]"), + ("db", "cdidx db restore-backups --list|--prune [--keep <n>]|--restore <id> [--dry-run] [--no-backup] [--db <path>] [--json]"), ("db-integrity", "cdidx db integrity|--integrity-check [--db <path>] [--json]"), ("db-schema", $"cdidx db schema [--type <table|index|trigger|view>] [--name <object>] [--limit <n<={DbCommandRunner.SchemaEntryLimit}>] [--max-sql-chars <n<={DbCommandRunner.SchemaSqlTextLimit}>] [--summary-only] [--include-internal|--exclude-internal] [--db <path>] [--json]"), ("db-prune", "cdidx db prune --dry-run|--apply [--db <path>] [--json]"), ("db-checkpoint", "cdidx db checkpoint [name<=128] [--dry-run] [--db <path>] [--json]"), ("db-checkpoints", "cdidx db checkpoints --list|--delete <name<=128>|--prune [--keep <n>] [--dry-run] [--db <path>] [--json]"), - ("db-restore", "cdidx db restore <name<=128> [--dry-run] [--db <path>] [--json]"), - ("db-restore-backups", "cdidx db restore-backups --list|--prune [--keep <n>] [--dry-run] [--db <path>] [--json]"), + ("db-restore", "cdidx db restore <name<=128> [--dry-run] [--no-backup] [--db <path>] [--json]"), + ("db-restore-backups", "cdidx db restore-backups --list|--prune [--keep <n>]|--restore <id> [--dry-run] [--no-backup] [--db <path>] [--json]"), ("diff", "cdidx diff <db1> <db2> [--json] [--summary-only] [--detailed] [--limit <n<=10000>] [--offset <n>]"), ("report", "cdidx report --output <bundle.tgz> [--overwrite] [--db <path>] [--json] [--redact-paths] [--log-lines <n<=2000>] [--no-log] [--include-args]"), ("validate", "cdidx validate [--db <path>] [--json[=array]] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--severity <info|warning|error>] [--path <glob>]"), @@ -144,7 +144,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("export", "cdidx export <archive> [--db <path>] [--json] [--overwrite] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--project <name|path>] [--solution <path>] [--exclude-tests]"), ("export", "cdidx export ctags [--output <path>] [--db <path>] [--json] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--include-generated]"), ("export-ctags", "cdidx export ctags [--output <path>] [--db <path>] [--json] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--include-generated]"), - ("import", "cdidx import <archive> [--db <path>] [--prune-paths] [--dry-run|--check] [--limit <n<=10000>] [--offset <n>] [--json]"), + ("import", "cdidx import <archive> [--db <path>] [--prune-paths] [--no-backup] [--dry-run|--check] [--limit <n<=10000>] [--offset <n>] [--json]"), ("languages", "cdidx languages [--db <path>] [--json] [--format <text|json|count>] [--summary-only] [--limit <n>|--top <n>] [--cursor <next_cursor>] [--max-json-bytes <n>] [--indexed-only] [--language <lang>|--extension <ext>|--alias <alias>] [--capability <all|none|graph|references|symbols|missing-any|missing-graph|missing-references|missing-symbols|search-only>]"), ("batch", "cdidx batch [--db <path>] [--json-summary] [--max-input-lines <n>] [--max-output-chars <n>] [--parallel <n>] # stdin is JSON Lines; --json-summary embeds typed child JSON plus a final summary"), ("hooks-install", "cdidx hooks install [--project <path>] [--force] [--dry-run] [--json]"), @@ -218,9 +218,10 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = ("recipes-list", "Example: `cdidx recipes list --names --json`."), ("db", "schema defaults to the full sqlite_master dump for support bundles; use --summary-only, --limit, --max-sql-chars, and --exclude-internal for bounded diagnostics."), ("db", "checkpoint --dry-run reports the DB/WAL/SHM files and byte count without creating the checkpoint directory."), - ("db", "checkpoint creates a filesystem snapshot next to the DB; restore replaces the DB and keeps a pre-restore backup directory."), - ("db", "restore --dry-run validates the checkpoint manifest, regular-file paths, and destination free space without replacing the DB."), + ("db", "checkpoint creates a filesystem snapshot next to the DB; restore creates a verified managed rollback backup before replacing an existing DB unless --no-backup is explicit."), + ("db", "restore --dry-run validates the checkpoint manifest, regular-file paths, rollback-backup policy, and destination free space without replacing the DB."), ("db", "checkpoints --delete and --prune remove snapshots; add --dry-run to report exact deleted/retained paths without mutation."), + ("db", "restore-backups --list exposes managed IDs and provenance; --restore <id> validates the manifest, hash, schema, and free space before an atomic rollback-capable replacement."), ("db", "restore-backups --prune --dry-run reports exact deleted/retained paths; omit --dry-run to apply retention."), ("db", "prune --dry-run only counts orphan rows; prune --apply deletes them and may run WAL checkpoint maintenance."), ("db-integrity", "Runs SQLite `PRAGMA integrity_check`; no database content is changed."), @@ -233,10 +234,11 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = ("db-checkpoint", "Example: `cdidx db checkpoint before-upgrade --dry-run --json`."), ("db-checkpoints", "--list reports checkpoints; --delete and --prune remove snapshots, while --dry-run reports exact deleted/retained paths without mutation."), ("db-checkpoints", "Example: `cdidx db checkpoints --prune --keep 3 --dry-run --json`."), - ("db-restore", "--dry-run validates the checkpoint manifest, regular-file paths, and destination free space; without it restore replaces the DB and keeps a backup."), + ("db-restore", "--dry-run validates the checkpoint manifest, regular-file paths, rollback-backup policy, and destination free space; without it restore creates a verified managed backup before replacing the DB."), ("db-restore", "Example: `cdidx db restore before-upgrade --dry-run --json`."), - ("db-restore-backups", "--list reports pre-restore backups; --prune --dry-run reports exact deleted/retained paths, and --prune without it applies retention."), - ("db-restore-backups", "Example: `cdidx db restore-backups --prune --keep 2 --dry-run --json`."), + ("db-restore-backups", "--list reports managed IDs and provenance; --restore <id> validates manifest/hash/schema/space and atomically replaces the DB with rollback-on-failure. --dry-run performs every validation without mutation."), + ("db-restore-backups", "--prune --dry-run reports exact deleted/retained paths, and --prune without it applies retention."), + ("db-restore-backups", "Example: `cdidx db restore-backups --restore <id> --dry-run --json`."), ("suggestions-list", "Reads local suggestion records, applies filters first, then applies the non-negative --offset/--limit page without changing the store."), ("suggestions-list", "Example: `cdidx suggestions list --status draft --limit 20 --json`."), ("suggestions-show", "Requires a full suggestion id or an unambiguous id prefix; filters are applied before id resolution and the store is not changed."), diff --git a/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs index b8a1e4d67..c5b3c0493 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs @@ -29,6 +29,8 @@ private sealed class DbArgumentParser private int checkpointsKeep = DefaultRestoreBackupKeepCount; private bool restoreBackupsList; private bool restoreBackupsPrune; + private bool restoreBackupsRestore; + private bool noBackup; private int restoreBackupsKeep = DefaultRestoreBackupKeepCount; private bool schemaSummaryOnly; private int schemaEntryLimit = SchemaEntryLimit; @@ -161,6 +163,23 @@ internal DbCommandOptions Parse(string[] args) case "--dry-run": pruneDryRun = true; break; + case "--no-backup": + noBackup = true; + break; + case "--restore" when i + 1 < args.Length + && !args[i + 1].StartsWith("-", StringComparison.Ordinal): + if (!restoreBackups) + { + parseError = "--restore is only valid with `cdidx db restore-backups --restore <id>`"; + break; + } + + restoreBackupsRestore = true; + name = args[++i]; + break; + case "--restore": + parseError = "--restore requires a managed restore backup ID"; + break; case "--apply": pruneApply = true; break; @@ -242,8 +261,8 @@ private void ValidateOptionCombinations() { if (parseError is null && restoreBackups && pruneApply) parseError = "--apply is not supported with `cdidx db restore-backups`; `--prune` is the explicit mutation opt-in."; - if (parseError is null && pruneDryRun && restoreBackups && !restoreBackupsPrune) - parseError = "--dry-run is only valid with `cdidx db restore-backups --prune`."; + if (parseError is null && pruneDryRun && restoreBackups && !restoreBackupsPrune && !restoreBackupsRestore) + parseError = "--dry-run is only valid with `cdidx db restore-backups --prune` or `--restore <id>`."; if (parseError is null && pruneDryRun && listCheckpoints && !checkpointsDelete && !checkpointsPrune) parseError = "--dry-run is only valid with checkpoint deletion or pruning."; if (parseError is null && !schema && schemaSpecificOptionSeen) @@ -252,6 +271,8 @@ private void ValidateOptionCombinations() parseError = "--dry-run is only valid with a supported preview operation."; if (parseError is null && pruneApply && !prune) parseError = "--apply is only valid with `cdidx db prune --apply`."; + if (parseError is null && noBackup && !restore && !(restoreBackups && restoreBackupsRestore)) + parseError = "--no-backup is only valid with `cdidx db restore <name>` or `cdidx db restore-backups --restore <id>`."; } private DbCommandOptions BuildOptions() @@ -277,8 +298,10 @@ private DbCommandOptions BuildOptions() RestoreBackups = restoreBackups, RestoreBackupsList = restoreBackupsList, RestoreBackupsPrune = restoreBackupsPrune, + RestoreBackupsRestore = restoreBackupsRestore, RestoreBackupsKeep = restoreBackupsKeep, RestoreBackupsDryRun = restoreBackups && pruneDryRun, + NoBackup = noBackup, SchemaSummaryOnly = schemaSummaryOnly, SchemaEntryLimit = schemaEntryLimit, SchemaSqlTextLimit = schemaSqlTextLimit, diff --git a/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs b/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs index 1bce8195d..b6d0b586e 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs @@ -199,12 +199,23 @@ private static DbRestoreBackupReadResult ListRestoreBackups(string fullDbPath, i } var bytes = SumCheckpointBytes(path, diagnostics); + var managed = ManagedRestoreBackupStore.TryReadSummary( + fullDbPath, + path, + out var summary); entries.Add(new DbRestoreBackupEntryJsonResult( info.Name, path, - createdAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + managed + ? summary.CreatedAtUtc + : createdAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), bytes.Bytes, - bytes.Truncated)); + bytes.Truncated, + managed ? summary.Id : null, + managed, + managed ? summary.Provenance : null, + managed ? summary.SourceId : null, + managed ? summary.UserVersion : null)); } entries.Sort((left, right) => diff --git a/src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs b/src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs new file mode 100644 index 000000000..bb9bbf5c3 --- /dev/null +++ b/src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs @@ -0,0 +1,281 @@ +using System.Text.Json; +using CodeIndex.Diagnostics; + +namespace CodeIndex.Cli; + +public static partial class DbCommandRunner +{ + private static int RunRestoreBackupById( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + string fullDbPath, + CancellationToken cancellationToken) + { + var id = options.Name ?? string.Empty; + try + { + ManagedRestoreBackupStore.ValidateId(id); + } + catch (ArgumentException ex) + { + return WriteCommandError( + options.Json, + jsonOptions, + CommandErrorWriter.FormatSanitizedExceptionMessage(ex), + CommandExitCodes.UsageError, + "Copy an ID from `cdidx db restore-backups --list`.", + CommandErrorCodes.UsageError); + } + + var preview = PreviewRestoreBackupById( + fullDbPath, + id, + backupEnabled: !options.NoBackup, + cancellationToken); + if (options.RestoreBackupsDryRun) + return WriteRestoreBackupByIdResult(options, jsonOptions, fullDbPath, preview, restored: false, preRestoreBackup: null); + if (!preview.Ready) + return WriteRestoreBackupByIdResult(options, jsonOptions, fullDbPath, preview, restored: false, preRestoreBackup: null); + + var parent = Path.GetDirectoryName(fullDbPath) + ?? Path.GetPathRoot(fullDbPath) + ?? Path.GetFullPath("."); + var restoreSuffix = MakeRestorePathSuffix(); + var restoreTempPath = fullDbPath + ".restore-tmp-" + restoreSuffix; + ManagedRestoreBackupInfo? preRestoreBackup = null; + try + { + DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath); + cancellationToken.ThrowIfCancellationRequested(); + var manifest = preview.Source.Manifest + ?? throw new InvalidDataException("managed restore backup manifest is unavailable"); + CopyIfExists( + Path.Combine(preview.Source.BackupPath, manifest.DatabaseFile), + Path.Combine(restoreTempPath, manifest.DatabaseFile), + privateDestination: true); + CopyIfExists( + Path.Combine(preview.Source.BackupPath, ManagedRestoreBackupStore.ManifestFileName), + Path.Combine(restoreTempPath, ManagedRestoreBackupStore.ManifestFileName), + privateDestination: true); + + var stagedValidation = ManagedRestoreBackupStore.ValidateStagedDirectory( + fullDbPath, + restoreTempPath, + id, + cancellationToken); + if (!stagedValidation.Ready) + throw new ManagedRestoreBackupException("staged restore backup validation failed", stagedValidation.Diagnostics); + + if (!options.NoBackup) + { + preRestoreBackup = ManagedRestoreBackupStore.Create( + fullDbPath, + ManagedRestoreBackupStore.PreRestoreBackupProvenance, + id, + cancellationToken); + } + + ExportImportCommandRunner.ReplaceImportedDatabase( + Path.Combine(restoreTempPath, manifest.DatabaseFile), + fullDbPath, + cancellationToken); + + return WriteRestoreBackupByIdResult( + options, + jsonOptions, + fullDbPath, + preview, + restored: true, + preRestoreBackup); + } + catch (OperationCanceledException) + { + throw; + } + catch (ManagedRestoreBackupException ex) + { + var diagnostics = preview.Diagnostics.ToList(); + diagnostics.AddRange(ex.Diagnostics); + var failed = preview with + { + Ready = false, + Diagnostics = diagnostics, + FailureMessage = "managed restore backup validation failed before replacement", + }; + return WriteRestoreBackupByIdResult( + options, + jsonOptions, + fullDbPath, + failed, + restored: false, + preRestoreBackup); + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or InvalidOperationException + or NotSupportedException + or PathTooLongException + or Microsoft.Data.Sqlite.SqliteException) + { + var diagnostics = preview.Diagnostics.ToList(); + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_replacement_failed", + $"Managed restore backup replacement failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(fullDbPath))); + var failed = preview with + { + Ready = false, + Diagnostics = diagnostics, + FailureMessage = "managed restore backup replacement failed; the previous destination was rolled back when possible", + }; + return WriteRestoreBackupByIdResult( + options, + jsonOptions, + fullDbPath, + failed, + restored: false, + preRestoreBackup); + } + finally + { + TryDeleteTemporaryDirectory( + restoreTempPath, + "managed restore staging directory", + parent, + Path.GetFileName(fullDbPath) + ".restore-tmp-"); + } + } + + private static DbManagedRestorePreview PreviewRestoreBackupById( + string fullDbPath, + string id, + bool backupEnabled, + CancellationToken cancellationToken) + { + var source = ManagedRestoreBackupStore.Validate( + fullDbPath, + id, + checkFreeSpace: false, + cancellationToken); + var diagnostics = source.Diagnostics.ToList(); + var currentBackup = ManagedRestoreBackupStore.PreviewCreation( + fullDbPath, + backupEnabled, + diagnostics, + cancellationToken); + + long requiredSpace; + try + { + requiredSpace = checked(source.RequiredSpaceBytes + currentBackup.RequiredSpaceBytes); + } + catch (OverflowException) + { + requiredSpace = long.MaxValue; + } + + var availableSpace = TryGetAvailableFreeSpace(fullDbPath, diagnostics); + var spaceSufficient = availableSpace is long available && available >= requiredSpace; + if (!spaceSufficient) + { + diagnostics.Add(new DbDiagnosticJsonResult( + availableSpace.HasValue ? "restore_backup_space_insufficient" : "restore_backup_space_unavailable", + availableSpace.HasValue + ? "The destination filesystem does not have enough free space for restore staging and rollback material." + : "Available destination space could not be confirmed for restore staging and rollback material.", + ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); + } + + return new DbManagedRestorePreview( + Ready: source.Ready && currentBackup.Ready && spaceSufficient, + source, + currentBackup, + requiredSpace, + availableSpace, + spaceSufficient, + diagnostics, + FailureMessage: null); + } + + private static int WriteRestoreBackupByIdResult( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + string fullDbPath, + DbManagedRestorePreview preview, + bool restored, + ManagedRestoreBackupInfo? preRestoreBackup) + { + var dryRun = options.RestoreBackupsDryRun; + var status = restored ? "success" : dryRun && preview.Ready ? "dry_run" : "invalid"; + var message = preview.FailureMessage + ?? (!preview.Ready ? "managed restore backup validation found blocking failures" : null); + const string hint = "Fix the reported manifest, hash, schema, or free-space diagnostics and retry; use --no-backup only when discarding rollback material is intentional."; + + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new DbRestoreBackupRestoreJsonResult( + status, + fullDbPath, + preview.Source.Id, + preview.Source.BackupPath, + dryRun, + restored, + preview.Ready, + preview.Source.ManifestValid, + preview.Source.HashValid, + preview.Source.SchemaValid, + options.NoBackup ? "disabled" : "automatic", + preRestoreBackup?.Id, + preview.CurrentBackup.WouldCreate, + preview.RequiredSpaceBytes, + preview.AvailableSpaceBytes, + preview.SpaceSufficient, + preview.Source.Manifest?.Provenance, + preview.Source.Manifest?.SourceId, + preview.Diagnostics, + message, + preview.Ready || restored ? null : CommandErrorCodes.DbError, + preview.Ready || restored ? null : hint), + CliJsonSerializerContextFactory.Create(jsonOptions).DbRestoreBackupRestoreJsonResult)); + } + else + { + Console.WriteLine(dryRun + ? "Managed restore backup dry run." + : restored + ? "Restored managed database backup." + : "Managed restore backup validation failed."); + Console.WriteLine($" database : {fullDbPath}"); + Console.WriteLine($" backup ID : {preview.Source.Id}"); + Console.WriteLine($" backup policy : {(options.NoBackup ? "disabled" : "automatic")}"); + Console.WriteLine($" manifest : {(preview.Source.ManifestValid ? "valid" : "invalid")}"); + Console.WriteLine($" SHA-256 : {(preview.Source.HashValid ? "valid" : "invalid")}"); + Console.WriteLine($" schema : {(preview.Source.SchemaValid ? "valid" : "invalid")}"); + Console.WriteLine($" required bytes : {preview.RequiredSpaceBytes:N0}"); + Console.WriteLine($" available bytes: {(preview.AvailableSpaceBytes is long available ? available.ToString("N0", System.Globalization.CultureInfo.CurrentCulture) : "unknown")}"); + Console.WriteLine($" side effect : {(dryRun ? "none" : restored ? "database atomically replaced" : "none")}"); + if (preRestoreBackup is not null) + Console.WriteLine($" rollback ID : {preRestoreBackup.Id}"); + foreach (var diagnostic in preview.Diagnostics) + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + if (!preview.Ready && !restored) + CommandErrorWriter.WriteStderr($"Error: {message}. Hint: {hint}"); + } + + return restored || (dryRun && preview.Ready) + ? CommandExitCodes.Success + : CommandExitCodes.DatabaseError; + } +} + +internal sealed record DbManagedRestorePreview( + bool Ready, + ManagedRestoreBackupValidation Source, + ManagedRestoreBackupCreationPreview CurrentBackup, + long RequiredSpaceBytes, + long? AvailableSpaceBytes, + bool SpaceSufficient, + List<DbDiagnosticJsonResult> Diagnostics, + string? FailureMessage); diff --git a/src/CodeIndex/Cli/DbCommandRunner.Restore.cs b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs index 8c486821e..57a0f3db8 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.Restore.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs @@ -10,7 +10,10 @@ namespace CodeIndex.Cli; public static partial class DbCommandRunner { - private static int RunRestore(DbCommandOptions options, JsonSerializerOptions jsonOptions) + private static int RunRestore( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(options.Name)) return WriteCommandError(options.Json, jsonOptions, "restore requires a checkpoint name", CommandExitCodes.UsageError, "Use `cdidx db restore <name> --db <path>`.", CommandErrorCodes.UsageError); @@ -24,13 +27,18 @@ private static int RunRestore(DbCommandOptions options, JsonSerializerOptions js if (!Directory.Exists(checkpointPath)) return WriteCommandError(options.Json, jsonOptions, $"checkpoint not found: {FormatCheckpointNameForDiagnostic(options.Name)}", CommandExitCodes.NotFound, "Run `cdidx db checkpoints --list` to see available checkpoints.", CommandErrorCodes.CheckpointNotFound); - var preview = PreviewRestoreCheckpoint(fullDbPath, options.Name, checkpointPath); + var preview = PreviewRestoreCheckpoint( + fullDbPath, + options.Name, + checkpointPath, + backupEnabled: !options.NoBackup, + cancellationToken); if (options.RestoreDryRun) return WriteRestoreDryRunResult(options, jsonOptions, fullDbPath, options.Name, checkpointPath, preview); if (!preview.Ready) throw new InvalidOperationException("checkpoint validation failed"); - var backupPath = RestoreCheckpoint(fullDbPath, options.Name, checkpointPath); + var backupPath = RestoreCheckpoint(fullDbPath, options.Name, checkpointPath, options.NoBackup, cancellationToken); if (options.Json) { Console.WriteLine(JsonSerializer.Serialize( @@ -42,7 +50,7 @@ private static int RunRestore(DbCommandOptions options, JsonSerializerOptions js Console.WriteLine("Restored database checkpoint."); Console.WriteLine($" database : {fullDbPath}"); Console.WriteLine($" checkpoint: {options.Name}"); - Console.WriteLine($" backup : {backupPath}"); + Console.WriteLine($" backup : {(options.NoBackup ? "disabled" : backupPath)}"); } return CommandExitCodes.Success; @@ -94,6 +102,9 @@ private static int WriteRestoreDryRunResult( preview.AvailableSpaceBytes, preview.Files, preview.Bytes, + options.NoBackup ? "disabled" : "automatic", + preview.BackupPreview.WouldCreate, + preview.BackupPreview.RequiredSpaceBytes, preview.Diagnostics, message, preview.Ready ? null : CommandErrorCodes.DbError, @@ -109,6 +120,8 @@ private static int WriteRestoreDryRunResult( Console.WriteLine($" manifest : {(preview.ManifestValid ? "valid" : "invalid")}"); Console.WriteLine($" paths : {(preview.PathsValid ? "valid" : "invalid")}"); Console.WriteLine($" bytes : {preview.Bytes:N0}"); + Console.WriteLine($" backup : {(options.NoBackup ? "disabled" : preview.BackupPreview.WouldCreate ? "would create" : "not required")}"); + Console.WriteLine($" backup bytes: {preview.BackupPreview.RequiredSpaceBytes:N0}"); Console.WriteLine($" available : {(preview.AvailableSpaceBytes is long available ? available.ToString("N0", System.Globalization.CultureInfo.CurrentCulture) : "unknown")}"); Console.WriteLine($" space : {(preview.SpaceSufficient is true ? "sufficient" : preview.SpaceSufficient is false ? "insufficient" : "unknown")}"); Console.WriteLine($" ready : {(preview.Ready ? "yes" : "no")}"); @@ -160,29 +173,37 @@ private static int WriteRestoreError( category: DiagnosticRedactor.ClassifyException(primary)); } - private static int RunRestoreBackups(DbCommandOptions options, JsonSerializerOptions jsonOptions) + private static int RunRestoreBackups( + DbCommandOptions options, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken) { - if (!options.RestoreBackupsList && !options.RestoreBackupsPrune) + if (!options.RestoreBackupsList && !options.RestoreBackupsPrune && !options.RestoreBackupsRestore) return WriteCommandError( options.Json, jsonOptions, - "restore-backups requires --list or --prune", + "restore-backups requires --list, --prune, or --restore <id>", CommandExitCodes.UsageError, - "Use `cdidx db restore-backups --list` or `cdidx db restore-backups --prune --keep <n>`.", + "Use `cdidx db restore-backups --list`, `--prune --keep <n>`, or `--restore <id>`.", CommandErrorCodes.UsageError); - if (options.RestoreBackupsList && options.RestoreBackupsPrune) + if ((options.RestoreBackupsList ? 1 : 0) + + (options.RestoreBackupsPrune ? 1 : 0) + + (options.RestoreBackupsRestore ? 1 : 0) > 1) return WriteCommandError( options.Json, jsonOptions, - "restore-backups accepts only one of --list or --prune", + "restore-backups accepts only one of --list, --prune, or --restore <id>", CommandExitCodes.UsageError, - "Choose `--list` or `--prune`.", + "Choose one restore-backup action.", CommandErrorCodes.UsageError); if (!TryResolveFileDb(options.DbPath, out var fullDbPath, out var error)) return WriteCommandError(options.Json, jsonOptions, error, CommandExitCodes.DatabaseError, "Use a filesystem database path, not a SQLite URI.", CommandErrorCodes.DbError); + if (options.RestoreBackupsRestore) + return RunRestoreBackupById(options, jsonOptions, fullDbPath, cancellationToken); + if (options.RestoreBackupsList) { var result = ListRestoreBackups(fullDbPath, RestoreBackupListEntryLimit); @@ -211,7 +232,12 @@ private static int RunRestoreBackups(DbCommandOptions options, JsonSerializerOpt else { foreach (var entry in result.Entries) - Console.WriteLine($" {entry.Name} {entry.CreatedAtUtc} {entry.Bytes:N0} bytes{(entry.FilesTruncated ? " (files truncated)" : string.Empty)}"); + { + var managedMetadata = entry.Managed + ? $" ID {entry.Id} {entry.Provenance}" + : " legacy (not restorable by ID)"; + Console.WriteLine($" {entry.Name} {entry.CreatedAtUtc} {entry.Bytes:N0} bytes{managedMetadata}{(entry.FilesTruncated ? " (files truncated)" : string.Empty)}"); + } } foreach (var diagnostic in result.Diagnostics) diff --git a/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs index 7ac2cad3b..3d0848c84 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs @@ -13,7 +13,9 @@ public static partial class DbCommandRunner private static DbRestorePreviewResult PreviewRestoreCheckpoint( string fullDbPath, string name, - string checkpointPath) + string checkpointPath, + bool backupEnabled, + CancellationToken cancellationToken) { ValidateCheckpointName(name); var diagnostics = new List<DbDiagnosticJsonResult>(); @@ -50,28 +52,47 @@ private static DbRestorePreviewResult PreviewRestoreCheckpoint( pathsValid = payload.PathsValid; } + var backupPreview = ManagedRestoreBackupStore.PreviewCreation( + fullDbPath, + backupEnabled, + diagnostics, + cancellationToken); + long requiredSpace; + try + { + requiredSpace = checked(payload.Bytes + backupPreview.RequiredSpaceBytes); + } + catch (OverflowException) + { + requiredSpace = long.MaxValue; + } + var availableSpace = TryGetAvailableFreeSpace(fullDbPath, diagnostics); - bool? spaceSufficient = availableSpace is long available ? available >= payload.Bytes : null; + bool? spaceSufficient = availableSpace is long available ? available >= requiredSpace : null; if (spaceSufficient == false) { diagnostics.Add(new DbDiagnosticJsonResult( "checkpoint_space_insufficient", - "The destination filesystem does not have enough free space to stage the checkpoint payload.", + "The destination filesystem does not have enough free space to stage the checkpoint payload and verified rollback backup.", ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); } - var ready = manifestValid && pathsValid && spaceSufficient == true; + var ready = manifestValid + && pathsValid + && backupPreview.Ready + && spaceSufficient == true; return new DbRestorePreviewResult( ready, manifestValid, pathsValid, availableSpace.HasValue, spaceSufficient, - payload.Bytes, + requiredSpace, availableSpace, payload.Files, payload.Bytes, - diagnostics); + diagnostics, + backupPreview); } private static bool TryGetCheckpointRetentionTimestamp( @@ -238,7 +259,7 @@ private static bool TryValidateCheckpointManifest( } } - private static long? TryGetAvailableFreeSpace( + internal static long? TryGetAvailableFreeSpace( string fullDbPath, List<DbDiagnosticJsonResult> diagnostics) { @@ -356,7 +377,12 @@ private static extern bool GetDiskFreeSpaceEx( out ulong totalNumberOfBytes, out ulong totalNumberOfFreeBytes); - private static string RestoreCheckpoint(string fullDbPath, string name, string checkpointPath) + private static string RestoreCheckpoint( + string fullDbPath, + string name, + string checkpointPath, + bool noBackup, + CancellationToken cancellationToken) { ValidateCheckpointName(name); if (!TryValidateCheckpointDirectoryTarget( @@ -369,27 +395,39 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c $"checkpoint path validation failed: {checkpointPathFailure}"); } - SqliteConnection.ClearAllPools(); var checkpointDbPath = Path.Combine(checkpointPath, Path.GetFileName(fullDbPath)); if (!File.Exists(LongPath.EnsureWindowsPrefix(checkpointDbPath))) throw new InvalidOperationException($"checkpoint is incomplete: {FormatCheckpointNameForDiagnostic(name)}"); var restorePathSuffix = MakeRestorePathSuffix(); var restoreTempPath = fullDbPath + ".restore-tmp-" + restorePathSuffix; - var backupPath = fullDbPath + ".restore-backup-" + restorePathSuffix; + var rollbackPath = Path.Combine(restoreTempPath, "rollback"); + ManagedRestoreBackupInfo? managedBackup = null; DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath); try { + cancellationToken.ThrowIfCancellationRequested(); CopyIfExists(checkpointDbPath, Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), privateDestination: true); CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-wal"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-shm"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath))))) throw new InvalidOperationException($"checkpoint staging failed: {FormatCheckpointNameForDiagnostic(name)}"); - DataDirectorySecurity.CreateSensitiveDirectory(backupPath); - MoveIfExists(fullDbPath, Path.Combine(backupPath, Path.GetFileName(fullDbPath)), privateDestination: true); - MoveIfExists(fullDbPath + "-wal", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); - MoveIfExists(fullDbPath + "-shm", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); + if (!noBackup) + { + managedBackup = ManagedRestoreBackupStore.Create( + fullDbPath, + ManagedRestoreBackupStore.PreCheckpointRestoreProvenance, + name, + cancellationToken); + } + + cancellationToken.ThrowIfCancellationRequested(); + SqliteConnection.ClearAllPools(); + DataDirectorySecurity.CreateSensitiveDirectory(rollbackPath); + MoveIfExists(fullDbPath, Path.Combine(rollbackPath, Path.GetFileName(fullDbPath)), privateDestination: true); + MoveIfExists(fullDbPath + "-wal", Path.Combine(rollbackPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); + MoveIfExists(fullDbPath + "-shm", Path.Combine(rollbackPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); RestoreFailureAfterBackupForTesting?.Invoke(); @@ -402,18 +440,22 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c DbDiagnosticJsonResult? rollbackFailure = null; try { - RestoreBackedUpFiles(fullDbPath, backupPath); + RestoreBackedUpFiles(fullDbPath, rollbackPath); } catch (Exception rollbackEx) when (IsRecoverableRestoreException(rollbackEx)) { rollbackFailure = new DbDiagnosticJsonResult( "restore_rollback_failed", $"Failed to roll back database restore from backup ({CommandErrorWriter.FormatSanitizedException(rollbackEx)}).", - ConsoleUi.FormatBoundedValue(backupPath)); + ConsoleUi.FormatBoundedValue(rollbackPath)); CommandErrorWriter.WriteStderr($"Warning [{rollbackFailure.Code}]: {rollbackFailure.Message} Backup: {rollbackFailure.Path}"); } - throw new DbRestoreOperationException(primaryEx, checkpointPath, backupPath, rollbackFailure); + throw new DbRestoreOperationException( + primaryEx, + checkpointPath, + managedBackup?.BackupPath ?? string.Empty, + rollbackFailure); } finally { @@ -424,6 +466,6 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c Path.GetFileName(fullDbPath) + ".restore-tmp-"); } - return backupPath; + return managedBackup?.BackupPath ?? string.Empty; } } diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 41d893865..73b90e94f 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -83,7 +83,7 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, Cance jsonOptions, "db requires a mode flag", CommandExitCodes.UsageError, - "Pass `integrity`, `--integrity-check`, `schema`, `prune --dry-run|--apply`, `checkpoint [name]`, `checkpoints --list|--delete|--prune`, `restore <name> [--dry-run]`, or `restore-backups --list|--prune --keep <n> [--dry-run]`.", + "Pass `integrity`, `--integrity-check`, `schema`, `prune --dry-run|--apply`, `checkpoint [name]`, `checkpoints --list|--delete|--prune`, `restore <name> [--dry-run] [--no-backup]`, or `restore-backups --list|--prune --keep <n>|--restore <id> [--dry-run] [--no-backup]`.", CommandErrorCodes.UsageError); if ((options.IntegrityCheck ? 1 : 0) @@ -98,7 +98,7 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, Cance jsonOptions, "db accepts exactly one mode", CommandExitCodes.UsageError, - "Run one of `cdidx db integrity`, `cdidx db --integrity-check`, `cdidx db schema`, `cdidx db prune --dry-run|--apply`, `cdidx db checkpoint [name]`, `cdidx db checkpoints --list|--delete|--prune`, `cdidx db restore <name> [--dry-run]`, or `cdidx db restore-backups --list|--prune --keep <n> [--dry-run]`.", + "Run one of `cdidx db integrity`, `cdidx db --integrity-check`, `cdidx db schema`, `cdidx db prune --dry-run|--apply`, `cdidx db checkpoint [name]`, `cdidx db checkpoints --list|--delete|--prune`, `cdidx db restore <name> [--dry-run] [--no-backup]`, or `cdidx db restore-backups --list|--prune --keep <n>|--restore <id> [--dry-run] [--no-backup]`.", CommandErrorCodes.UsageError); var dbPath = options.DbPath; @@ -138,10 +138,10 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, Cance return RunCheckpoints(options, jsonOptions); if (options.Restore) - return RunRestore(options, jsonOptions); + return RunRestore(options, jsonOptions, cancellationToken); if (options.RestoreBackups) - return RunRestoreBackups(options, jsonOptions); + return RunRestoreBackups(options, jsonOptions, cancellationToken); return RunIntegrityCheck(options, jsonOptions, dbPath, isUri, cancellationToken); } @@ -381,8 +381,10 @@ internal sealed class DbCommandOptions public bool RestoreBackups { get; init; } public bool RestoreBackupsList { get; init; } public bool RestoreBackupsPrune { get; init; } + public bool RestoreBackupsRestore { get; init; } public int RestoreBackupsKeep { get; init; } = DbCommandRunner.DefaultRestoreBackupKeepCount; public bool RestoreBackupsDryRun { get; init; } + public bool NoBackup { get; init; } public bool SchemaSummaryOnly { get; init; } public int SchemaEntryLimit { get; init; } = DbCommandRunner.SchemaEntryLimit; public int SchemaSqlTextLimit { get; init; } = DbCommandRunner.SchemaSqlTextLimit; @@ -432,7 +434,8 @@ internal sealed record DbRestorePreviewResult( long? AvailableSpaceBytes, List<string> Files, long Bytes, - List<DbDiagnosticJsonResult> Diagnostics); + List<DbDiagnosticJsonResult> Diagnostics, + ManagedRestoreBackupCreationPreview BackupPreview); internal sealed record DbCheckpointPayloadValidationResult( bool PathsValid, diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs index ae5e29749..df2f9e866 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs @@ -201,6 +201,9 @@ internal sealed record ImportDryRunResult( [property: JsonPropertyName("pruned_project_root")] [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? PrunedProjectRoot, + [property: JsonPropertyName("backup_policy")] string BackupPolicy, + [property: JsonPropertyName("backup_would_be_created")] bool BackupWouldBeCreated, + [property: JsonPropertyName("backup_required_space_bytes")] long BackupRequiredSpaceBytes, [property: JsonPropertyName("replacement_would_be_allowed")] bool ReplacementWouldBeAllowed, [property: JsonPropertyName("validation_phases")] IReadOnlyList<ImportValidationPhaseResult> ValidationPhases, [property: JsonPropertyName("destination_delta")] @@ -292,6 +295,10 @@ internal sealed record ImportResult( [property: JsonPropertyName("pruned_project_root")] [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? PrunedProjectRoot, + [property: JsonPropertyName("backup_policy")] string BackupPolicy, + [property: JsonPropertyName("backup_id")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? BackupId, [property: JsonPropertyName("validation_phases")] IReadOnlyList<ImportValidationPhaseResult> ValidationPhases, [property: JsonPropertyName("unknown_extension_file_count")] long? UnknownExtensionFileCount = null, [property: JsonPropertyName("unknown_extension_files")] string[]? UnknownExtensionFiles = null, diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs index 59532023e..669b83a23 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Import.cs @@ -26,6 +26,7 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca var prunePaths = importArguments.PrunePaths; var importMode = importArguments.ImportMode; var dryRun = importArguments.DryRun; + var backupEnabled = !importArguments.NoBackup; var limit = importArguments.Limit; var offset = importArguments.Offset; var dbPath = importArguments.DbPath @@ -39,6 +40,7 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca string? tempDirectory = null; string? tempPath = null; ExportManifest? importedManifest = null; + ManagedRestoreBackupInfo? managedBackup = null; var validationPhases = new List<ImportValidationPhaseResult>(); var phase = PhaseOpenArchive; try @@ -122,6 +124,27 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca SqliteConnection.ClearAllPools(); } + phase = PhasePreReplaceBackup; + var backupDiagnostics = new List<DbDiagnosticJsonResult>(); + var backupPreview = ManagedRestoreBackupStore.PreviewCreation( + fullDbPath, + backupEnabled, + backupDiagnostics, + cancellationToken); + if (!backupPreview.Ready) + { + return WriteImportError( + wantsJson, + jsonOptions, + PhasePreReplaceBackup, + "import_rollback_backup_unavailable", + "import cannot create verified rollback material for the existing destination database.", + "resolve the reported database or free-space problem, or pass `--no-backup` only when discarding rollback material is intentional.", + ImportUsage, + exitCode: CommandExitCodes.DatabaseError, + diagnostics: ConvertBackupDiagnostics(backupDiagnostics)); + } + if (dryRun) { phase = PhaseDestinationDelta; @@ -137,6 +160,15 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca PhaseDestinationDelta, destinationDelta.Comparable ? "success" : "unavailable", destinationDelta.Message); + AddImportValidationPhase( + validationPhases, + PhasePreReplaceBackup, + backupPreview.WouldCreate ? "success" : "skipped", + backupPreview.WouldCreate + ? $"would create a verified managed restore backup ({backupPreview.RequiredSpaceBytes} bytes required)" + : backupEnabled + ? "destination database does not exist; no rollback backup is required" + : "automatic rollback backup explicitly disabled by --no-backup"); AddImportValidationPhase(validationPhases, PhaseReplaceDb, "skipped", $"{importMode} mode does not replace the destination database"); var manifest = importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"); return WriteImportDryRunResult( @@ -146,8 +178,27 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca importTargetProjectRoot, validationPhases, destinationDelta, - manifest); + manifest, + backupPreview); + } + + if (backupPreview.WouldCreate) + { + managedBackup = ManagedRestoreBackupStore.Create( + fullDbPath, + ManagedRestoreBackupStore.PreImportProvenance, + importedManifest?.DatabaseSha256, + cancellationToken); } + AddImportValidationPhase( + validationPhases, + PhasePreReplaceBackup, + managedBackup is not null ? "success" : "skipped", + managedBackup is not null + ? $"created verified managed restore backup {managedBackup.Id}" + : backupEnabled + ? "destination database does not exist; no rollback backup was required" + : "automatic rollback backup explicitly disabled by --no-backup"); phase = PhaseReplaceDb; ReplaceImportedDatabase(tempPath, fullDbPath, cancellationToken); @@ -158,7 +209,8 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca fullDbPath, importTargetProjectRoot, validationPhases, - importedManifest ?? throw new InvalidDataException("archive manifest was not loaded")); + importedManifest ?? throw new InvalidDataException("archive manifest was not loaded"), + managedBackup); } catch (OperationCanceledException) { @@ -182,8 +234,22 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca $"import failed ({CommandErrorWriter.FormatSanitizedException(ex.InnerException ?? ex)}).", "check destination database permissions and inspect diagnostics for residual replacement state.", ImportUsage, + exitCode: CommandExitCodes.DatabaseError, diagnostics: ex.Diagnostics); } + catch (ManagedRestoreBackupException ex) + { + return WriteImportError( + wantsJson, + jsonOptions, + PhasePreReplaceBackup, + "import_rollback_backup_failed", + $"import could not create verified rollback material ({CommandErrorWriter.FormatSanitizedException(ex)}).", + "resolve the reported database or free-space problem, or pass `--no-backup` only when discarding rollback material is intentional.", + ImportUsage, + exitCode: CommandExitCodes.DatabaseError, + diagnostics: ConvertBackupDiagnostics(ex.Diagnostics)); + } catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or SqliteException) { return WriteImportError( @@ -207,4 +273,13 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions, Ca TryDeleteDirectoryIfEmpty(tempDirectory, "import temporary directory", Path.GetTempPath(), "codeindex-import-"); } } + + private static IReadOnlyList<ExportImportDiagnosticResult> ConvertBackupDiagnostics( + IEnumerable<DbDiagnosticJsonResult> diagnostics) + => diagnostics + .Select(diagnostic => new ExportImportDiagnosticResult( + diagnostic.Code, + diagnostic.Message, + diagnostic.Path)) + .ToList(); } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs index 67e4314a6..9117bc279 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportArguments.cs @@ -22,6 +22,7 @@ private static ImportArgumentParseResult ParseImportArguments( string? dbPath = null; var wantsJson = Array.Exists(args, arg => arg == "--json"); var prunePaths = false; + var noBackup = false; var importMode = "import"; var dryRun = false; var limit = DiffCommandRunner.DefaultDiffLimit; @@ -55,6 +56,11 @@ ImportArgumentParseResult Fail(string errorCode, string message, string recommen prunePaths = true; continue; } + if (arg == "--no-backup") + { + noBackup = true; + continue; + } if (arg is "--dry-run" or "--check") { importMode = arg == "--check" ? "check" : "dry_run"; @@ -118,7 +124,7 @@ ImportArgumentParseResult Fail(string errorCode, string message, string recommen return Fail("import_offset_invalid", "--offset is too large for the requested --limit.", "choose a lower --offset."); return new ImportArgumentParseResult( - new ImportArguments(archivePath, dbPath, wantsJson, prunePaths, importMode, dryRun, limit, offset), + new ImportArguments(archivePath, dbPath, wantsJson, prunePaths, noBackup, importMode, dryRun, limit, offset), CommandExitCodes.Success); } } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs index b764b219b..521b7f863 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs @@ -21,7 +21,8 @@ private static int WriteImportDryRunResult( string importTargetProjectRoot, IReadOnlyList<ImportValidationPhaseResult> validationPhases, ImportDestinationDeltaResult destinationDelta, - ExportManifest manifest) + ExportManifest manifest, + ManagedRestoreBackupCreationPreview backupPreview) { if (importArguments.WantsJson) { @@ -35,8 +36,11 @@ private static int WriteImportDryRunResult( importArguments.DryRun, importArguments.PrunePaths, importArguments.PrunePaths ? importTargetProjectRoot : null, + BackupPolicy: importArguments.NoBackup ? "disabled" : "automatic", + BackupWouldBeCreated: backupPreview.WouldCreate, + BackupRequiredSpaceBytes: backupPreview.RequiredSpaceBytes, ReplacementWouldBeAllowed: true, - validationPhases, + ValidationPhases: validationPhases, DestinationDelta: destinationDelta, UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, UnknownExtensionFiles: manifest.UnknownExtensionFiles, @@ -64,7 +68,8 @@ private static int WriteImportResult( string fullDbPath, string importTargetProjectRoot, IReadOnlyList<ImportValidationPhaseResult> validationPhases, - ExportManifest manifest) + ExportManifest manifest, + ManagedRestoreBackupInfo? managedBackup) { if (importArguments.WantsJson) { @@ -78,7 +83,9 @@ private static int WriteImportResult( DryRun: false, importArguments.PrunePaths, importArguments.PrunePaths ? importTargetProjectRoot : null, - validationPhases, + BackupPolicy: importArguments.NoBackup ? "disabled" : "automatic", + BackupId: managedBackup?.Id, + ValidationPhases: validationPhases, UnknownExtensionFileCount: manifest.UnknownExtensionFileCount, UnknownExtensionFiles: manifest.UnknownExtensionFiles, UnknownExtensionFilesTruncated: manifest.UnknownExtensionFilesTruncated, @@ -91,7 +98,9 @@ private static int WriteImportResult( else { Console.WriteLine(FormatImportSuccessMessage( - $"Imported CodeIndex database to {fullDbPath}", + managedBackup is null + ? $"Imported CodeIndex database to {fullDbPath}" + : $"Imported CodeIndex database to {fullDbPath}; rollback backup ID: {managedBackup.Id}", importArguments.PrunePaths, importTargetProjectRoot)); } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index cfad6b056..dcd9377f3 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -41,11 +41,12 @@ internal static partial class ExportImportCommandRunner private const string PhaseSqliteValidate = "sqlite_validate"; private const string PhasePrunePaths = "prune_paths"; private const string PhaseDestinationDelta = "destination_delta"; + private const string PhasePreReplaceBackup = "pre_replace_backup"; private const string PhaseScopeArchive = "scope_archive"; private const string PhaseReplaceDb = "replace_db"; private const string PhaseWriteArchive = "write_archive"; private const string PhaseWriteCtags = "write_ctags"; - private const string ImportUsage = "cdidx import <archive> [--db <path>] [--prune-paths] [--dry-run|--check] [--limit <n<=10000>] [--offset <n>] [--json]"; + private const string ImportUsage = "cdidx import <archive> [--db <path>] [--prune-paths] [--no-backup] [--dry-run|--check] [--limit <n<=10000>] [--offset <n>] [--json]"; private const string ArchiveExportUsage = "cdidx export <archive> [--db <path>] [--json] [--overwrite] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--project <name|path>] [--solution <path>] [--exclude-tests]"; private const string CtagsExportUsage = "cdidx export ctags [--output <path>] [--db <path>] [--json] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--include-generated]"; private const string CtagsSkipInvalidName = "invalid_name"; @@ -62,6 +63,7 @@ private sealed record ImportArguments( string? DbPath, bool WantsJson, bool PrunePaths, + bool NoBackup, string ImportMode, bool DryRun, int Limit, diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 70ab39551..7ba62163e 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -328,6 +328,9 @@ internal sealed record DbRestoreDryRunJsonResult( [property: JsonPropertyName("available_space_bytes")] long? AvailableSpaceBytes, [property: JsonPropertyName("files")] List<string> Files, [property: JsonPropertyName("bytes")] long Bytes, + [property: JsonPropertyName("backup_policy")] string BackupPolicy, + [property: JsonPropertyName("pre_restore_backup_would_be_created")] bool PreRestoreBackupWouldBeCreated, + [property: JsonPropertyName("backup_required_space_bytes")] long BackupRequiredSpaceBytes, [property: JsonPropertyName("diagnostics")] List<DbDiagnosticJsonResult> Diagnostics, [property: JsonPropertyName("message")] string? Message = null, [property: JsonPropertyName("error_code")] string? ErrorCode = null, @@ -339,7 +342,12 @@ internal sealed record DbRestoreBackupEntryJsonResult( [property: JsonPropertyName("backup_path")] string BackupPath, [property: JsonPropertyName("created_at_utc")] string CreatedAtUtc, [property: JsonPropertyName("bytes")] long Bytes, - [property: JsonPropertyName("files_truncated")] bool FilesTruncated = false); + [property: JsonPropertyName("files_truncated")] bool FilesTruncated = false, + [property: JsonPropertyName("id")] string? Id = null, + [property: JsonPropertyName("managed")] bool Managed = false, + [property: JsonPropertyName("provenance")] string? Provenance = null, + [property: JsonPropertyName("source_id")] string? SourceId = null, + [property: JsonPropertyName("user_version")] int? UserVersion = null); internal sealed record DbRestoreBackupListJsonResult( [property: JsonPropertyName("db_path")] string DbPath, @@ -364,6 +372,31 @@ internal sealed record DbRestoreBackupPruneJsonResult( [property: JsonPropertyName("diagnostics")] List<DbDiagnosticJsonResult>? Diagnostics = null, [property: JsonPropertyName("api_version")] string ApiVersion = JsonOutputContract.ApiVersion) : IVersionedJsonResult; +internal sealed record DbRestoreBackupRestoreJsonResult( + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("db_path")] string DbPath, + [property: JsonPropertyName("backup_id")] string BackupId, + [property: JsonPropertyName("backup_path")] string BackupPath, + [property: JsonPropertyName("dry_run")] bool DryRun, + [property: JsonPropertyName("restored")] bool Restored, + [property: JsonPropertyName("ready")] bool Ready, + [property: JsonPropertyName("manifest_valid")] bool ManifestValid, + [property: JsonPropertyName("hash_valid")] bool HashValid, + [property: JsonPropertyName("schema_valid")] bool SchemaValid, + [property: JsonPropertyName("backup_policy")] string BackupPolicy, + [property: JsonPropertyName("pre_restore_backup_id")] string? PreRestoreBackupId, + [property: JsonPropertyName("pre_restore_backup_would_be_created")] bool PreRestoreBackupWouldBeCreated, + [property: JsonPropertyName("required_space_bytes")] long RequiredSpaceBytes, + [property: JsonPropertyName("available_space_bytes")] long? AvailableSpaceBytes, + [property: JsonPropertyName("space_sufficient")] bool SpaceSufficient, + [property: JsonPropertyName("provenance")] string? Provenance, + [property: JsonPropertyName("source_id")] string? SourceId, + [property: JsonPropertyName("diagnostics")] List<DbDiagnosticJsonResult> Diagnostics, + [property: JsonPropertyName("message")] string? Message = null, + [property: JsonPropertyName("error_code")] string? ErrorCode = null, + [property: JsonPropertyName("hint")] string? Hint = null, + [property: JsonPropertyName("api_version")] string ApiVersion = JsonOutputContract.ApiVersion) : IVersionedJsonResult; + internal sealed record DbSchemaEntryJsonResult( [property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("name")] string Name, @@ -1008,6 +1041,7 @@ internal sealed record ValidateConfigJsonResult( [JsonSerializable(typeof(DbRestoreBackupEntryJsonResult))] [JsonSerializable(typeof(DbRestoreBackupListJsonResult))] [JsonSerializable(typeof(DbRestoreBackupPruneJsonResult))] +[JsonSerializable(typeof(DbRestoreBackupRestoreJsonResult))] [JsonSerializable(typeof(DbRestoreDryRunJsonResult))] [JsonSerializable(typeof(DbRestoreJsonResult))] [JsonSerializable(typeof(DbSchemaEntryJsonResult))] @@ -1051,6 +1085,7 @@ internal sealed record ValidateConfigJsonResult( [JsonSerializable(typeof(IndexFullScanSummaryJsonResult))] [JsonSerializable(typeof(StatusIndexFileError))] [JsonSerializable(typeof(List<StatusIndexFileError>))] +[JsonSerializable(typeof(ManagedRestoreBackupManifest))] [JsonSerializable(typeof(IndexMemorySampleJsonResult))] [JsonSerializable(typeof(IndexMemoryTimelineJsonResult))] [JsonSerializable(typeof(IndexUpdateJsonResult))] diff --git a/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs b/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs new file mode 100644 index 000000000..e55846158 --- /dev/null +++ b/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs @@ -0,0 +1,902 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using CodeIndex.Database; +using CodeIndex.Diagnostics; +using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static class ManagedRestoreBackupStore +{ + internal const string ManifestFileName = "manifest.json"; + internal const string Format = "cdidx-managed-restore-backup"; + internal const int FormatVersion = 1; + internal const int ManifestByteLimit = 64 * 1024; + internal const string PreImportProvenance = "pre_import"; + internal const string PreCheckpointRestoreProvenance = "pre_checkpoint_restore"; + internal const string PreRestoreBackupProvenance = "pre_restore_backup"; + + private const long ManifestSpaceAllowanceBytes = 64 * 1024; + + internal static ManagedRestoreBackupCreationPreview PreviewCreation( + string fullDbPath, + bool enabled, + List<DbDiagnosticJsonResult> diagnostics, + CancellationToken cancellationToken = default) + { + if (!enabled) + return new ManagedRestoreBackupCreationPreview(false, false, true, 0, null, true); + if (!File.Exists(LongPath.EnsureWindowsPrefix(fullDbPath))) + return new ManagedRestoreBackupCreationPreview(false, false, true, 0, null, true); + + cancellationToken.ThrowIfCancellationRequested(); + if (!DbContext.TryValidateExistingCodeIndexDb( + fullDbPath, + requireWritable: false, + requireSupportedUserVersion: true, + out var validationMessage, + out _, + out _, + cancellationToken)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_source_invalid", + $"The current database cannot be captured as verified rollback material ({DiagnosticSanitizer.ForMessage(validationMessage)}).", + ConsoleUi.FormatBoundedValue(fullDbPath))); + return new ManagedRestoreBackupCreationPreview(true, true, false, 0, null, false); + } + + var requiredBytes = EstimateSnapshotBytes(fullDbPath, diagnostics); + var availableBytes = DbCommandRunner.TryGetAvailableFreeSpace(fullDbPath, diagnostics); + var spaceSufficient = availableBytes is long available && available >= requiredBytes; + if (!spaceSufficient) + { + diagnostics.Add(new DbDiagnosticJsonResult( + availableBytes.HasValue ? "restore_backup_space_insufficient" : "restore_backup_space_unavailable", + availableBytes.HasValue + ? "The destination filesystem does not have enough free space for a verified rollback snapshot." + : "Available destination space could not be confirmed for a verified rollback snapshot.", + ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); + } + + return new ManagedRestoreBackupCreationPreview( + Enabled: true, + WouldCreate: true, + Ready: spaceSufficient, + RequiredSpaceBytes: requiredBytes, + AvailableSpaceBytes: availableBytes, + SpaceSufficient: spaceSufficient); + } + + internal static ManagedRestoreBackupInfo? Create( + string fullDbPath, + string provenance, + string? sourceId, + CancellationToken cancellationToken = default) + { + if (!File.Exists(LongPath.EnsureWindowsPrefix(fullDbPath))) + return null; + + ValidateProvenance(provenance); + ValidateSourceId(sourceId); + + var diagnostics = new List<DbDiagnosticJsonResult>(); + var preview = PreviewCreation(fullDbPath, enabled: true, diagnostics, cancellationToken); + if (!preview.Ready) + throw new ManagedRestoreBackupException("verified rollback backup preflight failed", diagnostics); + + var id = MakeId(); + var backupPath = GetBackupPath(fullDbPath, id); + var parent = Path.GetDirectoryName(fullDbPath) + ?? Path.GetPathRoot(fullDbPath) + ?? Path.GetFullPath("."); + var tempPath = fullDbPath + ".restore-tmp-" + id; + var published = false; + DataDirectorySecurity.CreateSensitiveDirectory(tempPath); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var dbFileName = Path.GetFileName(fullDbPath); + var snapshotPath = Path.Combine(tempPath, dbFileName); + ExportImportCommandRunner.CreateDatabaseSnapshot(fullDbPath, snapshotPath, cancellationToken); + + var manifest = CreateManifest( + snapshotPath, + id, + dbFileName, + provenance, + sourceId, + cancellationToken); + WriteManifest(tempPath, manifest); + + var stagedValidation = ValidateDirectory( + fullDbPath, + tempPath, + id, + requireManagedBoundary: false, + checkFreeSpace: false, + cancellationToken); + if (!stagedValidation.Ready) + throw new ManagedRestoreBackupException("staged rollback backup validation failed", stagedValidation.Diagnostics); + + AtomicFileWriter.PublishDirectory(tempPath, backupPath); + published = true; + + var publishedValidation = Validate( + fullDbPath, + id, + checkFreeSpace: false, + cancellationToken); + if (!publishedValidation.Ready) + throw new ManagedRestoreBackupException("published rollback backup validation failed", publishedValidation.Diagnostics); + + return new ManagedRestoreBackupInfo( + id, + backupPath, + manifest.CreatedAtUtc, + provenance, + sourceId, + manifest.DatabaseBytes, + manifest.DatabaseSha256, + manifest.UserVersion); + } + catch (Exception ex) + { + var cleanupPath = published ? backupPath : tempPath; + var cleanupPrefix = published + ? Path.GetFileName(fullDbPath) + ".restore-backup-" + : Path.GetFileName(fullDbPath) + ".restore-tmp-"; + DbCommandRunner.TryDeleteTemporaryDirectory( + cleanupPath, + published ? "invalid managed restore backup" : "managed restore backup staging directory", + parent, + cleanupPrefix); + if (ex is ManagedRestoreBackupException or OperationCanceledException) + throw; + if (IsRecoverableValidationException(ex)) + { + throw new ManagedRestoreBackupException( + "verified rollback backup creation failed", + [ + new DbDiagnosticJsonResult( + "restore_backup_creation_failed", + $"Verified rollback backup creation failed ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(fullDbPath)), + ]); + } + + throw; + } + } + + internal static ManagedRestoreBackupValidation Validate( + string fullDbPath, + string id, + bool checkFreeSpace, + CancellationToken cancellationToken = default) + { + ValidateId(id); + return ValidateDirectory( + fullDbPath, + GetBackupPath(fullDbPath, id), + id, + requireManagedBoundary: true, + checkFreeSpace, + cancellationToken); + } + + internal static ManagedRestoreBackupValidation ValidateStagedDirectory( + string fullDbPath, + string stagedDirectory, + string id, + CancellationToken cancellationToken = default) + { + ValidateId(id); + return ValidateDirectory( + fullDbPath, + stagedDirectory, + id, + requireManagedBoundary: false, + checkFreeSpace: false, + cancellationToken); + } + + internal static bool TryReadSummary( + string fullDbPath, + string backupPath, + out ManagedRestoreBackupSummary summary) + { + summary = default; + var prefix = GetBackupDirectoryPrefix(fullDbPath); + var directoryName = Path.GetFileName(backupPath); + if (!directoryName.StartsWith(prefix, StringComparison.Ordinal)) + return false; + + var id = directoryName[prefix.Length..]; + try + { + ValidateId(id); + if (!TryReadManifest(backupPath, out var manifest, out _) + || !ValidateManifestHeader(manifest, id, Path.GetFileName(fullDbPath), out _)) + { + return false; + } + + summary = new ManagedRestoreBackupSummary( + id, + manifest.CreatedAtUtc, + manifest.Provenance, + manifest.SourceId, + manifest.DatabaseBytes, + manifest.UserVersion); + return true; + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + return false; + } + } + + internal static string GetBackupPath(string fullDbPath, string id) + { + ValidateId(id); + var parent = Path.GetDirectoryName(fullDbPath) + ?? Path.GetPathRoot(fullDbPath) + ?? Path.GetFullPath("."); + return Path.Combine(parent, GetBackupDirectoryPrefix(fullDbPath) + id); + } + + internal static string GetBackupDirectoryPrefix(string fullDbPath) + => Path.GetFileName(fullDbPath) + ".restore-backup-"; + + internal static void ValidateId(string id) + { + if (string.IsNullOrWhiteSpace(id) + || id.Length > 128 + || id is "." or ".." + || id.Any(ch => !char.IsAsciiLetterOrDigit(ch) && ch != '-')) + { + throw new ArgumentException("restore backup ID must contain only ASCII letters, digits, and hyphens"); + } + } + + private static ManagedRestoreBackupValidation ValidateDirectory( + string fullDbPath, + string backupPath, + string id, + bool requireManagedBoundary, + bool checkFreeSpace, + CancellationToken cancellationToken) + { + var diagnostics = new List<DbDiagnosticJsonResult>(); + var validatedPath = backupPath; + if (requireManagedBoundary + && !TryValidateBackupDirectory(fullDbPath, backupPath, out validatedPath, out var pathFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_path_invalid", + $"Restore backup directory failed path validation: {pathFailure}.", + ConsoleUi.FormatBoundedValue(backupPath))); + return InvalidValidation(id, backupPath, diagnostics); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!TryReadManifest(validatedPath, out var manifest, out var manifestFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_manifest_invalid", + manifestFailure, + ConsoleUi.FormatBoundedValue(Path.Combine(validatedPath, ManifestFileName)))); + return InvalidValidation(id, validatedPath, diagnostics); + } + + if (!ValidateManifestHeader(manifest, id, Path.GetFileName(fullDbPath), out manifestFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_manifest_invalid", + manifestFailure, + ConsoleUi.FormatBoundedValue(Path.Combine(validatedPath, ManifestFileName)))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest); + } + + var snapshotPath = Path.Combine(validatedPath, manifest.DatabaseFile); + if (!TryGetRegularFile(snapshotPath, out var normalizedSnapshotPath, out var fileFailure)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_payload_invalid", + fileFailure, + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true); + } + + var unexpectedEntries = 0; + try + { + foreach (var entry in CodeIndex.FileSystemTraversalPolicy.EnumerateFileSystemEntries(validatedPath)) + { + var name = Path.GetFileName(entry); + if (string.Equals(name, manifest.DatabaseFile, StringComparison.Ordinal) + || string.Equals(name, ManifestFileName, StringComparison.Ordinal)) + { + continue; + } + + unexpectedEntries++; + if (unexpectedEntries >= 1) + break; + } + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_payload_enumeration_failed", + $"Restore backup contents could not be enumerated ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(validatedPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true); + } + + if (unexpectedEntries != 0) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_payload_unexpected_entry", + "Restore backup contains an unexpected filesystem entry.", + ConsoleUi.FormatBoundedValue(validatedPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true); + } + + long actualBytes; + try + { + actualBytes = new FileInfo(normalizedSnapshotPath).Length; + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_payload_stat_failed", + $"Restore backup database size could not be read ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true); + } + + if (actualBytes != manifest.DatabaseBytes) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_size_mismatch", + "Restore backup database size does not match its manifest.", + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true); + } + + string actualSha256; + try + { + actualSha256 = ComputeSha256(normalizedSnapshotPath, cancellationToken); + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_hash_unavailable", + $"Restore backup database SHA-256 could not be computed ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true); + } + + if (!string.Equals(actualSha256, manifest.DatabaseSha256, StringComparison.OrdinalIgnoreCase)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_hash_mismatch", + "Restore backup database SHA-256 does not match its manifest.", + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true); + } + + if (!DbContext.TryValidateExistingCodeIndexDb( + normalizedSnapshotPath, + requireWritable: false, + requireSupportedUserVersion: true, + out var schemaMessage, + out _, + out _, + cancellationToken)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_schema_invalid", + $"Restore backup database schema is not supported ({DiagnosticSanitizer.ForMessage(schemaMessage)}).", + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true, + hashValid: true); + } + + int actualUserVersion; + try + { + actualUserVersion = ReadUserVersion(normalizedSnapshotPath); + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_schema_stamp_unavailable", + $"Restore backup database schema stamp could not be read ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true, + hashValid: true); + } + if (actualUserVersion != manifest.UserVersion) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_schema_mismatch", + "Restore backup database schema stamp does not match its manifest.", + ConsoleUi.FormatBoundedValue(snapshotPath))); + return InvalidValidation( + id, + validatedPath, + diagnostics, + manifest, + manifestValid: true, + hashValid: true); + } + + long? availableBytes = null; + bool? spaceSufficient = null; + if (checkFreeSpace) + { + availableBytes = DbCommandRunner.TryGetAvailableFreeSpace(fullDbPath, diagnostics); + spaceSufficient = availableBytes is long available && available >= manifest.DatabaseBytes; + if (spaceSufficient != true) + { + diagnostics.Add(new DbDiagnosticJsonResult( + availableBytes.HasValue ? "restore_backup_space_insufficient" : "restore_backup_space_unavailable", + availableBytes.HasValue + ? "The destination filesystem does not have enough free space to stage this restore backup." + : "Available destination space could not be confirmed for this restore backup.", + ConsoleUi.FormatBoundedValue(Path.GetDirectoryName(fullDbPath) ?? fullDbPath))); + } + } + + return new ManagedRestoreBackupValidation( + Ready: !checkFreeSpace || spaceSufficient == true, + id, + validatedPath, + manifest, + ManifestValid: true, + HashValid: true, + SchemaValid: true, + RequiredSpaceBytes: manifest.DatabaseBytes, + AvailableSpaceBytes: availableBytes, + SpaceSufficient: spaceSufficient, + diagnostics); + } + + private static ManagedRestoreBackupValidation InvalidValidation( + string id, + string backupPath, + List<DbDiagnosticJsonResult> diagnostics, + ManagedRestoreBackupManifest? manifest = null, + bool manifestValid = false, + bool hashValid = false, + bool schemaValid = false) + => new( + Ready: false, + id, + backupPath, + manifest, + ManifestValid: manifestValid, + HashValid: hashValid, + SchemaValid: schemaValid, + RequiredSpaceBytes: manifest?.DatabaseBytes ?? 0, + AvailableSpaceBytes: null, + SpaceSufficient: null, + diagnostics); + + private static ManagedRestoreBackupManifest CreateManifest( + string snapshotPath, + string id, + string dbFileName, + string provenance, + string? sourceId, + CancellationToken cancellationToken) + { + if (!DbContext.TryValidateExistingCodeIndexDb( + snapshotPath, + requireWritable: false, + requireSupportedUserVersion: true, + out var validationMessage, + out _, + out _, + cancellationToken)) + { + throw new InvalidDataException( + $"rollback snapshot is not a supported CodeIndex database ({DiagnosticSanitizer.ForMessage(validationMessage)})"); + } + + var bytes = new FileInfo(LongPath.EnsureWindowsPrefix(snapshotPath)).Length; + return new ManagedRestoreBackupManifest( + Format, + FormatVersion, + id, + DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture), + provenance, + sourceId, + dbFileName, + bytes, + ComputeSha256(snapshotPath, cancellationToken), + ReadUserVersion(snapshotPath)); + } + + private static void WriteManifest(string directory, ManagedRestoreBackupManifest manifest) + { + var json = JsonSerializer.Serialize(manifest, ProgramRunner.CreateDefaultJsonOptions()); + if (System.Text.Encoding.UTF8.GetByteCount(json) > ManifestByteLimit) + throw new InvalidDataException("managed restore backup manifest exceeds its size limit"); + DataDirectorySecurity.WritePrivateText(Path.Combine(directory, ManifestFileName), json + "\n"); + } + + private static bool TryReadManifest( + string directory, + out ManagedRestoreBackupManifest manifest, + out string failure) + { + manifest = null!; + var manifestPath = Path.Combine(directory, ManifestFileName); + if (!TryGetRegularFile(manifestPath, out var normalizedManifestPath, out failure)) + return false; + + try + { + var json = DataDirectorySecurity.ReadTextWithinLimit( + normalizedManifestPath, + ManifestByteLimit, + FileShare.Read); + if (json is null) + { + failure = "Restore backup manifest is missing or exceeds its size limit."; + return false; + } + + manifest = JsonSerializer.Deserialize<ManagedRestoreBackupManifest>( + json, + ProgramRunner.CreateDefaultJsonOptions())!; + if (manifest is null) + { + failure = "Restore backup manifest is empty."; + return false; + } + + failure = string.Empty; + return true; + } + catch (Exception ex) when (ex is JsonException || IsRecoverableValidationException(ex)) + { + failure = $"Restore backup manifest could not be parsed ({CommandErrorWriter.FormatSanitizedException(ex)})."; + return false; + } + } + + private static bool ValidateManifestHeader( + ManagedRestoreBackupManifest manifest, + string id, + string dbFileName, + out string failure) + { + if (!string.Equals(manifest.Format, Format, StringComparison.Ordinal) + || manifest.FormatVersion != FormatVersion + || !string.Equals(manifest.Id, id, StringComparison.Ordinal) + || !DateTimeOffset.TryParse( + manifest.CreatedAtUtc, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out _) + || !string.Equals(manifest.DatabaseFile, dbFileName, StringComparison.Ordinal) + || !string.Equals(Path.GetFileName(manifest.DatabaseFile), manifest.DatabaseFile, StringComparison.Ordinal) + || manifest.DatabaseBytes < 0 + || manifest.DatabaseBytes > ExportImportCommandRunner.MaxImportDatabaseBytes + || string.IsNullOrWhiteSpace(manifest.DatabaseSha256) + || manifest.DatabaseSha256.Length != 64 + || manifest.DatabaseSha256.Any(ch => !Uri.IsHexDigit(ch)) + || manifest.UserVersion < 0) + { + failure = "Restore backup manifest header, database metadata, or identifier is invalid."; + return false; + } + + try + { + ValidateProvenance(manifest.Provenance); + ValidateSourceId(manifest.SourceId); + } + catch (ArgumentException) + { + failure = "Restore backup manifest provenance is invalid."; + return false; + } + + failure = string.Empty; + return true; + } + + private static void ValidateProvenance(string provenance) + { + if (provenance is not (PreImportProvenance + or PreCheckpointRestoreProvenance + or PreRestoreBackupProvenance)) + { + throw new ArgumentException("unsupported managed restore backup provenance", nameof(provenance)); + } + } + + private static void ValidateSourceId(string? sourceId) + { + if (sourceId is null) + return; + if (sourceId.Length is 0 or > 128 + || sourceId.Any(ch => char.IsControl(ch) + || ch is '/' or '\\' + || ch == Path.DirectorySeparatorChar + || ch == Path.AltDirectorySeparatorChar)) + { + throw new ArgumentException("managed restore backup source ID is invalid", nameof(sourceId)); + } + } + + private static bool TryValidateBackupDirectory( + string fullDbPath, + string backupPath, + out string validatedPath, + out string failure) + { + var parent = Path.GetDirectoryName(fullDbPath) + ?? Path.GetPathRoot(fullDbPath) + ?? Path.GetFullPath("."); + var options = new DirectoryCleanupBoundaryOptions( + GetBackupDirectoryPrefix(fullDbPath), + "target is outside the database directory", + "target name does not match the managed restore-backup prefix", + "target is not a regular managed restore-backup directory"); + return FileSystemBoundary.TryValidateDirectoryCleanupTarget( + backupPath, + parent, + options, + out validatedPath, + out failure); + } + + private static bool TryGetRegularFile(string path, out string normalizedPath, out string failure) + { + normalizedPath = LongPath.EnsureWindowsPrefix(path); + try + { + var attributes = File.GetAttributes(normalizedPath); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + { + failure = "Restore backup payload is not a regular file."; + return false; + } + + failure = string.Empty; + return true; + } + catch (FileNotFoundException) + { + failure = "Restore backup payload is missing."; + return false; + } + catch (DirectoryNotFoundException) + { + failure = "Restore backup directory is missing."; + return false; + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + failure = $"Restore backup payload could not be inspected ({CommandErrorWriter.FormatSanitizedException(ex)})."; + return false; + } + } + + private static long EstimateSnapshotBytes( + string fullDbPath, + List<DbDiagnosticJsonResult> diagnostics) + { + long bytes = ManifestSpaceAllowanceBytes; + try + { + foreach (var path in new[] { fullDbPath, fullDbPath + "-wal", fullDbPath + "-shm" }) + { + try + { + var attributes = File.GetAttributes(LongPath.EnsureWindowsPrefix(path)); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + throw new InvalidOperationException("database snapshot source is not a regular file"); + bytes = checked(bytes + new FileInfo(LongPath.EnsureWindowsPrefix(path)).Length); + } + catch (FileNotFoundException) + { + // Optional SQLite sidecar is absent. + } + catch (DirectoryNotFoundException) + { + // Optional SQLite sidecar is absent. + } + } + } + catch (Exception ex) when (IsRecoverableValidationException(ex) || ex is OverflowException) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_size_unavailable", + $"Rollback snapshot size could not be determined ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(fullDbPath))); + return long.MaxValue; + } + + return bytes; + } + + private static int ReadUserVersion(string dbPath) + { + using var connection = DbConnectionFactory.CreateArtifactPreservingQueryOnlyConnection( + dbPath, + pooling: false, + out _, + out _); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = SqliteCommandPolicy.PragmaSql("user_version"); + return SqliteCommandPolicy.ReadInt32Scalar(command, "pragma user_version"); + } + + private static string ComputeSha256(string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var stream = new FileStream( + LongPath.EnsureWindowsPrefix(path), + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 81920, + FileOptions.SequentialScan); + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var buffer = new byte[81920]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) + break; + hash.AppendData(buffer, 0, read); + } + + return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); + } + + private static string MakeId() + => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture) + + "-" + + Guid.NewGuid().ToString("N"); + + private static bool IsRecoverableValidationException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or InvalidOperationException + or NotSupportedException + or PathTooLongException + or SqliteException; +} + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +internal sealed record ManagedRestoreBackupManifest( + [property: JsonPropertyName("format")] string Format, + [property: JsonPropertyName("format_version")] int FormatVersion, + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("created_at_utc")] string CreatedAtUtc, + [property: JsonPropertyName("provenance")] string Provenance, + [property: JsonPropertyName("source_id")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? SourceId, + [property: JsonPropertyName("database_file")] string DatabaseFile, + [property: JsonPropertyName("database_bytes")] long DatabaseBytes, + [property: JsonPropertyName("database_sha256")] string DatabaseSha256, + [property: JsonPropertyName("user_version")] int UserVersion); + +internal sealed record ManagedRestoreBackupInfo( + string Id, + string BackupPath, + string CreatedAtUtc, + string Provenance, + string? SourceId, + long DatabaseBytes, + string DatabaseSha256, + int UserVersion); + +internal readonly record struct ManagedRestoreBackupSummary( + string Id, + string CreatedAtUtc, + string Provenance, + string? SourceId, + long DatabaseBytes, + int UserVersion); + +internal sealed record ManagedRestoreBackupCreationPreview( + bool Enabled, + bool WouldCreate, + bool Ready, + long RequiredSpaceBytes, + long? AvailableSpaceBytes, + bool? SpaceSufficient); + +internal sealed record ManagedRestoreBackupValidation( + bool Ready, + string Id, + string BackupPath, + ManagedRestoreBackupManifest? Manifest, + bool ManifestValid, + bool HashValid, + bool SchemaValid, + long RequiredSpaceBytes, + long? AvailableSpaceBytes, + bool? SpaceSufficient, + List<DbDiagnosticJsonResult> Diagnostics); + +internal sealed class ManagedRestoreBackupException : IOException +{ + internal ManagedRestoreBackupException( + string message, + IReadOnlyList<DbDiagnosticJsonResult> diagnostics) + : base(message) + { + Diagnostics = diagnostics; + } + + internal IReadOnlyList<DbDiagnosticJsonResult> Diagnostics { get; } +} diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index c9582392f..359ff28c3 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -670,7 +670,7 @@ public void Run_CheckpointAndRestore_RestoresDatabaseBytes() Assert.Equal(CommandExitCodes.Success, checkpointExit); Assert.Contains("saved", checkpointOut); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); var (restoreExit, restoreOut, _) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); @@ -695,7 +695,8 @@ public void Run_RestoreDryRun_ValidatesManifestPathsAndSpaceWithoutMutation_Issu InitializeEmptyDb(dbPath); var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.Success, checkpointExit); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); + var changedBytes = File.ReadAllBytes(dbPath); DbCommandRunner.AvailableFreeSpaceForTesting = _ => 1_000_000; var (exitCode, json) = RunAndCaptureJson(["restore", "saved", "--dry-run", "--db", dbPath, "--json"]); @@ -713,7 +714,7 @@ public void Run_RestoreDryRun_ValidatesManifestPathsAndSpaceWithoutMutation_Issu Assert.Contains( json.GetProperty("files").EnumerateArray(), file => file.GetString() == "codeindex.db"); - Assert.Equal("changed", File.ReadAllText(dbPath)); + Assert.Equal(changedBytes, File.ReadAllBytes(dbPath)); Assert.Empty(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); Assert.Empty(Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); } @@ -1530,7 +1531,8 @@ public void Run_RestoreFailureAfterBackup_RestoresOriginalDatabase() var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.Success, checkpointExit); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); + var changedBytes = File.ReadAllBytes(dbPath); DbCommandRunner.RestoreFailureAfterBackupForTesting = () => throw new IOException("injected restore failure"); var (restoreExit, _, stderr) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); @@ -1538,7 +1540,7 @@ public void Run_RestoreFailureAfterBackup_RestoresOriginalDatabase() Assert.Equal(CommandExitCodes.DatabaseError, restoreExit); Assert.Contains("IOException", stderr); Assert.DoesNotContain("injected restore failure", stderr); - Assert.Equal("changed", File.ReadAllText(dbPath)); + Assert.Equal(changedBytes, File.ReadAllBytes(dbPath)); Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); Assert.Empty(Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); } @@ -1561,7 +1563,7 @@ public void Run_RestoreRollbackFailurePreservesPrimaryFailure_Issue3514() var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.Success, checkpointExit); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); DbCommandRunner.RestoreFailureAfterBackupForTesting = () => { Directory.CreateDirectory(dbPath); @@ -1598,7 +1600,7 @@ public void Run_RestoreRollbackFailureJsonIncludesStructuredMetadata_Issue3833() var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.Success, checkpointExit); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); DbCommandRunner.RestoreFailureAfterBackupForTesting = () => { Directory.CreateDirectory(dbPath); @@ -1896,7 +1898,7 @@ public void Run_RestoreTemporaryNamesIncludeCollisionResistantSuffix_Issue3031() var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.Success, checkpointExit); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); DbCommandRunner.RestoreFailureAfterBackupForTesting = () => { var restoreTempPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); @@ -1931,7 +1933,7 @@ public void Run_RestoreTempCleanupFailureWarnsWithoutFailing_Issue3030() var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.Success, checkpointExit); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); DbCommandRunner.DeleteTemporaryDirectoryForTesting = path => { if (Path.GetFileName(path).StartsWith("codeindex.db.restore-tmp-", StringComparison.Ordinal)) @@ -1971,11 +1973,11 @@ public void Run_Restore_OnPosix_CreatesPrivateStagingAndBackupPermissions() var inspected = false; try { - File.WriteAllText(dbPath, "original"); + InitializeEmptyDb(dbPath); var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.Success, checkpointExit); - File.WriteAllText(dbPath, "changed"); + MutateValidDatabase(dbPath); DbCommandRunner.RestoreFailureAfterBackupForTesting = () => { var restoreTempPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); @@ -2153,7 +2155,7 @@ public void DbHelp_IncludesSafetyNotesForMaintenanceSubcommands_Issue3937() Assert.Contains("checkpoint --dry-run", stdout); Assert.Contains("schema defaults to the full sqlite_master dump", stdout); - Assert.Contains("manifest, regular-file paths, and destination free space", stdout); + Assert.Contains("manifest, regular-file paths, rollback-backup policy, and destination free space", stdout); Assert.Contains("--delete and --prune remove snapshots", stdout); Assert.Contains("--prune --dry-run reports exact deleted/retained paths", stdout); Assert.Contains("prune --dry-run only counts", stdout); @@ -2229,4 +2231,17 @@ private static void Execute(SqliteConnection connection, string sql) command.CommandText = sql; command.ExecuteNonQuery(); } + + private static void MutateValidDatabase(string dbPath) + { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWrite, + }.ConnectionString); + connection.Open(); + Execute(connection, "CREATE TABLE restore_test_state (value TEXT NOT NULL)"); + Execute(connection, "INSERT INTO restore_test_state(value) VALUES ('changed')"); + ReleaseSqlitePools(); + } } diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue3818Tests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue3818Tests.cs index 646b20f44..37a039ea9 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue3818Tests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue3818Tests.cs @@ -50,7 +50,7 @@ public void RunImport_JsonReplacementFailureIncludesResidualDiagnostics_Issue381 var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => ExportImportCommandRunner.RunImport([archivePath, "--db", targetDbPath, "--json"], jsonOptions)); - Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); Assert.Equal(string.Empty, stderr); using var document = JsonDocument.Parse(stdout); var root = document.RootElement; diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index c4350aaf3..acb96cd96 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -1345,12 +1345,14 @@ public void RunImport_DryRunReportsUnreadableDestinationWithoutFailingArchiveVal [archivePath, "--db", destinationDb, "--dry-run", "--json"], jsonOptions)); - Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); Assert.Equal(string.Empty, stderr); using var document = JsonDocument.Parse(stdout); - var delta = document.RootElement.GetProperty("destination_delta"); - Assert.False(delta.GetProperty("comparable").GetBoolean()); - Assert.Equal("destination_unreadable", delta.GetProperty("status").GetString()); + Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("pre_replace_backup", document.RootElement.GetProperty("phase").GetString()); + Assert.Equal( + "import_rollback_backup_unavailable", + document.RootElement.GetProperty("error_code").GetString()); } finally { diff --git a/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs b/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs new file mode 100644 index 000000000..2cf11f3f5 --- /dev/null +++ b/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs @@ -0,0 +1,488 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Tests; + +/// <summary> +/// Regression coverage for atomic import rollback and managed restore backups. +/// import の原子的ロールバックと管理対象復元バックアップの回帰テスト。 +/// </summary> +[Collection("SQLite pool sensitive")] +public sealed class Issue4857ManagedRestoreBackupTests +{ + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + + [Fact] + public void ImportCreatesManagedBackupThatDryRunValidatesAndRestoreSelectsById() + { + var root = TestProjectHelper.CreateTempProject("cdidx_issue4857_import_restore"); + try + { + var sourceDb = CreateDatabase(root, "source", "src/Imported.cs"); + var destinationDb = CreateDatabase(root, "destination", "src/Original.cs"); + var archivePath = ExportArchive(root, sourceDb); + + var (importExit, importJson) = RunImportJson( + [archivePath, "--db", destinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.Success, importExit); + Assert.Equal("automatic", importJson.GetProperty("backup_policy").GetString()); + var backupId = importJson.GetProperty("backup_id").GetString(); + Assert.False(string.IsNullOrWhiteSpace(backupId)); + Assert.Equal("src/Imported.cs", ReadFirstIndexedPath(destinationDb)); + + var (listExit, listJson) = RunDbJson( + ["restore-backups", "--list", "--db", destinationDb, "--json"]); + Assert.Equal(CommandExitCodes.Success, listExit); + var listed = Assert.Single(listJson.GetProperty("backups").EnumerateArray()); + Assert.Equal(backupId, listed.GetProperty("id").GetString()); + Assert.True(listed.GetProperty("managed").GetBoolean()); + Assert.Equal("pre_import", listed.GetProperty("provenance").GetString()); + var firstBackupPath = Assert.Single(EnumerateBackupDirectories(destinationDb)); + var manifestText = File.ReadAllText(Path.Combine(firstBackupPath, "manifest.json")); + Assert.DoesNotContain(Path.GetFullPath(root), manifestText, StringComparison.Ordinal); + + var beforeDryRun = File.ReadAllBytes(destinationDb); + var backupCountBeforeDryRun = EnumerateBackupDirectories(destinationDb).Length; + var (dryRunExit, dryRunJson) = RunDbJson( + ["restore-backups", "--restore", backupId!, "--dry-run", "--db", destinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.Success, dryRunExit); + Assert.Equal("dry_run", dryRunJson.GetProperty("status").GetString()); + Assert.True(dryRunJson.GetProperty("ready").GetBoolean()); + Assert.True(dryRunJson.GetProperty("manifest_valid").GetBoolean()); + Assert.True(dryRunJson.GetProperty("hash_valid").GetBoolean()); + Assert.True(dryRunJson.GetProperty("schema_valid").GetBoolean()); + Assert.True(dryRunJson.GetProperty("space_sufficient").GetBoolean()); + Assert.True(dryRunJson.GetProperty("pre_restore_backup_would_be_created").GetBoolean()); + Assert.Equal(beforeDryRun, File.ReadAllBytes(destinationDb)); + Assert.Equal(backupCountBeforeDryRun, EnumerateBackupDirectories(destinationDb).Length); + + var (restoreExit, restoreJson) = RunDbJson( + ["restore-backups", "--restore", backupId!, "--db", destinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.Equal("success", restoreJson.GetProperty("status").GetString()); + Assert.True(restoreJson.GetProperty("restored").GetBoolean()); + Assert.False(string.IsNullOrWhiteSpace( + restoreJson.GetProperty("pre_restore_backup_id").GetString())); + Assert.Equal("src/Original.cs", ReadFirstIndexedPath(destinationDb)); + Assert.Equal(2, EnumerateBackupDirectories(destinationDb).Length); + } + finally + { + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void ImportNoBackupExplicitlySkipsManagedRollbackMaterial() + { + var root = TestProjectHelper.CreateTempProject("cdidx_issue4857_import_no_backup"); + try + { + var sourceDb = CreateDatabase(root, "source", "src/Imported.cs"); + var destinationDb = CreateDatabase(root, "destination", "src/Original.cs"); + var archivePath = ExportArchive(root, sourceDb); + + var (exitCode, json) = RunImportJson( + [archivePath, "--db", destinationDb, "--no-backup", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("disabled", json.GetProperty("backup_policy").GetString()); + Assert.True( + !json.TryGetProperty("backup_id", out var backupId) + || backupId.ValueKind is JsonValueKind.Null); + Assert.Empty(EnumerateBackupDirectories(destinationDb)); + Assert.Equal("src/Imported.cs", ReadFirstIndexedPath(destinationDb)); + } + finally + { + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void ImportReplacementFailureRollsBackOriginalAndKeepsVerifiedBackup() + { + var root = TestProjectHelper.CreateTempProject("cdidx_issue4857_import_failure"); + try + { + var sourceDb = CreateDatabase(root, "source", "src/Imported.cs"); + var destinationDb = CreateDatabase(root, "destination", "src/Original.cs"); + var archivePath = ExportArchive(root, sourceDb); + ExportImportCommandRunner.ApplyPrivateFileModeForTesting = _ => + throw new IOException("simulated post-move failure"); + + var (exitCode, json) = RunImportJson( + [archivePath, "--db", destinationDb, "--json"]); + + Assert.NotEqual(CommandExitCodes.Success, exitCode); + Assert.Equal("replace_db", json.GetProperty("phase").GetString()); + Assert.Equal("import_replacement_failed", json.GetProperty("error_code").GetString()); + Assert.Equal("src/Original.cs", ReadFirstIndexedPath(destinationDb)); + var backupPath = Assert.Single(EnumerateBackupDirectories(destinationDb)); + Assert.True(File.Exists(Path.Combine(backupPath, "manifest.json"))); + + var (listExit, listJson) = RunDbJson( + ["restore-backups", "--list", "--db", destinationDb, "--json"]); + Assert.Equal(CommandExitCodes.Success, listExit); + Assert.True(Assert.Single(listJson.GetProperty("backups").EnumerateArray()) + .GetProperty("managed") + .GetBoolean()); + } + finally + { + ExportImportCommandRunner.ApplyPrivateFileModeForTesting = null; + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void RestoreDryRunRejectsCorruptPayloadWithoutMutatingDestination() + { + var fixture = CreateImportedFixture("cdidx_issue4857_corrupt_backup"); + try + { + var backupPath = Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + var payloadPath = Path.Combine(backupPath, Path.GetFileName(fixture.DestinationDb)); + File.AppendAllText(payloadPath, "corrupt"); + var before = File.ReadAllBytes(fixture.DestinationDb); + + var (exitCode, json) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--dry-run", "--db", fixture.DestinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal("invalid", json.GetProperty("status").GetString()); + Assert.False(json.GetProperty("ready").GetBoolean()); + Assert.True(json.GetProperty("manifest_valid").GetBoolean()); + Assert.False(json.GetProperty("hash_valid").GetBoolean()); + Assert.Equal(before, File.ReadAllBytes(fixture.DestinationDb)); + Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + } + finally + { + fixture.Dispose(); + } + } + + [Fact] + public void RestoreDryRunRejectsNullManifestHashWithoutUnhandledException() + { + var fixture = CreateImportedFixture("cdidx_issue4857_null_manifest_hash"); + try + { + var backupPath = Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + var manifestPath = Path.Combine(backupPath, "manifest.json"); + var manifest = JsonNode.Parse(File.ReadAllText(manifestPath))!.AsObject(); + manifest["database_sha256"] = null; + File.WriteAllText(manifestPath, manifest.ToJsonString()); + var before = File.ReadAllBytes(fixture.DestinationDb); + + var (exitCode, json) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--dry-run", "--db", fixture.DestinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal("invalid", json.GetProperty("status").GetString()); + Assert.False(json.GetProperty("ready").GetBoolean()); + Assert.False(json.GetProperty("manifest_valid").GetBoolean()); + Assert.Equal(before, File.ReadAllBytes(fixture.DestinationDb)); + Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + } + finally + { + fixture.Dispose(); + } + } + + [Fact] + public void RestoreDryRunRejectsInsufficientSpaceWithoutMutatingDestination() + { + var fixture = CreateImportedFixture("cdidx_issue4857_restore_space"); + try + { + var before = File.ReadAllBytes(fixture.DestinationDb); + DbCommandRunner.AvailableFreeSpaceForTesting = _ => 0; + + var (exitCode, json) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--dry-run", "--db", fixture.DestinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.False(json.GetProperty("ready").GetBoolean()); + Assert.False(json.GetProperty("space_sufficient").GetBoolean()); + Assert.Equal(0, json.GetProperty("available_space_bytes").GetInt64()); + Assert.Equal(before, File.ReadAllBytes(fixture.DestinationDb)); + Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + } + finally + { + DbCommandRunner.AvailableFreeSpaceForTesting = null; + fixture.Dispose(); + } + } + + [Fact] + public void RestoreFailureAfterMoveRollsBackDestinationAndKeepsBothManagedBackups() + { + var fixture = CreateImportedFixture("cdidx_issue4857_restore_failure"); + try + { + var before = File.ReadAllBytes(fixture.DestinationDb); + ExportImportCommandRunner.ApplyPrivateFileModeForTesting = _ => + throw new IOException("simulated restore installation failure"); + + var (exitCode, json) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--db", fixture.DestinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.False(json.GetProperty("restored").GetBoolean()); + Assert.Equal(before, File.ReadAllBytes(fixture.DestinationDb)); + Assert.Equal("src/Imported.cs", ReadFirstIndexedPath(fixture.DestinationDb)); + Assert.Equal(2, EnumerateBackupDirectories(fixture.DestinationDb).Length); + } + finally + { + ExportImportCommandRunner.ApplyPrivateFileModeForTesting = null; + fixture.Dispose(); + } + } + + [Fact] + public void RestoreAcceptsManagedBackupWithOlderSupportedSchemaStamp() + { + var root = TestProjectHelper.CreateTempProject("cdidx_issue4857_old_schema"); + try + { + var sourceDb = CreateDatabase(root, "source", "src/Imported.cs"); + var destinationDb = CreateDatabase(root, "destination", "src/Original.cs"); + SetUserVersion(destinationDb, 0); + var archivePath = ExportArchive(root, sourceDb); + var (importExit, importJson) = RunImportJson( + [archivePath, "--db", destinationDb, "--json"]); + Assert.Equal(CommandExitCodes.Success, importExit); + var backupId = importJson.GetProperty("backup_id").GetString()!; + + var (restoreExit, restoreJson) = RunDbJson( + ["restore-backups", "--restore", backupId, "--db", destinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.True(restoreJson.GetProperty("schema_valid").GetBoolean()); + Assert.Equal(0, ReadUserVersion(destinationDb)); + Assert.Equal("src/Original.cs", ReadFirstIndexedPath(destinationDb)); + } + finally + { + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void ImportRejectsCorruptArchiveBeforeCreatingBackupOrReplacingDestination() + { + var root = TestProjectHelper.CreateTempProject("cdidx_issue4857_corrupt_import"); + try + { + var sourceDb = CreateDatabase(root, "source", "src/Imported.cs"); + var destinationDb = CreateDatabase(root, "destination", "src/Original.cs"); + var archivePath = ExportArchive(root, sourceDb); + CorruptArchiveDatabaseEntry(archivePath); + + var (exitCode, json) = RunImportJson( + [archivePath, "--db", destinationDb, "--json"]); + + Assert.NotEqual(CommandExitCodes.Success, exitCode); + Assert.Equal("sha256", json.GetProperty("phase").GetString()); + Assert.Equal("src/Original.cs", ReadFirstIndexedPath(destinationDb)); + Assert.Empty(EnumerateBackupDirectories(destinationDb)); + } + finally + { + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void RestoreOnWindowsRejectsExclusivelyLockedDestinationWithoutMutation() + { + if (!OperatingSystem.IsWindows()) + return; + + var fixture = CreateImportedFixture("cdidx_issue4857_windows_lock"); + try + { + var before = File.ReadAllBytes(fixture.DestinationDb); + int exitCode; + JsonElement json; + using (var lockStream = new FileStream( + fixture.DestinationDb, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.None)) + { + (exitCode, json) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--db", fixture.DestinationDb, "--json"]); + } + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.False(json.GetProperty("restored").GetBoolean()); + Assert.Equal(before, File.ReadAllBytes(fixture.DestinationDb)); + } + finally + { + fixture.Dispose(); + } + } + + private ImportedFixture CreateImportedFixture(string name) + { + var root = TestProjectHelper.CreateTempProject(name); + var sourceDb = CreateDatabase(root, "source", "src/Imported.cs"); + var destinationDb = CreateDatabase(root, "destination", "src/Original.cs"); + var archivePath = ExportArchive(root, sourceDb); + var (exitCode, json) = RunImportJson( + [archivePath, "--db", destinationDb, "--json"]); + Assert.Equal(CommandExitCodes.Success, exitCode); + return new ImportedFixture( + root, + destinationDb, + json.GetProperty("backup_id").GetString()!); + } + + private static string CreateDatabase(string root, string directoryName, string indexedPath) + { + var dbPath = TestProjectHelper.CreateProjectDb(Path.Combine(root, directoryName)); + TestProjectHelper.InsertIndexedFile( + dbPath, + indexedPath, + "csharp", + "public class Fixture { }", + releasePoolForFileAccess: true); + return dbPath; + } + + private static string ExportArchive(string root, string dbPath) + { + var archivePath = Path.Combine(root, $"issue4857-{Guid.NewGuid():N}.zip"); + var (exitCode, _, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [archivePath, "--db", dbPath], + new JsonSerializerOptions(), + "test")); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + return archivePath; + } + + private (int ExitCode, JsonElement Json) RunImportJson(string[] args) + { + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport(args, _jsonOptions)); + Assert.Equal(string.Empty, stderr); + return (exitCode, ParseJson(stdout)); + } + + private (int ExitCode, JsonElement Json) RunDbJson(string[] args) + { + var (exitCode, stdout, _) = ConsoleCapture.Capture(() => + DbCommandRunner.Run(args, _jsonOptions)); + return (exitCode, ParseJson(stdout)); + } + + private static JsonElement ParseJson(string json) + { + using var document = JsonDocument.Parse(json); + return document.RootElement.Clone(); + } + + private static string ReadFirstIndexedPath(string dbPath) + { + SqliteConnection.ClearAllPools(); + using var connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT path FROM files ORDER BY path LIMIT 1"; + return Assert.IsType<string>(command.ExecuteScalar()); + } + + private static int ReadUserVersion(string dbPath) + { + SqliteConnection.ClearAllPools(); + using var connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly); + using var command = connection.CreateCommand(); + command.CommandText = "PRAGMA user_version"; + return Convert.ToInt32(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + } + + private static void SetUserVersion(string dbPath, int version) + { + SqliteConnection.ClearAllPools(); + using var connection = OpenConnection(dbPath, SqliteOpenMode.ReadWrite); + using var command = connection.CreateCommand(); + command.CommandText = $"PRAGMA user_version={version}"; + command.ExecuteNonQuery(); + SqliteConnection.ClearAllPools(); + } + + private static SqliteConnection OpenConnection(string dbPath, SqliteOpenMode mode) + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = mode, + }.ConnectionString); + connection.Open(); + return connection; + } + + private static string[] EnumerateBackupDirectories(string dbPath) + { + var parent = Path.GetDirectoryName(dbPath)!; + var prefix = Path.GetFileName(dbPath) + ".restore-backup-"; + return Directory.GetDirectories(parent) + .Where(path => Path.GetFileName(path).StartsWith(prefix, StringComparison.Ordinal)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + } + + private static void CorruptArchiveDatabaseEntry(string archivePath) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update); + var entry = archive.GetEntry("codeindex.db") + ?? throw new InvalidOperationException("archive database entry is missing"); + byte[] bytes; + using (var input = entry.Open()) + using (var buffer = new MemoryStream()) + { + input.CopyTo(buffer); + bytes = buffer.ToArray(); + } + + entry.Delete(); + var replacement = archive.CreateEntry("codeindex.db"); + using var output = replacement.Open(); + output.Write(bytes); + output.WriteByte(0x42); + } + + private sealed record ImportedFixture( + string Root, + string DestinationDb, + string BackupId) : IDisposable + { + public void Dispose() + { + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(Root); + } + } +} diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index ac44d7b6e..b9b01eb6e 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -804,7 +804,7 @@ public void ImportArchive_DryRunJsonValidatesWithoutReplacingDestination_Issue35 var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); var (dryRunExit, dryRunStdout, dryRunStderr) = RunCliInSubprocess([ - "import", archivePath, "--db", destinationDbPath, "--prune-paths", "--dry-run", "--json" + "import", archivePath, "--db", destinationDbPath, "--prune-paths", "--no-backup", "--dry-run", "--json" ]); Assert.True(exportExit == 0, exportStderr); @@ -856,7 +856,7 @@ public void ImportArchive_CheckJsonDistinguishesCheckMode_Issue4328() var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); var (checkExit, checkStdout, checkStderr) = RunCliInSubprocess([ - "import", archivePath, "--db", destinationDbPath, "--check", "--json" + "import", archivePath, "--db", destinationDbPath, "--no-backup", "--check", "--json" ]); Assert.True(exportExit == 0, exportStderr); @@ -1010,7 +1010,9 @@ public void ImportArchive_RemovesStaleDestinationSidecars() File.WriteAllText(destinationDbPath + "-shm", "old shm"); var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); - var (importExit, _, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", destinationDbPath]); + var (importExit, _, importStderr) = RunCliInSubprocess([ + "import", archivePath, "--db", destinationDbPath, "--no-backup" + ]); Assert.True(exportExit == 0, exportStderr); Assert.True(importExit == 0, importStderr); From 8d6f569434f589db7e5de844b69448063b464dfe Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Tue, 28 Jul 2026 21:10:40 +0900 Subject: [PATCH 2/4] Harden managed database recovery (#4857) --- .../Cli/DbCommandRunner.Checkpoints.cs | 21 +++- .../Cli/DbCommandRunner.FileOperations.cs | 39 ++++--- src/CodeIndex/Cli/DbCommandRunner.Restore.cs | 4 + .../Cli/DbCommandRunner.RestoreValidation.cs | 3 + src/CodeIndex/Cli/DbCommandRunner.cs | 4 +- src/CodeIndex/Cli/FileSystemBoundary.cs | 5 +- .../Cli/ManagedRestoreBackupStore.cs | 87 +++++++++++---- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 35 ++++++ .../Issue4857ManagedRestoreBackupTests.cs | 100 ++++++++++++++++++ 9 files changed, 259 insertions(+), 39 deletions(-) diff --git a/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs b/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs index b6d0b586e..7d0494ef1 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.Checkpoints.cs @@ -258,7 +258,13 @@ private static DbRestoreBackupPruneResult PruneRestoreBackups(string fullDbPath, { if (dryRun) { - if (TryValidateTemporaryDirectoryCleanupTarget(entry.BackupPath, parent, prefix, out _, out var validationFailure)) + if (TryValidateTemporaryDirectoryCleanupTarget( + entry.BackupPath, + parent, + prefix, + out _, + out var validationFailure, + filesystemAwarePrefix: true)) deletedPaths.Add(entry.BackupPath); else { @@ -297,17 +303,18 @@ private static (List<string> Items, bool Truncated) EnumerateRestoreBackupDirect var directories = new List<string>(); try { + var nameComparison = PathCasing.ComparisonFor(parent); foreach (var directory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(parent, prefix + "*")) { if (directories.Count >= limit) return (directories, Truncated: true); - if (Path.GetFileName(directory).StartsWith(prefix, StringComparison.Ordinal)) + if (Path.GetFileName(directory).StartsWith(prefix, nameComparison)) directories.Add(directory); } return (directories, Truncated: false); } - catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + catch (Exception ex) when (IsRecoverableFilesystemException(ex) || ex is CodeIndexException) { diagnostics.Add(CreateCheckpointDiagnostic("restore_backup_directory_enumeration_failed", "Unable to enumerate every restore backup directory.", parent)); return (directories, Truncated: true); @@ -321,7 +328,13 @@ private static bool TryDeleteRestoreBackupDirectory( { var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); var prefix = GetRestoreBackupDirectoryPrefix(fullDbPath); - if (!TryValidateTemporaryDirectoryCleanupTarget(backupPath, parent, prefix, out var fullPath, out var validationFailure)) + if (!TryValidateTemporaryDirectoryCleanupTarget( + backupPath, + parent, + prefix, + out var fullPath, + out var validationFailure, + filesystemAwarePrefix: true)) { diagnostics.Add(new DbDiagnosticJsonResult( "restore_backup_delete_skipped", diff --git a/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs b/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs index 6dbf43f43..65d6aca31 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs @@ -163,19 +163,34 @@ private static bool TryValidateTemporaryDirectoryCleanupTarget( string safeRoot, string expectedNamePrefix, out string fullPath, - out string failureReason) + out string failureReason, + bool filesystemAwarePrefix = false) { - var options = new DirectoryCleanupBoundaryOptions( - expectedNamePrefix, - "target is outside the expected cleanup root", - "target name does not match the expected temporary-directory prefix", - "target is not a regular temporary directory"); - return FileSystemBoundary.TryValidateDirectoryCleanupTarget( - path, - safeRoot, - options, - out fullPath, - out failureReason); + try + { + var nameComparison = filesystemAwarePrefix + ? PathCasing.ComparisonFor(safeRoot) + : StringComparison.Ordinal; + var options = new DirectoryCleanupBoundaryOptions( + expectedNamePrefix, + "target is outside the expected cleanup root", + "target name does not match the expected temporary-directory prefix", + "target is not a regular temporary directory", + NameComparison: nameComparison); + return FileSystemBoundary.TryValidateDirectoryCleanupTarget( + path, + safeRoot, + options, + out fullPath, + out failureReason, + filesystemAwarePrefix ? nameComparison : null); + } + catch (CodeIndexException ex) + { + fullPath = string.Empty; + failureReason = CommandErrorWriter.FormatSanitizedExceptionMessage(ex); + return false; + } } } diff --git a/src/CodeIndex/Cli/DbCommandRunner.Restore.cs b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs index 57a0f3db8..40d6406db 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.Restore.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs @@ -55,6 +55,10 @@ private static int RunRestore( return CommandExitCodes.Success; } + catch (OperationCanceledException) + { + throw; + } catch (DbRestoreOperationException ex) { return WriteRestoreError(options, jsonOptions, fullDbPath, options.Name, checkpointPath, ex); diff --git a/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs index 3d0848c84..bc11ec897 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs @@ -451,6 +451,9 @@ private static string RestoreCheckpoint( CommandErrorWriter.WriteStderr($"Warning [{rollbackFailure.Code}]: {rollbackFailure.Message} Backup: {rollbackFailure.Path}"); } + if (primaryEx is OperationCanceledException) + throw; + throw new DbRestoreOperationException( primaryEx, checkpointPath, diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 73b90e94f..33aa3e2e4 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -112,7 +112,9 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, Cance "Pass a valid SQLite file URI whose full value and query string fit within the supported limits.", CommandErrorCodes.DbError); - if (!isUri && !File.Exists(LongPath.EnsureWindowsPrefix(dbPath))) + if (!isUri + && !options.RestoreBackups + && !File.Exists(LongPath.EnsureWindowsPrefix(dbPath))) return WriteCommandError( options.Json, jsonOptions, diff --git a/src/CodeIndex/Cli/FileSystemBoundary.cs b/src/CodeIndex/Cli/FileSystemBoundary.cs index 3dfcf2b9c..cc68acc21 100644 --- a/src/CodeIndex/Cli/FileSystemBoundary.cs +++ b/src/CodeIndex/Cli/FileSystemBoundary.cs @@ -17,7 +17,8 @@ internal readonly record struct DirectoryCleanupBoundaryOptions( string PrefixMismatchReason, string UnsafeDirectoryReason, string NotDirectoryReason = "target is not a directory", - string InvalidPathReason = "target path is invalid"); + string InvalidPathReason = "target path is invalid", + StringComparison NameComparison = StringComparison.Ordinal); internal static class FileSystemBoundary { @@ -112,7 +113,7 @@ internal static bool TryValidateDirectoryCleanupTarget( return false; } - if (!Path.GetFileName(fullPath).StartsWith(options.ExpectedNamePrefix, StringComparison.Ordinal)) + if (!Path.GetFileName(fullPath).StartsWith(options.ExpectedNamePrefix, options.NameComparison)) { failureReason = options.PrefixMismatchReason; return false; diff --git a/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs b/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs index e55846158..8eb095713 100644 --- a/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs +++ b/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs @@ -210,17 +210,23 @@ internal static bool TryReadSummary( out ManagedRestoreBackupSummary summary) { summary = default; - var prefix = GetBackupDirectoryPrefix(fullDbPath); - var directoryName = Path.GetFileName(backupPath); - if (!directoryName.StartsWith(prefix, StringComparison.Ordinal)) - return false; - - var id = directoryName[prefix.Length..]; try { + var nameComparison = ResolveDatabaseFileNameComparison(fullDbPath); + var prefix = GetBackupDirectoryPrefix(fullDbPath); + var directoryName = Path.GetFileName(backupPath); + if (!directoryName.StartsWith(prefix, nameComparison)) + return false; + + var id = directoryName[prefix.Length..]; ValidateId(id); if (!TryReadManifest(backupPath, out var manifest, out _) - || !ValidateManifestHeader(manifest, id, Path.GetFileName(fullDbPath), out _)) + || !ValidateManifestHeader( + manifest, + id, + Path.GetFileName(fullDbPath), + nameComparison, + out _)) { return false; } @@ -293,7 +299,26 @@ private static ManagedRestoreBackupValidation ValidateDirectory( return InvalidValidation(id, validatedPath, diagnostics); } - if (!ValidateManifestHeader(manifest, id, Path.GetFileName(fullDbPath), out manifestFailure)) + StringComparison nameComparison; + try + { + nameComparison = ResolveDatabaseFileNameComparison(fullDbPath); + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + diagnostics.Add(new DbDiagnosticJsonResult( + "restore_backup_path_case_unavailable", + $"Restore backup database filename comparison could not be determined ({CommandErrorWriter.FormatSanitizedException(ex)}).", + ConsoleUi.FormatBoundedValue(fullDbPath))); + return InvalidValidation(id, validatedPath, diagnostics, manifest); + } + + if (!ValidateManifestHeader( + manifest, + id, + Path.GetFileName(fullDbPath), + nameComparison, + out manifestFailure)) { diagnostics.Add(new DbDiagnosticJsonResult( "restore_backup_manifest_invalid", @@ -628,6 +653,7 @@ private static bool ValidateManifestHeader( ManagedRestoreBackupManifest manifest, string id, string dbFileName, + StringComparison dbFileNameComparison, out string failure) { if (!string.Equals(manifest.Format, Format, StringComparison.Ordinal) @@ -638,7 +664,7 @@ private static bool ValidateManifestHeader( CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out _) - || !string.Equals(manifest.DatabaseFile, dbFileName, StringComparison.Ordinal) + || !string.Equals(manifest.DatabaseFile, dbFileName, dbFileNameComparison) || !string.Equals(Path.GetFileName(manifest.DatabaseFile), manifest.DatabaseFile, StringComparison.Ordinal) || manifest.DatabaseBytes < 0 || manifest.DatabaseBytes > ExportImportCommandRunner.MaxImportDatabaseBytes @@ -699,17 +725,37 @@ private static bool TryValidateBackupDirectory( var parent = Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."); - var options = new DirectoryCleanupBoundaryOptions( - GetBackupDirectoryPrefix(fullDbPath), - "target is outside the database directory", - "target name does not match the managed restore-backup prefix", - "target is not a regular managed restore-backup directory"); - return FileSystemBoundary.TryValidateDirectoryCleanupTarget( - backupPath, - parent, - options, - out validatedPath, - out failure); + try + { + var nameComparison = PathCasing.ComparisonFor(parent); + var options = new DirectoryCleanupBoundaryOptions( + GetBackupDirectoryPrefix(fullDbPath), + "target is outside the database directory", + "target name does not match the managed restore-backup prefix", + "target is not a regular managed restore-backup directory", + NameComparison: nameComparison); + return FileSystemBoundary.TryValidateDirectoryCleanupTarget( + backupPath, + parent, + options, + out validatedPath, + out failure, + pathComparisonOverride: nameComparison); + } + catch (Exception ex) when (IsRecoverableValidationException(ex)) + { + validatedPath = string.Empty; + failure = $"database filename comparison could not be determined ({CommandErrorWriter.FormatSanitizedException(ex)})"; + return false; + } + } + + private static StringComparison ResolveDatabaseFileNameComparison(string fullDbPath) + { + var parent = Path.GetDirectoryName(fullDbPath) + ?? Path.GetPathRoot(fullDbPath) + ?? Path.GetFullPath("."); + return PathCasing.ComparisonFor(parent); } private static bool TryGetRegularFile(string path, out string normalizedPath, out string failure) @@ -831,6 +877,7 @@ or ArgumentException or InvalidOperationException or NotSupportedException or PathTooLongException + or CodeIndexException or SqliteException; } diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 359ff28c3..036c0319c 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -1551,6 +1551,41 @@ public void Run_RestoreFailureAfterBackup_RestoresOriginalDatabase() } } + [Fact] + public void Run_RestoreCancellationAfterBackup_RollsBackAndReturnsInterrupted_Issue4857() + { + var root = TestProjectHelper.CreateTempProject("cdidx_db_checkpoint_cancel"); + var dbPath = Path.Combine(root, "codeindex.db"); + try + { + InitializeEmptyDb(dbPath); + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + MutateValidDatabase(dbPath); + var changedBytes = File.ReadAllBytes(dbPath); + DbCommandRunner.RestoreFailureAfterBackupForTesting = () => + throw new OperationCanceledException("injected restore cancellation"); + + var (restoreExit, stdout, stderr) = RunAndCaptureStreams( + ["restore", "saved", "--db", dbPath, "--json"]); + + Assert.Equal(CommandExitCodes.CancelledBySignal, restoreExit); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + Assert.Equal( + CommandErrorCodes.Interrupted, + document.RootElement.GetProperty("error_code").GetString()); + Assert.Equal(changedBytes, File.ReadAllBytes(dbPath)); + Assert.Empty(Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); + } + finally + { + DbCommandRunner.RestoreFailureAfterBackupForTesting = null; + DeleteWorkDirectory(root); + } + } + [Fact] public void Run_RestoreRollbackFailurePreservesPrimaryFailure_Issue3514() { diff --git a/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs b/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs index 2cf11f3f5..397e6491a 100644 --- a/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs +++ b/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs @@ -82,6 +82,106 @@ public void ImportCreatesManagedBackupThatDryRunValidatesAndRestoreSelectsById() } } + [Fact] + public void RestoreByIdRecoversWhenDestinationDatabaseIsMissing() + { + var fixture = CreateImportedFixture("cdidx_issue4857_missing_destination"); + try + { + SqliteConnection.ClearAllPools(); + File.Delete(fixture.DestinationDb); + + var (listExit, listJson) = RunDbJson( + ["restore-backups", "--list", "--db", fixture.DestinationDb, "--json"]); + Assert.Equal(CommandExitCodes.Success, listExit); + Assert.Equal( + fixture.BackupId, + Assert.Single(listJson.GetProperty("backups").EnumerateArray()) + .GetProperty("id") + .GetString()); + + var (restoreExit, restoreJson) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--db", fixture.DestinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.True(restoreJson.GetProperty("restored").GetBoolean()); + Assert.True(File.Exists(fixture.DestinationDb)); + Assert.Equal("src/Original.cs", ReadFirstIndexedPath(fixture.DestinationDb)); + } + finally + { + fixture.Dispose(); + } + } + + [Fact] + public void ManagedBackupSummaryUsesFilesystemCaseSemanticsForDatabaseName() + { + var fixture = CreateImportedFixture("cdidx_issue4857_case_summary"); + try + { + var backupPath = Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + var alternateDbPath = Path.Combine( + Path.GetDirectoryName(fixture.DestinationDb)!, + Path.GetFileName(fixture.DestinationDb).ToUpperInvariant()); + + lock (PathCasingTestLock.Gate) + { + var previousProbe = PathCasing.IgnoreCaseProbeForTesting; + try + { + PathCasing.ResetCacheForTests(); + PathCasing.IgnoreCaseProbeForTesting = _ => true; + + Assert.True(ManagedRestoreBackupStore.TryReadSummary( + alternateDbPath, + backupPath, + out var summary)); + Assert.Equal(fixture.BackupId, summary.Id); + } + finally + { + PathCasing.IgnoreCaseProbeForTesting = previousProbe; + PathCasing.ResetCacheForTests(); + } + } + } + finally + { + fixture.Dispose(); + } + } + + [Fact] + public void RestoreByIdAcceptsEquivalentDatabaseCasingOnCaseInsensitiveFilesystem() + { + var fixture = CreateImportedFixture("cdidx_issue4857_case_restore"); + try + { + if (PathCasing.ComparisonFor(fixture.Root) != StringComparison.OrdinalIgnoreCase) + return; + + var alternateDbPath = Path.Combine( + Path.GetDirectoryName(fixture.DestinationDb)!, + Path.GetFileName(fixture.DestinationDb).ToUpperInvariant()); + var (listExit, listJson) = RunDbJson( + ["restore-backups", "--list", "--db", alternateDbPath, "--json"]); + var (restoreExit, restoreJson) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--dry-run", "--db", alternateDbPath, "--json"]); + + Assert.Equal(CommandExitCodes.Success, listExit); + Assert.True(Assert.Single(listJson.GetProperty("backups").EnumerateArray()) + .GetProperty("managed") + .GetBoolean()); + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.True(restoreJson.GetProperty("ready").GetBoolean()); + } + finally + { + fixture.Dispose(); + } + } + [Fact] public void ImportNoBackupExplicitlySkipsManagedRollbackMaterial() { From 79a129bbc6263b1eb96d4cd884a72bbf61c16d2f Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Tue, 28 Jul 2026 21:32:43 +0900 Subject: [PATCH 3/4] Preserve rollback recovery material (#4857) --- .../Cli/DbCommandRunner.ArgumentParser.cs | 4 ++ .../Cli/DbCommandRunner.RestoreValidation.cs | 16 ++++-- .../Cli/ManagedRestoreBackupStore.cs | 1 - tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 39 +++++++++++++++ .../Issue4857ManagedRestoreBackupTests.cs | 49 +++++++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs index c5b3c0493..cb7a0be8c 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.ArgumentParser.cs @@ -31,6 +31,7 @@ private sealed class DbArgumentParser private bool restoreBackupsPrune; private bool restoreBackupsRestore; private bool noBackup; + private bool keepOptionSeen; private int restoreBackupsKeep = DefaultRestoreBackupKeepCount; private bool schemaSummaryOnly; private int schemaEntryLimit = SchemaEntryLimit; @@ -206,6 +207,7 @@ internal DbCommandOptions Parse(string[] args) parseError = "--delete requires a checkpoint name"; break; case "--keep" when i + 1 < args.Length: + keepOptionSeen = true; if (!restoreBackups && !checkpointsPrune) { parseError = "--keep is only valid with checkpoint or restore-backup pruning"; @@ -263,6 +265,8 @@ private void ValidateOptionCombinations() parseError = "--apply is not supported with `cdidx db restore-backups`; `--prune` is the explicit mutation opt-in."; if (parseError is null && pruneDryRun && restoreBackups && !restoreBackupsPrune && !restoreBackupsRestore) parseError = "--dry-run is only valid with `cdidx db restore-backups --prune` or `--restore <id>`."; + if (parseError is null && keepOptionSeen && restoreBackups && !restoreBackupsPrune) + parseError = "--keep is only valid with `cdidx db restore-backups --prune`."; if (parseError is null && pruneDryRun && listCheckpoints && !checkpointsDelete && !checkpointsPrune) parseError = "--dry-run is only valid with checkpoint deletion or pruning."; if (parseError is null && !schema && schemaSpecificOptionSeen) diff --git a/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs index bc11ec897..c02c41604 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.RestoreValidation.cs @@ -403,6 +403,7 @@ private static string RestoreCheckpoint( var restoreTempPath = fullDbPath + ".restore-tmp-" + restorePathSuffix; var rollbackPath = Path.Combine(restoreTempPath, "rollback"); ManagedRestoreBackupInfo? managedBackup = null; + var retainRestoreTemp = false; DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath); try { @@ -438,9 +439,11 @@ private static string RestoreCheckpoint( catch (Exception primaryEx) { DbDiagnosticJsonResult? rollbackFailure = null; + retainRestoreTemp = true; try { RestoreBackedUpFiles(fullDbPath, rollbackPath); + retainRestoreTemp = false; } catch (Exception rollbackEx) when (IsRecoverableRestoreException(rollbackEx)) { @@ -462,11 +465,14 @@ private static string RestoreCheckpoint( } finally { - TryDeleteTemporaryDirectory( - restoreTempPath, - "restore temporary directory", - Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."), - Path.GetFileName(fullDbPath) + ".restore-tmp-"); + if (!retainRestoreTemp) + { + TryDeleteTemporaryDirectory( + restoreTempPath, + "restore temporary directory", + Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."), + Path.GetFileName(fullDbPath) + ".restore-tmp-"); + } } return managedBackup?.BackupPath ?? string.Empty; diff --git a/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs b/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs index 8eb095713..3732d9fe6 100644 --- a/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs +++ b/src/CodeIndex/Cli/ManagedRestoreBackupStore.cs @@ -667,7 +667,6 @@ private static bool ValidateManifestHeader( || !string.Equals(manifest.DatabaseFile, dbFileName, dbFileNameComparison) || !string.Equals(Path.GetFileName(manifest.DatabaseFile), manifest.DatabaseFile, StringComparison.Ordinal) || manifest.DatabaseBytes < 0 - || manifest.DatabaseBytes > ExportImportCommandRunner.MaxImportDatabaseBytes || string.IsNullOrWhiteSpace(manifest.DatabaseSha256) || manifest.DatabaseSha256.Length != 64 || manifest.DatabaseSha256.Any(ch => !Uri.IsHexDigit(ch)) diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 036c0319c..9dfecd93f 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -1586,6 +1586,45 @@ public void Run_RestoreCancellationAfterBackup_RollsBackAndReturnsInterrupted_Is } } + [Fact] + public void Run_RestoreNoBackupRollbackFailureRetainsTransientOriginal_Issue4857() + { + var root = TestProjectHelper.CreateTempProject("cdidx_db_checkpoint_no_backup_rollback_fail"); + var dbPath = Path.Combine(root, "codeindex.db"); + try + { + InitializeEmptyDb(dbPath); + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + MutateValidDatabase(dbPath); + var changedBytes = File.ReadAllBytes(dbPath); + DbCommandRunner.RestoreFailureAfterBackupForTesting = () => + { + Directory.CreateDirectory(dbPath); + throw new IOException("injected no-backup restore failure"); + }; + + var (restoreExit, stdout, _) = RunAndCaptureStreams( + ["restore", "saved", "--no-backup", "--db", dbPath, "--json"]); + + Assert.Equal(CommandExitCodes.DatabaseError, restoreExit); + using var document = JsonDocument.Parse(stdout); + Assert.True(document.RootElement.GetProperty("rollback_failed").GetBoolean()); + Assert.Empty(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); + var restoreTempPath = Assert.Single( + Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); + var retainedOriginalPath = Path.Combine(restoreTempPath, "rollback", "codeindex.db"); + Assert.True(File.Exists(retainedOriginalPath)); + Assert.Equal(changedBytes, File.ReadAllBytes(retainedOriginalPath)); + } + finally + { + DbCommandRunner.RestoreFailureAfterBackupForTesting = null; + DeleteWorkDirectory(root); + } + } + [Fact] public void Run_RestoreRollbackFailurePreservesPrimaryFailure_Issue3514() { diff --git a/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs b/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs index 397e6491a..03fd405d8 100644 --- a/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs +++ b/tests/CodeIndex.Tests/Issue4857ManagedRestoreBackupTests.cs @@ -304,6 +304,55 @@ public void RestoreDryRunRejectsNullManifestHashWithoutUnhandledException() } } + [Fact] + public void RestoreManifestHeaderDoesNotApplyImportArchiveSizeCap() + { + var fixture = CreateImportedFixture("cdidx_issue4857_managed_size_limit"); + try + { + var backupPath = Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + var manifestPath = Path.Combine(backupPath, "manifest.json"); + var manifest = JsonNode.Parse(File.ReadAllText(manifestPath))!.AsObject(); + manifest["database_bytes"] = ExportImportCommandRunner.MaxImportDatabaseBytes + 1; + File.WriteAllText(manifestPath, manifest.ToJsonString()); + + var (exitCode, json) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--dry-run", "--db", fixture.DestinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.True(json.GetProperty("manifest_valid").GetBoolean()); + Assert.Contains( + json.GetProperty("diagnostics").EnumerateArray(), + diagnostic => diagnostic.GetProperty("code").GetString() == "restore_backup_size_mismatch"); + } + finally + { + fixture.Dispose(); + } + } + + [Fact] + public void RestoreRejectsKeepOptionWithoutMutatingDestination() + { + var fixture = CreateImportedFixture("cdidx_issue4857_restore_keep"); + try + { + var before = File.ReadAllBytes(fixture.DestinationDb); + + var (exitCode, json) = RunDbJson( + ["restore-backups", "--restore", fixture.BackupId, "--keep", "1", "--db", fixture.DestinationDb, "--json"]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(CommandErrorCodes.UsageError, json.GetProperty("error_code").GetString()); + Assert.Equal(before, File.ReadAllBytes(fixture.DestinationDb)); + Assert.Single(EnumerateBackupDirectories(fixture.DestinationDb)); + } + finally + { + fixture.Dispose(); + } + } + [Fact] public void RestoreDryRunRejectsInsufficientSpaceWithoutMutatingDestination() { From 7d3f11bb17ebdcb01b7434aa94198adf4c1d1782 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Wed, 29 Jul 2026 02:32:57 +0900 Subject: [PATCH 4/4] Release pooled SQLite handles before managed restore (#4857) --- src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs b/src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs index bb9bbf5c3..a0be46a42 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.ManagedRestoreBackups.cs @@ -66,6 +66,7 @@ private static int RunRestoreBackupById( if (!stagedValidation.Ready) throw new ManagedRestoreBackupException("staged restore backup validation failed", stagedValidation.Diagnostics); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); if (!options.NoBackup) { preRestoreBackup = ManagedRestoreBackupStore.Create(