diff --git a/src/ui/Logic/Download/TesseractDownloadService.cs b/src/ui/Logic/Download/TesseractDownloadService.cs index fe2baba0468..f461555cf23 100644 --- a/src/ui/Logic/Download/TesseractDownloadService.cs +++ b/src/ui/Logic/Download/TesseractDownloadService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Nikse.SubtitleEdit.Logic.Compression; using Nikse.SubtitleEdit.Logic.Config; +using Nikse.SubtitleEdit.UiLogic; namespace Nikse.SubtitleEdit.Logic.Download; @@ -18,6 +19,7 @@ public class TesseractDownloadService : ITesseractDownloadService { private readonly HttpClient _httpClient; private const string WindowsUrl = "https://github.com/SubtitleEdit/support-files/releases/download/tesseract553/Tesseract553.zip"; + internal const string WindowsArchiveSha256 = "fef2dbb1de8f25d660301c17aff107c0d9b0dc99e0d4f0eee938eb7238d7d2dc"; /// Tesseract version behind ; stamped into the install folder. public const string WindowsVersion = "5.5.3"; @@ -49,7 +51,12 @@ private static string GetTesseractUrl() public async Task DownloadTesseract(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, GetTesseractUrl(), stream, progress, cancellationToken); + await DownloadAndVerifyRuntimeAsync( + _httpClient, + GetTesseractUrl(), + stream, + progress, + cancellationToken); } public async Task DownloadTesseractModel(string modelUrl, Stream stream, IProgress? progress, CancellationToken cancellationToken) @@ -57,6 +64,42 @@ public async Task DownloadTesseractModel(string modelUrl, Stream stream, IProgre await DownloadHelper.DownloadFileAsync(_httpClient, modelUrl, stream, progress, cancellationToken); } + internal static async Task DownloadAndVerifyRuntimeAsync( + HttpClient httpClient, + string url, + Stream stream, + IProgress? progress, + CancellationToken cancellationToken) + { + await DownloadHelper.DownloadFileAsync(httpClient, url, stream, progress, cancellationToken); + await VerifyRuntimeArchiveAsync(stream, cancellationToken); + } + + internal static async Task VerifyRuntimeArchiveAsync(Stream stream, CancellationToken cancellationToken) + { + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("Tesseract 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(WindowsArchiveSha256, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Tesseract runtime download failed integrity check (expected SHA-256 {WindowsArchiveSha256}, got {actual})."); + } + } + /// /// True when Tesseract is installed but older than . Windows only: /// elsewhere the binary comes from brew/apt and is not ours to update. diff --git a/tests/UI/Logic/Download/TesseractDownloadServiceTests.cs b/tests/UI/Logic/Download/TesseractDownloadServiceTests.cs new file mode 100644 index 00000000000..69fc49c93cb --- /dev/null +++ b/tests/UI/Logic/Download/TesseractDownloadServiceTests.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class TesseractDownloadServiceTests +{ + [Fact] + public void WindowsArchiveSha256_MatchesSupportFilesReleaseDigest() + { + Assert.Equal( + "fef2dbb1de8f25d660301c17aff107c0d9b0dc99e0d4f0eee938eb7238d7d2dc", + TesseractDownloadService.WindowsArchiveSha256); + } + + [Fact] + public async Task DownloadAndVerifyRuntimeAsync_TamperedPayload_IsRejectedAndRewound() + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + TesseractDownloadService.DownloadAndVerifyRuntimeAsync( + httpClient, + "https://example.test/Tesseract553.zip", + stream, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyRuntimeArchiveAsync_TamperedPayload_RejectsAndRewindsStream() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered")); + + await Assert.ThrowsAsync(() => + TesseractDownloadService.VerifyRuntimeArchiveAsync( + stream, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyRuntimeArchiveAsync_NonSeekableStream_FailsClosed() + { + await using var stream = new NonSeekableReadStream(Encoding.ASCII.GetBytes("payload")); + + await Assert.ThrowsAsync(() => + TesseractDownloadService.VerifyRuntimeArchiveAsync( + stream, + TestContext.Current.CancellationToken)); + } + + 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), + }); + } + } + + private sealed class NonSeekableReadStream(byte[] data) : MemoryStream(data) + { + public override bool CanSeek => false; + + public override long Position + { + get => base.Position; + set => throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin loc) => throw new NotSupportedException(); + } +}