diff --git a/src/ui/Logic/Download/TtsDownloadService.cs b/src/ui/Logic/Download/TtsDownloadService.cs index d75452be055..0fbc561b4e2 100644 --- a/src/ui/Logic/Download/TtsDownloadService.cs +++ b/src/ui/Logic/Download/TtsDownloadService.cs @@ -15,6 +15,7 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; namespace Nikse.SubtitleEdit.Logic.Download; @@ -91,23 +92,99 @@ public TtsDownloadService(HttpClient httpClient) public async Task DownloadPiper(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) { - var url = OperatingSystem.IsWindows() ? WindowsPiperUrl : MacPiperUrl; - await DownloadHelper.DownloadFileAsync(_httpClient, url, destinationFileName, progress, cancellationToken); + await DownloadHelper.DownloadFileAsync(_httpClient, GetPiperUrl(), destinationFileName, progress, cancellationToken); + await VerifyPiperFileAsync(destinationFileName, cancellationToken); } public async Task DownloadPiper(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - var url = WindowsPiperUrl; + await DownloadHelper.DownloadFileAsync(_httpClient, GetPiperUrl(), stream, progress, cancellationToken); + await VerifyPiperArchiveAsync(stream, cancellationToken); + } + + private static string GetPiperUrl() + { + if (OperatingSystem.IsWindows()) + { + return WindowsPiperUrl; + } + if (OperatingSystem.IsLinux()) { - url = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? LinuxPiperArmUrl : LinuxPiperUrl; + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? LinuxPiperArmUrl : LinuxPiperUrl; } - else if (OperatingSystem.IsMacOS()) + + if (OperatingSystem.IsMacOS()) { - url = MacPiperUrl; + return MacPiperUrl; } - await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken); + throw new PlatformNotSupportedException(); + } + + internal static async Task VerifyPiperArchiveAsync(Stream stream, CancellationToken cancellationToken) + { + var key = DownloadHashManager.ResolvePiperKey(); + if (string.IsNullOrEmpty(key)) + { + throw new InvalidOperationException("No SHA-256 key is registered for the Piper runtime on this platform."); + } + + var expected = DownloadHashManager.GetLatestKnownHash(key); + if (string.IsNullOrEmpty(expected)) + { + throw new InvalidOperationException($"No SHA-256 is registered for Piper runtime key '{key}'."); + } + + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("Piper 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( + $"Piper runtime download failed integrity check (expected SHA-256 {expected}, got {actual})."); + } + } + + internal static async Task VerifyPiperFileAsync(string fileName, CancellationToken cancellationToken) + { + try + { + await using var stream = new FileStream( + fileName, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + useAsync: true); + await VerifyPiperArchiveAsync(stream, cancellationToken); + } + catch + { + try + { + File.Delete(fileName); + } + catch + { + // Preserve the integrity error; cleanup is best-effort. + } + + throw; + } } public async Task DownloadPiperModel(string destinationFileName, PiperVoice voice, IProgress? progress, CancellationToken cancellationToken) diff --git a/tests/UI/Logic/Download/TtsDownloadServicePiperTests.cs b/tests/UI/Logic/Download/TtsDownloadServicePiperTests.cs new file mode 100644 index 00000000000..6108264e5ce --- /dev/null +++ b/tests/UI/Logic/Download/TtsDownloadServicePiperTests.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class TtsDownloadServicePiperTests +{ + [Fact] + public async Task DownloadPiper_TamperedPayload_RejectsDownloadedBytes() + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new TtsDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadPiper( + stream, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyPiperArchiveAsync_TamperedPayload_RejectsBytes() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered")); + + await Assert.ThrowsAsync(() => + TtsDownloadService.VerifyPiperArchiveAsync( + stream, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyPiperFileAsync_TamperedPayload_DeletesFile() + { + var fileName = Path.Combine(Path.GetTempPath(), $"subtitleedit-piper-{Guid.NewGuid():N}.tmp"); + await File.WriteAllTextAsync(fileName, "tampered", TestContext.Current.CancellationToken); + + try + { + await Assert.ThrowsAsync(() => + TtsDownloadService.VerifyPiperFileAsync( + fileName, + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(fileName)); + } + finally + { + File.Delete(fileName); + } + } + + 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), + }); + } + } +}