From 98c188a5da8eb8af26ba0cc6870609a8fc24c91c Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 23 Apr 2026 14:45:13 +0200 Subject: [PATCH] feat(releases): add SelfUpdater.DownloadAndVerifyAsync --- src/ClaudeDo.Releases/SelfUpdater.cs | 31 ++++++++ .../SelfUpdaterTests.cs | 77 +++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/ClaudeDo.Releases/SelfUpdater.cs b/src/ClaudeDo.Releases/SelfUpdater.cs index 99ada5b..e0004d1 100644 --- a/src/ClaudeDo.Releases/SelfUpdater.cs +++ b/src/ClaudeDo.Releases/SelfUpdater.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Net.Http; using System.Text.RegularExpressions; @@ -92,4 +93,34 @@ public static partial class SelfUpdater File.Copy(currentExePath, oldPath, overwrite: false); return launchProcess(oldPath); } + + public static async Task DownloadAndVerifyAsync( + IReleaseClient releases, + ReleaseAsset installerAsset, + ReleaseAsset checksumsAsset, + string tempDir, + IProgress progress, + CancellationToken ct) + { + Directory.CreateDirectory(tempDir); + var installerPath = Path.Combine(tempDir, installerAsset.Name); + var checksumsPath = Path.Combine(tempDir, "checksums.txt"); + + try + { + await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct); + await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress(_ => { }), ct); + } + catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException) + { + return null; + } + + var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct); + var map = ChecksumVerifier.ParseChecksumsFile(checksumsText); + if (!map.TryGetValue(installerAsset.Name, out var expected)) + return null; + + return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null; + } } diff --git a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs index 62c33f8..01d1c82 100644 --- a/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs +++ b/tests/ClaudeDo.Releases.Tests/SelfUpdaterTests.cs @@ -177,3 +177,80 @@ public class SelfUpdaterReplaceSelfTests : IDisposable Assert.False(result); } } + +public class SelfUpdaterDownloadTests : IDisposable +{ + private readonly string _tempDir; + + public SelfUpdaterDownloadTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Releases.Tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() { try { Directory.Delete(_tempDir, true); } catch { } } + + private sealed class StubReleaseClient : IReleaseClient + { + public string FileContent { get; set; } = ""; + public string ChecksumsBody { get; set; } = ""; + + public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(null); + + public async Task DownloadAsync(string url, string destPath, IProgress progress, CancellationToken ct) + { + if (url.EndsWith("checksums.txt", StringComparison.OrdinalIgnoreCase)) + { + await File.WriteAllTextAsync(destPath, ChecksumsBody, ct); + } + else + { + await File.WriteAllTextAsync(destPath, FileContent, ct); + } + progress.Report(FileContent.Length); + } + } + + [Fact] + public async Task Download_MatchingChecksum_ReturnsPath() + { + var content = "FAKE-INSTALLER-BINARY"; + var hash = Sha256Hex(content); + var client = new StubReleaseClient + { + FileContent = content, + ChecksumsBody = $"{hash} ClaudeDo.Installer-0.3.0.exe\n", + }; + var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", content.Length); + var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); + + var path = await SelfUpdater.DownloadAndVerifyAsync( + client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None); + + Assert.NotNull(path); + Assert.Equal(content, await File.ReadAllTextAsync(path!)); + } + + [Fact] + public async Task Download_ChecksumMismatch_ReturnsNull() + { + var client = new StubReleaseClient + { + FileContent = "real", + ChecksumsBody = "deadbeef" + new string('0', 56) + " ClaudeDo.Installer-0.3.0.exe\n", + }; + var installer = new ReleaseAsset("ClaudeDo.Installer-0.3.0.exe", "https://x/inst", 4); + var checksums = new ReleaseAsset("checksums.txt", "https://x/checksums.txt", 100); + + var path = await SelfUpdater.DownloadAndVerifyAsync( + client, installer, checksums, _tempDir, new Progress(_ => { }), CancellationToken.None); + + Assert.Null(path); + } + + private static string Sha256Hex(string s) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + return Convert.ToHexString(sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(s))).ToLowerInvariant(); + } +}