feat(installer): add DownloadAndExtractStep with SHA256 verify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,4 +27,6 @@ public sealed class InstallContext
|
||||
|
||||
// InstallPage
|
||||
public bool CreateDesktopShortcut { get; set; } = true;
|
||||
|
||||
public string? InstalledVersion { get; set; }
|
||||
}
|
||||
|
||||
79
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal file
79
src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using ClaudeDo.Installer.Core;
|
||||
|
||||
namespace ClaudeDo.Installer.Steps;
|
||||
|
||||
public sealed class DownloadAndExtractStep : IInstallStep
|
||||
{
|
||||
private readonly IReleaseClient _releases;
|
||||
|
||||
public DownloadAndExtractStep(IReleaseClient releases)
|
||||
{
|
||||
_releases = releases;
|
||||
}
|
||||
|
||||
public string Name => "Download and Extract";
|
||||
|
||||
public async Task<StepResult> ExecuteAsync(InstallContext ctx, IProgress<string> progress, CancellationToken ct)
|
||||
{
|
||||
progress.Report("Fetching latest release metadata...");
|
||||
var release = await _releases.GetLatestReleaseAsync(ct);
|
||||
if (release is null)
|
||||
return StepResult.Fail("Could not reach the release server. Check your network connection and try again.");
|
||||
|
||||
var zipAsset = release.Assets.FirstOrDefault(a =>
|
||||
a.Name.StartsWith("ClaudeDo-", StringComparison.OrdinalIgnoreCase) &&
|
||||
a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
|
||||
var checksumAsset = release.Assets.FirstOrDefault(a =>
|
||||
a.Name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (zipAsset is null)
|
||||
return StepResult.Fail("Release zip asset not found in release metadata.");
|
||||
if (checksumAsset is null)
|
||||
return StepResult.Fail("checksums.txt not found in release metadata.");
|
||||
|
||||
var scratchDir = Path.Combine(Path.GetTempPath(), "ClaudeDo-install-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(scratchDir);
|
||||
|
||||
try
|
||||
{
|
||||
var zipPath = Path.Combine(scratchDir, zipAsset.Name);
|
||||
var checksumPath = Path.Combine(scratchDir, "checksums.txt");
|
||||
|
||||
progress.Report($"Downloading {zipAsset.Name} ({zipAsset.Size / (1024 * 1024)} MB)...");
|
||||
await _releases.DownloadAsync(zipAsset.BrowserDownloadUrl, zipPath,
|
||||
new Progress<long>(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")),
|
||||
ct);
|
||||
|
||||
progress.Report("Downloading checksums...");
|
||||
await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath,
|
||||
new Progress<long>(_ => { }), ct);
|
||||
|
||||
progress.Report("Verifying checksum...");
|
||||
var map = ChecksumVerifier.ParseChecksumsFile(File.ReadAllText(checksumPath));
|
||||
if (!map.TryGetValue(zipAsset.Name, out var expectedHash))
|
||||
return StepResult.Fail($"No checksum entry for {zipAsset.Name} in checksums.txt.");
|
||||
if (!ChecksumVerifier.Verify(zipPath, expectedHash))
|
||||
return StepResult.Fail("Checksum mismatch — the downloaded zip may be corrupt or tampered with.");
|
||||
|
||||
// Only after verification do we touch the install directory.
|
||||
progress.Report("Clearing previous app/worker binaries...");
|
||||
var appDest = Path.Combine(ctx.InstallDirectory, "app");
|
||||
var workerDest = Path.Combine(ctx.InstallDirectory, "worker");
|
||||
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
||||
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
||||
|
||||
progress.Report("Extracting...");
|
||||
Directory.CreateDirectory(ctx.InstallDirectory);
|
||||
ZipFile.ExtractToDirectory(zipPath, ctx.InstallDirectory, overwriteFiles: true);
|
||||
|
||||
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
|
||||
return StepResult.Ok();
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
147
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal file
147
tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Steps;
|
||||
|
||||
namespace ClaudeDo.Installer.Tests;
|
||||
|
||||
public sealed class DownloadAndExtractStepTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
private readonly string _installDir;
|
||||
|
||||
public DownloadAndExtractStepTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoDownloadStep-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
_installDir = Path.Combine(_tempDir, "install");
|
||||
Directory.CreateDirectory(_installDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
private sealed class FileCopyReleaseClient : IReleaseClient
|
||||
{
|
||||
private readonly Dictionary<string, string> _urlToSourceFile;
|
||||
public GiteaRelease? Release { get; set; }
|
||||
|
||||
public FileCopyReleaseClient(Dictionary<string, string> urlToSourceFile)
|
||||
=> _urlToSourceFile = urlToSourceFile;
|
||||
|
||||
public Task<GiteaRelease?> GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release);
|
||||
|
||||
public Task DownloadAsync(string url, string destPath, IProgress<long> progress, CancellationToken ct)
|
||||
{
|
||||
File.Copy(_urlToSourceFile[url], destPath, overwrite: true);
|
||||
progress.Report(new FileInfo(destPath).Length);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Extracts_Zip_Into_InstallDir_App_And_Worker()
|
||||
{
|
||||
var zipPath = Path.Combine(_tempDir, "release.zip");
|
||||
using (var fs = File.Create(zipPath))
|
||||
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
|
||||
{
|
||||
var a = zip.CreateEntry("app/a.txt");
|
||||
using (var w = new StreamWriter(a.Open())) w.Write("hello-app");
|
||||
var b = zip.CreateEntry("worker/b.txt");
|
||||
using (var w = new StreamWriter(b.Open())) w.Write("hello-worker");
|
||||
}
|
||||
|
||||
var zipHash = ChecksumVerifier.ComputeSha256(zipPath);
|
||||
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
|
||||
File.WriteAllText(checksumsPath, $"{zipHash} ClaudeDo-0.1.0-win-x64.zip\n");
|
||||
|
||||
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
|
||||
{
|
||||
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", new FileInfo(zipPath).Length),
|
||||
new ReleaseAsset("checksums.txt", "fake://checksums", new FileInfo(checksumsPath).Length),
|
||||
});
|
||||
|
||||
var client = new FileCopyReleaseClient(new()
|
||||
{
|
||||
["fake://zip"] = zipPath,
|
||||
["fake://checksums"] = checksumsPath,
|
||||
}) { Release = release };
|
||||
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Equal("hello-app", File.ReadAllText(Path.Combine(_installDir, "app", "a.txt")));
|
||||
Assert.Equal("hello-worker", File.ReadAllText(Path.Combine(_installDir, "worker", "b.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_On_ChecksumMismatch_Without_Overwriting_InstallDir()
|
||||
{
|
||||
var zipPath = Path.Combine(_tempDir, "release.zip");
|
||||
using (var fs = File.Create(zipPath))
|
||||
using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
|
||||
{
|
||||
var a = zip.CreateEntry("app/a.txt");
|
||||
using (var w = new StreamWriter(a.Open())) w.Write("x");
|
||||
}
|
||||
|
||||
var checksumsPath = Path.Combine(_tempDir, "checksums.txt");
|
||||
File.WriteAllText(checksumsPath, $"{new string('0', 64)} ClaudeDo-0.1.0-win-x64.zip\n");
|
||||
|
||||
File.WriteAllText(Path.Combine(_installDir, "marker.txt"), "untouched");
|
||||
|
||||
var release = new GiteaRelease("v0.1.0", "v0.1.0", new[]
|
||||
{
|
||||
new ReleaseAsset("ClaudeDo-0.1.0-win-x64.zip", "fake://zip", 0),
|
||||
new ReleaseAsset("checksums.txt", "fake://checksums", 0),
|
||||
});
|
||||
|
||||
var client = new FileCopyReleaseClient(new()
|
||||
{
|
||||
["fake://zip"] = zipPath,
|
||||
["fake://checksums"] = checksumsPath,
|
||||
}) { Release = release };
|
||||
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("checksum", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.True(File.Exists(Path.Combine(_installDir, "marker.txt")));
|
||||
Assert.False(Directory.Exists(Path.Combine(_installDir, "app")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_When_Release_Has_No_Zip_Asset()
|
||||
{
|
||||
var release = new GiteaRelease("v0.1.0", "v0.1.0", Array.Empty<ReleaseAsset>());
|
||||
var client = new FileCopyReleaseClient(new()) { Release = release };
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_When_ReleaseClient_Returns_Null()
|
||||
{
|
||||
var client = new FileCopyReleaseClient(new()) { Release = null };
|
||||
var step = new DownloadAndExtractStep(client);
|
||||
var ctx = new InstallContext { InstallDirectory = _installDir };
|
||||
|
||||
var result = await step.ExecuteAsync(ctx, new Progress<string>(_ => { }), CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user