From e14806d834794eccde598930a5113ec4fb8d1d1f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:14:47 +0900 Subject: [PATCH 1/6] Fix trusted installer shell resolution (#3378) --- changelog.d/unreleased/3378.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Cli/ProgramRunner.cs | 16 +++++++++++++++- tests/CodeIndex.Tests/ProgramRunnerTests.cs | 7 ++++++- 3 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3378.fixed.md diff --git a/changelog.d/unreleased/3378.fixed.md b/changelog.d/unreleased/3378.fixed.md new file mode 100644 index 0000000000..f34ab2db7e --- /dev/null +++ b/changelog.d/unreleased/3378.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3378 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Upgrade installer launches bash through a trusted absolute path (#3378)** — `cdidx upgrade` now resolves the POSIX installer shell from known absolute paths instead of relying on the ambient `PATH`. + +## 日本語 + +- **upgrade installer が信頼済みの絶対パスから bash を起動するようになりました (#3378)** — `cdidx upgrade` は周囲の `PATH` に頼らず、既知の絶対パスから POSIX installer shell を解決します。 diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 6195917035..3cfc1e46d4 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -3198,7 +3198,7 @@ internal static ProcessStartInfo CreateInstallerProcessStartInfo(string scriptPa { var startInfo = new ProcessStartInfo { - FileName = "bash", + FileName = ResolveTrustedBashPath(), UseShellExecute = false, }; startInfo.ArgumentList.Add(scriptPath); @@ -3207,6 +3207,20 @@ internal static ProcessStartInfo CreateInstallerProcessStartInfo(string scriptPa return startInfo; } + internal static string ResolveTrustedBashPath() + { + if (OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException("The install.sh upgrade path requires a POSIX bash executable."); + + foreach (var candidate in new[] { "/bin/bash", "/usr/bin/bash" }) + { + if (File.Exists(candidate)) + return candidate; + } + + throw new FileNotFoundException("Could not find a trusted absolute bash path for running install.sh."); + } + internal static int RunInstallerProcess( ProcessStartInfo startInfo, TimeSpan timeout, diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 5e3259bfc1..59c4eeb151 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -972,12 +972,17 @@ public void VerifyFileSha256_RejectsMismatchedDigest() [Fact] public void CreateInstallerProcessStartInfo_UsesArgumentList() { + if (OperatingSystem.IsWindows()) + return; + var startInfo = ProgramRunner.CreateInstallerProcessStartInfo( "/tmp/install script's path.sh", "v1.27.0", "/opt/cdidx install"); - Assert.Equal("bash", startInfo.FileName); + Assert.True(Path.IsPathFullyQualified(startInfo.FileName)); + Assert.Equal("bash", Path.GetFileName(startInfo.FileName)); + Assert.NotEqual("bash", startInfo.FileName); Assert.False(startInfo.UseShellExecute); Assert.Equal(string.Empty, startInfo.Arguments); Assert.Equal(["/tmp/install script's path.sh", "v1.27.0"], startInfo.ArgumentList.ToArray()); From 3d00538098a975cbac5ae147f22cd33312ad6d6b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:19:55 +0900 Subject: [PATCH 2/6] Harden sensitive temporary storage (#3411) --- changelog.d/unreleased/3411.fixed.md | 23 +++++++++++++ src/CodeIndex/Cli/DataDirectorySecurity.cs | 6 ++++ .../Cli/ExportImportCommandRunner.cs | 30 +++++++++++++++-- src/CodeIndex/Cli/GlobalToolLog.cs | 2 +- src/CodeIndex/Cli/ProgramRunner.cs | 10 ++++-- src/CodeIndex/Cli/UpdateChecker.cs | 9 +++-- .../DataDirectorySecurityTests.cs | 23 +++++++++++++ .../ExportImportCommandRunnerTests.cs | 12 +++++-- tests/CodeIndex.Tests/GlobalToolLogTests.cs | 4 +++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 33 +++++++++++++++++++ 10 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/3411.fixed.md diff --git a/changelog.d/unreleased/3411.fixed.md b/changelog.d/unreleased/3411.fixed.md new file mode 100644 index 0000000000..159f9d0c1c --- /dev/null +++ b/changelog.d/unreleased/3411.fixed.md @@ -0,0 +1,23 @@ +--- +category: fixed +issues: + - 3411 +affected: + - src/CodeIndex/Cli/DataDirectorySecurity.cs + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - src/CodeIndex/Cli/GlobalToolLog.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/UpdateChecker.cs + - tests/CodeIndex.Tests/DataDirectorySecurityTests.cs + - tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs + - tests/CodeIndex.Tests/GlobalToolLogTests.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Sensitive temporary and cache files now use private directories (#3411)** — upgrade installer scripts, export snapshots, update-check cache writes, and persistent log directories now create owner-only storage before writing sensitive material. + +## 日本語 + +- **機微な一時ファイルとキャッシュファイルが private directory を使うようになりました (#3411)** — upgrade installer script、export snapshot、update-check cache、persistent log directory は、機微な内容を書き込む前に所有者限定の保存先を作成します。 diff --git a/src/CodeIndex/Cli/DataDirectorySecurity.cs b/src/CodeIndex/Cli/DataDirectorySecurity.cs index f5f1a0c877..cc1c4e6110 100644 --- a/src/CodeIndex/Cli/DataDirectorySecurity.cs +++ b/src/CodeIndex/Cli/DataDirectorySecurity.cs @@ -34,6 +34,12 @@ public static DirectoryInfo CreateSensitiveDirectory(string path) return directory; } + public static DirectoryInfo CreateSensitiveTempDirectory(string prefix) + { + var path = Path.Combine(Path.GetTempPath(), $"{prefix}{Guid.NewGuid():N}"); + return CreateSensitiveDirectory(path); + } + public static void ApplyPrivateMode(string path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 57daf69d62..38e424726c 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -238,10 +238,13 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt return WriteExportError(wantsJson, jsonOptions, PhaseParseArgs, "export_archive_overlaps_database", "export archive path must not be the source database or a SQLite sidecar.", "choose a separate archive path, for example `codeindex.cdidx.zip`.", "cdidx export [--db ] [--json]"); } - var snapshotPath = Path.Combine(Path.GetTempPath(), $"codeindex-export-{Guid.NewGuid():N}.db"); + string? snapshotDirectory = null; + string? snapshotPath = null; var phase = PhaseWriteArchive; try { + snapshotDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("codeindex-export-").FullName; + snapshotPath = Path.Combine(snapshotDirectory, "codeindex.db"); var outputDirectory = Path.GetDirectoryName(fullOutputPath); if (!string.IsNullOrWhiteSpace(outputDirectory)) Directory.CreateDirectory(outputDirectory); @@ -272,8 +275,13 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt } finally { - TryDeleteFile(snapshotPath, "export temporary database"); - DeleteSqliteSidecars(snapshotPath, "export temporary database sidecar"); + if (snapshotPath != null) + { + TryDeleteFile(snapshotPath, "export temporary database"); + DeleteSqliteSidecars(snapshotPath, "export temporary database sidecar"); + } + if (snapshotDirectory != null) + TryDeleteDirectoryIfEmpty(snapshotDirectory, "export temporary directory"); } } @@ -861,6 +869,22 @@ private static void TryDeleteFile(string path, string? cleanupDescription = null } } + private static void TryDeleteDirectoryIfEmpty(string path, string? cleanupDescription = null) + { + try + { + if (!Directory.Exists(path) || Directory.EnumerateFileSystemEntries(path).Any()) + return; + + Directory.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + if (!string.IsNullOrWhiteSpace(cleanupDescription)) + Console.Error.WriteLine($"Warning: failed to delete {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + internal static Action? DeleteFileForTesting { get; set; } internal static Action? DeleteSqliteSidecarForTesting { get; set; } diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index e77ebc59a4..a46c495034 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -337,7 +337,7 @@ private static bool CanWriteProbe(string directory) { try { - Directory.CreateDirectory(directory); + DataDirectorySecurity.CreateSensitiveDirectory(directory); var probePath = Path.Combine(directory, $".cdidx-write-probe-{Guid.NewGuid():N}.tmp"); File.WriteAllText(probePath, string.Empty, Encoding.UTF8); File.Delete(probePath); diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 3cfc1e46d4..f9b71504bf 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -3127,9 +3127,12 @@ internal static int RunUpgrade( return CommandExitCodes.UsageError; } - var scriptPath = Path.Combine(Path.GetTempPath(), $"cdidx-install-{Guid.NewGuid():N}.sh"); + string? scriptDirectory = null; + string? scriptPath = null; try { + scriptDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("cdidx-install-").FullName; + scriptPath = Path.Combine(scriptDirectory, "install.sh"); using (var client = UpgradeHttpClientFactory()) { var checksumManifest = DownloadReleaseChecksumManifestAsync( @@ -3190,7 +3193,10 @@ internal static int RunUpgrade( } finally { - try { File.Delete(scriptPath); } catch { } + if (scriptPath != null) + try { File.Delete(scriptPath); } catch { } + if (scriptDirectory != null) + try { Directory.Delete(scriptDirectory, recursive: true); } catch { } } } diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index 556d99b233..eab0af8362 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -182,11 +182,14 @@ private static string ResolveDefaultCachePath() { var xdgCacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME"); var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); var root = !string.IsNullOrWhiteSpace(xdgCacheHome) ? Path.Combine(xdgCacheHome, "cdidx") : !string.IsNullOrWhiteSpace(home) ? Path.Combine(home, ".cache", "cdidx") - : Path.Combine(Path.GetTempPath(), "cdidx"); + : !string.IsNullOrWhiteSpace(localAppData) + ? Path.Combine(localAppData, "cdidx") + : Path.Combine(Path.GetTempPath(), "cdidx", "cache"); return Path.Combine(root, "update-check.json"); } @@ -232,14 +235,14 @@ private static void TryWriteCache(string cachePath, UpdateCheckCache cache) { var directory = Path.GetDirectoryName(cachePath); if (!string.IsNullOrWhiteSpace(directory)) - Directory.CreateDirectory(directory); + DataDirectorySecurity.CreateSensitiveDirectory(directory); var payload = new { checked_at = cache.CheckedAt.UtcDateTime.ToString("O", CultureInfo.InvariantCulture), latest_tag = cache.LatestTag, }; - AtomicFileWriter.WriteJson(cachePath, payload); + AtomicFileWriter.WriteJson(cachePath, payload, applyFileMode: DataDirectorySecurity.ApplyPrivateFileMode); } catch { diff --git a/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs b/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs index dac1dec0c3..feb976afb4 100644 --- a/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs +++ b/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs @@ -60,6 +60,29 @@ public void CreateSensitiveDirectory_OnPosix_Forces0700Mode() } } + [Fact] + public void CreateSensitiveTempDirectory_OnPosix_Forces0700Mode() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + DirectoryInfo? tempDir = null; + try + { + tempDir = DataDirectorySecurity.CreateSensitiveTempDirectory("cdidx-sensitive-test-"); + + Assert.StartsWith("cdidx-sensitive-test-", tempDir.Name, StringComparison.Ordinal); + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(tempDir.FullName) & DataDirectorySecurity.PermissionBits); + } + finally + { + if (tempDir != null && Directory.Exists(tempDir.FullName)) + Directory.Delete(tempDir.FullName, recursive: true); + } + } + [Fact] public void WritePrivateText_OnPosix_Forces0600Mode() { diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index 32db80009b..e7495f5a43 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -281,8 +281,8 @@ public void RunExportArchive_TemporaryDatabaseCleanupFailureWarnsWithoutFailing_ var outputPath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); ExportImportCommandRunner.DeleteFileForTesting = path => { - if (Path.GetFileName(path).StartsWith("codeindex-export-", StringComparison.Ordinal) - && path.EndsWith(".db", StringComparison.Ordinal)) + if (Path.GetFileName(path) == "codeindex.db" + && Path.GetFileName(Path.GetDirectoryName(path)!).StartsWith("codeindex-export-", StringComparison.Ordinal)) { cleanupPath = path; throw new IOException("simulated export temp cleanup failure"); @@ -301,12 +301,18 @@ public void RunExportArchive_TemporaryDatabaseCleanupFailureWarnsWithoutFailing_ Assert.Contains("IOException", stderr); Assert.NotNull(cleanupPath); Assert.True(File.Exists(cleanupPath)); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(Path.GetDirectoryName(cleanupPath)!) & DataDirectorySecurity.PermissionBits); + } } finally { ExportImportCommandRunner.DeleteFileForTesting = null; if (cleanupPath != null && File.Exists(cleanupPath)) - File.Delete(cleanupPath); + TestProjectHelper.DeleteDirectory(Path.GetDirectoryName(cleanupPath)!); TestProjectHelper.DeleteDirectory(projectRoot); } } diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index 9e12a2170f..df1469fe62 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -285,6 +285,10 @@ public void ResolveLogDirectoryForStatus_SkipsUnwritableCandidate() var resolved = GlobalToolLog.ResolveLogDirectoryForStatus(); Assert.Equal(Path.Combine(stateHome, "cdidx", "logs"), resolved); + if (!OperatingSystem.IsWindows()) + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(resolved) & PermissionBits); } finally { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 59c4eeb151..c803ef0cc6 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -1194,6 +1194,39 @@ exit 0 } } + [Fact] + public void UpdateChecker_Check_WritesCacheWithPrivateModes_Issue3411() + { + using var env = EnvironmentVariableScope.Capture(UpdateChecker.DisableEnvVar); + env.Set(UpdateChecker.DisableEnvVar, null); + var cacheRoot = Path.Combine(Path.GetTempPath(), $"cdidx_update_cache_private_{Guid.NewGuid():N}"); + var cachePath = Path.Combine(cacheRoot, "cdidx", "update-check.json"); + try + { + var result = UpdateChecker.Check( + "1.0.0", + cachePath, + DateTimeOffset.UtcNow, + _ => Task.FromResult("v9.9.9")); + + Assert.True(result.UpdateAvailable); + Assert.True(File.Exists(cachePath)); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(Path.GetDirectoryName(cachePath)!) & DataDirectorySecurity.PermissionBits); + Assert.Equal( + DataDirectorySecurity.PrivateFileMode, + File.GetUnixFileMode(cachePath) & DataDirectorySecurity.PermissionBits); + } + } + finally + { + TestProjectHelper.DeleteDirectory(cacheRoot); + } + } + [Fact] public async Task DownloadReleaseChecksumManifestAsync_RejectsOverLimitResponse() { From 1e829813eb469211220bee2b2be2248645a69f97 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:25:19 +0900 Subject: [PATCH 3/6] Validate installer cache purge paths (#3499) --- changelog.d/unreleased/3499.fixed.md | 16 ++++ install.sh | 81 ++++++++++++++++++++- tests/CodeIndex.Tests/InstallScriptTests.cs | 65 +++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3499.fixed.md diff --git a/changelog.d/unreleased/3499.fixed.md b/changelog.d/unreleased/3499.fixed.md new file mode 100644 index 0000000000..042935df83 --- /dev/null +++ b/changelog.d/unreleased/3499.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3499 +affected: + - install.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **`install.sh --purge-cache` now validates cache roots before deletion (#3499)** — uninstall cache purges now reject unsafe, relative, empty, or root cache locations before recursively removing the cdidx cache directory. + +## 日本語 + +- **`install.sh --purge-cache` が削除前に cache root を検証するようになりました (#3499)** — uninstall 時の cache purge は、cdidx cache directory を再帰削除する前に、危険な値・相対パス・空・root の cache location を拒否します。 diff --git a/install.sh b/install.sh index ddbfe58689..6ae69736ee 100755 --- a/install.sh +++ b/install.sh @@ -587,6 +587,79 @@ is_self_test_install_dir_risky() { return 1 } +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" } @@ -1449,6 +1522,13 @@ uninstall_cdidx() { 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" \ @@ -1473,7 +1553,6 @@ uninstall_cdidx() { fi if [ "$PURGE_CACHE_ON_UNINSTALL" = "1" ]; then - local cache_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/cdidx" if [ -d "$cache_dir" ]; then rm -rf "$cache_dir" info "Removed ${cache_dir}" diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index e1a441efaf..bd0e2ac2ab 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -53,6 +53,71 @@ public void Uninstall_RemovesInstalledPayloadAndLeavesProjectData() Assert.False(Directory.Exists(Path.Combine(installDir, "LICENSES"))); } + [Fact] + public void UninstallPurgeCache_RemovesOnlyValidatedCacheDirectory_Issue3499() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "uninstall_purge_bin"); + var cacheRoot = Path.Combine(_tempRoot, "xdg_cache"); + var cdidxCache = Path.Combine(cacheRoot, "cdidx"); + var siblingCache = Path.Combine(cacheRoot, "other"); + Directory.CreateDirectory(installDir); + Directory.CreateDirectory(cdidxCache); + Directory.CreateDirectory(siblingCache); + File.WriteAllText(Path.Combine(installDir, "cdidx"), "#!/usr/bin/env bash\n"); + File.WriteAllText(Path.Combine(cdidxCache, "update-check.json"), "{}"); + File.WriteAllText(Path.Combine(siblingCache, "keep.txt"), "keep"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + PURGE_CACHE_ON_UNINSTALL=1 + uninstall_cdidx + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + ["XDG_CACHE_HOME"] = cacheRoot + "/", + }); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("Removed", stdout); + Assert.False(Directory.Exists(cdidxCache)); + Assert.True(File.Exists(Path.Combine(siblingCache, "keep.txt"))); + } + + [Fact] + public void UninstallPurgeCache_RejectsUnsafeCacheRootBeforeRemovingInstall_Issue3499() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "uninstall_bad_cache_root_bin"); + Directory.CreateDirectory(installDir); + var binaryPath = Path.Combine(installDir, "cdidx"); + File.WriteAllText(binaryPath, "#!/usr/bin/env bash\n"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + PURGE_CACHE_ON_UNINSTALL=1 + uninstall_cdidx + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + ["XDG_CACHE_HOME"] = "/", + }, + enforceStrictMode: false); + + Assert.NotEqual(0, exitCode); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("Refusing to purge cdidx cache from unsafe cache root", stderr); + Assert.True(File.Exists(binaryPath)); + } + [Fact] public void DownloadAndInstall_SecuresStageDirectoryAfterMktemp() { From 32fac57aac011d25fa77162ec212006902b443e3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:30:52 +0900 Subject: [PATCH 4/6] Validate rollback asset names (#3503) --- changelog.d/unreleased/3503.fixed.md | 16 +++++++ install.sh | 35 +++++++++++++++ tests/CodeIndex.Tests/InstallScriptTests.cs | 50 +++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 changelog.d/unreleased/3503.fixed.md diff --git a/changelog.d/unreleased/3503.fixed.md b/changelog.d/unreleased/3503.fixed.md new file mode 100644 index 0000000000..a479642345 --- /dev/null +++ b/changelog.d/unreleased/3503.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3503 +affected: + - install.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **Rollback cleanup now validates promoted asset names before deletion (#3503)** — installer rollback removal rejects unsafe or unexpected asset names before deleting any promoted files. + +## 日本語 + +- **Rollback cleanup が削除前に promoted asset name を検証するようになりました (#3503)** — installer の rollback removal は、promoted file を削除する前に危険または想定外の asset name を拒否します。 diff --git a/install.sh b/install.sh index 6ae69736ee..4806680246 100755 --- a/install.sh +++ b/install.sh @@ -964,11 +964,46 @@ restore_backed_up_files() { 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 diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index bd0e2ac2ab..903425ea15 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -118,6 +118,56 @@ public void UninstallPurgeCache_RejectsUnsafeCacheRootBeforeRemovingInstall_Issu Assert.True(File.Exists(binaryPath)); } + [Fact] + public void RemovePromotedFiles_RemovesExpectedAssets_Issue3503() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "rollback_remove_expected"); + Directory.CreateDirectory(Path.Combine(installDir, "LICENSES")); + File.WriteAllText(Path.Combine(installDir, "version.json"), "{}"); + File.WriteAllText(Path.Combine(installDir, "libe_sqlite3.so"), ""); + File.WriteAllText(Path.Combine(installDir, "LICENSES", "notice.txt"), ""); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + remove_promoted_files "{{installDir}}" "version.json libe_sqlite3.so LICENSES" + echo "REMOVE_OK" + """); + + Assert.Equal(0, exitCode); + Assert.Contains("REMOVE_OK", stdout); + Assert.Equal(string.Empty, stderr); + Assert.False(File.Exists(Path.Combine(installDir, "version.json"))); + Assert.False(File.Exists(Path.Combine(installDir, "libe_sqlite3.so"))); + Assert.False(Directory.Exists(Path.Combine(installDir, "LICENSES"))); + } + + [Fact] + public void RemovePromotedFiles_RejectsUnsafeAssetBeforeDeletingAny_Issue3503() + { + if (OperatingSystem.IsWindows()) + return; + + var installDir = Path.Combine(_tempRoot, "rollback_reject_unsafe"); + Directory.CreateDirectory(installDir); + var versionPath = Path.Combine(installDir, "version.json"); + File.WriteAllText(versionPath, "{}"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + $$""" + remove_promoted_files "{{installDir}}" "version.json ../outside" + echo "UNREACHABLE" + """, + enforceStrictMode: false); + + Assert.NotEqual(0, exitCode); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("Refusing to remove unsafe rollback asset name", stderr); + Assert.True(File.Exists(versionPath)); + } + [Fact] public void DownloadAndInstall_SecuresStageDirectoryAfterMktemp() { From 0a550c3f4f341e4ffa9a984cb28cace7e4468672 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 09:40:20 +0900 Subject: [PATCH 5/6] Validate installer install directories (#3511) --- changelog.d/unreleased/3511.security.md | 16 ++ install.sh | 155 +++++++++++++++++++- tests/CodeIndex.Tests/InstallScriptTests.cs | 153 +++++++++++++++++++ 3 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3511.security.md diff --git a/changelog.d/unreleased/3511.security.md b/changelog.d/unreleased/3511.security.md new file mode 100644 index 0000000000..db96a4ded5 --- /dev/null +++ b/changelog.d/unreleased/3511.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3511 +affected: + - install.sh + - tests/CodeIndex.Tests/InstallScriptTests.cs +--- + +## English + +- **Normal install and uninstall now reject risky install directories (#3511)** — `install.sh` validates `CDIDX_INSTALL_DIR` before locking, installing, or removing files, and requires `CDIDX_ALLOW_RISKY_INSTALL_DIR=1` for root, home, or system install targets. + +## 日本語 + +- **通常 install / uninstall が危険な install directory を拒否するようになりました (#3511)** — `install.sh` は lock・install・file removal の前に `CDIDX_INSTALL_DIR` を検証し、root・home・system install target には `CDIDX_ALLOW_RISKY_INSTALL_DIR=1` を要求します。 diff --git a/install.sh b/install.sh index 4806680246..4154c96fa7 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,7 @@ # 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_INSTALL_DIR=/usr/local/bin; curl -fsSL ... | bash +# 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] @@ -21,6 +21,7 @@ # 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 @@ -74,7 +75,7 @@ set -euo pipefail REPO="Widthdom/CodeIndex" -INSTALL_DIR="${CDIDX_INSTALL_DIR:-$HOME/.local/bin}" +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}" @@ -587,6 +588,148 @@ is_self_test_install_dir_risky() { 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 @@ -1553,6 +1696,9 @@ check_path() { uninstall_cdidx() { info "cdidx uninstaller" + if ! validate_normal_install_dir; then + return 1 + fi acquire_install_lock local removed=0 @@ -2289,6 +2435,11 @@ format_doctor_probe_status() { 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 diff --git a/tests/CodeIndex.Tests/InstallScriptTests.cs b/tests/CodeIndex.Tests/InstallScriptTests.cs index 903425ea15..0d0e419428 100644 --- a/tests/CodeIndex.Tests/InstallScriptTests.cs +++ b/tests/CodeIndex.Tests/InstallScriptTests.cs @@ -118,6 +118,159 @@ public void UninstallPurgeCache_RejectsUnsafeCacheRootBeforeRemovingInstall_Issu Assert.True(File.Exists(binaryPath)); } + [Theory] + [InlineData("/")] + [InlineData("/usr/local")] + [InlineData("/tmp")] + public void Main_RiskyInstallDir_RejectsBeforeLockOrDownload_Issue3511(string installDir) + { + if (OperatingSystem.IsWindows()) + return; + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + detect_platform() { echo "PLATFORM_SHOULD_NOT_RUN"; } + acquire_install_lock() { echo "LOCK_SHOULD_NOT_RUN"; } + download_and_install() { echo "DOWNLOAD_SHOULD_NOT_RUN"; } + + main v1.2.3 + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + }, + enforceStrictMode: false); + + Assert.NotEqual(0, exitCode); + Assert.DoesNotContain("PLATFORM_SHOULD_NOT_RUN", stdout); + Assert.DoesNotContain("LOCK_SHOULD_NOT_RUN", stdout); + Assert.DoesNotContain("DOWNLOAD_SHOULD_NOT_RUN", stdout); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("Refusing risky install directory", stderr); + Assert.Contains("CDIDX_ALLOW_RISKY_INSTALL_DIR=1", stderr); + } + + [Theory] + [InlineData("")] + [InlineData("bin")] + [InlineData("../bin")] + public void Main_AmbiguousInstallDir_RejectsBeforeLock_Issue3511(string installDir) + { + if (OperatingSystem.IsWindows()) + return; + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + acquire_install_lock() { echo "LOCK_SHOULD_NOT_RUN"; } + + main v1.2.3 + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = installDir, + }, + enforceStrictMode: false); + + Assert.NotEqual(0, exitCode); + Assert.DoesNotContain("LOCK_SHOULD_NOT_RUN", stdout); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("Refusing", stderr); + } + + [Fact] + public void Main_DefaultHomeLocalBin_RemainsAllowed_Issue3511() + { + if (OperatingSystem.IsWindows()) + return; + + var homeDir = Path.Combine(_tempRoot, "default_install_home"); + Directory.CreateDirectory(homeDir); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + detect_platform() { OS_NAME="linux"; ARCH_NAME="x64"; RID="linux-x64"; } + acquire_install_lock() { echo "LOCK:$INSTALL_DIR"; } + detect_existing_install() { :; } + resolve_version() { VERSION="${1:-v1.2.3}"; echo "VERSION:$VERSION"; return 0; } + check_existing() { :; } + download_and_install() { echo "DOWNLOAD:$INSTALL_DIR"; } + check_path() { :; } + + main v1.2.3 + """, + new Dictionary + { + ["HOME"] = homeDir, + }); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("DOWNLOAD:", stdout); + Assert.Contains("/.local/bin", stdout); + } + + [Fact] + public void Main_RiskyInstallDir_ExplicitOverrideAllows_Issue3511() + { + if (OperatingSystem.IsWindows()) + return; + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + detect_platform() { OS_NAME="linux"; ARCH_NAME="x64"; RID="linux-x64"; } + acquire_install_lock() { echo "LOCK:$INSTALL_DIR"; } + detect_existing_install() { :; } + resolve_version() { VERSION="${1:-v1.2.3}"; echo "VERSION:$VERSION"; return 0; } + check_existing() { :; } + download_and_install() { echo "DOWNLOAD:$INSTALL_DIR"; } + check_path() { :; } + + main v1.2.3 + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = "/usr/local/bin", + ["CDIDX_ALLOW_RISKY_INSTALL_DIR"] = "1", + }); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("LOCK:/usr/local/bin", stdout); + Assert.Contains("DOWNLOAD:/usr/local/bin", stdout); + } + + [Fact] + public void Uninstall_RiskyHomeInstallDir_RejectsBeforeRemoval_Issue3511() + { + if (OperatingSystem.IsWindows()) + return; + + var homeDir = Path.Combine(_tempRoot, "uninstall_home_target"); + Directory.CreateDirectory(homeDir); + var binaryPath = Path.Combine(homeDir, "cdidx"); + File.WriteAllText(binaryPath, "#!/usr/bin/env bash\n"); + + var (exitCode, stdout, stderr) = RunInstallerSnippet( + """ + uninstall_cdidx + echo "UNREACHABLE" + """, + new Dictionary + { + ["CDIDX_INSTALL_DIR"] = homeDir, + ["HOME"] = homeDir, + }, + enforceStrictMode: false); + + Assert.NotEqual(0, exitCode); + Assert.DoesNotContain("Removed", stdout); + Assert.DoesNotContain("UNREACHABLE", stdout); + Assert.Contains("Refusing risky install directory", stderr); + Assert.True(File.Exists(binaryPath)); + } + [Fact] public void RemovePromotedFiles_RemovesExpectedAssets_Issue3503() { From e71a2d47ea1b32de89356e6053015365a6fafe5e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 10:00:13 +0900 Subject: [PATCH 6/6] Harden import dry-run temporary storage (#3411) --- .../Cli/ExportImportCommandRunner.cs | 24 +++++++-- .../ExportImportCommandRunnerTests.cs | 51 +++++++++++++++++++ 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 38e424726c..3b730b01d5 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -93,14 +93,23 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) if (string.IsNullOrWhiteSpace(dbDirectory)) return WriteImportError(wantsJson, jsonOptions, PhaseParseArgs, "import_db_directory_unresolved", $"could not resolve destination DB directory for `{dbPath}`.", "pass an explicit `--db `.", ImportUsage); - var tempDirectory = dryRun ? Path.GetTempPath() : dbDirectory; - var tempPath = Path.Combine(tempDirectory, $".codeindex-import-{Guid.NewGuid():N}.db"); + string? tempDirectory = null; + string? tempPath = null; var validationPhases = new List(); var phase = PhaseOpenArchive; try { - if (!dryRun) + if (dryRun) + { + tempDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("codeindex-import-").FullName; + tempPath = Path.Combine(tempDirectory, "codeindex.db"); + } + else + { Directory.CreateDirectory(dbDirectory); + tempPath = Path.Combine(dbDirectory, $".codeindex-import-{Guid.NewGuid():N}.db"); + } + using (var archive = ZipFile.OpenRead(archivePath)) { AddImportValidationPhase(validationPhases, PhaseOpenArchive); @@ -187,8 +196,13 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) } finally { - TryDeleteFile(tempPath, "import temporary database"); - DeleteSqliteSidecars(tempPath, "import temporary database sidecar"); + if (tempPath != null) + { + TryDeleteFile(tempPath, "import temporary database"); + DeleteSqliteSidecars(tempPath, "import temporary database sidecar"); + } + if (tempDirectory != null) + TryDeleteDirectoryIfEmpty(tempDirectory, "import temporary directory"); } } diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index e7495f5a43..70cf3f5ecb 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -245,6 +245,57 @@ public void RunImport_TemporaryDatabaseCleanupFailureWarnsAndPreservesImportErro } } + [Fact] + public void RunImport_DryRunUsesPrivateTemporaryDirectory_Issue3411() + { + var projectRoot = TestProjectHelper.CreateTempProject("import_dry_run_private_temp"); + string? cleanupPath = null; + try + { + var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); + var exportResult = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport([archivePath, "--db", sourceDbPath], new JsonSerializerOptions(), "test")); + Assert.Equal(CommandExitCodes.Success, exportResult.ExitCode); + + var importDbPath = Path.Combine(projectRoot, "imported.db"); + ExportImportCommandRunner.DeleteFileForTesting = path => + { + if (Path.GetFileName(path) == "codeindex.db" + && Path.GetFileName(Path.GetDirectoryName(path)!).StartsWith("codeindex-import-", StringComparison.Ordinal)) + { + cleanupPath = path; + throw new IOException("simulated import dry-run temp cleanup failure"); + } + + File.Delete(path); + }; + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport([archivePath, "--db", importDbPath, "--dry-run"], new JsonSerializerOptions())); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Contains("Validated CodeIndex archive", stdout); + Assert.False(File.Exists(importDbPath)); + Assert.Contains("Warning: failed to delete import temporary database", stderr); + Assert.NotNull(cleanupPath); + Assert.True(File.Exists(cleanupPath)); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(Path.GetDirectoryName(cleanupPath)!) & DataDirectorySecurity.PermissionBits); + } + } + finally + { + ExportImportCommandRunner.DeleteFileForTesting = null; + if (cleanupPath != null && File.Exists(cleanupPath)) + TestProjectHelper.DeleteDirectory(Path.GetDirectoryName(cleanupPath)!); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunExportArchive_FailureOmitsRawExceptionMessage() {