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(); + } +}