From d0c0e2ce1f036bd197665445892348ac87eade66 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 15 Apr 2026 09:03:08 +0200 Subject: [PATCH] feat(installer): add ChecksumVerifier (SHA256 + checksums.txt parser) --- .../Core/ChecksumVerifier.cs | 37 ++++++ .../ChecksumVerifierTests.cs | 106 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/ClaudeDo.Installer/Core/ChecksumVerifier.cs create mode 100644 tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs diff --git a/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs b/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs new file mode 100644 index 0000000..b2835a1 --- /dev/null +++ b/src/ClaudeDo.Installer/Core/ChecksumVerifier.cs @@ -0,0 +1,37 @@ +using System.IO; +using System.Security.Cryptography; + +namespace ClaudeDo.Installer.Core; + +public static class ChecksumVerifier +{ + public static string ComputeSha256(string filePath) + { + using var stream = File.OpenRead(filePath); + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(stream); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static bool Verify(string filePath, string expectedSha256) + { + var actual = ComputeSha256(filePath); + return string.Equals(actual, expectedSha256.Trim(), StringComparison.OrdinalIgnoreCase); + } + + public static IReadOnlyDictionary ParseChecksumsFile(string content) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var rawLine in content.Split('\n')) + { + var line = rawLine.Trim(); + if (line.Length == 0) continue; + var parts = line.Split(new[] { ' ', '\t' }, 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length != 2) continue; + var hashPart = parts[0].Trim(); + if (hashPart.Length != 64) continue; + map[parts[1].Trim()] = hashPart; + } + return map; + } +} diff --git a/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs b/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs new file mode 100644 index 0000000..d72a0d5 --- /dev/null +++ b/tests/ClaudeDo.Installer.Tests/ChecksumVerifierTests.cs @@ -0,0 +1,106 @@ +using System.IO; +using ClaudeDo.Installer.Core; + +namespace ClaudeDo.Installer.Tests; + +public sealed class ChecksumVerifierTests : IDisposable +{ + private readonly string _tempDir; + + public ChecksumVerifierTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "ClaudeDoChecksum-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { } + } + + [Fact] + public void ComputeSha256_KnownVector_EmptyFile() + { + var path = Path.Combine(_tempDir, "empty.bin"); + File.WriteAllBytes(path, Array.Empty()); + + var hash = ChecksumVerifier.ComputeSha256(path); + + Assert.Equal("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash); + } + + [Fact] + public void ComputeSha256_KnownVector_Hello() + { + var path = Path.Combine(_tempDir, "hello.bin"); + File.WriteAllText(path, "hello"); + + var hash = ChecksumVerifier.ComputeSha256(path); + + Assert.Equal("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", hash); + } + + [Fact] + public void Verify_ReturnsTrue_WhenHashMatches() + { + var path = Path.Combine(_tempDir, "x.bin"); + File.WriteAllText(path, "hello"); + + Assert.True(ChecksumVerifier.Verify(path, + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824")); + } + + [Fact] + public void Verify_IsCaseInsensitive() + { + var path = Path.Combine(_tempDir, "x.bin"); + File.WriteAllText(path, "hello"); + + Assert.True(ChecksumVerifier.Verify(path, + "2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824")); + } + + [Fact] + public void Verify_ReturnsFalse_OnMismatch() + { + var path = Path.Combine(_tempDir, "x.bin"); + File.WriteAllText(path, "hello"); + + Assert.False(ChecksumVerifier.Verify(path, new string('0', 64))); + } + + [Fact] + public void ParseChecksumsFile_ReadsTwoLines() + { + var content = """ + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ClaudeDo-0.2.0-win-x64.zip + 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ClaudeDo.Installer-0.2.0.exe + """; + + var map = ChecksumVerifier.ParseChecksumsFile(content); + + Assert.Equal(2, map.Count); + Assert.Equal( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + map["ClaudeDo-0.2.0-win-x64.zip"]); + Assert.Equal( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + map["ClaudeDo.Installer-0.2.0.exe"]); + } + + [Fact] + public void ParseChecksumsFile_SkipsBlankAndMalformedLines() + { + var content = """ + + not a line + e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 file.zip + + """; + + var map = ChecksumVerifier.ParseChecksumsFile(content); + + Assert.Single(map); + Assert.True(map.ContainsKey("file.zip")); + } +}