From b891682d7f60bccf9ad2a7b390f3165e2ea180e6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 04:35:01 +0900 Subject: [PATCH 1/3] Harden report bundle provenance and overwrite safety (#4828) --- DEVELOPER_GUIDE.md | 12 + TESTING_GUIDE.md | 2 + USER_GUIDE.md | 30 +- changelog.d/unreleased/4828.security.md | 18 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.Help.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/JsonOutputContracts.cs | 8 + src/CodeIndex/Cli/LastFailureEventStore.cs | 279 +++++++++++++++++- src/CodeIndex/Cli/ProgramRunner.Dispatch.cs | 6 +- src/CodeIndex/Cli/ProgramRunner.cs | 14 +- src/CodeIndex/Cli/ReportBundleWriter.cs | 15 +- src/CodeIndex/Cli/ReportCommandRunner.cs | 125 +++++++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- .../ReportCommandRunnerTests.cs | 276 ++++++++++++++++- 15 files changed, 735 insertions(+), 58 deletions(-) create mode 100644 changelog.d/unreleased/4828.security.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 3303e8c2e3..45fb79248e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2943,6 +2943,12 @@ Contract guarantees that downstream consumers can rely on: The flag parser (`ProgramRunner.TryConsumeAuditLogFlags`) is run before `QueryCommandRunner.ParseArgs` and consumes only the audit-specific tokens — `--db` and anything after `--` is left intact so existing escape semantics survive. `--audit-log-include-values` and `--audit-log-strict` require `--audit-log ` because neither value echo nor strict durability has meaning without a configured destination. +## Report artifact contract + +`ReportBundleWriter` stages a complete gzip/tar sibling file and publishes it through `AtomicFileWriter`. Publication uses a no-overwrite move unless `ReportCommandOptions.Overwrite` came from an explicit `--overwrite`; collection and staging failures therefore leave an existing destination unchanged. `support-manifest.json.bundle.members` is the authoritative archive member list. `db_inspected` and `db_diagnostics_included` describe read-only diagnostic collection, while `db_member_included` describes archive membership and is currently always `false`. The legacy `db_included` field remains an additive-compatibility alias for `db_inspected`. + +`LastFailureEventStore` schema 3 adds opaque `workspace_id`, `database_id`, and `run_id` provenance to the existing binary version and UTC timestamp. A report includes the saved event only when workspace, database, and binary version match and the event is no more than 24 hours old, allowing at most five minutes of future clock skew. Other records are excluded from the archive; the manifest publishes only a bounded `last_failure.disposition` / `reason` plus validated opaque provenance fields. Raw workspace and database paths never enter those fields. + ## Coding conventions - Comments are bilingual (English / Japanese), e.g. `// Enable WAL mode / WALモードを有効化` @@ -5492,6 +5498,12 @@ caller と operator が依存できる契約: フラグパーサ (`ProgramRunner.TryConsumeAuditLogFlags`) は `QueryCommandRunner.ParseArgs` より前に走り、audit 関連トークンのみを消費します。`--db` と `--` 以降はそのまま残し、既存の escape semantics を保ちます。`--audit-log-include-values` と `--audit-log-strict` は `--audit-log ` を必須とします。値の echo も strict durability も、出力先が設定されていなければ意味を持たないためです。 +## report artifact 契約 + +`ReportBundleWriter` は完全な gzip/tar sibling file を staging し、`AtomicFileWriter` で公開します。`ReportCommandOptions.Overwrite` が明示的な `--overwrite` から設定されていない限り no-overwrite move を使うため、収集・staging の失敗時には既存 destination を変更しません。`support-manifest.json.bundle.members` が archive member の正式な一覧です。`db_inspected` と `db_diagnostics_included` は read-only の診断収集を表し、`db_member_included` は archive membership を表して現在は常に `false` です。legacy の `db_included` は `db_inspected` の additive compatibility alias として残します。 + +`LastFailureEventStore` schema 3 は、従来の binary version と UTC timestamp に加えて、不透明な `workspace_id`、`database_id`、`run_id` provenance を持ちます。report は workspace、database、binary version が一致し、event が24時間以内で、未来方向の clock skew が5分以内の場合だけ保存 event を同梱します。それ以外は archive から除外し、manifest には上限付きの `last_failure.disposition` / `reason` と、検証済みの不透明 provenance field だけを出力します。workspace / database の raw path はこれらの field に入りません。 + ## コーディング規約 - コメントは英日併記(例: `// Enable WAL mode / WALモードを有効化`) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cabd5c32b4..37700da946 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -566,6 +566,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `ReportCommandRunnerTests.cs` Report log-tail fixtures should use the local log-directory and log-file helpers instead of repeating `Path.Combine(workDir, "logs")`, `Directory.CreateDirectory`, and ad hoc `File.WriteAllText` setup. JSON summary coverage keeps `output_path` as the artifact basename with and without `--redact-paths`, while asserting that diagnostic paths and parent directories remain redacted. + Keep no-overwrite refusal and explicit replacement in one shared-fixture test, and inject publication failure after staging to prove an existing bundle survives every pre-publication failure. Provenance coverage should reuse one saved-event fixture for current, stale, cross-workspace, cross-database, and missing/unsafe-provenance variants; assert both archive membership and manifest disposition/reason on `net8.0` and `net9.0`. - `PostExtractionHookTests.cs` Post-extraction hook discovery, mutation, diagnostics, callback budgets, and collectible hook assembly cleanup. Heavy hook worker and collectible assembly-load integration tests use `ProductionRuntimeFactAttribute` and run only on the `net8.0` production target, while direct worker protocol and metadata tests remain cross-target. Timed-out and canceled callback tests use a hook delay shorter than their leak-observation window, not a full one-second absence check, so worker-kill regressions still write the completion marker before the assertion exits. Duplicate-hook isolation coverage copies the dedicated `CodeIndex.HookIsolationFixture` assembly twice, so only the two hooks under test launch callback workers; the healthy worker retains a bounded one-second startup-and-callback budget while the selected slow hook blocks for 30 seconds. These tests mutate hook-related environment variables and test-only callback budget state, so the class belongs to the `SQLite pool sensitive` non-parallel collection. Keep the timed-out hook delay well beyond the callback budget while still below its bounded leak-observation window, so a loaded runner cannot let the hook finish at the timeout boundary and worker-kill regressions remain observable. @@ -1442,6 +1443,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ReportCommandRunnerTests.cs` report log-tail fixture では、`Path.Combine(workDir, "logs")`、`Directory.CreateDirectory`、ad hoc な `File.WriteAllText` setup を繰り返さず、ローカルの log directory / log file helper を使ってください。 JSON summary の coverage では、`--redact-paths` の有無にかかわらず `output_path` が artifact の basename を保持し、診断用 path と親 directory は伏字化されたままであることを確認します。 + no-overwrite の拒否と明示的な置換は shared fixture を使う1つの test にまとめ、staging 後に公開失敗を注入して、公開前の全 failure で既存 bundle が残ることを証明してください。provenance coverage は current、stale、cross-workspace、cross-database、missing / unsafe provenance で1つの保存 event fixture を再利用し、`net8.0` / `net9.0` の両方で archive membership と manifest の disposition / reason を検証します。 - `PostExtractionHookTests.cs` post-extraction hook の discovery、mutation、diagnostics、callback budget、collectible hook assembly cleanup のテスト。重い hook worker と collectible assembly-load の integration test は `ProductionRuntimeFactAttribute` を使って `net8.0` production target でのみ実行し、direct worker protocol と metadata test は cross-target のままにします。timeout / cancel された callback のテストは、hook delay を leak-observation window より短くし、1 秒丸ごとの absence check には戻しません。worker kill の回帰がある場合は assertion が終わる前に completion marker が書かれるようにします。duplicate-hook isolation coverage では専用の `CodeIndex.HookIsolationFixture` assembly を 2 つの名前で copy し、対象の 2 hook だけが callback worker を起動するようにします。正常な worker には bounded な 1 秒の startup-and-callback budget を残し、選択した slow hook は 30 秒 block させます。hook 関連の環境変数と test-only callback budget 状態を変更するため、このクラスは non-parallel な `SQLite pool sensitive` collection に入れます。 timeout 対象 hook の delay は callback budget より十分長く、かつ bounded な leak-observation window より短く保ってください。高負荷 runner で hook が timeout 境界上に完了する競合を避けながら、worker kill の回帰を観測可能にします。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7cc299c922..759e6b2b7e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1795,16 +1795,21 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output ` packages a redacted gzip-compressed tar archive you can attach to a GitHub issue. Use `.tgz` or `.tar.gz`; if the output path has a misleading extension such as `.json`, the command still writes the archive but warns on stderr and records the warning in JSON summary metadata. `--json` only changes the command summary written to stdout; it does not make the output artifact JSON. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no table row contents). When an unhandled command failure tells you to run `cdidx report`, cdidx first saves a bounded, redacted event and the next bundle includes it as `last-failure.json`, even when lifecycle logging is disabled. That event records the failure timestamp, binary version and sanitized path, command category, exit code, exception category/type, and sanitized diagnostics; it never records literal command arguments. The bundle also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with the database path, lifecycle-log source directory, `process_path=`, `base_dir=`, `cwd=`, `db=`, `path=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. Tar entry modification times are fixed for reproducible archive metadata; the actual generation timestamp is recorded inside `metadata.json`, `env.txt`, and `support-manifest.json`. +`cdidx report --output ` packages a redacted gzip-compressed tar archive you can attach to a GitHub issue. Use `.tgz` or `.tar.gz`; if the output path has a misleading extension such as `.json`, the command still writes the archive but warns on stderr and records the warning in JSON summary metadata. Existing output is refused by default and remains unchanged on collection or staging failure; pass `--overwrite` only when replacement is intentional. `--json` only changes the command summary written to stdout; it does not make the output artifact JSON. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no table row contents). + +When an unhandled command failure tells you to run `cdidx report`, cdidx first saves a bounded, redacted event. A report includes that event as `last-failure.json` only when its opaque workspace and database identities and binary version match the report context, its timestamp is no more than 24 hours old (with a five-minute future-clock allowance), and all provenance fields are valid. Stale, cross-workspace, cross-database, version-mismatched, and legacy records without provenance are excluded; `support-manifest.json.last_failure` records the `disposition` and bounded machine-readable `reason`. The event records the failure timestamp, binary version and sanitized path, command category, exit code, exception category/type, sanitized diagnostics, and an opaque source run ID; it never records literal command arguments or raw provenance paths. + +The bundle also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with the database path, lifecycle-log source directory, `process_path=`, `base_dir=`, `cwd=`, `db=`, `path=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. `support-manifest.json.bundle.members` lists every archive member exactly. `db_inspected` and `db_diagnostics_included` say whether the source database was read and diagnostics were emitted; `db_member_included` is `false` because the database file is not bundled. The legacy `db_included` field remains as a compatibility alias for `db_inspected` and must not be interpreted as archive membership. Tar entry modification times are fixed for reproducible archive metadata; the actual generation timestamp is recorded inside `metadata.json`, `env.txt`, and `support-manifest.json`. | Flag | Default | Effect | |---|---|---| -| `--output ` / `-o ` | (required) | Destination gzip-compressed tar bundle; `.tgz` or `.tar.gz` is recommended. The directory is created if missing; on POSIX, the archive and tar entries are owner-readable/writable only. | +| `--output ` / `-o ` | (required) | Destination gzip-compressed tar bundle; `.tgz` or `.tar.gz` is recommended. The directory is created if missing; an existing file is refused unless `--overwrite` is present. On POSIX, the archive and tar entries are owner-readable/writable only. | +| `--overwrite` | | Atomically replace an existing output after the complete new bundle has been staged. Without this flag, existing output is preserved. | | `--db ` | `.cdidx/codeindex.db` | Override the database whose schema is summarized. If absent, `schema.txt` records that no DB was found. Schema summaries cap table entries at 64, displayed table names at 96 characters, and row-count scans at 1000 rows per table. | | `--log-lines ` | `200` | How many trailing lifecycle-log lines to include (`0` disables the tail; values above `2000` are clamped). Report collection considers at most the 32 newest lifecycle log files; each file contributes from a bounded 1,048,576-byte tail window instead of being loaded fully. | -| `--no-log` | | Skip the lifecycle log entirely. A valid saved `last-failure.json` event is still included because it is independent of lifecycle logging. | -| `--include-args` | | Keep literal `cwd=` and `args=` values in the log tail (opt-in; share only with trusted recipients). | -| `--json` | | Print a stable stdout summary envelope (`output_path`, `version`, `artifact_format`, `artifact_media_type`, `recommended_extensions`, `json_metadata_stdout_only`, `warnings`, `files`, `schema_tables`, `log_lines_included`, `log_included`, `last_failure_included`, `db_included`, `db_path`) instead of the human-friendly output. | +| `--no-log` | | Skip the lifecycle log entirely. A saved `last-failure.json` event is evaluated independently and is included only when its provenance matches. | +| `--include-args` | | Keep literal non-path command arguments in `args=` log fields (opt-in; share only with trusted recipients). Path-bearing values such as `cwd=` remain redacted. | +| `--json` | | Print a stable stdout summary envelope (`output_path`, `version`, `artifact_format`, `artifact_media_type`, `recommended_extensions`, `json_metadata_stdout_only`, `warnings`, `files`, `schema_tables`, `log_lines_included`, `log_included`, `last_failure_included`, `last_failure_disposition`, `last_failure_reason`, `db_inspected`, `db_diagnostics_included`, `db_member_included`, legacy `db_included`, `db_path`) instead of the human-friendly output. | In JSON mode, `output_path` is the generated artifact's basename so automation retains a safe handle even with `--redact-paths`; diagnostic paths such as `db_path` remain `[redacted]`. @@ -4851,16 +4856,21 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output ` は GitHub Issue に添付できる匿名化済みの gzip 圧縮 tar archive を生成します。`.tgz` または `.tar.gz` を使ってください。出力先が `.json` のような誤解を招く拡張子でも archive は書き出されますが、stderr に warning が出力され、JSON summary metadata の `warnings` にも記録されます。`--json` は stdout に出す command summary だけを JSON にし、出力 artifact 自体を JSON にするものではありません。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(table の行内容は含まれません)が入ります。想定外の command failure が `cdidx report` の実行を案内する場合、cdidx はその案内より先に上限付き・匿名化済みのイベントを保存し、ライフサイクルログが無効でも次のバンドルへ `last-failure.json` として含めます。このイベントには失敗時刻、binary version と匿名化済み path、command category、exit code、exception category / type、匿名化済み diagnostics を記録し、具体的な command 引数は記録しません。さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`process_path=`、`base_dir=`、`cwd=`、`db=`、`path=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。tar entry の modification time は再現性のある archive metadata にするため固定され、実際の生成時刻は `metadata.json`、`env.txt`、`support-manifest.json` に記録されます。 +`cdidx report --output ` は GitHub Issue に添付できる匿名化済みの gzip 圧縮 tar archive を生成します。`.tgz` または `.tar.gz` を使ってください。出力先が `.json` のような誤解を招く拡張子でも archive は書き出されますが、stderr に warning が出力され、JSON summary metadata の `warnings` にも記録されます。既存の出力先は既定で拒否され、収集・staging の失敗時にも内容は変わりません。意図的に置換する場合だけ `--overwrite` を指定してください。`--json` は stdout に出す command summary だけを JSON にし、出力 artifact 自体を JSON にするものではありません。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(table の行内容は含まれません)が入ります。 + +想定外の command failure が `cdidx report` の実行を案内する場合、cdidx はその案内より先に上限付き・匿名化済みのイベントを保存します。report は、不透明な workspace / database identity と binary version が report context に一致し、timestamp が24時間以内(未来方向は clock skew として5分まで許容)で、すべての provenance field が有効な場合だけ、その event を `last-failure.json` として含めます。stale、cross-workspace、cross-database、version mismatch、provenance を持たない legacy record は除外され、`support-manifest.json.last_failure` の `disposition` と上限付きの machine-readable `reason` に結果が記録されます。この event には失敗時刻、binary version と匿名化済み path、command category、exit code、exception category / type、匿名化済み diagnostics、不透明な source run ID を記録し、具体的な command 引数や provenance の raw path は記録しません。 + +さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`process_path=`、`base_dir=`、`cwd=`、`db=`、`path=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。`support-manifest.json.bundle.members` は archive member を漏れなく列挙します。`db_inspected` と `db_diagnostics_included` は source DB を読んで診断を出力したかを示し、DB file 自体は同梱しないため `db_member_included` は `false` です。legacy の `db_included` は `db_inspected` の互換 alias として残りますが、archive membership を意味しません。tar entry の modification time は再現性のある archive metadata にするため固定され、実際の生成時刻は `metadata.json`、`env.txt`、`support-manifest.json` に記録されます。 | フラグ | 既定値 | 効果 | |---|---|---| -| `--output ` / `-o ` | (必須) | 出力先の gzip 圧縮 tar bundle。`.tgz` または `.tar.gz` を推奨します。親ディレクトリが無ければ作成します。POSIX では archive と tar entry は owner の読み書きのみになります。 | +| `--output ` / `-o ` | (必須) | 出力先の gzip 圧縮 tar bundle。`.tgz` または `.tar.gz` を推奨します。親ディレクトリが無ければ作成し、既存 file は `--overwrite` が無ければ拒否します。POSIX では archive と tar entry は owner の読み書きのみになります。 | +| `--overwrite` | | 完全な新規 bundle を staging した後、既存出力を原子的に置換します。この flag が無い場合は既存出力を維持します。 | | `--db ` | `.cdidx/codeindex.db` | スキーマ要約対象の DB を上書きします。存在しなければ `schema.txt` に「DB が見つからなかった」旨が記録されます。スキーマ要約は table entry を 64 件、表示 table 名を 96 文字、行数 scan を table ごとに 1000 行までに制限します。 | | `--log-lines ` | `200` | ライフサイクルログ末尾を何行含めるか(`0` で末尾を含めません。`2000` を超える値は clamp されます)。report 収集は最新 32 件までの lifecycle log file を対象にし、各ログファイルは全体を読み込まず、末尾 1,048,576 byte の範囲から収集します。 | -| `--no-log` | | ライフサイクルログを完全に省略します。有効な保存済み `last-failure.json` イベントはライフサイクルログと独立しているため、引き続き同梱されます。 | -| `--include-args` | | ログ末尾の `cwd=` / `args=` 値を伏字化せずそのまま含めます(信頼できる相手にだけ使用してください)。 | -| `--json` | | 人間向け出力の代わりに、安定した stdout summary JSON(`output_path` / `version` / `artifact_format` / `artifact_media_type` / `recommended_extensions` / `json_metadata_stdout_only` / `warnings` / `files` / `schema_tables` / `log_lines_included` / `log_included` / `last_failure_included` / `db_included` / `db_path`)を出力します。 | +| `--no-log` | | ライフサイクルログを完全に省略します。保存済み `last-failure.json` event は独立して評価され、provenance が一致する場合だけ同梱されます。 | +| `--include-args` | | ログ末尾の `args=` field にある path 以外の command 引数をそのまま含めます(信頼できる相手にだけ使用してください)。`cwd=` など path を含む値は引き続き伏字化します。 | +| `--json` | | 人間向け出力の代わりに、安定した stdout summary JSON(`output_path` / `version` / `artifact_format` / `artifact_media_type` / `recommended_extensions` / `json_metadata_stdout_only` / `warnings` / `files` / `schema_tables` / `log_lines_included` / `log_included` / `last_failure_included` / `last_failure_disposition` / `last_failure_reason` / `db_inspected` / `db_diagnostics_included` / `db_member_included` / legacy `db_included` / `db_path`)を出力します。 | JSON mode の `output_path` は生成した artifact の basename を返すため、`--redact-paths` を指定しても automation は安全な handle を保持できます。`db_path` などの診断用 path は引き続き `[redacted]` です。 diff --git a/changelog.d/unreleased/4828.security.md b/changelog.d/unreleased/4828.security.md new file mode 100644 index 0000000000..672b8d2e17 --- /dev/null +++ b/changelog.d/unreleased/4828.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 4828 +affected: + - src/CodeIndex/Cli/ReportCommandRunner.cs + - src/CodeIndex/Cli/ReportBundleWriter.cs + - src/CodeIndex/Cli/LastFailureEventStore.cs + - USER_GUIDE.md +--- + +## English + +- **Report bundles are overwrite-safe, provenance-scoped, and explicit about database membership (#4828)** — `cdidx report` now refuses an existing output unless `--overwrite` is supplied, correlates saved failures by opaque workspace/database identity, binary version, and timestamp while validating and disclosing their run ID, lists every archive member, and separates database inspection/diagnostics from the always-false database-member field while retaining `db_included` as a compatibility alias. + +## 日本語 + +- **report bundle を上書き安全・provenance 限定にし、DB membership を明確化しました (#4828)** — `cdidx report` は `--overwrite` が無ければ既存出力を拒否し、保存済み failure を同梱する前に不透明な workspace / database identity、binary version、timestamp で相関し、run ID を検証・開示します。また全 archive member を列挙し、DB の検査・診断と常に false の DB member field を分離しつつ、`db_included` は互換 alias として維持します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 8991e87d09..238b7d0f24 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -340,7 +340,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--description", ValuePlaceholder = "", Description = "Suggestions add: local suggestion description", PrimaryCommands = Set("suggestions") }, 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 = "Suggestions export: atomically replace an existing --output file", PrimaryCommands = Set("suggestions") }, + new() { Name = "--overwrite", Description = "Atomically replace an existing report bundle or suggestions export", PrimaryCommands = Set("report", "suggestions") }, 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.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 89cfa92ea9..076d1a70b5 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -136,7 +136,7 @@ private static void PrintCommandSummary() Console.WriteLine(" db schema Dump SQLite schema entries and PRAGMA user_version"); Console.WriteLine(" db prune --dry-run|--apply Count or delete orphaned DB rows"); Console.WriteLine(" diff <db1> <db2> Compare two index databases; exit 0 identical, 1 drift, 2 schema mismatch, 3 unreadable"); - Console.WriteLine(" report --output <bundle.tgz> Build a redacted crash-repro tarball (.tgz/.tar.gz); --json reports stdout metadata"); + Console.WriteLine(" report --output <bundle.tgz> Build a redacted crash-repro tarball without replacing existing output; use --overwrite to opt in"); Console.WriteLine(" validate Report encoding issues (U+FFFD origin/severity, BOM, null bytes, mixed line endings, UTF-16 BOM, likely non-UTF8)"); Console.WriteLine(" impact <query> Show transitive callers; type queries may return heuristic file-level dependency hints"); Console.WriteLine(" deps Show file-level dependency edges from the reference graph"); diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index b4a2817084..fdd7402932 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -127,7 +127,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("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]"), ("diff", "cdidx diff <db1> <db2> [--json] [--summary-only] [--detailed] [--limit <n<=10000>] [--offset <n>]"), - ("report", "cdidx report --output <bundle.tgz> [--db <path>] [--json] [--redact-paths] [--log-lines <n<=2000>] [--no-log] [--include-args]"), + ("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>]"), ("impact", "cdidx impact <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|compact>] [--compact] [--fields <csv>] [--cursor <next_cursor>] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--max-hops <n>] [--exact-name] [--count] [--with-paths]"), ("deps", "cdidx deps [--db <path>] [--json] [--format <dot|graphml|json-graph|edgelist>] [--summary-only] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--cursor <cursor>] [--graph-budget <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--reverse] [--cycles] [--suppress-noise] [--symbol <name>] [--symbol-family <prefix>]"), diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 3b1ba2b2b3..2eba86b00f 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -473,6 +473,14 @@ internal sealed record ReportBundleSummary( [property: JsonPropertyName("log_lines_included")] int LogLinesIncluded, [property: JsonPropertyName("log_included")] bool LogIncluded, [property: JsonPropertyName("last_failure_included")] bool LastFailureIncluded, + [property: JsonPropertyName("last_failure_disposition")] string LastFailureDisposition, + [property: JsonPropertyName("last_failure_reason")] string LastFailureReason, + [property: JsonPropertyName("db_inspected")] bool DbInspected, + [property: JsonPropertyName("db_diagnostics_included")] bool DbDiagnosticsIncluded, + [property: JsonPropertyName("db_member_included")] bool DbMemberIncluded, + // Compatibility alias: historically db_included meant the DB was inspected, + // not that the database file was an archive member. + // 互換 alias: 従来の db_included は DB file の同梱ではなく検査済みを意味する。 [property: JsonPropertyName("db_included")] bool DbIncluded, [property: JsonPropertyName("db_path")] string? DbPath, [property: JsonPropertyName("api_version")] string ApiVersion = JsonOutputContract.ApiVersion) : IVersionedJsonResult; diff --git a/src/CodeIndex/Cli/LastFailureEventStore.cs b/src/CodeIndex/Cli/LastFailureEventStore.cs index 03747e122c..f9810a2ff1 100644 --- a/src/CodeIndex/Cli/LastFailureEventStore.cs +++ b/src/CodeIndex/Cli/LastFailureEventStore.cs @@ -1,38 +1,53 @@ using System.Globalization; +using System.Security.Cryptography; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using CodeIndex.Diagnostics; +using Microsoft.Data.Sqlite; namespace CodeIndex.Cli; /// <summary> /// Persists one bounded, redacted top-level failure independently of lifecycle logging so -/// the next `cdidx report` invocation can describe the failure that recommended the report. +/// a provenance-matching `cdidx report` invocation can describe the failure that recommended the report. /// lifecycle log の有効・無効とは独立して、上限付き・匿名化済みの直近 top-level failure を -/// 1 件だけ保存し、案内後の `cdidx report` で同じ失敗を説明できるようにする。 +/// 1 件だけ保存し、provenance が一致する `cdidx report` で同じ失敗を説明できるようにする。 /// </summary> internal static class LastFailureEventStore { internal const string FileName = "last-failure.json"; - internal const int SchemaVersion = 2; + internal const int SchemaVersion = 3; internal const int MaxEventBytes = 32 * 1024; internal const int MaxDiagnosticsChars = 8 * 1024; internal const int MaxDiagnosticLines = 32; + internal static readonly TimeSpan MaxReportCorrelationAge = TimeSpan.FromHours(24); + internal static readonly TimeSpan MaxReportFutureSkew = TimeSpan.FromMinutes(5); private const int MaxFieldChars = 256; + private const int OpaqueIdentityBytes = 16; + private const int RunIdBytes = 16; internal static bool TryPersist( IReadOnlyList<string> args, string appVersion, int exitCode, Exception exception, - DateTimeOffset occurredAtUtc) + DateTimeOffset occurredAtUtc, + string? runId = null, + string? dbPathForTesting = null, + string? workspacePathForTesting = null) { ArgumentNullException.ThrowIfNull(args); ArgumentNullException.ThrowIfNull(exception); try { + var provenance = CreateReportProvenance( + dbPathForTesting ?? ResolveFailureDbPath(args), + appVersion, + occurredAtUtc, + runId ?? CreateRunId(), + workspacePathForTesting); var failure = new LastFailureEvent( SchemaVersion, occurredAtUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), @@ -46,7 +61,10 @@ internal static bool TryPersist( SanitizeField(DiagnosticRedactor.ClassifyException(exception)), SanitizeDiagnostics(GlobalToolLog.FormatExceptionChain(exception, includeStacks: true)), PathsRedacted: true, - LiteralArgumentsIncluded: false); + LiteralArgumentsIncluded: false, + WorkspaceId: provenance.WorkspaceId, + DatabaseId: provenance.DatabaseId, + RunId: provenance.RunId); var json = JsonSerializer.Serialize(failure, LastFailureEventJsonContext.Default.LastFailureEvent); if (Encoding.UTF8.GetByteCount(json) > MaxEventBytes) @@ -65,46 +83,103 @@ internal static bool TryPersist( } } - internal static bool TryBuildReportPayload(out string payload) + internal static bool TryBuildReportPayload( + ReportProvenance reportProvenance, + out string payload, + out ReportLastFailureEvidence evidence) { payload = string.Empty; + evidence = ReportLastFailureEvidence.Unavailable("not_found"); try { var path = Path.Combine(GlobalToolLog.ResolveLogDirectoryForReport(), FileName); if (!File.Exists(path)) return false; if (FileSystemBoundary.IsSymlinkOrReparsePoint(new FileInfo(path))) + { + evidence = ReportLastFailureEvidence.Unavailable("unsafe_file"); return false; + } var json = DataDirectorySecurity.ReadTextWithinLimit( path, MaxEventBytes, FileShare.ReadWrite | FileShare.Delete); if (string.IsNullOrWhiteSpace(json)) + { + evidence = ReportLastFailureEvidence.Unavailable("invalid_or_empty"); return false; + } var failure = JsonSerializer.Deserialize(json, LastFailureEventJsonContext.Default.LastFailureEvent); - if (!TryNormalize(failure, out var normalized)) + if (!TryNormalize(failure, out var normalized, out var validationReason)) + { + evidence = failure is null + ? ReportLastFailureEvidence.Unavailable(validationReason) + : BuildEvidence("excluded", validationReason, failure); + return false; + } + + var correlationReason = GetCorrelationFailureReason(normalized, reportProvenance); + if (correlationReason is not null) + { + evidence = BuildEvidence("excluded", correlationReason, normalized); return false; + } payload = JsonSerializer.Serialize(normalized, LastFailureEventJsonContext.Default.LastFailureEvent); - return Encoding.UTF8.GetByteCount(payload) <= MaxEventBytes; + if (Encoding.UTF8.GetByteCount(payload) > MaxEventBytes) + { + payload = string.Empty; + evidence = BuildEvidence("excluded", "event_too_large", normalized); + return false; + } + + evidence = BuildEvidence("included", "matched", normalized); + return true; } catch (Exception ex) when (ex is not OutOfMemoryException) { // Corrupt or unavailable diagnostic state must not prevent report creation. // 壊れた、または読み取れない診断 state で report 作成を妨げない。 payload = string.Empty; + evidence = ReportLastFailureEvidence.Unavailable("invalid_or_unreadable"); return false; } } - private static bool TryNormalize(LastFailureEvent? failure, out LastFailureEvent normalized) + private static bool TryNormalize( + LastFailureEvent? failure, + out LastFailureEvent normalized, + out string validationReason) { normalized = null!; - if (failure is null - || failure.SchemaVersion != SchemaVersion - || !failure.PathsRedacted + validationReason = "invalid"; + if (failure is null) + return false; + if (failure.SchemaVersion != SchemaVersion) + { + validationReason = failure.SchemaVersion < SchemaVersion + ? "missing_provenance" + : "unsupported_schema"; + return false; + } + if (string.IsNullOrWhiteSpace(failure.WorkspaceId) + || string.IsNullOrWhiteSpace(failure.DatabaseId) + || string.IsNullOrWhiteSpace(failure.RunId)) + { + validationReason = "missing_provenance"; + return false; + } + if (!IsOpaqueIdentity(failure.WorkspaceId, "ws_") + || !IsOpaqueIdentity(failure.DatabaseId, "db_") + || !IsOpaqueIdentity(failure.RunId, "run_")) + { + validationReason = "invalid_provenance"; + return false; + } + + if (!failure.PathsRedacted || failure.LiteralArgumentsIncluded || !DateTimeOffset.TryParseExact( failure.OccurredAtUtc, @@ -141,9 +216,184 @@ private static bool TryNormalize(LastFailureEvent? failure, out LastFailureEvent PathsRedacted = true, LiteralArgumentsIncluded = false, }; + validationReason = "valid"; return true; } + internal static string CreateRunId() + => "run_" + HexEncoding.ToLowerHexString(RandomNumberGenerator.GetBytes(RunIdBytes)); + + internal static ReportProvenance CreateReportProvenance( + string dbPath, + string appVersion, + DateTimeOffset timestampUtc, + string runId, + string? workspacePath = null) + { + var normalizedDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); + var normalizedWorkspacePath = Path.GetFullPath( + workspacePath ?? ResolveWorkspacePath(normalizedDbPath)); + return new ReportProvenance( + WorkspaceId: ComputeOpaquePathIdentity("workspace", "ws_", normalizedWorkspacePath), + DatabaseId: ComputeOpaquePathIdentity("database", "db_", normalizedDbPath), + BinaryVersion: SanitizeField(appVersion), + TimestampUtc: timestampUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), + RunId: IsOpaqueIdentity(runId, "run_") ? runId : CreateRunId()); + } + + private static string? GetCorrelationFailureReason( + LastFailureEvent failure, + ReportProvenance reportProvenance) + { + if (!string.Equals(failure.WorkspaceId, reportProvenance.WorkspaceId, StringComparison.Ordinal)) + return "workspace_mismatch"; + if (!string.Equals(failure.DatabaseId, reportProvenance.DatabaseId, StringComparison.Ordinal)) + return "database_mismatch"; + if (!string.Equals(failure.BinaryVersion, reportProvenance.BinaryVersion, StringComparison.Ordinal)) + return "binary_version_mismatch"; + if (!DateTimeOffset.TryParseExact( + failure.OccurredAtUtc, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var occurredAtUtc) + || !DateTimeOffset.TryParseExact( + reportProvenance.TimestampUtc, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var reportTimestampUtc)) + { + return "invalid_timestamp"; + } + + var age = reportTimestampUtc.ToUniversalTime() - occurredAtUtc.ToUniversalTime(); + if (age < -MaxReportFutureSkew) + return "future_timestamp"; + if (age > MaxReportCorrelationAge) + return "stale"; + return null; + } + + private static ReportLastFailureEvidence BuildEvidence( + string disposition, + string reason, + LastFailureEvent failure) + { + var occurredAtUtc = DateTimeOffset.TryParseExact( + failure.OccurredAtUtc, + "O", + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var parsedTimestamp) + ? parsedTimestamp.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture) + : null; + return new ReportLastFailureEvidence( + disposition, + reason, + occurredAtUtc, + string.IsNullOrWhiteSpace(failure.BinaryVersion) ? null : SanitizeField(failure.BinaryVersion), + IsOpaqueIdentity(failure.WorkspaceId, "ws_") ? failure.WorkspaceId : null, + IsOpaqueIdentity(failure.DatabaseId, "db_") ? failure.DatabaseId : null, + IsOpaqueIdentity(failure.RunId, "run_") ? failure.RunId : null); + } + + private static string ComputeOpaquePathIdentity(string domain, string prefix, string path) + { + // Emit only a bounded opaque fingerprint; raw local paths never enter the event or bundle. + // 上限付きの不透明 fingerprint だけを出力し、ローカル path 自体は event / bundle に入れない。 + var input = Encoding.UTF8.GetBytes(domain + "\0" + Path.GetFullPath(path)); + var digest = SHA256.HashData(input); + return prefix + HexEncoding.ToLowerHexString(digest, 0, OpaqueIdentityBytes); + } + + private static bool IsOpaqueIdentity(string? value, string prefix) + { + if (value is null || value.Length != prefix.Length + OpaqueIdentityBytes * 2 + || !value.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + foreach (var c in value.AsSpan(prefix.Length)) + { + if (c is not (>= '0' and <= '9') and not (>= 'a' and <= 'f')) + return false; + } + return true; + } + + private static string ResolveFailureDbPath(IReadOnlyList<string> args) + { + var explicitDbPath = TryReadOptionValue(args, "--db"); + var explicitDataDir = TryReadOptionValue(args, "--data-dir"); + var workspacePath = Environment.CurrentDirectory; + if (string.Equals(ResolveCommandCategory(args), "index", StringComparison.Ordinal)) + { + var projectPath = ResolveIndexProjectPath(args) ?? workspacePath; + return DbPathResolver.ResolveForIndex(projectPath, explicitDbPath, explicitDataDir).DbPath; + } + + return DbPathResolver.ResolveForQuery(workspacePath, explicitDbPath, explicitDataDir).DbPath; + } + + private static string? ResolveIndexProjectPath(IReadOnlyList<string> args) + { + if (args.Count == 0) + return null; + if (!string.Equals(args[0], "index", StringComparison.Ordinal) + && ProgramRunner.IsProjectPathArg(args[0])) + { + return args[0]; + } + if (string.Equals(args[0], "index", StringComparison.Ordinal) + && args.Count > 1 + && ProgramRunner.IsProjectPathArg(args[1])) + { + return args[1]; + } + return null; + } + + private static string? TryReadOptionValue(IReadOnlyList<string> args, string option) + { + for (var index = 0; index < args.Count; index++) + { + if (string.Equals(args[index], option, StringComparison.Ordinal)) + return index + 1 < args.Count ? args[index + 1] : null; + var prefix = option + "="; + if (args[index].StartsWith(prefix, StringComparison.Ordinal)) + return args[index][prefix.Length..]; + } + return null; + } + + private static string ResolveWorkspacePath(string normalizedDbPath) + { + try + { + var indexedProjectRoot = DbPathResolver.ResolveProjectRootForQuery( + normalizedDbPath, + dbPathExplicit: true); + if (!string.IsNullOrWhiteSpace(indexedProjectRoot)) + return indexedProjectRoot; + } + catch (Exception ex) when (ex is SqliteException or IOException or UnauthorizedAccessException or InvalidOperationException) + { + // Provenance falls back to path shape/current workspace when DB metadata is unavailable. + // DB metadata を読めない場合は path 形状 / current workspace へ fallback する。 + } + + var dbDirectory = Path.GetDirectoryName(normalizedDbPath); + if (dbDirectory is not null + && string.Equals(Path.GetFileName(dbDirectory), ".cdidx", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetDirectoryName(dbDirectory) ?? Environment.CurrentDirectory; + } + + return Environment.CurrentDirectory; + } + private static string ResolveCommandCategory(IReadOnlyList<string> args) { if (args.Count == 0 || string.IsNullOrWhiteSpace(args[0])) @@ -402,7 +652,10 @@ internal sealed record LastFailureEvent( string ExceptionMessage, string Diagnostics, bool PathsRedacted, - bool LiteralArgumentsIncluded); + bool LiteralArgumentsIncluded, + string? WorkspaceId, + string? DatabaseId, + string? RunId); [JsonSourceGenerationOptions( WriteIndented = true, diff --git a/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs b/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs index edbfe1e8d1..a555a3b230 100644 --- a/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs +++ b/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs @@ -121,7 +121,11 @@ private static int RunNonQueryCommand( context.JsonOptions, context.CancellationToken), "db" => DbCommandRunner.Run(subArgs, context.JsonOptions, context.CancellationToken), - "report" => ReportCommandRunner.Run(subArgs, context.JsonOptions, context.AppVersion), + "report" => ReportCommandRunner.Run( + subArgs, + context.JsonOptions, + context.AppVersion, + context.RunId), "test-extractor" => RunTestExtractor(subArgs, context.JsonOptions), _ when IsProjectPathArg(commandName) => IndexCommandRunner.Run(originalArgs, context.JsonOptions), diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 457d7d22c8..9bfd383b22 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -108,7 +108,8 @@ private sealed record CommandRunContext( string AppVersion, DateTimeOffset StartTimestamp, Stopwatch Stopwatch, - CancellationToken CancellationToken); + CancellationToken CancellationToken, + string RunId); internal sealed record UpgradeHandoff( string Command, @@ -228,7 +229,13 @@ internal static int Run( if (versionPinExit != CommandExitCodes.Success) return versionPinExit; - var context = new CommandRunContext(jsonOptions, appVersion, commandStartTimestamp, commandStopwatch, cancellationToken); + var context = new CommandRunContext( + jsonOptions, + appVersion, + commandStartTimestamp, + commandStopwatch, + cancellationToken, + LastFailureEventStore.CreateRunId()); if (TryRunImmediateCommand(args, context, out var immediateExitCode)) return immediateExitCode; @@ -271,7 +278,8 @@ internal static int Run( context.AppVersion, unhandledExitCode, ex, - TimeProvider.GetUtcNow()); + TimeProvider.GetUtcNow(), + context.RunId); CommandErrorWriter.WriteStderr(failureCaptured ? "Error: command failed before it could complete. Run `cdidx report` for details." : "Error: command failed before it could complete; current failure diagnostics could not be saved."); diff --git a/src/CodeIndex/Cli/ReportBundleWriter.cs b/src/CodeIndex/Cli/ReportBundleWriter.cs index a984c41409..60ca439bc6 100644 --- a/src/CodeIndex/Cli/ReportBundleWriter.cs +++ b/src/CodeIndex/Cli/ReportBundleWriter.cs @@ -5,7 +5,11 @@ namespace CodeIndex.Cli; internal static class ReportBundleWriter { - internal static void Write(string outputPath, ReportBundle bundle, Action? beforeWriteEntries = null) + internal static void Write( + string outputPath, + ReportBundle bundle, + bool overwrite, + Action? beforeWriteEntries = null) { var fullOutputPath = Path.GetFullPath(outputPath); var dir = Path.GetDirectoryName(fullOutputPath); @@ -31,12 +35,7 @@ internal static void Write(string outputPath, ReportBundle bundle, Action? befor tar.WriteEntry(entry); } }, - ApplyBundleFileMode); - } - - private static void ApplyBundleFileMode(string path) - { - if (!OperatingSystem.IsWindows()) - File.SetUnixFileMode(path, ReportCommandRunner.BundleFileMode); + AtomicFileWriter.WriteProfile.Sensitive, + overwrite); } } diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index 1ca040124c..936b25c26d 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -37,9 +37,13 @@ public static class ReportCommandRunner internal const UnixFileMode BundleFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; internal static readonly DateTimeOffset BundleEntryModificationTime = DateTimeOffset.UnixEpoch; private const string SchemaTableLabelPrefix = "table_"; - private const int SupportManifestVersion = 3; + private const int SupportManifestVersion = 4; - public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, string? appVersion = null) + public static int Run( + string[] cmdArgs, + JsonSerializerOptions jsonOptions, + string? appVersion = null, + string? runId = null) { var options = ParseArgs(cmdArgs); if (options.ShowHelp) @@ -69,10 +73,19 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, strin try { var fullOutputPath = Path.GetFullPath(options.OutputPath!); + if (!options.Overwrite && File.Exists(LongPath.EnsureWindowsPrefix(fullOutputPath))) + { + return WriteCommandError( + options.Json, + jsonOptions, + "report output already exists", + CommandExitCodes.UsageError, + "Choose a new --output path, or pass --overwrite to replace the existing report bundle."); + } var outputExtensionWarning = GetOutputExtensionWarning(fullOutputPath); var resolvedVersion = appVersion ?? ConsoleUi.LoadVersion(); - var bundle = BuildBundle(options, resolvedVersion); - WriteBundle(fullOutputPath, bundle); + var bundle = BuildBundle(options, resolvedVersion, reportRunId: runId); + WriteBundle(fullOutputPath, bundle, options.Overwrite); if (outputExtensionWarning is not null) CommandErrorWriter.WriteWarning(outputExtensionWarning); @@ -89,6 +102,11 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, strin bundle.LogLinesIncluded, bundle.LogIncluded, bundle.LastFailureIncluded, + bundle.LastFailureEvidence.Disposition, + bundle.LastFailureEvidence.Reason, + bundle.DbIncluded, + bundle.DbIncluded, + DbMemberIncluded: false, bundle.DbIncluded, options.Json ? RedactLocalJsonPath(bundle.DbPath) : bundle.DbPath); @@ -106,8 +124,9 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, strin Console.WriteLine($" files : {summary.Files}"); Console.WriteLine($" schema rows : {summary.SchemaTables}"); Console.WriteLine($" log lines : {(summary.LogIncluded ? summary.LogLinesIncluded.ToString() : "skipped")}"); - Console.WriteLine($" last failure : {(summary.LastFailureIncluded ? "included" : "unavailable")}"); - Console.WriteLine($" schema source: {(summary.DbIncluded ? bundle.DbPath : "(no DB found)")}"); + Console.WriteLine($" last failure : {summary.LastFailureDisposition} ({summary.LastFailureReason})"); + Console.WriteLine($" schema source: {(summary.DbInspected ? bundle.DbPath : "(no DB found)")}"); + Console.WriteLine(" database file: not included"); Console.WriteLine(); Console.WriteLine("Attach the tarball to the GitHub issue. Path lists, query strings, and"); Console.WriteLine("`args=` log lines are redacted by default; rerun with `--include-args` to"); @@ -115,6 +134,15 @@ public static int Run(string[] cmdArgs, JsonSerializerOptions jsonOptions, strin } return CommandExitCodes.Success; } + catch (AtomicFileWriter.DestinationAlreadyExistsException) + { + return WriteCommandError( + options.Json, + jsonOptions, + "report output already exists", + CommandExitCodes.UsageError, + "Choose a new --output path, or pass --overwrite to replace the existing report bundle."); + } catch (Exception ex) { if (JsonOutputFailure.TryHandle(ex, out var exitCode)) @@ -159,7 +187,8 @@ private static string FormatReportExceptionMessage(Exception ex) => internal static ReportBundle BuildBundle( ReportCommandOptions options, string version, - DateTimeOffset? generatedAtUtcForTesting = null) + DateTimeOffset? generatedAtUtcForTesting = null, + string? reportRunId = null) { var nowUtc = generatedAtUtcForTesting ?? DateTimeOffset.UtcNow; var bundle = new ReportBundle @@ -198,6 +227,12 @@ internal static ReportBundle BuildBundle( bundle.DbIncluded = dbIncluded; bundle.DbPath = dbPath; bundle.AddText("schema.txt", schemaText); + bundle.Provenance = LastFailureEventStore.CreateReportProvenance( + dbPath ?? options.DbPath, + version, + nowUtc, + reportRunId ?? LastFailureEventStore.CreateRunId(), + options.ProvenanceWorkspacePathForTesting); bundle.LogIncluded = options.IncludeLog; bundle.LogLinesIncluded = 0; @@ -217,11 +252,15 @@ internal static ReportBundle BuildBundle( bundle.AddText("log/stderr-recent.log", logText); } - if (LastFailureEventStore.TryBuildReportPayload(out var lastFailurePayload)) + if (LastFailureEventStore.TryBuildReportPayload( + bundle.Provenance, + out var lastFailurePayload, + out var lastFailureEvidence)) { bundle.LastFailureIncluded = true; bundle.AddText(LastFailureEventStore.FileName, lastFailurePayload); } + bundle.LastFailureEvidence = lastFailureEvidence; var supportManifest = BuildSupportManifest(options, bundle, nowUtc, redactions); bundle.AddText( @@ -250,8 +289,10 @@ internal static string BuildReadme(string version, bool includeLog, bool include sb.AppendLine("- `schema.txt` — capped SQLite table labels and bounded row counts (no raw table names or row contents)."); sb.AppendLine("- `support-manifest.json` — machine-readable redaction, omission, readiness, and diagnostic summary."); sb.AppendLine(lastFailureIncluded - ? $"- `{LastFailureEventStore.FileName}` — the bounded, redacted failure event that most recently recommended running `cdidx report`; literal arguments are never included." - : $"- `{LastFailureEventStore.FileName}` is unavailable because no valid saved failure event was found."); + ? $"- `{LastFailureEventStore.FileName}` — a bounded, redacted failure event whose workspace, database, binary version, and timestamp match this report; literal arguments are never included." + : $"- `{LastFailureEventStore.FileName}` is not included because no saved failure event matched this report's provenance."); + sb.AppendLine("- The database is inspected only for bounded diagnostics; the database file itself is never an archive member."); + sb.AppendLine("- `support-manifest.json` lists every archive member and records included/excluded failure evidence."); sb.AppendLine("- Archive entry modification times are fixed for reproducible tar metadata; generation time is recorded in `metadata.json`, `env.txt`, and `support-manifest.json`."); if (includeLog) { @@ -400,9 +441,15 @@ internal static ReportSupportManifest BuildSupportManifest( var readiness = BuildReadinessSnapshot(bundle.DbPath, bundle.DbIncluded); var diagnostics = BuildDiagnosticSummary(); var omissions = BuildOmissionSummary(options, bundle, readiness); + var memberNames = bundle.Files + .Select(static file => file.Name) + .Append("support-manifest.json") + .Append("README.md") + .ToList(); return new ReportSupportManifest( ManifestVersion: SupportManifestVersion, GeneratedAtUtc: generatedAtUtc.ToString("O"), + Provenance: bundle.Provenance, Artifact: new ReportManifestArtifact( BundleArtifactFormat, BundleArtifactMediaType, @@ -418,13 +465,18 @@ internal static ReportSupportManifest BuildSupportManifest( MaxRecentLogFiles, LastFailureEventStore.MaxEventBytes), Bundle: new ReportManifestBundle( + DbInspected: bundle.DbIncluded, + DbDiagnosticsIncluded: bundle.DbIncluded, + DbMemberIncluded: false, DbIncluded: bundle.DbIncluded, LogIncluded: bundle.LogIncluded, LastFailureIncluded: bundle.LastFailureIncluded, IncludeArgs: options.IncludeArgs, - Files: bundle.Files.Count + 2, + Files: memberNames.Count, + Members: memberNames, SchemaTables: bundle.SchemaTables.Count, LogLinesIncluded: bundle.LogLinesIncluded), + LastFailure: bundle.LastFailureEvidence, Redactions: redactions, Omissions: omissions, Readiness: readiness, @@ -469,7 +521,7 @@ private static ReportManifestOmissions BuildOmissionSummary( var lastFailure = bundle.LastFailureIncluded ? new List<string>() - : new List<string> { "last_failure_event_unavailable" }; + : new List<string> { $"last_failure_{bundle.LastFailureEvidence.Reason}" }; return new ReportManifestOmissions(schema, log, status, lastFailure); } @@ -653,8 +705,12 @@ internal static string RedactSensitiveFields(string line) internal static string RedactLogLine(string line, bool includeArgs) => DiagnosticRedactor.RedactReportLogLine(line, includeArgs, RedactedPlaceholder); - internal static void WriteBundle(string outputPath, ReportBundle bundle, Action? beforeWriteEntries = null) - => ReportBundleWriter.Write(outputPath, bundle, beforeWriteEntries); + internal static void WriteBundle( + string outputPath, + ReportBundle bundle, + bool overwrite = false, + Action? beforeWriteEntries = null) + => ReportBundleWriter.Write(outputPath, bundle, overwrite, beforeWriteEntries); internal static ReportCommandOptions ParseArgs(string[] args) { @@ -685,6 +741,9 @@ internal static ReportCommandOptions ParseArgs(string[] args) case "--json": options.Json = true; break; + case "--overwrite": + options.Overwrite = true; + break; case "--redact-paths": break; case "--no-log": @@ -734,8 +793,10 @@ internal sealed class ReportCommandOptions public bool ShowHelp { get; set; } public bool IncludeLog { get; set; } = true; public bool IncludeArgs { get; set; } + public bool Overwrite { get; set; } public int LogLines { get; set; } = ReportCommandRunner.DefaultLogLines; public string? ParseError { get; set; } + internal string? ProvenanceWorkspacePathForTesting { get; set; } } internal sealed class ReportBundle @@ -751,6 +812,8 @@ internal sealed class ReportBundle public bool LogTailTruncated { get; set; } public bool LogLineCharsTruncated { get; set; } public bool LastFailureIncluded { get; set; } + public ReportProvenance Provenance { get; set; } = ReportProvenance.Empty; + public ReportLastFailureEvidence LastFailureEvidence { get; set; } = ReportLastFailureEvidence.Unavailable("not_found"); public void AddText(string name, string content) => Files.Add((name, Encoding.UTF8.GetBytes(content))); @@ -828,9 +891,11 @@ private static int CountOccurrences(string text, string value) internal sealed record ReportSupportManifest( int ManifestVersion, string GeneratedAtUtc, + ReportProvenance Provenance, ReportManifestArtifact Artifact, ReportManifestLimits Limits, ReportManifestBundle Bundle, + ReportLastFailureEvidence LastFailure, ReportRedactionSummary Redactions, ReportManifestOmissions Omissions, ReportReadinessSnapshot Readiness, @@ -853,14 +918,46 @@ internal sealed record ReportManifestLimits( int MaxLastFailureEventBytes); internal sealed record ReportManifestBundle( + bool DbInspected, + bool DbDiagnosticsIncluded, + bool DbMemberIncluded, bool DbIncluded, bool LogIncluded, bool LastFailureIncluded, bool IncludeArgs, int Files, + List<string> Members, int SchemaTables, int LogLinesIncluded); +internal sealed record ReportProvenance( + string WorkspaceId, + string DatabaseId, + string BinaryVersion, + string TimestampUtc, + string RunId) +{ + public static ReportProvenance Empty { get; } = new( + WorkspaceId: "unavailable", + DatabaseId: "unavailable", + BinaryVersion: "unavailable", + TimestampUtc: DateTimeOffset.UnixEpoch.ToString("O"), + RunId: "unavailable"); +} + +internal sealed record ReportLastFailureEvidence( + string Disposition, + string Reason, + string? OccurredAtUtc, + string? BinaryVersion, + string? WorkspaceId, + string? DatabaseId, + string? RunId) +{ + public static ReportLastFailureEvidence Unavailable(string reason) => + new("unavailable", reason, null, null, null, null, null); +} + internal sealed record ReportRedactionSummary(int Total, Dictionary<string, int> Categories) { public static ReportRedactionSummary Empty { get; } = new(0, new Dictionary<string, int>(StringComparer.Ordinal)); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 2c566eff2e..25680db2bd 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -1472,7 +1472,7 @@ public void CompletionRenderer_ReportFlagSetsMatchAcrossShells() var expected = new SortedSet<string>(StringComparer.Ordinal) { - "db", "json", "pretty", "quiet", "silent", "no-progress", "output", "redact-paths", "log-lines", "no-log", "include-args", + "db", "json", "pretty", "quiet", "silent", "no-progress", "output", "overwrite", "redact-paths", "log-lines", "no-log", "include-args", }; Assert.Equal(expected, flagSets.Bash); Assert.Equal(expected, flagSets.Zsh); diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index c8451ec5c3..d95a1fcb5a 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -79,6 +79,15 @@ public void ParseArgs_IncludeArgsOptsInToLiteralLog() Assert.True(options.IncludeArgs); } + [Fact] + public void ParseArgs_OverwriteOptsInToReplacement() + { + var options = ReportCommandRunner.ParseArgs(["--output", "x.tgz", "--overwrite"]); + + Assert.True(options.Overwrite); + Assert.Null(options.ParseError); + } + [Fact] public void ParseArgs_LogLinesParsesPositive() { @@ -181,7 +190,7 @@ public void Run_NoDbAndNoLog_StillProducesBundleWithMetadata() using var manifest = ReadJsonEntry(entries, "support-manifest.json"); var root = manifest.RootElement; - Assert.Equal(3, root.GetProperty("manifest_version").GetInt32()); + Assert.Equal(4, root.GetProperty("manifest_version").GetInt32()); var artifact = root.GetProperty("artifact"); Assert.Equal(ReportCommandRunner.BundleArtifactFormat, artifact.GetProperty("format").GetString()); Assert.Equal(ReportCommandRunner.BundleArtifactMediaType, artifact.GetProperty("media_type").GetString()); @@ -189,6 +198,12 @@ public void Run_NoDbAndNoLog_StillProducesBundleWithMetadata() Assert.True(JsonArrayContains(artifact.GetProperty("recommended_extensions"), ".tar.gz")); Assert.True(artifact.GetProperty("json_metadata_stdout_only").GetBoolean()); Assert.Equal(entries.Count, root.GetProperty("bundle").GetProperty("files").GetInt32()); + Assert.Equal( + entries.Keys, + root.GetProperty("bundle").GetProperty("members").EnumerateArray().Select(static item => item.GetString())); + Assert.False(root.GetProperty("bundle").GetProperty("db_inspected").GetBoolean()); + Assert.False(root.GetProperty("bundle").GetProperty("db_diagnostics_included").GetBoolean()); + Assert.False(root.GetProperty("bundle").GetProperty("db_member_included").GetBoolean()); Assert.False(root.GetProperty("bundle").GetProperty("db_included").GetBoolean()); Assert.False(root.GetProperty("bundle").GetProperty("log_included").GetBoolean()); Assert.Equal(0, root.GetProperty("redactions").GetProperty("total").GetInt32()); @@ -210,10 +225,12 @@ public void Run_AfterCurrentProcessFailure_IncludesBoundedRedactedFailureWithout var workDir = CreateWorkDir(); var logDir = CreateLogDir(workDir); var output = Path.Combine(workDir, "bundle.tgz"); - const string secretArgument = "clients/private-project"; + var secretArgument = Path.Combine(workDir, "clients", "private-project"); + var failureDbPath = Path.Combine(secretArgument, ".cdidx", "codeindex.db"); const string secretMessage = "query=SELECT * FROM private_customer_records"; try { + Directory.CreateDirectory(secretArgument); using var env = EnvironmentVariableScope.Capture( "CDIDX_FORCE_GLOBAL_TOOL_LOG", "CDIDX_DISABLE_PERSISTENT_LOG", @@ -240,7 +257,7 @@ public void Run_AfterCurrentProcessFailure_IncludesBoundedRedactedFailureWithout } var (reportExitCode, reportStdout, reportStderr) = ConsoleCapture.Capture(() => ProgramRunner.Run( - ["report", "--output", output, "--db", Path.Combine(workDir, "missing.db"), "--no-log"], + ["report", "--output", output, "--db", failureDbPath, "--no-log"], appVersion: "1.38.0-test")); Assert.Equal(CommandExitCodes.Success, reportExitCode); @@ -263,6 +280,9 @@ public void Run_AfterCurrentProcessFailure_IncludesBoundedRedactedFailureWithout Assert.Contains(nameof(ThrowCurrentProcessFailure), failureRoot.GetProperty("diagnostics").GetString()); Assert.True(failureRoot.GetProperty("paths_redacted").GetBoolean()); Assert.False(failureRoot.GetProperty("literal_arguments_included").GetBoolean()); + Assert.StartsWith("ws_", failureRoot.GetProperty("workspace_id").GetString(), StringComparison.Ordinal); + Assert.StartsWith("db_", failureRoot.GetProperty("database_id").GetString(), StringComparison.Ordinal); + Assert.StartsWith("run_", failureRoot.GetProperty("run_id").GetString(), StringComparison.Ordinal); Assert.DoesNotContain(secretArgument, failureRoot.GetRawText()); Assert.DoesNotContain(secretMessage, failureRoot.GetRawText()); Assert.DoesNotContain(workDir, failureRoot.GetRawText()); @@ -270,6 +290,12 @@ public void Run_AfterCurrentProcessFailure_IncludesBoundedRedactedFailureWithout using var manifest = ReadJsonEntry(entries, "support-manifest.json"); var manifestRoot = manifest.RootElement; Assert.True(manifestRoot.GetProperty("bundle").GetProperty("last_failure_included").GetBoolean()); + Assert.Equal("included", manifestRoot.GetProperty("last_failure").GetProperty("disposition").GetString()); + Assert.Equal("matched", manifestRoot.GetProperty("last_failure").GetProperty("reason").GetString()); + Assert.StartsWith( + "run_", + manifestRoot.GetProperty("provenance").GetProperty("run_id").GetString(), + StringComparison.Ordinal); Assert.Equal( LastFailureEventStore.MaxEventBytes, manifestRoot.GetProperty("limits").GetProperty("max_last_failure_event_bytes").GetInt32()); @@ -279,6 +305,12 @@ public void Run_AfterCurrentProcessFailure_IncludesBoundedRedactedFailureWithout const string validDiagnostics = "exception[0] type=System.InvalidOperationException message=\"invalid_operation\"\n" + " stack: at CodeIndex.Tests.ReportCommandRunnerTests.SyntheticFailure()"; + var storedFailureProvenance = LastFailureEventStore.CreateReportProvenance( + failureDbPath, + "1.38.0-test", + DateTimeOffset.UtcNow, + LastFailureEventStore.CreateRunId(), + secretArgument); var storedFailure = new LastFailureEvent( LastFailureEventStore.SchemaVersion, DateTimeOffset.UtcNow.ToString("O"), @@ -292,7 +324,10 @@ public void Run_AfterCurrentProcessFailure_IncludesBoundedRedactedFailureWithout "invalid_operation", validDiagnostics, PathsRedacted: true, - LiteralArgumentsIncluded: false); + LiteralArgumentsIncluded: false, + storedFailureProvenance.WorkspaceId, + storedFailureProvenance.DatabaseId, + storedFailureProvenance.RunId); var unsafeStoredFailures = new[] { storedFailure with @@ -312,7 +347,10 @@ storedFailure with LastFailureEventJsonContext.Default.LastFailureEvent); DataDirectorySecurity.WritePrivateText(savedFailurePath, unsafeJson + "\n"); - Assert.False(LastFailureEventStore.TryBuildReportPayload(out var rejectedPayload)); + Assert.False(LastFailureEventStore.TryBuildReportPayload( + storedFailureProvenance, + out var rejectedPayload, + out _)); Assert.Equal(string.Empty, rejectedPayload); } } @@ -324,6 +362,181 @@ storedFailure with static void ThrowCurrentProcessFailure() => throw new InvalidOperationException(secretMessage); } + [Fact] + public void BuildBundle_LastFailureRequiresCurrentMatchingProvenance_Issue4828() + { + var workDir = CreateWorkDir(); + var logDir = CreateLogDir(workDir); + var dbPath = Path.Combine(workDir, ".cdidx", "codeindex.db"); + var otherWorkspace = Path.Combine(workDir, "other-workspace"); + var otherDbPath = Path.Combine(otherWorkspace, ".cdidx", "codeindex.db"); + var reportTimestamp = new DateTimeOffset(2026, 7, 26, 12, 0, 0, TimeSpan.Zero); + const string version = "1.40.3-test"; + try + { + Directory.CreateDirectory(otherWorkspace); + using var env = EnvironmentVariableScope.Capture("CDIDX_GLOBAL_TOOL_LOG_DIR"); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logDir); + + var reportProvenance = LastFailureEventStore.CreateReportProvenance( + dbPath, + version, + reportTimestamp, + LastFailureEventStore.CreateRunId(), + workDir); + var otherWorkspaceProvenance = LastFailureEventStore.CreateReportProvenance( + otherDbPath, + version, + reportTimestamp, + LastFailureEventStore.CreateRunId(), + otherWorkspace); + var otherDatabaseProvenance = LastFailureEventStore.CreateReportProvenance( + Path.Combine(workDir, ".cdidx", "other.db"), + version, + reportTimestamp, + LastFailureEventStore.CreateRunId(), + workDir); + var currentFailure = CreateStoredFailure( + reportProvenance, + reportTimestamp - TimeSpan.FromMinutes(1)); + var cases = new[] + { + new + { + Name = "current", + Event = currentFailure, + Included = true, + Disposition = "included", + Reason = "matched", + }, + new + { + Name = "stale", + Event = currentFailure with + { + OccurredAtUtc = (reportTimestamp - LastFailureEventStore.MaxReportCorrelationAge - TimeSpan.FromSeconds(1)).ToString("O"), + }, + Included = false, + Disposition = "excluded", + Reason = "stale", + }, + new + { + Name = "cross-workspace", + Event = currentFailure with + { + WorkspaceId = otherWorkspaceProvenance.WorkspaceId, + }, + Included = false, + Disposition = "excluded", + Reason = "workspace_mismatch", + }, + new + { + Name = "cross-database", + Event = currentFailure with + { + DatabaseId = otherDatabaseProvenance.DatabaseId, + }, + Included = false, + Disposition = "excluded", + Reason = "database_mismatch", + }, + new + { + Name = "version-mismatch", + Event = currentFailure with + { + BinaryVersion = "1.10.0", + }, + Included = false, + Disposition = "excluded", + Reason = "binary_version_mismatch", + }, + new + { + Name = "missing-provenance", + Event = currentFailure with + { + SchemaVersion = LastFailureEventStore.SchemaVersion - 1, + WorkspaceId = null, + DatabaseId = null, + RunId = null, + }, + Included = false, + Disposition = "excluded", + Reason = "missing_provenance", + }, + new + { + Name = "unsafe-provenance", + Event = currentFailure with + { + WorkspaceId = workDir, + }, + Included = false, + Disposition = "excluded", + Reason = "invalid_provenance", + }, + }; + var options = ReportCommandRunner.ParseArgs([ + "--output", Path.Combine(workDir, "bundle.tgz"), + "--db", dbPath, + "--no-log", + ]); + options.ProvenanceWorkspacePathForTesting = workDir; + var savedFailurePath = Path.Combine(logDir, LastFailureEventStore.FileName); + + foreach (var testCase in cases) + { + var json = JsonSerializer.Serialize( + testCase.Event, + LastFailureEventJsonContext.Default.LastFailureEvent); + DataDirectorySecurity.WritePrivateText(savedFailurePath, json + "\n"); + + var bundle = ReportCommandRunner.BuildBundle( + options, + version, + reportTimestamp, + reportProvenance.RunId); + + Assert.Equal(testCase.Included, bundle.LastFailureIncluded); + Assert.Equal(testCase.Disposition, bundle.LastFailureEvidence.Disposition); + Assert.Equal(testCase.Reason, bundle.LastFailureEvidence.Reason); + Assert.Equal( + testCase.Included, + bundle.Files.Any(static file => file.Name == LastFailureEventStore.FileName)); + Assert.DoesNotContain(workDir, JsonSerializer.Serialize(bundle.LastFailureEvidence), StringComparison.Ordinal); + } + } + finally + { + TestProjectHelper.DeleteDirectory(workDir); + } + + static LastFailureEvent CreateStoredFailure( + ReportProvenance provenance, + DateTimeOffset occurredAtUtc) => + new( + LastFailureEventStore.SchemaVersion, + occurredAtUtc.ToString("O"), + provenance.BinaryVersion, + "cdidx.dll", + "dotnet", + "index", + CommandExitCodes.UnhandledException, + "invalid_operation", + typeof(InvalidOperationException).FullName!, + "invalid_operation", + "exception[0] type=System.InvalidOperationException message=\"invalid_operation\"\n" + + " stack: at CodeIndex.Tests.ReportCommandRunnerTests.SyntheticFailure()", + PathsRedacted: true, + LiteralArgumentsIncluded: false, + provenance.WorkspaceId, + provenance.DatabaseId, + LastFailureEventStore.CreateRunId()); + } + [Fact] public void Run_OutputArchiveAndEntriesUseOwnerOnlyPermissions() { @@ -357,6 +570,42 @@ public void Run_OutputArchiveAndEntriesUseOwnerOnlyPermissions() } } + [Fact] + public void Run_ExistingOutputRequiresOverwriteAndOptInReplacesIt_Issue4828() + { + var workDir = CreateWorkDir(); + try + { + var output = Path.Combine(workDir, "bundle.tgz"); + File.WriteAllText(output, "existing bundle"); + var args = new[] + { + "--output", output, + "--db", Path.Combine(workDir, "missing.db"), + "--no-log", + }; + + var (refusedExitCode, _, refusedStderr) = RunAndCaptureStreams(args); + + Assert.Equal(CommandExitCodes.UsageError, refusedExitCode); + Assert.Contains("--overwrite", refusedStderr, StringComparison.Ordinal); + Assert.Equal("existing bundle", File.ReadAllText(output)); + Assert.Single(Directory.GetFiles(workDir)); + + var overwriteArgs = args.Append("--overwrite").ToArray(); + var (overwriteExitCode, _, overwriteStderr) = RunAndCaptureStreams(overwriteArgs); + + Assert.Equal(CommandExitCodes.Success, overwriteExitCode); + Assert.Equal(string.Empty, overwriteStderr); + Assert.Contains("support-manifest.json", ReadTarGzEntries(output).Keys); + Assert.Single(Directory.GetFiles(workDir)); + } + finally + { + TestProjectHelper.DeleteDirectory(workDir); + } + } + [Fact] public void WriteBundle_FailurePreservesExistingBundle() { @@ -372,6 +621,7 @@ public void WriteBundle_FailurePreservesExistingBundle() ReportCommandRunner.WriteBundle( output, bundle, + overwrite: true, beforeWriteEntries: () => throw new IOException("simulated report failure"))); Assert.Equal("existing bundle", File.ReadAllText(output)); @@ -485,6 +735,14 @@ public void Run_WithRealDb_SchemaTxtListsTablesAndRowCounts() Assert.True(JsonArrayContains( manifest.RootElement.GetProperty("omissions").GetProperty("schema"), "raw_schema_table_names")); + var manifestBundle = manifest.RootElement.GetProperty("bundle"); + Assert.True(manifestBundle.GetProperty("db_inspected").GetBoolean()); + Assert.True(manifestBundle.GetProperty("db_diagnostics_included").GetBoolean()); + Assert.False(manifestBundle.GetProperty("db_member_included").GetBoolean()); + Assert.True(manifestBundle.GetProperty("db_included").GetBoolean()); + Assert.DoesNotContain( + manifestBundle.GetProperty("members").EnumerateArray(), + static item => item.GetString() is "codeindex.db" or "database.db"); var readiness = manifest.RootElement.GetProperty("readiness"); Assert.Equal("database", readiness.GetProperty("source").GetString()); Assert.True(readiness.GetProperty("graph_table_available").GetBoolean()); @@ -1017,10 +1275,15 @@ public void Run_JsonMode_PrintsSummaryEnvelope() Assert.True(JsonArrayContains(json.GetProperty("recommended_extensions"), ".tar.gz")); Assert.True(json.GetProperty("json_metadata_stdout_only").GetBoolean()); Assert.True(json.TryGetProperty("last_failure_included", out _)); + Assert.True(json.TryGetProperty("last_failure_disposition", out _)); + Assert.True(json.TryGetProperty("last_failure_reason", out _)); Assert.Equal("1", json.GetProperty("api_version").GetString()); Assert.Equal(0, json.GetProperty("warnings").GetArrayLength()); Assert.True(json.GetProperty("files").GetInt32() >= 4); Assert.False(json.GetProperty("log_included").GetBoolean()); + Assert.False(json.GetProperty("db_inspected").GetBoolean()); + Assert.False(json.GetProperty("db_diagnostics_included").GetBoolean()); + Assert.False(json.GetProperty("db_member_included").GetBoolean()); Assert.False(json.GetProperty("db_included").GetBoolean()); } finally @@ -1125,6 +1388,9 @@ public void Run_JsonMode_PreservesArtifactNameAndRedactsDiagnosticPaths_Issues35 Assert.Equal(Path.GetFileName(output), json.GetProperty("output_path").GetString()); Assert.Equal(ReportCommandRunner.RedactedPlaceholder, json.GetProperty("db_path").GetString()); Assert.DoesNotContain(workDir, json.GetRawText(), StringComparison.Ordinal); + Assert.True(json.GetProperty("db_inspected").GetBoolean()); + Assert.True(json.GetProperty("db_diagnostics_included").GetBoolean()); + Assert.False(json.GetProperty("db_member_included").GetBoolean()); Assert.True(json.GetProperty("db_included").GetBoolean()); } finally From 96ed4eae1eb57d4ab5a2defb739dfcc029e29708 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Mon, 27 Jul 2026 05:05:59 +0900 Subject: [PATCH 2/3] Address report provenance review findings (#4828) --- DEVELOPER_GUIDE.md | 8 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 8 +- changelog.d/unreleased/4828.security.md | 5 +- src/CodeIndex/Cli/AtomicFileWriter.cs | 83 ++++++++++++ src/CodeIndex/Cli/LastFailureEventStore.cs | 53 +++----- src/CodeIndex/Cli/ReportBundleWriter.cs | 47 ++++--- src/CodeIndex/Cli/ReportCommandRunner.cs | 8 +- .../ReportCommandRunnerTests.cs | 123 ++++++++++++++++++ 9 files changed, 271 insertions(+), 68 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 45fb79248e..201d055b9c 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2945,9 +2945,9 @@ The flag parser (`ProgramRunner.TryConsumeAuditLogFlags`) is run before `QueryCo ## Report artifact contract -`ReportBundleWriter` stages a complete gzip/tar sibling file and publishes it through `AtomicFileWriter`. Publication uses a no-overwrite move unless `ReportCommandOptions.Overwrite` came from an explicit `--overwrite`; collection and staging failures therefore leave an existing destination unchanged. `support-manifest.json.bundle.members` is the authoritative archive member list. `db_inspected` and `db_diagnostics_included` describe read-only diagnostic collection, while `db_member_included` describes archive membership and is currently always `false`. The legacy `db_included` field remains an additive-compatibility alias for `db_inspected`. +`ReportBundleWriter` stages a complete gzip/tar sibling file and publishes it through `AtomicFileWriter`. Publication uses a no-overwrite move unless `ReportCommandOptions.Overwrite` came from an explicit `--overwrite`. Explicit replacement uses an atomic filesystem backup and retains it through the parent-directory durability flush; a publication failure restores that backup before surfacing the error. `support-manifest.json.bundle.members` is the authoritative archive member list. `db_inspected` and `db_diagnostics_included` describe read-only diagnostic collection, while `db_member_included` describes archive membership and is currently always `false`. The legacy `db_included` field remains an additive-compatibility alias for `db_inspected`. -`LastFailureEventStore` schema 3 adds opaque `workspace_id`, `database_id`, and `run_id` provenance to the existing binary version and UTC timestamp. A report includes the saved event only when workspace, database, and binary version match and the event is no more than 24 hours old, allowing at most five minutes of future clock skew. Other records are excluded from the archive; the manifest publishes only a bounded `last_failure.disposition` / `reason` plus validated opaque provenance fields. Raw workspace and database paths never enter those fields. +`LastFailureEventStore` schema 3 adds opaque `workspace_id`, `database_id`, and `run_id` provenance to the existing binary version and UTC timestamp. Failure capture derives the effective database from the same parsed index or query options used by the command, including positional ordering and the `--` literal sentinel. Report resolves its default database through the normal query precedence (`CDIDX_DATA_DIR`, active workspace, XDG, and ancestor workspace), so collection and correlation use the same database identity. A report includes the saved event only when workspace, database, and binary version match and the event is no more than 24 hours old, allowing at most five minutes of future clock skew. Other records are excluded from the archive; the manifest publishes only a bounded `last_failure.disposition` / `reason` plus validated opaque provenance fields. Raw workspace and database paths never enter those fields. ## Coding conventions @@ -5500,9 +5500,9 @@ caller と operator が依存できる契約: ## report artifact 契約 -`ReportBundleWriter` は完全な gzip/tar sibling file を staging し、`AtomicFileWriter` で公開します。`ReportCommandOptions.Overwrite` が明示的な `--overwrite` から設定されていない限り no-overwrite move を使うため、収集・staging の失敗時には既存 destination を変更しません。`support-manifest.json.bundle.members` が archive member の正式な一覧です。`db_inspected` と `db_diagnostics_included` は read-only の診断収集を表し、`db_member_included` は archive membership を表して現在は常に `false` です。legacy の `db_included` は `db_inspected` の additive compatibility alias として残します。 +`ReportBundleWriter` は完全な gzip/tar sibling file を staging し、`AtomicFileWriter` で公開します。`ReportCommandOptions.Overwrite` が明示的な `--overwrite` から設定されていない限り no-overwrite move を使います。明示的な置換では atomic な filesystem backup を作り、親 directory の durability flush が完了するまで保持します。公開に失敗した場合は error を返す前にその backup を復元します。`support-manifest.json.bundle.members` が archive member の正式な一覧です。`db_inspected` と `db_diagnostics_included` は read-only の診断収集を表し、`db_member_included` は archive membership を表して現在は常に `false` です。legacy の `db_included` は `db_inspected` の additive compatibility alias として残します。 -`LastFailureEventStore` schema 3 は、従来の binary version と UTC timestamp に加えて、不透明な `workspace_id`、`database_id`、`run_id` provenance を持ちます。report は workspace、database、binary version が一致し、event が24時間以内で、未来方向の clock skew が5分以内の場合だけ保存 event を同梱します。それ以外は archive から除外し、manifest には上限付きの `last_failure.disposition` / `reason` と、検証済みの不透明 provenance field だけを出力します。workspace / database の raw path はこれらの field に入りません。 +`LastFailureEventStore` schema 3 は、従来の binary version と UTC timestamp に加えて、不透明な `workspace_id`、`database_id`、`run_id` provenance を持ちます。failure capture は command が実際に使った index / query option parser から実効 DB を導出し、positional ordering と `--` literal sentinel も同じ規則で扱います。report の既定 DB は通常の query precedence(`CDIDX_DATA_DIR`、active workspace、XDG、ancestor workspace)で解決するため、診断収集と correlation は同じ database identity を使います。report は workspace、database、binary version が一致し、event が24時間以内で、未来方向の clock skew が5分以内の場合だけ保存 event を同梱します。それ以外は archive から除外し、manifest には上限付きの `last_failure.disposition` / `reason` と、検証済みの不透明 provenance field だけを出力します。workspace / database の raw path はこれらの field に入りません。 ## コーディング規約 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 37700da946..89cd9e80f9 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -566,7 +566,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `ReportCommandRunnerTests.cs` Report log-tail fixtures should use the local log-directory and log-file helpers instead of repeating `Path.Combine(workDir, "logs")`, `Directory.CreateDirectory`, and ad hoc `File.WriteAllText` setup. JSON summary coverage keeps `output_path` as the artifact basename with and without `--redact-paths`, while asserting that diagnostic paths and parent directories remain redacted. - Keep no-overwrite refusal and explicit replacement in one shared-fixture test, and inject publication failure after staging to prove an existing bundle survives every pre-publication failure. Provenance coverage should reuse one saved-event fixture for current, stale, cross-workspace, cross-database, and missing/unsafe-provenance variants; assert both archive membership and manifest disposition/reason on `net8.0` and `net9.0`. + Keep no-overwrite refusal and explicit replacement in one shared-fixture test. Inject both a staging failure and a post-replace parent-directory flush failure to prove an existing bundle survives every reported failure path. Provenance coverage should reuse one saved-event fixture for current, stale, cross-workspace, cross-database, and missing/unsafe-provenance variants; also pin default report DB precedence, option-before-project index parsing, and the query `--` literal sentinel. Assert both archive membership and manifest disposition/reason on `net8.0` and `net9.0`. - `PostExtractionHookTests.cs` Post-extraction hook discovery, mutation, diagnostics, callback budgets, and collectible hook assembly cleanup. Heavy hook worker and collectible assembly-load integration tests use `ProductionRuntimeFactAttribute` and run only on the `net8.0` production target, while direct worker protocol and metadata tests remain cross-target. Timed-out and canceled callback tests use a hook delay shorter than their leak-observation window, not a full one-second absence check, so worker-kill regressions still write the completion marker before the assertion exits. Duplicate-hook isolation coverage copies the dedicated `CodeIndex.HookIsolationFixture` assembly twice, so only the two hooks under test launch callback workers; the healthy worker retains a bounded one-second startup-and-callback budget while the selected slow hook blocks for 30 seconds. These tests mutate hook-related environment variables and test-only callback budget state, so the class belongs to the `SQLite pool sensitive` non-parallel collection. Keep the timed-out hook delay well beyond the callback budget while still below its bounded leak-observation window, so a loaded runner cannot let the hook finish at the timeout boundary and worker-kill regressions remain observable. @@ -1443,7 +1443,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ReportCommandRunnerTests.cs` report log-tail fixture では、`Path.Combine(workDir, "logs")`、`Directory.CreateDirectory`、ad hoc な `File.WriteAllText` setup を繰り返さず、ローカルの log directory / log file helper を使ってください。 JSON summary の coverage では、`--redact-paths` の有無にかかわらず `output_path` が artifact の basename を保持し、診断用 path と親 directory は伏字化されたままであることを確認します。 - no-overwrite の拒否と明示的な置換は shared fixture を使う1つの test にまとめ、staging 後に公開失敗を注入して、公開前の全 failure で既存 bundle が残ることを証明してください。provenance coverage は current、stale、cross-workspace、cross-database、missing / unsafe provenance で1つの保存 event fixture を再利用し、`net8.0` / `net9.0` の両方で archive membership と manifest の disposition / reason を検証します。 + no-overwrite の拒否と明示的な置換は shared fixture を使う1つの test にまとめます。staging failure と置換後の parent-directory flush failure の両方を注入し、報告されるすべての failure path で既存 bundle が残ることを証明してください。provenance coverage は current、stale、cross-workspace、cross-database、missing / unsafe provenance で1つの保存 event fixture を再利用し、report の既定 DB precedence、option-before-project の index parsing、query の `--` literal sentinel も固定します。`net8.0` / `net9.0` の両方で archive membership と manifest の disposition / reason を検証します。 - `PostExtractionHookTests.cs` post-extraction hook の discovery、mutation、diagnostics、callback budget、collectible hook assembly cleanup のテスト。重い hook worker と collectible assembly-load の integration test は `ProductionRuntimeFactAttribute` を使って `net8.0` production target でのみ実行し、direct worker protocol と metadata test は cross-target のままにします。timeout / cancel された callback のテストは、hook delay を leak-observation window より短くし、1 秒丸ごとの absence check には戻しません。worker kill の回帰がある場合は assertion が終わる前に completion marker が書かれるようにします。duplicate-hook isolation coverage では専用の `CodeIndex.HookIsolationFixture` assembly を 2 つの名前で copy し、対象の 2 hook だけが callback worker を起動するようにします。正常な worker には bounded な 1 秒の startup-and-callback budget を残し、選択した slow hook は 30 秒 block させます。hook 関連の環境変数と test-only callback budget 状態を変更するため、このクラスは non-parallel な `SQLite pool sensitive` collection に入れます。 timeout 対象 hook の delay は callback budget より十分長く、かつ bounded な leak-observation window より短く保ってください。高負荷 runner で hook が timeout 境界上に完了する競合を避けながら、worker kill の回帰を観測可能にします。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 759e6b2b7e..f3ffc711e7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1795,7 +1795,7 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output <path>` packages a redacted gzip-compressed tar archive you can attach to a GitHub issue. Use `.tgz` or `.tar.gz`; if the output path has a misleading extension such as `.json`, the command still writes the archive but warns on stderr and records the warning in JSON summary metadata. Existing output is refused by default and remains unchanged on collection or staging failure; pass `--overwrite` only when replacement is intentional. `--json` only changes the command summary written to stdout; it does not make the output artifact JSON. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no table row contents). +`cdidx report --output <path>` packages a redacted gzip-compressed tar archive you can attach to a GitHub issue. Use `.tgz` or `.tar.gz`; if the output path has a misleading extension such as `.json`, the command still writes the archive but warns on stderr and records the warning in JSON summary metadata. Existing output is refused by default. With explicit `--overwrite`, the complete replacement is staged and the previous bundle is retained as rollback evidence until publication is durable, so a reported collection, staging, replacement, or durability failure leaves the old destination in place. `--json` only changes the command summary written to stdout; it does not make the output artifact JSON. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no table row contents). When an unhandled command failure tells you to run `cdidx report`, cdidx first saves a bounded, redacted event. A report includes that event as `last-failure.json` only when its opaque workspace and database identities and binary version match the report context, its timestamp is no more than 24 hours old (with a five-minute future-clock allowance), and all provenance fields are valid. Stale, cross-workspace, cross-database, version-mismatched, and legacy records without provenance are excluded; `support-manifest.json.last_failure` records the `disposition` and bounded machine-readable `reason`. The event records the failure timestamp, binary version and sanitized path, command category, exit code, exception category/type, sanitized diagnostics, and an opaque source run ID; it never records literal command arguments or raw provenance paths. @@ -1805,7 +1805,7 @@ The bundle also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), wi |---|---|---| | `--output <path>` / `-o <path>` | (required) | Destination gzip-compressed tar bundle; `.tgz` or `.tar.gz` is recommended. The directory is created if missing; an existing file is refused unless `--overwrite` is present. On POSIX, the archive and tar entries are owner-readable/writable only. | | `--overwrite` | | Atomically replace an existing output after the complete new bundle has been staged. Without this flag, existing output is preserved. | -| `--db <path>` | `.cdidx/codeindex.db` | Override the database whose schema is summarized. If absent, `schema.txt` records that no DB was found. Schema summaries cap table entries at 64, displayed table names at 96 characters, and row-count scans at 1000 rows per table. | +| `--db <path>` | query DB selection | Override the database whose schema is summarized. Without this flag, report uses the same `CDIDX_DATA_DIR`, active-workspace, XDG, and ancestor-workspace selection as query commands, so diagnostics and failure provenance describe the same effective database. If the selected database does not exist, `schema.txt` records that no DB was found. Schema summaries cap table entries at 64, displayed table names at 96 characters, and row-count scans at 1000 rows per table. | | `--log-lines <n>` | `200` | How many trailing lifecycle-log lines to include (`0` disables the tail; values above `2000` are clamped). Report collection considers at most the 32 newest lifecycle log files; each file contributes from a bounded 1,048,576-byte tail window instead of being loaded fully. | | `--no-log` | | Skip the lifecycle log entirely. A saved `last-failure.json` event is evaluated independently and is included only when its provenance matches. | | `--include-args` | | Keep literal non-path command arguments in `args=` log fields (opt-in; share only with trusted recipients). Path-bearing values such as `cwd=` remain redacted. | @@ -4856,7 +4856,7 @@ cdidx report --output report.tgz cdidx report --output report.tgz --json ``` -`cdidx report --output <path>` は GitHub Issue に添付できる匿名化済みの gzip 圧縮 tar archive を生成します。`.tgz` または `.tar.gz` を使ってください。出力先が `.json` のような誤解を招く拡張子でも archive は書き出されますが、stderr に warning が出力され、JSON summary metadata の `warnings` にも記録されます。既存の出力先は既定で拒否され、収集・staging の失敗時にも内容は変わりません。意図的に置換する場合だけ `--overwrite` を指定してください。`--json` は stdout に出す command summary だけを JSON にし、出力 artifact 自体を JSON にするものではありません。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(table の行内容は含まれません)が入ります。 +`cdidx report --output <path>` は GitHub Issue に添付できる匿名化済みの gzip 圧縮 tar archive を生成します。`.tgz` または `.tar.gz` を使ってください。出力先が `.json` のような誤解を招く拡張子でも archive は書き出されますが、stderr に warning が出力され、JSON summary metadata の `warnings` にも記録されます。既存の出力先は既定で拒否されます。明示的な `--overwrite` では完全な置換 bundle を staging し、公開の durability が確認できるまで旧 bundle を rollback evidence として保持するため、収集、staging、置換、durability の failure が報告された場合は旧 destination が残ります。`--json` は stdout に出す command summary だけを JSON にし、出力 artifact 自体を JSON にするものではありません。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(table の行内容は含まれません)が入ります。 想定外の command failure が `cdidx report` の実行を案内する場合、cdidx はその案内より先に上限付き・匿名化済みのイベントを保存します。report は、不透明な workspace / database identity と binary version が report context に一致し、timestamp が24時間以内(未来方向は clock skew として5分まで許容)で、すべての provenance field が有効な場合だけ、その event を `last-failure.json` として含めます。stale、cross-workspace、cross-database、version mismatch、provenance を持たない legacy record は除外され、`support-manifest.json.last_failure` の `disposition` と上限付きの machine-readable `reason` に結果が記録されます。この event には失敗時刻、binary version と匿名化済み path、command category、exit code、exception category / type、匿名化済み diagnostics、不透明な source run ID を記録し、具体的な command 引数や provenance の raw path は記録しません。 @@ -4866,7 +4866,7 @@ cdidx report --output report.tgz --json |---|---|---| | `--output <path>` / `-o <path>` | (必須) | 出力先の gzip 圧縮 tar bundle。`.tgz` または `.tar.gz` を推奨します。親ディレクトリが無ければ作成し、既存 file は `--overwrite` が無ければ拒否します。POSIX では archive と tar entry は owner の読み書きのみになります。 | | `--overwrite` | | 完全な新規 bundle を staging した後、既存出力を原子的に置換します。この flag が無い場合は既存出力を維持します。 | -| `--db <path>` | `.cdidx/codeindex.db` | スキーマ要約対象の DB を上書きします。存在しなければ `schema.txt` に「DB が見つからなかった」旨が記録されます。スキーマ要約は table entry を 64 件、表示 table 名を 96 文字、行数 scan を table ごとに 1000 行までに制限します。 | +| `--db <path>` | query DB 選択規則 | スキーマ要約対象の DB を上書きします。この flag が無い場合、report は query command と同じ `CDIDX_DATA_DIR`、active workspace、XDG、ancestor workspace の選択規則を使うため、診断と failure provenance は同じ実効 DB を表します。選択された DB が存在しなければ `schema.txt` に「DB が見つからなかった」旨が記録されます。スキーマ要約は table entry を 64 件、表示 table 名を 96 文字、行数 scan を table ごとに 1000 行までに制限します。 | | `--log-lines <n>` | `200` | ライフサイクルログ末尾を何行含めるか(`0` で末尾を含めません。`2000` を超える値は clamp されます)。report 収集は最新 32 件までの lifecycle log file を対象にし、各ログファイルは全体を読み込まず、末尾 1,048,576 byte の範囲から収集します。 | | `--no-log` | | ライフサイクルログを完全に省略します。保存済み `last-failure.json` event は独立して評価され、provenance が一致する場合だけ同梱されます。 | | `--include-args` | | ログ末尾の `args=` field にある path 以外の command 引数をそのまま含めます(信頼できる相手にだけ使用してください)。`cwd=` など path を含む値は引き続き伏字化します。 | diff --git a/changelog.d/unreleased/4828.security.md b/changelog.d/unreleased/4828.security.md index 672b8d2e17..431f5a01ea 100644 --- a/changelog.d/unreleased/4828.security.md +++ b/changelog.d/unreleased/4828.security.md @@ -5,14 +5,15 @@ issues: affected: - src/CodeIndex/Cli/ReportCommandRunner.cs - src/CodeIndex/Cli/ReportBundleWriter.cs + - src/CodeIndex/Cli/AtomicFileWriter.cs - src/CodeIndex/Cli/LastFailureEventStore.cs - USER_GUIDE.md --- ## English -- **Report bundles are overwrite-safe, provenance-scoped, and explicit about database membership (#4828)** — `cdidx report` now refuses an existing output unless `--overwrite` is supplied, correlates saved failures by opaque workspace/database identity, binary version, and timestamp while validating and disclosing their run ID, lists every archive member, and separates database inspection/diagnostics from the always-false database-member field while retaining `db_included` as a compatibility alias. +- **Report bundles are overwrite-safe, provenance-scoped, and explicit about database membership (#4828)** — `cdidx report` now refuses an existing output unless `--overwrite` is supplied, restores the previous bundle if atomic replacement cannot be durably published, derives failure/report provenance from the effective parsed database selection, correlates saved failures by opaque workspace/database identity, binary version, and timestamp while validating and disclosing their run ID, lists every archive member, and separates database inspection/diagnostics from the always-false database-member field while retaining `db_included` as a compatibility alias. ## 日本語 -- **report bundle を上書き安全・provenance 限定にし、DB membership を明確化しました (#4828)** — `cdidx report` は `--overwrite` が無ければ既存出力を拒否し、保存済み failure を同梱する前に不透明な workspace / database identity、binary version、timestamp で相関し、run ID を検証・開示します。また全 archive member を列挙し、DB の検査・診断と常に false の DB member field を分離しつつ、`db_included` は互換 alias として維持します。 +- **report bundle を上書き安全・provenance 限定にし、DB membership を明確化しました (#4828)** — `cdidx report` は `--overwrite` が無ければ既存出力を拒否し、atomic replacement を durable に公開できなければ旧 bundle を復元します。failure / report provenance は実際に parse・選択された DB から導出し、保存済み failure を同梱する前に不透明な workspace / database identity、binary version、timestamp で相関して run ID を検証・開示します。また全 archive member を列挙し、DB の検査・診断と常に false の DB member field を分離しつつ、`db_included` は互換 alias として維持します。 diff --git a/src/CodeIndex/Cli/AtomicFileWriter.cs b/src/CodeIndex/Cli/AtomicFileWriter.cs index fda42e69a0..0424160505 100644 --- a/src/CodeIndex/Cli/AtomicFileWriter.cs +++ b/src/CodeIndex/Cli/AtomicFileWriter.cs @@ -89,6 +89,89 @@ public static void Write(string path, Action<Stream> writeContents, WriteProfile public static void Write(string path, Action<Stream> writeContents, WriteProfile profile, bool overwrite) => WriteCore(path, writeContents, ResolveProfileModeCallback(profile), profile, overwrite); + internal static void WritePreservingExistingOnFailure( + string path, + Action<Stream> writeContents, + WriteProfile profile) + { + ArgumentNullException.ThrowIfNull(writeContents); + + var tempPath = BuildTempPath(path); + var ioTempPath = LongPath.EnsureWindowsPrefix(tempPath); + var backupPath = BuildTempPath(path); + var ioBackupPath = LongPath.EnsureWindowsPrefix(backupPath); + var applyFileMode = ResolveProfileModeCallback(profile); + var tempPublished = false; + + try + { + using (var stream = CreateTempFile(ioTempPath, profile)) + { + applyFileMode?.Invoke(ioTempPath); + writeContents(stream); + stream.Flush(flushToDisk: true); + } + + if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) + { + try + { + MoveFileCore(tempPath, path, overwrite: false, applyDestinationMode: null); + tempPublished = true; + FlushParentDirectoryAfterCreate(path); + return; + } + catch (IOException) when (File.Exists(LongPath.EnsureWindowsPrefix(path))) + { + // A destination appeared after the existence check. Explicit overwrite + // still replaces it, while retaining rollback evidence. + // existence check 後に destination が作成された場合も、明示的な + // overwrite として rollback 用 evidence を保持して置換する。 + } + } + + File.Replace( + ioTempPath, + LongPath.EnsureWindowsPrefix(path), + ioBackupPath, + ignoreMetadataErrors: true); + tempPublished = true; + + try + { + FlushParentDirectoryAfterReplace(path); + DeleteFileIfExists(backupPath); + } + catch (Exception publishException) when (IsRecoverableFileMutationException(publishException)) + { + try + { + File.Replace( + ioBackupPath, + LongPath.EnsureWindowsPrefix(path), + destinationBackupFileName: null, + ignoreMetadataErrors: true); + } + catch (Exception rollbackException) when (IsRecoverableFileMutationException(rollbackException)) + { + throw new IOException( + $"Atomic report replacement failed and the previous destination could not be restored at {ConsoleUi.FormatBoundedValue(path)}.", + new AggregateException(publishException, rollbackException)); + } + + throw new IOException( + $"Atomic report replacement failed; the previous destination was restored at {ConsoleUi.FormatBoundedValue(path)}.", + publishException); + } + } + catch + { + if (!tempPublished) + TryDeleteFile(ioTempPath); + throw; + } + } + private static void WriteCore( string path, Action<Stream> writeContents, diff --git a/src/CodeIndex/Cli/LastFailureEventStore.cs b/src/CodeIndex/Cli/LastFailureEventStore.cs index f9810a2ff1..6dab940363 100644 --- a/src/CodeIndex/Cli/LastFailureEventStore.cs +++ b/src/CodeIndex/Cli/LastFailureEventStore.cs @@ -323,49 +323,28 @@ private static bool IsOpaqueIdentity(string? value, string prefix) return true; } - private static string ResolveFailureDbPath(IReadOnlyList<string> args) + internal static string ResolveFailureDbPath(IReadOnlyList<string> args) { - var explicitDbPath = TryReadOptionValue(args, "--db"); - var explicitDataDir = TryReadOptionValue(args, "--data-dir"); var workspacePath = Environment.CurrentDirectory; if (string.Equals(ResolveCommandCategory(args), "index", StringComparison.Ordinal)) { - var projectPath = ResolveIndexProjectPath(args) ?? workspacePath; - return DbPathResolver.ResolveForIndex(projectPath, explicitDbPath, explicitDataDir).DbPath; + var indexArgs = args.Count > 0 && string.Equals(args[0], "index", StringComparison.Ordinal) + ? args.Skip(1).ToArray() + : args.ToArray(); + var options = IndexCommandRunner.ParseArgs(indexArgs); + return DbPathResolver.ResolveForIndex( + options.ProjectPath ?? workspacePath, + options.DbPath, + options.DataDir).DbPath; } - return DbPathResolver.ResolveForQuery(workspacePath, explicitDbPath, explicitDataDir).DbPath; - } - - private static string? ResolveIndexProjectPath(IReadOnlyList<string> args) - { - if (args.Count == 0) - return null; - if (!string.Equals(args[0], "index", StringComparison.Ordinal) - && ProgramRunner.IsProjectPathArg(args[0])) - { - return args[0]; - } - if (string.Equals(args[0], "index", StringComparison.Ordinal) - && args.Count > 1 - && ProgramRunner.IsProjectPathArg(args[1])) - { - return args[1]; - } - return null; - } - - private static string? TryReadOptionValue(IReadOnlyList<string> args, string option) - { - for (var index = 0; index < args.Count; index++) - { - if (string.Equals(args[index], option, StringComparison.Ordinal)) - return index + 1 < args.Count ? args[index + 1] : null; - var prefix = option + "="; - if (args[index].StartsWith(prefix, StringComparison.Ordinal)) - return args[index][prefix.Length..]; - } - return null; + var queryArgs = args.Count > 0 ? args.Skip(1).ToArray() : []; + return QueryCommandRunner.ParseArgs( + queryArgs, + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false).DbPath; } private static string ResolveWorkspacePath(string normalizedDbPath) diff --git a/src/CodeIndex/Cli/ReportBundleWriter.cs b/src/CodeIndex/Cli/ReportBundleWriter.cs index 60ca439bc6..da986bcb41 100644 --- a/src/CodeIndex/Cli/ReportBundleWriter.cs +++ b/src/CodeIndex/Cli/ReportBundleWriter.cs @@ -16,26 +16,37 @@ internal static void Write( if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); - AtomicFileWriter.Write( - fullOutputPath, - stream => - { - using var gz = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true); - using var tar = new TarWriter(gz, TarEntryFormat.Pax, leaveOpen: true); - beforeWriteEntries?.Invoke(); + void WriteContents(Stream stream) + { + using var gz = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true); + using var tar = new TarWriter(gz, TarEntryFormat.Pax, leaveOpen: true); + beforeWriteEntries?.Invoke(); - foreach (var (name, bytes) in bundle.Files) + foreach (var (name, bytes) in bundle.Files) + { + var entry = new PaxTarEntry(TarEntryType.RegularFile, name) { - var entry = new PaxTarEntry(TarEntryType.RegularFile, name) - { - DataStream = new MemoryStream(bytes, writable: false), - Mode = ReportCommandRunner.BundleFileMode, - ModificationTime = ReportCommandRunner.BundleEntryModificationTime, - }; - tar.WriteEntry(entry); - } - }, + DataStream = new MemoryStream(bytes, writable: false), + Mode = ReportCommandRunner.BundleFileMode, + ModificationTime = ReportCommandRunner.BundleEntryModificationTime, + }; + tar.WriteEntry(entry); + } + } + + if (overwrite) + { + AtomicFileWriter.WritePreservingExistingOnFailure( + fullOutputPath, + WriteContents, + AtomicFileWriter.WriteProfile.Sensitive); + return; + } + + AtomicFileWriter.Write( + fullOutputPath, + WriteContents, AtomicFileWriter.WriteProfile.Sensitive, - overwrite); + overwrite: false); } } diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index 936b25c26d..c86b2cf83b 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -221,7 +221,11 @@ internal static ReportBundle BuildBundle( $"process-architecture: {RuntimeInformation.ProcessArchitecture}", "") + "\n"); - var (schemaText, tables, dbPath, dbIncluded, schemaTablesTruncated) = BuildSchemaSummary(options.DbPath); + var effectiveDbPath = DbPathResolver.ResolveForQuery( + Environment.CurrentDirectory, + options.DbPathExplicit ? options.DbPath : null, + explicitDataDir: null).DbPath; + var (schemaText, tables, dbPath, dbIncluded, schemaTablesTruncated) = BuildSchemaSummary(effectiveDbPath); bundle.SchemaTables = tables; bundle.SchemaTablesTruncated = schemaTablesTruncated; bundle.DbIncluded = dbIncluded; @@ -728,6 +732,7 @@ internal static ReportCommandOptions ParseArgs(string[] args) { case "--db" when i + 1 < args.Length: options.DbPath = args[++i]; + options.DbPathExplicit = true; break; case "--db": options.ParseError = "--db requires a value"; @@ -788,6 +793,7 @@ private static int WriteCommandError(bool json, JsonSerializerOptions jsonOption internal sealed class ReportCommandOptions { public string DbPath { get; set; } = string.Empty; + public bool DbPathExplicit { get; set; } public string? OutputPath { get; set; } public bool Json { get; set; } public bool ShowHelp { get; set; } diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index d95a1fcb5a..6b241eb72c 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -537,6 +537,96 @@ static LastFailureEvent CreateStoredFailure( LastFailureEventStore.CreateRunId()); } + [Fact] + public void BuildBundle_DefaultDatabaseUsesQueryResolutionForDiagnosticsAndProvenance_Issue4828() + { + var workDir = CreateWorkDir(); + var dataDir = Path.Combine(workDir, "data"); + var dbPath = Path.Combine(dataDir, "codeindex.db"); + try + { + Directory.CreateDirectory(dataDir); + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + using var env = EnvironmentVariableScope.Capture(DbPathResolver.DataDirEnvironmentVariable); + env.Set(DbPathResolver.DataDirEnvironmentVariable, dataDir); + var options = ReportCommandRunner.ParseArgs(["--output", "bundle.tgz", "--no-log"]); + var timestamp = new DateTimeOffset(2026, 7, 27, 1, 2, 3, TimeSpan.Zero); + var runId = LastFailureEventStore.CreateRunId(); + + var bundle = ReportCommandRunner.BuildBundle(options, "1.40.3-test", timestamp, runId); + var expectedProvenance = LastFailureEventStore.CreateReportProvenance( + dbPath, + "1.40.3-test", + timestamp, + runId); + + Assert.True(bundle.DbIncluded); + Assert.Equal(Path.GetFullPath(dbPath), bundle.DbPath); + Assert.Equal(expectedProvenance.DatabaseId, bundle.Provenance.DatabaseId); + Assert.Equal(expectedProvenance.WorkspaceId, bundle.Provenance.WorkspaceId); + } + finally + { + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(workDir); + } + } + + [Fact] + public void ResolveFailureDbPath_UsesParsedIndexProjectAfterOptions_Issue4828() + { + var workDir = CreateWorkDir(); + var projectPath = Path.Combine(workDir, "project"); + try + { + Directory.CreateDirectory(projectPath); + using var env = EnvironmentVariableScope.Capture( + DbPathResolver.DataDirEnvironmentVariable, + "XDG_DATA_HOME"); + env.Set(DbPathResolver.DataDirEnvironmentVariable, null); + env.Set("XDG_DATA_HOME", null); + + var resolved = LastFailureEventStore.ResolveFailureDbPath( + ["index", "--rebuild", projectPath]); + + Assert.Equal( + Path.Combine(Path.GetFullPath(projectPath), ".cdidx", "codeindex.db"), + resolved); + } + finally + { + TestProjectHelper.DeleteDirectory(workDir); + } + } + + [Fact] + public void ResolveFailureDbPath_DoesNotInterpretLiteralQueryAsDatabaseOption_Issue4828() + { + var workDir = CreateWorkDir(); + var literalValue = Path.Combine(workDir, "literal-query-value.db"); + try + { + var expected = QueryCommandRunner.ParseArgs( + ["--", "--db", literalValue], + jsonDefault: false, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false).DbPath; + + var resolved = LastFailureEventStore.ResolveFailureDbPath( + ["search", "--", "--db", literalValue]); + + Assert.Equal(expected, resolved); + Assert.NotEqual(Path.GetFullPath(literalValue), DbPathResolver.NormalizeDbPath(resolved)); + } + finally + { + TestProjectHelper.DeleteDirectory(workDir); + } + } + [Fact] public void Run_OutputArchiveAndEntriesUseOwnerOnlyPermissions() { @@ -598,6 +688,12 @@ public void Run_ExistingOutputRequiresOverwriteAndOptInReplacesIt_Issue4828() Assert.Equal(CommandExitCodes.Success, overwriteExitCode); Assert.Equal(string.Empty, overwriteStderr); Assert.Contains("support-manifest.json", ReadTarGzEntries(output).Keys); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + ReportCommandRunner.BundleFileMode, + File.GetUnixFileMode(output) & PermissionBits); + } Assert.Single(Directory.GetFiles(workDir)); } finally @@ -633,6 +729,33 @@ public void WriteBundle_FailurePreservesExistingBundle() } } + [Fact] + public void WriteBundle_PostReplaceFlushFailureRestoresExistingBundle_Issue4828() + { + var workDir = CreateWorkDir(); + try + { + var output = Path.Combine(workDir, "bundle.tgz"); + File.WriteAllText(output, "existing bundle"); + var bundle = new ReportBundle(); + bundle.AddText("metadata.txt", "replacement"); + AtomicFileWriter.FlushParentDirectoryForTesting = + _ => throw new IOException("simulated directory flush failure"); + + var exception = Assert.Throws<IOException>(() => + ReportCommandRunner.WriteBundle(output, bundle, overwrite: true)); + + Assert.Contains("previous destination was restored", exception.Message, StringComparison.Ordinal); + Assert.Equal("existing bundle", File.ReadAllText(output)); + Assert.Single(Directory.GetFiles(workDir)); + } + finally + { + AtomicFileWriter.FlushParentDirectoryForTesting = null; + TestProjectHelper.DeleteDirectory(workDir); + } + } + [Fact] public void BuildBundle_InjectedTimeControlsMetadataButArchiveEntriesStayStable_Issue3963_Issue3987() { From 55b133c288efd41e8926fa704a920b8735a2794c Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Mon, 27 Jul 2026 06:09:51 +0900 Subject: [PATCH 3/3] Close report provenance review gaps (#4828) --- changelog.d/unreleased/4828.security.md | 4 +- src/CodeIndex/Cli/AtomicFileWriter.cs | 3 +- src/CodeIndex/Cli/LastFailureEventStore.cs | 28 +++- src/CodeIndex/Cli/ReportCommandRunner.cs | 6 +- .../ReportCommandRunnerTests.cs | 137 +++++++++++++++++- 5 files changed, 165 insertions(+), 13 deletions(-) diff --git a/changelog.d/unreleased/4828.security.md b/changelog.d/unreleased/4828.security.md index 431f5a01ea..c45d2be1da 100644 --- a/changelog.d/unreleased/4828.security.md +++ b/changelog.d/unreleased/4828.security.md @@ -12,8 +12,8 @@ affected: ## English -- **Report bundles are overwrite-safe, provenance-scoped, and explicit about database membership (#4828)** — `cdidx report` now refuses an existing output unless `--overwrite` is supplied, restores the previous bundle if atomic replacement cannot be durably published, derives failure/report provenance from the effective parsed database selection, correlates saved failures by opaque workspace/database identity, binary version, and timestamp while validating and disclosing their run ID, lists every archive member, and separates database inspection/diagnostics from the always-false database-member field while retaining `db_included` as a compatibility alias. +- **Report bundles are overwrite-safe, provenance-scoped, and explicit about database membership (#4828)** — `cdidx report` now refuses an existing output unless `--overwrite` is supplied, durably restores the previous bundle if atomic replacement cannot be published, derives failure/report provenance from the effective parsed workspace and database selection, normalizes opaque path identities with the live filesystem case policy, correlates saved failures by workspace/database identity, binary version, and timestamp while validating and disclosing their run ID, lists every archive member, and separates database inspection/diagnostics from the always-false database-member field while retaining `db_included` as a compatibility alias. ## 日本語 -- **report bundle を上書き安全・provenance 限定にし、DB membership を明確化しました (#4828)** — `cdidx report` は `--overwrite` が無ければ既存出力を拒否し、atomic replacement を durable に公開できなければ旧 bundle を復元します。failure / report provenance は実際に parse・選択された DB から導出し、保存済み failure を同梱する前に不透明な workspace / database identity、binary version、timestamp で相関して run ID を検証・開示します。また全 archive member を列挙し、DB の検査・診断と常に false の DB member field を分離しつつ、`db_included` は互換 alias として維持します。 +- **report bundle を上書き安全・provenance 限定にし、DB membership を明確化しました (#4828)** — `cdidx report` は `--overwrite` が無ければ既存出力を拒否し、atomic replacement を公開できなければ旧 bundle を durable に復元します。failure / report provenance は実際に parse・選択された workspace と DB から導出し、不透明な path identity を実 FS の大小区別 policy に従って正規化します。保存済み failure を同梱する前に workspace / database identity、binary version、timestamp で相関して run ID を検証・開示します。また全 archive member を列挙し、DB の検査・診断と常に false の DB member field を分離しつつ、`db_included` は互換 alias として維持します。 diff --git a/src/CodeIndex/Cli/AtomicFileWriter.cs b/src/CodeIndex/Cli/AtomicFileWriter.cs index 0424160505..aecf50dc24 100644 --- a/src/CodeIndex/Cli/AtomicFileWriter.cs +++ b/src/CodeIndex/Cli/AtomicFileWriter.cs @@ -151,11 +151,12 @@ internal static void WritePreservingExistingOnFailure( LongPath.EnsureWindowsPrefix(path), destinationBackupFileName: null, ignoreMetadataErrors: true); + FlushParentDirectoryAfterReplace(path); } catch (Exception rollbackException) when (IsRecoverableFileMutationException(rollbackException)) { throw new IOException( - $"Atomic report replacement failed and the previous destination could not be restored at {ConsoleUi.FormatBoundedValue(path)}.", + $"Atomic report replacement failed and the previous destination could not be restored durably at {ConsoleUi.FormatBoundedValue(path)}.", new AggregateException(publishException, rollbackException)); } diff --git a/src/CodeIndex/Cli/LastFailureEventStore.cs b/src/CodeIndex/Cli/LastFailureEventStore.cs index 6dab940363..c5374d6251 100644 --- a/src/CodeIndex/Cli/LastFailureEventStore.cs +++ b/src/CodeIndex/Cli/LastFailureEventStore.cs @@ -42,12 +42,13 @@ internal static bool TryPersist( try { + var resolvedPaths = ResolveFailureProvenancePaths(args); var provenance = CreateReportProvenance( - dbPathForTesting ?? ResolveFailureDbPath(args), + dbPathForTesting ?? resolvedPaths.DbPath, appVersion, occurredAtUtc, runId ?? CreateRunId(), - workspacePathForTesting); + workspacePathForTesting ?? resolvedPaths.WorkspacePath); var failure = new LastFailureEvent( SchemaVersion, occurredAtUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), @@ -301,8 +302,16 @@ private static ReportLastFailureEvidence BuildEvidence( private static string ComputeOpaquePathIdentity(string domain, string prefix, string path) { // Emit only a bounded opaque fingerprint; raw local paths never enter the event or bundle. + // Case-insensitive filesystems fold path casing before hashing so equivalent spellings + // correlate, while case-sensitive filesystems retain the exact path identity. // 上限付きの不透明 fingerprint だけを出力し、ローカル path 自体は event / bundle に入れない。 - var input = Encoding.UTF8.GetBytes(domain + "\0" + Path.GetFullPath(path)); + // case-insensitive FS では同じ path の大小違いを hash 前に統一し、case-sensitive FS + // では正確な path identity を維持する。 + var normalizedPath = PathCasing.NormalizeBoundaryPath(path); + var identityPath = PathCasing.IsIgnoreCase(normalizedPath) + ? normalizedPath.ToUpperInvariant() + : normalizedPath; + var input = Encoding.UTF8.GetBytes(domain + "\0" + identityPath); var digest = SHA256.HashData(input); return prefix + HexEncoding.ToLowerHexString(digest, 0, OpaqueIdentityBytes); } @@ -324,6 +333,10 @@ private static bool IsOpaqueIdentity(string? value, string prefix) } internal static string ResolveFailureDbPath(IReadOnlyList<string> args) + => ResolveFailureProvenancePaths(args).DbPath; + + internal static (string DbPath, string? WorkspacePath) ResolveFailureProvenancePaths( + IReadOnlyList<string> args) { var workspacePath = Environment.CurrentDirectory; if (string.Equals(ResolveCommandCategory(args), "index", StringComparison.Ordinal)) @@ -332,19 +345,22 @@ internal static string ResolveFailureDbPath(IReadOnlyList<string> args) ? args.Skip(1).ToArray() : args.ToArray(); var options = IndexCommandRunner.ParseArgs(indexArgs); - return DbPathResolver.ResolveForIndex( - options.ProjectPath ?? workspacePath, + var projectPath = options.ProjectPath ?? workspacePath; + var dbPath = DbPathResolver.ResolveForIndex( + projectPath, options.DbPath, options.DataDir).DbPath; + return (dbPath, projectPath); } var queryArgs = args.Count > 0 ? args.Skip(1).ToArray() : []; - return QueryCommandRunner.ParseArgs( + var queryDbPath = QueryCommandRunner.ParseArgs( queryArgs, jsonDefault: false, validateDefaultLimit: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false).DbPath; + return (queryDbPath, null); } private static string ResolveWorkspacePath(string normalizedDbPath) diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index c86b2cf83b..a68a77eea8 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -80,7 +80,8 @@ public static int Run( jsonOptions, "report output already exists", CommandExitCodes.UsageError, - "Choose a new --output path, or pass --overwrite to replace the existing report bundle."); + "Choose a new --output path, or pass --overwrite to replace the existing report bundle.", + CommandErrorCodes.UsageError); } var outputExtensionWarning = GetOutputExtensionWarning(fullOutputPath); var resolvedVersion = appVersion ?? ConsoleUi.LoadVersion(); @@ -141,7 +142,8 @@ public static int Run( jsonOptions, "report output already exists", CommandExitCodes.UsageError, - "Choose a new --output path, or pass --overwrite to replace the existing report bundle."); + "Choose a new --output path, or pass --overwrite to replace the existing report bundle.", + CommandErrorCodes.UsageError); } catch (Exception ex) { diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index 6b241eb72c..1223cfd1c8 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -601,6 +601,93 @@ public void ResolveFailureDbPath_UsesParsedIndexProjectAfterOptions_Issue4828() } } + [Fact] + public void PersistedIndexFailure_CarriesExplicitProjectWorkspaceWithExternalDatabase_Issue4828() + { + var workDir = CreateWorkDir(); + var projectPath = Path.Combine(workDir, "project"); + var dataDir = Path.Combine(workDir, "external-data"); + var dbPath = Path.Combine(dataDir, "codeindex.db"); + var logDir = CreateLogDir(workDir); + var timestamp = new DateTimeOffset(2026, 7, 27, 4, 5, 6, TimeSpan.Zero); + var runId = LastFailureEventStore.CreateRunId(); + const string version = "1.40.3-test"; + try + { + Directory.CreateDirectory(projectPath); + Directory.CreateDirectory(dataDir); + using var env = EnvironmentVariableScope.Capture("CDIDX_GLOBAL_TOOL_LOG_DIR"); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logDir); + + Assert.True(LastFailureEventStore.TryPersist( + ["index", "--rebuild", "--db", dbPath, projectPath], + version, + CommandExitCodes.UnhandledException, + new InvalidOperationException("synthetic failure"), + timestamp, + runId)); + + var reportProvenance = LastFailureEventStore.CreateReportProvenance( + dbPath, + version, + timestamp + TimeSpan.FromMinutes(1), + LastFailureEventStore.CreateRunId(), + projectPath); + Assert.True(LastFailureEventStore.TryBuildReportPayload( + reportProvenance, + out var payload, + out var evidence)); + Assert.NotEmpty(payload); + Assert.Equal("included", evidence.Disposition); + Assert.Equal("matched", evidence.Reason); + } + finally + { + TestProjectHelper.DeleteDirectory(workDir); + } + } + + [Fact] + public void CreateReportProvenance_FoldsCaseOnCaseInsensitiveFilesystem_Issue4828() + { + var workDir = CreateWorkDir(); + lock (PathCasingTestLock.Gate) + { + var previousProbe = PathCasing.IgnoreCaseProbeForTesting; + try + { + PathCasing.ResetCacheForTests(); + PathCasing.IgnoreCaseProbeForTesting = _ => true; + var workspacePath = Path.Combine(workDir, "Workspace"); + var dbPath = Path.Combine(workspacePath, "Data", "CodeIndex.db"); + var timestamp = new DateTimeOffset(2026, 7, 27, 4, 5, 6, TimeSpan.Zero); + var runId = LastFailureEventStore.CreateRunId(); + + var original = LastFailureEventStore.CreateReportProvenance( + dbPath, + "1.40.3-test", + timestamp, + runId, + workspacePath); + var caseVariant = LastFailureEventStore.CreateReportProvenance( + dbPath.ToLowerInvariant(), + "1.40.3-test", + timestamp, + runId, + workspacePath.ToLowerInvariant()); + + Assert.Equal(original.DatabaseId, caseVariant.DatabaseId); + Assert.Equal(original.WorkspaceId, caseVariant.WorkspaceId); + } + finally + { + PathCasing.IgnoreCaseProbeForTesting = previousProbe; + PathCasing.ResetCacheForTests(); + TestProjectHelper.DeleteDirectory(workDir); + } + } + } + [Fact] public void ResolveFailureDbPath_DoesNotInterpretLiteralQueryAsDatabaseOption_Issue4828() { @@ -682,6 +769,12 @@ public void Run_ExistingOutputRequiresOverwriteAndOptInReplacesIt_Issue4828() Assert.Equal("existing bundle", File.ReadAllText(output)); Assert.Single(Directory.GetFiles(workDir)); + var (jsonExitCode, json) = RunAndCaptureJson(args.Append("--json").ToArray()); + + Assert.Equal(CommandExitCodes.UsageError, jsonExitCode); + Assert.Equal(CommandErrorCodes.UsageError, json.GetProperty("error_code").GetString()); + Assert.Equal("existing bundle", File.ReadAllText(output)); + var overwriteArgs = args.Append("--overwrite").ToArray(); var (overwriteExitCode, _, overwriteStderr) = RunAndCaptureStreams(overwriteArgs); @@ -739,13 +832,53 @@ public void WriteBundle_PostReplaceFlushFailureRestoresExistingBundle_Issue4828( File.WriteAllText(output, "existing bundle"); var bundle = new ReportBundle(); bundle.AddText("metadata.txt", "replacement"); - AtomicFileWriter.FlushParentDirectoryForTesting = - _ => throw new IOException("simulated directory flush failure"); + var flushCount = 0; + AtomicFileWriter.FlushParentDirectoryForTesting = _ => + { + flushCount++; + if (flushCount == 1) + throw new IOException("simulated directory flush failure"); + }; var exception = Assert.Throws<IOException>(() => ReportCommandRunner.WriteBundle(output, bundle, overwrite: true)); Assert.Contains("previous destination was restored", exception.Message, StringComparison.Ordinal); + Assert.Equal(2, flushCount); + Assert.Equal("existing bundle", File.ReadAllText(output)); + Assert.Single(Directory.GetFiles(workDir)); + } + finally + { + AtomicFileWriter.FlushParentDirectoryForTesting = null; + TestProjectHelper.DeleteDirectory(workDir); + } + } + + [Fact] + public void WriteBundle_RollbackFlushFailureAggregatesBothFailures_Issue4828() + { + var workDir = CreateWorkDir(); + try + { + var output = Path.Combine(workDir, "bundle.tgz"); + File.WriteAllText(output, "existing bundle"); + var bundle = new ReportBundle(); + bundle.AddText("metadata.txt", "replacement"); + var flushCount = 0; + AtomicFileWriter.FlushParentDirectoryForTesting = _ => + { + flushCount++; + throw new IOException($"simulated directory flush failure {flushCount}"); + }; + + var exception = Assert.Throws<IOException>(() => + ReportCommandRunner.WriteBundle(output, bundle, overwrite: true)); + + Assert.Contains("could not be restored durably", exception.Message, StringComparison.Ordinal); + var aggregate = Assert.IsType<AggregateException>(exception.InnerException); + Assert.Equal(2, aggregate.InnerExceptions.Count); + Assert.Equal(2, flushCount); Assert.Equal("existing bundle", File.ReadAllText(output)); Assert.Single(Directory.GetFiles(workDir)); }