diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 0fa7f4a33..01ffe21c9 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -101,7 +101,7 @@ dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll search --recipe filesystem-mut | Upgrade installer script and temp directory | Upgrade downloads use owner-only `cdidx-install-*` directories under `Path.GetTempPath()`. Recursive directory cleanup validates the temp root, required prefix, and symlink/reparse/device status before deletion. Install-directory write probes run only after install-directory validation rejects roots, symlinks/reparse points, and unsafe POSIX modes. | Installer script and temp-directory cleanup failures are warnings. The install operation reports its own result separately from secondary cleanup failures. | | `.cdidx` write probes and case-sensitivity probes | Write probes are freshly generated files under the already resolved install directory, `.cdidx` directory, or `.cdidx/probes` directory. Probe directories are created owner-only and are under the workspace data directory. | Probe files are deleted after the check. Case-sensitivity probe-directory cleanup revalidates the workspace data/probe root, expected directory name, and symlink/reparse/device status before deleting created empty probe directories; rejected cleanup records bounded diagnostics and suggests removing stale `.cdidx/probes` entries when no `cdidx` process is running. | | Legacy scan checkpoints | Full scans neither create nor consume `.cdidx/scan-checkpoint.json`. After the first immutable scan-input barrier, successful and partial runs delete any legacy file without parsing it. | Delete failures are warnings in human output and `CliJsonMessage` entries in JSON output; indexing continues without relying on stale HEAD-only state. | -| Git hook staging | Hook installation first classifies the operation as create, managed replacement, custom-hook chain, or exact no-op. `hooks install --dry-run` returns that plan and the managed script before directory creation or staging, while a real mutation writes a private staged hook script inside the repository hook directory and then replaces the hook file through `File.Replace` with a backup path when needed. | Exact executable UTF-8/no-BOM managed-hook reruns return `already_installed` without rewriting. Other encodings and non-executable managed hooks are replaced so Git can execute them. If a staged script was not moved into place, cleanup is best-effort and recorded as hook warnings. Failure to delete a managed hook is a command error because that is the requested mutation. | +| Git hook staging | Hook installation first resolves the current invocation to an absolute, runnable executable path; a `dotnet` launcher remains classified as a host before its symlink leaf resolves, the host also pins the absolute `cdidx.dll`, other symlinked executable leaves resolve to their final target, and every argv/path is POSIX-shell quoted. The managed block stores a bounded versioned provenance manifest and visible source/version comments, and the complete generated script must fit the same 64 KiB management-read bound before any preview or write. Installation then classifies create, managed replacement, custom-hook chain, or exact no-op. `hooks install --dry-run` returns that plan, managed script, and executable diagnostics before directory creation or staging, while a real mutation writes a private staged hook script inside the repository hook directory and then replaces the hook file through `File.Replace` with a backup path when needed. | Exact executable UTF-8/no-BOM managed-hook reruns return `already_installed` without rewriting. Other encodings and non-executable managed hooks are replaced so Git can execute them. `hooks status` parses but never executes the stored invocation; it first verifies source/argv cardinality and that the managed block matches the manifest, then checks path availability and effective POSIX execute access, obtains the current version only from the matching running invocation or a bounded adjacent `version.json`, and reports malformed, tampered, missing, unverifiable, or skewed provenance with support-safe diagnostic argv. If a staged script was not moved into place, cleanup is best-effort and recorded as hook warnings. Failure to delete a managed hook is a command error because that is the requested mutation. | | Index and MCP lock metadata sidecars | Lock files and `.info` sidecars live next to the resolved DB or MCP index lock path and are created with owner-only permissions. | Disposing a lock deletes only the metadata sidecar. Cleanup failures are logged through `GlobalToolLog` and optional test sinks; stale lock files rely on OS lock release rather than recursive cleanup. | | Search audit recipes | `SearchAuditRecipes` contains literal recipe strings such as `Directory.Delete` and `File.Move`; these are search metadata, not filesystem mutations. | No cleanup policy applies. | @@ -3473,7 +3473,7 @@ dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll search File.Move --path src/ - | upgrade installer script と temp directory | upgrade download は `Path.GetTempPath()` 配下の owner-only `cdidx-install-*` directory を使います。recursive directory cleanup は削除前に temp root、required prefix、symlink / reparse / device 状態を検証します。install-directory write probe は、root、symlink / reparse point、unsafe POSIX mode を拒否する install-directory validation の後だけ実行します。 | installer script と temp-directory cleanup の失敗は warning です。install operation の結果は、二次的な cleanup failure とは分けて報告します。 | | `.cdidx` write probe と case-sensitivity probe | write probe は、解決済み install directory、`.cdidx` directory、または `.cdidx/probes` directory 配下に fresh file として生成します。probe directory は owner-only で作成され、workspace data directory 配下にあります。 | probe file は確認後に削除します。case-sensitivity probe directory の cleanup は、作成済みの空 probe directory を削除する前に workspace data / probe root、期待する directory name、symlink / reparse / device 状態を再検証します。拒否した cleanup は bounded diagnostic を記録し、`cdidx` process が動いていないときに stale `.cdidx/probes` entry を削除するよう案内します。 | | legacy scan checkpoint | full scan は `.cdidx/scan-checkpoint.json` を作成も参照もせず、最初の immutable scan-input barrier 後に success / partial のどちらでも旧fileをparseせず削除します。 | delete failure は human output では warning、JSON output では `CliJsonMessage` entry です。indexing は古いHEAD-only stateに依存せず継続します。 | -| Git hook staging | hook installation は操作を create、managed replacement、custom-hook chain、exact no-op のいずれかに分類します。`hooks install --dry-run` は directory 作成や staging より前にその plan と managed script を返し、実際に変更する場合は repository hook directory 内に private staged hook script を書き込み、必要に応じて backup path 付きの `File.Replace` で hook file を置き換えます。 | 同一かつ実行可能な UTF-8/no-BOM の managed hook に対する再実行は書き換えず `already_installed` を返します。それ以外の encoding や実行不可の managed hook は Git が実行できるよう置き換えます。staged script が配置されなかった場合の cleanup は best-effort で、hook warning として記録します。managed hook の削除失敗は requested mutation の失敗なので command error です。 | +| Git hook staging | hook installation はまず current invocation を絶対かつ実行可能な executable path に解決します。`dotnet` launcher は symlink の leaf を解決する前の host 分類を維持し、host の場合は絶対 `cdidx.dll` も固定します。その他の symlink executable leaf は最終 target に解決し、すべての argv / path を POSIX shell 向けに quote します。managed block は上限付きの versioned provenance manifest と目視可能な source / version comment を保持し、生成 script 全体が preview または書き込み前に同じ 64 KiB の management-read 上限内であることを要求します。その後、操作を create、managed replacement、custom-hook chain、exact no-op のいずれかに分類します。`hooks install --dry-run` は directory 作成や staging より前に plan、managed script、executable diagnostics を返し、実際に変更する場合は repository hook directory 内に private staged hook scriptを書き込み、必要に応じて backup path 付きの `File.Replace` で hook file を置き換えます。 | 同一かつ実行可能な UTF-8/no-BOM の managed hook に対する再実行は書き換えず `already_installed` を返します。それ以外の encoding や実行不可の managed hook は Git が実行できるよう置き換えます。`hooks status` は保存済み invocation を parse しますが実行せず、まず source / argv の個数整合性と managed block / manifest の一致を確認してから path availability と POSIX の実効 execute access を検証します。current version は同じ実行中 invocation または上限付きの隣接 `version.json` からだけ取得し、不正形式、tamper、missing、検証不能、version skew を support-safe な diagnostic argv とともに報告します。staged script が配置されなかった場合の cleanup は best-effort で、hook warning として記録します。managed hook の削除失敗は requested mutation の失敗なので command error です。 | | index / MCP lock metadata sidecar | lock file と `.info` sidecar は、解決済み DB または MCP index lock path の隣に置き、owner-only permission で作成します。 | lock dispose は metadata sidecar だけを削除します。cleanup failure は `GlobalToolLog` と任意の test sink に記録します。stale lock file は recursive cleanup ではなく OS の lock release に依存して復旧します。 | | search audit recipe | `SearchAuditRecipes` には `Directory.Delete` や `File.Move` のような literal recipe string が含まれます。これは search metadata であり filesystem mutation ではありません。 | cleanup policy は適用されません。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e650298bf..52ef5c15f 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -603,7 +603,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `CliNonDatabaseErrorContractTests.cs` Table-driven empty, not-found, invalid-argument, invalid-config, and hook-platform failures. Keep JSON assertions focused on the stable versioned envelope and sanitized optional context, pair every scenario with its coded human `Error` / `Hint` / `Usage` form, and restore current-directory and hook filesystem seams after each case. - `GitHelperTests.cs`, `GitProcessRunnerTests.cs`, `HookCommandRunnerTests.cs` - Git-specific behavior, including worktrees, commit-based updates, direct git process runner diagnostics, cancellation of git subprocesses, portable absolute executable overrides, and fail-closed metadata-write boundaries. Hook install coverage keeps `installed`, `updated`, and `already_installed` distinct, proves an exact executable UTF-8/no-BOM rerun preserves the hook write time, and repairs non-executable or differently encoded managed hooks. Hook dry-run coverage keeps successful preview status at `dry_run`, asserts `filesystem_mutation: false`, and validates state plus hash/provenance-bearing `planned_changes` for install create/replacement/chain/no-op and uninstall managed-block removal/chained restoration/force/blocked/idempotent plans without changing content, mode, or write time. Keep unmanaged/conflicted marker and symlink cases fail-closed. Keep executable-override fixtures owner-only-writable, owned by the current test user, under trusted ancestors (including an explicit root-owned sticky `/tmp` case), and able to return `git version` from `--version` unless the test is asserting a rejection reason. Symlinked `.git` directory/file, intermediate metadata components, hard-linked metadata files, worktree target, `commondir`, `info`, `exclude`, `hooks`, and hook-file coverage must prove resolution is rejected before metadata is written outside the project boundary. Tests that create real repositories or launch real/fake git subprocesses use `ExternalProcessFactAttribute` / `ExternalProcessTheoryAttribute` and run only on the `net8.0` test target; keep pure `.git` metadata parsing, trust diagnostics, and trusted-candidate enumeration cross-target. Timeout and cancellation wall-clock assertions should stay below the fake git scripts' natural completion while leaving room for macOS CI scheduling and process-cleanup overhead. Fake git scripts that run after commit-ref validation should echo the verified commit argument for `rev-parse --verify ^{commit}` so timeout tests reach the intended git command. + Git-specific behavior, including worktrees, commit-based updates, direct git process runner diagnostics, cancellation of git subprocesses, portable absolute executable overrides, and fail-closed metadata-write boundaries. Hook install coverage keeps `installed`, `updated`, and `already_installed` distinct, proves an exact executable UTF-8/no-BOM rerun preserves the hook write time, and repairs non-executable or differently encoded managed hooks. Executable-provenance coverage uses bounded local fixtures to pin paths with spaces, a Windows-wrapper-shaped name, symlink final targets, a symlinked `dotnet` launcher whose target has another basename, host plus assembly argv, separate installed/current versions, adjacent version metadata skew, missing paths, effective POSIX execute-access failures, support-safe diagnostic argv, malformed source/argv cardinality, manifest/invocation tamper detection, complete-script size rejection, and custom-hook chaining without launching a manifest-derived command. Hook dry-run coverage keeps successful preview status at `dry_run`, asserts `filesystem_mutation: false`, and validates state plus hash/provenance-bearing `planned_changes` for install create/replacement/chain/no-op and uninstall managed-block removal/chained restoration/force/blocked/idempotent plans without changing content, mode, or write time. Keep unmanaged/conflicted marker and symlink cases fail-closed. Keep executable-override fixtures owner-only-writable, owned by the current test user, under trusted ancestors (including an explicit root-owned sticky `/tmp` case), and able to return `git version` from `--version` unless the test is asserting a rejection reason. Symlinked `.git` directory/file, intermediate metadata components, hard-linked metadata files, worktree target, `commondir`, `info`, `exclude`, `hooks`, and hook-file coverage must prove resolution is rejected before metadata is written outside the project boundary. Tests that create real repositories or launch real/fake git subprocesses use `ExternalProcessFactAttribute` / `ExternalProcessTheoryAttribute` and run only on the `net8.0` test target; keep pure `.git` metadata parsing, trust diagnostics, and trusted-candidate enumeration cross-target. Timeout and cancellation wall-clock assertions should stay below the fake git scripts' natural completion while leaving room for macOS CI scheduling and process-cleanup overhead. Fake git scripts that run after commit-ref validation should echo the verified commit argument for `rev-parse --verify ^{commit}` so timeout tests reach the intended git command. - `WorkspaceMetadataEnricherTests.cs` Workspace freshness and git metadata enrichment behavior. - `DiagnosticRedactorTests.cs`, `SuggestionStoreTests.cs` @@ -1539,7 +1539,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `CliNonDatabaseErrorContractTests.cs` empty、not-found、不正な引数、不正な設定、hook platform failure を表駆動で検証します。JSON assertion は安定したバージョン付き envelope と sanitization 済みの任意 context に集中させ、各 scenario で code 付き human `Error` / `Hint` / `Usage` も対にして検証し、current directory と hook filesystem seam を各 case の後に復元してください。 - `GitHelperTests.cs`、`GitProcessRunnerTests.cs`、`HookCommandRunnerTests.cs` - worktree、commit ベース更新、direct git process runner diagnostics、git subprocess cancellation、portable な absolute executable override、fail-closed な metadata-write boundary を含む Git まわりのテスト。hook install coverage では `installed`、`updated`、`already_installed` を区別し、同一かつ実行可能な UTF-8/no-BOM の内容での再実行が hook の write time を維持すること、および実行不可または異なる encoding の managed hook を修復することを検証します。hook の dry-run coverage では、成功したプレビューの status を `dry_run` に保ち、`filesystem_mutation: false` を検証します。また、install の create / replacement / chain / no-op と uninstall の managed block 除去 / chained 復元 / force / blocked / idempotent plan について、state と hash / provenance 付き `planned_changes` を検証し、content、mode、write time が変化しないことを確認します。unmanaged / conflicted marker と symlink の case は fail-closed に保ってください。executable override fixture は rejection reason 自体を検証する場合を除き owner-only-writable、test user 所有、trusted ancestor 配下(root 所有の sticky `/tmp` case を明示的に含む)とし、`--version` で `git version` を返せるようにします。symlinked `.git` directory / file、中間 metadata component、hard-linked metadata file、worktree target、`commondir`、`info`、`exclude`、`hooks`、hook file の coverage は、project boundary 外へ metadata を書く前に解決が拒否されることを証明してください。実 repo を作る、または real/fake git subprocess を起動するテストは `ExternalProcessFactAttribute` / `ExternalProcessTheoryAttribute` を使い、`net8.0` test target だけで実行します。純粋な `.git` metadata parsing、trust diagnostics、trusted-candidate enumeration は cross-target のままにしてください。Timeout と cancellation の wall-clock assertion は fake git script の自然完了より短く保ちつつ、macOS CI の scheduling や process cleanup の遅れを許容する余裕を持たせます。commit-ref validation 後に使う fake git script は `rev-parse --verify ^{commit}` の検証対象 commit 引数を返し、timeout テストが意図した git command まで到達するようにします。 + worktree、commit ベース更新、direct git process runner diagnostics、git subprocess cancellation、portable な absolute executable override、fail-closed な metadata-write boundary を含む Git まわりのテスト。hook install coverage では `installed`、`updated`、`already_installed` を区別し、同一かつ実行可能な UTF-8/no-BOM の内容での再実行が hook の write time を維持すること、および実行不可または異なる encoding の managed hook を修復することを検証します。executable provenance coverage は上限付きの local fixture を使い、space を含む path、Windows wrapper 形式の名前、symlink の最終 target、target の basename が異なる symlinked `dotnet` launcher、host と assembly の argv、別々の installed/current version、隣接 version metadata の skew、missing path、POSIX の実効 execute access failure、support-safe な diagnostic argv、source / argv の不正な個数、manifest / invocation の tamper 検知、生成 script 全体の size 拒否、manifest 由来 command を起動しない custom-hook chain を固定します。hook の dry-run coverage では、成功したプレビューの status を `dry_run` に保ち、`filesystem_mutation: false` を検証します。また、install の create / replacement / chain / no-op と uninstall の managed block 除去 / chained 復元 / force / blocked / idempotent plan について、state と hash / provenance 付き `planned_changes` を検証し、content、mode、write time が変化しないことを確認します。unmanaged / conflicted marker と symlink の case は fail-closed に保ってください。executable override fixture は rejection reason 自体を検証する場合を除き owner-only-writable、test user 所有、trusted ancestor 配下(root 所有の sticky `/tmp` case を明示的に含む)とし、`--version` で `git version` を返せるようにします。symlinked `.git` directory / file、中間 metadata component、hard-linked metadata file、worktree target、`commondir`、`info`、`exclude`、`hooks`、hook file の coverage は、project boundary 外へ metadata を書く前に解決が拒否されることを証明してください。実 repo を作る、または real/fake git subprocess を起動するテストは `ExternalProcessFactAttribute` / `ExternalProcessTheoryAttribute` を使い、`net8.0` test target だけで実行します。純粋な `.git` metadata parsing、trust diagnostics、trusted-candidate enumeration は cross-target のままにしてください。Timeout と cancellation の wall-clock assertion は fake git script の自然完了より短く保ちつつ、macOS CI の scheduling や process cleanup の遅れを許容する余裕を持たせます。commit-ref validation 後に使う fake git script は `rev-parse --verify ^{commit}` の検証対象 commit 引数を返し、timeout テストが意図した git command まで到達するようにします。 - `WorkspaceMetadataEnricherTests.cs` ワークスペース鮮度と git メタデータ付与のテスト。 - `DiagnosticRedactorTests.cs`、`SuggestionStoreTests.cs` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 702a3780f..0362dbd81 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -220,13 +220,15 @@ local index automatically: | Hook behavior | Detail | |---|---| -| Refresh command | The installed hook runs `cdidx index --quiet` before the commit completes. When `--project` is omitted, the selected path is the current directory at install time. | +| Refresh command | The installed hook runs the absolute executable invocation selected at install time followed by `index --quiet`; it never performs a mutable `PATH` lookup for `cdidx`. A framework-dependent invocation pins both the `dotnet` host and `cdidx.dll`, while apphosts and Windows command wrappers pin their resolved executable path. When `--project` is omitted, the selected path is the current directory at install time. | | Quiet mode | `--quiet` suppresses normal progress and success output for hook contexts while still printing indexing errors to stderr and returning a non-zero exit code. | -| Preview contract | `cdidx hooks install --dry-run` and `cdidx hooks uninstall --dry-run` return `status: dry_run` for a successful preview, `dry_run: true`, `filesystem_mutation: false`, `planned_action`, `hook_state`, `chained_hook_state`, and `planned_changes`. Each planned change carries its action/path/source, content hashes, executable-mode transition, and provenance. A blocked preview keeps `status: error` while retaining the same non-mutating plan fields. | +| Executable provenance | Install and install dry-run output report `executable.source`, the pinned path/argv, `expected_version`, the observed `actual_version` when available, and an executable `status`. Raw paths are for local use; the sibling `diagnostic_path`, `diagnostic_entry_assembly_path`, and `diagnostic_argv` fields are support-safe. Spaces and shell metacharacters are single-quoted, symlinked executable leaves are pinned to their final target, and a generated script that exceeds the bounded hook-management read limit is rejected before writing. Error previews redact both visible paths and the encoded manifest payload. | +| Preview contract | `cdidx hooks install --dry-run` and `cdidx hooks uninstall --dry-run` return `status: dry_run` for a successful preview, `dry_run: true`, `filesystem_mutation: false`, `planned_action`, `hook_state`, `chained_hook_state`, and `planned_changes`. Each planned change carries its action/path/source, content hashes, executable-mode transition, and provenance. Install preview also reports the resolved executable object. A blocked preview keeps `status: error` while retaining the same non-mutating plan fields. | | Install preview | Install plans use `create`, `replace_managed`, `chain_existing`, `none`, or `blocked`; `managed_hook_preview` contains the generated managed hook. Preview never creates the missing hooks directory or rewrites an existing file. | | Uninstall preview | Uninstall plans use `delete_managed`, `remove_managed_block`, `restore_chained`, `force_delete_unmanaged`, `force_restore_chained`, `none`, or `blocked`. A well-formed managed block is removed while preserving surrounding custom hook content; a chained backup is reported as the restoration source. Unmanaged or conflicted marker content is blocked unless `--force` is explicit. | | Install result | A real install returns `status: installed` when it creates the hook, `status: updated` when it replaces managed content, repairs a non-executable or non-UTF-8/no-BOM managed hook, or chains an existing custom hook, and `status: already_installed` without rewriting when the exact executable UTF-8/no-BOM managed hook is already present. | -| Status JSON diagnostics | `cdidx hooks status --json` keeps `project_path`, `hook_path`, and `chained_hook_path` for compatibility, and also emits `diagnostic_project_path`, `diagnostic_hook_path`, and `diagnostic_chained_hook_path` with path-sanitized values for logs and support bundles. | +| Status JSON diagnostics | `cdidx hooks status --json` keeps `project_path`, `hook_path`, and `chained_hook_path` for compatibility, and also emits `diagnostic_project_path`, `diagnostic_hook_path`, and `diagnostic_chained_hook_path` with path-sanitized values for logs and support bundles. For managed hooks, `executable.status` is `available`, `available_unverified`, `version_mismatch`, `missing`, `not_executable`, or `unresolved`, with a stable `failure_reason` when attention is required. Status never executes a path parsed from a hook, verifies effective executable/read access and regular-file types, requires canonical fully qualified manifest paths, validates a pinned managed assembly plus its adjacent `.runtimeconfig.json` and `.deps.json`, and reports malformed source/argv shapes or a manifest/invocation mismatch as unresolved. Repository relocation and linked-worktree path differences retain these executable diagnostics while `hook_state` reports `project_path_mismatch`, `chained_hook_path_mismatch`, or `repository_path_mismatch` separately. | +| Relocation and upgrades | A pinned apphost/wrapper path follows in-place upgrades at that path and status reports a version mismatch until `cdidx hooks install` refreshes the manifest. A versioned `dotnet` tool assembly remains pinned to the installed `cdidx.dll`; moving or removing it is reported as `missing`. After relocating or upgrading cdidx, rerun `cdidx hooks install` from the intended executable. | | Existing hooks | If `.git/hooks/pre-commit` already exists, `cdidx hooks install` moves it to `.git/hooks/pre-commit.cdidx-chain` and calls it after the cdidx refresh, preserving tools such as Husky, pre-commit, and lefthook. | | Intentional skip | Use `git commit --no-verify` when you intentionally need to skip all pre-commit hooks. | @@ -3520,13 +3522,15 @@ pre-commit hook をインストールします: | hook の動作 | 詳細 | |---|---| -| 更新コマンド | インストールされた hook はコミット完了前に `cdidx index --quiet` を実行します。`--project` を省略した場合、選択パスはインストール時のカレントディレクトリです。 | +| 更新コマンド | インストールされた hook は、インストール時に選択した絶対 executable invocation に `index --quiet` を続けて実行し、可変な `PATH` から `cdidx` を検索しません。framework-dependent invocation では `dotnet` host と `cdidx.dll` の両方を固定し、apphost と Windows command wrapper では解決済み executable path を固定します。`--project` を省略した場合、選択パスはインストール時のカレントディレクトリです。 | | quiet mode | `--quiet` は hook 環境向けに通常の進捗・成功出力を抑制しつつ、indexing エラーは引き続き stderr に出力し、非ゼロの終了コードを返します。 | -| プレビュー契約 | `cdidx hooks install --dry-run` と `cdidx hooks uninstall --dry-run` は、成功時に `status: dry_run`、`dry_run: true`、`filesystem_mutation: false`、`planned_action`、`hook_state`、`chained_hook_state`、`planned_changes` を返します。各変更予定には、操作、対象/変更元のパス、内容のハッシュ、実行モードの遷移、由来が含まれます。実行できないプレビューは `status: error` を維持しつつ、同じ非変更の計画フィールドを返します。 | +| executable provenance | install と install dry-run の出力は、`executable.source`、固定した path / argv、`expected_version`、取得できた場合の `actual_version`、executable の `status` を報告します。raw path はローカル利用向けで、対応する `diagnostic_path`、`diagnostic_entry_assembly_path`、`diagnostic_argv` はサポート共有用に安全化されます。space や shell metacharacter は single quote で保護し、symlink になっている executable leaf は最終 target に固定します。生成 script が上限付きの hook-management read limit を超える場合は、書き込み前に拒否します。error preview では表示 path と encoded manifest payload の両方を redaction します。 | +| プレビュー契約 | `cdidx hooks install --dry-run` と `cdidx hooks uninstall --dry-run` は、成功時に `status: dry_run`、`dry_run: true`、`filesystem_mutation: false`、`planned_action`、`hook_state`、`chained_hook_state`、`planned_changes` を返します。各変更予定には、操作、対象/変更元のパス、内容のハッシュ、実行モードの遷移、由来が含まれます。install preview は解決済み executable object も報告します。実行できないプレビューは `status: error` を維持しつつ、同じ非変更の計画フィールドを返します。 | | インストールのプレビュー | インストール計画は `create`、`replace_managed`、`chain_existing`、`none`、`blocked` のいずれかで、`managed_hook_preview` に生成予定の管理対象 hook を含めます。プレビューは存在しない hooks ディレクトリを作成せず、既存ファイルも再書き込みしません。 | | アンインストールのプレビュー | アンインストール計画は `delete_managed`、`remove_managed_block`、`restore_chained`、`force_delete_unmanaged`、`force_restore_chained`、`none`、`blocked` のいずれかです。正しい管理対象 block は周囲の独自 hook 内容を保持して除去し、連結されたバックアップがある場合は復元元として報告します。管理対象外の内容や marker が競合した内容は、明示的な `--force` がない限り実行できません。 | | インストール結果 | 実際のインストールでは、hook を新規作成した場合は `status: installed`、managed content の置換、実行不可または UTF-8/no-BOM 以外になった managed hook の修復、あるいは既存 custom hook の chain 化を行った場合は `status: updated`、同一かつ実行可能な UTF-8/no-BOM の managed hook がすでに存在して再書き込みを行わなかった場合は `status: already_installed` を返します。 | -| status JSON 診断 | `cdidx hooks status --json` は互換性のため `project_path`、`hook_path`、`chained_hook_path` を維持しつつ、ログやサポートバンドル向けに path をサニタイズした `diagnostic_project_path`、`diagnostic_hook_path`、`diagnostic_chained_hook_path` も出力します。 | +| status JSON 診断 | `cdidx hooks status --json` は互換性のため `project_path`、`hook_path`、`chained_hook_path` を維持しつつ、ログやサポートバンドル向けに path をサニタイズした `diagnostic_project_path`、`diagnostic_hook_path`、`diagnostic_chained_hook_path` も出力します。managed hook では `executable.status` が `available`、`available_unverified`、`version_mismatch`、`missing`、`not_executable`、`unresolved` のいずれかになり、対応が必要な場合は安定した `failure_reason` を返します。status は hook から読み取った path を実行せず、実効的な execute / read access と通常ファイル種別を検証し、manifest path に canonical な完全修飾 path を要求し、固定された managed assembly と隣接する `.runtimeconfig.json` / `.deps.json` を検証します。また、source / argv の不正な組み合わせまたは manifest と invocation の不一致を unresolved と報告します。repository の移動や linked worktree による path 差異があっても executable 診断は維持し、`hook_state` で `project_path_mismatch`、`chained_hook_path_mismatch`、`repository_path_mismatch` を分けて報告します。 | +| relocation と upgrade | 固定した apphost / wrapper path は同じ path での in-place upgrade に追従し、`cdidx hooks install` が manifest を更新するまでは status が version mismatch を報告します。version 付きの `dotnet` tool assembly はインストール済み `cdidx.dll` に固定され、移動または削除されると `missing` になります。cdidx の移動または upgrade 後は、意図する executable から `cdidx hooks install` を再実行してください。 | | 既存 hook の扱い | リポジトリに `.git/hooks/pre-commit` がある場合、`cdidx hooks install` はそれを `.git/hooks/pre-commit.cdidx-chain` に移動し、cdidx の更新後に呼び出すため、Husky、pre-commit、lefthook などのツールも維持されます。 | | 意図的な skip | すべての pre-commit hook を意図的にスキップする必要があるときは `git commit --no-verify` を使ってください。 | diff --git a/changelog.d/unreleased/4892.fixed.md b/changelog.d/unreleased/4892.fixed.md new file mode 100644 index 000000000..f9bf0389e --- /dev/null +++ b/changelog.d/unreleased/4892.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 4892 +affected: + - src/CodeIndex/Cli/HookCommandRunner.cs + - tests/CodeIndex.Tests/HookCommandRunnerTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Generated pre-commit hooks now pin and diagnose the intended cdidx executable (#4892)** — hook installation records a bounded, absolute, safely quoted executable invocation and version instead of relying on mutable `PATH`; install previews and status expose support-safe provenance, while malformed or tampered provenance, ineffective POSIX execute access, and missing, unverifiable, or version-skewed pinned installations receive stable diagnostics. + +## 日本語 + +- **生成する pre-commit hook が意図した cdidx executable を固定し診断するようになりました (#4892)** — hook installation は可変な `PATH` に依存せず、上限付きの絶対 path を安全に quote した executable invocation と version を記録します。install preview と status はサポート共有用に安全化した provenance を公開し、不正形式または tamper された provenance、POSIX の実効 execute access 不足、固定先の missing、検証不能、version skew を安定した診断で報告します。 diff --git a/src/CodeIndex/Cli/HookCommandRunner.cs b/src/CodeIndex/Cli/HookCommandRunner.cs index 260bd3813..506d19459 100644 --- a/src/CodeIndex/Cli/HookCommandRunner.cs +++ b/src/CodeIndex/Cli/HookCommandRunner.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -15,6 +16,13 @@ public static class HookCommandRunner private const string ChainedHookName = "pre-commit.cdidx-chain"; private const string BeginMarker = "# BEGIN CDIDX MANAGED PRE-COMMIT"; private const string EndMarker = "# END CDIDX MANAGED PRE-COMMIT"; + private const string ExecutableManifestPrefix = "# CDIDX EXECUTABLE MANIFEST "; + private const int MaxExecutableManifestChars = 32 * 1024; + private const int MaxExecutableArgumentChars = 8 * 1024; + private const int UnixExecuteAccess = 1; + private const int UnixReadAccess = 4; + private const int UnixFileTypeMask = 0xF000; + private const int UnixRegularFileType = 0x8000; private static readonly byte[] BeginMarkerBytes = Encoding.ASCII.GetBytes(BeginMarker); private static readonly byte[] EndMarkerBytes = Encoding.ASCII.GetBytes(EndMarker); private static readonly byte[] HookPreambleBytes = Encoding.ASCII.GetBytes("#!/bin/sh"); @@ -22,9 +30,11 @@ public static class HookCommandRunner internal const int MaxHookMarkerBytes = 64 * 1024; internal static Action? DeleteFileForTesting { get; set; } internal static Action? ReplaceFileForTesting { get; set; } + internal static Func? ExecutableSelectionForTesting { get; set; } - public static int Run(string[] args, JsonSerializerOptions jsonOptions) + public static int Run(string[] args, JsonSerializerOptions jsonOptions, string? appVersion = null) { + appVersion ??= ConsoleUi.LoadVersion(); var options = ParseArgs(args); var wantsJson = args.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal)); if (wantsJson && !options.Json) @@ -110,9 +120,9 @@ public static int Run(string[] args, JsonSerializerOptions jsonOptions) return options.Command switch { - "install" => Install(options, jsonOptions, projectPath, gitDir, hooksDir, hookPath, chainedHookPath), + "install" => Install(options, jsonOptions, appVersion, projectPath, gitDir, hooksDir, hookPath, chainedHookPath), "uninstall" => Uninstall(options, jsonOptions, projectPath, gitDir, hooksDir, hookPath, chainedHookPath), - "status" => Status(options, jsonOptions, projectPath, hookPath, chainedHookPath), + "status" => Status(options, jsonOptions, appVersion, projectPath, hookPath, chainedHookPath), _ => UnknownCommand(options, jsonOptions, projectPath) }; } @@ -183,25 +193,92 @@ internal static HookCommandOptions ParseArgs(string[] args) return new HookCommandOptions(command, projectPath, json, force, dryRun, showHelp, parseError); } - private static int Install(HookCommandOptions options, JsonSerializerOptions jsonOptions, string projectPath, string gitDir, string hooksDir, string hookPath, string chainedHookPath) + private static int Install( + HookCommandOptions options, + JsonSerializerOptions jsonOptions, + string appVersion, + string projectPath, + string gitDir, + string hooksDir, + string hookPath, + string chainedHookPath) { + if (!TryResolveCurrentExecutable(appVersion, out var executableSelection, out var resolutionFailure)) + { + var message = $"could not pin the current cdidx executable ({resolutionFailure})"; + var unresolvedExecutable = HookExecutableJsonResult.Unresolved(resolutionFailure); + if (options.DryRun) + { + var preflightPlan = BuildInstallPreflightFailurePlan( + hookPath, + chainedHookPath, + message); + return WriteDryRunResult( + options, + jsonOptions, + projectPath, + hookPath, + chainedHookPath, + preflightPlan, + unresolvedExecutable); + } + + return WriteResult( + options.Json, + jsonOptions, + "error", + message, + projectPath, + hookPath, + null, + CommandExitCodes.InstallError, + executable: unresolvedExecutable); + } + + var executable = InspectExecutable(executableSelection, executableSelection); var warnings = new List(); - var hookScript = BuildHookScript(chainedHookPath, projectPath); + if (!TryBuildHookScript( + chainedHookPath, + projectPath, + executableSelection, + out var hookScript)) + { + return WriteGeneratedHookTooLargeResult( + options, + jsonOptions, + projectPath, + hookPath, + chainedHookPath, + executable); + } var plan = BuildInstallPlan(options, hookPath, chainedHookPath, hookScript); if (options.DryRun) - return WriteDryRunResult(options, jsonOptions, projectPath, hookPath, chainedHookPath, plan, hookScript); + return WriteDryRunResult(options, jsonOptions, projectPath, hookPath, chainedHookPath, plan, executable, hookScript); if (plan.Blocked) - return WriteBlockedPlanResult(options, jsonOptions, projectPath, hookPath, chainedHookPath, plan); + return WriteBlockedPlanResult(options, jsonOptions, projectPath, hookPath, chainedHookPath, plan, executable); Directory.CreateDirectory(LongPath.EnsureWindowsPrefix(hooksDir)); if (!TryResolveHookWritePaths(gitDir, out hooksDir, out hookPath, out chainedHookPath)) return WriteResult(options.Json, jsonOptions, "error", "unsafe Git hook file path", projectPath, null, null, CommandExitCodes.InstallError); - hookScript = BuildHookScript(chainedHookPath, projectPath); + if (!TryBuildHookScript( + chainedHookPath, + projectPath, + executableSelection, + out hookScript)) + { + return WriteGeneratedHookTooLargeResult( + options, + jsonOptions, + projectPath, + hookPath, + chainedHookPath, + executable); + } plan = BuildInstallPlan(options, hookPath, chainedHookPath, hookScript); if (plan.Blocked) - return WriteBlockedPlanResult(options, jsonOptions, projectPath, hookPath, chainedHookPath, plan); + return WriteBlockedPlanResult(options, jsonOptions, projectPath, hookPath, chainedHookPath, plan, executable); if (plan.PlannedAction == "none") { @@ -213,7 +290,8 @@ private static int Install(HookCommandOptions options, JsonSerializerOptions jso projectPath, hookPath, plan.ChainedHookState == "present" ? chainedHookPath : null, - CommandExitCodes.Success); + CommandExitCodes.Success, + executable: executable); } if (plan.PlannedAction == "chain_existing") @@ -221,23 +299,55 @@ private static int Install(HookCommandOptions options, JsonSerializerOptions jso try { if (!TryResolveHookWritePaths(gitDir, out hooksDir, out hookPath, out chainedHookPath)) - return WriteResult(options.Json, jsonOptions, "error", "Git hook file path became unsafe before write", projectPath, null, null, CommandExitCodes.InstallError); - ReplaceCustomHookWithManagedHook(hooksDir, hookPath, chainedHookPath, projectPath, warnings); + return WriteResult(options.Json, jsonOptions, "error", "Git hook file path became unsafe before write", projectPath, null, null, CommandExitCodes.InstallError, executable: executable); + if (!TryBuildHookScript( + chainedHookPath, + projectPath, + executableSelection, + out hookScript)) + { + return WriteGeneratedHookTooLargeResult( + options, + jsonOptions, + projectPath, + hookPath, + chainedHookPath, + executable); + } + ReplaceCustomHookWithManagedHook( + hooksDir, + hookPath, + chainedHookPath, + hookScript, + warnings); } catch (Exception ex) when (IsHookFileOperationException(ex)) { RecordHookWarning(warnings, "chained_hook_backup", chainedHookPath, "failed to back up existing hook", ex); var message = $"failed to install cdidx pre-commit hook ({CommandErrorWriter.FormatSanitizedException(ex)})"; - return WriteResult(options.Json, jsonOptions, "error", message, projectPath, hookPath, chainedHookPath, CommandExitCodes.InstallError, warnings); + return WriteResult(options.Json, jsonOptions, "error", message, projectPath, hookPath, chainedHookPath, CommandExitCodes.InstallError, warnings, executable: executable); } - return WriteResult(options.Json, jsonOptions, "updated", "cdidx pre-commit hook updated", projectPath, hookPath, chainedHookPath, CommandExitCodes.Success, warnings); + return WriteResult(options.Json, jsonOptions, "updated", "cdidx pre-commit hook updated", projectPath, hookPath, chainedHookPath, CommandExitCodes.Success, warnings, executable: executable); } if (!TryResolveHookWritePaths(gitDir, out hooksDir, out hookPath, out chainedHookPath)) - return WriteResult(options.Json, jsonOptions, "error", "Git hook file path became unsafe before write", projectPath, null, null, CommandExitCodes.InstallError); + return WriteResult(options.Json, jsonOptions, "error", "Git hook file path became unsafe before write", projectPath, null, null, CommandExitCodes.InstallError, executable: executable); - hookScript = BuildHookScript(chainedHookPath, projectPath); + if (!TryBuildHookScript( + chainedHookPath, + projectPath, + executableSelection, + out hookScript)) + { + return WriteGeneratedHookTooLargeResult( + options, + jsonOptions, + projectPath, + hookPath, + chainedHookPath, + executable); + } var status = plan.PlannedAction == "create" ? "installed" : "updated"; var resultMessage = status == "updated" ? "cdidx pre-commit hook updated" @@ -253,7 +363,8 @@ private static int Install(HookCommandOptions options, JsonSerializerOptions jso hookPath, File.Exists(LongPath.EnsureWindowsPrefix(chainedHookPath)) ? chainedHookPath : null, CommandExitCodes.Success, - warnings); + warnings, + executable: executable); } private static HookOperationPlan BuildInstallPlan( @@ -576,14 +687,62 @@ private static HookOperationPlan BuildUninstallPlan( ]); } - private static int Status(HookCommandOptions options, JsonSerializerOptions jsonOptions, string projectPath, string hookPath, string chainedHookPath) + private static int Status( + HookCommandOptions options, + JsonSerializerOptions jsonOptions, + string appVersion, + string projectPath, + string hookPath, + string chainedHookPath) { var ioHookPath = LongPath.EnsureWindowsPrefix(hookPath); var ioChainedHookPath = LongPath.EnsureWindowsPrefix(chainedHookPath); var hookExists = File.Exists(ioHookPath); - var installed = hookExists && IsManagedHookFile(ioHookPath); + var hookContent = hookExists ? ReadHookBytesWithinLimit(ioHookPath) : null; + var installed = hookExists && AnalyzeManagedHook(hookContent).State == "managed"; var status = installed ? "installed" : hookExists ? "custom" : "absent"; - return WriteResult(options.Json, jsonOptions, status, $"cdidx pre-commit hook is {status}", projectPath, hookPath, File.Exists(ioChainedHookPath) ? chainedHookPath : null, CommandExitCodes.Success); + HookExecutableJsonResult? executable = null; + string? hookState = null; + if (installed) + { + if (!TryReadExecutableManifest(hookContent, out var installedSelection)) + { + hookState = "executable_manifest_unresolved"; + executable = HookExecutableJsonResult.Unresolved("managed_hook_missing_executable_manifest"); + } + else if (!TryAnalyzeManagedInvocation( + hookContent, + projectPath, + chainedHookPath, + installedSelection, + out hookState)) + { + hookState = "executable_manifest_unresolved"; + executable = HookExecutableJsonResult.Unresolved("managed_hook_executable_manifest_mismatch"); + } + else + { + _ = TryResolveCurrentExecutable(appVersion, out var currentSelection, out _); + executable = InspectExecutable(installedSelection, currentSelection); + } + } + + var message = $"cdidx pre-commit hook is {status}"; + if (executable is { Status: not "available" }) + message += $"; pinned executable is {executable.Status}"; + if (hookState is not null and not "managed" and not "executable_manifest_unresolved") + message += $"; managed hook state is {hookState}"; + return WriteResult( + options.Json, + jsonOptions, + status, + message, + projectPath, + hookPath, + File.Exists(ioChainedHookPath) ? chainedHookPath : null, + CommandExitCodes.Success, + hookState: hookState, + executable: executable); } private static int UnknownCommand(HookCommandOptions options, JsonSerializerOptions jsonOptions, string projectPath) @@ -593,12 +752,6 @@ private static int UnknownCommand(HookCommandOptions options, JsonSerializerOpti return WriteResult(options.Json, jsonOptions, "error", $"unknown hooks command: {ConsoleUi.FormatBoundedValue(options.Command)}", projectPath, null, null, CommandExitCodes.UsageError); } - private static bool IsManagedHookFile(string ioHookPath) - { - var content = ReadHookBytesWithinLimit(ioHookPath); - return AnalyzeManagedHook(content).State == "managed"; - } - private static byte[]? ReadHookBytesWithinLimit(string ioHookPath) => DataDirectorySecurity.ReadBytesWithinLimit(ioHookPath, MaxHookMarkerBytes, FileShare.ReadWrite); @@ -620,21 +773,15 @@ private static ManagedHookAnalysis AnalyzeManagedHook(byte[]? content) private static ManagedHookAnalysis AnalyzeRawManagedHook(byte[] content) { var contentSpan = content.AsSpan(); - var beginCount = CountOccurrences(contentSpan, BeginMarkerBytes); - var endCount = CountOccurrences(contentSpan, EndMarkerBytes); + var beginCount = CountMarkerLines(contentSpan, BeginMarkerBytes, out var beginIndex); + var endCount = CountMarkerLines(contentSpan, EndMarkerBytes, out var endIndex); if (beginCount == 0 && endCount == 0) return new ManagedHookAnalysis("unmanaged", null, false); if (beginCount != 1 || endCount != 1) return new ManagedHookAnalysis("conflicted", null, false); - var beginIndex = contentSpan.IndexOf(BeginMarkerBytes); - var endIndex = contentSpan.IndexOf(EndMarkerBytes); - if (endIndex < beginIndex - || !IsMarkerOnlyLine(contentSpan, beginIndex, BeginMarkerBytes) - || !IsMarkerOnlyLine(contentSpan, endIndex, EndMarkerBytes)) - { + if (endIndex < beginIndex) return new ManagedHookAnalysis("conflicted", null, false); - } var blockStart = FindLineStart(contentSpan, beginIndex); var blockEnd = FindLineEndIncludingTerminator( @@ -664,21 +811,15 @@ private static ManagedHookAnalysis AnalyzeBomEncodedManagedHook( return new ManagedHookAnalysis("unmanaged", null, false); } - var beginCount = CountOccurrences(text, BeginMarker); - var endCount = CountOccurrences(text, EndMarker); + var beginCount = CountMarkerLines(text, BeginMarker, out var beginIndex); + var endCount = CountMarkerLines(text, EndMarker, out var endIndex); if (beginCount == 0 && endCount == 0) return new ManagedHookAnalysis("unmanaged", null, false); if (beginCount != 1 || endCount != 1) return new ManagedHookAnalysis("conflicted", null, false); - var beginIndex = text.IndexOf(BeginMarker, StringComparison.Ordinal); - var endIndex = text.IndexOf(EndMarker, StringComparison.Ordinal); - if (endIndex < beginIndex - || !IsMarkerOnlyLine(text, beginIndex, BeginMarker) - || !IsMarkerOnlyLine(text, endIndex, EndMarker)) - { + if (endIndex < beginIndex) return new ManagedHookAnalysis("conflicted", null, false); - } var blockStart = FindLineStart(text, beginIndex); var blockEnd = FindLineEndIncludingTerminator( @@ -758,16 +899,24 @@ private static bool TryGetBomEncoding( return false; } - private static int CountOccurrences(string value, string marker) + private static int CountMarkerLines( + string value, + string marker, + out int firstMarkerIndex) { var count = 0; + firstMarkerIndex = -1; var offset = 0; while (offset <= value.Length - marker.Length) { var index = value.IndexOf(marker, offset, StringComparison.Ordinal); if (index < 0) break; - count++; + if (IsMarkerOnlyLine(value, index, marker)) + { + firstMarkerIndex = firstMarkerIndex < 0 ? index : firstMarkerIndex; + count++; + } offset = index + marker.Length; } @@ -818,17 +967,26 @@ private static int FindLineEndIncludingTerminator(string content, int offset) private static bool IsLineTerminator(char value) => value is '\r' or '\n'; - private static int CountOccurrences(ReadOnlySpan value, ReadOnlySpan marker) + private static int CountMarkerLines( + ReadOnlySpan value, + ReadOnlySpan marker, + out int firstMarkerIndex) { var count = 0; + firstMarkerIndex = -1; var offset = 0; while (offset <= value.Length - marker.Length) { var relativeIndex = value[offset..].IndexOf(marker); if (relativeIndex < 0) break; - count++; - offset += relativeIndex + marker.Length; + var index = offset + relativeIndex; + if (IsMarkerOnlyLine(value, index, marker)) + { + firstMarkerIndex = firstMarkerIndex < 0 ? index : firstMarkerIndex; + count++; + } + offset = index + marker.Length; } return count; @@ -919,7 +1077,7 @@ private static void ReplaceCustomHookWithManagedHook( string hooksDir, string hookPath, string chainedHookPath, - string projectPath, + string hookScript, List warnings) { var stagedHookPath = Path.Combine(hooksDir, $".{HookName}.{Guid.NewGuid():N}.tmp"); @@ -930,7 +1088,7 @@ private static void ReplaceCustomHookWithManagedHook( try { - WriteStagedHookScript(ioStagedHookPath, chainedHookPath, projectPath); + WriteStagedHookScript(ioStagedHookPath, hookScript); ReplaceFile(ioStagedHookPath, ioHookPath, ioChainedHookPath); stagedHookMoved = true; MakeExecutable(ioHookPath); @@ -942,7 +1100,9 @@ private static void ReplaceCustomHookWithManagedHook( } } - private static void WriteStagedHookScript(string ioStagedHookPath, string chainedHookPath, string projectPath) + private static void WriteStagedHookScript( + string ioStagedHookPath, + string hookScript) { using (var stream = CreateStagedHookFileStream(ioStagedHookPath)) { @@ -952,7 +1112,7 @@ private static void WriteStagedHookScript(string ioStagedHookPath, string chaine bufferSize: 1024, leaveOpen: true)) { - writer.Write(BuildHookScript(chainedHookPath, projectPath)); + writer.Write(hookScript); writer.Flush(); } @@ -1016,14 +1176,930 @@ private static void RecordHookWarning( warnings.Add(new HookCommandWarningJsonResult(category, path, DiagnosticSanitizer.ForPath(path), message)); } - private static string BuildHookScript(string chainedHookPath, string projectPath) + private static bool TryResolveCurrentExecutable( + string appVersion, + out HookExecutableSelection selection, + out string failureReason) + { + if (ExecutableSelectionForTesting != null) + { + selection = ExecutableSelectionForTesting(appVersion); + return ValidateExecutableSelection(selection, out failureReason); + } + + // Single-file apphosts pin Environment.ProcessPath and never consume this + // entry-assembly value; framework-dependent `dotnet` launches require it. +#pragma warning disable IL3000 // Assembly.Location is intentionally unused by single-file apphosts. + var entryAssemblyPath = typeof(HookCommandRunner).Assembly.Location; +#pragma warning restore IL3000 + return TryCreateExecutableSelection( + Environment.ProcessPath, + entryAssemblyPath, + appVersion, + out selection, + out failureReason); + } + + internal static bool TryCreateExecutableSelection( + string? processPath, + string? entryAssemblyPath, + string appVersion, + out HookExecutableSelection selection, + out string failureReason) + { + selection = default!; + if (string.IsNullOrWhiteSpace(processPath)) + { + failureReason = "current_process_path_unavailable"; + return false; + } + + var launcherIsDotnetHost = IsDotnetHost(processPath); + if (!TryResolvePinnedPath(processPath, out var resolvedProcessPath, out failureReason)) + return false; + + if (!launcherIsDotnetHost && !IsDotnetHost(resolvedProcessPath)) + { + string? resolvedEntryAssemblyPath = null; + if (!string.IsNullOrWhiteSpace(entryAssemblyPath)) + { + if (!TryResolvePinnedPath( + entryAssemblyPath, + out resolvedEntryAssemblyPath, + out failureReason)) + { + return false; + } + } + + selection = new HookExecutableSelection( + "process_path", + appVersion, + [resolvedProcessPath], + resolvedEntryAssemblyPath); + return ValidateExecutableSelection(selection, out failureReason); + } + + if (string.IsNullOrWhiteSpace(entryAssemblyPath)) + { + failureReason = "current_entry_assembly_path_unavailable"; + return false; + } + + if (!TryResolvePinnedPath(entryAssemblyPath, out var resolvedAssemblyPath, out failureReason)) + return false; + + selection = new HookExecutableSelection( + "dotnet_host_and_assembly", + appVersion, + [resolvedProcessPath, resolvedAssemblyPath]); + return ValidateExecutableSelection(selection, out failureReason); + } + + private static bool TryResolvePinnedPath( + string path, + out string resolvedPath, + out string failureReason) + { + resolvedPath = string.Empty; + try + { + var fullPath = Path.GetFullPath(path); + if (!File.Exists(LongPath.EnsureWindowsPrefix(fullPath))) + { + failureReason = "current_executable_path_missing"; + return false; + } + + var linkTarget = new FileInfo(LongPath.EnsureWindowsPrefix(fullPath)) + .ResolveLinkTarget(returnFinalTarget: true); + resolvedPath = Path.GetFullPath(linkTarget?.FullName ?? fullPath); + if (!File.Exists(LongPath.EnsureWindowsPrefix(resolvedPath))) + { + failureReason = "resolved_executable_path_missing"; + return false; + } + + failureReason = string.Empty; + return true; + } + catch (Exception ex) when (IsHookFileOperationException(ex)) + { + failureReason = $"current_executable_path_unusable:{DiagnosticRedactor.ClassifyException(ex)}"; + return false; + } + } + + private static bool ValidateExecutableSelection( + HookExecutableSelection selection, + out string failureReason) + { + if (!HasValidExecutableSelectionShape(selection)) + { + failureReason = "current_executable_provenance_invalid"; + return false; + } + + foreach (var argument in selection.Argv) + { + if (!File.Exists(LongPath.EnsureWindowsPrefix(argument))) + { + failureReason = "current_executable_path_unusable"; + return false; + } + } + if (!IsRunnableExecutable(selection.Argv[0])) + { + failureReason = "current_executable_not_runnable"; + return false; + } + var entryAssemblyPath = GetEntryAssemblyPath(selection); + if (entryAssemblyPath != null + && !ValidateManagedDeploymentFiles(entryAssemblyPath, out failureReason)) + { + return false; + } + if (EncodeExecutableManifest(selection).Length > MaxExecutableManifestChars) + { + failureReason = "current_executable_provenance_too_large"; + return false; + } + + failureReason = string.Empty; + return true; + } + + private static bool HasValidExecutableSelectionShape(HookExecutableSelection selection) + => selection.Source is "process_path" or "dotnet_host_and_assembly" + && !string.IsNullOrWhiteSpace(selection.Version) + && selection.Version.Length <= 128 + && !selection.Version.Any(char.IsControl) + && ((selection.Source == "process_path" && selection.Argv.Count == 1) + || (selection.Source == "dotnet_host_and_assembly" && selection.Argv.Count == 2)) + && selection.Argv.All(static argument => + !string.IsNullOrWhiteSpace(argument) + && argument.Length <= MaxExecutableArgumentChars + && IsCanonicalFullyQualifiedPath(argument) + && argument.IndexOfAny(['\0', '\r', '\n']) < 0) + && (selection.EntryAssemblyPath == null + || (!string.IsNullOrWhiteSpace(selection.EntryAssemblyPath) + && selection.EntryAssemblyPath.Length <= MaxExecutableArgumentChars + && IsCanonicalFullyQualifiedPath(selection.EntryAssemblyPath) + && selection.EntryAssemblyPath.IndexOfAny(['\0', '\r', '\n']) < 0)) + && (selection.Source != "dotnet_host_and_assembly" + || selection.EntryAssemblyPath == null + || string.Equals( + selection.EntryAssemblyPath, + selection.Argv[1], + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)); + + private static string? GetEntryAssemblyPath(HookExecutableSelection selection) + => selection.EntryAssemblyPath + ?? (selection.Source == "dotnet_host_and_assembly" && selection.Argv.Count > 1 + ? selection.Argv[1] + : null); + + internal static bool IsCanonicalFullyQualifiedPath(string path) + { + if (!Path.IsPathFullyQualified(path)) + return false; + + try + { + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(path, Path.GetFullPath(path), comparison); + } + catch (Exception ex) when (IsHookFileOperationException(ex)) + { + return false; + } + } + + private static HookExecutableJsonResult InspectExecutable( + HookExecutableSelection installedSelection, + HookExecutableSelection? currentSelection) + { + var currentInvocationMatches = currentSelection != null + && ExecutableSelectionsMatch(installedSelection, currentSelection); + var inspectedSelection = currentInvocationMatches + ? currentSelection! + : installedSelection; + var path = inspectedSelection.Argv.FirstOrDefault(); + var entryAssemblyPath = GetEntryAssemblyPath(inspectedSelection); + var diagnosticArgv = inspectedSelection.Argv + .Select(DiagnosticSanitizer.ForSupportSafePath) + .ToArray(); + HookExecutableJsonResult Result( + string status, + string failureReason, + string? actualVersion = null) + => new( + inspectedSelection.Source, + path, + entryAssemblyPath, + inspectedSelection.Argv, + path == null ? null : DiagnosticSanitizer.ForSupportSafePath(path), + entryAssemblyPath == null ? null : DiagnosticSanitizer.ForSupportSafePath(entryAssemblyPath), + diagnosticArgv, + installedSelection.Version, + actualVersion, + status, + failureReason); + + var missingPath = inspectedSelection.Argv.FirstOrDefault( + static argument => !File.Exists(LongPath.EnsureWindowsPrefix(argument))); + if (missingPath != null) + return Result("missing", "pinned_executable_missing"); + if (!IsRunnableExecutable(inspectedSelection.Argv[0])) + return Result("not_executable", "pinned_executable_not_runnable"); + if (entryAssemblyPath != null) + { + if (!File.Exists(LongPath.EnsureWindowsPrefix(entryAssemblyPath))) + return Result("missing", "pinned_entry_assembly_missing"); + if (!IsReadableRegularFile(entryAssemblyPath)) + return Result("not_executable", "pinned_entry_assembly_unreadable"); + + foreach (var runtimePath in GetManagedRuntimePaths(entryAssemblyPath)) + { + if (!File.Exists(LongPath.EnsureWindowsPrefix(runtimePath))) + return Result("missing", "pinned_runtime_file_missing"); + if (!IsReadableRegularFile(runtimePath)) + return Result("not_executable", "pinned_runtime_file_unreadable"); + } + } + + string? actualVersion; + if (currentInvocationMatches) + actualVersion = inspectedSelection.Version; + else + actualVersion = TryReadPinnedVersion(inspectedSelection); + + var status = actualVersion == null + ? "available_unverified" + : string.Equals(actualVersion, installedSelection.Version, StringComparison.Ordinal) + ? "available" + : "version_mismatch"; + var failureReason = status switch + { + "available_unverified" => "pinned_executable_version_unavailable", + "version_mismatch" => "pinned_executable_version_mismatch", + _ => null, + }; + return new HookExecutableJsonResult( + inspectedSelection.Source, + path, + entryAssemblyPath, + inspectedSelection.Argv, + path == null ? null : DiagnosticSanitizer.ForSupportSafePath(path), + entryAssemblyPath == null ? null : DiagnosticSanitizer.ForSupportSafePath(entryAssemblyPath), + diagnosticArgv, + installedSelection.Version, + actualVersion, + status, + failureReason); + } + + internal static bool ExecutableSelectionsMatch( + HookExecutableSelection left, + HookExecutableSelection right) + { + if (!string.Equals(left.Source, right.Source, StringComparison.Ordinal) + || left.Argv.Count != right.Argv.Count) + { + return false; + } + + for (var index = 0; index < left.Argv.Count; index++) + { + var comparison = PathCasing.ComparisonFor(left.Argv[index]); + if (!string.Equals(left.Argv[index], right.Argv[index], comparison)) + return false; + } + + // A process-path hook invokes the apphost or wrapper directly. Its managed + // payload may change during an in-place upgrade without changing that argv. + if (left.Source == "process_path") + return true; + + var leftEntryAssemblyPath = GetEntryAssemblyPath(left); + var rightEntryAssemblyPath = GetEntryAssemblyPath(right); + if (leftEntryAssemblyPath == null || rightEntryAssemblyPath == null) + return leftEntryAssemblyPath == rightEntryAssemblyPath; + + return string.Equals( + leftEntryAssemblyPath, + rightEntryAssemblyPath, + PathCasing.ComparisonFor(leftEntryAssemblyPath)); + } + + private static string? TryReadPinnedVersion(HookExecutableSelection selection) + { + var versionTarget = GetEntryAssemblyPath(selection) ?? selection.Argv[0]; + try + { + var directory = Path.GetDirectoryName(versionTarget); + if (directory == null) + return null; + var versionPath = Path.Combine(directory, "version.json"); + if (!IsReadableRegularFile(versionPath)) + return null; + var versionBytes = DataDirectorySecurity.ReadBytesWithinLimit( + versionPath, + 16 * 1024, + FileShare.ReadWrite); + if (versionBytes is null) + return null; + + using var document = JsonDocument.Parse(versionBytes); + if (document.RootElement.ValueKind != JsonValueKind.Object) + return null; + if (!document.RootElement.TryGetProperty("version", out var version) + || version.ValueKind != JsonValueKind.String) + { + return null; + } + + var value = version.GetString(); + return !string.IsNullOrWhiteSpace(value) + && value.Length <= 128 + && !value.Any(char.IsControl) + ? value + : null; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or JsonException) + { + return null; + } + } + + private static string EncodeExecutableManifest(HookExecutableSelection selection) + { + using var buffer = new MemoryStream(); + using (var writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen: true)) + { + writer.Write(2); + WriteManifestString(writer, selection.Source); + WriteManifestString(writer, selection.Version); + writer.Write(selection.Argv.Count); + foreach (var argument in selection.Argv) + WriteManifestString(writer, argument); + var entryAssemblyPath = GetEntryAssemblyPath(selection); + writer.Write(entryAssemblyPath != null); + if (entryAssemblyPath != null) + WriteManifestString(writer, entryAssemblyPath); + } + + return Convert.ToBase64String(buffer.ToArray()); + } + + private static bool TryReadExecutableManifest( + byte[]? hookContent, + out HookExecutableSelection selection) + { + selection = default!; + if (hookContent == null) + return false; + + string text; + try + { + text = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true).GetString(hookContent); + } + catch (DecoderFallbackException) + { + return false; + } + + if (CountMarkerLines(text, BeginMarker, out var beginIndex) != 1) + return false; + var headerOffset = FindLineEndIncludingTerminator( + text, + beginIndex + BeginMarker.Length); + if (!TryReadLine(text, ref headerOffset, out var sourceLine) + || !sourceLine.StartsWith( + "# CDIDX EXECUTABLE SOURCE: ", + StringComparison.Ordinal) + || !TryReadLine(text, ref headerOffset, out var versionLine) + || !versionLine.StartsWith( + "# CDIDX EXECUTABLE VERSION: ", + StringComparison.Ordinal) + || !TryReadLine(text, ref headerOffset, out var manifestLine) + || !manifestLine.StartsWith( + ExecutableManifestPrefix, + StringComparison.Ordinal)) + { + return false; + } + + var encoded = manifestLine[ExecutableManifestPrefix.Length..].Trim(); + if (encoded.Length is 0 or > MaxExecutableManifestChars) + return false; + + try + { + var bytes = Convert.FromBase64String(encoded); + using var buffer = new MemoryStream(bytes, writable: false); + using var reader = new BinaryReader(buffer, Encoding.UTF8, leaveOpen: false); + var manifestVersion = reader.ReadInt32(); + if (manifestVersion is not 1 and not 2) + return false; + if (!TryReadManifestString(reader, 64, out var source) + || !TryReadManifestString(reader, 128, out var version)) + { + return false; + } + var argumentCount = reader.ReadInt32(); + if (argumentCount is < 1 or > 2) + return false; + var arguments = new string[argumentCount]; + for (var index = 0; index < arguments.Length; index++) + { + if (!TryReadManifestString(reader, MaxExecutableArgumentChars, out arguments[index])) + return false; + } + + string? entryAssemblyPath = null; + if (manifestVersion == 2 + && reader.ReadBoolean() + && !TryReadManifestString( + reader, + MaxExecutableArgumentChars, + out entryAssemblyPath)) + { + return false; + } + + if (buffer.Position != buffer.Length) + return false; + selection = new HookExecutableSelection( + source, + version, + arguments, + entryAssemblyPath); + return HasValidExecutableSelectionShape(selection); + } + catch (Exception ex) when (ex is FormatException + or EndOfStreamException + or IOException + or ArgumentException + or DecoderFallbackException) + { + return false; + } + } + + private static bool TryAnalyzeManagedInvocation( + byte[]? hookContent, + string projectPath, + string chainedHookPath, + HookExecutableSelection selection, + out string hookState) + { + hookState = "executable_manifest_unresolved"; + if (hookContent is null) + return false; + + string actualText; + try + { + actualText = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true).GetString(hookContent); + } + catch (DecoderFallbackException) + { + return false; + } + + if (!TryExtractManagedBlock(actualText, out var actualBlock)) + return false; + + var invocation = string.Join( + ' ', + selection.Argv.Select(static argument => QuoteShell(NormalizeShellPath(argument)))); + var header = $""" +{BeginMarker} +# CDIDX EXECUTABLE SOURCE: {FormatManifestComment(selection.Source)} +# CDIDX EXECUTABLE VERSION: {FormatManifestComment(selection.Version)} +{ExecutableManifestPrefix}{EncodeExecutableManifest(selection)} +{invocation} index +""" + " "; + if (!actualBlock.StartsWith(header, StringComparison.Ordinal)) + return false; + + var offset = header.Length; + if (!TryReadQuotedShellValue(actualBlock, ref offset, out var installedProjectPath)) + return false; + const string beforeChainedPath = """ + --quiet +cdidx_status=$? +if [ "$cdidx_status" -ne 0 ]; then + echo "cdidx pre-commit index failed; commit aborted. Use git commit --no-verify to bypass hooks." >&2 + exit "$cdidx_status" +fi +if [ -x +""" + " "; + if (!actualBlock.AsSpan(offset).StartsWith(beforeChainedPath, StringComparison.Ordinal)) + return false; + offset += beforeChainedPath.Length; + if (!TryReadQuotedShellValue(actualBlock, ref offset, out var installedChainedHookPath)) + return false; + if (!IsCanonicalFullyQualifiedPath(installedProjectPath) + || !IsCanonicalFullyQualifiedPath(installedChainedHookPath)) + { + return false; + } + + if (!TryBuildHookScript( + installedChainedHookPath, + installedProjectPath, + selection, + out var expectedText) + || !TryExtractManagedBlock(expectedText, out var expectedBlock) + || !string.Equals(actualBlock, expectedBlock, StringComparison.Ordinal)) + { + return false; + } + + bool projectPathMatches; + bool chainedHookPathMatches; + try + { + projectPathMatches = RepositoryPathsEqual(projectPath, installedProjectPath); + chainedHookPathMatches = RepositoryPathsEqual( + chainedHookPath, + installedChainedHookPath); + } + catch (Exception ex) when (IsHookFileOperationException(ex) || ex is CodeIndexException) + { + return false; + } + + hookState = (projectPathMatches, chainedHookPathMatches) switch + { + (true, true) => "managed", + (false, true) => "project_path_mismatch", + (true, false) => "chained_hook_path_mismatch", + _ => "repository_path_mismatch", + }; + return true; + } + + internal static bool RepositoryPathsEqual(string currentPath, string installedPath) + { + var normalizedCurrentPath = PathCasing.NormalizeBoundaryPath(currentPath); + var normalizedInstalledPath = PathCasing.NormalizeBoundaryPath(installedPath); + if (PathCasing.PathsEqual(normalizedCurrentPath, normalizedInstalledPath)) + return true; + if (OperatingSystem.IsWindows() + || !TryResolveRepositoryPathAliases(normalizedCurrentPath, out var resolvedCurrentPath) + || !TryResolveRepositoryPathAliases(normalizedInstalledPath, out var resolvedInstalledPath)) + { + return false; + } + + return PathCasing.PathsEqual(resolvedCurrentPath, resolvedInstalledPath); + } + + private static bool TryResolveRepositoryPathAliases(string path, out string resolvedPath) + { + resolvedPath = string.Empty; + var suffix = new Stack(); + var existingAncestor = Path.GetFullPath(path); + while (!File.Exists(LongPath.EnsureWindowsPrefix(existingAncestor)) + && !Directory.Exists(LongPath.EnsureWindowsPrefix(existingAncestor))) + { + var name = Path.GetFileName(existingAncestor); + var parent = Path.GetDirectoryName(existingAncestor); + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(parent)) + return false; + suffix.Push(name); + existingAncestor = parent; + } + + IntPtr pointer = IntPtr.Zero; + try + { + pointer = UnixRealPath(existingAncestor, IntPtr.Zero); + if (pointer == IntPtr.Zero) + return false; + var realAncestor = Marshal.PtrToStringUTF8(pointer); + if (string.IsNullOrEmpty(realAncestor)) + return false; + + resolvedPath = suffix.Aggregate(realAncestor, Path.Combine); + resolvedPath = Path.GetFullPath(resolvedPath); + return true; + } + catch (Exception ex) when (IsHookFileOperationException(ex) + || ex is DllNotFoundException + or EntryPointNotFoundException) + { + return false; + } + finally + { + if (pointer != IntPtr.Zero) + UnixFree(pointer); + } + } + + private static bool TryReadQuotedShellValue( + string text, + ref int offset, + out string value) + { + value = string.Empty; + if (offset >= text.Length || text[offset] != '\'') + return false; + + offset++; + var builder = new StringBuilder(); + while (offset < text.Length) + { + if (text[offset] != '\'') + { + builder.Append(text[offset++]); + continue; + } + + if (text.AsSpan(offset).StartsWith("'\"'\"'", StringComparison.Ordinal)) + { + builder.Append('\''); + offset += 5; + continue; + } + + offset++; + value = builder.ToString(); + return true; + } + + return false; + } + + private static bool TryReadLine( + string text, + ref int offset, + out string line) + { + line = string.Empty; + if (offset < 0 || offset >= text.Length) + return false; + + var lineEnd = FindLineEnd(text, offset); + line = text[offset..lineEnd]; + offset = FindLineEndIncludingTerminator(text, lineEnd); + return true; + } + + private static bool TryExtractManagedBlock(string text, out string block) + { + block = string.Empty; + if (CountMarkerLines(text, BeginMarker, out var beginIndex) != 1 + || CountMarkerLines(text, EndMarker, out var endIndex) != 1) + { + return false; + } + + if (endIndex < beginIndex) + return false; + + var blockStart = FindLineStart(text, beginIndex); + var blockEnd = FindLineEndIncludingTerminator(text, endIndex + EndMarker.Length); + block = text[blockStart..blockEnd]; + return true; + } + + private static void WriteManifestString(BinaryWriter writer, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + writer.Write(bytes.Length); + writer.Write(bytes); + } + + private static bool TryReadManifestString( + BinaryReader reader, + int maxCharacters, + out string value) + { + value = string.Empty; + var byteLength = reader.ReadInt32(); + var maxBytes = checked(maxCharacters * 4); + if (byteLength is < 0 || byteLength > maxBytes) + return false; + var bytes = reader.ReadBytes(byteLength); + if (bytes.Length != byteLength) + return false; + value = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true).GetString(bytes); + return value.Length <= maxCharacters; + } + + private static string FormatManifestComment(string value) + => new(value.Select(static character => char.IsControl(character) ? '?' : character).ToArray()); + + private static string NormalizeShellPath(string value) + => OperatingSystem.IsWindows() + ? value.Replace('\\', '/') + : value; + + private static bool IsRunnableExecutable(string path) + { + if (!IsRegularFile(path)) + return false; + if (OperatingSystem.IsWindows()) + return true; + try + { + return UnixAccess(LongPath.EnsureWindowsPrefix(path), UnixExecuteAccess) == 0; + } + catch (Exception ex) when (IsHookFileOperationException(ex) + || ex is DllNotFoundException + or EntryPointNotFoundException) + { + return false; + } + } + + private static bool ValidateManagedDeploymentFiles( + string assemblyPath, + out string failureReason) + { + if (!IsReadableRegularFile(assemblyPath)) + { + failureReason = "current_entry_assembly_unreadable"; + return false; + } + + foreach (var runtimePath in GetManagedRuntimePaths(assemblyPath)) + { + if (!File.Exists(LongPath.EnsureWindowsPrefix(runtimePath))) + { + failureReason = "current_runtime_file_missing"; + return false; + } + if (!IsReadableRegularFile(runtimePath)) + { + failureReason = "current_runtime_file_unreadable"; + return false; + } + } + + failureReason = string.Empty; + return true; + } + + private static string[] GetManagedRuntimePaths(string assemblyPath) + => + [ + Path.ChangeExtension(assemblyPath, ".runtimeconfig.json"), + Path.ChangeExtension(assemblyPath, ".deps.json"), + ]; + + private static bool IsReadableRegularFile(string path) + { + if (!IsRegularFile(path)) + return false; + + if (!OperatingSystem.IsWindows()) + { + try + { + return UnixAccess(LongPath.EnsureWindowsPrefix(path), UnixReadAccess) == 0; + } + catch (Exception ex) when (IsHookFileOperationException(ex) + || ex is DllNotFoundException + or EntryPointNotFoundException) + { + return false; + } + } + + try + { + using var stream = File.Open( + LongPath.EnsureWindowsPrefix(path), + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + return stream.CanRead; + } + catch (Exception ex) when (IsHookFileOperationException(ex)) + { + return false; + } + } + + private static bool IsRegularFile(string path) + { + try + { + var ioPath = LongPath.EnsureWindowsPrefix(path); + var attributes = File.GetAttributes(ioPath); + if ((attributes + & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + { + return false; + } + + if (OperatingSystem.IsWindows()) + return true; + + return UnixStat(ioPath, out var status) == 0 + && (status.Mode & UnixFileTypeMask) == UnixRegularFileType; + } + catch (Exception ex) when (IsHookFileOperationException(ex) + || ex is DllNotFoundException + or EntryPointNotFoundException) + { + return false; + } + } + + [DllImport("libc", EntryPoint = "access", SetLastError = true)] + private static extern int UnixAccess(string path, int mode); + + [DllImport("libSystem.Native", EntryPoint = "SystemNative_Stat", CharSet = CharSet.Ansi)] + private static extern int UnixStat(string path, out UnixFileStatus status); + + [DllImport("libc", EntryPoint = "realpath", SetLastError = true)] + private static extern IntPtr UnixRealPath(string path, IntPtr resolvedPath); + + [DllImport("libc", EntryPoint = "free")] + private static extern void UnixFree(IntPtr pointer); + + [StructLayout(LayoutKind.Sequential)] + private struct UnixFileStatus + { + internal uint Flags; + internal int Mode; + internal uint Uid; + internal uint Gid; + internal long Size; + internal long ATime; + internal long ATimeNsec; + internal long MTime; + internal long MTimeNsec; + internal long CTime; + internal long CTimeNsec; + internal long BirthTime; + internal long BirthTimeNsec; + internal long Dev; + internal long RDev; + internal long Ino; + internal uint UserFlags; + } + + private static bool IsDotnetHost(string processPath) + => string.Equals( + Path.GetFileNameWithoutExtension(processPath.Replace('\\', '/')), + "dotnet", + StringComparison.OrdinalIgnoreCase); + + internal static bool TryBuildHookScript( + string chainedHookPath, + string projectPath, + HookExecutableSelection executableSelection, + out string hookScript) + { + hookScript = BuildHookScript(chainedHookPath, projectPath, executableSelection); + if (Encoding.UTF8.GetByteCount(hookScript) <= MaxHookMarkerBytes) + return true; + + hookScript = string.Empty; + return false; + } + + private static string BuildHookScript( + string chainedHookPath, + string projectPath, + HookExecutableSelection executableSelection) { var quotedChainedHook = QuoteShell(chainedHookPath); var quotedProjectPath = QuoteShell(projectPath); + var invocation = string.Join( + ' ', + executableSelection.Argv.Select(static argument => QuoteShell(NormalizeShellPath(argument)))); + var manifest = EncodeExecutableManifest(executableSelection); return $""" #!/bin/sh {BeginMarker} -cdidx index {quotedProjectPath} --quiet +# CDIDX EXECUTABLE SOURCE: {FormatManifestComment(executableSelection.Source)} +# CDIDX EXECUTABLE VERSION: {FormatManifestComment(executableSelection.Version)} +{ExecutableManifestPrefix}{manifest} +{invocation} index {quotedProjectPath} --quiet cdidx_status=$? if [ "$cdidx_status" -ne 0 ]; then echo "cdidx pre-commit index failed; commit aborted. Use git commit --no-verify to bypass hooks." >&2 @@ -1067,6 +2143,7 @@ private static int WriteDryRunResult( string hookPath, string chainedHookPath, HookOperationPlan plan, + HookExecutableJsonResult? executable = null, string? managedHookPreview = null) { var reportedChainedHookPath = plan.ChainedHookState == "present" @@ -1088,7 +2165,8 @@ private static int WriteDryRunResult( filesystemMutation: false, hookState: plan.HookState, chainedHookState: plan.ChainedHookState, - plannedChanges: plan.PlannedChanges); + plannedChanges: plan.PlannedChanges, + executable: executable); } private static int WriteBlockedPlanResult( @@ -1097,7 +2175,8 @@ private static int WriteBlockedPlanResult( string projectPath, string hookPath, string chainedHookPath, - HookOperationPlan plan) + HookOperationPlan plan, + HookExecutableJsonResult? executable = null) => WriteResult( options.Json, jsonOptions, @@ -1106,7 +2185,68 @@ private static int WriteBlockedPlanResult( projectPath, hookPath, plan.ChainedHookState == "present" ? chainedHookPath : null, - plan.BlockExitCode); + plan.BlockExitCode, + executable: executable); + + private static int WriteGeneratedHookTooLargeResult( + HookCommandOptions options, + JsonSerializerOptions jsonOptions, + string projectPath, + string hookPath, + string chainedHookPath, + HookExecutableJsonResult executable) + { + var message = + $"generated cdidx pre-commit hook exceeds the {MaxHookMarkerBytes}-byte management limit"; + if (options.DryRun) + { + var plan = BuildInstallPreflightFailurePlan( + hookPath, + chainedHookPath, + message); + return WriteDryRunResult( + options, + jsonOptions, + projectPath, + hookPath, + chainedHookPath, + plan, + executable); + } + + return WriteResult( + options.Json, + jsonOptions, + "error", + message, + projectPath, + hookPath, + null, + CommandExitCodes.InstallError, + executable: executable); + } + + private static HookOperationPlan BuildInstallPreflightFailurePlan( + string hookPath, + string chainedHookPath, + string message) + { + var ioHookPath = LongPath.EnsureWindowsPrefix(hookPath); + var hookState = File.Exists(ioHookPath) + ? AnalyzeManagedHook(ReadHookBytesWithinLimit(ioHookPath)).State + : "absent"; + var chainedHookState = File.Exists(LongPath.EnsureWindowsPrefix(chainedHookPath)) + ? "present" + : "absent"; + return new HookOperationPlan( + "blocked", + message, + hookState, + chainedHookState, + [], + Blocked: true, + BlockExitCode: CommandExitCodes.InstallError); + } private static int WriteResult( bool json, @@ -1124,11 +2264,15 @@ private static int WriteResult( bool? filesystemMutation = null, string? hookState = null, string? chainedHookState = null, - IReadOnlyList? plannedChanges = null) + IReadOnlyList? plannedChanges = null, + HookExecutableJsonResult? executable = null) { var hasWarnings = warnings is { Count: > 0 }; if (exitCode != CommandExitCodes.Success) { + var safeManagedHookPreview = managedHookPreview == null + ? null + : SanitizeManagedHookPreviewForError(managedHookPreview); if (!json && hasWarnings) { foreach (var warning in warnings!) @@ -1159,6 +2303,9 @@ private static int WriteResult( : DiagnosticSanitizer.ForPath(change.SourcePath), }) .ToArray(); + var safeExecutable = executable == null + ? null + : SanitizeExecutableResult(executable); additionalJsonProperties = JsonSerializer.SerializeToNode( new HookCommandJsonResult( status, @@ -1172,13 +2319,12 @@ private static int WriteResult( safeWarnings, dryRun, plannedAction, - managedHookPreview == null - ? null - : DiagnosticRedactor.RedactSensitiveText(managedHookPreview, "[redacted]", redactPaths: true), + safeManagedHookPreview, filesystemMutation, hookState, chainedHookState, - safePlannedChanges), + safePlannedChanges, + safeExecutable), CliJsonSerializerContextFactory.Create(jsonOptions).HookCommandJsonResult)!.AsObject(); } @@ -1209,7 +2355,7 @@ CommandExitCodes.UsageError or CommandExitCodes.InvalidArgument hookState, chainedHookState, plannedChanges, - managedHookPreview); + safeManagedHookPreview); } return result; @@ -1234,7 +2380,8 @@ CommandExitCodes.UsageError or CommandExitCodes.InvalidArgument filesystemMutation, hookState, chainedHookState, - plannedChanges), + plannedChanges, + executable), CliJsonSerializerContextFactory.Create(jsonOptions).HookCommandJsonResult)); } else @@ -1252,6 +2399,7 @@ CommandExitCodes.UsageError or CommandExitCodes.InvalidArgument CommandErrorWriter.WriteStdout($"Hook: {hookPath}"); if (chainedHookPath != null) CommandErrorWriter.WriteStdout($"Chained hook: {chainedHookPath}"); + WriteExecutableDetails(executable); } else { @@ -1303,6 +2451,59 @@ private static void WriteDryRunDetails( } } + private static HookExecutableJsonResult SanitizeExecutableResult(HookExecutableJsonResult executable) + => executable with + { + Path = executable.DiagnosticPath, + EntryAssemblyPath = executable.DiagnosticEntryAssemblyPath, + Argv = executable.DiagnosticArgv, + FailureReason = executable.FailureReason == null + ? null + : DiagnosticSanitizer.ForMessage(executable.FailureReason), + }; + + private static string SanitizeManagedHookPreviewForError(string managedHookPreview) + { + var manifestStart = managedHookPreview.IndexOf( + ExecutableManifestPrefix, + StringComparison.Ordinal); + if (manifestStart >= 0) + { + var payloadStart = manifestStart + ExecutableManifestPrefix.Length; + var payloadEnd = managedHookPreview.IndexOfAny(['\r', '\n'], payloadStart); + if (payloadEnd < 0) + payloadEnd = managedHookPreview.Length; + managedHookPreview = string.Concat( + managedHookPreview.AsSpan(0, payloadStart), + "[redacted]", + managedHookPreview.AsSpan(payloadEnd)); + } + + return DiagnosticRedactor.RedactSensitiveText( + managedHookPreview, + "[redacted]", + redactPaths: true); + } + + private static void WriteExecutableDetails(HookExecutableJsonResult? executable) + { + if (executable == null) + return; + + CommandErrorWriter.WriteStdout($"Executable status: {executable.Status}"); + CommandErrorWriter.WriteStdout($"Executable source: {executable.Source}"); + if (executable.Path != null) + CommandErrorWriter.WriteStdout($"Executable: {executable.Path}"); + if (executable.EntryAssemblyPath != null) + CommandErrorWriter.WriteStdout($"Entry assembly: {executable.EntryAssemblyPath}"); + if (executable.ExpectedVersion != null) + CommandErrorWriter.WriteStdout($"Expected version: {executable.ExpectedVersion}"); + if (executable.ActualVersion != null) + CommandErrorWriter.WriteStdout($"Actual version: {executable.ActualVersion}"); + if (executable.FailureReason != null) + CommandErrorWriter.WriteStdout($"Executable diagnostic: {executable.FailureReason}"); + } + private static string GetUsage() => "cdidx hooks [--project ] [--force] [--dry-run] [--json]"; @@ -1369,4 +2570,39 @@ public sealed record HookCommandJsonResult( string? HookState = null, string? ChainedHookState = null, IReadOnlyList? PlannedChanges = null, + HookExecutableJsonResult? Executable = null, [property: JsonPropertyName("api_version")] string ApiVersion = JsonOutputContract.ApiVersion) : IVersionedJsonResult; + +internal sealed record HookExecutableSelection( + string Source, + string Version, + IReadOnlyList Argv, + string? EntryAssemblyPath = null); + +public sealed record HookExecutableJsonResult( + [property: JsonPropertyName("source")] string Source, + [property: JsonPropertyName("path")] string? Path, + [property: JsonPropertyName("entry_assembly_path")] string? EntryAssemblyPath, + [property: JsonPropertyName("argv")] IReadOnlyList? Argv, + [property: JsonPropertyName("diagnostic_path")] string? DiagnosticPath, + [property: JsonPropertyName("diagnostic_entry_assembly_path")] string? DiagnosticEntryAssemblyPath, + [property: JsonPropertyName("diagnostic_argv")] IReadOnlyList? DiagnosticArgv, + [property: JsonPropertyName("expected_version")] string? ExpectedVersion, + [property: JsonPropertyName("actual_version")] string? ActualVersion, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("failure_reason")] string? FailureReason) +{ + public static HookExecutableJsonResult Unresolved(string failureReason) + => new( + "unresolved", + null, + null, + null, + null, + null, + null, + null, + null, + "unresolved", + failureReason); +} diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 087eb5951..44a48fdfa 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -1230,6 +1230,7 @@ internal sealed record ValidateConfigJsonResult( [JsonSerializable(typeof(IndexWatchRecoveryCommandJsonResult))] [JsonSerializable(typeof(ExportImportCommandRunner.ImportResult))] [JsonSerializable(typeof(HookCommandJsonResult))] +[JsonSerializable(typeof(HookExecutableJsonResult))] [JsonSerializable(typeof(HookCommandFileChangeJsonResult))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(HookCommandWarningJsonResult))] diff --git a/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs b/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs index 22980e9f6..982851606 100644 --- a/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs +++ b/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs @@ -102,7 +102,7 @@ private static int RunNonQueryCommand( "export" => ExportImportCommandRunner.RunExport(subArgs, context.JsonOptions, context.AppVersion, context.CancellationToken), "import" => ExportImportCommandRunner.RunImport(subArgs, context.JsonOptions, context.CancellationToken), "diff" => DiffCommandRunner.Run(subArgs, context.JsonOptions, context.CancellationToken), - "hooks" => HookCommandRunner.Run(subArgs, context.JsonOptions), + "hooks" => HookCommandRunner.Run(subArgs, context.JsonOptions, context.AppVersion), "backfill-fold" => IndexCommandRunner.RunBackfillFold(subArgs, context.JsonOptions), "fold" => IndexCommandRunner.RunBackfillFold(subArgs, context.JsonOptions), "optimize" => IndexCommandRunner.RunOptimizeFts(subArgs, context.JsonOptions), diff --git a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs index f5d8d31df..2d65e5a99 100644 --- a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using CodeIndex.Cli; @@ -29,7 +30,7 @@ public void Hooks_InstallStatusUninstall_ManagesPreCommitHook() Assert.True(File.Exists(hookPath)); var hook = File.ReadAllText(hookPath); Assert.Contains("BEGIN CDIDX MANAGED PRE-COMMIT", hook); - Assert.Contains($"cdidx index {QuoteShellForTest(projectRoot)} --quiet", hook); + Assert.Contains($"{BuildExpectedPinnedInvocation()} index {QuoteShellForTest(projectRoot)} --quiet", hook); var statusExit = RunHooksAndCaptureStreams(["status", "--project", projectRoot]).ExitCode; Assert.Equal(CommandExitCodes.Success, statusExit); @@ -81,7 +82,7 @@ public void Hooks_InstallJson_DistinguishesInstalledUpdatedAndAlreadyInstalled_I Assert.Equal(string.Empty, updatedInstall.StdErr); using (var document = JsonDocument.Parse(updatedInstall.StdOut)) Assert.Equal("updated", document.RootElement.GetProperty("status").GetString()); - Assert.Contains($"cdidx index {QuoteShellForTest(projectRoot)} --quiet", File.ReadAllText(hookPath)); + Assert.Contains($"{BuildExpectedPinnedInvocation()} index {QuoteShellForTest(projectRoot)} --quiet", File.ReadAllText(hookPath)); Assert.DoesNotContain("echo stale", File.ReadAllText(hookPath)); } finally @@ -298,6 +299,10 @@ public void Hooks_InstallDryRun_PreviewsCreateChainReplaceAndNoOpWithoutWriting_ "BEGIN CDIDX MANAGED PRE-COMMIT", document.RootElement.GetProperty("managed_hook_preview").GetString(), StringComparison.Ordinal); + Assert.Contains( + "# CDIDX EXECUTABLE MANIFEST [redacted]", + document.RootElement.GetProperty("managed_hook_preview").GetString(), + StringComparison.Ordinal); } Assert.Equal(customHook, File.ReadAllText(hookPath)); Assert.Equal(existingChain, File.ReadAllText(chainedHookPath)); @@ -309,6 +314,11 @@ public void Hooks_InstallDryRun_PreviewsCreateChainReplaceAndNoOpWithoutWriting_ Assert.Contains("Planned action: blocked", blockedHumanPreview.StdOut, StringComparison.Ordinal); Assert.Contains("Filesystem mutation: false", blockedHumanPreview.StdOut, StringComparison.Ordinal); Assert.Contains("Managed hook preview:", blockedHumanPreview.StdOut, StringComparison.Ordinal); + Assert.Contains( + "# CDIDX EXECUTABLE MANIFEST [redacted]", + blockedHumanPreview.StdOut, + StringComparison.Ordinal); + Assert.DoesNotContain(projectRoot, blockedHumanPreview.StdOut, StringComparison.Ordinal); Assert.Contains("chained hook already exists", blockedHumanPreview.StdErr, StringComparison.Ordinal); Assert.Contains("Hint:", blockedHumanPreview.StdErr, StringComparison.Ordinal); Assert.Contains("Usage:", blockedHumanPreview.StdErr, StringComparison.Ordinal); @@ -377,6 +387,45 @@ public void Hooks_DryRun_RejectsStatusSubcommand_Issues4716And4883() document.RootElement.GetProperty("message").GetString()); } + [Fact] + public void Hooks_InstallDryRun_PreflightFailurePreservesBlockedPlan_Issue4892() + { + var projectRoot = TestProjectHelper.CreateTempProject("hook_preflight_failure"); + try + { + TestProjectHelper.InitializeGitRepo(projectRoot); + HookCommandRunner.ExecutableSelectionForTesting = _ => + new HookExecutableSelection( + "process_path", + "1.0.0", + [Path.Combine(projectRoot, "missing-cdidx")]); + + var preview = RunHooksAndCaptureStreams( + ["install", "--project", projectRoot, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.InstallError, preview.ExitCode); + using var document = JsonDocument.Parse(preview.StdOut); + Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); + Assert.True(document.RootElement.GetProperty("dry_run").GetBoolean()); + Assert.False(document.RootElement.GetProperty("filesystem_mutation").GetBoolean()); + Assert.Equal("blocked", document.RootElement.GetProperty("planned_action").GetString()); + Assert.Equal("absent", document.RootElement.GetProperty("hook_state").GetString()); + Assert.Equal("absent", document.RootElement.GetProperty("chained_hook_state").GetString()); + Assert.Empty(document.RootElement.GetProperty("planned_changes").EnumerateArray()); + Assert.Equal( + "unresolved", + document.RootElement + .GetProperty("executable") + .GetProperty("status") + .GetString()); + } + finally + { + HookCommandRunner.ExecutableSelectionForTesting = null; + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Hooks_UninstallDryRun_PreviewsBlockRemovalRestoreForceAndIdempotenceWithoutWriting_Issue4883() { @@ -643,11 +692,790 @@ public void Hooks_Install_QuotesSelectedProjectPathInGeneratedHook() var hook = File.ReadAllText(hookPath); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Contains($"cdidx index {QuoteShellForTest(projectRoot)} --quiet", hook); - Assert.DoesNotContain("cdidx index . --quiet", hook); + Assert.Contains($"{BuildExpectedPinnedInvocation()} index {QuoteShellForTest(projectRoot)} --quiet", hook); + Assert.DoesNotContain("\ncdidx index ", hook, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(parent); + } + } + + [Fact] + public void Hooks_Status_ReadsManifestOnlyFromManagedHeader_Issue4892() + { + if (OperatingSystem.IsWindows()) + return; + + var parent = TestProjectHelper.CreateTempProject("hook_manifest_header"); + var projectRoot = Path.Combine( + parent, + "repo\n# CDIDX EXECUTABLE MANIFEST forged"); + try + { + Directory.CreateDirectory(projectRoot); + TestProjectHelper.InitializeGitRepo(projectRoot); + + var install = RunHooksAndCaptureStreams( + ["install", "--project", projectRoot, "--json"]); + var status = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, install.ExitCode); + Assert.Equal(CommandExitCodes.Success, status.ExitCode); + using var document = JsonDocument.Parse(status.StdOut); + Assert.Equal("installed", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("managed", document.RootElement.GetProperty("hook_state").GetString()); + Assert.Equal( + "available", + document.RootElement + .GetProperty("executable") + .GetProperty("status") + .GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(parent); + } + } + + [Fact] + public void Hooks_InstallDryRunAndStatus_PinAndReportExecutableProvenance_Issue4892() + { + var parent = TestProjectHelper.CreateTempProject("hook_pinned_executable"); + var projectRoot = Path.Combine(parent, "repo with ' quote"); + var toolDirectory = Path.Combine(parent, "tool versions", "v1"); + var toolPath = Path.Combine(toolDirectory, "cdidx wrapper.cmd"); + try + { + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(toolDirectory); + WriteRunnableFile(toolPath); + WriteVersionFile(toolDirectory, "1.2.3"); + HookCommandRunner.ExecutableSelectionForTesting = _ => + new HookExecutableSelection("process_path", "1.2.3", [toolPath]); + TestProjectHelper.InitializeGitRepo(projectRoot); + var hooksDir = Path.Combine(projectRoot, ".git", "hooks"); + var hookPath = Path.Combine(hooksDir, "pre-commit"); + var chainedHookPath = Path.Combine(hooksDir, "pre-commit.cdidx-chain"); + Directory.CreateDirectory(hooksDir); + const string customHook = "#!/bin/sh\necho existing\n"; + File.WriteAllText(hookPath, customHook); + + var preview = RunHooksAndCaptureStreams( + ["install", "--project", projectRoot, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, preview.ExitCode); + Assert.Equal(customHook, File.ReadAllText(hookPath)); + using (var document = JsonDocument.Parse(preview.StdOut)) + { + Assert.Equal("chain_existing", document.RootElement.GetProperty("planned_action").GetString()); + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("process_path", executable.GetProperty("source").GetString()); + Assert.Equal(toolPath, executable.GetProperty("path").GetString()); + Assert.Equal("1.2.3", executable.GetProperty("expected_version").GetString()); + Assert.Equal("1.2.3", executable.GetProperty("actual_version").GetString()); + Assert.Equal("available", executable.GetProperty("status").GetString()); + Assert.DoesNotContain( + parent, + executable.GetProperty("diagnostic_path").GetString(), + StringComparison.Ordinal); + Assert.All( + executable.GetProperty("diagnostic_argv").EnumerateArray(), + argument => Assert.DoesNotContain( + parent, + argument.GetString(), + StringComparison.Ordinal)); + } + + var install = RunHooksAndCaptureStreams( + ["install", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, install.ExitCode); + Assert.Equal(customHook, File.ReadAllText(chainedHookPath)); + var hook = File.ReadAllText(hookPath); + Assert.Contains( + $"{QuoteShellForTest(NormalizeExpectedShellPath(toolPath))} index {QuoteShellForTest(projectRoot)} --quiet", + hook, + StringComparison.Ordinal); + Assert.DoesNotContain("\ncdidx index ", hook, StringComparison.Ordinal); + Assert.Contains("# CDIDX EXECUTABLE SOURCE: process_path", hook, StringComparison.Ordinal); + Assert.Contains("# CDIDX EXECUTABLE VERSION: 1.2.3", hook, StringComparison.Ordinal); + + var status = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + var humanStatus = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot]); + + Assert.Equal(CommandExitCodes.Success, status.ExitCode); + using (var document = JsonDocument.Parse(status.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal(toolPath, executable.GetProperty("path").GetString()); + Assert.Equal("available", executable.GetProperty("status").GetString()); + } + Assert.Contains("Executable source: process_path", humanStatus.StdOut, StringComparison.Ordinal); + Assert.Contains($"Executable: {toolPath}", humanStatus.StdOut, StringComparison.Ordinal); + Assert.Contains("Actual version: 1.2.3", humanStatus.StdOut, StringComparison.Ordinal); + + if (PathCasing.IsIgnoreCase(projectRoot)) + { + var alternateCasing = projectRoot.ToUpperInvariant(); + Assert.NotEqual(projectRoot, alternateCasing); + var alternateCaseStatus = RunHooksAndCaptureStreams( + ["status", "--project", alternateCasing, "--json"]); + + Assert.Equal(CommandExitCodes.Success, alternateCaseStatus.ExitCode); + using var document = JsonDocument.Parse(alternateCaseStatus.StdOut); + Assert.Equal( + "available", + document.RootElement + .GetProperty("executable") + .GetProperty("status") + .GetString()); + } + + var alternateProjectPath = Path.Combine(parent, "relocated repo"); + var pathDriftHook = hook.Replace( + QuoteShellForTest(projectRoot), + QuoteShellForTest(alternateProjectPath), + StringComparison.Ordinal); + Assert.NotEqual(hook, pathDriftHook); + File.WriteAllText(hookPath, pathDriftHook); + var pathDriftStatus = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, pathDriftStatus.ExitCode); + using (var document = JsonDocument.Parse(pathDriftStatus.StdOut)) + { + Assert.Equal( + "project_path_mismatch", + document.RootElement.GetProperty("hook_state").GetString()); + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("available", executable.GetProperty("status").GetString()); + Assert.Equal(toolPath, executable.GetProperty("path").GetString()); + } + File.WriteAllText(hookPath, hook); + + var pinnedInvocation = + $"{QuoteShellForTest(NormalizeExpectedShellPath(toolPath))} index {QuoteShellForTest(projectRoot)} --quiet"; + var tamperedHook = hook.Replace( + pinnedInvocation, + $"cdidx index {QuoteShellForTest(projectRoot)} --quiet", + StringComparison.Ordinal); + Assert.NotEqual(hook, tamperedHook); + File.WriteAllText( + hookPath, + tamperedHook); + var tamperedStatus = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, tamperedStatus.ExitCode); + using (var document = JsonDocument.Parse(tamperedStatus.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("unresolved", executable.GetProperty("status").GetString()); + Assert.Equal( + "managed_hook_executable_manifest_mismatch", + executable.GetProperty("failure_reason").GetString()); + } + + var originalManifestLine = hook + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Single(static line => line.StartsWith( + "# CDIDX EXECUTABLE MANIFEST ", + StringComparison.Ordinal)); + var malformedManifestLine = + "# CDIDX EXECUTABLE MANIFEST " + + EncodeExecutableManifestForTest( + "process_path", + "1.2.3", + [toolPath, toolPath]); + var malformedHook = hook + .Replace( + originalManifestLine, + malformedManifestLine, + StringComparison.Ordinal) + .Replace( + pinnedInvocation, + $"{QuoteShellForTest(NormalizeExpectedShellPath(toolPath))} " + + $"{QuoteShellForTest(NormalizeExpectedShellPath(toolPath))} " + + $"index {QuoteShellForTest(projectRoot)} --quiet", + StringComparison.Ordinal); + File.WriteAllText(hookPath, malformedHook); + var malformedStatus = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, malformedStatus.ExitCode); + using (var document = JsonDocument.Parse(malformedStatus.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("unresolved", executable.GetProperty("status").GetString()); + Assert.False(string.IsNullOrWhiteSpace( + executable.GetProperty("failure_reason").GetString())); + } } finally { + HookCommandRunner.ExecutableSelectionForTesting = null; + TestProjectHelper.DeleteDirectory(parent); + } + } + + [Fact] + public void Hooks_Status_DiagnosesMultipleVersionsSkewAndMissingPinnedExecutable_Issue4892() + { + var projectRoot = TestProjectHelper.CreateTempProject("hook_pinned_status"); + var toolRoot = TestProjectHelper.CreateTempProject("hook_pinned_versions"); + var v1Directory = Path.Combine(toolRoot, "v1"); + var v2Directory = Path.Combine(toolRoot, "v2"); + var v1Path = Path.Combine(v1Directory, "cdidx"); + var v2Path = Path.Combine(v2Directory, "cdidx"); + try + { + Directory.CreateDirectory(v1Directory); + Directory.CreateDirectory(v2Directory); + WriteRunnableFile(v1Path); + WriteRunnableFile(v2Path); + WriteVersionFile(v1Directory, "1.0.0"); + WriteVersionFile(v2Directory, "2.0.0"); + TestProjectHelper.InitializeGitRepo(projectRoot); + HookCommandRunner.ExecutableSelectionForTesting = _ => + new HookExecutableSelection("process_path", "1.0.0", [v1Path]); + Assert.Equal( + CommandExitCodes.Success, + RunHooksAndCaptureStreams(["install", "--project", projectRoot]).ExitCode); + + HookCommandRunner.ExecutableSelectionForTesting = _ => + new HookExecutableSelection("process_path", "2.0.0", [v2Path]); + var separateVersion = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, separateVersion.ExitCode); + using (var document = JsonDocument.Parse(separateVersion.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal(v1Path, executable.GetProperty("path").GetString()); + Assert.Equal("1.0.0", executable.GetProperty("actual_version").GetString()); + Assert.Equal("available", executable.GetProperty("status").GetString()); + } + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + v1Path, + UnixFileMode.UserRead + | UnixFileMode.UserWrite + | UnixFileMode.GroupRead + | UnixFileMode.GroupExecute + | UnixFileMode.OtherRead + | UnixFileMode.OtherExecute); + var inaccessible = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + using (var document = JsonDocument.Parse(inaccessible.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("not_executable", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_executable_not_runnable", + executable.GetProperty("failure_reason").GetString()); + } + WriteRunnableFile(v1Path); + } + + WriteVersionFile(v1Directory, "1.1.0"); + var skewed = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + using (var document = JsonDocument.Parse(skewed.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("1.0.0", executable.GetProperty("expected_version").GetString()); + Assert.Equal("1.1.0", executable.GetProperty("actual_version").GetString()); + Assert.Equal("version_mismatch", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_executable_version_mismatch", + executable.GetProperty("failure_reason").GetString()); + } + + File.WriteAllText( + Path.Combine(v1Directory, "version.json"), + new string('x', 16 * 1024 + 1)); + var oversizedVersionMetadata = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + using (var document = JsonDocument.Parse(oversizedVersionMetadata.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("available_unverified", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_executable_version_unavailable", + executable.GetProperty("failure_reason").GetString()); + } + + File.WriteAllText( + Path.Combine(v1Directory, "version.json"), + """["not-an-object"]"""); + var nonObjectVersionMetadata = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, nonObjectVersionMetadata.ExitCode); + using (var document = JsonDocument.Parse(nonObjectVersionMetadata.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("available_unverified", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_executable_version_unavailable", + executable.GetProperty("failure_reason").GetString()); + } + + WriteVersionFile(v1Directory, "1.0.0\\nforged-status"); + var unsafeVersionMetadata = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, unsafeVersionMetadata.ExitCode); + using (var document = JsonDocument.Parse(unsafeVersionMetadata.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("available_unverified", executable.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Null, executable.GetProperty("actual_version").ValueKind); + } + + if (!OperatingSystem.IsWindows()) + { + var versionPath = Path.Combine(v1Directory, "version.json"); + File.Delete(versionPath); + Assert.Equal(0, CreateFifo(versionPath, 0x180)); + var specialVersionMetadata = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, specialVersionMetadata.ExitCode); + using (var document = JsonDocument.Parse(specialVersionMetadata.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("available_unverified", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_executable_version_unavailable", + executable.GetProperty("failure_reason").GetString()); + } + File.Delete(versionPath); + } + + File.Delete(v1Path); + var missing = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + using (var document = JsonDocument.Parse(missing.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("missing", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_executable_missing", + executable.GetProperty("failure_reason").GetString()); + Assert.Equal(JsonValueKind.Null, executable.GetProperty("actual_version").ValueKind); + } + } + finally + { + HookCommandRunner.ExecutableSelectionForTesting = null; + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(toolRoot); + } + } + + [Fact] + public void ExecutableSelectionComparison_FollowsFilesystemCaseSensitivity_Issue4892() + { + lock (PathCasingTestLock.Gate) + { + var previousProbe = PathCasing.IgnoreCaseProbeForTesting; + var upperPath = Path.Combine(Path.GetTempPath(), "CdidxCase", "Tool.exe"); + var lowerPath = Path.Combine(Path.GetTempPath(), "cdidxcase", "tool.exe"); + var upperSelection = new HookExecutableSelection("process_path", "1.0.0", [upperPath]); + var lowerSelection = new HookExecutableSelection("process_path", "2.0.0", [lowerPath]); + try + { + PathCasing.ResetCacheForTests(); + PathCasing.IgnoreCaseProbeForTesting = _ => false; + Assert.False(HookCommandRunner.ExecutableSelectionsMatch( + upperSelection, + lowerSelection)); + + PathCasing.ResetCacheForTests(); + PathCasing.IgnoreCaseProbeForTesting = _ => true; + Assert.True(HookCommandRunner.ExecutableSelectionsMatch( + upperSelection, + lowerSelection)); + } + finally + { + PathCasing.IgnoreCaseProbeForTesting = previousProbe; + PathCasing.ResetCacheForTests(); + } + } + } + + [Fact] + public void RepositoryPathComparison_NormalizesTrailingSeparators_Issue4892() + { + var parent = TestProjectHelper.CreateTempProject("hook_repository_path_normalization"); + try + { + var missingRepositoryPath = Path.Combine(parent, "missing-repository"); + + Assert.True(HookCommandRunner.RepositoryPathsEqual( + missingRepositoryPath + Path.DirectorySeparatorChar, + missingRepositoryPath)); + } + finally + { + TestProjectHelper.DeleteDirectory(parent); + } + } + + [Fact] + public void Hooks_Status_ValidatesManagedAssemblyAndRuntimeFiles_Issue4892() + { + var projectRoot = TestProjectHelper.CreateTempProject("hook_managed_status"); + var toolRoot = TestProjectHelper.CreateTempProject("hook_managed_tool"); + var hostPath = Path.Combine(toolRoot, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + var assemblyPath = Path.Combine(toolRoot, "cdidx.dll"); + var runtimeConfigPath = Path.ChangeExtension(assemblyPath, ".runtimeconfig.json"); + var depsPath = Path.ChangeExtension(assemblyPath, ".deps.json"); + try + { + WriteRunnableFile(hostPath); + File.WriteAllText(assemblyPath, "managed fixture"); + File.WriteAllText(runtimeConfigPath, "{}"); + File.WriteAllText(depsPath, "{}"); + WriteVersionFile(toolRoot, "1.0.0"); + TestProjectHelper.InitializeGitRepo(projectRoot); + HookCommandRunner.ExecutableSelectionForTesting = _ => + new HookExecutableSelection( + "dotnet_host_and_assembly", + "1.0.0", + [hostPath, assemblyPath]); + Assert.Equal( + CommandExitCodes.Success, + RunHooksAndCaptureStreams(["install", "--project", projectRoot]).ExitCode); + + var available = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, available.ExitCode); + using (var document = JsonDocument.Parse(available.StdOut)) + Assert.Equal( + "available", + document.RootElement + .GetProperty("executable") + .GetProperty("status") + .GetString()); + + File.Delete(runtimeConfigPath); + var missingRuntime = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, missingRuntime.ExitCode); + using (var document = JsonDocument.Parse(missingRuntime.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("missing", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_runtime_file_missing", + executable.GetProperty("failure_reason").GetString()); + } + + File.WriteAllText(runtimeConfigPath, "{}"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(assemblyPath, UnixFileMode.None); + var unreadableAssembly = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, unreadableAssembly.ExitCode); + using var document = JsonDocument.Parse(unreadableAssembly.StdOut); + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("not_executable", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_entry_assembly_unreadable", + executable.GetProperty("failure_reason").GetString()); + } + } + finally + { + HookCommandRunner.ExecutableSelectionForTesting = null; + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(toolRoot); + } + } + + [Fact] + public void Hooks_Status_ValidatesFrameworkDependentAppHostFiles_Issue4892() + { + var projectRoot = TestProjectHelper.CreateTempProject("hook_apphost_status"); + var toolRoot = TestProjectHelper.CreateTempProject("hook_apphost_tool"); + var appHostPath = Path.Combine( + toolRoot, + OperatingSystem.IsWindows() ? "cdidx.exe" : "cdidx"); + var assemblyPath = Path.Combine(toolRoot, "cdidx.dll"); + var runtimeConfigPath = Path.ChangeExtension(assemblyPath, ".runtimeconfig.json"); + var depsPath = Path.ChangeExtension(assemblyPath, ".deps.json"); + try + { + WriteRunnableFile(appHostPath); + File.WriteAllText(assemblyPath, "managed fixture"); + File.WriteAllText(runtimeConfigPath, "{}"); + File.WriteAllText(depsPath, "{}"); + WriteVersionFile(toolRoot, "1.0.0"); + TestProjectHelper.InitializeGitRepo(projectRoot); + Assert.True( + HookCommandRunner.TryCreateExecutableSelection( + appHostPath, + assemblyPath, + "1.0.0", + out var appHostSelection, + out var selectionFailure), + selectionFailure); + Assert.Equal("process_path", appHostSelection.Source); + Assert.Equal(Path.GetFullPath(appHostPath), Assert.Single(appHostSelection.Argv)); + Assert.Equal(Path.GetFullPath(assemblyPath), appHostSelection.EntryAssemblyPath); + HookCommandRunner.ExecutableSelectionForTesting = _ => appHostSelection; + + var install = RunHooksAndCaptureStreams( + ["install", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, install.ExitCode); + using (var document = JsonDocument.Parse(install.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal( + assemblyPath, + executable.GetProperty("entry_assembly_path").GetString()); + Assert.Single(executable.GetProperty("argv").EnumerateArray()); + } + var hookPath = Path.Combine(projectRoot, ".git", "hooks", "pre-commit"); + Assert.Contains( + $"{QuoteShellForTest(NormalizeExpectedShellPath(appHostPath))} index ", + File.ReadAllText(hookPath), + StringComparison.Ordinal); + + var upgradedToolRoot = Path.Combine(toolRoot, "upgraded"); + var upgradedAssemblyPath = Path.Combine(upgradedToolRoot, "cdidx.dll"); + Directory.CreateDirectory(upgradedToolRoot); + File.WriteAllText(upgradedAssemblyPath, "upgraded managed fixture"); + File.WriteAllText( + Path.ChangeExtension(upgradedAssemblyPath, ".runtimeconfig.json"), + "{}"); + File.WriteAllText( + Path.ChangeExtension(upgradedAssemblyPath, ".deps.json"), + "{}"); + WriteVersionFile(upgradedToolRoot, "2.0.0"); + var upgradedSelection = new HookExecutableSelection( + "process_path", + "2.0.0", + [Path.GetFullPath(appHostPath)], + Path.GetFullPath(upgradedAssemblyPath)); + HookCommandRunner.ExecutableSelectionForTesting = _ => upgradedSelection; + File.Delete(assemblyPath); + var upgraded = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, upgraded.ExitCode); + using (var document = JsonDocument.Parse(upgraded.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal( + upgradedAssemblyPath, + executable.GetProperty("entry_assembly_path").GetString()); + Assert.Equal("1.0.0", executable.GetProperty("expected_version").GetString()); + Assert.Equal("2.0.0", executable.GetProperty("actual_version").GetString()); + Assert.Equal("version_mismatch", executable.GetProperty("status").GetString()); + } + + HookCommandRunner.ExecutableSelectionForTesting = _ => appHostSelection; + var missingAssembly = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, missingAssembly.ExitCode); + using (var document = JsonDocument.Parse(missingAssembly.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("missing", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_entry_assembly_missing", + executable.GetProperty("failure_reason").GetString()); + } + + File.WriteAllText(assemblyPath, "managed fixture"); + File.Delete(runtimeConfigPath); + var missingRuntime = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, missingRuntime.ExitCode); + using (var document = JsonDocument.Parse(missingRuntime.StdOut)) + { + var executable = document.RootElement.GetProperty("executable"); + Assert.Equal("missing", executable.GetProperty("status").GetString()); + Assert.Equal( + "pinned_runtime_file_missing", + executable.GetProperty("failure_reason").GetString()); + } + } + finally + { + HookCommandRunner.ExecutableSelectionForTesting = null; + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(toolRoot); + } + } + + [Fact] + public void Hooks_ExecutableSelection_ResolvesSymlinksAndDotnetOrWrapperShapes_Issue4892() + { + var root = TestProjectHelper.CreateTempProject("hook_executable_shapes"); + try + { + var targetPath = Path.Combine(root, "real cdidx"); + var linkPath = Path.Combine(root, "linked cdidx"); + WriteRunnableFile(targetPath); + if (!OperatingSystem.IsWindows()) + { + File.CreateSymbolicLink(linkPath, targetPath); + Assert.True(HookCommandRunner.TryCreateExecutableSelection( + linkPath, + null, + "1.0.0", + out var linkedSelection, + out var linkFailure), + linkFailure); + Assert.Equal(Path.GetFullPath(targetPath), Assert.Single(linkedSelection.Argv)); + } + + var wrapperPath = Path.Combine(root, "cdidx wrapper.cmd"); + WriteRunnableFile(wrapperPath); + Assert.True(HookCommandRunner.TryCreateExecutableSelection( + wrapperPath, + null, + "1.0.0", + out var wrapperSelection, + out var wrapperFailure), + wrapperFailure); + Assert.Equal("process_path", wrapperSelection.Source); + Assert.Equal(Path.GetFullPath(wrapperPath), Assert.Single(wrapperSelection.Argv)); + + var dotnetPath = Path.Combine(root, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"); + var resolvedDotnetPath = dotnetPath; + var assemblyPath = Path.Combine(root, "tool version", "cdidx.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(assemblyPath)!); + if (OperatingSystem.IsWindows()) + { + WriteRunnableFile(dotnetPath); + } + else + { + resolvedDotnetPath = Path.Combine(root, "dotnet8"); + WriteRunnableFile(resolvedDotnetPath); + File.CreateSymbolicLink(dotnetPath, resolvedDotnetPath); + } + File.WriteAllText(assemblyPath, "fixture"); + File.WriteAllText(Path.ChangeExtension(assemblyPath, ".runtimeconfig.json"), "{}"); + File.WriteAllText(Path.ChangeExtension(assemblyPath, ".deps.json"), "{}"); + Assert.True(HookCommandRunner.TryCreateExecutableSelection( + dotnetPath, + assemblyPath, + "1.0.0", + out var dotnetSelection, + out var dotnetFailure), + dotnetFailure); + Assert.Equal("dotnet_host_and_assembly", dotnetSelection.Source); + Assert.Equal( + [ + Path.GetFullPath(resolvedDotnetPath), + Path.GetFullPath(assemblyPath), + ], + dotnetSelection.Argv); + + Assert.True(HookCommandRunner.IsCanonicalFullyQualifiedPath(wrapperPath)); + Assert.False(HookCommandRunner.IsCanonicalFullyQualifiedPath("relative/cdidx")); + Assert.False(HookCommandRunner.IsCanonicalFullyQualifiedPath( + Path.Combine(root, "nested", "..", "cdidx"))); + if (OperatingSystem.IsWindows()) + { + Assert.False(HookCommandRunner.IsCanonicalFullyQualifiedPath(@"C:tools\cdidx.exe")); + Assert.False(HookCommandRunner.IsCanonicalFullyQualifiedPath(@"\tools\cdidx.exe")); + } + + Assert.True(HookCommandRunner.TryBuildHookScript( + Path.Combine(root, "pre-commit.cdidx-chain"), + root, + wrapperSelection, + out var boundedHook)); + Assert.True( + Encoding.UTF8.GetByteCount(boundedHook) + <= HookCommandRunner.MaxHookMarkerBytes); + Assert.False(HookCommandRunner.TryBuildHookScript( + "/" + new string('x', HookCommandRunner.MaxHookMarkerBytes), + root, + wrapperSelection, + out var oversizedHook)); + Assert.Equal(string.Empty, oversizedHook); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + + [Theory] + [InlineData("# BEGIN CDIDX MANAGED PRE-COMMIT")] + [InlineData("# END CDIDX MANAGED PRE-COMMIT")] + public void Hooks_ManagedMarkersInsideExecutablePath_DoNotConflict_Issue4892( + string marker) + { + var parent = TestProjectHelper.CreateTempProject("hook_executable_marker"); + var projectRoot = Path.Combine(parent, "repo"); + var toolDirectory = Path.Combine(parent, $"tool-{marker}"); + var toolPath = Path.Combine( + toolDirectory, + OperatingSystem.IsWindows() ? "cdidx.cmd" : "cdidx"); + try + { + Directory.CreateDirectory(projectRoot); + Directory.CreateDirectory(toolDirectory); + WriteRunnableFile(toolPath); + WriteVersionFile(toolDirectory, "1.0.0"); + HookCommandRunner.ExecutableSelectionForTesting = _ => + new HookExecutableSelection("process_path", "1.0.0", [toolPath]); + TestProjectHelper.InitializeGitRepo(projectRoot); + + var install = RunHooksAndCaptureStreams( + ["install", "--project", projectRoot, "--json"]); + var status = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + var reinstall = RunHooksAndCaptureStreams( + ["install", "--project", projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, install.ExitCode); + Assert.Equal(CommandExitCodes.Success, status.ExitCode); + using (var document = JsonDocument.Parse(status.StdOut)) + { + Assert.Equal("installed", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("managed", document.RootElement.GetProperty("hook_state").GetString()); + } + Assert.Equal(CommandExitCodes.Success, reinstall.ExitCode); + using (var document = JsonDocument.Parse(reinstall.StdOut)) + Assert.Equal( + "already_installed", + document.RootElement.GetProperty("status").GetString()); + Assert.False(File.Exists(Path.Combine( + projectRoot, + ".git", + "hooks", + "pre-commit.cdidx-chain"))); + } + finally + { + HookCommandRunner.ExecutableSelectionForTesting = null; TestProjectHelper.DeleteDirectory(parent); } } @@ -762,6 +1590,19 @@ public void Hooks_StatusJson_UsesSourceGeneratedSerializer() var diagnosticProjectPath = document.RootElement.GetProperty("diagnostic_project_path").GetString(); Assert.Contains("hook_status_json", diagnosticProjectPath, StringComparison.Ordinal); Assert.DoesNotContain(projectRoot, diagnosticProjectPath, StringComparison.Ordinal); + + var hookPath = Path.Combine(projectRoot, ".git", "hooks", "pre-commit"); + File.WriteAllText( + hookPath, + "#!/bin/sh\n# BEGIN CDIDX MANAGED PRE-COMMIT\ncdidx index . --quiet\n# END CDIDX MANAGED PRE-COMMIT\n"); + var legacyStatus = RunHooksAndCaptureStreams( + ["status", "--project", projectRoot, "--json"]); + using var legacyDocument = JsonDocument.Parse(legacyStatus.StdOut); + var executable = legacyDocument.RootElement.GetProperty("executable"); + Assert.Equal("unresolved", executable.GetProperty("status").GetString()); + Assert.Equal( + "managed_hook_missing_executable_manifest", + executable.GetProperty("failure_reason").GetString()); } finally { @@ -1230,6 +2071,69 @@ private static void AssertPrivateFileMode(string path) Assert.Equal(DataDirectorySecurity.PrivateFileMode, mode); } + private static string BuildExpectedPinnedInvocation() + { + Assert.True(HookCommandRunner.TryCreateExecutableSelection( + Environment.ProcessPath, + typeof(HookCommandRunner).Assembly.Location, + ConsoleUi.LoadVersion(), + out var selection, + out var failureReason), + failureReason); + return string.Join( + ' ', + selection.Argv.Select(static argument => + QuoteShellForTest(NormalizeExpectedShellPath(argument)))); + } + + private static void WriteRunnableFile(string path) + { + File.WriteAllText(path, OperatingSystem.IsWindows() ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + private static void WriteVersionFile(string directory, string version) + => File.WriteAllText( + Path.Combine(directory, "version.json"), + $$"""{"version":"{{version}}"}"""); + + [DllImport("libc", EntryPoint = "mkfifo", SetLastError = true)] + private static extern int CreateFifo(string path, uint mode); + + private static string EncodeExecutableManifestForTest( + string source, + string version, + IReadOnlyList arguments) + { + using var buffer = new MemoryStream(); + using (var writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen: true)) + { + writer.Write(1); + WriteManifestStringForTest(writer, source); + WriteManifestStringForTest(writer, version); + writer.Write(arguments.Count); + foreach (var argument in arguments) + WriteManifestStringForTest(writer, argument); + } + + return Convert.ToBase64String(buffer.ToArray()); + } + + private static void WriteManifestStringForTest(BinaryWriter writer, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + writer.Write(bytes.Length); + writer.Write(bytes); + } + + private static string NormalizeExpectedShellPath(string path) + => OperatingSystem.IsWindows() ? path.Replace('\\', '/') : path; + private static string QuoteShellForTest(string value) => "'" + value.Replace("'", "'\"'\"'", StringComparison.Ordinal) + "'"; }