diff --git a/src/ui/Logic/Download/CrispAsrDownloadService.cs b/src/ui/Logic/Download/CrispAsrDownloadService.cs index 10298a7313b..8687f218f81 100644 --- a/src/ui/Logic/Download/CrispAsrDownloadService.cs +++ b/src/ui/Logic/Download/CrispAsrDownloadService.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; namespace Nikse.SubtitleEdit.Logic.Download; @@ -64,69 +65,121 @@ public CrispAsrDownloadService(HttpClient httpClient) public async Task DownloadEngine(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), stream, progress, cancellationToken); + var download = GetDefaultDownload(); + await DownloadAndVerifyAsync(download.Url, download.HashKey, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsCuda(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCudaUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsCudaUrl, DownloadHashManager.CrispAsr.WindowsCuda, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsCuda13(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCuda13Url, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsCuda13Url, DownloadHashManager.CrispAsr.WindowsCuda13, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsVulkan(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsVulkanUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsVulkanUrl, DownloadHashManager.CrispAsr.WindowsVulkan, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsCpu(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCpuUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsCpuUrl, DownloadHashManager.CrispAsr.WindowsCpu, stream, progress, cancellationToken); } public async Task DownloadEngineWindowsCpuLegacy(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCpuLegacyUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(WindowsCpuLegacyUrl, DownloadHashManager.CrispAsr.WindowsCpuLegacy, stream, progress, cancellationToken); } public async Task DownloadEngineLinuxCuda(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, LinuxCudaUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(LinuxCudaUrl, DownloadHashManager.CrispAsr.LinuxCuda, stream, progress, cancellationToken); } public async Task DownloadEngineLinuxCuda13(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, LinuxCuda13Url, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(LinuxCuda13Url, DownloadHashManager.CrispAsr.LinuxCuda13, stream, progress, cancellationToken); } public async Task DownloadEngineLinuxVulkan(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, LinuxVulkanUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(LinuxVulkanUrl, DownloadHashManager.CrispAsr.LinuxVulkan, stream, progress, cancellationToken); } public async Task DownloadEngineLinuxHip(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, LinuxHipUrl, stream, progress, cancellationToken); + await DownloadAndVerifyAsync(LinuxHipUrl, DownloadHashManager.CrispAsr.LinuxHip, stream, progress, cancellationToken); } - private static string GetUrl() + private async Task DownloadAndVerifyAsync( + string url, + string hashKey, + Stream stream, + IProgress? progress, + CancellationToken cancellationToken) + { + await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken); + await VerifyArchiveAsync(stream, hashKey, cancellationToken); + } + + internal static async Task VerifyArchiveAsync(Stream stream, string? hashKey, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(hashKey)) + { + throw new InvalidOperationException("No SHA-256 key is registered for the Crisp ASR runtime."); + } + + var expected = DownloadHashManager.GetLatestKnownHash(hashKey); + if (string.IsNullOrEmpty(expected)) + { + throw new InvalidOperationException($"No SHA-256 is registered for Crisp ASR runtime key '{hashKey}'."); + } + + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("Crisp ASR 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( + $"Crisp ASR runtime download failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + private static (string Url, string HashKey) GetDefaultDownload() { if (OperatingSystem.IsWindows()) { - return WindowsVulkanUrl; + return (WindowsVulkanUrl, DownloadHashManager.CrispAsr.WindowsVulkan); } if (OperatingSystem.IsLinux()) { - return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? LinuxArmUrl : LinuxUrl; + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? (LinuxArmUrl, DownloadHashManager.CrispAsr.LinuxArm) + : (LinuxUrl, DownloadHashManager.CrispAsr.Linux); } if (OperatingSystem.IsMacOS()) { - return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? MacUrl : MacIntelUrl; + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 + ? (MacUrl, DownloadHashManager.CrispAsr.MacOs) + : (MacIntelUrl, DownloadHashManager.CrispAsr.MacOsX64); } throw new PlatformNotSupportedException(); diff --git a/tests/UI/Logic/Download/CrispAsrDownloadServiceTests.cs b/tests/UI/Logic/Download/CrispAsrDownloadServiceTests.cs new file mode 100644 index 00000000000..f619398fc95 --- /dev/null +++ b/tests/UI/Logic/Download/CrispAsrDownloadServiceTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class CrispAsrDownloadServiceTests +{ + [Theory] + [InlineData(DownloadHashManager.CrispAsr.WindowsCuda, "9108d2be9b61415cf2c6d758d09a6fbfda369c2cda2d98f1f3d61e1326792d01")] + [InlineData(DownloadHashManager.CrispAsr.WindowsCuda13, "e2183d839d13a2eeea175f167acfafbd66ba5cc072bd21500777c2a43e1aa8a4")] + [InlineData(DownloadHashManager.CrispAsr.WindowsVulkan, "112a33912d464346ba1c2a75f975864a7ed0a3c1bd1ad0c3cf8806b6919efd7d")] + [InlineData(DownloadHashManager.CrispAsr.WindowsCpu, "ac8b6caf4dd448d00c5050907275bce4d154747110c37943aa4f69ee7fac9541")] + [InlineData(DownloadHashManager.CrispAsr.WindowsCpuLegacy, "ba4e23fb8dfcc99b8a76af034954576a75f88193e3dbf62fc774287bcbd1114b")] + [InlineData(DownloadHashManager.CrispAsr.MacOs, "5e740d35e91a8dcaa79efd3ef0be3412de4796b68066921a9ea6984d2fc6b2ad")] + [InlineData(DownloadHashManager.CrispAsr.MacOsX64, "a2760e096d64aeab03904f2d3a57bdee3db7f9d8ebdc621075473463236d2b01")] + [InlineData(DownloadHashManager.CrispAsr.Linux, "6953d1e6cd8d7d828183befcf76877f1a7e3908514548a511de786887106ff08")] + [InlineData(DownloadHashManager.CrispAsr.LinuxCuda, "becc7ae1359713af19fa09446cdc32d55c7cbca137b0a0f8cfddb6d45be04cde")] + [InlineData(DownloadHashManager.CrispAsr.LinuxCuda13, "ef59f54e9409bb2ba52508c7a115a2f0fe73a04c0313dea79842bea3a231fb7a")] + [InlineData(DownloadHashManager.CrispAsr.LinuxVulkan, "8d670a24830610861a3f47c4b2e78eeefc5174ef68cf415c8b0accc6545141fe")] + [InlineData(DownloadHashManager.CrispAsr.LinuxHip, "5a3d2ff4fa02d91d56cce6d3f4162faf70a5798e3ecbb857153d56fd637de711")] + [InlineData(DownloadHashManager.CrispAsr.LinuxArm, "eb39ca1274084add172764ce638a600e50fc4b65f4b18776aa78dcf31486570c")] + public void RegistryHash_MatchesPublishedReleaseDigest(string key, string expected) + { + Assert.Equal(expected, DownloadHashManager.GetLatestKnownHash(key)); + } + + [Fact] + public async Task DownloadEngine_TamperedPayload_RejectsDownloadedBytes() + { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + Assert.Skip("Crisp ASR runtime is not supported on this operating system."); + } + + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new CrispAsrDownloadService(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 VerifyArchiveAsync_UnknownKey_FailsClosed() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await Assert.ThrowsAsync(() => + CrispAsrDownloadService.VerifyArchiveAsync( + stream, + "CrispAsr.Unknown", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VerifyArchiveAsync_TamperedPayload_RewindsStream() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered")); + + await Assert.ThrowsAsync(() => + CrispAsrDownloadService.VerifyArchiveAsync( + stream, + DownloadHashManager.CrispAsr.Linux, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + 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), + }); + } + } +}