diff --git a/src/ClaudeDo.Installer/Core/InstallContext.cs b/src/ClaudeDo.Installer/Core/InstallContext.cs index eafded5..39dd358 100644 --- a/src/ClaudeDo.Installer/Core/InstallContext.cs +++ b/src/ClaudeDo.Installer/Core/InstallContext.cs @@ -27,4 +27,6 @@ public sealed class InstallContext // InstallPage public bool CreateDesktopShortcut { get; set; } = true; + + public string? InstalledVersion { get; set; } } diff --git a/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs new file mode 100644 index 0000000..8b93212 --- /dev/null +++ b/src/ClaudeDo.Installer/Steps/DownloadAndExtractStep.cs @@ -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 ExecuteAsync(InstallContext ctx, IProgress 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(b => progress.Report($" {b / (1024 * 1024)} MB downloaded")), + ct); + + progress.Report("Downloading checksums..."); + await _releases.DownloadAsync(checksumAsset.BrowserDownloadUrl, checksumPath, + new Progress(_ => { }), 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 */ } + } + } +} diff --git a/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs new file mode 100644 index 0000000..b8acdb2 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/DownloadAndExtractStepTests.cs @@ -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 _urlToSourceFile; + public GiteaRelease? Release { get; set; } + + public FileCopyReleaseClient(Dictionary urlToSourceFile) + => _urlToSourceFile = urlToSourceFile; + + public Task GetLatestReleaseAsync(CancellationToken ct) => Task.FromResult(Release); + + public Task DownloadAsync(string url, string destPath, IProgress 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(_ => { }), 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(_ => { }), 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()); + 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(_ => { }), 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(_ => { }), CancellationToken.None); + + Assert.False(result.Success); + } +}