diff --git a/changelog.d/unreleased/1751.fixed.md b/changelog.d/unreleased/1751.fixed.md new file mode 100644 index 0000000000..dc95d3c187 --- /dev/null +++ b/changelog.d/unreleased/1751.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1751 +affected: + - install.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **The installer now verifies the installed binary before reporting success (#1751)** — `install.sh` runs `cdidx --version`, checks that the installed version matches the requested release, and reports likely architecture or native-runtime problems before printing the success path. + +## 日本語 + +- **インストーラーが成功報告前にインストール済みバイナリを検証するようになりました (#1751)** — `install.sh` は `cdidx --version` を実行し、インストールされたバージョンが要求リリースと一致することを確認し、成功扱いにする前に architecture や native runtime の問題候補を表示します。 diff --git a/changelog.d/unreleased/1766.fixed.md b/changelog.d/unreleased/1766.fixed.md new file mode 100644 index 0000000000..0e3f8b0851 --- /dev/null +++ b/changelog.d/unreleased/1766.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1766 +affected: + - install.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **The installer now preflights temporary storage before `mktemp` use (#1766)** — `install.sh` checks that `TMPDIR` is a writable directory with at least 100 MiB free and warns when it appears to be mounted `noexec`. + +## 日本語 + +- **インストーラーが `mktemp` 使用前に一時領域を事前検査するようになりました (#1766)** — `install.sh` は `TMPDIR` が書き込み可能なディレクトリで 100 MiB 以上の空き容量を持つことを確認し、`noexec` mount と思われる場合は警告します。 diff --git a/changelog.d/unreleased/2011.fixed.md b/changelog.d/unreleased/2011.fixed.md new file mode 100644 index 0000000000..83b47bb2ee --- /dev/null +++ b/changelog.d/unreleased/2011.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2011 +affected: + - install.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **The installer can now update shell profiles for PATH setup on opt-in (#2011)** — users can rerun with `CDIDX_INSTALL_UPDATE_PATH=1` to append the installer PATH export to the detected shell profile and make the new path active for the installer process. + +## 日本語 + +- **インストーラーが opt-in で shell profile の PATH 設定を更新できるようになりました (#2011)** — `CDIDX_INSTALL_UPDATE_PATH=1` を付けて再実行すると、検出した shell profile に installer の PATH export を追記し、インストーラー処理内でも新しい PATH を有効化します。 diff --git a/changelog.d/unreleased/2014.fixed.md b/changelog.d/unreleased/2014.fixed.md new file mode 100644 index 0000000000..1c80373cce --- /dev/null +++ b/changelog.d/unreleased/2014.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2014 +affected: + - install.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **The installer now warns when an older `cdidx` shadows the new install on PATH (#2014)** — `check_path` resolves the active binary, lists every `cdidx` found on PATH with versions, and points users to put the install directory first. + +## 日本語 + +- **古い `cdidx` が PATH 上で新しいインストールを shadow している場合に警告するようになりました (#2014)** — `check_path` は有効なバイナリを解決し、PATH 上の全 `cdidx` とバージョンを一覧表示して、インストール先ディレクトリを先頭に置くよう案内します。 diff --git a/install.sh b/install.sh index 46bf87243d..4ae1cc9435 100755 --- a/install.sh +++ b/install.sh @@ -180,6 +180,54 @@ need_cmd() { fi } +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" @@ -228,6 +276,39 @@ semver_ge() { }' } +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="" @@ -403,10 +484,12 @@ curl_http_get() { 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" @@ -457,9 +540,11 @@ fetch_latest_release_version() { 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 @@ -884,6 +969,7 @@ 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 @@ -892,9 +978,11 @@ download_and_install() { local checksums_url="${base_url}/sha256sums.txt" 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}..." @@ -1016,6 +1104,9 @@ download_and_install() { 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 @@ -1029,6 +1120,10 @@ download_and_install() { 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="" @@ -1040,11 +1135,74 @@ download_and_install() { # --- 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}:"*) ;; *) @@ -1073,6 +1231,36 @@ check_path() { 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 } report_local_mirror_start_failure() { diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 37e84fcfea..ef31afc434 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -1383,7 +1383,7 @@ public void DownloadAndInstall_BackupDirMktempFailure_PreservesExistingHealthyIn mkdir -p "{{payloadDir}}" cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' #!/usr/bin/env bash - echo "NEW_BINARY" + echo "cdidx v1.2.3" EOF chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" @@ -1482,7 +1482,7 @@ public void DownloadAndInstall_MissingAsset_DoesNotOverwriteExistingHealthyInsta mkdir -p "{{payloadDir}}" cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' #!/usr/bin/env bash - echo "BROKEN_NEW_BINARY" + echo "cdidx v1.2.3" EOF chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" @@ -1586,7 +1586,7 @@ public void DownloadAndInstall_MoveFailure_RollsBackExistingHealthyInstall() mkdir -p "{{payloadDir}}" cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' #!/usr/bin/env bash - echo "NEW_BINARY" + echo "cdidx v1.2.3" EOF chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" @@ -1718,7 +1718,7 @@ public void DownloadAndInstall_BackupMoveFailure_PreservesExistingHealthyInstall mkdir -p "{{payloadDir}}" cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' #!/usr/bin/env bash - echo "NEW_BINARY" + echo "cdidx v1.2.3" EOF chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" @@ -1854,7 +1854,7 @@ public void DownloadAndInstall_RollbackFailure_PreservesRecoveryArtifacts() mkdir -p "{{payloadDir}}" cat > "{{Path.Combine(payloadDir, "cdidx")}}" <<'EOF' #!/usr/bin/env bash - echo "NEW_BINARY" + echo "cdidx v1.2.3" EOF chmod +x "{{Path.Combine(payloadDir, "cdidx")}}" printf '{"version":"1.2.3"}' > "{{Path.Combine(payloadDir, "version.json")}}" @@ -3226,6 +3226,125 @@ shift 2 Assert.Contains("https://mirror.example/releases/Widthdom/CodeIndex/releases/download/v1.2.3/sha256sums.txt", stdout); } + [Fact] + public void ProbeTempRoot_UnwritableTmpdir_PrintsSpecificError() + { + if (OperatingSystem.IsWindows()) + return; + + var tmpdir = Path.Combine(_tempRoot, "not_a_dir"); + File.WriteAllText(tmpdir, "not a directory"); + + var (exitCode, _, stderr) = RunInstallerSnippet( + """ + probe_temp_root + """, + new Dictionary + { + ["TMPDIR"] = tmpdir, + }, + enforceStrictMode: false); + + Assert.Equal(1, exitCode); + Assert.Contains("TMPDIR not usable", stderr); + } + + [Fact] + public void VerifyCdidxBinary_VersionMismatch_FailsBeforeSuccess() + { + if (OperatingSystem.IsWindows()) + return; + + var binDir = Path.Combine(_tempRoot, "verify_mismatch"); + Directory.CreateDirectory(binDir); + var binaryPath = Path.Combine(binDir, "cdidx"); + File.WriteAllText(binaryPath, "#!/usr/bin/env bash\necho 'cdidx v9.9.9'\n"); + File.SetUnixFileMode(binaryPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + VERSION="v1.2.3" + verify_cdidx_binary "{{binaryPath}}" + echo "UNREACHABLE" + """, + enforceStrictMode: false); + + Assert.Equal(1, exitCode); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("Installed binary version mismatch", stderr); + Assert.Contains("expected 1.2.3, got 9.9.9", stderr); + } + + [Fact] + public void CheckPath_WhenOlderCdidxPrecedesInstallDir_WarnsAboutShadowing() + { + if (OperatingSystem.IsWindows()) + return; + + var oldDir = Path.Combine(_tempRoot, "old_path_bin"); + var installDir = Path.Combine(_tempRoot, "new_path_bin"); + Directory.CreateDirectory(oldDir); + Directory.CreateDirectory(installDir); + var oldBinary = Path.Combine(oldDir, "cdidx"); + var newBinary = Path.Combine(installDir, "cdidx"); + File.WriteAllText(oldBinary, "#!/usr/bin/env bash\necho 'cdidx v1.0.0'\n"); + File.WriteAllText(newBinary, "#!/usr/bin/env bash\necho 'cdidx v1.2.3'\n"); + File.SetUnixFileMode(oldBinary, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.SetUnixFileMode(newBinary, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + check_path + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + ["PATH"] = oldDir + Path.PathSeparator + installDir, + }); + + Assert.Equal(0, exitCode); + Assert.Contains("cdidx binaries found on PATH", stdout); + Assert.Contains(oldBinary, stdout); + Assert.Contains(newBinary, stdout); + Assert.Contains("shadowing the new install", stderr); + } + + [Fact] + public void CheckPath_UpdatePathOptIn_AppendsProfileExportAndUpdatesCurrentPath() + { + if (OperatingSystem.IsWindows()) + return; + + var homeDir = Path.Combine(_tempRoot, "path_profile_home"); + var installDir = Path.Combine(_tempRoot, "profile_install_bin"); + Directory.CreateDirectory(homeDir); + Directory.CreateDirectory(installDir); + var newBinary = Path.Combine(installDir, "cdidx"); + File.WriteAllText(newBinary, "#!/usr/bin/env bash\necho 'cdidx v1.2.3'\n"); + File.SetUnixFileMode(newBinary, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + export HOME="{{homeDir}}" + export SHELL="/bin/bash" + check_path + echo "ACTIVE:$(command -v cdidx)" + cat "{{Path.Combine(homeDir, ".bashrc")}}" + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + ["CDIDX_INSTALL_UPDATE_PATH"] = "1", + ["PATH"] = "/usr/bin:/bin", + }); + + Assert.Equal(0, exitCode); + Assert.Contains($"Added {installDir} to PATH", stdout); + Assert.Contains($"ACTIVE:{newBinary}", stdout); + Assert.Contains($"export PATH=\"{installDir}:$PATH\"", stdout); + Assert.Contains($"{installDir} is not in your PATH", stderr); + } + [Fact] public void ReinstallReal_WithoutVersionArgument_FailsWithUsageError() {