From 64148b2b40a07ea39950a5487c77d95d09b18d48 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Sat, 12 Sep 2026 23:32:13 +0200 Subject: [PATCH 1/2] Verify OmniVoice model downloads before publish --- .../Download/OmniVoiceDownloadService.cs | 87 ++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/src/ui/Logic/Download/OmniVoiceDownloadService.cs b/src/ui/Logic/Download/OmniVoiceDownloadService.cs index 66eaafd7046..3bda358051c 100644 --- a/src/ui/Logic/Download/OmniVoiceDownloadService.cs +++ b/src/ui/Logic/Download/OmniVoiceDownloadService.cs @@ -28,8 +28,10 @@ public class OmniVoiceDownloadService : IOmniVoiceDownloadService public const string WindowsVariantVulkan = "vulkan"; public const string WindowsVariantCuda = "cuda"; - private const string ModelBaseUrl = "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/main/omnivoice-base-Q8_0.gguf"; - private const string ModelTokenizerUrl = "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/main/omnivoice-tokenizer-F32.gguf"; + internal const string ModelRepoRevision = "017094167b5c9ed565a5076ac9b3b93c5ecf5c73"; + internal const string ModelBaseSha256 = "2882d887921798aea13d45236556bdf8012842ab6f8cd2690943eead6289f298"; + internal const string ModelTokenizerSha256 = "83820c6316da023076af7c1d06de5e38dcd09ae9f42203675bf8b3bd9a58e330"; + private const string ModelRepoBaseUrl = "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/" + ModelRepoRevision + "/"; // omnivoice.cpp release pin. Bump in lockstep with the hashes in DownloadHashManager.OmniVoice // (each new release: prepend the new SHA-256 at index 0, keep the previous one for "update available"). @@ -63,13 +65,90 @@ public async Task DownloadModels(string modelsFolder, IProgress? progress { step++; titleProgress?.Invoke($"Downloading OmniVoice TTS models ({step}/{total}): {ModelBaseFileName}"); - await DownloadHelper.DownloadFileAsync(_httpClient, ModelBaseUrl, basePath, progress, cancellationToken); + await DownloadAndPublishModelAsync( + _httpClient, + GetModelUrl(ModelBaseFileName), + basePath, + ModelBaseSha256, + progress, + cancellationToken); } if (needTokenizer) { step++; titleProgress?.Invoke($"Downloading OmniVoice TTS models ({step}/{total}): {ModelTokenizerFileName}"); - await DownloadHelper.DownloadFileAsync(_httpClient, ModelTokenizerUrl, tokenizerPath, progress, cancellationToken); + await DownloadAndPublishModelAsync( + _httpClient, + GetModelUrl(ModelTokenizerFileName), + tokenizerPath, + ModelTokenizerSha256, + progress, + cancellationToken); + } + } + + internal static string GetModelUrl(string fileName) => ModelRepoBaseUrl + fileName; + + 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 OmniVoice 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( + $"OmniVoice model {Path.GetFileName(destinationFileName)} failed integrity check " + + $"(expected SHA-256 {expectedSha256}, got {actual})."); + } + + cancellationToken.ThrowIfCancellationRequested(); + 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; } } From e56cd3a06187b54d2e6fb764e41b15ee87fffad9 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Sat, 12 Sep 2026 23:32:31 +0200 Subject: [PATCH 2/2] Add OmniVoice model integrity regressions --- .../OmniVoiceDownloadServiceModelTests.cs | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 tests/UI/Logic/Download/OmniVoiceDownloadServiceModelTests.cs diff --git a/tests/UI/Logic/Download/OmniVoiceDownloadServiceModelTests.cs b/tests/UI/Logic/Download/OmniVoiceDownloadServiceModelTests.cs new file mode 100644 index 00000000000..762ee5c503c --- /dev/null +++ b/tests/UI/Logic/Download/OmniVoiceDownloadServiceModelTests.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class OmniVoiceDownloadServiceModelTests +{ + [Fact] + public void ModelMetadata_PinsImmutableRevisionAndPublishedDigests() + { + Assert.Equal( + "017094167b5c9ed565a5076ac9b3b93c5ecf5c73", + OmniVoiceDownloadService.ModelRepoRevision); + Assert.Equal( + "2882d887921798aea13d45236556bdf8012842ab6f8cd2690943eead6289f298", + OmniVoiceDownloadService.ModelBaseSha256); + Assert.Equal( + "83820c6316da023076af7c1d06de5e38dcd09ae9f42203675bf8b3bd9a58e330", + OmniVoiceDownloadService.ModelTokenizerSha256); + } + + [Fact] + public void ModelUrls_DoNotUseMutableMain() + { + var baseUrl = OmniVoiceDownloadService.GetModelUrl(OmniVoiceDownloadService.ModelBaseFileName); + var tokenizerUrl = OmniVoiceDownloadService.GetModelUrl(OmniVoiceDownloadService.ModelTokenizerFileName); + + Assert.Equal( + "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/" + + "017094167b5c9ed565a5076ac9b3b93c5ecf5c73/" + + "omnivoice-base-Q8_0.gguf", + baseUrl); + Assert.Equal( + "https://huggingface.co/Serveurperso/OmniVoice-GGUF/resolve/" + + "017094167b5c9ed565a5076ac9b3b93c5ecf5c73/" + + "omnivoice-tokenizer-F32.gguf", + tokenizerUrl); + Assert.DoesNotContain("/resolve/main/", baseUrl, StringComparison.Ordinal); + Assert.DoesNotContain("/resolve/main/", tokenizerUrl, StringComparison.Ordinal); + } + + [Fact] + public async Task DownloadAndPublishModelAsync_ValidPayload_PublishesVerifiedBytes() + { + 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.gguf"); + + try + { + using var httpClient = new HttpClient(new StaticResponseHandler(payload)); + + await OmniVoiceDownloadService.DownloadAndPublishModelAsync( + httpClient, + "https://example.test/model.gguf", + 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_PreservesExistingDestination() + { + var expectedPayload = Encoding.ASCII.GetBytes("expected"); + var expected = Convert.ToHexString(SHA256.HashData(expectedPayload)).ToLowerInvariant(); + var existingPayload = Encoding.ASCII.GetBytes("existing-model"); + var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + var destination = Path.Combine(folder, "model.gguf"); + await File.WriteAllBytesAsync(destination, existingPayload, TestContext.Current.CancellationToken); + + try + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + + await Assert.ThrowsAsync(() => + OmniVoiceDownloadService.DownloadAndPublishModelAsync( + httpClient, + "https://example.test/model.gguf", + destination, + expected, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal( + existingPayload, + await File.ReadAllBytesAsync(destination, TestContext.Current.CancellationToken)); + Assert.False(File.Exists(destination + ".part")); + } + finally + { + Directory.Delete(folder, true); + } + } + + [Fact] + public async Task DownloadAndPublishModelAsync_MissingDigest_FailsBeforeHttp() + { + var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("unused")); + using var httpClient = new HttpClient(handler); + var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + var destination = Path.Combine(folder, "model.gguf"); + + try + { + await Assert.ThrowsAsync(() => + OmniVoiceDownloadService.DownloadAndPublishModelAsync( + httpClient, + "https://example.test/model.gguf", + destination, + string.Empty, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, handler.RequestCount); + Assert.False(File.Exists(destination)); + Assert.False(File.Exists(destination + ".part")); + } + 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), + }); + } + } +}