Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion src/ui/Logic/Download/TesseractDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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";

/// <summary>Tesseract version behind <see cref="WindowsUrl"/>; stamped into the install folder.</summary>
public const string WindowsVersion = "5.5.3";
Expand Down Expand Up @@ -49,14 +51,55 @@ private static string GetTesseractUrl()

public async Task DownloadTesseract(Stream stream, IProgress<float>? 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<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, modelUrl, stream, progress, cancellationToken);
}

internal static async Task DownloadAndVerifyRuntimeAsync(
HttpClient httpClient,
string url,
Stream stream,
IProgress<float>? 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}).");
}
}

/// <summary>
/// True when Tesseract is installed but older than <see cref="WindowsVersion"/>. Windows only:
/// elsewhere the binary comes from brew/apt and is not ours to update.
Expand Down
83 changes: 83 additions & 0 deletions tests/UI/Logic/Download/TesseractDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<IOException>(() =>
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<IOException>(() =>
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<InvalidOperationException>(() =>
TesseractDownloadService.VerifyRuntimeArchiveAsync(
stream,
TestContext.Current.CancellationToken));
}

private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> 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();
}
}