From 7aa5dd0b0bec79f3d3a6d10bf70b38d162e491f1 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Sun, 13 Sep 2026 09:19:08 +0200 Subject: [PATCH] Verify Whisper engine downloads before install --- .../Logic/Download/WhisperDownloadService.cs | 73 ++++++++++++++++++ .../Download/WhisperDownloadServiceTests.cs | 76 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/UI/Logic/Download/WhisperDownloadServiceTests.cs diff --git a/src/ui/Logic/Download/WhisperDownloadService.cs b/src/ui/Logic/Download/WhisperDownloadService.cs index c479029d70d..633340bfb26 100644 --- a/src/ui/Logic/Download/WhisperDownloadService.cs +++ b/src/ui/Logic/Download/WhisperDownloadService.cs @@ -4,6 +4,8 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; +using Nikse.SubtitleEdit.UiLogic.AudioToText; namespace Nikse.SubtitleEdit.Logic.Download; @@ -73,16 +75,19 @@ public async Task DownloadFile(string url, string destinationFileName, IProgress public async Task DownloadWhisperCpp(Stream stream, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), stream, progress, cancellationToken); + await VerifyArchiveAsync(stream, DownloadHashManager.ResolveWhisperCppKey(WhisperChoice.Cpp), "Whisper.cpp", cancellationToken); } public async Task DownloadWhisperCppCuBlas(Stream stream, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlCuBlas(), stream, progress, cancellationToken); + await VerifyArchiveAsync(stream, DownloadHashManager.ResolveWhisperCppKey(WhisperChoice.CppCuBlas), "Whisper.cpp cuBLAS", cancellationToken); } public async Task DownloadWhisperConstMe(Stream stream, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, DownloadUrlConstMe, stream, progress, cancellationToken); + await VerifyArchiveAsync(stream, DownloadHashManager.ResolveWhisperConstMeKey(), "Const-me Whisper", cancellationToken); } public async Task DownloadWhisperPurfviewFasterWhisperXxl(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) @@ -105,16 +110,19 @@ public async Task DownloadWhisperPurfviewFasterWhisperXxl(string destinationFile } await DownloadHelper.DownloadFileAsync(_httpClient, url, destinationFileName, progress, cancellationToken); + await VerifyFileAsync(destinationFileName, DownloadHashManager.ResolvePurfviewFasterWhisperXxlKey(), "Purfview Faster-Whisper-XXL", cancellationToken); } public async Task DownloadWhisperCppVulkan(Stream stream, Progress progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlCppVulkan(), stream, progress, cancellationToken); + await VerifyArchiveAsync(stream, DownloadHashManager.ResolveWhisperCppKey(WhisperChoice.CppVulkan), "Whisper.cpp Vulkan", cancellationToken); } public async Task DownloadWhisperCTranslate2(Stream stream, Progress progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlTranslate2(), stream, progress, cancellationToken); + await VerifyArchiveAsync(stream, DownloadHashManager.ResolveWhisperCTranslate2Key(), "Whisper CTranslate2", cancellationToken); } public async Task DownloadWhisperX(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) @@ -123,6 +131,7 @@ public async Task DownloadWhisperX(string destinationFileName, IProgress? // in-memory _downloadStream - at 216 MB-355 MB, buffering this in memory would peak far // higher before unpacking even starts (MemoryStream's doubling growth). await DownloadHelper.DownloadFileAsync(_httpClient, GetUrlWhisperX(), destinationFileName, progress, cancellationToken); + await VerifyFileAsync(destinationFileName, DownloadHashManager.ResolveWhisperXKey(), "WhisperX", cancellationToken); } public async Task DownloadSileroVad(Stream stream, IProgress? progress, CancellationToken cancellationToken) @@ -130,6 +139,70 @@ public async Task DownloadSileroVad(Stream stream, IProgress? progress, C await DownloadHelper.DownloadFileAsync(_httpClient, SileroVadUrl, stream, progress, cancellationToken); } + internal static async Task VerifyArchiveAsync(Stream stream, string? key, string artifactName, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(key)) + { + throw new InvalidOperationException($"No SHA-256 key is registered for {artifactName}."); + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + throw new InvalidOperationException($"No SHA-256 is registered for {artifactName} key '{key}'."); + } + + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException($"{artifactName} 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( + $"{artifactName} download failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + internal static async Task VerifyFileAsync(string fileName, string? key, string artifactName, CancellationToken cancellationToken) + { + try + { + await using var stream = new FileStream( + fileName, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + useAsync: true); + await VerifyArchiveAsync(stream, key, artifactName, cancellationToken); + } + catch + { + try + { + File.Delete(fileName); + } + catch + { + // Preserve the verification error; cleanup is best-effort. + } + + throw; + } + } + private static string GetUrlTranslate2() { if (OperatingSystem.IsWindows()) diff --git a/tests/UI/Logic/Download/WhisperDownloadServiceTests.cs b/tests/UI/Logic/Download/WhisperDownloadServiceTests.cs new file mode 100644 index 00000000000..9d0e265bf50 --- /dev/null +++ b/tests/UI/Logic/Download/WhisperDownloadServiceTests.cs @@ -0,0 +1,76 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class WhisperDownloadServiceTests +{ + [Fact] + public async Task DownloadWhisperCpp_TamperedPayload_RejectsDownloadedBytes() + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new WhisperDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadWhisperCpp( + stream, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyArchiveAsync_UnknownKey_FailsClosed() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await Assert.ThrowsAsync(() => + WhisperDownloadService.VerifyArchiveAsync( + stream, + "Whisper.Unknown", + "Whisper test artifact", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VerifyFileAsync_TamperedPayload_DeletesFile() + { + var fileName = Path.Combine(Path.GetTempPath(), $"subtitleedit-whisper-{Guid.NewGuid():N}.tmp"); + await File.WriteAllTextAsync(fileName, "tampered", TestContext.Current.CancellationToken); + + try + { + await Assert.ThrowsAsync(() => + WhisperDownloadService.VerifyFileAsync( + fileName, + DownloadHashManager.WhisperConstMe.Windows, + "Whisper test artifact", + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(fileName)); + } + finally + { + File.Delete(fileName); + } + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Head) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + } + } +}