diff --git a/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs b/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs index e8066a55edf..566ed44629f 100644 --- a/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs +++ b/src/ui/Logic/Download/IndexTts25AudioCppDownloadService.cs @@ -98,6 +98,44 @@ public string[] GetAvailableBackends() public async Task DownloadEngine(Stream stream, string backend, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, GetEngineUrl(backend), stream, progress, cancellationToken); + await VerifyEngineArchiveAsync(stream, backend, cancellationToken); + } + + internal static async Task VerifyEngineArchiveAsync(Stream stream, string backend, CancellationToken cancellationToken) + { + var key = DownloadHashManager.ResolveIndexTts25AudioCppKey(backend); + if (string.IsNullOrEmpty(key)) + { + throw new InvalidOperationException("No SHA-256 key is registered for the audio.cpp runtime on this platform/backend."); + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + throw new InvalidOperationException($"No SHA-256 is registered for audio.cpp runtime key '{key}'."); + } + + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("audio.cpp runtime integrity verification requires a readable, seekable stream."); + } + + string actual; + stream.Position = 0; + try + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + finally + { + stream.Position = 0; + } + + if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"audio.cpp runtime download failed integrity check (expected SHA-256 {expected}, got {actual})."); + } } private static string GetEngineUrl(string backend) diff --git a/tests/UI/Logic/Download/IndexTts25AudioCppDownloadServiceTests.cs b/tests/UI/Logic/Download/IndexTts25AudioCppDownloadServiceTests.cs new file mode 100644 index 00000000000..d9485113979 --- /dev/null +++ b/tests/UI/Logic/Download/IndexTts25AudioCppDownloadServiceTests.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class IndexTts25AudioCppDownloadServiceTests +{ + [Fact] + public async Task DownloadEngine_TamperedPayload_RejectsDownloadedBytes() + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new IndexTts25AudioCppDownloadService(httpClient); + var backend = GetSupportedBackendOrSkip(); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadEngine( + stream, + backend, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyEngineArchiveAsync_TamperedPayload_RejectsBytes() + { + var backend = GetSupportedBackendOrSkip(); + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered")); + + await Assert.ThrowsAsync(() => + IndexTts25AudioCppDownloadService.VerifyEngineArchiveAsync( + stream, + backend, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + private static string GetSupportedBackendOrSkip() + { + if (OperatingSystem.IsMacOS()) + { + if (RuntimeInformation.ProcessArchitecture != Architecture.Arm64) + { + Assert.Skip("audio.cpp runtime archives support Apple Silicon only on macOS."); + return IndexTts25AudioCppDownloadService.BackendMetal; + } + + return IndexTts25AudioCppDownloadService.BackendMetal; + } + + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) + { + if (RuntimeInformation.ProcessArchitecture != Architecture.X64) + { + Assert.Skip("audio.cpp runtime archives support x86-64 only on Windows and Linux."); + return IndexTts25AudioCppDownloadService.BackendCpu; + } + + return IndexTts25AudioCppDownloadService.BackendCpu; + } + + Assert.Skip("audio.cpp runtime is not supported on this operating system."); + return IndexTts25AudioCppDownloadService.BackendCpu; + } + + 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), + }); + } + } +}