diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 5f528990c2..9187207f92 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -333,6 +333,13 @@ On startup, `cdidx` walks up from the current directory looking for `.cdidx-vers `cdidx --check-updates` and `cdidx status --check-updates` query the GitHub latest-release endpoint through `UpdateChecker`, using the same 24-hour cache and `CDIDX_DISABLE_UPDATE_CHECK=1` opt-out as the `--version` hint. `cdidx upgrade --check-only` reuses that check. `cdidx upgrade` is intentionally a thin wrapper around the signed release installer: it downloads `install.sh`, verifies the current binary directory is writable, sets `CDIDX_INSTALL_DIR` to that directory, and runs the installer for the latest release. +`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. + ### Degradation reason codes Readiness degradation reason codes are centralized in `DegradationReasonCodes`. Add new codes there with human text, a recommended action, and an alternative action before emitting them from readers, CLI, or MCP payloads. @@ -2393,6 +2400,13 @@ endpoint を確認します。`cdidx upgrade --check-only` はこの check を 現在の binary directory が writable か確認し、`CDIDX_INSTALL_DIR` をその directory に向けて 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` が +追加されます。 + ### 劣化理由コード readiness degradation reason code は `DegradationReasonCodes` に集約します。reader、CLI、 diff --git a/README.md b/README.md index 47ee3b6cb7..168d627b47 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,13 @@ performed. Set `CDIDX_RELEASE_GPG_FINGERPRINT=` to pin the expected release signing key; strict mode requires this fingerprint once GPG verification succeeds. +`cdidx upgrade --json` keeps stdout machine-readable even when an update is +available. `--check-only --json` and no-update checks keep the update-check +shape (`current_version`, `latest_version`, `update_available`, `from_cache`, +`error`); install attempts suppress installer stdout/stderr and add +`install_attempted`, `install_exit_code`, and `install_succeeded` to the single +JSON document written to stdout. + See [DISTRIBUTION.md](DISTRIBUTION.md) for the full channel matrix and [isolated network install notes](USER_GUIDE.md#isolated-networks-and-proxies). For database compatibility across `cdidx` upgrades and downgrades, see @@ -440,6 +447,13 @@ installer は `sha256sums.txt.asc` も取得して GnuPG がある場合は `CDIDX_RELEASE_GPG_FINGERPRINT=` を設定します。strict mode では、 GPG 検証が成功した後にこの fingerprint の設定も必須です。 +`cdidx upgrade --json` は update が見つかった場合も stdout を機械処理向けに保ちます。 +`--check-only --json` と no-update の check は update-check shape +(`current_version`, `latest_version`, `update_available`, `from_cache`, `error`) +を維持します。install を試行する場合は installer stdout/stderr を抑制し、stdout に書く +1 個の JSON document に `install_attempted`、`install_exit_code`、 +`install_succeeded` を追加します。 + ### Validate `cdidx validate [--db ] [--json[=array]] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]` diff --git a/changelog.d/unreleased/3011.fixed.md b/changelog.d/unreleased/3011.fixed.md new file mode 100644 index 0000000000..0ae2268207 --- /dev/null +++ b/changelog.d/unreleased/3011.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3011 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Upgrade release downloads now honor caller cancellation (#3011)** — `cdidx upgrade` passes the active cancellation token into release checksum and installer script downloads instead of waiting for download timeouts after cancellation. + +## 日本語 + +- **upgrade のリリース download が caller cancellation を尊重するようになりました (#3011)** — `cdidx upgrade` は release checksum と installer script の download に active cancellation token を渡し、キャンセル後に download timeout まで待ち続けないようになりました。 diff --git a/changelog.d/unreleased/3058.fixed.md b/changelog.d/unreleased/3058.fixed.md new file mode 100644 index 0000000000..30e872c3ae --- /dev/null +++ b/changelog.d/unreleased/3058.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3058 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Upgrade installer runs now honor caller cancellation (#3058)** — `cdidx upgrade` observes cancellation while waiting for `install.sh` and terminates the installer process tree instead of blocking until the installer timeout. + +## 日本語 + +- **upgrade の installer 実行が caller cancellation を尊重するようになりました (#3058)** — `cdidx upgrade` は `install.sh` の終了待機中にもキャンセルを監視し、installer timeout までブロックせず installer process tree を終了します。 diff --git a/changelog.d/unreleased/3074.fixed.md b/changelog.d/unreleased/3074.fixed.md new file mode 100644 index 0000000000..a49d0c3e42 --- /dev/null +++ b/changelog.d/unreleased/3074.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3074 +affected: + - src/CodeIndex/Cli/UpdateChecker.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Update-check cache parsing now enforces a JSON depth cap (#3074)** — malformed over-depth local cache files are ignored as cache misses instead of forcing unbounded parser depth work. + +## 日本語 + +- **update-check cache の JSON parse が depth cap を適用するようになりました (#3074)** — 深すぎる malformed local cache file は unbounded な parser depth work を発生させず、cache miss として無視されます。 diff --git a/changelog.d/unreleased/3191.fixed.md b/changelog.d/unreleased/3191.fixed.md new file mode 100644 index 0000000000..d6346c7dba --- /dev/null +++ b/changelog.d/unreleased/3191.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3191 +affected: + - DEVELOPER_GUIDE.md + - README.md + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **`cdidx upgrade --json` now keeps stdout as JSON when installing updates (#3191)** — update-available upgrade runs suppress installer console noise and emit a structured install result with the update-check fields. + +## 日本語 + +- **`cdidx upgrade --json` が update install 時も stdout を JSON のまま保つようになりました (#3191)** — update available の upgrade 実行は installer の console 出力を混ぜず、update-check fields を含む構造化された install result を出力します。 diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 055ea4400f..b1a0536bca 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -39,6 +39,16 @@ internal sealed record CommandErrorJsonResult( [property: JsonPropertyName("path")] string? Path = null, [property: JsonPropertyName("category")] string? Category = null); +internal sealed record UpgradeJsonResult( + [property: JsonPropertyName("current_version")] string CurrentVersion, + [property: JsonPropertyName("latest_version")] string? LatestVersion, + [property: JsonPropertyName("update_available")] bool UpdateAvailable, + [property: JsonPropertyName("from_cache")] bool FromCache, + [property: JsonPropertyName("error")] string? Error, + [property: JsonPropertyName("install_attempted")] bool InstallAttempted, + [property: JsonPropertyName("install_exit_code")] int? InstallExitCode, + [property: JsonPropertyName("install_succeeded")] bool? InstallSucceeded); + internal sealed record DbIntegrityCheckJsonResult( [property: JsonPropertyName("db_path")] string DbPath, [property: JsonPropertyName("ok")] bool Ok, @@ -485,6 +495,7 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(SymbolResult))] [JsonSerializable(typeof(UnusedSymbolResult))] [JsonSerializable(typeof(CodeIndex.Models.UpdateCheckResult))] +[JsonSerializable(typeof(UpgradeJsonResult))] [JsonSerializable(typeof(VacuumResult))] [JsonSerializable(typeof(VersionInfoJsonResult))] [JsonSerializable(typeof(WorkspaceListJsonResult))] diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index b4d1dbf78b..3d25dd91bf 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -12,6 +12,7 @@ using CodeIndex.Indexer.Hooks; using CodeIndex.Lsp; using CodeIndex.Mcp; +using CodeIndex.Models; using Microsoft.Data.Sqlite; namespace CodeIndex.Cli; @@ -38,6 +39,7 @@ internal static class ProgramRunner private static readonly HashSet TopLevelValueOptionNames = CliFlagSchema.GetTopLevelValueOptionNames(); internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; + internal static Func UpgradeHttpClientFactory { get; set; } = CreateUpgradeHttpClient; private sealed record CommandRunContext( JsonSerializerOptions JsonOptions, @@ -3043,29 +3045,47 @@ internal static int RunUpgrade( if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) { - Console.Error.WriteLine("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); - Console.Error.WriteLine("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult(result, installAttempted: false, installExitCode: null, "unsupported_platform"), + jsonOptions)); + } + else + { + Console.Error.WriteLine("Error: cdidx upgrade currently requires a POSIX shell installer on Linux or macOS."); + Console.Error.WriteLine("Hint: download the latest release asset manually, or rerun install.sh from a shell environment."); + } return CommandExitCodes.FeatureUnavailable; } var installDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (!CanWriteDirectory(installDir)) { - Console.Error.WriteLine($"Error: install directory is not writable: {installDir}"); - Console.Error.WriteLine("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult(result, installAttempted: false, installExitCode: null, "install_directory_not_writable"), + jsonOptions)); + } + else + { + Console.Error.WriteLine($"Error: install directory is not writable: {installDir}"); + Console.Error.WriteLine("Hint: rerun with permissions that can write this directory, or reinstall cdidx into a per-user directory."); + } return CommandExitCodes.UsageError; } var scriptPath = Path.Combine(Path.GetTempPath(), $"cdidx-install-{Guid.NewGuid():N}.sh"); try { - using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(20) }) + using (var client = UpgradeHttpClientFactory()) { var checksumManifest = DownloadReleaseChecksumManifestAsync( client, result.LatestVersion, TimeSpan.FromSeconds(20), - CancellationToken.None) + cancellationToken) .GetAwaiter() .GetResult(); var expectedInstallerSha256 = GetReleaseAssetChecksum(checksumManifest, InstallerScriptAssetName); @@ -3075,19 +3095,46 @@ internal static int RunUpgrade( result.LatestVersion, scriptPath, TimeSpan.FromSeconds(20), - CancellationToken.None) + cancellationToken) .GetAwaiter() .GetResult(); VerifyFileSha256(scriptPath, expectedInstallerSha256, InstallerScriptAssetName); } var startInfo = CreateInstallerProcessStartInfo(scriptPath, result.LatestVersion, installDir); - return RunInstallerProcess(startInfo, InstallerRunTimeout); + var installExitCode = RunInstallerProcess( + startInfo, + InstallerRunTimeout, + cancellationToken, + suppressOutput: wantsJson); + if (wantsJson) + { + var error = installExitCode == CommandExitCodes.Success + ? null + : $"installer_exit_code_{installExitCode.ToString(CultureInfo.InvariantCulture)}"; + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult(result, installAttempted: true, installExitCode, error), + jsonOptions)); + } + return installExitCode; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch (Exception ex) { - Console.Error.WriteLine($"Error: upgrade failed before install.sh completed ({ex.GetType().Name}: {ex.Message})."); - Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release."); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize( + CreateUpgradeJsonResult(result, installAttempted: false, installExitCode: null, ex.GetType().Name), + jsonOptions)); + } + else + { + Console.Error.WriteLine($"Error: upgrade failed before install.sh completed ({ex.GetType().Name}: {ex.Message})."); + Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release."); + } return CommandExitCodes.DatabaseError; } finally @@ -3109,27 +3156,94 @@ internal static ProcessStartInfo CreateInstallerProcessStartInfo(string scriptPa return startInfo; } - internal static int RunInstallerProcess(ProcessStartInfo startInfo, TimeSpan timeout) + internal static int RunInstallerProcess( + ProcessStartInfo startInfo, + TimeSpan timeout, + CancellationToken cancellationToken = default, + bool suppressOutput = false) { + if (suppressOutput) + { + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + } + using var process = Process.Start(startInfo); if (process == null) { - Console.Error.WriteLine("Error: failed to start install.sh for upgrade."); + if (!suppressOutput) + Console.Error.WriteLine("Error: failed to start install.sh for upgrade."); return CommandExitCodes.DatabaseError; } - if (process.WaitForExit(ToWaitMilliseconds(timeout))) + var outputDrainTask = suppressOutput + ? Task.WhenAll(process.StandardOutput.ReadToEndAsync(), process.StandardError.ReadToEndAsync()) + : Task.CompletedTask; + + try + { + var waitTask = process.WaitForExitAsync(cancellationToken); + var timeoutTask = Task.Delay(ToWaitMilliseconds(timeout)); + if (Task.WhenAny(waitTask, timeoutTask).GetAwaiter().GetResult() == waitTask) + { + waitTask.GetAwaiter().GetResult(); + outputDrainTask.GetAwaiter().GetResult(); + return process.ExitCode; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + TryKillProcessTree(process); + if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) + { + if (!suppressOutput) + Console.Error.WriteLine("Error: install.sh was cancelled and did not exit after cancellation."); + } + else + { + outputDrainTask.GetAwaiter().GetResult(); + } + throw; + } + + if (process.HasExited) + { + outputDrainTask.GetAwaiter().GetResult(); return process.ExitCode; + } TryKillProcessTree(process); if (!process.WaitForExit(ToWaitMilliseconds(InstallerKillWaitTimeout))) - Console.Error.WriteLine("Error: install.sh timed out and did not exit after cancellation."); + { + if (!suppressOutput) + Console.Error.WriteLine("Error: install.sh timed out and did not exit after cancellation."); + } else - Console.Error.WriteLine($"Error: install.sh timed out after {FormatDuration(timeout)}."); - Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release."); + { + outputDrainTask.GetAwaiter().GetResult(); + if (!suppressOutput) + Console.Error.WriteLine($"Error: install.sh timed out after {FormatDuration(timeout)}."); + } + if (!suppressOutput) + Console.Error.WriteLine("Hint: rerun `install.sh` manually for the desired release."); return CommandExitCodes.DatabaseError; } + private static UpgradeJsonResult CreateUpgradeJsonResult( + UpdateCheckResult result, + bool installAttempted, + int? installExitCode, + string? error) + => new( + result.CurrentVersion, + result.LatestVersion, + result.UpdateAvailable, + result.FromCache, + error ?? result.Error, + installAttempted, + installExitCode, + installExitCode is null ? null : installExitCode == CommandExitCodes.Success); + internal static string BuildInstallerScriptUrl(string releaseTag) => BuildReleaseAssetUrl(releaseTag, InstallerScriptAssetName); @@ -3140,6 +3254,9 @@ internal static string BuildReleaseAssetUrl(string releaseTag, string assetName) Uri.EscapeDataString(releaseTag.Trim()), Uri.EscapeDataString(assetName)); + private static HttpClient CreateUpgradeHttpClient() + => new() { Timeout = TimeSpan.FromSeconds(20) }; + internal static async Task DownloadReleaseChecksumManifestAsync( HttpClient client, string releaseTag, diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index 945c75afb8..556d99b233 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -12,6 +12,7 @@ internal static class UpdateChecker internal const long MaxLatestReleaseResponseBytes = 64 * 1024; internal const int MaxLatestReleaseJsonDepth = 16; internal const int MaxUpdateCheckCacheBytes = 8 * 1024; + internal const int MaxUpdateCheckCacheJsonDepth = 8; private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(2); @@ -200,7 +201,9 @@ private static string ResolveDefaultCachePath() if (text is null) return null; - using var doc = JsonDocument.Parse(text); + using var doc = JsonDocument.Parse( + text, + new JsonDocumentOptions { MaxDepth = MaxUpdateCheckCacheJsonDepth }); var root = doc.RootElement; if (!root.TryGetProperty("checked_at", out var checkedAtElement) || !DateTimeOffset.TryParse( diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 892e430574..cc90f83b16 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -745,6 +745,32 @@ public void UpdateChecker_Check_IgnoresOversizedCache() } } + [Fact] + public void UpdateChecker_Check_IgnoresOverDepthCache() + { + var cachePath = Path.Combine(Path.GetTempPath(), $"cdidx_update_check_{Guid.NewGuid():N}.json"); + try + { + var depth = UpdateChecker.MaxUpdateCheckCacheJsonDepth + 8; + File.WriteAllText(cachePath, new string('[', depth) + new string(']', depth)); + + var result = UpdateChecker.Check( + "1.10.0", + cachePath, + DateTimeOffset.Parse("2026-01-01T00:00:00Z"), + _ => Task.FromResult("v1.11.0")); + + Assert.False(result.FromCache); + Assert.Equal("v1.11.0", result.LatestVersion); + Assert.True(result.UpdateAvailable); + } + finally + { + if (File.Exists(cachePath)) + File.Delete(cachePath); + } + } + [Fact] public void UpdateChecker_Check_PassesCallerCancellationTokenToFetch() { @@ -926,6 +952,43 @@ sleep 5 } } + [Fact] + public void RunInstallerProcess_CancelsHungInstaller() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_installer_cancel_{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + var script = Path.Combine(root, "install.sh"); + var pidFile = Path.Combine(root, "installer.pid"); + try + { + File.WriteAllText(script, $""" +#!/bin/sh +echo $$ > {ShellQuote(pidFile)} +sleep 30 +"""); + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + var startInfo = ProgramRunner.CreateInstallerProcessStartInfo(script, "v1.27.0", root); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + Assert.ThrowsAny(() => + ProgramRunner.RunInstallerProcess(startInfo, TimeSpan.FromSeconds(30), cts.Token)); + + Assert.True(File.Exists(pidFile)); + var pid = int.Parse(File.ReadAllText(pidFile), CultureInfo.InvariantCulture); + Assert.False(IsProcessRunning(pid)); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + } + [Fact] public async Task DownloadInstallerScriptAsync_CancelsStalledBody() { @@ -951,6 +1014,114 @@ await Assert.ThrowsAnyAsync(() => } } + [Fact] + public void RunUpgrade_PassesCallerCancellationToReleaseDownloads() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture("XDG_CACHE_HOME", UpdateChecker.DisableEnvVar); + var cacheRoot = Path.Combine(Path.GetTempPath(), $"cdidx_update_cache_{Guid.NewGuid():N}"); + env.Set("XDG_CACHE_HOME", cacheRoot); + env.Set(UpdateChecker.DisableEnvVar, null); + WriteFreshUpdateCheckCache(cacheRoot, "v9.9.9"); + + var installerScript = "#!/bin/sh\nexit 0\n"; + var installerSha256 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(installerScript))).ToLowerInvariant(); + var checksumManifest = $"{installerSha256} install.sh\n"; + var observedCanBeCanceled = new List(); + var previousFactory = ProgramRunner.UpgradeHttpClientFactory; + ProgramRunner.UpgradeHttpClientFactory = () => new HttpClient( + new UpgradeAssetResponseHandler( + checksumManifest, + installerScript, + token => observedCanBeCanceled.Add(token.CanBeCanceled))) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + using var cts = new CancellationTokenSource(); + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["upgrade"], + appVersion: "1.10.0", + cancellationToken: cts.Token)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stdout); + Assert.Empty(stderr); + Assert.Equal([true, true], observedCanBeCanceled); + } + finally + { + ProgramRunner.UpgradeHttpClientFactory = previousFactory; + TestProjectHelper.DeleteDirectory(cacheRoot); + } + } + } + + [Fact] + public void RunUpgrade_JsonUpdateAvailable_EmitsSingleJsonInstallResult() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture("XDG_CACHE_HOME", UpdateChecker.DisableEnvVar); + var cacheRoot = Path.Combine(Path.GetTempPath(), $"cdidx_update_cache_{Guid.NewGuid():N}"); + env.Set("XDG_CACHE_HOME", cacheRoot); + env.Set(UpdateChecker.DisableEnvVar, null); + WriteFreshUpdateCheckCache(cacheRoot, "v9.9.9"); + + var installerScript = """ +#!/bin/sh +echo SHOULD_NOT_LEAK_STDOUT +echo SHOULD_NOT_LEAK_STDERR >&2 +exit 0 +"""; + var installerSha256 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(installerScript))).ToLowerInvariant(); + var checksumManifest = $"{installerSha256} install.sh\n"; + var previousFactory = ProgramRunner.UpgradeHttpClientFactory; + ProgramRunner.UpgradeHttpClientFactory = () => new HttpClient( + new UpgradeAssetResponseHandler( + checksumManifest, + installerScript, + _ => { })) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["upgrade", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + Assert.DoesNotContain("SHOULD_NOT_LEAK", stdout); + using var doc = JsonDocument.Parse(stdout); + var root = doc.RootElement; + Assert.Equal("1.10.0", root.GetProperty("current_version").GetString()); + Assert.Equal("v9.9.9", root.GetProperty("latest_version").GetString()); + Assert.True(root.GetProperty("update_available").GetBoolean()); + Assert.True(root.GetProperty("from_cache").GetBoolean()); + Assert.True(root.GetProperty("install_attempted").GetBoolean()); + Assert.Equal(CommandExitCodes.Success, root.GetProperty("install_exit_code").GetInt32()); + Assert.True(root.GetProperty("install_succeeded").GetBoolean()); + } + finally + { + ProgramRunner.UpgradeHttpClientFactory = previousFactory; + TestProjectHelper.DeleteDirectory(cacheRoot); + } + } + } + [Fact] public async Task DownloadReleaseChecksumManifestAsync_RejectsOverLimitResponse() { @@ -2346,6 +2517,37 @@ public void TryConsumeDebugUnsafeFlag_AfterDoubleDash_PreservesQueryEscape() private static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func action) => ConsoleCapture.Capture(action); + private static void WriteFreshUpdateCheckCache(string cacheRoot, string latestTag) + { + var cacheDir = Path.Combine(cacheRoot, "cdidx"); + Directory.CreateDirectory(cacheDir); + File.WriteAllText( + Path.Combine(cacheDir, "update-check.json"), + $$""" + {"checked_at":"{{DateTimeOffset.UtcNow.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)}}","latest_tag":"{{latestTag}}"} + """); + } + + private static string ShellQuote(string value) + => "'" + value.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + + private static bool IsProcessRunning(int pid) + { + try + { + using var process = System.Diagnostics.Process.GetProcessById(pid); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + private static void AssertCanonicalCommandError(string stderr) { var lines = stderr.TrimEnd().Split(Environment.NewLine); @@ -2543,6 +2745,36 @@ protected override Task SendAsync(HttpRequestMessage reques => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = _content }); } + private sealed class UpgradeAssetResponseHandler : HttpMessageHandler + { + private readonly string _checksumManifest; + private readonly string _installerScript; + private readonly Action _observeToken; + + internal UpgradeAssetResponseHandler( + string checksumManifest, + string installerScript, + Action observeToken) + { + _checksumManifest = checksumManifest; + _installerScript = installerScript; + _observeToken = observeToken; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + _observeToken(cancellationToken); + var path = request.RequestUri?.AbsolutePath ?? string.Empty; + HttpContent content = path.EndsWith("/sha256sums.txt", StringComparison.Ordinal) + ? new StringContent(_checksumManifest, Encoding.UTF8, "text/plain") + : path.EndsWith("/install.sh", StringComparison.Ordinal) + ? new StringContent(_installerScript, Encoding.UTF8, "text/x-shellscript") + : new StringContent(string.Empty, Encoding.UTF8, "text/plain"); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + } + } + private sealed class StalledContent : HttpContent { protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)