diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index bfaf0096cd..e4e2a1faeb 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -94,6 +94,22 @@ or publishing NuGet artifacts: dotnet run --project tools/CodeIndex.PackageNormalize -- nupkg/*.nupkg nupkg/*.snupkg ``` +For release diagnostics, inspect without rewriting and request a bounded +summary across all candidate packages: + +```bash +dotnet run --project tools/CodeIndex.PackageNormalize -- --dry-run --summary nupkg/*.nupkg nupkg/*.snupkg +dotnet run --project tools/CodeIndex.PackageNormalize -- --dry-run --json --continue-on-error nupkg/*.nupkg nupkg/*.snupkg +``` + +`install.sh` is generated from focused fragments under `install_modules/`. +After editing installer, doctor, self-test, reinstall, uninstall, or dispatch +logic, regenerate the checked-in one-file installer before testing: + +```bash +bash tools/build-install-sh.sh +``` + | Normalizer rule | Detail | |---|---| | Reproducible OPC metadata (#2756) | NuGet's OPC package writer generates a random `package/services/metadata/core-properties/*.psmdcp` part name on each pack run. The normalizer rewrites that part to `package/services/metadata/core-properties/core-properties.psmdcp`, updates the matching content-type and relationship references, and gives ZIP entries stable timestamps. This is the package reproducibility boundary for `.nupkg` and `.snupkg` archives. | @@ -352,9 +368,13 @@ On startup, `cdidx` walks up from the current directory looking for `.cdidx-vers `cdidx upgrade --json` has a stdout contract suitable for automation. Check-only and no-update results use the update-check fields (`current_version`, `latest_version`, `update_available`, `from_cache`, -`error`). When an update is installed, installer stdout/stderr is captured so -stdout remains one JSON document, with `install_attempted`, `install_exit_code`, -and `install_succeeded` added to the update-check fields. +`error`) plus release-selection fields (`selected_version`, +`selected_channel`, `selection_source`, `include_prerelease`). When an update is +installed, installer stdout/stderr is captured so stdout remains one JSON +document, with `install_attempted`, `install_exit_code`, and +`install_succeeded` added to the update-check fields. Windows handoff responses +also include `handoff_command`, `handoff_url`, `handoff_asset`, and +`handoff_asset_url`. ### Degradation reason codes @@ -2221,6 +2241,22 @@ package normalization を実行します: dotnet run --project tools/CodeIndex.PackageNormalize -- nupkg/*.nupkg nupkg/*.snupkg ``` +release diagnostics では、書き換えずに検査し、candidate package 全体の bounded +summary を取得できます: + +```bash +dotnet run --project tools/CodeIndex.PackageNormalize -- --dry-run --summary nupkg/*.nupkg nupkg/*.snupkg +dotnet run --project tools/CodeIndex.PackageNormalize -- --dry-run --json --continue-on-error nupkg/*.nupkg nupkg/*.snupkg +``` + +`install.sh` は `install_modules/` 配下の focused fragment から生成されます。 +installer、doctor、self-test、reinstall、uninstall、dispatch logic を変更した場合は、 +テスト前に checked-in の単一ファイル installer を再生成してください: + +```bash +bash tools/build-install-sh.sh +``` + | normalizer rule | 詳細 | |---|---| | 再現可能な OPC metadata (#2756) | NuGet の OPC package writer は `package/services/metadata/core-properties/*.psmdcp` part 名を pack ごとにランダム生成します。normalizer はその part を `package/services/metadata/core-properties/core-properties.psmdcp` に書き換え、対応する content-type / relationship 参照も更新し、ZIP entry timestamp を固定します。これが `.nupkg` / `.snupkg` archive の package 再現性境界です。 | @@ -2531,10 +2567,13 @@ latest release の installer を実行します。 `cdidx upgrade --json` は automation 向けの stdout contract を持ちます。check-only と no-update の結果は update-check fields (`current_version`, `latest_version`, -`update_available`, `from_cache`, `error`) を使います。update を install する場合、 -installer stdout/stderr は capture されるため stdout は 1 個の JSON document のままになり、 -update-check fields に `install_attempted`、`install_exit_code`、`install_succeeded` が -追加されます。 +`update_available`, `from_cache`, `error`) に release-selection fields +(`selected_version`, `selected_channel`, `selection_source`, `include_prerelease`) +を加えたものを使います。update を install する場合、installer stdout/stderr は +capture されるため stdout は 1 個の JSON document のままになり、update-check fields に +`install_attempted`、`install_exit_code`、`install_succeeded` が追加されます。Windows +handoff response には `handoff_command`、`handoff_url`、`handoff_asset`、 +`handoff_asset_url` も含まれます。 ### 劣化理由コード diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2cda96d0d5..d93f4bc4dd 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -580,6 +580,22 @@ automatically when the `gh` command is available and the public GitHub release host is used. Set `CDIDX_REQUIRE_ATTESTATION=1` to make the installer fail closed when provenance verification cannot be completed. +Installer verification policy is explicit: + +- `CDIDX_VERIFY_POLICY=compat` is the default. The installer always enforces + archive checksums, runs GitHub attestation and GPG checksum-signature checks + when the required tools/configuration are available, and warns before + continuing when an optional second-channel check cannot run. +- `CDIDX_VERIFY_POLICY=strict` or `--verify-policy strict` makes public GitHub + attestation and GPG checksum-signature verification fail closed. Strict mode + also requires signer fingerprint pinning through + `CDIDX_RELEASE_GPG_FINGERPRINT`. +- `CDIDX_REQUIRE_ATTESTATION=1` and `CDIDX_STRICT_VERIFY=1` remain available as + narrower compatibility knobs when only one second-channel check should be + required. Until an official default release-signing fingerprint is bundled, + strict GPG verification requires operators to distribute the trusted + fingerprint through `CDIDX_RELEASE_GPG_FINGERPRINT`. + ### Option A: One-liner install (no .NET required) Works in containers, CI, and any Linux/macOS environment — no .NET SDK needed. @@ -652,6 +668,31 @@ bash ./install.sh --self-test-local-mirror If the default local self-test port is busy, set `CDIDX_LOCAL_MIRROR_PORT=18766`. +#### Upgrade an install.sh installation + +`cdidx upgrade` checks GitHub releases and reruns the verified installer for +the selected release. It defaults to the stable/latest release channel. + +```bash +cdidx upgrade +cdidx upgrade --check-only --json +cdidx upgrade --prerelease +cdidx upgrade --channel prerelease +cdidx upgrade --version v1.29.0-rc.1 +``` + +Use `--channel stable` (or `--channel latest`) to stay on stable releases, +`--prerelease` / `--channel prerelease` to dogfood the newest prerelease, and +`--version ` to install a specific release tag. JSON output includes +`selected_version`, `selected_channel`, `selection_source`, and +`include_prerelease` so automation can record why a release was selected. + +On Windows, `cdidx upgrade` selects the same release but does not replace the +running binary in place. It prints a NuGet handoff command such as +`dotnet tool update -g cdidx --version ` plus the matching release +page and `CodeIndex-win-*.zip` asset URL; JSON output carries those values in +`handoff_command`, `handoff_url`, `handoff_asset`, and `handoff_asset_url`. + ### Option B: NuGet Global Tool Requires the [.NET 8.x SDK](https://dotnet.microsoft.com/download/dotnet/8.0) @@ -2934,6 +2975,22 @@ installer はこの provenance verification を自動実行します。 `CDIDX_REQUIRE_ATTESTATION=1` を設定すると、provenance verification を完了 できない場合に installer は fail closed します。 +installer の verification policy は明示的です: + +- 既定は `CDIDX_VERIFY_POLICY=compat` です。installer は archive checksum を常に + 検証し、必要な tool/configuration がある場合は GitHub attestation と GPG + checksum-signature verification を実行します。任意の second-channel check を + 実行できない場合は警告して続行します。 +- `CDIDX_VERIFY_POLICY=strict` または `--verify-policy strict` は、public GitHub + attestation と GPG checksum-signature verification を fail closed にします。 + strict mode では `CDIDX_RELEASE_GPG_FINGERPRINT` による signer fingerprint + pinning も必須です。 +- `CDIDX_REQUIRE_ATTESTATION=1` と `CDIDX_STRICT_VERIFY=1` は、片方の + second-channel check だけを必須化したい場合の互換 knob として残っています。 + 公式の default release-signing fingerprint が bundled されるまでは、strict GPG + verification を使う operator が信頼する fingerprint を + `CDIDX_RELEASE_GPG_FINGERPRINT` 経由で配布してください。 + GitHub attestation は、その artifact が repository workflow identity により 生成されたことを検証します。 @@ -3009,6 +3066,32 @@ bash ./install.sh --self-test-local-mirror 既定の local self-test port が埋まっている場合は `CDIDX_LOCAL_MIRROR_PORT=18766` を設定してください。 +#### install.sh で入れた cdidx のアップグレード + +`cdidx upgrade` は GitHub releases を確認し、選択した release に対して検証済み +installer を再実行します。既定では stable/latest release channel を使います。 + +```bash +cdidx upgrade +cdidx upgrade --check-only --json +cdidx upgrade --prerelease +cdidx upgrade --channel prerelease +cdidx upgrade --version v1.29.0-rc.1 +``` + +stable release に留まる場合は `--channel stable`(または `--channel latest`)、 +最新 prerelease を試す場合は `--prerelease` / `--channel prerelease`、特定 +release tag を入れる場合は `--version ` を使います。JSON 出力には +`selected_version`、`selected_channel`、`selection_source`、 +`include_prerelease` が含まれるため、automation 側で選択理由を記録できます。 + +Windows では `cdidx upgrade` は同じ release を選択しますが、実行中 binary を +その場では置き換えません。代わりに +`dotnet tool update -g cdidx --version ` 形式の NuGet handoff command +と、対応する release page / `CodeIndex-win-*.zip` asset URL を表示します。JSON 出力では +`handoff_command`、`handoff_url`、`handoff_asset`、`handoff_asset_url` に +同じ値が入ります。 + ### 方法B: NuGet グローバルツール `dotnet tool install` / `dotnet tool update` には diff --git a/changelog.d/unreleased/3374.added.md b/changelog.d/unreleased/3374.added.md new file mode 100644 index 0000000000..03493f14e1 --- /dev/null +++ b/changelog.d/unreleased/3374.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 3374 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - USER_GUIDE.md +--- + +## English + +- **Windows upgrade now reports a first-class handoff path (#3374)** — when `cdidx upgrade` runs on Windows, it returns a NuGet update command plus release page and matching `CodeIndex-win-*.zip` asset details in human and JSON output instead of only reporting an unsupported platform. + +## 日本語 + +- **Windows upgrade が first-class handoff path を返すようになりました (#3374)** — Windows で `cdidx upgrade` を実行した場合、単なる unsupported platform ではなく、NuGet update command と release page、対応する `CodeIndex-win-*.zip` asset 情報を human / JSON 出力で返します。 diff --git a/changelog.d/unreleased/3375.added.md b/changelog.d/unreleased/3375.added.md new file mode 100644 index 0000000000..7604ec79c3 --- /dev/null +++ b/changelog.d/unreleased/3375.added.md @@ -0,0 +1,24 @@ +--- +category: added +issues: + - 3375 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/UpdateChecker.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/CliFlagSchemaTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - DEVELOPER_GUIDE.md + - USER_GUIDE.md +--- + +## English + +- **Upgrade can now select stable, prerelease, or explicit release tags (#3375)** — `cdidx upgrade` accepts `--channel stable|prerelease`, `--prerelease`, and `--version `, and JSON output now reports the selected version, channel, selection source, and prerelease inclusion. + +## 日本語 + +- **upgrade で stable、prerelease、明示 release tag を選択できるようにしました (#3375)** — `cdidx upgrade` は `--channel stable|prerelease`、`--prerelease`、`--version ` を受け付け、JSON 出力で選択 version、channel、selection source、prerelease inclusion を返します。 diff --git a/changelog.d/unreleased/3498.internal.md b/changelog.d/unreleased/3498.internal.md new file mode 100644 index 0000000000..106c13ca32 --- /dev/null +++ b/changelog.d/unreleased/3498.internal.md @@ -0,0 +1,19 @@ +--- +category: internal +issues: + - 3498 +affected: + - install.sh + - install_modules/ + - tools/build-install-sh.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Split the one-file installer source into focused generated fragments (#3498)** — `install.sh` is now generated from dedicated installer, path guidance, uninstall, self-test, reinstall, doctor, and dispatch fragments, with tests that keep the generated one-liner synchronized. + +## 日本語 + +- **単一ファイル installer の source を focused fragment に分割しました (#3498)** — `install.sh` は installer、PATH guidance、uninstall、self-test、reinstall、doctor、dispatch 用の fragment から生成されるようになり、生成された one-liner との同期をテストで固定します。 diff --git a/changelog.d/unreleased/3502.security.md b/changelog.d/unreleased/3502.security.md new file mode 100644 index 0000000000..6bb71b0168 --- /dev/null +++ b/changelog.d/unreleased/3502.security.md @@ -0,0 +1,19 @@ +--- +category: security +issues: + - 3502 +affected: + - install.sh + - install_modules/00-core-and-verification.sh + - install_modules/90-dispatch.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs + - USER_GUIDE.md +--- + +## English + +- **Installer verification policy is now explicit (#3502)** — `CDIDX_VERIFY_POLICY=compat` remains the default warning-compatible mode, while `CDIDX_VERIFY_POLICY=strict` or `--verify-policy strict` fail closed on missing GitHub attestation, GPG signature verification, or signer fingerprint pinning. + +## 日本語 + +- **installer の verification policy を明示しました (#3502)** — 既定は警告互換の `CDIDX_VERIFY_POLICY=compat` のまま、`CDIDX_VERIFY_POLICY=strict` または `--verify-policy strict` では GitHub attestation、GPG signature verification、signer fingerprint pinning が欠けた場合に fail closed します。 diff --git a/changelog.d/unreleased/3553.added.md b/changelog.d/unreleased/3553.added.md new file mode 100644 index 0000000000..d60fbec015 --- /dev/null +++ b/changelog.d/unreleased/3553.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 3553 +affected: + - tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs + - tests/CodeIndex.Tests/ReleaseWorkflowTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **PackageNormalize now supports release diagnostics without rewriting packages (#3553)** — `--dry-run` / `--check`, `--summary`, `--json`, and `--continue-on-error` report inspected, normalized, unchanged, failed, and skipped package counts while keeping positional-only normalization compatible. + +## 日本語 + +- **PackageNormalize が package を書き換えない release diagnostics に対応しました (#3553)** — `--dry-run` / `--check`、`--summary`、`--json`、`--continue-on-error` により inspected、normalized、unchanged、failed、skipped の package 件数を返しつつ、従来の positional-only normalize 挙動は維持します。 diff --git a/install.sh b/install.sh index 4154c96fa7..9d17caa267 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,7 @@ # bash ./install.sh --reinstall-real vX.Y.Z # bash ./install.sh --doctor [vX.Y.Z] # bash ./install.sh --uninstall [--purge-cache] +# bash ./install.sh --verify-policy strict vX.Y.Z # HTTPS_PROXY=http://proxy.example:8080 bash ./install.sh --doctor # CDIDX_GITHUB_BASE_URL=https://github.example.internal \ # CDIDX_GITHUB_API_BASE_URL=https://github.example.internal/api/v3 \ @@ -18,6 +19,7 @@ # Optional env vars / 任意環境変数: # CDIDX_GITHUB_BASE_URL Release download base URL override # CDIDX_GITHUB_API_BASE_URL API base URL override for latest-release lookup +# CDIDX_VERIFY_POLICY=compat|strict Verification policy (default: compat) # CDIDX_REQUIRE_ATTESTATION=1 Require GitHub provenance verification via gh # CDIDX_STRICT_VERIFY=1 Require GPG checksum-manifest signature verification # CDIDX_RELEASE_GPG_FINGERPRINT Expected checksum signer fingerprint @@ -82,9 +84,11 @@ GITHUB_BASE_URL="${CDIDX_GITHUB_BASE_URL:-https://github.com}" GITHUB_API_BASE_URL="${CDIDX_GITHUB_API_BASE_URL:-https://api.github.com}" CURL_STDERR_SAMPLE_BYTES=8192 LATEST_RELEASE_RESPONSE_MAX_BYTES=65536 +VERIFY_POLICY="${CDIDX_VERIFY_POLICY:-compat}" REQUIRE_ATTESTATION="${CDIDX_REQUIRE_ATTESTATION:-0}" STRICT_VERIFY="${CDIDX_STRICT_VERIFY:-0}" -RELEASE_GPG_FINGERPRINT="${CDIDX_RELEASE_GPG_FINGERPRINT:-}" +DEFAULT_RELEASE_GPG_FINGERPRINT="" +RELEASE_GPG_FINGERPRINT="${CDIDX_RELEASE_GPG_FINGERPRINT:-$DEFAULT_RELEASE_GPG_FINGERPRINT}" # Normalize optional base URL overrides by removing a trailing slash. # 末尾スラッシュ付きでも URL 連結が壊れないようにする。 GITHUB_BASE_URL="${GITHUB_BASE_URL%/}" @@ -195,6 +199,21 @@ has_cmd() { command -v "$1" > /dev/null 2>&1 } +apply_verification_policy() { + case "$VERIFY_POLICY" in + ""|compat) + VERIFY_POLICY="compat" + ;; + strict) + REQUIRE_ATTESTATION=1 + STRICT_VERIFY=1 + ;; + *) + error "CDIDX_VERIFY_POLICY must be 'compat' or 'strict' (got '${VERIFY_POLICY}')." + ;; + esac +} + release_attestation_supported() { if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" = "1" ] && [ "${CDIDX_TEST_ENABLE_ATTESTATION:-0}" != "1" ]; then return 1 @@ -209,16 +228,16 @@ verify_release_attestation() { if ! release_attestation_supported; then if [ "$REQUIRE_ATTESTATION" = "1" ]; then - error "GitHub provenance attestation verification is required, but the release host is not github.com. Unset CDIDX_REQUIRE_ATTESTATION or install from the public GitHub release." + error "GitHub provenance attestation verification is required, but the release host is not github.com. Set CDIDX_VERIFY_POLICY=compat or unset CDIDX_REQUIRE_ATTESTATION, or install from the public GitHub release." fi return 0 fi if ! has_cmd gh; then if [ "$REQUIRE_ATTESTATION" = "1" ]; then - error "GitHub provenance attestation verification is required, but the 'gh' command was not found. Install GitHub CLI or unset CDIDX_REQUIRE_ATTESTATION." + error "GitHub provenance attestation verification is required, but the 'gh' command was not found. Install GitHub CLI, set CDIDX_VERIFY_POLICY=compat, or unset CDIDX_REQUIRE_ATTESTATION." fi - warn "Skipping GitHub provenance attestation for ${artifact_name}: 'gh' command not found. Set CDIDX_REQUIRE_ATTESTATION=1 to require this verification." + warn "Skipping GitHub provenance attestation for ${artifact_name}: 'gh' command not found. Set CDIDX_VERIFY_POLICY=strict or CDIDX_REQUIRE_ATTESTATION=1 to require this verification." return 0 fi @@ -231,7 +250,7 @@ verify_release_attestation() { error "GitHub provenance attestation verification failed for ${artifact_name}." fi - warn "GitHub provenance attestation verification failed for ${artifact_name}; continuing with checksum verification. Set CDIDX_REQUIRE_ATTESTATION=1 to fail closed." + warn "GitHub provenance attestation verification failed for ${artifact_name}; continuing with checksum verification. Set CDIDX_VERIFY_POLICY=strict or CDIDX_REQUIRE_ATTESTATION=1 to fail closed." } checksum_signature_supported() { @@ -256,9 +275,9 @@ verify_checksum_signature() { if ! has_cmd gpg; then if [ "$STRICT_VERIFY" = "1" ]; then - error "GPG signature verification is required, but the 'gpg' command was not found. Install GnuPG or unset CDIDX_STRICT_VERIFY." + error "GPG signature verification is required, but the 'gpg' command was not found. Install GnuPG, set CDIDX_VERIFY_POLICY=compat, or unset CDIDX_STRICT_VERIFY." fi - warn "Skipping GPG signature verification for sha256sums.txt: 'gpg' command not found. Set CDIDX_STRICT_VERIFY=1 to require this verification." + warn "Skipping GPG signature verification for sha256sums.txt: 'gpg' command not found. Set CDIDX_VERIFY_POLICY=strict or CDIDX_STRICT_VERIFY=1 to require this verification." return 0 fi @@ -269,7 +288,7 @@ verify_checksum_signature() { if [ "$STRICT_VERIFY" = "1" ]; then error "GPG signature verification failed for sha256sums.txt." fi - warn "GPG signature verification failed for sha256sums.txt; continuing with checksum verification. Set CDIDX_STRICT_VERIFY=1 to fail closed." + warn "GPG signature verification failed for sha256sums.txt; continuing with checksum verification. Set CDIDX_VERIFY_POLICY=strict or CDIDX_STRICT_VERIFY=1 to fail closed." return 0 fi @@ -285,9 +304,9 @@ verify_checksum_signature() { if [ -z "$RELEASE_GPG_FINGERPRINT" ]; then if [ "$STRICT_VERIFY" = "1" ]; then - error "GPG signature verification is strict, but CDIDX_RELEASE_GPG_FINGERPRINT is not set." + error "GPG signature verification is strict, but no expected release signer fingerprint is configured. Set CDIDX_RELEASE_GPG_FINGERPRINT to the trusted release signing key fingerprint, or set CDIDX_VERIFY_POLICY=compat." fi - warn "GPG signature verification succeeded for sha256sums.txt, but no expected release signing fingerprint is configured. Set CDIDX_RELEASE_GPG_FINGERPRINT to pin the signer." + warn "GPG signature verification succeeded for sha256sums.txt, but no expected release signing fingerprint is configured. Set CDIDX_RELEASE_GPG_FINGERPRINT to pin the signer, or set CDIDX_VERIFY_POLICY=strict to require it." return 0 fi @@ -2465,11 +2484,32 @@ main() { echo "" } -if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" != "1" ]; then - while [ "${1:-}" = "--strict-verify" ]; do - STRICT_VERIFY=1 - shift +if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" = "1" ]; then + apply_verification_policy +else + while [ $# -gt 0 ]; do + case "${1:-}" in + --strict-verify) + STRICT_VERIFY=1 + shift + ;; + --verify-policy) + if [ $# -lt 2 ]; then + error "--verify-policy requires a value: compat or strict." + fi + VERIFY_POLICY="$2" + shift 2 + ;; + --verify-policy=*) + VERIFY_POLICY="${1#--verify-policy=}" + shift + ;; + *) + break + ;; + esac done + apply_verification_policy case "${1:-}" in --self-test-local-mirror) diff --git a/install_modules/00-core-and-verification.sh b/install_modules/00-core-and-verification.sh new file mode 100644 index 0000000000..1105bfaa72 --- /dev/null +++ b/install_modules/00-core-and-verification.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# install.sh — One-liner installer for cdidx (CodeIndex) +# cdidxワンライナーインストーラー +# +# Usage / 使い方: +# curl -fsSL https://raw.githubusercontent.com/Widthdom/CodeIndex/main/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/Widthdom/CodeIndex/v1.5.0/install.sh | bash -s -- v1.5.0 +# export CDIDX_ALLOW_RISKY_INSTALL_DIR=1 CDIDX_INSTALL_DIR=/usr/local/bin; curl -fsSL ... | bash +# bash ./install.sh --self-test-local-mirror [--self-test-allow-overwrite] [vX.Y.Z] +# bash ./install.sh --reinstall-real vX.Y.Z +# bash ./install.sh --doctor [vX.Y.Z] +# bash ./install.sh --uninstall [--purge-cache] +# bash ./install.sh --verify-policy strict vX.Y.Z +# HTTPS_PROXY=http://proxy.example:8080 bash ./install.sh --doctor +# CDIDX_GITHUB_BASE_URL=https://github.example.internal \ +# CDIDX_GITHUB_API_BASE_URL=https://github.example.internal/api/v3 \ +# bash ./install.sh --doctor vX.Y.Z +# +# Optional env vars / 任意環境変数: +# CDIDX_GITHUB_BASE_URL Release download base URL override +# CDIDX_GITHUB_API_BASE_URL API base URL override for latest-release lookup +# CDIDX_VERIFY_POLICY=compat|strict Verification policy (default: compat) +# CDIDX_REQUIRE_ATTESTATION=1 Require GitHub provenance verification via gh +# CDIDX_STRICT_VERIFY=1 Require GPG checksum-manifest signature verification +# CDIDX_RELEASE_GPG_FINGERPRINT Expected checksum signer fingerprint +# CDIDX_ALLOW_RISKY_INSTALL_DIR=1 Allow root/home/system install targets +# CDIDX_LOCAL_MIRROR_PORT Local self-test HTTP server port (default: 18765) +# HTTPS_PROXY / HTTP_PROXY Proxy used by curl for release and API probes +# NO_PROXY Hosts that should bypass the proxy +# +# Self-test mock payload safety / セルフテスト mock 上書き防止: +# The --self-test-local-mirror path installs a **mock** cdidx that only +# handles --version. To prevent that mock from silently replacing a real +# ~/.local/bin/cdidx when CDIDX_INSTALL_DIR is pre-exported to a +# well-known system/user install path or to a directory that already +# holds a cdidx binary, the self-test aborts unless the caller also +# passes --self-test-allow-overwrite. +# --self-test-local-mirror は --version だけを返す mock cdidx を配置する。 +# CDIDX_INSTALL_DIR が既知のシステム/ユーザー install 先や、既に cdidx を +# 持つディレクトリを指しているときは、--self-test-allow-overwrite を明示 +# しない限り self-test を中断して real install の上書きを防ぐ。 +# +# Real reinstall validation / 実リリースの再インストール検証: +# --reinstall-real vX.Y.Z downloads the real published release (no mock) +# into an **isolated temp dir** — it never touches the user's real install +# — and verifies the binary end-to-end: `cdidx --version` plus a real +# `cdidx . --db ` indexing run against a minimal scratch project. +# Catches regressions that --self-test-local-mirror cannot (symbol +# extraction, SQLite loading, FTS, etc.) because the mock only handles +# --version. CDIDX_INSTALL_DIR is intentionally ignored for this mode. +# --reinstall-real vX.Y.Z は、公開済みリリースを **隔離された temp dir** に +# 実ダウンロードし(ユーザーの実インストールには触らない)、`cdidx --version` +# だけでなく最小スクラッチプロジェクトに対する `cdidx . --db ` 実行まで +# 含めた end-to-end 検証を行う。--self-test-local-mirror の mock は --version +# しか返さないため拾えない、シンボル抽出・SQLite ロード・FTS 等のリグレッション +# を検出できる。このモードでは CDIDX_INSTALL_DIR は意図的に無視する。 +# +# Network diagnostics / ネットワーク診断: +# --doctor [vX.Y.Z] does not install anything. It prints the active proxy +# environment variables and probes the installer's upstream URLs (the +# latest-release API endpoint plus the release tarball and sha256sums asset +# URLs for the requested version — or the version recorded in version.json +# if no version is provided) with `curl -sSI`. Each probe reports its HTTP +# status. On `CONNECT tunnel failed, response 403` (curl exit 56) the doctor +# prints the canonical upstream-proxy guidance so users get a single, +# actionable next step without needing prior network knowledge. Exits 0 when +# every probe returns a 2xx/3xx response, 1 otherwise. +# --doctor [vX.Y.Z] は何もインストールせず、有効な proxy 環境変数と、 +# installer が叩く upstream URL(latest-release API と、指定バージョン +# または version.json 記載バージョンのリリース tarball / sha256sums)を +# `curl -sSI` で probe し、各結果の HTTP status を表示する。 +# `CONNECT tunnel failed, response 403` (curl exit 56) を検知した場合は、 +# upstream proxy / egress policy 側の拒否であり経路差し替えでは解消しない +# という定型ガイダンスを出力し、ユーザーがネットワーク知識なしで次の一手 +# を取れるようにする。全 probe が 2xx/3xx を返したら exit 0、それ以外は 1。 + +set -euo pipefail + +REPO="Widthdom/CodeIndex" +INSTALL_DIR="${CDIDX_INSTALL_DIR-${HOME:-}/.local/bin}" +BINARY_NAME="cdidx" +MANIFEST_REQUIRED_VERSION="1.24.6" +GITHUB_BASE_URL="${CDIDX_GITHUB_BASE_URL:-https://github.com}" +GITHUB_API_BASE_URL="${CDIDX_GITHUB_API_BASE_URL:-https://api.github.com}" +CURL_STDERR_SAMPLE_BYTES=8192 +LATEST_RELEASE_RESPONSE_MAX_BYTES=65536 +VERIFY_POLICY="${CDIDX_VERIFY_POLICY:-compat}" +REQUIRE_ATTESTATION="${CDIDX_REQUIRE_ATTESTATION:-0}" +STRICT_VERIFY="${CDIDX_STRICT_VERIFY:-0}" +DEFAULT_RELEASE_GPG_FINGERPRINT="" +RELEASE_GPG_FINGERPRINT="${CDIDX_RELEASE_GPG_FINGERPRINT:-$DEFAULT_RELEASE_GPG_FINGERPRINT}" +# Normalize optional base URL overrides by removing a trailing slash. +# 末尾スラッシュ付きでも URL 連結が壊れないようにする。 +GITHUB_BASE_URL="${GITHUB_BASE_URL%/}" +GITHUB_API_BASE_URL="${GITHUB_API_BASE_URL%/}" +TMPDIR_CLEANUP="" +STAGE_DIR_CLEANUP="" +BACKUP_DIR_CLEANUP="" +LOCAL_MIRROR_DIR_CLEANUP="" +LOCAL_MIRROR_PID="" +SELF_TEST_INSTALL_DIR_CLEANUP="" +REINSTALL_SCRATCH_CLEANUP="" +INSTALL_LOCK_DIR_CLEANUP="" +SELF_TEST_LOCAL_MIRROR=0 +# Only set via the --self-test-allow-overwrite CLI flag. We intentionally do +# NOT inherit this from the environment so that a stale SELF_TEST_ALLOW_OVERWRITE=1 +# in the caller's shell / CI cannot silently bypass the install-dir guard. +# CLI フラグ --self-test-allow-overwrite 経由でのみ 1 になる。環境変数からは +# 継承しない (呼び出し側のシェルに残った SELF_TEST_ALLOW_OVERWRITE=1 が +# install-dir ガードを黙って無効化しないようにするため)。 +SELF_TEST_ALLOW_OVERWRITE=0 +EXISTING_BIN="" +EXISTING_VERSION="" +EXPLICIT_VERSION_REQUESTED=0 +PURGE_CACHE_ON_UNINSTALL=0 + +# --- Helpers / ヘルパー --- + +info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } +warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$1" >&2; } +error() { printf '\033[1;31mERROR:\033[0m %s\n' "$1" >&2; exit 1; } +report_error() { printf '\033[1;31mERROR:\033[0m %s\n' "$1" >&2; } + +published_release_rids() { + printf '%s' "linux-x64, linux-arm64, osx-arm64, win-x64, win-arm64" +} + +platform_support_request_url() { + printf 'https://github.com/%s/issues/new?title=Request%%20official%%20release%%20asset%%20for%%20RID' "$REPO" +} + +is_published_release_rid() { + case "$1" in + linux-x64|linux-arm64|osx-arm64|win-x64|win-arm64) + return 0 + ;; + *) + return 1 + ;; + esac +} + +validate_published_release_rid() { + if is_published_release_rid "$RID"; then + return 0 + fi + + error "Unsupported release asset RID: ${RID}. Official release assets are published for $(published_release_rids). Install via 'dotnet tool install -g cdidx' with the .NET SDK, build from source with 'dotnet publish src/CodeIndex/CodeIndex.csproj -c Release -r ${RID} --self-contained true', or request official platform support at $(platform_support_request_url). See https://github.com/${REPO}/blob/main/docs/platform-support.md." +} + +cleanup() { + if [ -n "$TMPDIR_CLEANUP" ]; then + rm -rf "$TMPDIR_CLEANUP" + fi + if [ -n "$STAGE_DIR_CLEANUP" ]; then + rm -rf "$STAGE_DIR_CLEANUP" + fi + if [ -n "$BACKUP_DIR_CLEANUP" ]; then + rm -rf "$BACKUP_DIR_CLEANUP" + fi + if [ -n "$LOCAL_MIRROR_PID" ]; then + kill "$LOCAL_MIRROR_PID" > /dev/null 2>&1 || true + fi + if [ -n "$LOCAL_MIRROR_DIR_CLEANUP" ]; then + rm -rf "$LOCAL_MIRROR_DIR_CLEANUP" + fi + if [ -n "$SELF_TEST_INSTALL_DIR_CLEANUP" ]; then + rm -rf "$SELF_TEST_INSTALL_DIR_CLEANUP" + fi + if [ -n "$REINSTALL_SCRATCH_CLEANUP" ]; then + rm -rf "$REINSTALL_SCRATCH_CLEANUP" + fi + if [ -n "$INSTALL_LOCK_DIR_CLEANUP" ]; then + rm -rf "$INSTALL_LOCK_DIR_CLEANUP" + fi +} +trap cleanup EXIT + +preserve_recovery_artifacts() { + report_error "Rollback incomplete. Preserving recovery artifacts for manual recovery." + if [ -n "${BACKUP_DIR_CLEANUP:-}" ]; then + report_error "Backup: ${BACKUP_DIR_CLEANUP}" + fi + if [ -n "${STAGE_DIR_CLEANUP:-}" ]; then + report_error "Stage: ${STAGE_DIR_CLEANUP}" + fi + + BACKUP_DIR_CLEANUP="" + STAGE_DIR_CLEANUP="" +} + +need_cmd() { + if ! command -v "$1" > /dev/null 2>&1; then + error "Required command not found: $1" + fi +} + +has_cmd() { + command -v "$1" > /dev/null 2>&1 +} + +apply_verification_policy() { + case "$VERIFY_POLICY" in + ""|compat) + VERIFY_POLICY="compat" + ;; + strict) + REQUIRE_ATTESTATION=1 + STRICT_VERIFY=1 + ;; + *) + error "CDIDX_VERIFY_POLICY must be 'compat' or 'strict' (got '${VERIFY_POLICY}')." + ;; + esac +} + +release_attestation_supported() { + if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" = "1" ] && [ "${CDIDX_TEST_ENABLE_ATTESTATION:-0}" != "1" ]; then + return 1 + fi + + [ "$GITHUB_BASE_URL" = "https://github.com" ] && [ "${SELF_TEST_LOCAL_MIRROR:-0}" != "1" ] +} + +verify_release_attestation() { + local artifact_path="$1" + local artifact_name="$2" + + if ! release_attestation_supported; then + if [ "$REQUIRE_ATTESTATION" = "1" ]; then + error "GitHub provenance attestation verification is required, but the release host is not github.com. Set CDIDX_VERIFY_POLICY=compat or unset CDIDX_REQUIRE_ATTESTATION, or install from the public GitHub release." + fi + return 0 + fi + + if ! has_cmd gh; then + if [ "$REQUIRE_ATTESTATION" = "1" ]; then + error "GitHub provenance attestation verification is required, but the 'gh' command was not found. Install GitHub CLI, set CDIDX_VERIFY_POLICY=compat, or unset CDIDX_REQUIRE_ATTESTATION." + fi + warn "Skipping GitHub provenance attestation for ${artifact_name}: 'gh' command not found. Set CDIDX_VERIFY_POLICY=strict or CDIDX_REQUIRE_ATTESTATION=1 to require this verification." + return 0 + fi + + info "Verifying GitHub provenance attestation for ${artifact_name}..." + if gh attestation verify "$artifact_path" -R "$REPO" > /dev/null; then + return 0 + fi + + if [ "$REQUIRE_ATTESTATION" = "1" ]; then + error "GitHub provenance attestation verification failed for ${artifact_name}." + fi + + warn "GitHub provenance attestation verification failed for ${artifact_name}; continuing with checksum verification. Set CDIDX_VERIFY_POLICY=strict or CDIDX_REQUIRE_ATTESTATION=1 to fail closed." +} + +checksum_signature_supported() { + if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" = "1" ] && [ "${CDIDX_TEST_ENABLE_SIGNATURE_VERIFY:-0}" != "1" ]; then + return 1 + fi + + return 0 +} + +normalize_gpg_fingerprint() { + printf '%s' "$1" | tr -d '[:space:]' | tr '[:lower:]' '[:upper:]' +} + +extract_validsig_fingerprint() { + awk '$1 == "[GNUPG:]" && $2 == "VALIDSIG" { print $3; exit }' "$1" +} + +verify_checksum_signature() { + local checksums_path="$1" + local signature_path="$2" + + if ! has_cmd gpg; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification is required, but the 'gpg' command was not found. Install GnuPG, set CDIDX_VERIFY_POLICY=compat, or unset CDIDX_STRICT_VERIFY." + fi + warn "Skipping GPG signature verification for sha256sums.txt: 'gpg' command not found. Set CDIDX_VERIFY_POLICY=strict or CDIDX_STRICT_VERIFY=1 to require this verification." + return 0 + fi + + local gpg_status="${signature_path}.status" + local gpg_stderr="${signature_path}.stderr" + info "Verifying checksum signature..." + if ! gpg --batch --status-fd 1 --verify "$signature_path" "$checksums_path" > "$gpg_status" 2> "$gpg_stderr"; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification failed for sha256sums.txt." + fi + warn "GPG signature verification failed for sha256sums.txt; continuing with checksum verification. Set CDIDX_VERIFY_POLICY=strict or CDIDX_STRICT_VERIFY=1 to fail closed." + return 0 + fi + + local actual_fingerprint + actual_fingerprint="$(extract_validsig_fingerprint "$gpg_status")" + if [ -z "$actual_fingerprint" ]; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification did not report a signer fingerprint." + fi + warn "GPG signature verification succeeded but no signer fingerprint was reported; continuing without fingerprint pinning." + return 0 + fi + + if [ -z "$RELEASE_GPG_FINGERPRINT" ]; then + if [ "$STRICT_VERIFY" = "1" ]; then + error "GPG signature verification is strict, but no expected release signer fingerprint is configured. Set CDIDX_RELEASE_GPG_FINGERPRINT to the trusted release signing key fingerprint, or set CDIDX_VERIFY_POLICY=compat." + fi + warn "GPG signature verification succeeded for sha256sums.txt, but no expected release signing fingerprint is configured. Set CDIDX_RELEASE_GPG_FINGERPRINT to pin the signer, or set CDIDX_VERIFY_POLICY=strict to require it." + return 0 + fi + + local expected_fingerprint + expected_fingerprint="$(normalize_gpg_fingerprint "$RELEASE_GPG_FINGERPRINT")" + actual_fingerprint="$(normalize_gpg_fingerprint "$actual_fingerprint")" + if [ "$actual_fingerprint" != "$expected_fingerprint" ]; then + error "GPG signature fingerprint mismatch for sha256sums.txt. Expected ${expected_fingerprint}, got ${actual_fingerprint}." + fi +} diff --git a/install_modules/10-network-and-platform.sh b/install_modules/10-network-and-platform.sh new file mode 100644 index 0000000000..0791c66731 --- /dev/null +++ b/install_modules/10-network-and-platform.sh @@ -0,0 +1,666 @@ + +temp_root() { + printf '%s' "${TMPDIR:-/tmp}" +} + +probe_temp_root() { + local root + root="$(temp_root)" + + if [ ! -d "$root" ]; then + error "TMPDIR not usable: ${root} is not a directory. Set TMPDIR to a writable directory with at least 100 MiB free." + fi + + local probe + if ! probe="$(mktemp "${root%/}/.cdidx-install-probe.XXXXXX")"; then + error "TMPDIR not writable: ${root}. Set TMPDIR to a writable directory and rerun the installer." + fi + rm -f "$probe" + + local free_kb="" + if command -v df > /dev/null 2>&1; then + free_kb="$(df -Pk "$root" 2>/dev/null | awk 'NR==2 {print $4}' || true)" + fi + + if [ -n "$free_kb" ] && [ "$free_kb" -lt 102400 ] 2>/dev/null; then + error "Insufficient temp space in ${root}: ${free_kb} KiB available; need at least 102400 KiB. Set TMPDIR to a larger writable directory." + fi + + if command -v mount > /dev/null 2>&1 && mount 2>/dev/null | awk -v root="$root" ' + index($0, " on " root " ") && index($0, "noexec") { found = 1 } + END { exit found ? 0 : 1 } + '; then + warn "TMPDIR appears to be on a noexec filesystem: ${root}. The installer will avoid executing staged files from TMPDIR." + fi +} + +verify_temp_path_space() { + local path="$1" + local free_kb="" + + if command -v df > /dev/null 2>&1; then + free_kb="$(df -Pk "$path" 2>/dev/null | awk 'NR==2 {print $4}' || true)" + fi + + if [ -n "$free_kb" ] && [ "$free_kb" -lt 102400 ] 2>/dev/null; then + error "Insufficient temp space for ${path}: ${free_kb} KiB available; need at least 102400 KiB." + fi +} + +acquire_install_lock() { + mkdir -p "$INSTALL_DIR" + + local lock_path="${INSTALL_DIR}/.cdidx-install.lock" + + if command -v flock > /dev/null 2>&1; then + exec 9>"$lock_path" + if ! flock -n 9; then + error "Another cdidx install is already running for ${INSTALL_DIR}. Retry after it finishes." + fi + return 0 + fi + + local lock_dir="${INSTALL_DIR}/.cdidx-install.lockdir" + if ! mkdir "$lock_dir" 2>/dev/null; then + error "Another cdidx install is already running for ${INSTALL_DIR}. Retry after it finishes." + fi + INSTALL_LOCK_DIR_CLEANUP="$lock_dir" + return 0 +} + +strip_version_prefix() { + printf '%s' "$1" | sed 's/^[^0-9]*//' +} + +semver_core() { + printf '%s' "$1" | sed 's/^[^0-9]*//' | sed 's/[^0-9.].*$//' +} + +semver_ge() { + local left right + left="$(semver_core "$1")" + right="$(semver_core "$2")" + + awk -v left="$left" -v right="$right" ' + BEGIN { + split(left, l, ".") + split(right, r, ".") + for (i = 1; i <= 3; i++) { + li = (l[i] == "" ? 0 : l[i]) + 0 + ri = (r[i] == "" ? 0 : r[i]) + 0 + if (li > ri) exit 0 + if (li < ri) exit 1 + } + exit 0 + }' +} + +verify_cdidx_binary() { + local binary_path="$1" + local expected_version="${VERSION#v}" + local version_output="" + local actual_version="" + + if [ ! -x "$binary_path" ]; then + report_error "Installed binary is not executable: ${binary_path}." + return 1 + fi + + if ! version_output="$("$binary_path" --version 2>&1)"; then + report_error "Installed binary failed to run: ${binary_path} --version." + report_error "Output: ${version_output}" + report_error "Likely causes include a wrong-architecture release asset or missing native runtime dependency next to the binary." + return 1 + fi + + actual_version="$(semver_core "$version_output")" + if [ -z "$actual_version" ]; then + report_error "Installed binary returned an unparsable version from ${binary_path}: ${version_output}" + return 1 + fi + + if [ -n "$expected_version" ] && [ "$actual_version" != "$expected_version" ]; then + report_error "Installed binary version mismatch at ${binary_path}: expected ${expected_version}, got ${actual_version}." + report_error "Check for a stale release archive, wrong architecture, or PATH shadowing by an older cdidx." + return 1 + fi + + return 0 +} + +extract_release_tag_name() { + local api_response="$1" + local version="" + + if command -v jq > /dev/null 2>&1; then + version="$(printf '%s' "$api_response" | jq -r '.tag_name // empty' 2>/dev/null || true)" + fi + + if [ -z "$version" ]; then + version="$(printf '%s' "$api_response" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" + fi + + printf '%s' "$version" +} + +default_self_test_version() { + local script_dir + local version_file + local version + + script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" + version_file="${script_dir}/version.json" + version="" + + if [ -f "$version_file" ]; then + if command -v jq > /dev/null 2>&1; then + version="$(jq -r '.version // empty' "$version_file" 2>/dev/null || true)" + fi + + if [ -z "$version" ]; then + version="$(grep '"version"' "$version_file" | head -1 | sed 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')" + fi + fi + + if [ -z "$version" ]; then + printf '%s' "v0.0.0" + return 0 + fi + + case "$version" in + v*) printf '%s' "$version" ;; + *) printf 'v%s' "$version" ;; + esac +} + +latest_release_api_url() { + printf '%s/repos/%s/releases/latest' "$GITHUB_API_BASE_URL" "$REPO" +} + +latest_release_api_diagnostic_label() { + if [ "$GITHUB_API_BASE_URL" = "https://api.github.com" ]; then + printf '%s' "GitHub API" + else + printf 'configured latest-release API (%s)' "$GITHUB_API_BASE_URL" + fi +} + +release_host_diagnostic_label() { + if [ "$GITHUB_BASE_URL" = "https://github.com" ]; then + printf '%s' "GitHub release host" + else + printf 'configured release host (%s)' "$GITHUB_BASE_URL" + fi +} + +is_loopback_url() { + case "$1" in + http://127.0.0.1:*|https://127.0.0.1:*|http://localhost:*|https://localhost:*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +append_loopback_no_proxy_list() { + local current_value="${1:-}" + + if [ -n "$current_value" ]; then + printf '%s,%s' "$current_value" "127.0.0.1,localhost" + else + printf '%s' "127.0.0.1,localhost" + fi +} + +prepare_loopback_no_proxy_env() { + NO_PROXY="$(append_loopback_no_proxy_list "${NO_PROXY:-}")" + no_proxy="$(append_loopback_no_proxy_list "${no_proxy:-}")" + export NO_PROXY no_proxy +} + +run_curl_with_optional_loopback_bypass() { + if is_loopback_url "$1"; then + shift + curl --noproxy 127.0.0.1,localhost "$@" + else + shift + curl "$@" + fi +} + +has_explicit_self_test_install_dir() { + [ -n "${CDIDX_INSTALL_DIR:-}" ] +} + +# Decide whether an explicit CDIDX_INSTALL_DIR is risky enough to refuse the +# self-test mock install. A "risky" dir is either a well-known system/user +# install path (where a real cdidx would normally live) or any directory that +# already contains an executable cdidx binary. Callers can opt out of this +# guard with --self-test-allow-overwrite. +# 明示指定された CDIDX_INSTALL_DIR が、既知のシステム/ユーザー install 先か +# 既に cdidx を持つディレクトリなら、mock での上書きを拒否する。解除には +# --self-test-allow-overwrite を使う。 +is_self_test_install_dir_risky() { + local dir="$1" + + if [ -z "$dir" ]; then + return 1 + fi + + # Expand a leading ~ manually; bash does not expand ~ inside env values. + # 先頭の ~ は env の値内では展開されないので手動で置換する。 + case "$dir" in + "~"|"~/"*) + if [ -n "${HOME:-}" ]; then + dir="${HOME}${dir#\~}" + fi + ;; + esac + + # Normalize trailing slashes so /usr/local/bin and /usr/local/bin/ (or + # "$HOME/.local/bin/") match the well-known-path branches below. Leave a + # lone "/" intact so we don't turn it into an empty string. + # 末尾スラッシュを正規化し、/usr/local/bin と /usr/local/bin/ などを同一視する。 + # ルート "/" は空文字にならないよう保持する。 + while [ "${#dir}" -gt 1 ]; do + case "$dir" in + */) dir="${dir%/}" ;; + *) break ;; + esac + done + + case "$dir" in + /usr/local/bin|/usr/bin|/opt/homebrew/bin|/opt/local/bin) + return 0 + ;; + esac + + if [ -n "${HOME:-}" ] && [ "$dir" = "${HOME}/.local/bin" ]; then + return 0 + fi + + if [ -x "${dir}/${BINARY_NAME}" ]; then + return 0 + fi + + return 1 +} + +allow_risky_install_dir() { + [ "${CDIDX_ALLOW_RISKY_INSTALL_DIR:-0}" = "1" ] +} + +expand_install_dir_path() { + local dir="$1" + + case "$dir" in + "~"|"~/"*) + if [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; then + report_error "Cannot expand cdidx install directory ${dir}: HOME is empty or root." + return 1 + fi + dir="${HOME}${dir#\~}" + ;; + esac + + while [ "${#dir}" -gt 1 ]; do + case "$dir" in + */) dir="${dir%/}" ;; + *) break ;; + esac + done + + printf '%s\n' "$dir" +} + +normalize_install_dir_path() { + local path="$1" + local existing="$path" + local suffix="" + local base + local normalized_existing + + while [ ! -e "$existing" ] && [ "$existing" != "/" ]; do + base="$(basename -- "$existing")" + suffix="/${base}${suffix}" + existing="$(dirname -- "$existing")" + done + + if [ ! -d "$existing" ]; then + report_error "Install directory ancestor is not a directory: ${existing}" + return 1 + fi + + normalized_existing="$(CDPATH= cd -P -- "$existing" && pwd)" || return 1 + if [ "$normalized_existing" = "/" ]; then + if [ -n "$suffix" ]; then + printf '%s\n' "$suffix" + else + printf '/\n' + fi + else + printf '%s%s\n' "$normalized_existing" "$suffix" + fi +} + +normalized_home_dir() { + local home_dir="${HOME:-}" + + if [ -z "$home_dir" ]; then + return 1 + fi + + while [ "${#home_dir}" -gt 1 ]; do + case "$home_dir" in + */) home_dir="${home_dir%/}" ;; + *) break ;; + esac + done + + if [ -d "$home_dir" ]; then + (CDPATH= cd -P -- "$home_dir" && pwd) + else + printf '%s\n' "$home_dir" + fi +} + +is_high_risk_install_dir() { + local dir="$1" + local home_dir + + case "$dir" in + /|/bin|/sbin|/tmp|/var|/var/tmp|/private/tmp|/private/var|/private/var/tmp|/usr|/usr/bin|/usr/sbin|/usr/local|/usr/local/bin|/usr/local/sbin|/usr/share|/usr/local/share|/usr/lib|/usr/local/lib|/opt|/opt/bin|/opt/homebrew|/opt/homebrew/bin|/opt/local|/opt/local/bin|/Applications|/Library|/System) + return 0 + ;; + esac + + home_dir="$(normalized_home_dir || true)" + if [ -n "$home_dir" ] && [ "$dir" = "$home_dir" ]; then + return 0 + fi + + return 1 +} + +validate_normal_install_dir() { + local expanded + local normalized + + if [ -z "${CDIDX_INSTALL_DIR+x}" ] && { [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; }; then + report_error "Cannot safely derive cdidx install directory: HOME is empty or root." + return 1 + fi + + if ! expanded="$(expand_install_dir_path "$INSTALL_DIR")"; then + return 1 + fi + + case "$expanded" in + "") + report_error "Refusing empty cdidx install directory. Set CDIDX_INSTALL_DIR to an absolute directory." + return 1 + ;; + //*) + report_error "Refusing ambiguous cdidx install directory: ${expanded}" + return 1 + ;; + "."|".."|./*|../*|*/./*|*/../*|*/.|*/..) + report_error "Refusing ambiguous cdidx install directory: ${expanded}" + return 1 + ;; + /*) ;; + *) + report_error "Refusing non-absolute cdidx install directory: ${expanded}" + return 1 + ;; + esac + + if ! normalized="$(normalize_install_dir_path "$expanded")"; then + return 1 + fi + + if is_high_risk_install_dir "$normalized" && ! allow_risky_install_dir; then + report_error "Refusing risky install directory: ${normalized}. Set CDIDX_ALLOW_RISKY_INSTALL_DIR=1 to override." + return 1 + fi + + INSTALL_DIR="$normalized" + return 0 +} + +normalize_existing_or_parent_directory() { + local path="$1" + local parent + local base + local normalized_parent + + while [ "${#path}" -gt 1 ]; do + case "$path" in + */) path="${path%/}" ;; + *) break ;; + esac + done + + if [ -d "$path" ]; then + (CDPATH= cd -P -- "$path" && pwd) + return + fi + + parent="$(dirname -- "$path")" + base="$(basename -- "$path")" + if [ ! -d "$parent" ]; then + report_error "Cache root parent does not exist: ${parent}" + return 1 + fi + + normalized_parent="$(CDPATH= cd -P -- "$parent" && pwd)" || return 1 + if [ "$normalized_parent" = "/" ]; then + printf '/%s\n' "$base" + else + printf '%s/%s\n' "$normalized_parent" "$base" + fi +} + +resolve_purge_cache_dir() { + local cache_root + local normalized_root + + if [ -n "${XDG_CACHE_HOME:-}" ]; then + cache_root="$XDG_CACHE_HOME" + else + if [ -z "${HOME:-}" ] || [ "$HOME" = "/" ]; then + report_error "Cannot safely derive cdidx cache directory: HOME is empty or root." + return 1 + fi + cache_root="${HOME}/.cache" + fi + + case "$cache_root" in + ""|"/"|".") + report_error "Refusing to purge cdidx cache from unsafe cache root: ${cache_root:-}" + return 1 + ;; + /*) ;; + *) + report_error "Refusing to purge cdidx cache from non-absolute cache root: ${cache_root}" + return 1 + ;; + esac + + if ! normalized_root="$(normalize_existing_or_parent_directory "$cache_root")"; then + return 1 + fi + + case "$normalized_root" in + ""|"/") + report_error "Refusing to purge cdidx cache from unsafe normalized cache root: ${normalized_root:-}" + return 1 + ;; + esac + + printf '%s/cdidx\n' "$normalized_root" +} + +release_download_base_url() { + printf '%s/%s/releases/download/%s' "$GITHUB_BASE_URL" "$REPO" "$VERSION" +} + +is_proxy_tunnel_403() { + printf '%s' "$1" | grep -Eqi 'CONNECT tunnel failed, response 403|HTTP code 403 from proxy after CONNECT' +} + +file_size_bytes() { + wc -c < "$1" | tr -d '[:space:]' +} + +read_bounded_file_sample() { + local path="$1" + local max_bytes="$2" + local label="$3" + local byte_count + + if ! byte_count="$(file_size_bytes "$path")"; then + return 1 + fi + + if [ "${byte_count:-0}" -le "$max_bytes" ]; then + cat "$path" + return 0 + fi + + head -c "$max_bytes" "$path" + printf '\n[cdidx installer truncated %s: showing first %s of %s bytes]\n' "$label" "$max_bytes" "$byte_count" +} + +curl_http_get() { + local url="$1" + local output_path="$2" + local source_label="${3:-remote host}" + local http_code + local curl_stderr + + probe_temp_root + if ! curl_stderr="$(mktemp)"; then + report_error "Failed to create temporary curl stderr capture while fetching ${source_label} at $url." + return 1 + fi + verify_temp_path_space "$curl_stderr" + + if http_code="$(run_curl_with_optional_loopback_bypass "$url" -sSL -o "$output_path" -w '%{http_code}' "$url" 2>"$curl_stderr")"; then + rm -f "$curl_stderr" + printf '%s' "$http_code" + return 0 + else + local curl_status=$? + local stderr_text="" + if [ -f "$curl_stderr" ]; then + stderr_text="$(read_bounded_file_sample "$curl_stderr" "$CURL_STDERR_SAMPLE_BYTES" "curl stderr for ${source_label}")" + rm -f "$curl_stderr" + fi + + if [ "$curl_status" -eq 56 ] && is_proxy_tunnel_403 "$stderr_text"; then + if [ -n "$stderr_text" ]; then + printf '%s\n' "$stderr_text" >&2 + fi + report_error "CONNECT tunnel failed with HTTP 403 while reaching ${source_label} at $url (curl exit 56). This deny is happening in an upstream proxy/egress policy before TLS." + report_error "If every HTTPS endpoint fails with a CONNECT-stage HTTP 403, route substitution alone will not fix it." + report_error "Ask your network administrator to allow-list at least one required API or artifact host path." + return 1 + fi + + if [ -n "$stderr_text" ]; then + printf '%s\n' "$stderr_text" >&2 + fi + + case "$curl_status" in + 6|7|28|35|52|56) + report_error "Network error reaching ${source_label} while fetching $url (curl exit $curl_status). Check your connection, proxy, or configured mirror." + ;; + *) + report_error "curl failed while fetching ${source_label} at $url (exit $curl_status)." + ;; + esac + + return 1 + fi +} + +fetch_latest_release_version() { + need_cmd curl + need_cmd mktemp + + local api_url="https://api.github.com/repos/${REPO}/releases/latest" + local api_url + local api_label + api_url="$(latest_release_api_url)" + api_label="$(latest_release_api_diagnostic_label)" + local response_file + probe_temp_root + if ! response_file="$(mktemp)"; then + error "Failed to create temporary file for latest-release lookup." + fi + verify_temp_path_space "$response_file" + + local http_code + if ! http_code="$(curl_http_get "$api_url" "$response_file" "$api_label")"; then + rm -f "$response_file" + return 1 + fi + local explicit_version_examples + explicit_version_examples="rerun the installer with an explicit version (for example: 'curl -fsSL https://raw.githubusercontent.com/${REPO}/vX.Y.Z/install.sh | bash -s -- vX.Y.Z', or 'bash ./install.sh vX.Y.Z' from a checkout)" + local api_response_bytes + if ! api_response_bytes="$(file_size_bytes "$response_file")"; then + rm -f "$response_file" + report_error "Failed to inspect ${api_label} response size while fetching ${api_url}." + return 1 + fi + if [ "${api_response_bytes:-0}" -gt "$LATEST_RELEASE_RESPONSE_MAX_BYTES" ]; then + rm -f "$response_file" + report_error "${api_label} response exceeded the ${LATEST_RELEASE_RESPONSE_MAX_BYTES} byte limit before shell parsing while fetching ${api_url} (HTTP ${http_code}). ${explicit_version_examples} to skip the latest-release API call." + return 1 + fi + local api_response + api_response="$(cat "$response_file")" + rm -f "$response_file" + + case "$http_code" in + 200) ;; + 403) + if printf '%s' "$api_response" | grep -qi "rate limit"; then + report_error "${api_label} rate limit exceeded while fetching ${api_url}. Retry later, or pass an explicit version: 'curl ... | bash -s -- vX.Y.Z'." + return 1 + fi + if [ "$GITHUB_API_BASE_URL" = "https://api.github.com" ]; then + report_error "${api_label} returned HTTP 403 while fetching ${api_url}. ${explicit_version_examples} to skip the latest-release API call, or set CDIDX_GITHUB_API_BASE_URL to a reachable internal mirror API." + else + report_error "${api_label} returned HTTP 403 while fetching ${api_url}. Check the configured API endpoint, credentials, path ACL, or proxy policy. You can also ${explicit_version_examples} to skip the latest-release API call." + fi + report_error "If every HTTPS endpoint fails with 'CONNECT tunnel failed, response 403', this is an upstream proxy/egress policy deny before TLS; route substitution alone will not fix it." + return 1 + ;; + 404) + report_error "${api_label} returned HTTP 404 while fetching ${api_url}. Check that REPO=${REPO} and the configured API base are correct." + return 1 + ;; + 5??) + report_error "${api_label} returned HTTP $http_code while fetching ${api_url}. The configured API endpoint may be temporarily unavailable; retry in a few minutes." + return 1 + ;; + *) + report_error "${api_label} returned HTTP $http_code while fetching ${api_url}." + return 1 + ;; + esac + + local version + version="$(extract_release_tag_name "$api_response")" + if [ -z "$version" ]; then + report_error "Could not determine latest version from ${api_label} response at ${api_url}." + return 1 + fi + + printf '%s' "$version" + return 0 +} diff --git a/install_modules/20-installer.sh b/install_modules/20-installer.sh new file mode 100644 index 0000000000..b3917a338f --- /dev/null +++ b/install_modules/20-installer.sh @@ -0,0 +1,599 @@ + +existing_install_is_reusable() { + if [ -z "$EXISTING_VERSION" ] || [ "$EXISTING_VERSION" = "0.0.0" ]; then + return 1 + fi + + if [ ! -f "${INSTALL_DIR}/version.json" ]; then + return 1 + fi + if ! grep -Eq '"integrity_ok"[[:space:]]*:[[:space:]]*true' "${INSTALL_DIR}/version.json"; then + return 1 + fi + + [ -f "${INSTALL_DIR}/LICENSE" ] || return 1 + [ -f "${INSTALL_DIR}/COMMERCIAL_LICENSE.md" ] || return 1 + [ -f "${INSTALL_DIR}/INTEGRATION_POLICY.md" ] || return 1 + [ -f "${INSTALL_DIR}/TRADEMARKS.md" ] || return 1 + [ -f "${INSTALL_DIR}/LICENSES/FSL-1.1-ALv2.txt" ] || return 1 + [ -f "${INSTALL_DIR}/LICENSES/Apache-2.0.txt" ] || return 1 + + case "${OS_NAME:-}" in + linux) + [ -f "${INSTALL_DIR}/libe_sqlite3.so" ] || return 1 + ;; + osx) + [ -f "${INSTALL_DIR}/libe_sqlite3.dylib" ] || return 1 + ;; + esac + + return 0 +} + +calculate_sha256() { + local path="$1" + + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + elif command -v shasum > /dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + elif command -v openssl > /dev/null 2>&1; then + openssl dgst -sha256 "$path" | awk '{print $NF}' + else + error "No checksum tool found (need sha256sum, shasum, or openssl). Cannot verify release payload integrity." + fi +} + +validate_archive_members() { + local archive="$1" + local member + + tar tzf "$archive" | while IFS= read -r member || [ -n "$member" ]; do + case "$member" in + ""|/*|..|../*|*/../*|*/.. ) + error "Release archive contains unsafe member path before extraction: ${member:-}" + ;; + esac + done +} + +verify_payload_manifest() { + local extract_dir="$1" + local manifest="${extract_dir}/MANIFEST.sha256" + local manifest_paths line expected path actual extracted_paths + + if [ ! -f "$manifest" ]; then + if semver_ge "${VERSION#v}" "$MANIFEST_REQUIRED_VERSION"; then + error "Release payload is missing MANIFEST.sha256. Refusing to install without per-file integrity metadata." + fi + + warn "Release payload is missing MANIFEST.sha256; falling back to archive-level checksum verification for legacy release ${VERSION}." + return 0 + fi + + if ! manifest_paths="$(mktemp)"; then + error "Failed to create temporary manifest path list." + fi + if ! extracted_paths="$(mktemp)"; then + rm -f "$manifest_paths" + error "Failed to create temporary extracted path list." + fi + + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + expected="${line%% *}" + path="${line#* }" + case "$path" in + ""|/*|*"/../"*|../*|*"/.." ) + error "Invalid path in release payload manifest: ${path}" + ;; + esac + printf '%s\n' "$path" >> "$manifest_paths" + if [ ! -f "${extract_dir}/${path}" ]; then + rm -f "$manifest_paths" "$extracted_paths" + error "Release payload manifest entry missing after extraction: ${path}" + fi + actual="$(calculate_sha256 "${extract_dir}/${path}")" + if [ "$actual" != "$expected" ]; then + rm -f "$manifest_paths" "$extracted_paths" + error "Release payload checksum mismatch for ${path}.\n Expected: ${expected}\n Actual: ${actual}" + fi + done < "$manifest" + + ( + cd "$extract_dir" + find . -type f ! -name MANIFEST.sha256 | sed 's#^\./##' | LC_ALL=C sort + ) > "$extracted_paths" + + while IFS= read -r path || [ -n "$path" ]; do + [ -n "$path" ] || continue + if ! grep -Fxq "$path" "$manifest_paths"; then + rm -f "$manifest_paths" "$extracted_paths" + error "Release payload contains file not listed in MANIFEST.sha256: ${path}" + fi + done < "$extracted_paths" + + rm -f "$manifest_paths" "$extracted_paths" +} + +write_integrity_version_json() { + local target="$1" + local version="${VERSION#v}" + + printf '{"version":"%s","integrity_ok":true}\n' "$version" > "$target" +} + +restore_backed_up_files() { + local backup_dir="$1" + local install_dir="$2" + local backed_up_files="$3" + local asset + + for asset in $backed_up_files; do + if [ -e "${backup_dir}/${asset}" ]; then + if ! mv "${backup_dir}/${asset}" "${install_dir}/${asset}"; then + report_error "Failed to restore previous install file ${asset} from backup at ${backup_dir}. Manual recovery may be required." + return 1 + fi + fi + done + + return 0 +} + +is_expected_release_asset_name() { + case "$1" in + "$BINARY_NAME"|version.json|libe_sqlite3.so|libe_sqlite3.dylib|LICENSE|COMMERCIAL_LICENSE.md|INTEGRATION_POLICY.md|TRADEMARKS.md|MANIFEST.sha256|LICENSES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +validate_promoted_asset_name() { + local asset="$1" + + case "$asset" in + ""|"."|".."|/*|*/*|*\\*) + report_error "Refusing to remove unsafe rollback asset name: ${asset:-}" + return 1 + ;; + esac + + if ! is_expected_release_asset_name "$asset"; then + report_error "Refusing to remove unexpected rollback asset name: ${asset}" + return 1 + fi + + return 0 +} + +remove_promoted_files() { + local install_dir="$1" + local promoted_files="$2" + local asset + + for asset in $promoted_files; do + if ! validate_promoted_asset_name "$asset"; then + return 1 + fi + done + + for asset in $promoted_files; do + if [ -e "${install_dir}/${asset}" ]; then + if ! rm -rf "${install_dir}/${asset}"; then + report_error "Failed to remove partially installed file ${install_dir}/${asset} during rollback. Manual recovery may be required." + return 1 + fi + fi + done + + return 0 +} + +promote_staged_install() { + local stage_dir="$1" + local backup_dir="$2" + local install_dir="$3" + local required_files="$4" + local required_assets="$5" + local asset + local backed_up_files="" + local promoted_files="" + + for asset in ${BINARY_NAME} $required_assets; do + if [ -e "${install_dir}/${asset}" ]; then + if ! mv "${install_dir}/${asset}" "${backup_dir}/${asset}"; then + report_error "Failed to stage existing ${asset} into backup at ${backup_dir}. Install aborted before replacing the current install." + if [ -n "$backed_up_files" ]; then + if restore_backed_up_files "$backup_dir" "$install_dir" "$backed_up_files"; then + rm -rf "$backup_dir" + else + preserve_recovery_artifacts + fi + fi + return 1 + fi + backed_up_files="${backed_up_files} ${asset}" + fi + done + + for asset in $required_assets; do + if ! mv "${stage_dir}/${asset}" "${install_dir}/${asset}"; then + report_error "Failed to install ${asset} into ${install_dir}. Restoring previous install." + if [ -n "$promoted_files" ] && ! remove_promoted_files "$install_dir" "$promoted_files"; then + preserve_recovery_artifacts + return 1 + fi + if [ -n "$backed_up_files" ]; then + if restore_backed_up_files "$backup_dir" "$install_dir" "$backed_up_files"; then + rm -rf "$backup_dir" + else + preserve_recovery_artifacts + fi + fi + return 1 + fi + promoted_files="${promoted_files} ${asset}" + done + + if ! mv "${stage_dir}/${BINARY_NAME}" "${install_dir}/${BINARY_NAME}"; then + report_error "Failed to install ${BINARY_NAME} into ${install_dir}. Restoring previous install." + if [ -n "$promoted_files" ] && ! remove_promoted_files "$install_dir" "$promoted_files"; then + preserve_recovery_artifacts + return 1 + fi + if [ -n "$backed_up_files" ]; then + if restore_backed_up_files "$backup_dir" "$install_dir" "$backed_up_files"; then + rm -rf "$backup_dir" + else + preserve_recovery_artifacts + fi + fi + return 1 + fi + + rm -rf "$backup_dir" + return 0 +} + +download_release_file() { + local url="$1" + local output_path="$2" + local description="$3" + local release_host_label + release_host_label="$(release_host_diagnostic_label)" + + local http_code + if ! http_code="$(curl_http_get "$url" "$output_path" "$release_host_label")"; then + return 1 + fi + + case "$http_code" in + 200) ;; + 403) + report_error "Failed to download ${description} from ${release_host_label} at $url (HTTP 403)." + if [ "$GITHUB_BASE_URL" = "https://github.com" ]; then + report_error "GitHub may be blocking or rate-limiting this route." + else + report_error "Check the configured mirror/proxy path, credentials, or access policy." + fi + report_error "If both github.com and the configured mirror/proxy host fail at CONNECT tunnel stage with 403, ask your network administrator to allow-list at least one artifact host path." + return 1 + ;; + 404) + report_error "Failed to download ${description} from ${release_host_label} at $url (HTTP 404). Check that version ${VERSION} exists and that the configured release host publishes ${RID} assets." + return 1 + ;; + 5??) + report_error "Failed to download ${description} from ${release_host_label} at $url (HTTP $http_code). The configured release host may be temporarily unavailable; retry in a few minutes." + return 1 + ;; + *) + report_error "Failed to download ${description} from ${release_host_label} at $url (HTTP $http_code)." + return 1 + ;; + esac + + return 0 +} + +download_optional_release_file() { + local url="$1" + local output_path="$2" + local release_host_label + release_host_label="$(release_host_diagnostic_label)" + + local http_code + if ! http_code="$(curl_http_get "$url" "$output_path" "$release_host_label")"; then + return 1 + fi + + [ "$http_code" = "200" ] +} + +# --- Detect OS and architecture / OS・アーキテクチャ検出 --- + +detect_platform() { + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + case "$os" in + Linux) OS_NAME="linux" ;; + Darwin) OS_NAME="osx" ;; + *) error "Unsupported OS: $os (supported: Linux, macOS)" ;; + esac + + case "$arch" in + x86_64|amd64) ARCH_NAME="x64" ;; + aarch64|arm64) ARCH_NAME="arm64" ;; + *) error "Unsupported architecture: $arch. Official release assets are published for $(published_release_rids). Other RIDs such as linux-x86, osx-x64, and win-x86 are not currently shipped. Install via 'dotnet tool install -g cdidx' with the .NET SDK, build from source with 'dotnet publish src/CodeIndex/CodeIndex.csproj -c Release -r --self-contained true', or request official platform support at $(platform_support_request_url). See https://github.com/${REPO}/blob/main/docs/platform-support.md." ;; + esac + + RID="${OS_NAME}-${ARCH_NAME}" + + # osx-x64 is not published / osx-x64 はリリースしていない + if [ "$RID" = "osx-x64" ]; then + error "macOS x86_64 (Intel) binaries are not published as CodeIndex-osx-x64.tar.gz. Official release assets are published for $(published_release_rids). Install via 'dotnet tool install -g cdidx' with the .NET SDK, build from source with 'dotnet publish src/CodeIndex/CodeIndex.csproj -c Release -r osx-x64 --self-contained true', or request official platform support at $(platform_support_request_url). See https://github.com/${REPO}/blob/main/docs/platform-support.md." + fi + + validate_published_release_rid + + # Reject musl-based Linux (e.g. Alpine) — published binaries require glibc + # musl系Linux(Alpine等)を拒否 — リリースバイナリはglibcが必要 + if [ "$OS_NAME" = "linux" ]; then + if command -v ldd > /dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then + error "musl-based Linux (e.g. Alpine) is not supported. Published binaries require glibc. Use a glibc-based image (e.g. debian, ubuntu) or install via 'dotnet tool install -g cdidx'." + fi + fi +} + +# --- Resolve version / バージョン解決 --- + +resolve_version() { + EXPLICIT_VERSION_REQUESTED=0 + + if [ -n "${1:-}" ]; then + EXPLICIT_VERSION_REQUESTED=1 + VERSION="$1" + # Ensure v prefix / vプレフィックスを補完 + case "$VERSION" in + v*) ;; + *) VERSION="v${VERSION}" ;; + esac + else + info "Fetching latest release version..." + if ! VERSION="$(fetch_latest_release_version)"; then + return 1 + fi + fi + + info "Version: $VERSION" + return 0 +} + +# --- Check existing installation / 既存インストール確認 --- + +detect_existing_install() { + EXISTING_BIN="${INSTALL_DIR}/${BINARY_NAME}" + EXISTING_VERSION="" + + if [ -x "$EXISTING_BIN" ]; then + local raw_version + raw_version="$("$EXISTING_BIN" --version 2>/dev/null || echo "unknown")" + # Strip any prefix like "cdidx " or "cdidx v" / プレフィックスを除去 + EXISTING_VERSION="$(strip_version_prefix "$raw_version")" + fi + + return 0 +} + +check_existing() { + if [ -n "$EXISTING_VERSION" ]; then + local target_version="${VERSION#v}" + if [ "$EXISTING_VERSION" = "$target_version" ]; then + if existing_install_is_reusable && [ "$EXPLICIT_VERSION_REQUESTED" != "1" ]; then + info "cdidx $target_version is already installed at $EXISTING_BIN. Skipping." + exit 0 + fi + + if [ "$EXPLICIT_VERSION_REQUESTED" = "1" ]; then + info "Reinstalling cdidx $target_version because it was requested explicitly..." + return 0 + fi + + info "Reinstalling cdidx $target_version because the existing install is incomplete..." + return 0 + fi + info "Switching cdidx from $EXISTING_VERSION to ${VERSION#v}..." + fi + + return 0 +} + +# --- Download and verify / ダウンロード・検証 --- + +download_and_install() { + need_cmd curl + need_cmd tar + need_cmd mktemp + need_cmd awk + + local archive_name="CodeIndex-${RID}.tar.gz" + local base_url + base_url="$(release_download_base_url)" + local archive_url="${base_url}/${archive_name}" + local checksums_url="${base_url}/sha256sums.txt" + local checksums_signature_url="${base_url}/sha256sums.txt.asc" + + local tmpdir + probe_temp_root + if ! tmpdir="$(mktemp -d)"; then + error "Failed to create temporary working directory for install." + fi + verify_temp_path_space "$tmpdir" + TMPDIR_CLEANUP="$tmpdir" + + info "Downloading ${archive_name}..." + download_release_file "$archive_url" "${tmpdir}/${archive_name}" "${archive_name}" + verify_release_attestation "${tmpdir}/${archive_name}" "$archive_name" + + info "Downloading checksums..." + download_release_file "$checksums_url" "${tmpdir}/sha256sums.txt" "sha256sums.txt" + verify_release_attestation "${tmpdir}/sha256sums.txt" "sha256sums.txt" + + if checksum_signature_supported; then + info "Downloading checksum signature..." + if download_optional_release_file "$checksums_signature_url" "${tmpdir}/sha256sums.txt.asc"; then + verify_checksum_signature "${tmpdir}/sha256sums.txt" "${tmpdir}/sha256sums.txt.asc" + elif [ "$STRICT_VERIFY" = "1" ]; then + error "Failed to download sha256sums.txt.asc while strict verification is enabled." + else + warn "Skipping GPG signature verification: sha256sums.txt.asc was not available. Set CDIDX_STRICT_VERIFY=1 to fail closed." + fi + fi + + # Verify checksum / チェックサム検証 + info "Verifying checksum..." + local expected_checksum + expected_checksum="$(awk -v name="$archive_name" '$2 == name { print $1; exit }' "${tmpdir}/sha256sums.txt")" + + if [ -z "$expected_checksum" ]; then + error "Checksum for $archive_name not found in sha256sums.txt." + fi + + local actual_checksum + actual_checksum="$(calculate_sha256 "${tmpdir}/${archive_name}")" + + if [ "$actual_checksum" != "$expected_checksum" ]; then + error "Checksum mismatch!\n Expected: $expected_checksum\n Actual: $actual_checksum" + fi + + # Extract into a dedicated subdirectory so we don't mix extracted files + # with the downloaded archive/checksums when copying. + # 展開用サブディレクトリを使い、アーカイブや checksum ファイルと混在させない。 + local extract_dir="${tmpdir}/extract" + mkdir -p "$extract_dir" + info "Checking archive member paths..." + validate_archive_members "${tmpdir}/${archive_name}" + info "Extracting..." + tar xzf "${tmpdir}/${archive_name}" -C "$extract_dir" + info "Verifying extracted payload..." + verify_payload_manifest "$extract_dir" + + # Validate the extracted payload before copying anything into INSTALL_DIR. + # This avoids overwriting a healthy install with a partially broken one + # when the tarball is missing required files. + # INSTALL_DIR に何か書き込む前に展開済み payload 全体を検証する。 + # tarball の必須ファイルが欠けているときに、健全な install を + # 部分的に壊れた内容で上書きしないため。 + # + # Install runtime assets alongside the binary. Fail fast if any required + # asset is missing rather than silently installing a partially broken + # binary that will crash on first use. + # - cdidx loads version.json via AppContext.BaseDirectory (the binary's dir), + # so without it `cdidx --version` reports v0.0.0. + # - The native SQLite library (libe_sqlite3.so on Linux, libe_sqlite3.dylib + # on macOS) must live next to the binary for P/Invoke to resolve; without + # it every command crashes with DllNotFoundException at startup. + # Required assets are OS-specific, so we match on $OS_NAME instead of + # "copy whatever happens to be in the archive". This keeps the installer + # compatible with bash 3.2 (the default /bin/bash on macOS) — no arrays, + # no `mapfile`, no `find` — and works for all currently published tarballs. + # ランタイム資産をバイナリの隣へ配置する。必須資産が欠落している場合は、 + # 部分的に壊れたインストールを黙って進めず即時失敗させる(起動直後の + # クラッシュを防ぐため)。 + # - cdidx は AppContext.BaseDirectory(バイナリのディレクトリ)から + # version.json を読むため、これが無いと --version が v0.0.0 になる。 + # - ネイティブ SQLite ライブラリ(Linux は libe_sqlite3.so、macOS は + # libe_sqlite3.dylib)は P/Invoke 解決のためバイナリの隣に必要で、 + # 無いと起動直後に DllNotFoundException で全コマンドが落ちる。 + # 必須資産は OS ごとに異なるため「アーカイブにあるものを何でも」ではなく + # $OS_NAME で分岐する。macOS の既定 /bin/bash 3.2 でも動くよう、配列・ + # `mapfile`・`find` は使わず、現行リリースの tarball 配置前提で実装する。 + local required_assets + case "$OS_NAME" in + linux) required_assets="version.json libe_sqlite3.so" ;; + osx) required_assets="version.json libe_sqlite3.dylib" ;; + *) error "Internal error: unknown OS_NAME '$OS_NAME' for asset selection." ;; + esac + + # License, integration-policy, and trademark notices are shipped when + # present, but older mirrors may still lack them. Treat them as best-effort + # extras so we can keep supporting older release archives while ensuring new + # releases install the legal files that the release workflow now verifies. + # LICENSE / 統合ポリシー / 商用ライセンス / 商標の案内は存在すれば + # 一緒に配置するが、古い mirror にはまだ無い可能性があるため必須には + # しない。古い release archive を壊さず、新しい release では workflow + # が検証する法務ファイルを確実にインストールできるようにする。 + local required_files="${BINARY_NAME} ${required_assets}" + local optional_assets="LICENSE COMMERCIAL_LICENSE.md INTEGRATION_POLICY.md TRADEMARKS.md LICENSES" + local staged_assets="$required_assets" + local asset + for asset in $required_files; do + if [ ! -f "${extract_dir}/${asset}" ]; then + if [ "$asset" = "$BINARY_NAME" ]; then + error "Required release payload missing from tarball: ${asset}. Refusing to install a partially broken binary. Please report this at https://github.com/${REPO}/issues." + fi + + error "Required runtime asset missing from release tarball: ${asset}. Refusing to install a partially broken binary. Please report this at https://github.com/${REPO}/issues." + fi + done + + mkdir -p "$INSTALL_DIR" + + local stage_dir + if ! stage_dir="$(mktemp -d "${INSTALL_DIR}/.cdidx-stage.XXXXXX")"; then + error "Failed to create staging directory under ${INSTALL_DIR}." + fi + if ! chmod 700 "$stage_dir"; then + error "Failed to restrict staging directory permissions under ${INSTALL_DIR}." + fi + STAGE_DIR_CLEANUP="$stage_dir" + + for asset in $required_files; do + cp "${extract_dir}/${asset}" "${stage_dir}/${asset}" + done + for asset in $optional_assets; do + if [ -f "${extract_dir}/${asset}" ]; then + cp "${extract_dir}/${asset}" "${stage_dir}/${asset}" + elif [ -d "${extract_dir}/${asset}" ]; then + cp -R "${extract_dir}/${asset}" "${stage_dir}/${asset}" + fi + if [ -e "${stage_dir}/${asset}" ]; then + staged_assets="${staged_assets} ${asset}" + fi + done + write_integrity_version_json "${stage_dir}/version.json" + chmod +x "${stage_dir}/${BINARY_NAME}" + if ! verify_cdidx_binary "${stage_dir}/${BINARY_NAME}"; then + return 1 + fi + + local backup_dir + if ! backup_dir="$(mktemp -d "${INSTALL_DIR}/.cdidx-backup.XXXXXX")"; then + error "Failed to create backup directory under ${INSTALL_DIR}." + fi + BACKUP_DIR_CLEANUP="$backup_dir" + if ! chmod 0700 "$backup_dir"; then + error "Failed to restrict backup directory permissions under ${INSTALL_DIR}." + fi + + if ! promote_staged_install "$stage_dir" "$backup_dir" "$INSTALL_DIR" "$required_files" "$staged_assets"; then + return 1 + fi + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + if ! verify_cdidx_binary "${INSTALL_DIR}/${BINARY_NAME}"; then + return 1 + fi + + rm -rf "$stage_dir" + STAGE_DIR_CLEANUP="" + rm -rf "$backup_dir" + BACKUP_DIR_CLEANUP="" + + info "Installed cdidx to ${INSTALL_DIR}/${BINARY_NAME}" +} diff --git a/install_modules/30-path-guidance.sh b/install_modules/30-path-guidance.sh new file mode 100644 index 0000000000..4f648dd2cf --- /dev/null +++ b/install_modules/30-path-guidance.sh @@ -0,0 +1,130 @@ + +# --- PATH guidance / PATHガイダンス --- + +active_cdidx_path() { + command -v "$BINARY_NAME" 2>/dev/null || true +} + +list_path_cdidx_binaries() { + local old_ifs="$IFS" + local dir + IFS=: + for dir in ${PATH:-}; do + [ -n "$dir" ] || dir="." + if [ -x "${dir}/${BINARY_NAME}" ]; then + printf '%s\n' "${dir}/${BINARY_NAME}" + fi + done + IFS="$old_ifs" +} + +print_path_cdidx_versions() { + local binary + list_path_cdidx_binaries | while IFS= read -r binary; do + [ -n "$binary" ] || continue + printf ' %s -> %s\n' "$binary" "$("$binary" --version 2>/dev/null || printf '%s' "unavailable")" + done +} + +candidate_shell_profile() { + local shell_name + shell_name="$(basename "${SHELL:-/bin/bash}")" + case "$shell_name" in + zsh) printf '%s' "${HOME}/.zshrc" ;; + bash) + if [ -f "${HOME}/.bash_profile" ]; then + printf '%s' "${HOME}/.bash_profile" + else + printf '%s' "${HOME}/.bashrc" + fi + ;; + *) printf '%s' "${HOME}/.profile" ;; + esac +} + +append_path_to_shell_profile() { + local profile_path + profile_path="$(candidate_shell_profile)" + mkdir -p "$(dirname "$profile_path")" + + if [ -f "$profile_path" ] && grep -F "export PATH=\"${INSTALL_DIR}:\$PATH\"" "$profile_path" >/dev/null 2>&1; then + info "PATH export already present in ${profile_path}" + return 0 + fi + + { + printf '\n# Added by cdidx installer\n' + printf 'export PATH="%s:$PATH"\n' "$INSTALL_DIR" + } >> "$profile_path" + + info "Added ${INSTALL_DIR} to PATH in ${profile_path}" +} + +check_path() { + if [ "${SELF_TEST_LOCAL_MIRROR:-0}" = "1" ]; then + return 0 + fi + + local installed_bin="${INSTALL_DIR}/${BINARY_NAME}" + local active_bin + active_bin="$(active_cdidx_path)" + + case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + warn "${INSTALL_DIR} is not in your PATH." + echo "" + echo " Add it to your shell profile:" + echo "" + local shell_name + shell_name="$(basename "${SHELL:-/bin/bash}")" + case "$shell_name" in + zsh) + echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.zshrc" + echo " source ~/.zshrc" + ;; + bash) + echo " echo 'export PATH=\"${INSTALL_DIR}:\$PATH\"' >> ~/.bashrc" + echo " source ~/.bashrc" + ;; + fish) + echo " fish_add_path ${INSTALL_DIR}" + ;; + *) + echo " export PATH=\"${INSTALL_DIR}:\$PATH\"" + ;; + esac + echo "" + ;; + esac + + if [ "${CDIDX_INSTALL_UPDATE_PATH:-0}" = "1" ]; then + append_path_to_shell_profile + case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) PATH="${INSTALL_DIR}:${PATH}"; export PATH ;; + esac + else + echo " To let the installer update your shell profile, rerun with CDIDX_INSTALL_UPDATE_PATH=1." + echo "" + fi + + active_bin="$(active_cdidx_path)" + if [ -n "$active_bin" ] && [ "$active_bin" != "$installed_bin" ]; then + warn "The active cdidx on PATH is ${active_bin}, not the newly installed ${installed_bin}." + warn "An earlier PATH entry is shadowing the new install." + echo "" + echo " cdidx binaries found on PATH:" + print_path_cdidx_versions + echo "" + echo " Put ${INSTALL_DIR} before the earlier directory in PATH, or rerun with CDIDX_INSTALL_UPDATE_PATH=1." + echo "" + return 0 + fi + + if [ -n "$active_bin" ]; then + if ! "$active_bin" --version >/dev/null 2>&1; then + warn "The active cdidx at ${active_bin} failed to run --version. Check architecture and native runtime assets." + fi + fi +} diff --git a/install_modules/40-uninstall.sh b/install_modules/40-uninstall.sh new file mode 100644 index 0000000000..dcb3437427 --- /dev/null +++ b/install_modules/40-uninstall.sh @@ -0,0 +1,57 @@ + +uninstall_cdidx() { + info "cdidx uninstaller" + if ! validate_normal_install_dir; then + return 1 + fi + acquire_install_lock + + local removed=0 + local path + local cache_dir="" + if [ "$PURGE_CACHE_ON_UNINSTALL" = "1" ]; then + if ! cache_dir="$(resolve_purge_cache_dir)"; then + return 1 + fi + fi + + for path in \ + "${INSTALL_DIR}/${BINARY_NAME}" \ + "${INSTALL_DIR}/version.json" \ + "${INSTALL_DIR}/libe_sqlite3.so" \ + "${INSTALL_DIR}/libe_sqlite3.dylib" \ + "${INSTALL_DIR}/LICENSE" \ + "${INSTALL_DIR}/COMMERCIAL_LICENSE.md" \ + "${INSTALL_DIR}/INTEGRATION_POLICY.md" \ + "${INSTALL_DIR}/TRADEMARKS.md" \ + "${INSTALL_DIR}/MANIFEST.sha256"; do + if [ -e "$path" ]; then + rm -f "$path" + info "Removed ${path}" + removed=1 + fi + done + + if [ -d "${INSTALL_DIR}/LICENSES" ]; then + rm -rf "${INSTALL_DIR}/LICENSES" + info "Removed ${INSTALL_DIR}/LICENSES" + removed=1 + fi + + if [ "$PURGE_CACHE_ON_UNINSTALL" = "1" ]; then + if [ -d "$cache_dir" ]; then + rm -rf "$cache_dir" + info "Removed ${cache_dir}" + removed=1 + fi + fi + + if [ "$removed" = "0" ]; then + warn "No cdidx install files were found under ${INSTALL_DIR}." + fi + + echo "" + info "Uninstall complete." + echo "Not removed: project-local .cdidx/ directories, shell profile PATH edits, shell completion scripts, or global-tool installs managed by dotnet/Homebrew." + echo "To remove cached update metadata too, rerun with --uninstall --purge-cache." +} diff --git a/install_modules/50-self-test.sh b/install_modules/50-self-test.sh new file mode 100644 index 0000000000..94810d483b --- /dev/null +++ b/install_modules/50-self-test.sh @@ -0,0 +1,153 @@ + +report_local_mirror_start_failure() { + local local_mirror_port="$1" + local local_mirror_log="$2" + + report_error "Local mirror self-test could not start a loopback HTTP server on 127.0.0.1:${local_mirror_port}." + report_error "This is a self-test harness failure, not an external network/proxy problem." + if [ -f "$local_mirror_log" ]; then + report_error "Local mirror log tail (${local_mirror_log}):" + if command -v tail > /dev/null 2>&1; then + tail -n 20 "$local_mirror_log" >&2 || true + else + cat "$local_mirror_log" >&2 || true + fi + + if grep -qi 'Address already in use' "$local_mirror_log"; then + error "Local mirror self-test aborted because 127.0.0.1:${local_mirror_port} is already in use. Set CDIDX_LOCAL_MIRROR_PORT to a free port." + fi + + if grep -Eqi 'PermissionError|Operation not permitted|Permission denied' "$local_mirror_log"; then + error "Local mirror self-test aborted because this environment does not permit binding a loopback TCP port. Run it in a less-restricted shell or use a pre-hosted mirror." + fi + fi + error "Local mirror self-test aborted before download. Check the local mirror error above." +} + +wait_for_local_mirror_ready() { + local ready_url="$1" + local local_mirror_port="$2" + local local_mirror_log="$3" + local attempt=0 + local http_code="" + + while [ "$attempt" -lt 5 ]; do + if ! kill -0 "$LOCAL_MIRROR_PID" > /dev/null 2>&1; then + report_local_mirror_start_failure "$local_mirror_port" "$local_mirror_log" + fi + + http_code="$(run_curl_with_optional_loopback_bypass "$ready_url" -sS -o /dev/null -w '%{http_code}' "$ready_url" 2>/dev/null || true)" + if [ "$http_code" = "200" ]; then + return 0 + fi + + attempt=$((attempt + 1)) + sleep 1 + done + + report_local_mirror_start_failure "$local_mirror_port" "$local_mirror_log" +} + +run_local_mirror_self_test() { + need_cmd curl + need_cmd python3 + need_cmd tar + need_cmd mktemp + need_cmd awk + need_cmd sleep + + detect_platform + + local rehearsal_version="${1:-$(default_self_test_version)}" + case "$rehearsal_version" in + v*) ;; + *) rehearsal_version="v${rehearsal_version}" ;; + esac + local rehearsal_version_no_prefix="${rehearsal_version#v}" + local local_mirror_port="${CDIDX_LOCAL_MIRROR_PORT:-18765}" + local local_mirror_root + local local_release_base + local local_payload_dir + local local_mirror_log + local local_mirror_base_url + local self_test_install_dir="" + local archive_name="CodeIndex-${RID}.tar.gz" + local runtime_asset + local checksum + + case "$OS_NAME" in + linux) runtime_asset="libe_sqlite3.so" ;; + osx) runtime_asset="libe_sqlite3.dylib" ;; + *) error "Internal error: unknown OS_NAME '$OS_NAME' for local mirror self-test." ;; + esac + + if ! local_mirror_root="$(mktemp -d /tmp/cdidx-local-mirror.XXXXXX)"; then + error "Failed to create local mirror directory for self-test." + fi + LOCAL_MIRROR_DIR_CLEANUP="$local_mirror_root" + local_mirror_log="${local_mirror_root}/local-mirror.log" + + local_release_base="${local_mirror_root}/${REPO}/releases/download/${rehearsal_version}" + local_payload_dir="${local_release_base}/payload" + mkdir -p "$local_payload_dir" + + cat > "${local_payload_dir}/${BINARY_NAME}" <&2 +exit 2 +EOF + chmod +x "${local_payload_dir}/${BINARY_NAME}" + printf '{"version":"%s","integrity_ok":true}\n' "$rehearsal_version_no_prefix" > "${local_payload_dir}/version.json" + : > "${local_payload_dir}/${runtime_asset}" + + ( + cd "$local_payload_dir" + { + calculate_sha256 "${BINARY_NAME}" | awk -v file="${BINARY_NAME}" '{ print $1 " " file }' + calculate_sha256 version.json | awk '{ print $1 " version.json" }' + calculate_sha256 "${runtime_asset}" | awk -v file="${runtime_asset}" '{ print $1 " " file }' + } > .MANIFEST.sha256.tmp + mv .MANIFEST.sha256.tmp MANIFEST.sha256 + tar czf "../${archive_name}" MANIFEST.sha256 "${BINARY_NAME}" version.json "${runtime_asset}" + ) + + checksum="$(calculate_sha256 "${local_release_base}/${archive_name}")" + printf '%s %s\n' "$checksum" "$archive_name" > "${local_release_base}/sha256sums.txt" + + if has_explicit_self_test_install_dir; then + if is_self_test_install_dir_risky "$INSTALL_DIR" && [ "${SELF_TEST_ALLOW_OVERWRITE:-0}" != "1" ]; then + report_error "CDIDX_INSTALL_DIR=\"$INSTALL_DIR\" points at a real install path; refusing to run the mock self-test there." + report_error "The self-test installs a mock cdidx that only handles --version, which would silently break the real binary." + report_error "Unset CDIDX_INSTALL_DIR to run the self-test in an isolated temp dir, or pass --self-test-allow-overwrite if you truly want to inspect the mock layout in place." + error "Local mirror self-test aborted to protect an existing install at ${INSTALL_DIR}." + fi + else + if ! self_test_install_dir="$(mktemp -d /tmp/cdidx-self-test-install.XXXXXX)"; then + error "Failed to create isolated install directory for local mirror self-test." + fi + SELF_TEST_INSTALL_DIR_CLEANUP="$self_test_install_dir" + INSTALL_DIR="$self_test_install_dir" + fi + + python3 -m http.server "$local_mirror_port" --bind 127.0.0.1 --directory "$local_mirror_root" > "$local_mirror_log" 2>&1 & + LOCAL_MIRROR_PID=$! + local_mirror_base_url="http://127.0.0.1:${local_mirror_port}" + prepare_loopback_no_proxy_env + wait_for_local_mirror_ready "${local_mirror_base_url}/${REPO}/releases/download/${rehearsal_version}/${archive_name}" "$local_mirror_port" "$local_mirror_log" + + info "Running local mirror self-test against ${local_mirror_base_url}/" + if has_explicit_self_test_install_dir; then + info "Using explicit self-test install dir: ${INSTALL_DIR}" + else + info "Using isolated self-test install dir: ${INSTALL_DIR}" + fi + SELF_TEST_LOCAL_MIRROR=1 + GITHUB_BASE_URL="${local_mirror_base_url}" + main "$rehearsal_version" + "${INSTALL_DIR}/${BINARY_NAME}" --version + info "Local mirror self-test passed." +} diff --git a/install_modules/60-reinstall.sh b/install_modules/60-reinstall.sh new file mode 100644 index 0000000000..b5f3d49ddc --- /dev/null +++ b/install_modules/60-reinstall.sh @@ -0,0 +1,307 @@ + +# Download the real release for the requested version into an isolated temp +# dir and exercise the installed binary end-to-end (--version + cdidx . --db). +# Never writes to the user's real install location, even if CDIDX_INSTALL_DIR +# is set — validation must not carry the risk of clobbering a working install. +# 実リリースを隔離された temp dir にダウンロードし、`cdidx --version` と +# 最小プロジェクトに対する `cdidx . --db ` 実行まで行う。CDIDX_INSTALL_DIR +# が設定されていても、ユーザーの実インストールには絶対に書き込まない。 +run_reinstall_real() { + local version="${1:-}" + if [ -z "$version" ]; then + error "--reinstall-real requires a version argument (e.g. v1.5.0)." + fi + case "$version" in + v*) ;; + *) version="v${version}" ;; + esac + + need_cmd curl + need_cmd tar + need_cmd mktemp + + detect_platform + + # Always install to an isolated temp dir. CDIDX_INSTALL_DIR is ignored + # here on purpose: a validation mode must never risk replacing a working + # real install with a freshly-downloaded build that turns out to be broken. + # CDIDX_INSTALL_DIR は無視する。検証モードは実インストールを上書きしない。 + local reinstall_dir + if ! reinstall_dir="$(mktemp -d /tmp/cdidx-reinstall-real.XXXXXX)"; then + error "Failed to create isolated install directory for --reinstall-real." + fi + SELF_TEST_INSTALL_DIR_CLEANUP="$reinstall_dir" + INSTALL_DIR="$reinstall_dir" + + info "Real reinstall validation: installing ${version} into isolated dir ${INSTALL_DIR}" + + # Signal main() to skip the trailing "quick start" banner; this is a + # validation run, not a user-facing install. + # main() の "quick start" バナーを抑止する。 + SELF_TEST_LOCAL_MIRROR=1 + main "$version" + + local reinstall_cdidx="${INSTALL_DIR}/${BINARY_NAME}" + if [ ! -x "$reinstall_cdidx" ]; then + error "Real reinstall validation: installed binary not found at ${reinstall_cdidx}." + fi + + info "Verifying ${BINARY_NAME} --version" + local reinstall_version_output + if ! reinstall_version_output="$("$reinstall_cdidx" --version 2>&1)"; then + error "Real reinstall validation: ${BINARY_NAME} --version failed." + fi + printf '%s\n' "$reinstall_version_output" + local reinstall_expected_version="${version#v}" + # Extract every v token in the output and require that the only + # distinct token present equals v. A plain "contains the + # requested version" check false-passes mixed output such as + # "warning: requested v1.2.3 not installed; running v9.9.9", because the + # requested tag appears in a diagnostic while a different version is + # actually running. Enumerating all tokens also catches right-side + # boundary violations (e.g. v1.2.30 captures as v1.2.30, which is not + # equal to v1.2.3) and suffix mismatches (e.g. v1.2.3 vs v1.2.3-rc.1). + # `grep -oE` alone has no left-boundary awareness, so `prefixv1.2.3` + # would still extract `v1.2.3` and silently pass; awk's match() lets us + # reject any candidate whose preceding character is itself an identifier + # char (`[A-Za-z0-9._+-]`, the same class used for the right-side suffix + # capture). POSIX awk's match() / RSTART / RLENGTH are supported on both + # macOS (BSD awk) and Linux (gawk / mawk) so this stays portable. + # ミラー取り違えや version.json ずれ、診断文に要求タグが紛れ込むケースを + # silent pass させないため、出力中の v token を全て抽出し、 + # 唯一の値が v<要求版> と一致することを検証する。`grep -oE` だけでは + # `prefixv1.2.3` の左境界違反が素通りするため、awk の match() で直前文字が + # 識別子クラスなら棄却する。POSIX awk の RSTART/RLENGTH は BSD awk・gawk・ + # mawk すべて対応しているためポータブル。 + local reinstall_version_output_for_token_check="$reinstall_version_output" + case "$reinstall_version_output_for_token_check" in + *" [A newer release is available: v"*"]") + reinstall_version_output_for_token_check="${reinstall_version_output_for_token_check% \[A newer release is available: v*\]}" + ;; + esac + local reinstall_found_versions + reinstall_found_versions="$(printf '%s\n' "$reinstall_version_output_for_token_check" \ + | awk '{ + line = $0 + while (match(line, /v[0-9]+\.[0-9]+\.[0-9]+([A-Za-z0-9._+-]*)?/)) { + if (RSTART == 1 || substr(line, RSTART - 1, 1) !~ /[A-Za-z0-9._+-]/) + print substr(line, RSTART, RLENGTH) + line = substr(line, RSTART + RLENGTH) + } + }' \ + | sort -u || true)" + if [ "$reinstall_found_versions" != "v${reinstall_expected_version}" ]; then + error "Real reinstall validation: expected exactly one version token v${reinstall_expected_version} in output, got: ${reinstall_version_output:-}." + fi + # Token enumeration alone still false-passes a diagnostic-only output + # whose single extracted token happens to equal the requested tag but + # does not represent the binary's own reported version, e.g. + # "warning: expected package v1.2.3" or "see /releases/v1.2.3/notes". + # Real `cdidx --version` output is exactly one non-empty line that + # starts with `cdidx v` and, since #1550, optionally ends with a + # parenthesized build-metadata block `(commit , built , + # )`, followed by the exact #1626 update-check hint when + # the validated version is older than the latest release. No bare + # trailing text is permitted. Require two + # invariants: + # (a) EXACTLY one non-empty line in the output, rejecting multi-line + # shapes such as `cdidx v1.2.3\nwarning: expected package v1.2.3 + # missing` where the first line is exact but a trailing diagnostic + # line slips through the token enumeration with the same single + # distinct version token. + # (b) That single non-empty line EITHER EXACTLY equals `${BINARY_NAME} + # v` OR equals `${BINARY_NAME} v ()`, with an optional exact update-check hint suffix. + # Trailing-diagnostic shapes such as + # `cdidx v1.2.3 warning: expected package missing` (no parens + # around the trailing text) are rejected. + # single-token の診断文だけで silent pass しないよう、`cdidx --version` の + # 出力全体が 1 行の非空行で、その行が `${BINARY_NAME} v<要求版>` か + # `${BINARY_NAME} v<要求版> ()` に、必要なら #1626 の定型 + # update-check hint が続く形と完全一致することを要求する(#1550 以降、末尾に + # 括弧で囲ったメタデータが付くケースを許容する)。末尾に括弧無しの診断文が続く + # `cdidx v1.2.3 warning: ...` や、 + # 先頭行の後に診断行が続く `cdidx v1.2.3\nwarning: ...` のような shape は + # これで弾く。 + local reinstall_nonempty_line_count + reinstall_nonempty_line_count="$(printf '%s\n' "$reinstall_version_output" | awk 'NF { count++ } END { print count + 0 }')" + if [ "$reinstall_nonempty_line_count" != "1" ]; then + error "Real reinstall validation: ${BINARY_NAME} --version must emit exactly one non-empty line but got ${reinstall_nonempty_line_count} non-empty lines: ${reinstall_version_output:-}." + fi + local reinstall_first_version_line + reinstall_first_version_line="$(printf '%s\n' "$reinstall_version_output" | awk 'NF { print; exit }')" + local reinstall_version_head="${BINARY_NAME} v${reinstall_expected_version}" + local reinstall_version_line_core="$reinstall_first_version_line" + case "$reinstall_first_version_line" in + *" [A newer release is available: v"*"]") + reinstall_version_line_core="${reinstall_first_version_line% \[A newer release is available: v*\]}" + ;; + esac + local reinstall_version_line_ok=0 + if [ "$reinstall_version_line_core" = "$reinstall_version_head" ]; then + reinstall_version_line_ok=1 + else + case "$reinstall_version_line_core" in + "${reinstall_version_head} ("*")") + reinstall_version_line_ok=1 + ;; + esac + fi + if [ "$reinstall_version_line_ok" != "1" ]; then + error "Real reinstall validation: first non-empty line of ${BINARY_NAME} --version must be exactly '${reinstall_version_head}', '${reinstall_version_head} ()', or either form with the standard update hint suffix but got: ${reinstall_first_version_line:-}." + fi + + # Build a tiny scratch project and exercise `cdidx . --db ` so that + # the validation covers the real indexing path (symbol extraction, SQLite + # FTS5, version.json load, native SQLite lib load). --self-test-local-mirror's + # mock only handles --version, so regressions in those paths are invisible there. + # 最小プロジェクトで `cdidx . --db ` を走らせ、シンボル抽出・FTS5・ + # version.json ロード・ネイティブ SQLite ロードまで通ることを確認する。 + local scratch_project + if ! scratch_project="$(mktemp -d /tmp/cdidx-reinstall-scratch.XXXXXX)"; then + error "Failed to create scratch project for --reinstall-real." + fi + REINSTALL_SCRATCH_CLEANUP="$scratch_project" + + cat > "${scratch_project}/sample.py" <<'PY' +def greet(name): + return f"hello {name}" + + +def main(): + print(greet("world")) + + +if __name__ == "__main__": + main() +PY + + local scratch_db="${scratch_project}/.cdidx/codeindex.db" + info "Running ${BINARY_NAME} . --db ${scratch_db} against scratch project" + if ! "$reinstall_cdidx" "$scratch_project" --db "$scratch_db"; then + error "Real reinstall validation: ${BINARY_NAME} could not index a scratch project." + fi + if [ ! -s "$scratch_db" ]; then + error "Real reinstall validation: ${BINARY_NAME} did not produce a populated index DB at ${scratch_db}." + fi + + # Human-readable output covers the default user path. Current trimmed + # releases are expected to support --json via source-generated CLI DTOs; + # JsonOutputFailure is only a fallback for old/custom binaries that miss + # serializer coverage. + # 人間向け出力で既定のユーザー経路を検証する。現在の公式 trimmed release は + # source-generated CLI JSON DTO により --json が動作する前提で、exit 4 は + # serializer 登録が欠けた古い/カスタムバイナリ向けの fallback。 + info "Running ${BINARY_NAME} search greet --db ${scratch_db} to verify FTS" + local reinstall_search_output + if ! reinstall_search_output="$("$reinstall_cdidx" search greet --db "$scratch_db" 2>&1)"; then + error "Real reinstall validation: ${BINARY_NAME} search returned a non-zero exit code." + fi + # Require a structured match block anchored at the scratch file path AND + # the verbatim source-code signature `def greet(name):` from the scratch + # sample.py appearing as an EXACT full-line match inside that block. + # A successful human-readable search prints: + # sample.py:1-6 + # def greet(name): + # return f"hello {name}" + # with a strict path-range header (no trailing text) at column 0 and the + # first snippet line indented with exactly two spaces followed by the + # real Python source line. The optional single-line "grep-like" form is + # `path:line:code` with a colon immediately after the line number and + # nothing between the colon and the source. Earlier iterations matched + # any header starting with `^sample\.py:[0-9]` and accepted any `greet` + # / `def greet` / `def greet(name):` substring inside the line, so + # adversarial shapes such as + # sample.py:1: warning: expected code signature def greet(name): missing (grep-header diagnostic carrying the verbatim signature as a substring) + # sample.py:1-6\n warning: expected code signature def greet(name): missing (indented diagnostic carrying the verbatim signature as a substring) + # sample.py:1-6\n warning: no matches\n def greet(name): (non-adjacent indented signature after a decoy diagnostic) + # could false-pass even though no real FTS hit had occurred. The + # state machine below enforces exact-line semantics so any line that + # only embeds the verbatim signature as a substring of a longer + # diagnostic, or that appears in the block after a non-matching + # indented line, is rejected. The state machine: + # 1. Accepts the single-line grep form only when the entire line is + # exactly `sample.py::def greet(name):` (end anchored — no + # trailing diagnostic prose, no space between the colon and the + # source signature). + # 2. Enters block mode only on a strict range-form header + # `^sample\.py:[0-9]+-[0-9]+$` (no trailing text) and arms a + # one-shot "expect the first indented snippet line" flag. + # 3. Inside an armed block, accepts only a line that is exactly + # ` def greet(name):` (two-space indent + the verbatim source + # signature + nothing else). The flag is consumed on the first + # two-space-indented line, so a later indented line that happens + # to carry the signature is rejected. + # 4. Any other line (blank line, one-space line, non-indented + # diagnostic, `(N results in M files)` summary footer, an + # unrelated header) clears the flag, so the block is abandoned + # the moment the expected adjacency is broken. + # 構造化ヘッダ(厳密な grep 形 `^sample\.py:[0-9]+:` または末尾アンカー付き + # 範囲形 `^sample\.py:[0-9]+-[0-9]+$`)と、同じ match block 内で scratch の + # sample.py の実ソース行 `def greet(name):` を full-line で要求する 1 つの + # awk 状態機械。grep 形ではヘッダ行自体を `sample.py::def greet(name):` + # に完全一致させ(コロン直後に診断文も空白も許さない)、範囲形ではヘッダ + # 直後の 1 行目が exactly ` def greet(name):` であることを要求する + # one-shot フラグを立てる。block 内で最初の 2 スペースインデント行が完全 + # 一致しなければフラグを消費して block を諦めるため、途中に decoy の + # 診断行を挟んで signature 行を後置するシェイプも弾ける。`def greet + # missing` のような「def greet を含むが引数リストを伴わない」診断文も、 + # `def greet(name): missing` のように verbatim な署名を substring として + # 埋め込んだ診断文も、両方とも完全一致を外すため false-pass しない。 + if ! printf '%s\n' "$reinstall_search_output" | awk ' + /^sample\.py:[0-9]+:def greet\(name\):$/ { + # Strict grep form: entire line must be exactly + # `sample.py::def greet(name):`. Anchors both ends so a + # diagnostic like `sample.py:1: warning: ... def greet(name): + # missing` is rejected even though it contains the verbatim + # signature as a substring. + # 厳密な grep 形。行全体を `sample.py::def greet(name):` に + # 完全一致させ、末尾に診断文が付くシェイプや、コロンと署名の間に + # 空白が入るシェイプを弾く。 + found = 1 + exit 0 + } + /^sample\.py:[0-9]+-[0-9]+$/ { + # Strict range-form header — no trailing text. Arm the + # one-shot "expect first indented line to be the verbatim + # signature" flag; the first `^ /` line we see under this + # header will either match exactly or consume the flag and + # cause the block to be abandoned. + # 厳密な range 形ヘッダ。末尾の余計なテキストを許さず、block + # モードに入って「直後の 1 行目が exactly ` def greet(name):` + # であるべき」という one-shot フラグを立てる。最初の + # 2 スペースインデント行で flag を消費して完全一致を判定する。 + expect_first_indent = 1 + next + } + /^ / { + # First two-space-indented line under an armed range header + # must equal exactly ` def greet(name):`. Any other indent + # (even one that later happens to carry the verbatim + # signature) consumes the flag and kills the block. + # 範囲形ヘッダ直後の最初の 2 スペースインデント行は exactly + # ` def greet(name):` でなければならない。それ以外(途中に + # 署名を後置するシェイプも含む)は flag を消費して block を + # 放棄する。 + if (expect_first_indent) { + expect_first_indent = 0 + if ($0 == " def greet(name):") { + found = 1 + exit 0 + } + } + next + } + # Any other line (blank, one-space, non-indented diagnostic, + # unrelated header, footer) clears the adjacency flag so the + # block is abandoned the moment adjacency is broken. + # その他の行(空行・1 スペース行・非インデント診断行・無関係な + # ヘッダ・フッタ)は隣接フラグを落として block を放棄する。 + { expect_first_indent = 0 } + END { exit (found ? 0 : 1) } + '; then + error "Real reinstall validation: ${BINARY_NAME} search did not return a structured match block at sample.py whose first snippet line is the exact verbatim scratch-source signature 'def greet(name):'. Output: ${reinstall_search_output:-}." + fi + + info "Real reinstall validation passed for ${version}." +} diff --git a/install_modules/70-doctor.sh b/install_modules/70-doctor.sh new file mode 100644 index 0000000000..6d2a3b3261 --- /dev/null +++ b/install_modules/70-doctor.sh @@ -0,0 +1,220 @@ + +# Probe a single URL for the doctor diagnostic. Prints HTTP status on success, +# surfaces CONNECT-tunnel 403 with the canonical upstream-proxy guidance, and +# returns 0 iff curl exited cleanly AND the response code was 2xx/3xx. +# This path uses HEAD (`-I`) because the doctor is about reachability, not +# content, and so a multi-MB release tarball does not need to be downloaded. +# doctor 用の URL probe。curl が 0 で終了し、かつ HTTP ステータスが 2xx/3xx の +# ときだけ 0 を返す。CONNECT-tunnel 403 (curl exit 56) を検知したら定型の +# 上流 proxy ガイダンスを出す。reachability 確認が目的なので HEAD (`-I`) を +# 使い、数 MB のリリース tarball を実ダウンロードしない。 +probe_doctor_url() { + local url="$1" + local label="$2" + info "Probing ${label}: ${url}" + + local curl_stderr + if ! curl_stderr="$(mktemp)"; then + report_error "${label}: failed to create curl stderr capture." + return 1 + fi + + local http_code="" + local curl_status=0 + # Run curl in a conditional context so `set -e` does not abort the script + # on a non-zero curl exit; we want to inspect curl_status and surface a + # doctor-specific error, not die here. + # `set -e` 下で curl の失敗時にスクリプトを中断させないよう条件文脈で呼ぶ。 + # curl_status を読んで doctor 専用のエラーメッセージに変換する。 + if http_code="$(run_curl_with_optional_loopback_bypass "$url" -sSI -o /dev/null -w '%{http_code}' "$url" 2>"$curl_stderr")"; then + curl_status=0 + else + curl_status=$? + fi + + local stderr_text="" + if [ -f "$curl_stderr" ]; then + stderr_text="$(read_bounded_file_sample "$curl_stderr" "$CURL_STDERR_SAMPLE_BYTES" "curl stderr for ${label}")" + rm -f "$curl_stderr" + fi + + if [ "$curl_status" -eq 0 ]; then + info "Result: HTTP ${http_code}" + case "$http_code" in + 2??|3??) return 0 ;; + esac + report_error "${label}: HTTP ${http_code} is not a 2xx/3xx response; release reachability is not confirmed." + return 1 + fi + + if [ "$curl_status" -eq 56 ] && is_proxy_tunnel_403 "$stderr_text"; then + if [ -n "$stderr_text" ]; then + printf '%s\n' "$stderr_text" >&2 + fi + report_error "${label}: CONNECT tunnel failed with HTTP 403 (curl exit 56). This deny is happening in an upstream proxy/egress policy before TLS." + report_error "Route substitution alone will not fix it." + report_error "Ask your network administrator to allow-list at least one artifact host path, or point CDIDX_GITHUB_BASE_URL / CDIDX_GITHUB_API_BASE_URL at a reachable internal mirror." + return 1 + fi + + if [ -n "$stderr_text" ]; then + printf '%s\n' "$stderr_text" >&2 + fi + case "$curl_status" in + 6|7|28|35|52|56) + report_error "${label}: network error (curl exit ${curl_status}) while reaching ${url}. Check your connection, proxy, or configured mirror." + ;; + *) + report_error "${label}: curl exit ${curl_status} while reaching ${url}." + ;; + esac + return 1 +} + +# Redact the userinfo portion of a proxy URL so credentials in values such as +# `http://user:password@proxy:8080` do not get printed into logs, issue +# attachments, or support transcripts when users share `--doctor` output. +# Handles `scheme://user@host` and `scheme://user:password@host`, leaves +# credential-less URLs and non-URL values untouched, and preserves the rest of +# the URL so the host/port is still visible for diagnosing reachability. +# `http://user:password@proxy:8080` のような proxy URL の資格情報部分を +# redact し、`--doctor` の出力を log / issue / サポート窓口に貼っても秘密が +# 漏れないようにする。`scheme://user@host` / `scheme://user:password@host` の +# 両形を処理し、資格情報を含まない URL や URL 以外の値はそのまま返す。 +# host/port は reachability 診断のため保持する。 +redact_proxy_userinfo() { + local value="$1" + case "$value" in + *://*@*) + local scheme="${value%%://*}" + local rest="${value#*://}" + local hostpart="${rest#*@}" + printf '%s://@%s' "$scheme" "$hostpart" + ;; + *) + printf '%s' "$value" + ;; + esac +} + +# Print the active proxy environment so users can see what curl will inherit +# before the probes run. This is the first thing the doctor prints because +# misconfigured proxy env vars are the single most common cause of CONNECT +# tunnel 403 / network-policy-style failures. Values are routed through +# `redact_proxy_userinfo` so embedded credentials never surface in the output. +# curl に引き継がれる proxy 系環境変数を probe 前に表示する。 +# 誤った proxy 設定は CONNECT 403 系の失敗原因として最も多いため最初に出す。 +# 出力は `redact_proxy_userinfo` を通し、URL 中の資格情報が漏れないようにする。 +print_doctor_proxy_env() { + info "Proxy environment variables (inherited by curl; URL credentials redacted):" + local var val redacted + for var in HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy; do + # Use `printenv` instead of bash indirection so an unset variable + # under `set -u` does not abort the function. + # `set -u` 下で未設定変数を参照して落ちないよう `printenv` を使う。 + val="$(printenv "$var" 2>/dev/null || true)" + if [ -n "$val" ]; then + redacted="$(redact_proxy_userinfo "$val")" + printf ' %s=%s\n' "$var" "$redacted" + else + printf ' %s=(unset)\n' "$var" + fi + done +} + +# Network diagnostics for the installer's upstream URLs. Does not install +# anything and never writes outside /tmp. Exits 0 when every probe is reachable +# (2xx/3xx), 1 otherwise. See `is_proxy_tunnel_403` for the CONNECT-403 +# advisory path used by all probes. +# installer が叩く upstream URL のネットワーク診断。インストールはしない。 +# 全 probe が reachability を確認できたら exit 0、それ以外は exit 1。 +# CONNECT 403 系の定型ガイダンスは `is_proxy_tunnel_403` を使い全 probe で共有する。 +run_doctor() { + local version="${1:-}" + + need_cmd curl + need_cmd mktemp + + detect_platform + + info "cdidx installer doctor" + info "Detected platform: ${RID}" + + # Resolve a probe version without requiring network access: explicit + # argument first, then version.json alongside this script; fall back to + # "no version" if neither is available so the API probe still runs and + # the user gets a useful diagnostic instead of a hard abort. + # probe 用バージョン解決。引数 -> version.json -> なし の順で、 + # どれも無ければ API probe だけでも走らせて診断情報を出す。 + local probe_version="" + local probe_version_source="" + if [ -n "$version" ]; then + case "$version" in + v*) probe_version="$version" ;; + *) probe_version="v${version}" ;; + esac + probe_version_source="explicit argument" + else + local resolved + resolved="$(default_self_test_version)" + if [ -n "$resolved" ] && [ "$resolved" != "v0.0.0" ]; then + probe_version="$resolved" + probe_version_source="version.json" + fi + fi + + if [ -n "$probe_version" ]; then + info "Probing version: ${probe_version} (${probe_version_source})" + else + info "Probing version: unknown (no explicit version and no version.json). Only the latest-release API probe will run." + fi + + print_doctor_proxy_env + + local api_url + api_url="$(latest_release_api_url)" + local api_label + api_label="$(latest_release_api_diagnostic_label)" + local api_status=0 + probe_doctor_url "$api_url" "$api_label" || api_status=$? + + local asset_url="" + local asset_status=0 + local checksums_url="" + local checksums_status=0 + if [ -n "$probe_version" ]; then + local release_label + release_label="$(release_host_diagnostic_label)" + local base_url="${GITHUB_BASE_URL}/${REPO}/releases/download/${probe_version}" + asset_url="${base_url}/CodeIndex-${RID}.tar.gz" + checksums_url="${base_url}/sha256sums.txt" + probe_doctor_url "$asset_url" "${release_label} (release asset ${probe_version})" || asset_status=$? + probe_doctor_url "$checksums_url" "${release_label} (sha256sums for ${probe_version})" || checksums_status=$? + fi + + info "Doctor summary:" + printf ' API probe: %s\n' "$(format_doctor_probe_status "$api_status")" + if [ -n "$probe_version" ]; then + printf ' Release asset probe: %s\n' "$(format_doctor_probe_status "$asset_status")" + printf ' Checksums probe: %s\n' "$(format_doctor_probe_status "$checksums_status")" + else + printf ' Release asset probe: skipped (no version)\n' + printf ' Checksums probe: skipped (no version)\n' + fi + + if [ "$api_status" -ne 0 ] || [ "$asset_status" -ne 0 ] || [ "$checksums_status" -ne 0 ]; then + report_error "Doctor detected at least one unreachable endpoint. See the probe output above for the specific failure and next step." + return 1 + fi + + info "Doctor: all probed endpoints are reachable." + return 0 +} + +format_doctor_probe_status() { + if [ "$1" -eq 0 ]; then + printf '%s' "reachable" + else + printf '%s' "FAILED (see probe output above)" + fi +} diff --git a/install_modules/90-dispatch.sh b/install_modules/90-dispatch.sh new file mode 100644 index 0000000000..e47adc87da --- /dev/null +++ b/install_modules/90-dispatch.sh @@ -0,0 +1,115 @@ + +# --- Main / メイン --- + +main() { + info "cdidx installer" + if [ "${SELF_TEST_LOCAL_MIRROR:-0}" != "1" ]; then + if ! validate_normal_install_dir; then + exit 1 + fi + fi + detect_platform + info "Detected platform: ${RID}" + acquire_install_lock + detect_existing_install + if ! resolve_version "${1:-}"; then + exit 1 + fi + check_existing + download_and_install + check_path + + if [ "${SELF_TEST_LOCAL_MIRROR:-0}" = "1" ]; then + return 0 + fi + + echo "" + info "Done! Run 'cdidx --version' to verify." + echo "" + echo " Quick start:" + echo " cdidx . # Index current directory" + echo " cdidx search # Search your code" + echo " cdidx mcp # Start MCP server for AI tools" + echo "" +} + +if [ "${CDIDX_INSTALL_SH_LIB_ONLY:-0}" = "1" ]; then + apply_verification_policy +else + while [ $# -gt 0 ]; do + case "${1:-}" in + --strict-verify) + STRICT_VERIFY=1 + shift + ;; + --verify-policy) + if [ $# -lt 2 ]; then + error "--verify-policy requires a value: compat or strict." + fi + VERIFY_POLICY="$2" + shift 2 + ;; + --verify-policy=*) + VERIFY_POLICY="${1#--verify-policy=}" + shift + ;; + *) + break + ;; + esac + done + apply_verification_policy + + case "${1:-}" in + --self-test-local-mirror) + shift + while [ $# -gt 0 ]; do + case "$1" in + --self-test-allow-overwrite) + SELF_TEST_ALLOW_OVERWRITE=1 + shift + ;; + --*) + error "Unknown self-test option: $1" + ;; + *) + break + ;; + esac + done + run_local_mirror_self_test "${1:-}" + ;; + --reinstall-real) + shift + if [ $# -eq 0 ]; then + error "--reinstall-real requires a version argument (e.g. v1.5.0)." + fi + run_reinstall_real "$1" + ;; + --doctor) + shift + run_doctor "${1:-}" + ;; + --uninstall) + shift + while [ $# -gt 0 ]; do + case "$1" in + --purge-cache) + PURGE_CACHE_ON_UNINSTALL=1 + shift + ;; + --*) + error "Unknown uninstall option: $1" + ;; + *) + error "--uninstall does not accept a version argument." + ;; + esac + done + uninstall_cdidx + ;; + *) + main "$@" + ;; + esac +fi diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 8c88c62bcd..82d9bbc9ac 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -192,7 +192,7 @@ internal static class CliFlagSchema [ "index", "backfill-fold", "optimize", "vacuum", "search", "definition", "goto", "references", "callers", "callees", "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", - "validate", "deps", "impact", "unused", "hotspots", "suggestions", "languages", "db", "report", + "validate", "deps", "impact", "unused", "hotspots", "suggestions", "languages", "db", "report", "upgrade", ]; private static readonly string[] CompactJsonCommands = ["map", "inspect", "outline"]; @@ -329,8 +329,9 @@ private static IReadOnlyList BuildAll() new() { Name = "--log-path", Description = "Print the active persistent log directory", Commands = Set("status") }, new() { Name = "--check-updates", Description = "Check whether a newer cdidx release is available", Commands = Set("status", "upgrade") }, new() { Name = "--check-only", Description = "Upgrade: only report whether an upgrade is available", Commands = Set("upgrade") }, - new() { Name = "--channel", ValuePlaceholder = "", Description = "Upgrade channel selector (reserved)", Commands = Set("upgrade") }, - new() { Name = "--prerelease", Description = "Upgrade: include prerelease versions (reserved)", Commands = Set("upgrade") }, + new() { Name = "--channel", ValuePlaceholder = "", Description = "Upgrade: select stable/latest or prerelease releases", Commands = Set("upgrade") }, + new() { Name = "--prerelease", Description = "Upgrade: select the newest prerelease", Commands = Set("upgrade") }, + new() { Name = "--version", ValuePlaceholder = "", Description = "Upgrade: install a specific release tag", Commands = Set("upgrade") }, new() { Name = "--integrity-check", Description = "Run PRAGMA integrity_check on the database", Commands = Set("db") }, new() { Name = "--rebuild", Description = "Delete existing DB and rebuild from scratch", Commands = Set("index") }, new() { Name = "--optimize", Description = "Optimize the existing FTS5 table without scanning files", Commands = Set("index") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 83376a99ec..a9a083b70d 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -123,7 +123,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("lsp", "cdidx lsp [--db ]"), ("completions", "cdidx completions "), ("--completions", "cdidx --completions "), - ("upgrade", "cdidx upgrade [--check-only]"), + ("upgrade", "cdidx upgrade [--check-only] [--json] [--channel ] [--prerelease] [--version ]"), ("license", "cdidx license"), ]; diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index f3c550643a..49a1a4b171 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -44,10 +44,18 @@ internal sealed record UpgradeJsonResult( [property: JsonPropertyName("latest_version")] string? LatestVersion, [property: JsonPropertyName("update_available")] bool UpdateAvailable, [property: JsonPropertyName("from_cache")] bool FromCache, + [property: JsonPropertyName("selected_version")] string? SelectedVersion, + [property: JsonPropertyName("selected_channel")] string SelectedChannel, + [property: JsonPropertyName("selection_source")] string SelectionSource, + [property: JsonPropertyName("include_prerelease")] bool IncludePrerelease, [property: JsonPropertyName("error")] string? Error, [property: JsonPropertyName("install_attempted")] bool InstallAttempted, [property: JsonPropertyName("install_exit_code")] int? InstallExitCode, - [property: JsonPropertyName("install_succeeded")] bool? InstallSucceeded); + [property: JsonPropertyName("install_succeeded")] bool? InstallSucceeded, + [property: JsonPropertyName("handoff_command")] string? HandoffCommand, + [property: JsonPropertyName("handoff_url")] string? HandoffUrl, + [property: JsonPropertyName("handoff_asset")] string? HandoffAsset, + [property: JsonPropertyName("handoff_asset_url")] string? HandoffAssetUrl); internal sealed record DbIntegrityCheckJsonResult( [property: JsonPropertyName("db_path")] string DbPath, diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index f9b71504bf..5a264e2036 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -24,6 +24,7 @@ internal static class ProgramRunner internal const int QueryTraceArrayMaxItems = 8; internal const string QuietEnvironmentVariable = "CDIDX_QUIET"; private const string ReleaseAssetUrlTemplate = "https://github.com/Widthdom/CodeIndex/releases/download/{0}/{1}"; + private const string ReleasePageUrlTemplate = "https://github.com/Widthdom/CodeIndex/releases/tag/{0}"; private const string InstallerScriptAssetName = "install.sh"; private const string ReleaseChecksumAssetName = "sha256sums.txt"; private const long MaxInstallerScriptBytes = 1024 * 1024; @@ -51,6 +52,12 @@ private sealed record CommandRunContext( Stopwatch Stopwatch, CancellationToken CancellationToken); + internal sealed record UpgradeHandoff( + string Command, + string Url, + string Asset, + string AssetUrl); + internal static int Run( string[] args, JsonSerializerOptions? jsonOptions = null, @@ -3059,8 +3066,13 @@ internal static int RunUpgrade( { var checkOnly = false; var wantsJson = false; - foreach (var arg in cmdArgs) + var selectedChannel = "stable"; + var includePrerelease = false; + var selectionSource = "latest"; + string? explicitVersion = null; + for (var i = 0; i < cmdArgs.Length; i++) { + var arg = cmdArgs[i]; if (arg is "--check-only" or "--check-updates") { checkOnly = true; @@ -3071,35 +3083,138 @@ internal static int RunUpgrade( wantsJson = true; continue; } - if (arg is "--channel" or "--prerelease" || arg.StartsWith("--channel=", StringComparison.Ordinal)) + if (arg == "--prerelease") { - Console.Error.WriteLine("Error: upgrade channels and prerelease upgrades are not supported yet."); - Console.Error.WriteLine("Hint: rerun `install.sh` with an explicit release tag if you need a non-latest version."); - return CommandExitCodes.UsageError; + selectedChannel = "prerelease"; + includePrerelease = true; + selectionSource = "prerelease"; + continue; } - Console.Error.WriteLine($"Error: upgrade does not accept '{arg}'."); - Console.Error.WriteLine("Hint: use `cdidx upgrade` or `cdidx upgrade --check-only`."); - return CommandExitCodes.UsageError; + if (arg == "--channel") + { + if (i + 1 >= cmdArgs.Length) + return WriteUpgradeUsageError("--channel requires a value: stable, latest, or prerelease."); + + if (!TryApplyUpgradeChannel(cmdArgs[++i], out selectedChannel, out includePrerelease, out var channelError)) + return WriteUpgradeUsageError(channelError); + + selectionSource = selectedChannel == "prerelease" ? "prerelease" : "latest"; + continue; + } + if (arg.StartsWith("--channel=", StringComparison.Ordinal)) + { + if (!TryApplyUpgradeChannel(arg["--channel=".Length..], out selectedChannel, out includePrerelease, out var channelError)) + return WriteUpgradeUsageError(channelError); + + selectionSource = selectedChannel == "prerelease" ? "prerelease" : "latest"; + continue; + } + if (arg == "--version") + { + if (i + 1 >= cmdArgs.Length) + return WriteUpgradeUsageError("--version requires a release tag such as v1.29.0."); + + if (!TryNormalizeReleaseTag(cmdArgs[++i], out explicitVersion, out var versionError)) + return WriteUpgradeUsageError(versionError); + + selectionSource = "explicit_version"; + continue; + } + if (arg.StartsWith("--version=", StringComparison.Ordinal)) + { + if (!TryNormalizeReleaseTag(arg["--version=".Length..], out explicitVersion, out var versionError)) + return WriteUpgradeUsageError(versionError); + + selectionSource = "explicit_version"; + continue; + } + return WriteUpgradeUsageError($"upgrade does not accept '{arg}'."); } - var result = UpdateChecker.Check(appVersion, cancellationToken); - if (checkOnly || !result.UpdateAvailable || result.LatestVersion == null) + if (explicitVersion != null && IsPrereleaseTag(explicitVersion) && selectedChannel == "stable") + { + selectedChannel = "prerelease"; + includePrerelease = true; + } + + var result = explicitVersion != null + ? new UpdateCheckResult( + appVersion, + explicitVersion, + UpdateChecker.IsNewerRelease(explicitVersion, appVersion), + FromCache: false, + Error: null) + : includePrerelease + ? CheckLatestPrerelease(appVersion, cancellationToken) + : UpdateChecker.Check(appVersion, cancellationToken); + + var shouldInstall = result.LatestVersion != null && (explicitVersion != null || result.UpdateAvailable); + if (checkOnly || !shouldInstall) { if (wantsJson) - Console.WriteLine(JsonSerializer.Serialize(result, jsonOptions)); + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + installAttempted: false, + installExitCode: null, + error: null), + jsonOptions)); + } else if (result.UpdateAvailable && result.LatestVersion != null) - Console.WriteLine($"A newer cdidx release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); + Console.WriteLine($"A newer cdidx {selectedChannel} release is available: {result.LatestVersion} (current: {result.CurrentVersion})."); + else if (result.Error != null) + Console.WriteLine($"Could not select a cdidx {selectedChannel} release ({result.Error}); current: {result.CurrentVersion}."); else Console.WriteLine($"cdidx is up to date (current: {result.CurrentVersion})."); return CommandExitCodes.Success; } + var selectedReleaseTag = result.LatestVersion!; + + if (OperatingSystem.IsWindows()) + { + var handoff = CreateWindowsUpgradeHandoff(selectedReleaseTag, RuntimeInformation.ProcessArchitecture); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + installAttempted: false, + installExitCode: null, + error: "windows_handoff_required", + handoff: handoff), + jsonOptions)); + } + else + { + Console.Error.WriteLine("Error: cdidx upgrade cannot replace the running Windows binary directly."); + Console.Error.WriteLine($"Hint: update via NuGet global tool: {handoff.Command}"); + Console.Error.WriteLine($"Release page: {handoff.Url}"); + Console.Error.WriteLine($"Manual zip asset: {handoff.Asset} ({handoff.AssetUrl})"); + } + return CommandExitCodes.FeatureUnavailable; + } + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) { if (wantsJson) { Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult(result, installAttempted: false, installExitCode: null, "unsupported_platform"), + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + installAttempted: false, + installExitCode: null, + error: "unsupported_platform"), jsonOptions)); } else @@ -3116,7 +3231,14 @@ internal static int RunUpgrade( if (wantsJson) { Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult(result, installAttempted: false, installExitCode: null, "install_directory_not_writable"), + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + installAttempted: false, + installExitCode: null, + error: "install_directory_not_writable"), jsonOptions)); } else @@ -3137,7 +3259,7 @@ internal static int RunUpgrade( { var checksumManifest = DownloadReleaseChecksumManifestAsync( client, - result.LatestVersion, + selectedReleaseTag, TimeSpan.FromSeconds(20), cancellationToken) .GetAwaiter() @@ -3146,7 +3268,7 @@ internal static int RunUpgrade( DownloadInstallerScriptAsync( client, - result.LatestVersion, + selectedReleaseTag, scriptPath, TimeSpan.FromSeconds(20), cancellationToken) @@ -3155,7 +3277,7 @@ internal static int RunUpgrade( VerifyFileSha256(scriptPath, expectedInstallerSha256, InstallerScriptAssetName); } - var startInfo = CreateInstallerProcessStartInfo(scriptPath, result.LatestVersion, installDir); + var startInfo = CreateInstallerProcessStartInfo(scriptPath, selectedReleaseTag, installDir); var installExitCode = RunInstallerProcess( startInfo, InstallerRunTimeout, @@ -3167,7 +3289,14 @@ internal static int RunUpgrade( ? null : $"installer_exit_code_{installExitCode.ToString(CultureInfo.InvariantCulture)}"; Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult(result, installAttempted: true, installExitCode, error), + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + installAttempted: true, + installExitCode: installExitCode, + error: error), jsonOptions)); } return installExitCode; @@ -3181,7 +3310,14 @@ internal static int RunUpgrade( if (wantsJson) { Console.WriteLine(JsonSerializer.Serialize( - CreateUpgradeJsonResult(result, installAttempted: false, installExitCode: null, ex.GetType().Name), + CreateUpgradeJsonResult( + result, + selectedChannel, + selectionSource, + includePrerelease, + installAttempted: false, + installExitCode: null, + error: ex.GetType().Name), jsonOptions)); } else @@ -3200,6 +3336,108 @@ internal static int RunUpgrade( } } + private static int WriteUpgradeUsageError(string message) + { + Console.Error.WriteLine($"Error: {message}"); + Console.Error.WriteLine("Hint: use `cdidx upgrade [--check-only] [--channel stable|prerelease] [--prerelease] [--version vX.Y.Z]`."); + return CommandExitCodes.UsageError; + } + + private static bool TryApplyUpgradeChannel( + string rawChannel, + out string selectedChannel, + out bool includePrerelease, + out string error) + { + selectedChannel = "stable"; + includePrerelease = false; + error = string.Empty; + + switch (rawChannel.Trim().ToLowerInvariant()) + { + case "stable": + case "latest": + selectedChannel = "stable"; + return true; + case "prerelease": + case "preview": + selectedChannel = "prerelease"; + includePrerelease = true; + return true; + default: + error = $"unsupported upgrade channel '{rawChannel}'."; + return false; + } + } + + private static bool TryNormalizeReleaseTag(string rawVersion, out string? normalizedVersion, out string error) + { + normalizedVersion = null; + error = string.Empty; + + var trimmed = rawVersion.Trim(); + if (trimmed.Length == 0) + { + error = "--version requires a non-empty release tag."; + return false; + } + + normalizedVersion = trimmed[0] is 'v' or 'V' + ? "v" + trimmed[1..] + : "v" + trimmed; + return true; + } + + private static bool IsPrereleaseTag(string releaseTag) + => releaseTag.Contains('-', StringComparison.Ordinal); + + internal static UpgradeHandoff CreateWindowsUpgradeHandoff(string releaseTag, Architecture processArchitecture) + { + var normalizedTag = releaseTag.Trim(); + var nugetVersion = normalizedTag.Length > 0 && (normalizedTag[0] is 'v' or 'V') + ? normalizedTag[1..] + : normalizedTag; + var asset = processArchitecture == Architecture.Arm64 + ? "CodeIndex-win-arm64.zip" + : "CodeIndex-win-x64.zip"; + return new UpgradeHandoff( + $"dotnet tool update -g cdidx --version {nugetVersion}", + BuildReleasePageUrl(normalizedTag), + asset, + BuildReleaseAssetUrl(normalizedTag, asset)); + } + + private static UpdateCheckResult CheckLatestPrerelease(string appVersion, CancellationToken cancellationToken) + { + if (UpdateChecker.IsDisabled()) + return new UpdateCheckResult(appVersion, null, false, FromCache: false, Error: "disabled"); + + try + { + using var client = UpgradeHttpClientFactory(); + var tag = UpdateChecker.FetchLatestPrereleaseTagAsync( + client, + TimeSpan.FromSeconds(20), + cancellationToken) + .GetAwaiter() + .GetResult(); + return new UpdateCheckResult( + appVersion, + tag, + UpdateChecker.IsNewerRelease(tag, appVersion), + FromCache: false, + Error: tag is null ? "prerelease_not_found" : null); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + return new UpdateCheckResult(appVersion, null, false, FromCache: false, Error: ex.GetType().Name); + } + } + internal static ProcessStartInfo CreateInstallerProcessStartInfo(string scriptPath, string releaseTag, string installDir) { var startInfo = new ProcessStartInfo @@ -3302,18 +3540,36 @@ internal static int RunInstallerProcess( private static UpgradeJsonResult CreateUpgradeJsonResult( UpdateCheckResult result, + string selectedChannel, + string selectionSource, + bool includePrerelease, bool installAttempted, int? installExitCode, - string? error) + string? error, + UpgradeHandoff? handoff = null) => new( result.CurrentVersion, result.LatestVersion, result.UpdateAvailable, result.FromCache, + result.LatestVersion, + selectedChannel, + selectionSource, + includePrerelease, error ?? result.Error, installAttempted, installExitCode, - installExitCode is null ? null : installExitCode == CommandExitCodes.Success); + installExitCode is null ? null : installExitCode == CommandExitCodes.Success, + handoff?.Command, + handoff?.Url, + handoff?.Asset, + handoff?.AssetUrl); + + internal static string BuildReleasePageUrl(string releaseTag) + => string.Format( + CultureInfo.InvariantCulture, + ReleasePageUrlTemplate, + Uri.EscapeDataString(releaseTag.Trim())); internal static string BuildInstallerScriptUrl(string releaseTag) => BuildReleaseAssetUrl(releaseTag, InstallerScriptAssetName); diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index eab0af8362..8beb712183 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -9,6 +9,7 @@ internal static class UpdateChecker { internal const string DisableEnvVar = "CDIDX_DISABLE_UPDATE_CHECK"; private const string LatestReleaseUrl = "https://api.github.com/repos/Widthdom/CodeIndex/releases/latest"; + private const string ReleasesUrl = "https://api.github.com/repos/Widthdom/CodeIndex/releases?per_page=20"; internal const long MaxLatestReleaseResponseBytes = 64 * 1024; internal const int MaxLatestReleaseJsonDepth = 16; internal const int MaxUpdateCheckCacheBytes = 8 * 1024; @@ -128,7 +129,7 @@ internal static bool IsNewerRelease(string? latestTag, string currentVersion) && latest > current; } - private static bool IsDisabled() + internal static bool IsDisabled() { var value = Environment.GetEnvironmentVariable(DisableEnvVar); return value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); @@ -164,6 +165,27 @@ private static string FormatHint(string latestTag) return await ReadLatestReleaseTagAsync(response.Content, requestCts.Token).ConfigureAwait(false); } + internal static async Task FetchLatestPrereleaseTagAsync( + HttpClient client, + TimeSpan timeout, + CancellationToken cancellationToken) + { + using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestCts.CancelAfter(timeout); + using var request = new HttpRequestMessage(HttpMethod.Get, ReleasesUrl); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("cdidx", ConsoleUi.LoadVersion())); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + requestCts.Token).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + return null; + + return await ReadLatestPrereleaseTagAsync(response.Content, requestCts.Token).ConfigureAwait(false); + } + internal static async Task ReadLatestReleaseTagAsync(HttpContent content, CancellationToken cancellationToken) { var payload = await BoundedHttpContentReader.ReadAsByteArrayAsync( @@ -178,6 +200,40 @@ private static string FormatHint(string latestTag) : null; } + internal static async Task ReadLatestPrereleaseTagAsync(HttpContent content, CancellationToken cancellationToken) + { + var payload = await BoundedHttpContentReader.ReadAsByteArrayAsync( + content, + MaxLatestReleaseResponseBytes, + cancellationToken).ConfigureAwait(false); + using var doc = JsonDocument.Parse( + payload.AsMemory(), + new JsonDocumentOptions { MaxDepth = MaxLatestReleaseJsonDepth }); + if (doc.RootElement.ValueKind != JsonValueKind.Array) + return null; + + foreach (var release in doc.RootElement.EnumerateArray()) + { + if (release.ValueKind != JsonValueKind.Object) + continue; + + var isDraft = release.TryGetProperty("draft", out var draftElement) + && draftElement.ValueKind == JsonValueKind.True; + if (isDraft) + continue; + + var isPrerelease = release.TryGetProperty("prerelease", out var prereleaseElement) + && prereleaseElement.ValueKind == JsonValueKind.True; + if (!isPrerelease) + continue; + + if (release.TryGetProperty("tag_name", out var tagElement)) + return tagElement.GetString(); + } + + return null; + } + private static string ResolveDefaultCachePath() { var xdgCacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index be6a097707..f866e313c1 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -113,6 +113,26 @@ public void GetAcceptedFlagNamesForCommand_UnionsCommandsAndAlsoAcceptedBy() Assert.DoesNotContain(CliFlagSchema.GetCompletionFlagsForCommand("definition"), f => f.Name == "--exact-substring"); } + [Fact] + public void UpgradeFlags_SurfaceImplementedSelectionAndJsonOptions() + { + var accepted = CliFlagSchema.GetAcceptedFlagNamesForCommand("upgrade"); + Assert.Contains("--json", accepted); + Assert.Contains("--channel", accepted); + Assert.Contains("--prerelease", accepted); + Assert.Contains("--version", accepted); + + var channel = Assert.Single(CliFlagSchema.GetCompletionFlagsForCommand("upgrade"), f => f.Name == "--channel"); + Assert.Equal("", channel.ValuePlaceholder); + Assert.DoesNotContain("reserved", channel.Description, StringComparison.OrdinalIgnoreCase); + + var prerelease = Assert.Single(CliFlagSchema.GetCompletionFlagsForCommand("upgrade"), f => f.Name == "--prerelease"); + Assert.DoesNotContain("reserved", prerelease.Description, StringComparison.OrdinalIgnoreCase); + + var version = Assert.Single(CliFlagSchema.GetCompletionFlagsForCommand("upgrade"), f => f.Name == "--version"); + Assert.Equal("", version.ValuePlaceholder); + } + [Fact] public void TopLevelGlobalSchema_IncludesLogFlagsAndMatchesProgramRunnerParserSets() { diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 7e9a2ebfde..60ae1ce7fb 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -1100,6 +1100,18 @@ public void PrintUsage_ShowsWorkingFindDashedLiteralExample() Assert.DoesNotContain("cdidx find -- --path --path README.md", output); } + [Fact] + public void UpgradeUsageLine_ShowsImplementedSelectionAndJsonOptions() + { + var usage = ConsoleUi.GetUsageLine("upgrade"); + + Assert.NotNull(usage); + Assert.Contains("--json", usage); + Assert.Contains("--channel ", usage); + Assert.Contains("--prerelease", usage); + Assert.Contains("--version ", usage); + } + [Fact] public void PrintUsageBrief_IsConciseAndPointsToExtendedHelp() { diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 0d0e419428..316c22aff7 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -334,6 +334,30 @@ public void DownloadAndInstall_SecuresStageDirectoryAfterMktemp() Assert.True(cleanupIndex > chmodIndex); } + [Fact] + public void InstallScript_IsGeneratedFromFocusedModules() + { + var root = GetRepositoryRoot(); + var script = File.ReadAllText(Path.Combine(root, "install.sh")); + var generated = string.Concat(InstallModuleFiles.Select(module => + File.ReadAllText(Path.Combine(root, "install_modules", module)))); + + Assert.Equal(NormalizeNewlines(generated), NormalizeNewlines(script)); + } + + [Theory] + [InlineData("20-installer.sh", "download_and_install()")] + [InlineData("40-uninstall.sh", "uninstall_cdidx()")] + [InlineData("50-self-test.sh", "run_local_mirror_self_test()")] + [InlineData("60-reinstall.sh", "run_reinstall_real()")] + [InlineData("70-doctor.sh", "run_doctor()")] + public void InstallModules_ExposeFocusedFlowEntrypoints(string module, string entrypoint) + { + var moduleText = File.ReadAllText(Path.Combine(GetRepositoryRoot(), "install_modules", module)); + + Assert.Contains(entrypoint, moduleText); + } + [Theory] [InlineData("linux", "x64", "linux-x64", "libe_sqlite3.so")] [InlineData("osx", "arm64", "osx-arm64", "libe_sqlite3.dylib")] @@ -2686,6 +2710,80 @@ builtin command "$@" Assert.Contains("GPG signature verification is required", stderr); } + [Fact] + public void VerificationPolicy_StrictEnablesAttestationAndStrictGpg() + { + if (OperatingSystem.IsWindows()) + return; + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + printf 'policy=%s attestation=%s strict=%s\n' "$VERIFY_POLICY" "$REQUIRE_ATTESTATION" "$STRICT_VERIFY" + """, + new Dictionary + { + ["CDIDX_VERIFY_POLICY"] = "strict", + }); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("policy=strict attestation=1 strict=1", stdout); + } + + [Fact] + public void VerificationPolicy_InvalidValue_FailsDuringSource() + { + if (OperatingSystem.IsWindows()) + return; + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_VERIFY_POLICY"] = "locked", + }, + enforceStrictMode: false); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("CDIDX_VERIFY_POLICY must be 'compat' or 'strict'", stderr); + } + + [Fact] + public void VerifyChecksumSignature_StrictPolicyWithoutFingerprint_FailsClosed() + { + if (OperatingSystem.IsWindows()) + return; + + var checksumsPath = Path.Combine(_tempRoot, "strict-policy.sha256sums.txt"); + var signaturePath = checksumsPath + ".asc"; + File.WriteAllText(checksumsPath, "checksum"); + File.WriteAllText(signaturePath, "signature"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + gpg() { + printf '[GNUPG:] VALIDSIG AABBCCDDEEFF00112233445566778899AABBCCDD 2026-01-01 0 4 0 1 10 00 AABBCCDDEEFF00112233445566778899AABBCCDD\n' + return 0 + } + + verify_checksum_signature "{{checksumsPath}}" "{{signaturePath}}" + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_VERIFY_POLICY"] = "strict", + }, + enforceStrictMode: false); + + Assert.Equal(1, exitCode); + Assert.Contains("Verifying checksum signature", stdout); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("no expected release signer fingerprint is configured", stderr); + } + [Fact] public void VerifyChecksumSignature_FingerprintMismatch_Fails() { @@ -5585,6 +5683,53 @@ public void Doctor_DispatcherWiresDoctorCliFlag() } } + [Fact] + public void VerificationPolicy_DispatcherRejectsInvalidCliValue() + { + if (OperatingSystem.IsWindows()) + return; + + var psi = new ProcessStartInfo + { + FileName = "bash", + WorkingDirectory = GetRepositoryRoot(), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.ArgumentList.Add(GetInstallScriptPath()); + psi.ArgumentList.Add("--verify-policy"); + psi.ArgumentList.Add("locked"); + psi.ArgumentList.Add("--doctor"); + + using var process = Process.Start(psi) + ?? throw new InvalidOperationException("Failed to start install.sh / install.sh 起動失敗"); + var stdOut = process.StandardOutput.ReadToEnd(); + var stdErr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + + Assert.Equal(1, process.ExitCode); + Assert.Equal(string.Empty, stdOut); + Assert.Contains("CDIDX_VERIFY_POLICY must be 'compat' or 'strict'", stdErr); + } + + private static readonly string[] InstallModuleFiles = + [ + "00-core-and-verification.sh", + "10-network-and-platform.sh", + "20-installer.sh", + "30-path-guidance.sh", + "40-uninstall.sh", + "50-self-test.sh", + "60-reinstall.sh", + "70-doctor.sh", + "90-dispatch.sh", + ]; + + private static string NormalizeNewlines(string value) + => value.Replace("\r\n", "\n", StringComparison.Ordinal); + [UnsupportedOSPlatform("windows")] private static (int ExitCode, string StdOut, string StdErr) RunInstallerSnippet(string snippet, IReadOnlyDictionary? extraEnvironment = null, bool enforceStrictMode = true) { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index c803ef0cc6..36507bade7 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Net; +using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -1227,6 +1228,84 @@ public void UpdateChecker_Check_WritesCacheWithPrivateModes_Issue3411() } } + [Fact] + public void RunUpgrade_CheckOnlyJsonExplicitVersion_ReportsSelection() + { + lock (TestConsoleLock.Gate) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["upgrade", "--check-only", "--json", "--version", "2.0.0-rc.1"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var doc = JsonDocument.Parse(stdout); + var root = doc.RootElement; + Assert.Equal("v2.0.0-rc.1", root.GetProperty("latest_version").GetString()); + Assert.Equal("v2.0.0-rc.1", root.GetProperty("selected_version").GetString()); + Assert.Equal("prerelease", root.GetProperty("selected_channel").GetString()); + Assert.Equal("explicit_version", root.GetProperty("selection_source").GetString()); + Assert.True(root.GetProperty("include_prerelease").GetBoolean()); + Assert.False(root.GetProperty("install_attempted").GetBoolean()); + } + } + + [Fact] + public void RunUpgrade_CheckOnlyJsonPrerelease_ReportsSelection() + { + lock (TestConsoleLock.Gate) + { + var previousFactory = ProgramRunner.UpgradeHttpClientFactory; + ProgramRunner.UpgradeHttpClientFactory = () => new HttpClient( + new StaticResponseHandler(new ByteArrayContent(Encoding.UTF8.GetBytes(""" + [ + { "tag_name": "v9.9.9", "draft": false, "prerelease": false }, + { "tag_name": "v9.9.9-rc.2", "draft": true, "prerelease": true }, + { "tag_name": "v9.9.9-rc.1", "draft": false, "prerelease": true } + ] + """)))) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["upgrade", "--check-only", "--json", "--prerelease"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var doc = JsonDocument.Parse(stdout); + var root = doc.RootElement; + Assert.Equal("v9.9.9-rc.1", root.GetProperty("latest_version").GetString()); + Assert.Equal("v9.9.9-rc.1", root.GetProperty("selected_version").GetString()); + Assert.Equal("prerelease", root.GetProperty("selected_channel").GetString()); + Assert.Equal("prerelease", root.GetProperty("selection_source").GetString()); + Assert.True(root.GetProperty("include_prerelease").GetBoolean()); + } + finally + { + ProgramRunner.UpgradeHttpClientFactory = previousFactory; + } + } + } + + [Theory] + [InlineData(Architecture.X64, "CodeIndex-win-x64.zip")] + [InlineData(Architecture.Arm64, "CodeIndex-win-arm64.zip")] + public void CreateWindowsUpgradeHandoff_UsesNuGetVersionAndMatchingAsset(Architecture architecture, string expectedAsset) + { + var handoff = ProgramRunner.CreateWindowsUpgradeHandoff("v2.0.0-rc.1", architecture); + + Assert.Equal("dotnet tool update -g cdidx --version 2.0.0-rc.1", handoff.Command); + Assert.Equal("https://github.com/Widthdom/CodeIndex/releases/tag/v2.0.0-rc.1", handoff.Url); + Assert.Equal(expectedAsset, handoff.Asset); + Assert.Equal( + $"https://github.com/Widthdom/CodeIndex/releases/download/v2.0.0-rc.1/{expectedAsset}", + handoff.AssetUrl); + } + [Fact] public async Task DownloadReleaseChecksumManifestAsync_RejectsOverLimitResponse() { @@ -1276,6 +1355,22 @@ await Assert.ThrowsAnyAsync(() => UpdateChecker.ReadLatestReleaseTagAsync(content, CancellationToken.None)); } + [Fact] + public async Task UpdateChecker_ReadLatestPrereleaseTagAsync_SkipsDraftsAndStableReleases() + { + using var content = new ByteArrayContent(Encoding.UTF8.GetBytes(""" + [ + { "tag_name": "v3.0.0", "draft": false, "prerelease": false }, + { "tag_name": "v3.1.0-rc.2", "draft": true, "prerelease": true }, + { "tag_name": "v3.1.0-rc.1", "draft": false, "prerelease": true } + ] + """)); + + var tag = await UpdateChecker.ReadLatestPrereleaseTagAsync(content, CancellationToken.None); + + Assert.Equal("v3.1.0-rc.1", tag); + } + [Fact] public async Task UpdateChecker_FetchLatestReleaseTagAsync_CancelsStalledBody() { diff --git a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs index 78b4927e30..216fee0cf0 100644 --- a/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs +++ b/tests/CodeIndex.Tests/ReleaseWorkflowTests.cs @@ -2,6 +2,7 @@ using System.IO.Compression; using System.Security.Cryptography; using System.Text; +using System.Text.Json; namespace CodeIndex.Tests; @@ -219,6 +220,74 @@ public void PackageNormalizer_RewritesRandomCorePropertiesPartDeterministically( } } + [Fact] + public void PackageNormalizeCli_DryRunDoesNotRewritePackage() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizeCli_DryRunDoesNotRewritePackage)); + try + { + var packagePath = Path.Combine(projectRoot, "dry-run.nupkg"); + CreateMinimalNuGetPackage(packagePath, "random.psmdcp"); + var beforeHash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(packagePath))); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + PackageNormalizeCli.Run(["--dry-run", "--summary", packagePath])); + + var afterHash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(packagePath))); + Assert.Equal(0, exitCode); + Assert.Empty(stderr); + Assert.Contains($"Would normalize {packagePath}", stdout); + Assert.Contains("Summary: inspected=1 normalized=0 unchanged=0 failed=0 skipped=1", stdout); + Assert.Equal(beforeHash, afterHash); + Assert.False(File.Exists(packagePath + ".normalize-tmp")); + + using var archive = ZipFile.OpenRead(packagePath); + Assert.Contains(archive.Entries, entry => entry.FullName.EndsWith("random.psmdcp", StringComparison.Ordinal)); + Assert.DoesNotContain(archive.Entries, entry => entry.FullName == PackageCorePropertiesNormalizer.CanonicalCorePropertiesPath); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PackageNormalizeCli_JsonContinueOnErrorReportsAggregateSummary() + { + var projectRoot = TestProjectHelper.CreateTempProject(nameof(PackageNormalizeCli_JsonContinueOnErrorReportsAggregateSummary)); + try + { + var packagePath = Path.Combine(projectRoot, "good.nupkg"); + var missingPackagePath = Path.Combine(projectRoot, "missing.nupkg"); + CreateMinimalNuGetPackage(packagePath, "random.psmdcp"); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + PackageNormalizeCli.Run(["--dry-run", "--json", "--continue-on-error", missingPackagePath, packagePath])); + + Assert.Equal(1, exitCode); + Assert.Empty(stderr); + using var doc = JsonDocument.Parse(stdout); + var root = doc.RootElement; + Assert.True(root.GetProperty("dry_run").GetBoolean()); + Assert.True(root.GetProperty("continue_on_error").GetBoolean()); + Assert.Equal(2, root.GetProperty("inspected").GetInt32()); + Assert.Equal(0, root.GetProperty("normalized").GetInt32()); + Assert.Equal(0, root.GetProperty("unchanged").GetInt32()); + Assert.Equal(1, root.GetProperty("failed").GetInt32()); + Assert.Equal(1, root.GetProperty("skipped").GetInt32()); + + var packages = root.GetProperty("packages").EnumerateArray().ToArray(); + Assert.Equal("failed", packages[0].GetProperty("status").GetString()); + Assert.Equal(missingPackagePath, packages[0].GetProperty("path").GetString()); + Assert.Equal("would_normalize", packages[1].GetProperty("status").GetString()); + Assert.Equal(packagePath, packages[1].GetProperty("path").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void PackageNormalizer_RejectsPackageThatExceedsEntryCountLimit() { diff --git a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs index 5d3996f35d..4257ec2042 100644 --- a/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs +++ b/tools/CodeIndex.PackageNormalize/PackageNormalizeCli.cs @@ -1,5 +1,6 @@ using System.IO.Compression; using System.Text; +using System.Text.Json; namespace CodeIndex.PackageNormalize; @@ -7,22 +8,181 @@ public static class PackageNormalizeCli { public static int Run(string[] args) { - if (args.Length == 0 || args.Any(arg => arg is "-h" or "--help")) + if (args.Any(arg => arg is "-h" or "--help")) { - Console.Error.WriteLine("Usage: dotnet run --project tools/CodeIndex.PackageNormalize -- [...]"); - return args.Length == 0 ? 1 : 0; + WriteUsage(); + return 0; } - foreach (var packagePath in args) + if (!PackageNormalizeOptions.TryParse(args, out var options, out var parseError)) { - PackageCorePropertiesNormalizer.NormalizePackage(packagePath); - Console.WriteLine($"Normalized {packagePath}"); + Console.Error.WriteLine($"Error: {parseError}"); + WriteUsage(); + return 1; } - return 0; + var results = new List(); + var summary = new PackageNormalizeSummary(); + + foreach (var packagePath in options.PackagePaths) + { + summary.Inspected++; + try + { + if (options.DryRun) + { + var inspection = PackageCorePropertiesNormalizer.InspectPackage(packagePath); + if (inspection.NeedsNormalization) + { + summary.Skipped++; + results.Add(new PackageNormalizePackageResult(packagePath, "would_normalize", null)); + if (!options.Json) + Console.WriteLine($"Would normalize {packagePath}"); + } + else + { + summary.Unchanged++; + results.Add(new PackageNormalizePackageResult(packagePath, "unchanged", null)); + if (!options.Json) + Console.WriteLine($"Unchanged {packagePath}"); + } + } + else + { + PackageCorePropertiesNormalizer.NormalizePackage(packagePath); + summary.Normalized++; + results.Add(new PackageNormalizePackageResult(packagePath, "normalized", null)); + if (!options.Json) + Console.WriteLine($"Normalized {packagePath}"); + } + } + catch (Exception ex) + { + summary.Failed++; + results.Add(new PackageNormalizePackageResult(packagePath, "failed", ex.Message)); + if (!options.Json) + Console.Error.WriteLine($"Failed {packagePath}: {ex.Message}"); + + if (!options.ContinueOnError) + break; + } + } + + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new PackageNormalizeJsonResult( + options.DryRun, + options.ContinueOnError, + summary.Inspected, + summary.Normalized, + summary.Unchanged, + summary.Failed, + summary.Skipped, + results), + PackageNormalizeJsonContext.Default.PackageNormalizeJsonResult)); + } + else if (options.Summary) + { + Console.WriteLine( + $"Summary: inspected={summary.Inspected} normalized={summary.Normalized} unchanged={summary.Unchanged} failed={summary.Failed} skipped={summary.Skipped}"); + } + + return summary.Failed == 0 ? 0 : 1; + } + + private static void WriteUsage() + { + Console.Error.WriteLine("Usage: dotnet run --project tools/CodeIndex.PackageNormalize -- [--dry-run|--check] [--summary] [--json] [--continue-on-error] [...]"); } } +internal sealed class PackageNormalizeOptions +{ + private PackageNormalizeOptions(bool dryRun, bool summary, bool json, bool continueOnError, IReadOnlyList packagePaths) + { + DryRun = dryRun; + Summary = summary; + Json = json; + ContinueOnError = continueOnError; + PackagePaths = packagePaths; + } + + internal bool DryRun { get; } + internal bool Summary { get; } + internal bool Json { get; } + internal bool ContinueOnError { get; } + internal IReadOnlyList PackagePaths { get; } + + internal static bool TryParse(string[] args, out PackageNormalizeOptions options, out string error) + { + var dryRun = false; + var summary = false; + var json = false; + var continueOnError = false; + var packagePaths = new List(); + + foreach (var arg in args) + { + switch (arg) + { + case "--dry-run": + case "--check": + dryRun = true; + break; + case "--summary": + summary = true; + break; + case "--json": + json = true; + break; + case "--continue-on-error": + continueOnError = true; + break; + default: + if (arg.Length > 0 && arg[0] == '-') + { + options = null!; + error = $"unknown option: {arg}"; + return false; + } + + packagePaths.Add(arg); + break; + } + } + + if (packagePaths.Count == 0) + { + options = null!; + error = "at least one package path is required."; + return false; + } + + options = new PackageNormalizeOptions(dryRun, summary, json, continueOnError, packagePaths); + error = string.Empty; + return true; + } +} + +internal sealed record PackageNormalizePackageResult( + [property: System.Text.Json.Serialization.JsonPropertyName("path")] string Path, + [property: System.Text.Json.Serialization.JsonPropertyName("status")] string Status, + [property: System.Text.Json.Serialization.JsonPropertyName("error")] string? Error); + +internal sealed record PackageNormalizeJsonResult( + [property: System.Text.Json.Serialization.JsonPropertyName("dry_run")] bool DryRun, + [property: System.Text.Json.Serialization.JsonPropertyName("continue_on_error")] bool ContinueOnError, + [property: System.Text.Json.Serialization.JsonPropertyName("inspected")] int Inspected, + [property: System.Text.Json.Serialization.JsonPropertyName("normalized")] int Normalized, + [property: System.Text.Json.Serialization.JsonPropertyName("unchanged")] int Unchanged, + [property: System.Text.Json.Serialization.JsonPropertyName("failed")] int Failed, + [property: System.Text.Json.Serialization.JsonPropertyName("skipped")] int Skipped, + [property: System.Text.Json.Serialization.JsonPropertyName("packages")] IReadOnlyList Packages); + +[System.Text.Json.Serialization.JsonSerializable(typeof(PackageNormalizeJsonResult))] +internal sealed partial class PackageNormalizeJsonContext : System.Text.Json.Serialization.JsonSerializerContext; + public static class PackageCorePropertiesNormalizer { public const string CanonicalCorePropertiesPath = "package/services/metadata/core-properties/core-properties.psmdcp"; @@ -98,6 +258,27 @@ internal static void NormalizePackage(string packagePath, PackageNormalizeLimits } } + internal static PackageNormalizeInspection InspectPackage(string packagePath) + { + return InspectPackage(packagePath, PackageNormalizeLimits.Default); + } + + internal static PackageNormalizeInspection InspectPackage(string packagePath, PackageNormalizeLimits limits) + { + ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); + limits.Validate(); + + var fullPath = Path.GetFullPath(packagePath); + using var sourceStream = File.Open(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var sourceArchive = new ZipArchive(sourceStream, ZipArchiveMode.Read, leaveOpen: false); + var originalCorePropertiesPath = ValidateSourceArchive(sourceArchive, packagePath, limits); + ValidateEntryNamesBeforeRewrite(sourceArchive, originalCorePropertiesPath); + var needsNormalization = originalCorePropertiesPath != CanonicalCorePropertiesPath + || XmlReferencesNeedRewrite(sourceArchive, originalCorePropertiesPath, limits); + + return new PackageNormalizeInspection(fullPath, needsNormalization, originalCorePropertiesPath); + } + private static string ValidateSourceArchive(ZipArchive sourceArchive, string packagePath, PackageNormalizeLimits limits) { if (sourceArchive.Entries.Count > limits.MaxEntryCount) @@ -377,6 +558,27 @@ private static bool NeedsXmlReferenceRewrite(string entryName) || entryName.EndsWith(".rels", StringComparison.Ordinal); } + private static bool XmlReferencesNeedRewrite( + ZipArchive sourceArchive, + string originalCorePropertiesPath, + PackageNormalizeLimits limits) + { + var readBudget = new PackageNormalizeReadBudget(limits); + foreach (var sourceEntry in sourceArchive.Entries) + { + if (!NeedsXmlReferenceRewrite(sourceEntry.FullName)) + continue; + + using var rawSourceEntryStream = sourceEntry.Open(); + using var sourceEntryStream = new BudgetedEntryReadStream(rawSourceEntryStream, sourceEntry, readBudget); + var content = ReadXmlEntryText(sourceEntry, sourceEntryStream, limits); + if (RewriteCorePropertiesReferences(content, originalCorePropertiesPath) != content) + return true; + } + + return false; + } + private static string RewriteCorePropertiesReferences(string content, string originalCorePropertiesPath) { var canonical = CanonicalCorePropertiesPath; @@ -386,6 +588,20 @@ private static string RewriteCorePropertiesReferences(string content, string ori } } +internal readonly record struct PackageNormalizeInspection( + string PackagePath, + bool NeedsNormalization, + string OriginalCorePropertiesPath); + +internal sealed class PackageNormalizeSummary +{ + internal int Inspected { get; set; } + internal int Normalized { get; set; } + internal int Unchanged { get; set; } + internal int Failed { get; set; } + internal int Skipped { get; set; } +} + internal readonly record struct PackageNormalizeLimits( int MaxEntryCount, long MaxEntryUncompressedBytes, diff --git a/tools/build-install-sh.sh b/tools/build-install-sh.sh new file mode 100755 index 0000000000..5233fbf05b --- /dev/null +++ b/tools/build-install-sh.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" +output_path="${1:-${repo_root}/install.sh}" +tmp_path="${output_path}.tmp" + +modules=( + "00-core-and-verification.sh" + "10-network-and-platform.sh" + "20-installer.sh" + "30-path-guidance.sh" + "40-uninstall.sh" + "50-self-test.sh" + "60-reinstall.sh" + "70-doctor.sh" + "90-dispatch.sh" +) + +: > "$tmp_path" +for module in "${modules[@]}"; do + module_path="${repo_root}/install_modules/${module}" + if [ ! -f "$module_path" ]; then + echo "Missing install module: ${module_path}" >&2 + rm -f "$tmp_path" + exit 1 + fi + + cat "$module_path" >> "$tmp_path" +done + +chmod +x "$tmp_path" +mv "$tmp_path" "$output_path"