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
64 changes: 64 additions & 0 deletions 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,75 @@ 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<string, string> VoikkoHashes =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["libvoikko-1.dll"] = "bfffd537ff372b425a61940d4f5ac6c80e2a745dab33cc00ac50e8f50441d1b0",
["dict.zip"] = "98f26bb67e08288910fbf1aa92521f28bee79538ba86aefa267713333e7fa537",
};

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 (!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}).");
}
}
}
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() =>
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<IOException>(() =>
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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload),
});
}
}
}
Loading