diff --git a/src/ui/Features/Video/TextToSpeech/VoiceManager/VoicePacks/VoicePack.cs b/src/ui/Features/Video/TextToSpeech/VoiceManager/VoicePacks/VoicePack.cs
index a2046a52567..1119c2fb6dc 100644
--- a/src/ui/Features/Video/TextToSpeech/VoiceManager/VoicePacks/VoicePack.cs
+++ b/src/ui/Features/Video/TextToSpeech/VoiceManager/VoicePacks/VoicePack.cs
@@ -18,7 +18,7 @@ public class VoicePack
public long SizeBytes { get; }
public string License { get; }
- /// Lower-case SHA-256 of the zip; empty skips verification.
+ /// Required lower-case SHA-256 of the zip.
public string Sha256 { get; }
public VoicePack(string id, string name, string languageName, string languageCode, string description, string url, int voiceCount, long sizeBytes, string license, string sha256)
diff --git a/src/ui/Logic/Download/VoicePackDownloadService.cs b/src/ui/Logic/Download/VoicePackDownloadService.cs
index 87f74e6e7f1..1b8b94d73e8 100644
--- a/src/ui/Logic/Download/VoicePackDownloadService.cs
+++ b/src/ui/Logic/Download/VoicePackDownloadService.cs
@@ -28,16 +28,43 @@ public VoicePackDownloadService(HttpClient httpClient)
public async Task DownloadPack(VoicePack pack, Stream stream, IProgress? progress, CancellationToken cancellationToken)
{
+ if (string.IsNullOrWhiteSpace(pack.Sha256))
+ {
+ throw new InvalidOperationException($"No SHA-256 is registered for voice pack '{pack.Name}'.");
+ }
+
+ if (!stream.CanRead || !stream.CanSeek)
+ {
+ throw new InvalidOperationException("Voice pack integrity verification requires a readable, seekable stream.");
+ }
+
await DownloadHelper.DownloadFileAsync(_httpClient, pack.Url, stream, progress, cancellationToken);
+ await VerifyPackAsync(pack, stream, cancellationToken);
+ }
- if (string.IsNullOrEmpty(pack.Sha256) || stream.Length == 0)
+ internal static async Task VerifyPackAsync(VoicePack pack, Stream stream, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(pack.Sha256))
{
- return;
+ throw new InvalidOperationException($"No SHA-256 is registered for voice pack '{pack.Name}'.");
}
+ if (!stream.CanRead || !stream.CanSeek)
+ {
+ throw new InvalidOperationException("Voice pack integrity verification requires a readable, seekable stream.");
+ }
+
+ string actual;
stream.Position = 0;
- var actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken);
- stream.Position = 0;
+ try
+ {
+ actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken);
+ }
+ finally
+ {
+ stream.Position = 0;
+ }
+
if (!string.Equals(pack.Sha256, actual, StringComparison.OrdinalIgnoreCase))
{
throw new IOException($"Voice pack '{pack.Name}' failed integrity check (expected SHA-256 {pack.Sha256}, got {actual}).");
diff --git a/tests/UI/Logic/Download/VoicePackDownloadServiceTests.cs b/tests/UI/Logic/Download/VoicePackDownloadServiceTests.cs
new file mode 100644
index 00000000000..72c2d7e9719
--- /dev/null
+++ b/tests/UI/Logic/Download/VoicePackDownloadServiceTests.cs
@@ -0,0 +1,143 @@
+using System.Linq;
+using System.Net;
+using System.Text;
+using Nikse.SubtitleEdit.Features.Video.TextToSpeech.VoiceManager.VoicePacks;
+using Nikse.SubtitleEdit.Logic.Download;
+
+namespace UITests.Logic.Download;
+
+public class VoicePackDownloadServiceTests
+{
+ [Theory]
+ [InlineData("en-standard", "8935dcb18c71fe261e95c0e7c8e4f6cbca153c76109bb946ba4dfe1f115fcada")]
+ [InlineData("de", "c541c76ab960ab31165d753385016012622bd36e43f0fdb3e53e96b31741b88d")]
+ [InlineData("es", "dcd84cf4286152c2dd23436ce36d42427a587af5f9213162f4f61adc6510ec84")]
+ [InlineData("fr", "f0a81f8558d9c3c42de55915b5721a98ef0d1db4f4da52d7e14d848349d7e71f")]
+ [InlineData("it", "ad082e544bc515a54d52736d882b90f00e7a88f07ce5f69e66eb18ec823640f2")]
+ [InlineData("nl", "260a8f4900242b9fd655d4bde7a9d5b2d9e4b5ddfd24f159e78ff1af08541d2a")]
+ [InlineData("pl", "d2f43bdf5d9d955f1523d65dfe91de502892fca1708322510a9bab7d3efae55a")]
+ [InlineData("pt", "7ef61545300c4cfbd0c837acb80625835f3f4e673461ac4eae3b444eb33a8685")]
+ public void CatalogHash_MatchesPublishedReleaseDigest(string id, string expected)
+ {
+ var pack = VoicePackCatalog.All.Single(p => p.Id == id);
+ Assert.Equal(expected, pack.Sha256);
+ }
+
+ [Fact]
+ public async Task DownloadPack_MissingDigest_FailsBeforeHttp()
+ {
+ var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("unused"));
+ using var httpClient = new HttpClient(handler);
+ var service = new VoicePackDownloadService(httpClient);
+ var pack = CreatePack(string.Empty);
+ await using var stream = new MemoryStream();
+
+ await Assert.ThrowsAsync(() =>
+ service.DownloadPack(pack, stream, progress: null, TestContext.Current.CancellationToken));
+
+ Assert.Equal(0, handler.RequestCount);
+ }
+
+ [Fact]
+ public async Task DownloadPack_TamperedPayload_IsRejectedAndRewound()
+ {
+ var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"));
+ using var httpClient = new HttpClient(handler);
+ var service = new VoicePackDownloadService(httpClient);
+ var pack = CreatePack("cea23dd4b87e8b8634567c2f6c9a3a31ce51ec44c2c45a70ac999aa00516a04b");
+ await using var stream = new MemoryStream();
+
+ await Assert.ThrowsAsync(() =>
+ service.DownloadPack(pack, stream, progress: null, TestContext.Current.CancellationToken));
+
+ Assert.Equal(0, stream.Position);
+ Assert.True(handler.RequestCount > 0);
+ }
+
+ [Fact]
+ public async Task VerifyPackAsync_ValidPayload_RewindsStream()
+ {
+ var pack = CreatePack("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
+ await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc"));
+ stream.Position = stream.Length;
+
+ await VoicePackDownloadService.VerifyPackAsync(
+ pack,
+ stream,
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(0, stream.Position);
+ }
+
+ [Fact]
+ public async Task VerifyPackAsync_EmptyStream_IsRejectedAndRewound()
+ {
+ var pack = VoicePackCatalog.All[0];
+ await using var stream = new MemoryStream();
+
+ await Assert.ThrowsAsync(() =>
+ VoicePackDownloadService.VerifyPackAsync(
+ pack,
+ stream,
+ TestContext.Current.CancellationToken));
+
+ Assert.Equal(0, stream.Position);
+ }
+
+ [Fact]
+ public async Task DownloadPack_NonSeekableStream_FailsBeforeHttp()
+ {
+ var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("unused"));
+ using var httpClient = new HttpClient(handler);
+ var service = new VoicePackDownloadService(httpClient);
+ var pack = VoicePackCatalog.All[0];
+ await using var stream = new NonSeekableReadStream(Encoding.ASCII.GetBytes("data"));
+
+ await Assert.ThrowsAsync(() =>
+ service.DownloadPack(pack, stream, progress: null, TestContext.Current.CancellationToken));
+
+ Assert.Equal(0, handler.RequestCount);
+ }
+
+ private static VoicePack CreatePack(string sha256)
+ {
+ return new VoicePack(
+ "test",
+ "Test pack",
+ "Test",
+ "xx",
+ "Test voice pack",
+ "https://example.test/voices.zip",
+ 1,
+ 123,
+ "Test license",
+ sha256);
+ }
+
+ private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler
+ {
+ public int RequestCount { get; private set; }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ RequestCount++;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(payload),
+ });
+ }
+ }
+
+ private sealed class NonSeekableReadStream(byte[] data) : MemoryStream(data)
+ {
+ public override bool CanSeek => false;
+
+ public override long Position
+ {
+ get => base.Position;
+ set => throw new NotSupportedException();
+ }
+
+ public override long Seek(long offset, SeekOrigin loc) => throw new NotSupportedException();
+ }
+}