diff --git a/src/ui/Logic/Download/OmniVoiceDownloadService.cs b/src/ui/Logic/Download/OmniVoiceDownloadService.cs index 66eaafd7046..926ed6b98f1 100644 --- a/src/ui/Logic/Download/OmniVoiceDownloadService.cs +++ b/src/ui/Logic/Download/OmniVoiceDownloadService.cs @@ -85,26 +85,37 @@ public async Task DownloadVoices(Stream stream, IProgress? progress, Canc await VerifyArchive(stream, DownloadHashManager.OmniVoice.Voices, "voices", cancellationToken); } - // Compares the downloaded bytes against the known SHA-256 for this key and throws on mismatch - // so the caller's IsFaulted branch surfaces "Download failed" instead of silently unpacking a - // truncated or tampered file. A null/unknown key (e.g. unrecognised Windows variant) skips the - // check rather than failing closed - same policy as the rest of DownloadHashManager. - private static async Task VerifyArchive(Stream stream, string? key, string label, CancellationToken cancellationToken) + // Compares the downloaded bytes against the current registered SHA-256 and fails closed + // if the key/digest cannot be resolved. The caller must never unpack bytes whose expected + // identity is unknown. + internal static async Task VerifyArchive(Stream stream, string? key, string label, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(key) || stream.Length == 0) + if (string.IsNullOrEmpty(key)) { - return; + throw new InvalidOperationException($"No SHA-256 key is registered for OmniVoice {label}."); } var expected = DownloadHashManager.GetLatestKnownHash(key); if (string.IsNullOrEmpty(expected)) { - return; + throw new InvalidOperationException($"No SHA-256 is registered for OmniVoice {label} key '{key}'."); } + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException($"OmniVoice {label} 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(expected, actual, StringComparison.OrdinalIgnoreCase)) { diff --git a/tests/UI/Logic/Download/OmniVoiceDownloadServiceTests.cs b/tests/UI/Logic/Download/OmniVoiceDownloadServiceTests.cs new file mode 100644 index 00000000000..8752e9ff4d6 --- /dev/null +++ b/tests/UI/Logic/Download/OmniVoiceDownloadServiceTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class OmniVoiceDownloadServiceTests +{ + [Theory] + [InlineData(DownloadHashManager.OmniVoice.WindowsCpu, "8f7d78f72cfc69c904eb702497a27f9e760dcfff435d8e6acdee34cbf40a39aa")] + [InlineData(DownloadHashManager.OmniVoice.WindowsVulkan, "a0172efa536e230c647ebfe7e0491a9f37be26622f792bcd1ff247310af9558b")] + [InlineData(DownloadHashManager.OmniVoice.WindowsCuda, "02042cedf07e43915c24ddd14f4989648e334e0270cada8ff8074f39fa91209a")] + [InlineData(DownloadHashManager.OmniVoice.MacOs, "d398d77684277824d5ff83e252fc9b7c518563b930a1dd717afc540f71100c00")] + [InlineData(DownloadHashManager.OmniVoice.LinuxX64, "cc063f669a742a443866611b3f752528693e1426cf4dec0c023c1ccda86e5966")] + [InlineData(DownloadHashManager.OmniVoice.LinuxArm64, "ca193e791973bb0016703e3b1798d1dea049cafcdb2ad8abd05ee99c14285b02")] + [InlineData(DownloadHashManager.OmniVoice.Voices, "5d252eb78e8f4891279a36fa5127ea5ab80be35057eeaa5fadb49baeacd0c773")] + public void RegistryHash_MatchesPublishedReleaseDigest(string key, string expected) + { + Assert.Equal(expected, DownloadHashManager.GetLatestKnownHash(key)); + } + + [Fact] + public async Task DownloadEngine_TamperedPayload_IsRejectedAndRewound() + { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + Assert.Skip("OmniVoice runtime is not supported on this operating system."); + } + + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new OmniVoiceDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadEngine( + stream, + OmniVoiceDownloadService.WindowsVariantCpu, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task DownloadVoices_TamperedPayload_IsRejectedAndRewound() + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new OmniVoiceDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadVoices( + stream, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyArchive_NullKey_FailsClosed() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await Assert.ThrowsAsync(() => + OmniVoiceDownloadService.VerifyArchive( + stream, + null, + "engine", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VerifyArchive_UnknownKey_FailsClosed() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await Assert.ThrowsAsync(() => + OmniVoiceDownloadService.VerifyArchive( + stream, + "OmniVoice.Unknown", + "engine", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VerifyArchive_EmptyStream_IsRejectedAndRewound() + { + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + OmniVoiceDownloadService.VerifyArchive( + stream, + DownloadHashManager.OmniVoice.LinuxX64, + "engine", + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyArchive_NonSeekableStream_FailsClosed() + { + await using var stream = new NonSeekableReadStream(Encoding.ASCII.GetBytes("data")); + + await Assert.ThrowsAsync(() => + OmniVoiceDownloadService.VerifyArchive( + stream, + DownloadHashManager.OmniVoice.LinuxX64, + "engine", + TestContext.Current.CancellationToken)); + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + 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(); + } +}