Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion src/ui/Logic/Download/SpellCheckDictionaryDownloadService.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -15,13 +17,85 @@ 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<string, string> VoikkoHashes =
new Dictionary<string, string>(StringComparer.Ordinal)
{
[VoikkoDllUrl] = VoikkoDllSha256,
[VoikkoDictionaryUrl] = VoikkoDictionarySha256,
};

public SpellCheckDictionaryDownloadService(HttpClient httpClient)
{
_httpClient = httpClient;
}

public async Task DownloadDictionary(Stream stream, string url, IProgress<float>? 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}).");
}
}
}
}
118 changes: 118 additions & 0 deletions tests/UI/Logic/Download/SpellCheckDictionaryDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<IOException>(() =>
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<HttpMethod> RequestMethods { get; } = new();

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestMethods.Add(request.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload),
});
}
}
}