feat(releases): add SelfUpdater.DownloadAndVerifyAsync

This commit is contained in:
mika kuns
2026-04-23 14:45:13 +02:00
parent 0c3dcb0052
commit 98c188a5da
2 changed files with 108 additions and 0 deletions

View File

@@ -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<string?> DownloadAndVerifyAsync(
IReleaseClient releases,
ReleaseAsset installerAsset,
ReleaseAsset checksumsAsset,
string tempDir,
IProgress<long> 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<long>(_ => { }), 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;
}
}

View File

@@ -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<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult<GiteaRelease?>(null);
public async Task DownloadAsync(string url, string destPath, IProgress<long> 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<long>(_ => { }), 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<long>(_ => { }), 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();
}
}