diff --git a/src/ui/Logic/Download/SpellCheckDictionaryDownloadService.cs b/src/ui/Logic/Download/SpellCheckDictionaryDownloadService.cs index 2fc8aba0652..7b129d80a53 100644 --- a/src/ui/Logic/Download/SpellCheckDictionaryDownloadService.cs +++ b/src/ui/Logic/Download/SpellCheckDictionaryDownloadService.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; namespace Nikse.SubtitleEdit.Logic.Download; @@ -15,6 +17,16 @@ public class SpellCheckDictionaryDownloadService : ISpellCheckDictionaryDownload { private readonly HttpClient _httpClient; + private const string VoikkoReleaseUrlPrefix = + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/"; + + private static readonly IReadOnlyDictionary VoikkoHashes = + new Dictionary(StringComparer.Ordinal) + { + ["libvoikko-1.dll"] = "bfffd537ff372b425a61940d4f5ac6c80e2a745dab33cc00ac50e8f50441d1b0", + ["dict.zip"] = "98f26bb67e08288910fbf1aa92521f28bee79538ba86aefa267713333e7fa537", + }; + public SpellCheckDictionaryDownloadService(HttpClient httpClient) { _httpClient = httpClient; @@ -22,6 +34,58 @@ public SpellCheckDictionaryDownloadService(HttpClient httpClient) public async Task DownloadDictionary(Stream stream, string url, IProgress? progress, CancellationToken cancellationToken) { + var expected = GetExpectedVoikkoHash(url); await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken); + + if (!string.IsNullOrEmpty(expected)) + { + await VerifyVoikkoDownloadAsync(stream, expected, Path.GetFileName(new Uri(url).AbsolutePath), cancellationToken); + } + } + + internal static string? GetExpectedVoikkoHash(string url) + { + if (!url.StartsWith(VoikkoReleaseUrlPrefix, StringComparison.Ordinal)) + { + return null; + } + + var fileName = Path.GetFileName(new Uri(url).AbsolutePath); + if (string.IsNullOrEmpty(fileName) || !VoikkoHashes.TryGetValue(fileName, out var expected)) + { + throw new InvalidOperationException($"No SHA-256 is registered for Voikko asset '{fileName}'."); + } + + return expected; + } + + internal static async Task VerifyVoikkoDownloadAsync( + Stream stream, + string expected, + string fileName, + CancellationToken cancellationToken) + { + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("Voikko integrity verification requires a readable, seekable stream."); + } + + string actual; + stream.Position = 0; + try + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + finally + { + stream.Position = 0; + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Voikko download failed integrity check for {fileName} " + + $"(expected SHA-256 {expected}, got {actual})."); + } } } \ No newline at end of file diff --git a/tests/UI/Logic/Download/SpellCheckDictionaryDownloadServiceTests.cs b/tests/UI/Logic/Download/SpellCheckDictionaryDownloadServiceTests.cs new file mode 100644 index 00000000000..500438a7c3e --- /dev/null +++ b/tests/UI/Logic/Download/SpellCheckDictionaryDownloadServiceTests.cs @@ -0,0 +1,78 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class SpellCheckDictionaryDownloadServiceTests +{ + private const string VoikkoReleaseUrl = + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/"; + + [Theory] + [InlineData("libvoikko-1.dll", "bfffd537ff372b425a61940d4f5ac6c80e2a745dab33cc00ac50e8f50441d1b0")] + [InlineData("dict.zip", "98f26bb67e08288910fbf1aa92521f28bee79538ba86aefa267713333e7fa537")] + public void VoikkoHash_MatchesPublishedReleaseDigest(string fileName, string expected) + { + Assert.Equal(expected, SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash(VoikkoReleaseUrl + fileName)); + } + + [Fact] + public void UnknownVoikkoAsset_FailsClosed() + { + Assert.Throws(() => + SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash(VoikkoReleaseUrl + "unknown.zip")); + } + + [Fact] + public void GenericDictionaryUrl_IsNotForcedIntoVoikkoVerification() + { + Assert.Null(SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash( + "https://example.invalid/dictionaries/pt_PT.dic")); + } + + [Fact] + public async Task DownloadDictionary_TamperedVoikkoPayload_IsRejectedAndRewound() + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new SpellCheckDictionaryDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadDictionary( + stream, + VoikkoReleaseUrl + "dict.zip", + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task GenericDictionaryDownload_RemainsUnchanged() + { + var payload = Encoding.ASCII.GetBytes("dictionary-data"); + using var httpClient = new HttpClient(new StaticResponseHandler(payload)); + var service = new SpellCheckDictionaryDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await service.DownloadDictionary( + stream, + "https://example.invalid/dictionaries/pt_PT.dic", + progress: null, + TestContext.Current.CancellationToken); + + Assert.Equal(payload.Length, stream.Length); + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + } + } +}