diff --git a/src/ui/Logic/Download/TesseractDownloadService.cs b/src/ui/Logic/Download/TesseractDownloadService.cs index fe2baba0468..66dfab7bf45 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"; @@ -50,6 +52,7 @@ private static string GetTesseractUrl() public async Task DownloadTesseract(Stream stream, IProgress? progress, CancellationToken cancellationToken) { await DownloadHelper.DownloadFileAsync(_httpClient, GetTesseractUrl(), stream, progress, cancellationToken); + await VerifyRuntimeArchiveAsync(stream, cancellationToken); } public async Task DownloadTesseractModel(string modelUrl, Stream stream, IProgress? progress, CancellationToken cancellationToken) @@ -57,6 +60,31 @@ public async Task DownloadTesseractModel(string modelUrl, Stream stream, IProgre await DownloadHelper.DownloadFileAsync(_httpClient, modelUrl, stream, progress, 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..22995bb766a --- /dev/null +++ b/tests/UI/Logic/Download/TesseractDownloadServiceTests.cs @@ -0,0 +1,60 @@ +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 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 inner = new MemoryStream(Encoding.ASCII.GetBytes("payload")); + await using var stream = new NonSeekableStream(inner); + + await Assert.ThrowsAsync(() => + TesseractDownloadService.VerifyRuntimeArchiveAsync( + stream, + TestContext.Current.CancellationToken)); + } + + private sealed class NonSeekableStream(Stream inner) : Stream + { + public override bool CanRead => inner.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => inner.CanWrite; + public override long Length => inner.Length; + public override long Position + { + get => inner.Position; + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + public override ValueTask DisposeAsync() => inner.DisposeAsync(); + } +}