diff --git a/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs b/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs index 3d194ac3075..199ccd0cafc 100644 --- a/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs +++ b/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs @@ -44,25 +44,37 @@ public async Task DownloadEngine(Stream stream, IProgress? progress, Canc await VerifyArchive(stream, DownloadHashManager.ResolveKokoroTtsCppKey(), "engine", 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. Mirrors OmniVoiceDownloadService.VerifyArchive. - 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 Kokoro TTS {label}."); } var expected = DownloadHashManager.GetLatestKnownHash(key); if (string.IsNullOrEmpty(expected)) { - return; + throw new InvalidOperationException($"No SHA-256 is registered for Kokoro TTS {label} key '{key}'."); } + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException($"Kokoro TTS {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/KokoroTtsCppRuntimeDownloadServiceTests.cs b/tests/UI/Logic/Download/KokoroTtsCppRuntimeDownloadServiceTests.cs new file mode 100644 index 00000000000..569b4abb004 --- /dev/null +++ b/tests/UI/Logic/Download/KokoroTtsCppRuntimeDownloadServiceTests.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class KokoroTtsCppRuntimeDownloadServiceTests +{ + [Theory] + [InlineData(DownloadHashManager.KokoroTtsCpp.Windows, "560014a5f82ccb2df2dc54b7701adfbad7d23d154783d70530c86a577a9ab918")] + [InlineData(DownloadHashManager.KokoroTtsCpp.MacOs, "39a1b4e15b48b364862ba29cf923507ee759f37d420de42bd41d333cd4dcc0ab")] + [InlineData(DownloadHashManager.KokoroTtsCpp.LinuxX64, "3383a9154a1d34f227ea4e8a1d5aff5dd60adf4061a3b14bf93e1ad8582eddf9")] + [InlineData(DownloadHashManager.KokoroTtsCpp.LinuxArm64, "673f49ffd2c0653b195a57f566038c05b2a962defc3e1c9c2664c8306d09352d")] + 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("Kokoro TTS runtime is not supported on this operating system."); + } + + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new KokoroTtsCppDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadEngine( + 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(() => + KokoroTtsCppDownloadService.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(() => + KokoroTtsCppDownloadService.VerifyArchive( + stream, + "KokoroTtsCpp.Unknown", + "engine", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VerifyArchive_EmptyStream_IsRejectedAndRewound() + { + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + KokoroTtsCppDownloadService.VerifyArchive( + stream, + DownloadHashManager.KokoroTtsCpp.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(() => + KokoroTtsCppDownloadService.VerifyArchive( + stream, + DownloadHashManager.KokoroTtsCpp.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(); + } +}