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();
+ }
+}