Skip to content
Draft
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
73 changes: 73 additions & 0 deletions src/ui/Logic/Download/WhisperDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -73,16 +75,19 @@ public async Task DownloadFile(string url, string destinationFileName, IProgress
public async Task DownloadWhisperCpp(Stream stream, IProgress<float>? 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<float>? 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<float>? 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<float>? progress, CancellationToken cancellationToken)
Expand All @@ -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<float> 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<float> 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<float>? progress, CancellationToken cancellationToken)
Expand All @@ -123,13 +131,78 @@ public async Task DownloadWhisperX(string destinationFileName, IProgress<float>?
// 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<float>? progress, CancellationToken cancellationToken)
{
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())
Expand Down
76 changes: 76 additions & 0 deletions tests/UI/Logic/Download/WhisperDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOException>(() =>
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<InvalidOperationException>(() =>
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<IOException>(() =>
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<HttpResponseMessage> 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),
});
}
}
}