diff --git a/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs b/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs index 3d194ac3075..5237cbeaf9f 100644 --- a/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs +++ b/src/ui/Logic/Download/KokoroTtsCppDownloadService.cs @@ -31,7 +31,9 @@ public class KokoroTtsCppDownloadService : IKokoroTtsCppDownloadService private const string TtsModelFileName = "kokoro-v1.1-zh.onnx"; private const string VoicesModelFileName = "voices-v1.1-zh.bin"; private const string TtsModelUrl = "https://github.com/koth/kokoro.cpp/releases/download/voices_model_files/kokoro-v1.1-zh.onnx"; + internal const string TtsModelSha256 = "eefec708cbc7aba8e8129b5c2f7cb92e1fe7d281af1e1dd451592d9ff0714a0d"; private const string VoicesModelUrl = "https://github.com/koth/kokoro.cpp/releases/download/voices_model_files/voices-v1.1-zh.bin"; + internal const string VoicesModelSha256 = "e678019845e6cfe3b7c34531779396b28f509451b91e6535d5dc09bbf11a4be5"; public KokoroTtsCppDownloadService(HttpClient httpClient) { @@ -84,13 +86,87 @@ public async Task DownloadModels(string modelsFolder, IProgress? progress { step++; titleProgress?.Invoke($"Downloading Kokoro TTS models ({step}/{total}): {TtsModelFileName}"); - await DownloadHelper.DownloadFileAsync(_httpClient, TtsModelUrl, ttsPath, progress, cancellationToken); + await DownloadAndPublishModelAsync( + _httpClient, + TtsModelUrl, + ttsPath, + TtsModelSha256, + progress, + cancellationToken); } if (needVoices) { step++; titleProgress?.Invoke($"Downloading Kokoro TTS models ({step}/{total}): {VoicesModelFileName}"); - await DownloadHelper.DownloadFileAsync(_httpClient, VoicesModelUrl, voicesPath, progress, cancellationToken); + await DownloadAndPublishModelAsync( + _httpClient, + VoicesModelUrl, + voicesPath, + VoicesModelSha256, + progress, + cancellationToken); + } + } + + internal static async Task DownloadAndPublishModelAsync( + HttpClient httpClient, + string url, + string destinationFileName, + string expectedSha256, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(expectedSha256)) + { + throw new InvalidOperationException( + $"No SHA-256 is registered for Kokoro TTS model '{Path.GetFileName(destinationFileName)}'."); + } + + var tempFileName = destinationFileName + ".part"; + try + { + if (File.Exists(tempFileName)) + { + File.Delete(tempFileName); + } + + await DownloadHelper.DownloadFileAsync( + httpClient, + url, + tempFileName, + progress, + cancellationToken); + + string actual; + await using (var stream = File.OpenRead(tempFileName)) + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + + if (!string.Equals(expectedSha256, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Kokoro TTS model {Path.GetFileName(destinationFileName)} failed integrity check " + + $"(expected SHA-256 {expectedSha256}, got {actual})."); + } + + File.Move(tempFileName, destinationFileName, true); + } + catch + { + try + { + if (File.Exists(tempFileName)) + { + File.Delete(tempFileName); + } + } + catch + { + // Preserve the original download/integrity error; cleanup is best-effort. + } + + throw; } } diff --git a/tests/UI/Logic/Download/KokoroTtsCppDownloadServiceTests.cs b/tests/UI/Logic/Download/KokoroTtsCppDownloadServiceTests.cs new file mode 100644 index 00000000000..cbd67763283 --- /dev/null +++ b/tests/UI/Logic/Download/KokoroTtsCppDownloadServiceTests.cs @@ -0,0 +1,128 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class KokoroTtsCppDownloadServiceTests +{ + [Fact] + public void ModelDigests_MatchPublishedReleaseAssets() + { + Assert.Equal( + "eefec708cbc7aba8e8129b5c2f7cb92e1fe7d281af1e1dd451592d9ff0714a0d", + KokoroTtsCppDownloadService.TtsModelSha256); + Assert.Equal( + "e678019845e6cfe3b7c34531779396b28f509451b91e6535d5dc09bbf11a4be5", + KokoroTtsCppDownloadService.VoicesModelSha256); + } + + [Fact] + public async Task DownloadAndPublishModelAsync_ValidPayload_PublishesAtomically() + { + var payload = Encoding.ASCII.GetBytes("valid-model"); + var expected = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant(); + var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + var destination = Path.Combine(folder, "model.bin"); + + try + { + using var httpClient = new HttpClient(new StaticResponseHandler(payload)); + + await KokoroTtsCppDownloadService.DownloadAndPublishModelAsync( + httpClient, + "https://example.test/model.bin", + destination, + expected, + progress: null, + TestContext.Current.CancellationToken); + + Assert.True(File.Exists(destination)); + Assert.False(File.Exists(destination + ".part")); + Assert.Equal(payload, await File.ReadAllBytesAsync(destination, TestContext.Current.CancellationToken)); + } + finally + { + Directory.Delete(folder, true); + } + } + + [Fact] + public async Task DownloadAndPublishModelAsync_TamperedPayload_RejectsWithoutPublishing() + { + var expectedPayload = Encoding.ASCII.GetBytes("expected"); + var expected = Convert.ToHexString(SHA256.HashData(expectedPayload)).ToLowerInvariant(); + var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + var destination = Path.Combine(folder, "model.bin"); + + try + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + + await Assert.ThrowsAsync(() => + KokoroTtsCppDownloadService.DownloadAndPublishModelAsync( + httpClient, + "https://example.test/model.bin", + destination, + expected, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(destination + ".part")); + } + finally + { + Directory.Delete(folder, true); + } + } + + [Fact] + public async Task DownloadAndPublishModelAsync_MissingDigest_FailsBeforeHttp() + { + var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + var destination = Path.Combine(folder, "model.bin"); + var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("payload")); + + try + { + using var httpClient = new HttpClient(handler); + + await Assert.ThrowsAsync(() => + KokoroTtsCppDownloadService.DownloadAndPublishModelAsync( + httpClient, + "https://example.test/model.bin", + destination, + string.Empty, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, handler.RequestCount); + Assert.False(File.Exists(destination)); + } + finally + { + Directory.Delete(folder, true); + } + } + + 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), + }); + } + } +}