From ccd10aa4a46b4c645c53b9545d71729a983fe3b2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 00:03:15 +0900 Subject: [PATCH 1/5] Add path-redacted portable exports (#5195) --- DEVELOPER_GUIDE.md | 28 ++ TESTING_GUIDE.md | 4 + USER_GUIDE.md | 42 ++- changelog.d/unreleased/5195.security.md | 17 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- .../ExportImportCommandRunner.Contracts.cs | 26 +- ...ExportImportCommandRunner.ExportArchive.cs | 40 ++- .../ExportImportCommandRunner.ImportOutput.cs | 10 +- .../Cli/ExportImportCommandRunner.Manifest.cs | 7 +- ...ExportImportCommandRunner.PathRedaction.cs | 145 ++++++++++ .../Cli/ExportImportCommandRunner.cs | 5 +- .../Cli/ExportImportManifestCodec.cs | 50 ++++ src/CodeIndex/Cli/SolutionProjectResolver.cs | 10 +- ...ExportImportCommandRunnerIssue5195Tests.cs | 266 ++++++++++++++++++ 15 files changed, 630 insertions(+), 24 deletions(-) create mode 100644 changelog.d/unreleased/5195.security.md create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs create mode 100644 tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index d0716224a..d971fd838 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -58,6 +58,21 @@ Development contracts: Portable archive trust is scope-aware. Unfiltered exports set `scope.represents_entire_source_database` and preserve completeness, indexed-HEAD, run, and unknown-extension metadata. Filtered exports normalize only the archive snapshot to `index_complete=false` with `partial_archive`, remove source-wide HEAD/run provenance, and omit unavailable unknown-extension summaries. Import repeats that normalization for legacy manifests without scope metadata, while current full-snapshot manifests preserve trust. Scoped indexing of a partial archive falls back to a full workspace scan before it may clear `partial_archive`. +Portable archive path privacy is opt-in for compatibility. Default exports retain +`manifest.project_root`, the snapshot's `indexed_project_root`, requested scope +values, and resolved success paths. `--redact-paths` must operate only on the +private copied snapshot: resolve and apply scope first, delete the copied project +root, replace absolute POSIX/Windows/file-URI scope values and persisted +path-sample values with `[redacted]`, then run `VACUUM` before computing +`database_sha256`. Successful manifest and export JSON must keep +`path_redaction_requested`, `path_redaction_complete`, and bounded stable +`path_redaction_omitted_categories` synchronized. Redacted success output must +not repeat the resolved archive, database, or source-root paths. Repository-relative +indexed paths, source content, hashes, readiness, and commit provenance are not +redaction targets. Import derives its destination project root from the destination +DB path/current directory and must tolerate an absent source root in both execution +and dry-run modes. + Checkpoint plan drift detection covers DB/WAL/SHM content changes and sidecar appearance or disappearance through the final pre-publication validation. The plan's `uncertainty` value records the remaining post-validation race; copied outputs are independently hash-verified against the plan before atomic publication. DB/WAL/SHM candidates must pass native regular-file type validation before hashing so Unix FIFOs cannot block planning. `metadata_policy` reports `owner_only_files_and_directories` on POSIX and the actually inherited `inherited_windows_acls` policy on Windows. A database payload whose output name collides with `manifest.txt`, including filesystem-equivalent casing, makes the plan not ready and is rejected before mutation. ## Filesystem Permissions @@ -4245,6 +4260,19 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま portable archive の trust は scope を考慮します。filter なし export は `scope.represents_entire_source_database` を設定し、completeness、indexed-HEAD、run、unknown-extension metadata を維持します。filter 済み export は archive snapshot だけを `index_complete=false` / `partial_archive` に正規化し、source 全体に対する HEAD / run provenance を削除して、未計測の unknown-extension summary を省略します。scope metadata がない legacy manifest は import 時に同じ正規化を行い、現行の full-snapshot manifest は trust を維持します。partial archive に対する scoped index は `partial_archive` を解除する前に full workspace scan へ fallback します。 +portable archive の path privacy は互換性のため opt-in です。既定 export は +`manifest.project_root`、snapshot の `indexed_project_root`、指定 scope value、解決済み +success path を保持します。`--redact-paths` は private な copy 済み snapshot だけを変更します。 +最初に scope を解決・適用し、copy 側の project root を削除して、POSIX / Windows / +file URI 形式の絶対 scope value と永続化済み path sample を `[redacted]` に置換します。 +その後 `VACUUM` を実行してから `database_sha256` を計算します。成功時の manifest と +export JSON では `path_redaction_requested`、`path_redaction_complete`、上限付きで安定した +`path_redaction_omitted_categories` を同期させます。redacted success output は解決済み +archive / database / source-root path を再表示してはいけません。repository-relative な +indexed path、source content、hash、readiness、commit provenance は redaction 対象外です。 +import は destination DB path / current directory から destination project root を導出し、 +source root がない archive を execution / dry-run の双方で許容する必要があります。 + checkpoint plan の drift 検出は、publish 前の最終検証までに起きる DB/WAL/SHM content の変更と sidecar の出現・消失を対象にします。plan の `uncertainty` 値は最終検証後に残る race を記録し、copy 済み output は atomic publish 前に plan の hash と個別に照合されます。DB/WAL/SHM 候補は hash 読み取り前に native regular-file type validation を通すため、Unix FIFO が plan 作成を停止させることはありません。`metadata_policy` は POSIX では `owner_only_files_and_directories`、Windows では実際に継承される `inherited_windows_acls` policy を報告します。database payload の output 名が filesystem 上で同一の大小文字を含め `manifest.txt` と衝突する場合、plan は not ready となり、変更前に拒否されます。 ## ファイルシステム権限 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 8e0d20f0f..4f772db02 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -443,6 +443,8 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result keeps the overwrite boundary and artifact attestation deterministic. Preserve default refusal of regular and dangling-symlink destinations, explicit replacement with verifiable byte-size/SHA-256/manifest metadata and POSIX owner-only mode, failed-write temp cleanup, and a publish-boundary injection that creates a concurrent winner without using sleeps. - `ExportImportCommandRunnerIssue5185Tests` owns the import-staging confidentiality regression. Keep the non-dry-run parent at mode `0755`, set process umask `022` only inside the non-parallel SQLite-sensitive collection, observe the empty staging DB before extraction can write its first byte, and verify owner-only DB/WAL/SHM modes through validation, trust rewriting, `--prune-paths`, rollback-backup creation, cancellation cleanup, collision rejection, and final replacement. POSIX mode assertions must remain platform-conditional while success and `CreateNew` collision behavior continue to run on Windows. +- `ExportImportCommandRunnerIssue5195Tests` + owns portable-archive path-redaction coverage. Keep default path compatibility separate from the redacted full-archive fixture; the latter jointly verifies manifest and SQLite root removal, POSIX/Windows path-form handling, scope selectors, hash-after-redaction ordering, source-byte immutability, owner-only atomic replacement, import/dry-run tolerance, and a post-import query. - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`, `Run_CancelDuringDryRunScan_ReturnsInterruptedJson`, and `Run_CancelBeforeFreshScan_ReturnsInterruptedJson` exercise the same in-process cancellation paths used after Ctrl-C/SIGINT wiring, including scan-time cancellation, so interrupted index runs keep returning the canonical JSON error contract. - `IndexCommandRunnerTests.RunOptimizeFts_DryRunPreviewsWithoutWritingThenOptimizeMutates_Issue4577`, `RunOptimizeFts_DryRunPathDisclosureRequiresShowPaths_Issue5193`, `RunOptimizeFts_LockHeld_ReportsDbLocked`, and `RunOptimizeFts_ReadOnlyUri_ReturnsDbNotWritable` @@ -1588,6 +1590,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" overwrite 境界と artifact attestation を deterministic に固定します。通常 file と dangling symlink destination の既定拒否、明示置換で検証可能な byte size / SHA-256 / manifest metadata と POSIX owner-only mode、書き込み失敗時の temp cleanup、sleep を使わず publish 境界へ concurrent winner を生成する injection を維持してください。 - `ExportImportCommandRunnerIssue5185Tests` import staging の confidentiality 回帰を担当します。non-dry-run の parent を mode `0755` にし、process umask `022` は非並列の SQLite-sensitive collection 内だけで設定し、extraction が最初の byte を書く前の空 staging DB を観測してください。validation、trust rewrite、`--prune-paths`、rollback backup 作成、cancellation cleanup、衝突拒否、最終 replacement を通して DB/WAL/SHM が owner-only であることを検証します。POSIX mode assertion は platform 条件付きに保ちつつ、success と `CreateNew` の衝突挙動は Windows でも実行してください。 +- `ExportImportCommandRunnerIssue5195Tests` + portable archive の path-redaction coverage を担当します。既定 path の互換性は redacted full-archive fixture と分離し、後者では manifest / SQLite の root 除去、POSIX / Windows path 形式、scope selector、redaction 後の hash 計算順、source byte 不変性、owner-only atomic replacement、import / dry-run の許容、import 後 query を一体で検証してください。 - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`、`Run_CancelDuringDryRunScan_ReturnsInterruptedJson`、`Run_CancelBeforeFreshScan_ReturnsInterruptedJson` Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を、scan 中のキャンセルも含めて検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 - `IndexCommandRunnerTests.RunOptimizeFts_DryRunPreviewsWithoutWritingThenOptimizeMutates_Issue4577`、`RunOptimizeFts_DryRunPathDisclosureRequiresShowPaths_Issue5193`、`RunOptimizeFts_LockHeld_ReportsDbLocked`、`RunOptimizeFts_ReadOnlyUri_ReturnsDbNotWritable` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e4265239c..f9f084bdc 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -542,6 +542,7 @@ job: ```bash cdidx export codeindex.cdidx.zip cdidx export codeindex.cdidx.zip --overwrite --json +cdidx export support.cdidx.zip --redact-paths --json cdidx export app.cdidx.zip --project App --lang csharp --exclude-tests cdidx export shared.cdidx.zip --path 'src/shared/*' --exclude-path 'src/shared/generated/*' cdidx import codeindex.cdidx.zip @@ -576,15 +577,30 @@ complete immutable `manifest` object. That manifest carries the database hash, row counts, schema contract stamps, readiness state, unknown-extension summary, and export scope needed to evaluate the artifact before import. +By default, compatibility archives retain `manifest.project_root` and the +embedded database's `indexed_project_root`, and successful human/JSON output +reports the resolved archive and database paths. Use `--redact-paths` before +sharing an archive outside the source machine. This opt-in mode removes the +project root from both manifest and snapshot, replaces absolute POSIX, Windows, +or file-URI scope values and unknown-extension path samples with `[redacted]`, +then vacuums the copied snapshot before computing `database_sha256`. Indexed +repository-relative paths, source text, hashes, readiness, and commit provenance +are retained. Export JSON and the manifest expose `path_redaction_requested`, +`path_redaction_complete`, and `path_redaction_omitted_categories`; top-level +`archive_path` / `db_path` are `[redacted]`, and human success output does not +repeat a local path. The source database is never modified. + The archive path is intended for trusted CodeIndex databases. Import validates that the embedded SQLite file is a CodeIndex DB before replacing the destination database. `--prune-paths` rewrites the imported `indexed_project_root` metadata to the import target project root. Imports targeting `.../.cdidx/codeindex.db` use the sibling project directory; other database paths fall back to the process -current directory. `--dry-run` and its `--check` alias also compare an existing +current directory. A path-redacted archive may omit the source root entirely; +import and dry-run validation do not trust it and remain usable without +`--prune-paths`. `--dry-run` and its `--check` alias also compare an existing destination DB with the validated archive without replacing it. JSON results -expose the normalized `index_complete`, `index_incomplete_reasons`, and `scope` -values. Archives with no scope metadata are treated conservatively as partial +expose the normalized `index_complete`, `index_incomplete_reasons`, `scope`, and +path-redaction state. Archives with no scope metadata are treated conservatively as partial during import; current unfiltered archives explicitly preserve full-snapshot trust. JSON `destination_delta.comparison` reports schema and count deltas plus bounded file, symbol, reference-edge, chunk, and metadata records. Text fields in those @@ -4250,6 +4266,7 @@ legacy database も query でき、generated-code policy は `unavailable` と ```bash cdidx export codeindex.cdidx.zip cdidx export codeindex.cdidx.zip --overwrite --json +cdidx export support.cdidx.zip --redact-paths --json cdidx export app.cdidx.zip --project App --lang csharp --exclude-tests cdidx export shared.cdidx.zip --path 'src/shared/*' --exclude-path 'src/shared/generated/*' cdidx import codeindex.cdidx.zip @@ -4280,14 +4297,29 @@ portable export は既存 destination を既定で拒否します。意図して には import 前に artifact を評価するための database hash、row count、schema contract stamp、readiness state、unknown-extension summary、export scope が含まれます。 +互換性を維持する既定 archive は `manifest.project_root` と embedded database の +`indexed_project_root` を保持し、成功時の human / JSON output も解決済み archive path と +database path を報告します。source machine の外へ共有する前に `--redact-paths` を指定してください。 +この opt-in mode は manifest と snapshot の両方から project root を除去し、scope と +unknown-extension path sample に含まれる POSIX / Windows / file URI 形式の絶対 path を +`[redacted]` に置換してから、copy 済み snapshot を vacuum し、その後で +`database_sha256` を計算します。repository-relative な indexed path、source text、hash、 +readiness、commit provenance は維持します。export JSON と manifest は +`path_redaction_requested`、`path_redaction_complete`、 +`path_redaction_omitted_categories` を公開します。top-level の `archive_path` / +`db_path` は `[redacted]` となり、human success output も local path を再表示しません。 +source database は変更しません。 + archive は信頼できる CodeIndex database の共有向けです。Import は埋め込まれた SQLite file が CodeIndex DB であることを検証してから destination database を置き換えます。 `--prune-paths` は import した `indexed_project_root` metadata を import 先 project root に書き換えます。 `.../.cdidx/codeindex.db` を import 先にした場合は sibling の project directory を使い、 -それ以外の database path では process current directory に fallback します。 +それ以外の database path では process current directory に fallback します。path-redacted +archive は source root を完全に省略できますが、import と dry-run validation はこの値を +信頼しないため、`--prune-paths` なしでも利用できます。 `--dry-run` と alias の `--check` は置換せず、既存 destination DB と検証済み archive を 比較します。JSON result は正規化後の `index_complete`、`index_incomplete_reasons`、 -`scope` を公開します。scope metadata がない archive は import 時に保守的に partial と +`scope`、path-redaction state を公開します。scope metadata がない archive は import 時に保守的に partial と 扱い、現行の filter なし archive だけが full snapshot の trust を明示的に維持します。 JSON の `destination_delta.comparison` には schema / count delta と、 file、symbol、reference edge、chunk、metadata の bounded record が含まれます。 diff --git a/changelog.d/unreleased/5195.security.md b/changelog.d/unreleased/5195.security.md new file mode 100644 index 000000000..01d7f6201 --- /dev/null +++ b/changelog.d/unreleased/5195.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 5195 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs + - src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs + - USER_GUIDE.md +--- + +## English + +- **Portable archives can now redact source-machine paths (#5195)** — `cdidx export --redact-paths` removes the project root from both `manifest.json` and the copied SQLite snapshot, redacts absolute scope and diagnostic path samples, computes integrity hashes after redaction, suppresses resolved output paths, and reports requested/completed privacy metadata without mutating the source database. + +## 日本語 + +- **portable archive で source machine の path を秘匿できるようになりました (#5195)** — `cdidx export --redact-paths` は `manifest.json` と copy 済み SQLite snapshot の両方から project root を除去し、scope と diagnostic sample の絶対 path を秘匿してから integrity hash を計算します。解決済み output path の再表示も抑止し、source database を変更せずに requested / completed privacy metadata を報告します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 73c8f8459..cd829328c 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -731,7 +731,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--debounce", ValuePlaceholder = "", Description = "Watch only: coalesce file events after 0..60000 ms of quiet (default 500)", PrimaryCommands = Set("index") }, new() { Name = "--watch-pending-path-limit", ValuePlaceholder = "", Description = "Watch only: changed-path queue limit (1..262144); invalid CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT warns and uses the documented effective value", PrimaryCommands = Set("index") }, new() { Name = "--output", ShortName = "-o", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Report bundle or suggestions export output path", PrimaryCommands = Set("report", "suggestions") }, - new() { Name = "--redact-paths", Description = "Redact machine-specific paths (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report"]).ToArray()) }, + new() { Name = "--redact-paths", Description = "Redact machine-specific paths; opt in for portable archives (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report", "export"]).ToArray()) }, new() { Name = "--no-log", Description = "Exclude global tool log from bundle", PrimaryCommands = Set("report") }, new() { Name = "--include-args", Description = "Include args in bundle log", PrimaryCommands = Set("report") }, new() { Name = "--log-lines", ValuePlaceholder = "", Description = "Number of log lines to include in bundle (clamped to 2000)", PrimaryCommands = Set("report") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index f97757b60..12837047e 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -141,7 +141,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("suggestions-update", "cdidx suggestions update [--description ] [--context ] [--title ] [--evidence-path ] [--category ] [--language ] [--agent ] [--db ] [--json]"), ("suggestions-update", "cdidx suggestions update --status [--actor ] [--reason ] [--db ] [--json]"), ("suggestions-delete", "cdidx suggestions delete [--db ] [--json]"), - ("export", "cdidx export [--db ] [--json] [--overwrite] [--lang ] [--path ] [--exclude-path ] [--project ] [--solution ] [--exclude-tests]"), + ("export", "cdidx export [--db ] [--json] [--overwrite] [--redact-paths] [--lang ] [--path ] [--exclude-path ] [--project ] [--solution ] [--exclude-tests]"), ("export", "cdidx export ctags [--output ] [--db ] [--json] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--include-generated]"), ("export-ctags", "cdidx export ctags [--output ] [--db ] [--json] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--include-generated]"), ("import", "cdidx import [--db ] [--prune-paths] [--no-backup] [--dry-run|--check] [--limit ] [--offset ] [--json]"), diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs index 23c04e205..3296e6e6b 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Contracts.cs @@ -168,7 +168,13 @@ internal sealed record ExportManifest( [property: JsonPropertyName("index_incomplete_reasons")] string[]? IndexIncompleteReasons = null, [property: JsonPropertyName("scope")] - ArchiveExportScopeResult? Scope = null); + ArchiveExportScopeResult? Scope = null, + [property: JsonPropertyName("path_redaction_requested")] + bool PathRedactionRequested = false, + [property: JsonPropertyName("path_redaction_complete")] + bool PathRedactionComplete = false, + [property: JsonPropertyName("path_redaction_omitted_categories")] + string[]? PathRedactionOmittedCategories = null); internal sealed record ExportImportErrorResult( [property: JsonPropertyName("api_version")] string ApiVersion, [property: JsonPropertyName("status")] string Status, @@ -221,7 +227,10 @@ internal sealed record ImportDryRunResult( [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null, [property: JsonPropertyName("index_complete")] bool? IndexComplete = null, [property: JsonPropertyName("index_incomplete_reasons")] string[]? IndexIncompleteReasons = null, - [property: JsonPropertyName("scope")] ArchiveExportScopeResult? Scope = null); + [property: JsonPropertyName("scope")] ArchiveExportScopeResult? Scope = null, + [property: JsonPropertyName("path_redaction_requested")] bool PathRedactionRequested = false, + [property: JsonPropertyName("path_redaction_complete")] bool PathRedactionComplete = false, + [property: JsonPropertyName("path_redaction_omitted_categories")] string[]? PathRedactionOmittedCategories = null); internal sealed record ImportDestinationDeltaResult( [property: JsonPropertyName("destination_exists")] bool DestinationExists, [property: JsonPropertyName("comparable")] bool Comparable, @@ -237,14 +246,18 @@ internal sealed record ExportArchiveResult( [property: JsonPropertyName("archive_size_bytes")] long ArchiveSizeBytes, [property: JsonPropertyName("archive_sha256")] string ArchiveSha256, [property: JsonPropertyName("manifest")] ExportManifest Manifest, - [property: JsonPropertyName("scope")] ArchiveExportScopeResult Scope); + [property: JsonPropertyName("scope")] ArchiveExportScopeResult Scope, + [property: JsonPropertyName("path_redaction_requested")] bool PathRedactionRequested, + [property: JsonPropertyName("path_redaction_complete")] bool PathRedactionComplete, + [property: JsonPropertyName("path_redaction_omitted_categories")] string[] PathRedactionOmittedCategories); private sealed record ArchiveExportOptions( string? Lang, IReadOnlyList PathPatterns, IReadOnlyList ExcludePathPatterns, IReadOnlyList Projects, string? Solution, - bool ExcludeTests) + bool ExcludeTests, + bool RedactPaths) { internal bool IsScoped => !string.IsNullOrWhiteSpace(Lang) || @@ -318,7 +331,10 @@ internal sealed record ImportResult( [property: JsonPropertyName("unknown_extension_file_sample_truncated")] bool? UnknownExtensionFileSampleTruncated = null, [property: JsonPropertyName("index_complete")] bool? IndexComplete = null, [property: JsonPropertyName("index_incomplete_reasons")] string[]? IndexIncompleteReasons = null, - [property: JsonPropertyName("scope")] ArchiveExportScopeResult? Scope = null); + [property: JsonPropertyName("scope")] ArchiveExportScopeResult? Scope = null, + [property: JsonPropertyName("path_redaction_requested")] bool PathRedactionRequested = false, + [property: JsonPropertyName("path_redaction_complete")] bool PathRedactionComplete = false, + [property: JsonPropertyName("path_redaction_omitted_categories")] string[]? PathRedactionOmittedCategories = null); private sealed class ImportReplacementException : IOException { diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs index f580c9887..8851bf992 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs @@ -25,6 +25,7 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt var projects = new List(); var excludeTests = false; var overwrite = false; + var redactPaths = false; var wantsJson = Array.Exists(args, arg => arg == "--json"); for (var i = 0; i < args.Length; i++) @@ -45,6 +46,11 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt overwrite = true; continue; } + if (arg == "--redact-paths") + { + redactPaths = true; + continue; + } if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) { @@ -144,7 +150,8 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt excludePathPatterns.ToArray(), projects.ToArray(), solution, - excludeTests); + excludeTests, + redactPaths); string? snapshotDirectory = null; string? snapshotPath = null; var phase = PhaseWriteArchive; @@ -173,7 +180,14 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt phase = PhaseScopeArchive; var snapshotConnection = snapshotContext.Connection; var scope = ApplyArchiveScope(snapshotConnection, scopeOptions, cancellationToken); - manifest = BuildManifest(snapshotConnection, appVersion, scope, cancellationToken); + var redaction = ApplyArchivePathRedaction(snapshotConnection, scope, scopeOptions.RedactPaths, cancellationToken); + manifest = BuildManifest( + snapshotConnection, + appVersion, + redaction.Scope, + scopeOptions.RedactPaths, + redaction.OmittedCategories, + cancellationToken); } else { @@ -182,7 +196,14 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt snapshotConnection.Open(); phase = PhaseScopeArchive; var scope = ApplyArchiveScope(snapshotConnection, scopeOptions, cancellationToken); - manifest = BuildManifest(snapshotConnection, appVersion, scope, cancellationToken); + var redaction = ApplyArchivePathRedaction(snapshotConnection, scope, scopeOptions.RedactPaths, cancellationToken); + manifest = BuildManifest( + snapshotConnection, + appVersion, + redaction.Scope, + scopeOptions.RedactPaths, + redaction.OmittedCategories, + cancellationToken); } SqliteConnection.ClearAllPools(); phase = PhaseSha256; @@ -204,16 +225,21 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt Console.WriteLine(JsonSerializer.Serialize( new ExportArchiveResult( "1", - fullOutputPath, - fullSourceDbPath, + redactPaths ? RedactedArchivePath : fullOutputPath, + redactPaths ? RedactedArchivePath : fullSourceDbPath, attestation.SizeBytes, attestation.Sha256, manifest, - manifest.Scope ?? throw new InvalidDataException("export scope metadata was not created")), + manifest.Scope ?? throw new InvalidDataException("export scope metadata was not created"), + manifest.PathRedactionRequested, + manifest.PathRedactionComplete, + manifest.PathRedactionOmittedCategories ?? []), jsonOptions)); } else - Console.WriteLine($"Exported CodeIndex archive to {fullOutputPath}"); + Console.WriteLine(redactPaths + ? "Exported path-redacted CodeIndex archive." + : $"Exported CodeIndex archive to {fullOutputPath}"); return CommandExitCodes.Success; } catch (OperationCanceledException) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs index d96a23fff..32b4107a8 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportOutput.cs @@ -51,7 +51,10 @@ private static int WriteImportDryRunResult( UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated, IndexComplete: manifest.IndexComplete, IndexIncompleteReasons: manifest.IndexIncompleteReasons, - Scope: manifest.Scope), + Scope: manifest.Scope, + PathRedactionRequested: manifest.PathRedactionRequested, + PathRedactionComplete: manifest.PathRedactionComplete, + PathRedactionOmittedCategories: manifest.PathRedactionOmittedCategories), CliJsonSerializerContextFactory.Create(jsonOptions).ImportDryRunResult)); } else @@ -98,7 +101,10 @@ private static int WriteImportResult( UnknownExtensionFileSampleTruncated: manifest.UnknownExtensionFileSampleTruncated, IndexComplete: manifest.IndexComplete, IndexIncompleteReasons: manifest.IndexIncompleteReasons, - Scope: manifest.Scope), + Scope: manifest.Scope, + PathRedactionRequested: manifest.PathRedactionRequested, + PathRedactionComplete: manifest.PathRedactionComplete, + PathRedactionOmittedCategories: manifest.PathRedactionOmittedCategories), jsonOptions)); } else diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs index c2d8b9d75..2bd8311f6 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs @@ -18,6 +18,8 @@ private static ExportManifest BuildManifest( SqliteConnection connection, string appVersion, ArchiveExportScopeResult scope, + bool pathRedactionRequested, + string[] pathRedactionOmittedCategories, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -71,7 +73,10 @@ private static ExportManifest BuildManifest( ? null : string.Equals(indexCompleteness, "complete", StringComparison.Ordinal), IndexIncompleteReasons: indexIncompleteReasons, - Scope: scope); + Scope: scope, + PathRedactionRequested: pathRedactionRequested, + PathRedactionComplete: pathRedactionRequested, + PathRedactionOmittedCategories: pathRedactionOmittedCategories); } private static void AddTextEntry(ZipArchive archive, string name, string content) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs new file mode 100644 index 000000000..7d1933420 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs @@ -0,0 +1,145 @@ +using System.Text.Json; +using CodeIndex.Database; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static partial class ExportImportCommandRunner +{ + private readonly record struct ArchivePathRedactionResult( + ArchiveExportScopeResult Scope, + string[] OmittedCategories); + + private static ArchivePathRedactionResult ApplyArchivePathRedaction( + SqliteConnection connection, + ArchiveExportScopeResult scope, + bool redactPaths, + CancellationToken cancellationToken) + { + if (!redactPaths) + return new ArchivePathRedactionResult(scope, []); + + cancellationToken.ThrowIfCancellationRequested(); + var omittedCategories = new HashSet(StringComparer.Ordinal); + var projectRoot = ReadMetaString(connection, DbContext.IndexedProjectRootMetaKey); + using (var transaction = connection.BeginTransaction()) + { + if (!string.IsNullOrWhiteSpace(projectRoot)) + { + using var deleteRoot = connection.CreateCommand(); + deleteRoot.Transaction = transaction; + deleteRoot.CommandText = "DELETE FROM codeindex_meta WHERE key = @key"; + SqliteCommandPolicy.Add(deleteRoot, "@key", DbContext.IndexedProjectRootMetaKey); + deleteRoot.ExecuteNonQuery(); + omittedCategories.Add("project_root"); + } + + RedactPersistedPathList( + connection, + transaction, + DbContext.UnknownExtensionFilePathsMetaKey, + "unknown_extension_files", + omittedCategories); + RedactPersistedPathList( + connection, + transaction, + DbContext.WorkspaceVerificationPendingPathsMetaKey, + "workspace_pending_paths", + omittedCategories); + transaction.Commit(); + } + + var redactedScope = RedactArchiveScope(scope, omittedCategories); + cancellationToken.ThrowIfCancellationRequested(); + using (var vacuum = connection.CreateCommand()) + { + vacuum.CommandText = "VACUUM"; + vacuum.ExecuteNonQuery(); + } + cancellationToken.ThrowIfCancellationRequested(); + return new ArchivePathRedactionResult( + redactedScope, + omittedCategories.Order(StringComparer.Ordinal).ToArray()); + } + + private static ArchiveExportScopeResult RedactArchiveScope( + ArchiveExportScopeResult scope, + HashSet omittedCategories) + => scope with + { + PathPatterns = RedactArchivePathValues(scope.PathPatterns, "scope.path", omittedCategories), + ExcludePathPatterns = RedactArchivePathValues(scope.ExcludePathPatterns, "scope.exclude_path", omittedCategories), + Projects = RedactArchivePathValues(scope.Projects, "scope.project", omittedCategories), + Solution = RedactArchivePathValue(scope.Solution, "scope.solution", omittedCategories), + ResolvedProjectPathPatterns = RedactArchivePathValues( + scope.ResolvedProjectPathPatterns, + "scope.resolved_project_path", + omittedCategories), + }; + + private static string[] RedactArchivePathValues( + IReadOnlyList values, + string category, + HashSet omittedCategories) + => values.Select(value => RedactArchivePathValue(value, category, omittedCategories)!).ToArray(); + + private static string? RedactArchivePathValue( + string? value, + string category, + HashSet omittedCategories) + { + if (value == null || !LooksLikeMachineAbsolutePath(value)) + return value; + + omittedCategories.Add(category); + return RedactedArchivePath; + } + + private static bool LooksLikeMachineAbsolutePath(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + if (value.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + || value[0] is '/' or '\\') + { + return true; + } + + return value.Length >= 3 + && char.IsAsciiLetter(value[0]) + && value[1] == ':' + && value[2] is '/' or '\\'; + } + + private static void RedactPersistedPathList( + SqliteConnection connection, + SqliteTransaction transaction, + string key, + string category, + HashSet omittedCategories) + { + var raw = ReadMetaString(connection, key, transaction); + if (string.IsNullOrWhiteSpace(raw) || raw.Length > MaxImportManifestBytes) + return; + + string[]? paths; + try + { + paths = JsonSerializer.Deserialize(raw); + } + catch (JsonException) + { + return; + } + if (paths == null || !paths.Any(LooksLikeMachineAbsolutePath)) + return; + + var redacted = RedactArchivePathValues(paths, category, omittedCategories); + using var update = connection.CreateCommand(); + update.Transaction = transaction; + update.CommandText = "UPDATE codeindex_meta SET value = @value WHERE key = @key"; + SqliteCommandPolicy.Add(update, "@value", JsonSerializer.Serialize(redacted)); + SqliteCommandPolicy.Add(update, "@key", key); + update.ExecuteNonQuery(); + } +} diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 410a5f778..e4f0a8a0a 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -34,6 +34,9 @@ internal static partial class ExportImportCommandRunner internal const int MaxArchiveIncompleteReasonChars = 128; internal const int MaxArchiveIncompleteReasonsTotalChars = 4096; internal const string PartialArchiveIncompleteReason = "partial_archive"; + internal const int MaxArchivePathRedactionCategories = 16; + internal const int MaxArchivePathRedactionCategoryChars = 64; + private const string RedactedArchivePath = "[redacted]"; private static readonly DateTimeOffset DeterministicZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); private const string ExportCommandName = "export"; private const string ImportCommandName = "import"; @@ -51,7 +54,7 @@ internal static partial class ExportImportCommandRunner private const string PhaseWriteArchive = "write_archive"; private const string PhaseWriteCtags = "write_ctags"; private const string ImportUsage = "cdidx import [--db ] [--prune-paths] [--no-backup] [--dry-run|--check] [--limit ] [--offset ] [--json]"; - private const string ArchiveExportUsage = "cdidx export [--db ] [--json] [--overwrite] [--lang ] [--path ] [--exclude-path ] [--project ] [--solution ] [--exclude-tests]"; + private const string ArchiveExportUsage = "cdidx export [--db ] [--json] [--overwrite] [--redact-paths] [--lang ] [--path ] [--exclude-path ] [--project ] [--solution ] [--exclude-tests]"; private const string CtagsExportUsage = "cdidx export ctags [--output ] [--db ] [--json] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--include-generated]"; private const string CtagsSkipInvalidName = "invalid_name"; private const string CtagsSkipUnsupportedKind = "unsupported_kind"; diff --git a/src/CodeIndex/Cli/ExportImportManifestCodec.cs b/src/CodeIndex/Cli/ExportImportManifestCodec.cs index 2a40451de..3a480b049 100644 --- a/src/CodeIndex/Cli/ExportImportManifestCodec.cs +++ b/src/CodeIndex/Cli/ExportImportManifestCodec.cs @@ -174,6 +174,9 @@ internal static bool TryValidateHeader(ExportImportCommandRunner.ExportManifest if (!TryValidateIncompleteReasons(manifest, out message)) return false; + if (!TryValidatePathRedaction(manifest, out message)) + return false; + if (manifest.Scope != null) { if (!TryValidateScope(manifest.Scope, out message)) @@ -184,6 +187,53 @@ internal static bool TryValidateHeader(ExportImportCommandRunner.ExportManifest return true; } + private static bool TryValidatePathRedaction( + ExportImportCommandRunner.ExportManifest manifest, + out string message) + { + if (manifest.PathRedactionComplete && !manifest.PathRedactionRequested) + { + message = "path_redaction_complete requires path_redaction_requested"; + return false; + } + + var categories = manifest.PathRedactionOmittedCategories; + if (categories == null) + { + message = string.Empty; + return true; + } + if (!manifest.PathRedactionRequested && categories.Length > 0) + { + message = "path_redaction_omitted_categories requires path_redaction_requested"; + return false; + } + if (categories.Length > ExportImportCommandRunner.MaxArchivePathRedactionCategories) + { + message = $"path_redaction_omitted_categories exceeds the manifest limit of {ExportImportCommandRunner.MaxArchivePathRedactionCategories}"; + return false; + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var category in categories) + { + if (string.IsNullOrWhiteSpace(category) + || category.Length > ExportImportCommandRunner.MaxArchivePathRedactionCategoryChars) + { + message = "path_redaction_omitted_categories contains an invalid category"; + return false; + } + if (!seen.Add(category)) + { + message = "path_redaction_omitted_categories contains a duplicate category"; + return false; + } + } + + message = string.Empty; + return true; + } + private static bool TryValidateScope( ExportImportCommandRunner.ArchiveExportScopeResult scope, out string message) diff --git a/src/CodeIndex/Cli/SolutionProjectResolver.cs b/src/CodeIndex/Cli/SolutionProjectResolver.cs index b38068c5e..aa2911bab 100644 --- a/src/CodeIndex/Cli/SolutionProjectResolver.cs +++ b/src/CodeIndex/Cli/SolutionProjectResolver.cs @@ -367,10 +367,18 @@ private static DotNetProjectInfo BuildProjectInfo(string fullProjectPath, string private static DotNetProjectInfo? MatchProject(IReadOnlyList projects, string requested) { var trimmed = requested.Trim(); + var absoluteRequested = Path.IsPathFullyQualified(trimmed) + ? Path.GetFullPath(trimmed) + : null; return projects.FirstOrDefault(project => string.Equals(project.Name, trimmed, StringComparison.OrdinalIgnoreCase) || string.Equals(project.ProjectPath, trimmed.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase) - || string.Equals(Path.GetFileName(project.ProjectPath), trimmed, StringComparison.OrdinalIgnoreCase)); + || string.Equals(Path.GetFileName(project.ProjectPath), trimmed, StringComparison.OrdinalIgnoreCase) + || absoluteRequested != null + && string.Equals( + Path.GetFullPath(Path.Combine(project.DirectoryPath, Path.GetFileName(project.ProjectPath))), + absoluteRequested, + StringComparison.OrdinalIgnoreCase)); } private static IEnumerable EnumerateFilesUsingIndexerPolicy( diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs new file mode 100644 index 000000000..335b31b6f --- /dev/null +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs @@ -0,0 +1,266 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Database; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public class ExportImportCommandRunnerIssue5195Tests +{ + [Fact] + public void RunExportArchive_DefaultPreservesPathCompatibility_Issue5195() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_default_paths_5195"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var archivePath = Path.Combine(projectRoot, "default.cdidx.zip"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [archivePath, "--db", dbPath, "--json"], + jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var result = JsonDocument.Parse(stdout); + Assert.Equal(Path.GetFullPath(archivePath), result.RootElement.GetProperty("archive_path").GetString()); + Assert.Equal(Path.GetFullPath(dbPath), result.RootElement.GetProperty("db_path").GetString()); + Assert.False(result.RootElement.GetProperty("path_redaction_requested").GetBoolean()); + Assert.False(result.RootElement.GetProperty("path_redaction_complete").GetBoolean()); + Assert.Empty(result.RootElement.GetProperty("path_redaction_omitted_categories").EnumerateArray()); + Assert.Equal( + Path.GetFullPath(projectRoot), + result.RootElement.GetProperty("manifest").GetProperty("project_root").GetString()); + + var extractedDb = ExtractDatabase(projectRoot, archivePath, "default.db"); + Assert.Equal(Path.GetFullPath(projectRoot), ReadMetaValue(extractedDb, DbContext.IndexedProjectRootMetaKey)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunExportArchive_RedactsManifestSnapshotAndOutputAndImports_Issue5195() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_redacted_paths_5195"); + var importRoot = TestProjectHelper.CreateTempProject("import_redacted_paths_5195"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + "public class App { public void Run() { } }"); + const string posixSample = "/Users/alice/work/private-repository/secret.foo"; + const string windowsSample = @"C:\Users\Alice\work\private-repository\secret.bar"; + SetUnknownExtensionPaths(dbPath, [posixSample, windowsSample, "docs/relative.baz"]); + SqliteConnection.ClearAllPools(); + var sourceBytesBefore = File.ReadAllBytes(dbPath); + var archivePath = Path.Combine(projectRoot, "redacted.cdidx.zip"); + File.WriteAllText(archivePath, "replace me"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [archivePath, "--db", dbPath, "--overwrite", "--redact-paths", "--json"], + jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.DoesNotContain(projectRoot, stdout, StringComparison.Ordinal); + Assert.DoesNotContain(dbPath, stdout, StringComparison.Ordinal); + Assert.DoesNotContain(archivePath, stdout, StringComparison.Ordinal); + Assert.DoesNotContain(posixSample, stdout, StringComparison.Ordinal); + Assert.DoesNotContain(windowsSample, stdout, StringComparison.Ordinal); + using var result = JsonDocument.Parse(stdout); + Assert.Equal("[redacted]", result.RootElement.GetProperty("archive_path").GetString()); + Assert.Equal("[redacted]", result.RootElement.GetProperty("db_path").GetString()); + Assert.True(result.RootElement.GetProperty("path_redaction_requested").GetBoolean()); + Assert.True(result.RootElement.GetProperty("path_redaction_complete").GetBoolean()); + var manifest = result.RootElement.GetProperty("manifest"); + Assert.Equal(JsonValueKind.Null, manifest.GetProperty("project_root").ValueKind); + Assert.True(manifest.GetProperty("path_redaction_requested").GetBoolean()); + Assert.True(manifest.GetProperty("path_redaction_complete").GetBoolean()); + Assert.Contains( + manifest.GetProperty("path_redaction_omitted_categories").EnumerateArray(), + category => category.GetString() == "project_root"); + Assert.Contains( + manifest.GetProperty("path_redaction_omitted_categories").EnumerateArray(), + category => category.GetString() == "unknown_extension_files"); + Assert.Equal("[redacted]", manifest.GetProperty("unknown_extension_files")[0].GetString()); + Assert.Equal("[redacted]", manifest.GetProperty("unknown_extension_files")[1].GetString()); + Assert.Equal("docs/relative.baz", manifest.GetProperty("unknown_extension_files")[2].GetString()); + + var extractedDb = ExtractDatabase(projectRoot, archivePath, "redacted.db"); + Assert.Null(ReadMetaValue(extractedDb, DbContext.IndexedProjectRootMetaKey)); + using (var paths = JsonDocument.Parse(ReadMetaValue(extractedDb, DbContext.UnknownExtensionFilePathsMetaKey)!)) + { + Assert.Equal("[redacted]", paths.RootElement[0].GetString()); + Assert.Equal("[redacted]", paths.RootElement[1].GetString()); + Assert.Equal("docs/relative.baz", paths.RootElement[2].GetString()); + } + Assert.DoesNotContain(projectRoot, Encoding.UTF8.GetString(File.ReadAllBytes(extractedDb)), StringComparison.Ordinal); + Assert.Equal( + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(extractedDb))).ToLowerInvariant(), + manifest.GetProperty("database_sha256").GetString()); + Assert.Equal(sourceBytesBefore, File.ReadAllBytes(dbPath)); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + DataDirectorySecurity.PrivateFileMode, + File.GetUnixFileMode(archivePath) & DataDirectorySecurity.PermissionBits); + } + + var humanArchivePath = Path.Combine(projectRoot, "redacted-human.cdidx.zip"); + var humanExport = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [humanArchivePath, "--db", dbPath, "--redact-paths"], + jsonOptions, + "test")); + Assert.Equal(CommandExitCodes.Success, humanExport.ExitCode); + Assert.Equal(string.Empty, humanExport.Stderr); + Assert.Equal("Exported path-redacted CodeIndex archive." + Environment.NewLine, humanExport.Stdout); + Assert.DoesNotContain(projectRoot, humanExport.Stdout, StringComparison.Ordinal); + + var importedDb = Path.Combine(importRoot, ".cdidx", "codeindex.db"); + var dryRun = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport( + [archivePath, "--db", importedDb, "--dry-run", "--no-backup", "--json"], + jsonOptions)); + Assert.Equal(CommandExitCodes.Success, dryRun.ExitCode); + Assert.Equal(string.Empty, dryRun.Stderr); + using (var dryRunJson = JsonDocument.Parse(dryRun.Stdout)) + { + Assert.True(dryRunJson.RootElement.GetProperty("path_redaction_requested").GetBoolean()); + Assert.True(dryRunJson.RootElement.GetProperty("path_redaction_complete").GetBoolean()); + } + Assert.False(File.Exists(importedDb)); + + var import = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport( + [archivePath, "--db", importedDb, "--no-backup", "--json"], + jsonOptions)); + Assert.Equal(CommandExitCodes.Success, import.ExitCode); + Assert.Equal(string.Empty, import.Stderr); + Assert.Null(ReadMetaValue(importedDb, DbContext.IndexedProjectRootMetaKey)); + + var query = ConsoleCapture.Capture(() => + ProgramRunner.Run( + ["symbols", "App", "--db", importedDb, "--json", "--limit", "10"], + appVersion: "test")); + Assert.Equal(CommandExitCodes.Success, query.ExitCode); + Assert.Equal(string.Empty, query.Stderr); + Assert.Contains("src/App.cs", query.Stdout, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(importRoot); + } + } + + [Fact] + public void RunExportArchive_RedactsAbsoluteScopeFormsAndKeepsResolvedRelativePaths_Issue5195() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_redacted_scope_5195"); + try + { + var projectPath = TestProjectHelper.WriteTextFile( + projectRoot, + "src/App/App.csproj", + ""); + TestProjectHelper.WriteTextFile(projectRoot, "src/App/App.cs", "public class App { }"); + var solutionPath = TestProjectHelper.WriteTextFile( + projectRoot, + "App.sln", + "Project(\"{11111111-1111-1111-1111-111111111111}\") = \"App\", \"src/App/App.csproj\", \"{22222222-2222-2222-2222-222222222222}\"\nEndProject\n"); + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App/App.cs", "csharp", "public class App { }"); + var archivePath = Path.Combine(projectRoot, "scoped.cdidx.zip"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [ + archivePath, + "--db", dbPath, + "--project", Path.GetFullPath(projectPath), + "--solution", Path.GetFullPath(solutionPath), + "--path", "/Users/alice/work/private-repository/**", + "--exclude-path", @"C:\Users\Alice\work\private-repository\**", + "--redact-paths", + "--json", + ], + jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.DoesNotContain(projectRoot, stdout, StringComparison.Ordinal); + Assert.DoesNotContain("/Users/alice", stdout, StringComparison.Ordinal); + Assert.DoesNotContain(@"C:\Users\Alice", stdout, StringComparison.Ordinal); + using var result = JsonDocument.Parse(stdout); + var scope = result.RootElement.GetProperty("scope"); + Assert.True(scope.GetProperty("scoped").GetBoolean()); + Assert.Equal("[redacted]", scope.GetProperty("project")[0].GetString()); + Assert.Equal("[redacted]", scope.GetProperty("solution").GetString()); + Assert.Equal("[redacted]", scope.GetProperty("path")[0].GetString()); + Assert.Equal("[redacted]", scope.GetProperty("exclude_path")[0].GetString()); + Assert.Equal("src/App/*", scope.GetProperty("resolved_project_path")[0].GetString()); + Assert.Equal(1, scope.GetProperty("exported_file_count").GetInt64()); + var categories = result.RootElement.GetProperty("path_redaction_omitted_categories").EnumerateArray(); + Assert.Contains(categories, category => category.GetString() == "scope.project"); + Assert.Contains(categories, category => category.GetString() == "scope.solution"); + Assert.Contains(categories, category => category.GetString() == "scope.path"); + Assert.Contains(categories, category => category.GetString() == "scope.exclude_path"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static void SetUnknownExtensionPaths(string dbPath, string[] paths) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var writer = new DbWriter(db.Connection); + writer.SetMeta( + DbContext.UnknownExtensionDiagnosticsVersionMetaKey, + DbContext.UnknownExtensionDiagnosticsVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + writer.SetMeta(DbContext.UnknownExtensionFileCountMetaKey, paths.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)); + writer.SetMeta(DbContext.UnknownExtensionFilePathsMetaKey, JsonSerializer.Serialize(paths)); + writer.SetMeta(DbContext.UnknownExtensionFilesTruncatedMetaKey, bool.FalseString); + writer.SetMeta( + DbContext.UnknownExtensionFilePathLimitMetaKey, + DbContext.UnknownExtensionFilePathSampleLimit.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private static string ExtractDatabase(string projectRoot, string archivePath, string fileName) + { + var extractedDb = Path.Combine(projectRoot, fileName); + using var archive = ZipFile.OpenRead(archivePath); + archive.GetEntry("codeindex.db")!.ExtractToFile(extractedDb); + return extractedDb; + } + + private static string? ReadMetaValue(string dbPath, string key) + { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key"; + command.Parameters.AddWithValue("@key", key); + return command.ExecuteScalar() as string; + } +} From 785a3b67a60590104a27f29713910b7e3ad36274 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 00:14:48 +0900 Subject: [PATCH 2/5] Align export redaction completion metadata (#5195) --- src/CodeIndex/Cli/CliFlagSchema.cs | 12 +++++++++++- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index a7433ef79..39a9fcaf9 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -835,7 +835,17 @@ private static IReadOnlyList BuildAll() ["export"] = Set("ctags"), }, }, - new() { Name = "--redact-paths", Description = "Redact machine-specific paths; opt in for portable archives (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report", "export"]).ToArray()) }, + new() + { + Name = "--redact-paths", + Description = "Redact machine-specific paths; opt in for portable archives (the default for recovery/config JSON and reports)", + PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report", "export"]).ToArray()), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["export"] = Set(), + }, + ParentCompletionCommands = Set("export"), + }, new() { Name = "--no-log", Description = "Exclude global tool log from bundle", PrimaryCommands = Set("report") }, new() { Name = "--include-args", Description = "Include args in bundle log", PrimaryCommands = Set("report") }, new() { Name = "--log-lines", ValuePlaceholder = "", Description = "Number of log lines to include in bundle (clamped to 2000)", PrimaryCommands = Set("report") }, diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index 721897371..3a3323766 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -232,7 +232,7 @@ public void DedicatedCommandOptionInventoriesMatchSchemaHelpAndParsers_Issue5194 ], [("export", null)] = [ - "--db", "--json", "--overwrite", "--lang", "--path", "--exclude-path", "--project", "--solution", "--exclude-tests", + "--db", "--json", "--overwrite", "--redact-paths", "--lang", "--path", "--exclude-path", "--project", "--solution", "--exclude-tests", ], [("export", "ctags")] = [ From d0a2d1e6777b42566015a6686bcbf591120d3f3f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 00:46:06 +0900 Subject: [PATCH 3/5] Harden export path redaction (#5195) --- DEVELOPER_GUIDE.md | 8 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 9 ++- changelog.d/unreleased/5195.security.md | 4 +- .../ExportImportCommandRunner.ArchiveScope.cs | 3 +- ...ExportImportCommandRunner.ExportArchive.cs | 2 + .../Cli/ExportImportCommandRunner.Manifest.cs | 3 +- ...ExportImportCommandRunner.PathRedaction.cs | 81 ++++++++++++++++--- ...ExportImportCommandRunnerIssue5195Tests.cs | 79 ++++++++++++++++++ 9 files changed, 172 insertions(+), 21 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index d9eb331d9..d75af2c29 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -63,7 +63,9 @@ Portable archive path privacy is opt-in for compatibility. Default exports retai values, and resolved success paths. `--redact-paths` must operate only on the private copied snapshot: resolve and apply scope first, delete the copied project root, replace absolute POSIX/Windows/file-URI scope values and persisted -path-sample values with `[redacted]`, then run `VACUUM` before computing +flat and grouped path-sample values with `[redacted]`, and fail closed by deleting +malformed or over-budget path metadata from the copy. Run exactly one final +`VACUUM` after scope and redaction before computing `database_sha256`. Successful manifest and export JSON must keep `path_redaction_requested`, `path_redaction_complete`, and bounded stable `path_redaction_omitted_categories` synchronized. Redacted success output must @@ -4265,7 +4267,9 @@ portable archive の path privacy は互換性のため opt-in です。既定 e success path を保持します。`--redact-paths` は private な copy 済み snapshot だけを変更します。 最初に scope を解決・適用し、copy 側の project root を削除して、POSIX / Windows / file URI 形式の絶対 scope value と永続化済み path sample を `[redacted]` に置換します。 -その後 `VACUUM` を実行してから `database_sha256` を計算します。成功時の manifest と +flat / group 別 sample の両方を対象とし、不正または上限超過の path metadata は fail-closed +として copy から削除します。scope と redaction の後に最終 `VACUUM` を一度だけ実行してから +`database_sha256` を計算します。成功時の manifest と export JSON では `path_redaction_requested`、`path_redaction_complete`、上限付きで安定した `path_redaction_omitted_categories` を同期させます。redacted success output は解決済み archive / database / source-root path を再表示してはいけません。repository-relative な diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index afb674278..7dfe168ab 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -445,7 +445,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result - `ExportImportCommandRunnerIssue5185Tests` owns the import-staging confidentiality regression. Keep the non-dry-run parent at mode `0755`, set process umask `022` only inside the non-parallel SQLite-sensitive collection, observe the empty staging DB before extraction can write its first byte, and verify owner-only DB/WAL/SHM modes through validation, trust rewriting, `--prune-paths`, rollback-backup creation, cancellation cleanup, collision rejection, and final replacement. POSIX mode assertions must remain platform-conditional while success and `CreateNew` collision behavior continue to run on Windows. - `ExportImportCommandRunnerIssue5195Tests` - owns portable-archive path-redaction coverage. Keep default path compatibility separate from the redacted full-archive fixture; the latter jointly verifies manifest and SQLite root removal, POSIX/Windows path-form handling, scope selectors, hash-after-redaction ordering, source-byte immutability, owner-only atomic replacement, import/dry-run tolerance, and a post-import query. + owns portable-archive path-redaction coverage. Keep default path compatibility separate from the redacted full-archive fixture; the latter jointly verifies manifest and SQLite root removal, flat and grouped POSIX/Windows path samples, scope selectors, hash-after-redaction ordering, source-byte immutability, owner-only atomic replacement, import/dry-run tolerance, and a post-import query. Preserve the separate fail-closed fixture for valid path-list metadata above 64 KiB plus malformed or over-budget path metadata. - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`, `Run_CancelDuringDryRunScan_ReturnsInterruptedJson`, and `Run_CancelBeforeFreshScan_ReturnsInterruptedJson` exercise the same in-process cancellation paths used after Ctrl-C/SIGINT wiring, including scan-time cancellation, so interrupted index runs keep returning the canonical JSON error contract. - `IndexCommandRunnerTests.RunOptimizeFts_DryRunPreviewsWithoutWritingThenOptimizeMutates_Issue4577`, `RunOptimizeFts_DryRunPathDisclosureRequiresShowPaths_Issue5193`, `RunOptimizeFts_LockHeld_ReportsDbLocked`, and `RunOptimizeFts_ReadOnlyUri_ReturnsDbNotWritable` @@ -1593,7 +1593,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ExportImportCommandRunnerIssue5185Tests` import staging の confidentiality 回帰を担当します。non-dry-run の parent を mode `0755` にし、process umask `022` は非並列の SQLite-sensitive collection 内だけで設定し、extraction が最初の byte を書く前の空 staging DB を観測してください。validation、trust rewrite、`--prune-paths`、rollback backup 作成、cancellation cleanup、衝突拒否、最終 replacement を通して DB/WAL/SHM が owner-only であることを検証します。POSIX mode assertion は platform 条件付きに保ちつつ、success と `CreateNew` の衝突挙動は Windows でも実行してください。 - `ExportImportCommandRunnerIssue5195Tests` - portable archive の path-redaction coverage を担当します。既定 path の互換性は redacted full-archive fixture と分離し、後者では manifest / SQLite の root 除去、POSIX / Windows path 形式、scope selector、redaction 後の hash 計算順、source byte 不変性、owner-only atomic replacement、import / dry-run の許容、import 後 query を一体で検証してください。 + portable archive の path-redaction coverage を担当します。既定 path の互換性は redacted full-archive fixture と分離し、後者では manifest / SQLite の root 除去、flat / group 別の POSIX / Windows path sample、scope selector、redaction 後の hash 計算順、source byte 不変性、owner-only atomic replacement、import / dry-run の許容、import 後 query を一体で検証してください。64 KiB を超える正当な path-list metadata と、不正または上限超過の path metadata を扱う fail-closed fixture も分離して維持してください。 - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`、`Run_CancelDuringDryRunScan_ReturnsInterruptedJson`、`Run_CancelBeforeFreshScan_ReturnsInterruptedJson` Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を、scan 中のキャンセルも含めて検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 - `IndexCommandRunnerTests.RunOptimizeFts_DryRunPreviewsWithoutWritingThenOptimizeMutates_Issue4577`、`RunOptimizeFts_DryRunPathDisclosureRequiresShowPaths_Issue5193`、`RunOptimizeFts_LockHeld_ReportsDbLocked`、`RunOptimizeFts_ReadOnlyUri_ReturnsDbNotWritable` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index f9f084bdc..b81cc66ad 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -583,7 +583,10 @@ reports the resolved archive and database paths. Use `--redact-paths` before sharing an archive outside the source machine. This opt-in mode removes the project root from both manifest and snapshot, replaces absolute POSIX, Windows, or file-URI scope values and unknown-extension path samples with `[redacted]`, -then vacuums the copied snapshot before computing `database_sha256`. Indexed +including the grouped samples exposed by `status`. Malformed or over-budget +path-sample metadata is removed from the private copy instead of being retained +under a completed-redaction claim. The exporter then vacuums the copied snapshot +once before computing `database_sha256`. Indexed repository-relative paths, source text, hashes, readiness, and commit provenance are retained. Export JSON and the manifest expose `path_redaction_requested`, `path_redaction_complete`, and `path_redaction_omitted_categories`; top-level @@ -4302,7 +4305,9 @@ stamp、readiness state、unknown-extension summary、export scope が含まれ database path を報告します。source machine の外へ共有する前に `--redact-paths` を指定してください。 この opt-in mode は manifest と snapshot の両方から project root を除去し、scope と unknown-extension path sample に含まれる POSIX / Windows / file URI 形式の絶対 path を -`[redacted]` に置換してから、copy 済み snapshot を vacuum し、その後で +`[redacted]` に置換し、`status` が公開する group 別 sample も同様に処理します。不正または +上限超過の path-sample metadata は redaction 完了と報告したまま保持せず、private copy から +削除します。その後 copy 済み snapshot を一度だけ vacuum し、 `database_sha256` を計算します。repository-relative な indexed path、source text、hash、 readiness、commit provenance は維持します。export JSON と manifest は `path_redaction_requested`、`path_redaction_complete`、 diff --git a/changelog.d/unreleased/5195.security.md b/changelog.d/unreleased/5195.security.md index 01d7f6201..352704fa5 100644 --- a/changelog.d/unreleased/5195.security.md +++ b/changelog.d/unreleased/5195.security.md @@ -10,8 +10,8 @@ affected: ## English -- **Portable archives can now redact source-machine paths (#5195)** — `cdidx export --redact-paths` removes the project root from both `manifest.json` and the copied SQLite snapshot, redacts absolute scope and diagnostic path samples, computes integrity hashes after redaction, suppresses resolved output paths, and reports requested/completed privacy metadata without mutating the source database. +- **Portable archives can now redact source-machine paths (#5195)** — `cdidx export --redact-paths` removes the project root from both `manifest.json` and the copied SQLite snapshot, redacts absolute scope and flat/grouped diagnostic path samples, removes unsafe path metadata fail-closed, computes integrity hashes after one final redaction vacuum, suppresses resolved output paths, and reports requested/completed privacy metadata without mutating the source database. ## 日本語 -- **portable archive で source machine の path を秘匿できるようになりました (#5195)** — `cdidx export --redact-paths` は `manifest.json` と copy 済み SQLite snapshot の両方から project root を除去し、scope と diagnostic sample の絶対 path を秘匿してから integrity hash を計算します。解決済み output path の再表示も抑止し、source database を変更せずに requested / completed privacy metadata を報告します。 +- **portable archive で source machine の path を秘匿できるようになりました (#5195)** — `cdidx export --redact-paths` は `manifest.json` と copy 済み SQLite snapshot の両方から project root を除去し、scope と flat / group 別 diagnostic sample の絶対 path を秘匿し、不正な path metadata は fail-closed で削除します。最終 redaction vacuum を一度だけ行ってから integrity hash を計算し、解決済み output path の再表示も抑止します。source database を変更せずに requested / completed privacy metadata を報告します。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs index f7e79a31c..89549c609 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ArchiveScope.cs @@ -145,8 +145,9 @@ WHERE reference_id NOT IN (SELECT id FROM symbol_references) throw new InvalidDataException("scoped archive snapshot failed SQLite foreign-key validation"); } - using (var vacuum = connection.CreateCommand()) + if (!options.RedactPaths) { + using var vacuum = connection.CreateCommand(); vacuum.CommandText = "VACUUM"; vacuum.ExecuteNonQuery(); } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs index 8851bf992..b2b505a8f 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ExportArchive.cs @@ -186,6 +186,7 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt appVersion, redaction.Scope, scopeOptions.RedactPaths, + redaction.Complete, redaction.OmittedCategories, cancellationToken); } @@ -202,6 +203,7 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt appVersion, redaction.Scope, scopeOptions.RedactPaths, + redaction.Complete, redaction.OmittedCategories, cancellationToken); } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs index 2bd8311f6..6f3a8a86a 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.Manifest.cs @@ -19,6 +19,7 @@ private static ExportManifest BuildManifest( string appVersion, ArchiveExportScopeResult scope, bool pathRedactionRequested, + bool pathRedactionComplete, string[] pathRedactionOmittedCategories, CancellationToken cancellationToken) { @@ -75,7 +76,7 @@ private static ExportManifest BuildManifest( IndexIncompleteReasons: indexIncompleteReasons, Scope: scope, PathRedactionRequested: pathRedactionRequested, - PathRedactionComplete: pathRedactionRequested, + PathRedactionComplete: pathRedactionComplete, PathRedactionOmittedCategories: pathRedactionOmittedCategories); } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs index 7d1933420..e20e2c763 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using CodeIndex.Database; using Microsoft.Data.Sqlite; @@ -8,6 +7,7 @@ internal static partial class ExportImportCommandRunner { private readonly record struct ArchivePathRedactionResult( ArchiveExportScopeResult Scope, + bool Complete, string[] OmittedCategories); private static ArchivePathRedactionResult ApplyArchivePathRedaction( @@ -17,7 +17,7 @@ private static ArchivePathRedactionResult ApplyArchivePathRedaction( CancellationToken cancellationToken) { if (!redactPaths) - return new ArchivePathRedactionResult(scope, []); + return new ArchivePathRedactionResult(scope, Complete: false, []); cancellationToken.ThrowIfCancellationRequested(); var omittedCategories = new HashSet(StringComparer.Ordinal); @@ -40,6 +40,10 @@ private static ArchivePathRedactionResult ApplyArchivePathRedaction( DbContext.UnknownExtensionFilePathsMetaKey, "unknown_extension_files", omittedCategories); + RedactPersistedUnknownExtensionGroups( + connection, + transaction, + omittedCategories); RedactPersistedPathList( connection, transaction, @@ -59,6 +63,7 @@ private static ArchivePathRedactionResult ApplyArchivePathRedaction( cancellationToken.ThrowIfCancellationRequested(); return new ArchivePathRedactionResult( redactedScope, + Complete: true, omittedCategories.Order(StringComparer.Ordinal).ToArray()); } @@ -119,27 +124,81 @@ private static void RedactPersistedPathList( HashSet omittedCategories) { var raw = ReadMetaString(connection, key, transaction); - if (string.IsNullOrWhiteSpace(raw) || raw.Length > MaxImportManifestBytes) + if (raw == null) return; - string[]? paths; - try + var paths = JsonStringListCodec.Deserialize(raw); + if (paths == null) { - paths = JsonSerializer.Deserialize(raw); + DeletePersistedPathMetadata(connection, transaction, key); + omittedCategories.Add(category); + return; } - catch (JsonException) + + var redacted = RedactArchivePathValues(paths, category, omittedCategories); + UpdatePersistedPathMetadata( + connection, + transaction, + key, + JsonStringListCodec.Serialize(redacted)); + } + + private static void RedactPersistedUnknownExtensionGroups( + SqliteConnection connection, + SqliteTransaction transaction, + HashSet omittedCategories) + { + var raw = ReadMetaString(connection, DbContext.UnknownExtensionGroupsMetaKey, transaction); + if (raw == null) + return; + + var groups = UnknownExtensionClassifier.DeserializeGroups(raw); + if (groups == null) { + DeletePersistedPathMetadata(connection, transaction, DbContext.UnknownExtensionGroupsMetaKey); + omittedCategories.Add("unknown_extension_groups"); return; } - if (paths == null || !paths.Any(LooksLikeMachineAbsolutePath)) - return; - var redacted = RedactArchivePathValues(paths, category, omittedCategories); + foreach (var group in groups) + { + group.SamplePaths = RedactArchivePathValues( + group.SamplePaths, + "unknown_extension_groups", + omittedCategories) + .ToList(); + } + + UpdatePersistedPathMetadata( + connection, + transaction, + DbContext.UnknownExtensionGroupsMetaKey, + UnknownExtensionClassifier.SerializeGroups(groups)); + } + + private static void UpdatePersistedPathMetadata( + SqliteConnection connection, + SqliteTransaction transaction, + string key, + string value) + { using var update = connection.CreateCommand(); update.Transaction = transaction; update.CommandText = "UPDATE codeindex_meta SET value = @value WHERE key = @key"; - SqliteCommandPolicy.Add(update, "@value", JsonSerializer.Serialize(redacted)); + SqliteCommandPolicy.Add(update, "@value", value); SqliteCommandPolicy.Add(update, "@key", key); update.ExecuteNonQuery(); } + + private static void DeletePersistedPathMetadata( + SqliteConnection connection, + SqliteTransaction transaction, + string key) + { + using var delete = connection.CreateCommand(); + delete.Transaction = transaction; + delete.CommandText = "DELETE FROM codeindex_meta WHERE key = @key"; + SqliteCommandPolicy.Add(delete, "@key", key); + delete.ExecuteNonQuery(); + } } diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs index 335b31b6f..8cd079f60 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs @@ -98,6 +98,9 @@ public void RunExportArchive_RedactsManifestSnapshotAndOutputAndImports_Issue519 Assert.Contains( manifest.GetProperty("path_redaction_omitted_categories").EnumerateArray(), category => category.GetString() == "unknown_extension_files"); + Assert.Contains( + manifest.GetProperty("path_redaction_omitted_categories").EnumerateArray(), + category => category.GetString() == "unknown_extension_groups"); Assert.Equal("[redacted]", manifest.GetProperty("unknown_extension_files")[0].GetString()); Assert.Equal("[redacted]", manifest.GetProperty("unknown_extension_files")[1].GetString()); Assert.Equal("docs/relative.baz", manifest.GetProperty("unknown_extension_files")[2].GetString()); @@ -110,6 +113,14 @@ public void RunExportArchive_RedactsManifestSnapshotAndOutputAndImports_Issue519 Assert.Equal("[redacted]", paths.RootElement[1].GetString()); Assert.Equal("docs/relative.baz", paths.RootElement[2].GetString()); } + var groups = UnknownExtensionClassifier.DeserializeGroups( + ReadMetaValue(extractedDb, DbContext.UnknownExtensionGroupsMetaKey)); + Assert.NotNull(groups); + var groupedSamples = groups.SelectMany(group => group.SamplePaths).ToArray(); + Assert.DoesNotContain(posixSample, groupedSamples); + Assert.DoesNotContain(windowsSample, groupedSamples); + Assert.Equal(2, groupedSamples.Count(path => path == "[redacted]")); + Assert.Contains("docs/relative.baz", groupedSamples); Assert.DoesNotContain(projectRoot, Encoding.UTF8.GetString(File.ReadAllBytes(extractedDb)), StringComparison.Ordinal); Assert.Equal( Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(extractedDb))).ToLowerInvariant(), @@ -170,6 +181,70 @@ public void RunExportArchive_RedactsManifestSnapshotAndOutputAndImports_Issue519 } } + [Fact] + public void RunExportArchive_RedactsLargeListsAndDeletesUnsafePathMetadata_Issue5195() + { + var projectRoot = TestProjectHelper.CreateTempProject("export_unsafe_path_metadata_5195"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var largePaths = Enumerable.Range(0, 400) + .Select(index => @"C:\" + new string('\\', 120) + $"secret-{index}.foo") + .ToArray(); + var largePathsJson = JsonStringListCodec.Serialize(largePaths); + Assert.True(largePathsJson.Length > 64 * 1024); + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.UnknownExtensionFilePathsMetaKey, largePathsJson); + writer.SetMeta( + DbContext.UnknownExtensionGroupsMetaKey, + "[{\"extension\":\".foo\",\"sample_paths\":[\"/Users/alice/group-secret.foo\"]"); + writer.SetMeta( + DbContext.WorkspaceVerificationPendingPathsMetaKey, + JsonStringListCodec.Serialize( + ["/" + new string('x', JsonStringListCodec.MaxRawJsonCharacters)])); + } + SqliteConnection.ClearAllPools(); + + var archivePath = Path.Combine(projectRoot, "unsafe-metadata.cdidx.zip"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [archivePath, "--db", dbPath, "--redact-paths", "--json"], + jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var result = JsonDocument.Parse(stdout); + Assert.True(result.RootElement.GetProperty("path_redaction_complete").GetBoolean()); + var categories = result.RootElement + .GetProperty("path_redaction_omitted_categories") + .EnumerateArray(); + Assert.Contains(categories, category => category.GetString() == "unknown_extension_files"); + Assert.Contains(categories, category => category.GetString() == "unknown_extension_groups"); + Assert.Contains(categories, category => category.GetString() == "workspace_pending_paths"); + + var extractedDb = ExtractDatabase(projectRoot, archivePath, "unsafe-metadata.db"); + var redactedPaths = JsonStringListCodec.Deserialize( + ReadMetaValue(extractedDb, DbContext.UnknownExtensionFilePathsMetaKey)); + Assert.NotNull(redactedPaths); + Assert.Equal(largePaths.Length, redactedPaths.Count); + Assert.All(redactedPaths, path => Assert.Equal("[redacted]", path)); + Assert.Null(ReadMetaValue(extractedDb, DbContext.UnknownExtensionGroupsMetaKey)); + Assert.Null(ReadMetaValue(extractedDb, DbContext.WorkspaceVerificationPendingPathsMetaKey)); + var extractedBytes = Encoding.UTF8.GetString(File.ReadAllBytes(extractedDb)); + Assert.DoesNotContain("group-secret.foo", extractedBytes, StringComparison.Ordinal); + Assert.DoesNotContain("secret-0.foo", extractedBytes, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunExportArchive_RedactsAbsoluteScopeFormsAndKeepsResolvedRelativePaths_Issue5195() { @@ -240,6 +315,10 @@ private static void SetUnknownExtensionPaths(string dbPath, string[] paths) DbContext.UnknownExtensionDiagnosticsVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); writer.SetMeta(DbContext.UnknownExtensionFileCountMetaKey, paths.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)); writer.SetMeta(DbContext.UnknownExtensionFilePathsMetaKey, JsonSerializer.Serialize(paths)); + var classification = UnknownExtensionClassifier.Classify(paths); + writer.SetMeta( + DbContext.UnknownExtensionGroupsMetaKey, + UnknownExtensionClassifier.SerializeGroups(classification.Groups)); writer.SetMeta(DbContext.UnknownExtensionFilesTruncatedMetaKey, bool.FalseString); writer.SetMeta( DbContext.UnknownExtensionFilePathLimitMetaKey, From 688ec9079208a5a75a5bb9800c3abcea9065ca12 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 01:34:46 +0900 Subject: [PATCH 4/5] Preserve redaction and trust invariants (#5195) --- DEVELOPER_GUIDE.md | 13 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 16 +- changelog.d/unreleased/5195.security.md | 4 +- ...ortImportCommandRunner.ImportValidation.cs | 7 +- ...ExportImportCommandRunner.PathRedaction.cs | 240 +++++++++++++++++- .../Cli/ExportImportManifestCodec.cs | 10 +- src/CodeIndex/Cli/SolutionProjectResolver.cs | 28 +- ...ExportImportCommandRunnerIssue5195Tests.cs | 119 ++++++++- 9 files changed, 405 insertions(+), 36 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index d75af2c29..d780cb411 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -64,7 +64,9 @@ values, and resolved success paths. `--redact-paths` must operate only on the private copied snapshot: resolve and apply scope first, delete the copied project root, replace absolute POSIX/Windows/file-URI scope values and persisted flat and grouped path-sample values with `[redacted]`, and fail closed by deleting -malformed or over-budget path metadata from the copy. Run exactly one final +or emptying malformed or over-budget path metadata from the copy. Losing a +workspace-verification pending-path identity must also stamp its coverage marker +incomplete. Run exactly one final `VACUUM` after scope and redaction before computing `database_sha256`. Successful manifest and export JSON must keep `path_redaction_requested`, `path_redaction_complete`, and bounded stable @@ -73,7 +75,9 @@ not repeat the resolved archive, database, or source-root paths. Repository-rela indexed paths, source content, hashes, readiness, and commit provenance are not redaction targets. Import derives its destination project root from the destination DB path/current directory and must tolerate an absent source root in both execution -and dry-run modes. +and dry-run modes. Imports accepting `path_redaction_complete=true` must verify +the manifest root/scope values and known embedded path metadata before reporting +that claim as complete. Checkpoint plan drift detection covers DB/WAL/SHM content changes and sidecar appearance or disappearance through the final pre-publication validation. The plan's `uncertainty` value records the remaining post-validation race; copied outputs are independently hash-verified against the plan before atomic publication. DB/WAL/SHM candidates must pass native regular-file type validation before hashing so Unix FIFOs cannot block planning. `metadata_policy` reports `owner_only_files_and_directories` on POSIX and the actually inherited `inherited_windows_acls` policy on Windows. A database payload whose output name collides with `manifest.txt`, including filesystem-equivalent casing, makes the plan not ready and is rejected before mutation. @@ -4268,7 +4272,8 @@ success path を保持します。`--redact-paths` は private な copy 済み s 最初に scope を解決・適用し、copy 側の project root を削除して、POSIX / Windows / file URI 形式の絶対 scope value と永続化済み path sample を `[redacted]` に置換します。 flat / group 別 sample の両方を対象とし、不正または上限超過の path metadata は fail-closed -として copy から削除します。scope と redaction の後に最終 `VACUUM` を一度だけ実行してから +として copy から削除するか空にします。workspace verification の pending-path identity を失う場合は、 +coverage marker も incomplete に stamp します。scope と redaction の後に最終 `VACUUM` を一度だけ実行してから `database_sha256` を計算します。成功時の manifest と export JSON では `path_redaction_requested`、`path_redaction_complete`、上限付きで安定した `path_redaction_omitted_categories` を同期させます。redacted success output は解決済み @@ -4276,6 +4281,8 @@ archive / database / source-root path を再表示してはいけません。rep indexed path、source content、hash、readiness、commit provenance は redaction 対象外です。 import は destination DB path / current directory から destination project root を導出し、 source root がない archive を execution / dry-run の双方で許容する必要があります。 +`path_redaction_complete=true` を受け入れる import は、その claim を complete と報告する前に +manifest の root / scope value と既知の embedded path metadata を検証する必要があります。 checkpoint plan の drift 検出は、publish 前の最終検証までに起きる DB/WAL/SHM content の変更と sidecar の出現・消失を対象にします。plan の `uncertainty` 値は最終検証後に残る race を記録し、copy 済み output は atomic publish 前に plan の hash と個別に照合されます。DB/WAL/SHM 候補は hash 読み取り前に native regular-file type validation を通すため、Unix FIFO が plan 作成を停止させることはありません。`metadata_policy` は POSIX では `owner_only_files_and_directories`、Windows では実際に継承される `inherited_windows_acls` policy を報告します。database payload の output 名が filesystem 上で同一の大小文字を含め `manifest.txt` と衝突する場合、plan は not ready となり、変更前に拒否されます。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 8d496c59b..8d7356717 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -446,7 +446,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result - `ExportImportCommandRunnerIssue5185Tests` owns the import-staging confidentiality regression. Keep the non-dry-run parent at mode `0755`, set process umask `022` only inside the non-parallel SQLite-sensitive collection, observe the empty staging DB before extraction can write its first byte, and verify owner-only DB/WAL/SHM modes through validation, trust rewriting, `--prune-paths`, rollback-backup creation, cancellation cleanup, collision rejection, and final replacement. POSIX mode assertions must remain platform-conditional while success and `CreateNew` collision behavior continue to run on Windows. - `ExportImportCommandRunnerIssue5195Tests` - owns portable-archive path-redaction coverage. Keep default path compatibility separate from the redacted full-archive fixture; the latter jointly verifies manifest and SQLite root removal, flat and grouped POSIX/Windows path samples, scope selectors, hash-after-redaction ordering, source-byte immutability, owner-only atomic replacement, import/dry-run tolerance, and a post-import query. Preserve the separate fail-closed fixture for valid path-list metadata above 64 KiB plus malformed or over-budget path metadata. + owns portable-archive path-redaction coverage. Keep default path compatibility separate from the redacted full-archive fixture; the latter jointly verifies manifest and SQLite root removal, flat and grouped POSIX/Windows path samples, whitespace-wrapped absolute scope selectors, pending-path coverage demotion, hash-after-redaction ordering, source-byte immutability, owner-only atomic replacement, import/dry-run tolerance, and a post-import query. Preserve the separate fail-closed fixture for valid path-list metadata above 64 KiB plus malformed or over-budget path metadata, the forged completed-claim import rejection, and exact-case absolute project selection. - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`, `Run_CancelDuringDryRunScan_ReturnsInterruptedJson`, and `Run_CancelBeforeFreshScan_ReturnsInterruptedJson` exercise the same in-process cancellation paths used after Ctrl-C/SIGINT wiring, including scan-time cancellation, so interrupted index runs keep returning the canonical JSON error contract. - `IndexCommandRunnerTests.RunOptimizeFts_DryRunPreviewsWithoutWritingThenOptimizeMutates_Issue4577`, `RunOptimizeFts_DryRunPathDisclosureRequiresShowPaths_Issue5193`, `RunOptimizeFts_LockHeld_ReportsDbLocked`, and `RunOptimizeFts_ReadOnlyUri_ReturnsDbNotWritable` @@ -1595,7 +1595,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ExportImportCommandRunnerIssue5185Tests` import staging の confidentiality 回帰を担当します。non-dry-run の parent を mode `0755` にし、process umask `022` は非並列の SQLite-sensitive collection 内だけで設定し、extraction が最初の byte を書く前の空 staging DB を観測してください。validation、trust rewrite、`--prune-paths`、rollback backup 作成、cancellation cleanup、衝突拒否、最終 replacement を通して DB/WAL/SHM が owner-only であることを検証します。POSIX mode assertion は platform 条件付きに保ちつつ、success と `CreateNew` の衝突挙動は Windows でも実行してください。 - `ExportImportCommandRunnerIssue5195Tests` - portable archive の path-redaction coverage を担当します。既定 path の互換性は redacted full-archive fixture と分離し、後者では manifest / SQLite の root 除去、flat / group 別の POSIX / Windows path sample、scope selector、redaction 後の hash 計算順、source byte 不変性、owner-only atomic replacement、import / dry-run の許容、import 後 query を一体で検証してください。64 KiB を超える正当な path-list metadata と、不正または上限超過の path metadata を扱う fail-closed fixture も分離して維持してください。 + portable archive の path-redaction coverage を担当します。既定 path の互換性は redacted full-archive fixture と分離し、後者では manifest / SQLite の root 除去、flat / group 別の POSIX / Windows path sample、前後に空白を含む絶対 scope selector、pending-path coverage の demotion、redaction 後の hash 計算順、source byte 不変性、owner-only atomic replacement、import / dry-run の許容、import 後 query を一体で検証してください。64 KiB を超える正当な path-list metadata と不正または上限超過の path metadata を扱う fail-closed fixture、偽造された完了 claim の import 拒否、絶対 project 指定の正確な大小文字選択も維持してください。 - `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`、`Run_CancelDuringDryRunScan_ReturnsInterruptedJson`、`Run_CancelBeforeFreshScan_ReturnsInterruptedJson` Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を、scan 中のキャンセルも含めて検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 - `IndexCommandRunnerTests.RunOptimizeFts_DryRunPreviewsWithoutWritingThenOptimizeMutates_Issue4577`、`RunOptimizeFts_DryRunPathDisclosureRequiresShowPaths_Issue5193`、`RunOptimizeFts_LockHeld_ReportsDbLocked`、`RunOptimizeFts_ReadOnlyUri_ReturnsDbNotWritable` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b81cc66ad..1622f13a6 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -584,8 +584,10 @@ sharing an archive outside the source machine. This opt-in mode removes the project root from both manifest and snapshot, replaces absolute POSIX, Windows, or file-URI scope values and unknown-extension path samples with `[redacted]`, including the grouped samples exposed by `status`. Malformed or over-budget -path-sample metadata is removed from the private copy instead of being retained -under a completed-redaction claim. The exporter then vacuums the copied snapshot +path-sample metadata is removed or replaced with an empty fail-closed value instead +of being retained under a completed-redaction claim. If a workspace-verification +pending-path identity is removed or redacted, its coverage marker is set incomplete +so a later scoped refresh cannot trust the placeholder. The exporter then vacuums the copied snapshot once before computing `database_sha256`. Indexed repository-relative paths, source text, hashes, readiness, and commit provenance are retained. Export JSON and the manifest expose `path_redaction_requested`, @@ -595,7 +597,9 @@ repeat a local path. The source database is never modified. The archive path is intended for trusted CodeIndex databases. Import validates that the embedded SQLite file is a CodeIndex DB before replacing the destination -database. `--prune-paths` rewrites the imported `indexed_project_root` metadata +database. A completed path-redaction claim is also checked against the manifest +root/scope fields and the corresponding embedded path metadata; an inconsistent +claim is rejected rather than echoed as verified. `--prune-paths` rewrites the imported `indexed_project_root` metadata to the import target project root. Imports targeting `.../.cdidx/codeindex.db` use the sibling project directory; other database paths fall back to the process current directory. A path-redacted archive may omit the source root entirely; @@ -4307,7 +4311,9 @@ database path を報告します。source machine の外へ共有する前に `- unknown-extension path sample に含まれる POSIX / Windows / file URI 形式の絶対 path を `[redacted]` に置換し、`status` が公開する group 別 sample も同様に処理します。不正または 上限超過の path-sample metadata は redaction 完了と報告したまま保持せず、private copy から -削除します。その後 copy 済み snapshot を一度だけ vacuum し、 +削除するか、空の fail-closed 値へ置換します。workspace verification の pending-path identity を +削除または秘匿した場合は coverage marker を incomplete にし、後続の scoped refresh が placeholder を +信頼しないようにします。その後 copy 済み snapshot を一度だけ vacuum し、 `database_sha256` を計算します。repository-relative な indexed path、source text、hash、 readiness、commit provenance は維持します。export JSON と manifest は `path_redaction_requested`、`path_redaction_complete`、 @@ -4317,6 +4323,8 @@ source database は変更しません。 archive は信頼できる CodeIndex database の共有向けです。Import は埋め込まれた SQLite file が CodeIndex DB であることを検証してから destination database を置き換えます。 +path redaction 完了の claim は manifest の root / scope field と対応する embedded path metadata に +照合し、不整合な claim は verified として再表示せず拒否します。 `--prune-paths` は import した `indexed_project_root` metadata を import 先 project root に書き換えます。 `.../.cdidx/codeindex.db` を import 先にした場合は sibling の project directory を使い、 それ以外の database path では process current directory に fallback します。path-redacted diff --git a/changelog.d/unreleased/5195.security.md b/changelog.d/unreleased/5195.security.md index 352704fa5..00003397b 100644 --- a/changelog.d/unreleased/5195.security.md +++ b/changelog.d/unreleased/5195.security.md @@ -10,8 +10,8 @@ affected: ## English -- **Portable archives can now redact source-machine paths (#5195)** — `cdidx export --redact-paths` removes the project root from both `manifest.json` and the copied SQLite snapshot, redacts absolute scope and flat/grouped diagnostic path samples, removes unsafe path metadata fail-closed, computes integrity hashes after one final redaction vacuum, suppresses resolved output paths, and reports requested/completed privacy metadata without mutating the source database. +- **Portable archives can now redact source-machine paths (#5195)** — `cdidx export --redact-paths` removes the project root from both `manifest.json` and the copied SQLite snapshot, redacts absolute scope and flat/grouped diagnostic path samples, demotes trust when pending-path identities are lost, removes unsafe path metadata fail-closed, computes integrity hashes after one final redaction vacuum, suppresses resolved output paths, and reports verifiable requested/completed privacy metadata without mutating the source database. ## 日本語 -- **portable archive で source machine の path を秘匿できるようになりました (#5195)** — `cdidx export --redact-paths` は `manifest.json` と copy 済み SQLite snapshot の両方から project root を除去し、scope と flat / group 別 diagnostic sample の絶対 path を秘匿し、不正な path metadata は fail-closed で削除します。最終 redaction vacuum を一度だけ行ってから integrity hash を計算し、解決済み output path の再表示も抑止します。source database を変更せずに requested / completed privacy metadata を報告します。 +- **portable archive で source machine の path を秘匿できるようになりました (#5195)** — `cdidx export --redact-paths` は `manifest.json` と copy 済み SQLite snapshot の両方から project root を除去し、scope と flat / group 別 diagnostic sample の絶対 path を秘匿し、pending-path identity を失った場合は trust を demote して、不正な path metadata を fail-closed で除去します。最終 redaction vacuum を一度だけ行ってから integrity hash を計算し、解決済み output path の再表示も抑止します。source database を変更せず、検証可能な requested / completed privacy metadata を報告します。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs index 5c0f0d119..0c2844758 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.ImportValidation.cs @@ -146,7 +146,12 @@ private static bool TryValidateImportedManifest( if (!TryValidateManifestCount(manifest.FileCount, connection, "files", "file_count", out message, cancellationToken) || !TryValidateManifestCount(manifest.ChunkCount, connection, "chunks", "chunk_count", out message, cancellationToken) || !TryValidateManifestCount(manifest.SymbolCount, connection, "symbols", "symbol_count", out message, cancellationToken) - || !TryValidateManifestCount(manifest.ReferenceCount, connection, "symbol_references", "reference_count", out message, cancellationToken)) + || !TryValidateManifestCount(manifest.ReferenceCount, connection, "symbol_references", "reference_count", out message, cancellationToken) + || !TryValidateCompletedDatabasePathRedaction( + manifest, + connection, + out message, + cancellationToken)) { return false; } diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs index e20e2c763..0e30a3a93 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.PathRedaction.cs @@ -44,11 +44,9 @@ private static ArchivePathRedactionResult ApplyArchivePathRedaction( connection, transaction, omittedCategories); - RedactPersistedPathList( + RedactPersistedWorkspacePendingPaths( connection, transaction, - DbContext.WorkspaceVerificationPendingPathsMetaKey, - "workspace_pending_paths", omittedCategories); transaction.Commit(); } @@ -100,20 +98,21 @@ private static string[] RedactArchivePathValues( return RedactedArchivePath; } - private static bool LooksLikeMachineAbsolutePath(string value) + internal static bool LooksLikeMachineAbsolutePath(string value) { - if (string.IsNullOrWhiteSpace(value)) + var trimmed = value.Trim(); + if (trimmed.Length == 0) return false; - if (value.StartsWith("file:", StringComparison.OrdinalIgnoreCase) - || value[0] is '/' or '\\') + if (trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + || trimmed[0] is '/' or '\\') { return true; } - return value.Length >= 3 - && char.IsAsciiLetter(value[0]) - && value[1] == ':' - && value[2] is '/' or '\\'; + return trimmed.Length >= 3 + && char.IsAsciiLetter(trimmed[0]) + && trimmed[1] == ':' + && trimmed[2] is '/' or '\\'; } private static void RedactPersistedPathList( @@ -143,6 +142,54 @@ private static void RedactPersistedPathList( JsonStringListCodec.Serialize(redacted)); } + private static void RedactPersistedWorkspacePendingPaths( + SqliteConnection connection, + SqliteTransaction transaction, + HashSet omittedCategories) + { + var raw = ReadMetaString( + connection, + DbContext.WorkspaceVerificationPendingPathsMetaKey, + transaction); + if (raw == null) + return; + + var paths = JsonStringListCodec.Deserialize(raw); + if (paths == null) + { + UpsertPersistedPathMetadata( + connection, + transaction, + DbContext.WorkspaceVerificationPendingPathsMetaKey, + JsonStringListCodec.Serialize([])); + MarkWorkspacePendingPathCoverageIncomplete(connection, transaction); + omittedCategories.Add("workspace_pending_paths"); + return; + } + + var identityLost = paths.Any(LooksLikeMachineAbsolutePath); + var redacted = RedactArchivePathValues( + paths, + "workspace_pending_paths", + omittedCategories); + UpdatePersistedPathMetadata( + connection, + transaction, + DbContext.WorkspaceVerificationPendingPathsMetaKey, + JsonStringListCodec.Serialize(redacted)); + if (identityLost) + MarkWorkspacePendingPathCoverageIncomplete(connection, transaction); + } + + private static void MarkWorkspacePendingPathCoverageIncomplete( + SqliteConnection connection, + SqliteTransaction transaction) + => UpsertPersistedPathMetadata( + connection, + transaction, + DbContext.WorkspaceVerificationPendingPathsCompleteMetaKey, + bool.FalseString); + private static void RedactPersistedUnknownExtensionGroups( SqliteConnection connection, SqliteTransaction transaction, @@ -190,6 +237,24 @@ private static void UpdatePersistedPathMetadata( update.ExecuteNonQuery(); } + private static void UpsertPersistedPathMetadata( + SqliteConnection connection, + SqliteTransaction transaction, + string key, + string value) + { + using var upsert = connection.CreateCommand(); + upsert.Transaction = transaction; + upsert.CommandText = """ + INSERT INTO codeindex_meta(key, value) + VALUES(@key, @value) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + """; + SqliteCommandPolicy.Add(upsert, "@key", key); + SqliteCommandPolicy.Add(upsert, "@value", value); + upsert.ExecuteNonQuery(); + } + private static void DeletePersistedPathMetadata( SqliteConnection connection, SqliteTransaction transaction, @@ -201,4 +266,157 @@ private static void DeletePersistedPathMetadata( SqliteCommandPolicy.Add(delete, "@key", key); delete.ExecuteNonQuery(); } + + internal static bool TryValidateCompletedManifestPathRedaction( + ExportManifest manifest, + out string message) + { + if (!manifest.PathRedactionComplete) + { + message = string.Empty; + return true; + } + + if (manifest.ProjectRoot != null) + { + message = "path_redaction_complete requires project_root to be omitted"; + return false; + } + if (ContainsMachineAbsolutePath(manifest.UnknownExtensionFiles)) + { + message = "path_redaction_complete cannot include absolute unknown_extension_files values"; + return false; + } + + var scope = manifest.Scope; + if (scope != null + && (ContainsMachineAbsolutePath(scope.PathPatterns) + || ContainsMachineAbsolutePath(scope.ExcludePathPatterns) + || ContainsMachineAbsolutePath(scope.Projects) + || ContainsMachineAbsolutePath(scope.ResolvedProjectPathPatterns) + || scope.Solution != null && LooksLikeMachineAbsolutePath(scope.Solution))) + { + message = "path_redaction_complete cannot include absolute scope values"; + return false; + } + + message = string.Empty; + return true; + } + + internal static bool TryValidateCompletedDatabasePathRedaction( + ExportManifest manifest, + SqliteConnection connection, + out string message, + CancellationToken cancellationToken) + { + if (!manifest.PathRedactionComplete) + { + message = string.Empty; + return true; + } + + cancellationToken.ThrowIfCancellationRequested(); + if (ReadMetaString(connection, DbContext.IndexedProjectRootMetaKey) != null) + { + message = "path_redaction_complete does not match the embedded project root metadata"; + return false; + } + if (!TryValidatePersistedPathListRedaction( + connection, + DbContext.UnknownExtensionFilePathsMetaKey, + "unknown-extension path", + out _, + out message) + || !TryValidatePersistedUnknownExtensionGroupRedaction(connection, out message) + || !TryValidatePersistedPathListRedaction( + connection, + DbContext.WorkspaceVerificationPendingPathsMetaKey, + "workspace pending path", + out var workspaceContainsRedactedValue, + out message)) + { + return false; + } + + var workspaceIdentityOmitted = workspaceContainsRedactedValue + || manifest.PathRedactionOmittedCategories?.Contains( + "workspace_pending_paths", + StringComparer.Ordinal) == true; + if (workspaceIdentityOmitted + && !string.Equals( + ReadMetaString(connection, DbContext.WorkspaceVerificationPendingPathsCompleteMetaKey), + bool.FalseString, + StringComparison.OrdinalIgnoreCase)) + { + message = "redacted workspace pending paths require incomplete coverage metadata"; + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + message = string.Empty; + return true; + } + + private static bool TryValidatePersistedPathListRedaction( + SqliteConnection connection, + string key, + string description, + out bool containsRedactedValue, + out string message) + { + containsRedactedValue = false; + var raw = ReadMetaString(connection, key); + if (raw == null) + { + message = string.Empty; + return true; + } + + var paths = JsonStringListCodec.Deserialize(raw); + if (paths == null) + { + message = $"path_redaction_complete does not match valid embedded {description} metadata"; + return false; + } + if (ContainsMachineAbsolutePath(paths)) + { + message = $"path_redaction_complete does not match embedded absolute {description} metadata"; + return false; + } + + containsRedactedValue = paths.Contains(RedactedArchivePath, StringComparer.Ordinal); + message = string.Empty; + return true; + } + + private static bool TryValidatePersistedUnknownExtensionGroupRedaction( + SqliteConnection connection, + out string message) + { + var raw = ReadMetaString(connection, DbContext.UnknownExtensionGroupsMetaKey); + if (raw == null) + { + message = string.Empty; + return true; + } + + var groups = UnknownExtensionClassifier.DeserializeGroups(raw); + if (groups == null) + { + message = "path_redaction_complete does not match valid embedded unknown-extension group metadata"; + return false; + } + if (groups.Any(group => ContainsMachineAbsolutePath(group.SamplePaths))) + { + message = "path_redaction_complete does not match embedded absolute unknown-extension group metadata"; + return false; + } + + message = string.Empty; + return true; + } + + private static bool ContainsMachineAbsolutePath(IEnumerable? values) + => values?.Any(LooksLikeMachineAbsolutePath) == true; } diff --git a/src/CodeIndex/Cli/ExportImportManifestCodec.cs b/src/CodeIndex/Cli/ExportImportManifestCodec.cs index 3a480b049..c46bbb537 100644 --- a/src/CodeIndex/Cli/ExportImportManifestCodec.cs +++ b/src/CodeIndex/Cli/ExportImportManifestCodec.cs @@ -200,8 +200,9 @@ private static bool TryValidatePathRedaction( var categories = manifest.PathRedactionOmittedCategories; if (categories == null) { - message = string.Empty; - return true; + return ExportImportCommandRunner.TryValidateCompletedManifestPathRedaction( + manifest, + out message); } if (!manifest.PathRedactionRequested && categories.Length > 0) { @@ -230,8 +231,9 @@ private static bool TryValidatePathRedaction( } } - message = string.Empty; - return true; + return ExportImportCommandRunner.TryValidateCompletedManifestPathRedaction( + manifest, + out message); } private static bool TryValidateScope( diff --git a/src/CodeIndex/Cli/SolutionProjectResolver.cs b/src/CodeIndex/Cli/SolutionProjectResolver.cs index aa2911bab..5af0c6d1b 100644 --- a/src/CodeIndex/Cli/SolutionProjectResolver.cs +++ b/src/CodeIndex/Cli/SolutionProjectResolver.cs @@ -364,23 +364,37 @@ private static DotNetProjectInfo BuildProjectInfo(string fullProjectPath, string return new DotNetProjectInfo(name, relativeProject, Path.GetDirectoryName(fullProjectPath) ?? workspaceRoot); } - private static DotNetProjectInfo? MatchProject(IReadOnlyList projects, string requested) + internal static DotNetProjectInfo? MatchProject(IReadOnlyList projects, string requested) { var trimmed = requested.Trim(); var absoluteRequested = Path.IsPathFullyQualified(trimmed) ? Path.GetFullPath(trimmed) : null; + if (absoluteRequested != null) + { + var exactMatch = projects.FirstOrDefault(project => + string.Equals( + GetAbsoluteProjectPath(project), + absoluteRequested, + StringComparison.Ordinal)); + if (exactMatch != null) + return exactMatch; + + return projects.FirstOrDefault(project => + PathCasing.PathsEqual( + GetAbsoluteProjectPath(project), + absoluteRequested)); + } + return projects.FirstOrDefault(project => string.Equals(project.Name, trimmed, StringComparison.OrdinalIgnoreCase) || string.Equals(project.ProjectPath, trimmed.Replace('\\', '/'), StringComparison.OrdinalIgnoreCase) - || string.Equals(Path.GetFileName(project.ProjectPath), trimmed, StringComparison.OrdinalIgnoreCase) - || absoluteRequested != null - && string.Equals( - Path.GetFullPath(Path.Combine(project.DirectoryPath, Path.GetFileName(project.ProjectPath))), - absoluteRequested, - StringComparison.OrdinalIgnoreCase)); + || string.Equals(Path.GetFileName(project.ProjectPath), trimmed, StringComparison.OrdinalIgnoreCase)); } + private static string GetAbsoluteProjectPath(DotNetProjectInfo project) + => Path.GetFullPath(Path.Combine(project.DirectoryPath, Path.GetFileName(project.ProjectPath))); + private static IEnumerable EnumerateFilesUsingIndexerPolicy( string workspaceRoot, string startDirectory, diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs index 8cd079f60..7478b2c4a 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; using Microsoft.Data.Sqlite; @@ -64,6 +65,7 @@ public void RunExportArchive_RedactsManifestSnapshotAndOutputAndImports_Issue519 const string posixSample = "/Users/alice/work/private-repository/secret.foo"; const string windowsSample = @"C:\Users\Alice\work\private-repository\secret.bar"; SetUnknownExtensionPaths(dbPath, [posixSample, windowsSample, "docs/relative.baz"]); + SetWorkspaceVerificationPendingPaths(dbPath, [posixSample, "src/App.cs"]); SqliteConnection.ClearAllPools(); var sourceBytesBefore = File.ReadAllBytes(dbPath); var archivePath = Path.Combine(projectRoot, "redacted.cdidx.zip"); @@ -122,6 +124,13 @@ public void RunExportArchive_RedactsManifestSnapshotAndOutputAndImports_Issue519 Assert.Equal(2, groupedSamples.Count(path => path == "[redacted]")); Assert.Contains("docs/relative.baz", groupedSamples); Assert.DoesNotContain(projectRoot, Encoding.UTF8.GetString(File.ReadAllBytes(extractedDb)), StringComparison.Ordinal); + var pendingPaths = JsonStringListCodec.Deserialize( + ReadMetaValue(extractedDb, DbContext.WorkspaceVerificationPendingPathsMetaKey)); + Assert.NotNull(pendingPaths); + Assert.Equal(["[redacted]", "src/App.cs"], pendingPaths); + Assert.Equal( + bool.FalseString, + ReadMetaValue(extractedDb, DbContext.WorkspaceVerificationPendingPathsCompleteMetaKey)); Assert.Equal( Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(extractedDb))).ToLowerInvariant(), manifest.GetProperty("database_sha256").GetString()); @@ -205,6 +214,9 @@ public void RunExportArchive_RedactsLargeListsAndDeletesUnsafePathMetadata_Issue DbContext.WorkspaceVerificationPendingPathsMetaKey, JsonStringListCodec.Serialize( ["/" + new string('x', JsonStringListCodec.MaxRawJsonCharacters)])); + writer.SetMeta( + DbContext.WorkspaceVerificationPendingPathsCompleteMetaKey, + bool.TrueString); } SqliteConnection.ClearAllPools(); @@ -234,7 +246,11 @@ public void RunExportArchive_RedactsLargeListsAndDeletesUnsafePathMetadata_Issue Assert.Equal(largePaths.Length, redactedPaths.Count); Assert.All(redactedPaths, path => Assert.Equal("[redacted]", path)); Assert.Null(ReadMetaValue(extractedDb, DbContext.UnknownExtensionGroupsMetaKey)); - Assert.Null(ReadMetaValue(extractedDb, DbContext.WorkspaceVerificationPendingPathsMetaKey)); + Assert.Empty(JsonStringListCodec.Deserialize( + ReadMetaValue(extractedDb, DbContext.WorkspaceVerificationPendingPathsMetaKey))!); + Assert.Equal( + bool.FalseString, + ReadMetaValue(extractedDb, DbContext.WorkspaceVerificationPendingPathsCompleteMetaKey)); var extractedBytes = Encoding.UTF8.GetString(File.ReadAllBytes(extractedDb)); Assert.DoesNotContain("group-secret.foo", extractedBytes, StringComparison.Ordinal); Assert.DoesNotContain("secret-0.foo", extractedBytes, StringComparison.Ordinal); @@ -270,7 +286,7 @@ public void RunExportArchive_RedactsAbsoluteScopeFormsAndKeepsResolvedRelativePa [ archivePath, "--db", dbPath, - "--project", Path.GetFullPath(projectPath), + "--project", $" {Path.GetFullPath(projectPath)} ", "--solution", Path.GetFullPath(solutionPath), "--path", "/Users/alice/work/private-repository/**", "--exclude-path", @"C:\Users\Alice\work\private-repository\**", @@ -306,6 +322,74 @@ public void RunExportArchive_RedactsAbsoluteScopeFormsAndKeepsResolvedRelativePa } } + [Fact] + public void RunImport_RejectsUnverifiedCompletedRedactionClaim_Issue5195() + { + var projectRoot = TestProjectHelper.CreateTempProject("import_unverified_redaction_5195"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var archivePath = Path.Combine(projectRoot, "unverified-redaction.cdidx.zip"); + var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower }; + var export = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport( + [archivePath, "--db", dbPath, "--json"], + jsonOptions, + "test")); + Assert.Equal(CommandExitCodes.Success, export.ExitCode); + + RewriteManifest(archivePath, manifest => + { + manifest["project_root"] = null; + manifest["path_redaction_requested"] = true; + manifest["path_redaction_complete"] = true; + manifest["path_redaction_omitted_categories"] = new JsonArray("project_root"); + }); + + var importedDb = Path.Combine(projectRoot, "forged-import.db"); + var import = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport( + [archivePath, "--db", importedDb, "--dry-run", "--no-backup", "--json"], + jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, import.ExitCode); + Assert.Equal(string.Empty, import.Stderr); + using var error = JsonDocument.Parse(import.Stdout); + Assert.Equal("sqlite_validate", error.RootElement.GetProperty("phase").GetString()); + Assert.Equal("import_manifest_mismatch", error.RootElement.GetProperty("error_code").GetString()); + Assert.Contains( + "embedded project root metadata", + error.RootElement.GetProperty("message").GetString(), + StringComparison.Ordinal); + Assert.False(File.Exists(importedDb)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void MatchProject_PrefersExactAbsolutePathCasing_Issue5195() + { + var root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "project-case-5195")); + var upperDirectory = Path.Combine(root, "src", "Foo"); + var lowerDirectory = Path.Combine(root, "src", "foo"); + var projects = new DotNetProjectInfo[] + { + new("Upper", "src/Foo/App.csproj", upperDirectory), + new("Lower", "src/foo/App.csproj", lowerDirectory), + }; + + var match = SolutionProjectResolver.MatchProject( + projects, + Path.Combine(lowerDirectory, "App.csproj")); + + Assert.NotNull(match); + Assert.Equal("Lower", match.Name); + Assert.Equal(lowerDirectory, match.DirectoryPath); + } + private static void SetUnknownExtensionPaths(string dbPath, string[] paths) { using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); @@ -325,6 +409,37 @@ private static void SetUnknownExtensionPaths(string dbPath, string[] paths) DbContext.UnknownExtensionFilePathSampleLimit.ToString(System.Globalization.CultureInfo.InvariantCulture)); } + private static void SetWorkspaceVerificationPendingPaths(string dbPath, string[] paths) + { + using var db = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var writer = new DbWriter(db.Connection); + writer.SetMeta( + DbContext.WorkspaceVerificationPendingPathsMetaKey, + JsonStringListCodec.Serialize(paths)); + writer.SetMeta( + DbContext.WorkspaceVerificationPendingPathsCompleteMetaKey, + bool.TrueString); + } + + private static void RewriteManifest(string archivePath, Action rewrite) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Update); + var manifestEntry = archive.GetEntry("manifest.json") + ?? throw new InvalidOperationException("manifest.json entry was not found"); + JsonObject manifest; + using (var stream = manifestEntry.Open()) + { + manifest = JsonNode.Parse(stream)?.AsObject() + ?? throw new InvalidOperationException("manifest.json did not contain an object"); + } + + rewrite(manifest); + manifestEntry.Delete(); + var replacement = archive.CreateEntry("manifest.json", CompressionLevel.SmallestSize); + using var writer = new StreamWriter(replacement.Open(), new UTF8Encoding(false)); + writer.Write(manifest.ToJsonString()); + } + private static string ExtractDatabase(string projectRoot, string archivePath, string fileName) { var extractedDb = Path.Combine(projectRoot, fileName); From 6d57d5217d54d5ee706604ef8d271695061646cd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 28 Aug 2026 13:18:29 +0900 Subject: [PATCH 5/5] Release Windows archive test handles (#5195) --- .../ExportImportCommandRunnerIssue5195Tests.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs index 7478b2c4a..4e3171f38 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerIssue5195Tests.cs @@ -450,7 +450,12 @@ private static string ExtractDatabase(string projectRoot, string archivePath, st private static string? ReadMetaValue(string dbPath, string key) { - using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString); + using var connection = new SqliteConnection( + new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Pooling = false, + }.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key";