diff --git a/src/ui/Logic/Download/SpellCheckDictionaryDownloadService.cs b/src/ui/Logic/Download/SpellCheckDictionaryDownloadService.cs index 2fc8aba0652..4e41605a3ac 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,26 @@ public class SpellCheckDictionaryDownloadService : ISpellCheckDictionaryDownload { private readonly HttpClient _httpClient; + internal const string VoikkoDllUrl = + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/libvoikko-1.dll"; + internal const string VoikkoDictionaryUrl = + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/dict.zip"; + + internal const string VoikkoDllSha256 = + "bfffd537ff372b425a61940d4f5ac6c80e2a745dab33cc00ac50e8f50441d1b0"; + internal const string VoikkoDictionarySha256 = + "98f26bb67e08288910fbf1aa92521f28bee79538ba86aefa267713333e7fa537"; + + private const string VoikkoReleasePathPrefix = + "/SubtitleEdit/support-files/releases/download/voikko-"; + + private static readonly IReadOnlyDictionary VoikkoHashes = + new Dictionary(StringComparer.Ordinal) + { + [VoikkoDllUrl] = VoikkoDllSha256, + [VoikkoDictionaryUrl] = VoikkoDictionarySha256, + }; + public SpellCheckDictionaryDownloadService(HttpClient httpClient) { _httpClient = httpClient; @@ -22,6 +44,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 (VoikkoHashes.TryGetValue(url, out var expected)) + { + return expected; + } + + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && + uri.AbsolutePath.StartsWith(VoikkoReleasePathPrefix, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"No SHA-256 is registered for Voikko URL '{url}'."); + } + + return null; + } + + 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..9a17758fdd5 --- /dev/null +++ b/tests/UI/Logic/Download/SpellCheckDictionaryDownloadServiceTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class SpellCheckDictionaryDownloadServiceTests +{ + [Theory] + [InlineData( + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/libvoikko-1.dll", + "bfffd537ff372b425a61940d4f5ac6c80e2a745dab33cc00ac50e8f50441d1b0")] + [InlineData( + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/dict.zip", + "98f26bb67e08288910fbf1aa92521f28bee79538ba86aefa267713333e7fa537")] + public void VoikkoUrl_MatchesPublishedReleaseDigest(string url, string expected) + { + Assert.Equal(expected, SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash(url)); + } + + [Fact] + public void UnknownAssetOnPinnedVoikkoRelease_FailsClosed() + { + Assert.Throws(() => + SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash( + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/future.zip")); + } + + [Fact] + public void FutureVoikkoRelease_FailsClosedUntilHashesAreRegistered() + { + Assert.Throws(() => + SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash( + "https://github.com/SubtitleEdit/support-files/releases/download/voikko-5.0-fi-2026-09/dict.zip")); + } + + [Fact] + public void PinnedVoikkoPathOnWrongOrigin_FailsClosed() + { + Assert.Throws(() => + SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash( + "https://example.invalid/SubtitleEdit/support-files/releases/download/voikko-4.3-fi-2024-06/dict.zip")); + } + + [Fact] + public void GenericDictionaryUrl_IsNotForcedIntoVoikkoVerification() + { + Assert.Null(SpellCheckDictionaryDownloadService.GetExpectedVoikkoHash( + "https://example.invalid/dictionaries/dict.zip")); + } + + [Fact] + public async Task DownloadDictionary_TamperedVoikkoPayload_IsRejectedAndRewound() + { + var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered")); + using var httpClient = new HttpClient(handler); + var service = new SpellCheckDictionaryDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadDictionary( + stream, + SpellCheckDictionaryDownloadService.VoikkoDictionaryUrl, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + Assert.Equal(new[] { HttpMethod.Head, HttpMethod.Get }, handler.RequestMethods); + } + + [Fact] + public async Task VerifyVoikkoDownloadAsync_ValidPayload_RewindsStream() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + stream.Position = stream.Length; + + await SpellCheckDictionaryDownloadService.VerifyVoikkoDownloadAsync( + stream, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + "test.zip", + TestContext.Current.CancellationToken); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task GenericDictionaryDownload_RemainsUnchanged() + { + var payload = Encoding.ASCII.GetBytes("dictionary-data"); + var handler = new StaticResponseHandler(payload); + using var httpClient = new HttpClient(handler); + 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); + Assert.Equal(new[] { HttpMethod.Head, HttpMethod.Get }, handler.RequestMethods); + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + public List RequestMethods { get; } = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + RequestMethods.Add(request.Method); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + } + } +}