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
117 changes: 107 additions & 10 deletions src/ui/Logic/Download/GoogleLensOcrDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Nikse.SubtitleEdit.UiLogic;

namespace Nikse.SubtitleEdit.Logic.Download;

Expand All @@ -18,35 +19,131 @@ public class GoogleLensOcrDownloadService(HttpClient httpClient) : IGoogleLensOc
{
//private const string WindowsUrl = "https://github.com/timminator/chrome-lens-py/releases/download/v3.3.0/Chrome-Lens-CLI-v3.3.0.7z";
private const string WindowsUrl = "https://github.com/timminator/Chrome-Lens-OCR/releases/download/v3.4.0/Chrome-Lens-OCR-v3.4.0.7z";
private const string WindowsSha256 = "201685c3a3857515360174ab1e470c0f6d1e35fd90ece76deb74c277d17df085";
private const string LinuxUrl = "https://github.com/timminator/Chrome-Lens-OCR/releases/download/v3.4.0/Chrome-Lens-OCR-v3.4.0-Linux.7z";
private const string LinuxSha256 = "661348e20c12e4e43df061189bb90c46eff07ec25835532c52f6dcb1ce9d6d42";

internal readonly record struct DownloadInfo(string Url, string Sha256);

public async Task DownloadGoogleLensOcrStandalone(string destinationFileName, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(httpClient, GetUrl(), destinationFileName, progress, cancellationToken);
await DownloadAndVerifyFileAsync(httpClient, GetDownload(), destinationFileName, progress, cancellationToken);
}

public async Task DownloadGoogleLensOcrStandalone(Stream stream, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(httpClient, GetUrl(), stream, progress, cancellationToken);
var download = GetDownload();
await DownloadHelper.DownloadFileAsync(httpClient, download.Url, stream, progress, cancellationToken);
await VerifyStreamAsync(stream, download.Sha256, cancellationToken);
}

private string GetUrl()
internal static async Task DownloadAndVerifyFileAsync(
HttpClient client,
DownloadInfo download,
string destinationFileName,
IProgress<float>? progress,
CancellationToken cancellationToken)
{
if (OperatingSystem.IsWindows())
if (string.IsNullOrWhiteSpace(download.Sha256))
{
return WindowsUrl;
throw new InvalidOperationException("No SHA-256 is registered for the selected Google Lens OCR archive.");
}

if (OperatingSystem.IsLinux())
try
{
await DownloadHelper.DownloadFileAsync(
client,
download.Url,
destinationFileName,
progress,
cancellationToken);

await using var stream = File.OpenRead(destinationFileName);
var actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken);
if (!string.Equals(download.Sha256, actual, StringComparison.OrdinalIgnoreCase))
{
throw new IOException(
$"Google Lens OCR download failed integrity check (expected SHA-256 {download.Sha256}, got {actual}).");
}
}
catch
{
if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
try
{
if (File.Exists(destinationFileName))
{
File.Delete(destinationFileName);
}
}
catch
{
throw new PlatformNotSupportedException("Google Lens OCR is not available for Linux ARM64.");
// Preserve the original download/integrity error; cleanup is best-effort.
}

return LinuxUrl;
throw;
}
}

internal static async Task VerifyStreamAsync(
Stream stream,
string expectedSha256,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(expectedSha256))
{
throw new InvalidOperationException("No SHA-256 is registered for the selected Google Lens OCR archive.");
}

if (!stream.CanRead || !stream.CanSeek)
{
throw new InvalidOperationException("Google Lens OCR 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(expectedSha256, actual, StringComparison.OrdinalIgnoreCase))
{
throw new IOException(
$"Google Lens OCR download failed integrity check (expected SHA-256 {expectedSha256}, got {actual}).");
}
}

private static DownloadInfo GetDownload()
{
if (OperatingSystem.IsWindows())
{
return ResolveWindowsDownload();
}

if (OperatingSystem.IsLinux())
{
return ResolveLinuxDownload(RuntimeInformation.ProcessArchitecture);
}

throw new PlatformNotSupportedException("Google Lens OCR does not support this platform");
}
}

internal static DownloadInfo ResolveWindowsDownload()
{
return new DownloadInfo(WindowsUrl, WindowsSha256);
}

internal static DownloadInfo ResolveLinuxDownload(Architecture architecture)
{
if (architecture == Architecture.Arm64)
{
throw new PlatformNotSupportedException("Google Lens OCR is not available for Linux ARM64.");
}

return new DownloadInfo(LinuxUrl, LinuxSha256);
}
}
169 changes: 169 additions & 0 deletions tests/UI/Logic/Download/GoogleLensOcrDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using System.Net;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Nikse.SubtitleEdit.Logic.Download;

namespace UITests.Logic.Download;

public class GoogleLensOcrDownloadServiceTests
{
[Fact]
public void ResolveWindowsDownload_PinsOfficialArchiveDigest()
{
var download = GoogleLensOcrDownloadService.ResolveWindowsDownload();

Assert.Equal(
"https://github.com/timminator/Chrome-Lens-OCR/releases/download/v3.4.0/Chrome-Lens-OCR-v3.4.0.7z",
download.Url);
Assert.Equal(
"201685c3a3857515360174ab1e470c0f6d1e35fd90ece76deb74c277d17df085",
download.Sha256);
}

[Fact]
public void ResolveLinuxDownload_X64_PinsOfficialArchiveDigest()
{
var download = GoogleLensOcrDownloadService.ResolveLinuxDownload(Architecture.X64);

Assert.Equal(
"https://github.com/timminator/Chrome-Lens-OCR/releases/download/v3.4.0/Chrome-Lens-OCR-v3.4.0-Linux.7z",
download.Url);
Assert.Equal(
"661348e20c12e4e43df061189bb90c46eff07ec25835532c52f6dcb1ce9d6d42",
download.Sha256);
}

[Fact]
public void ResolveLinuxDownload_Arm64_RemainsUnsupported()
{
Assert.Throws<PlatformNotSupportedException>(() =>
GoogleLensOcrDownloadService.ResolveLinuxDownload(Architecture.Arm64));
}

[Fact]
public async Task DownloadAndVerifyFileAsync_TamperedPayload_RejectsAndDeletesFile()
{
var expectedPayload = Encoding.ASCII.GetBytes("expected");
var expected = Convert.ToHexString(SHA256.HashData(expectedPayload)).ToLowerInvariant();
var download = new GoogleLensOcrDownloadService.DownloadInfo("https://example.test/google-lens.7z", expected);
var fileName = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".7z");

using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered")));

await Assert.ThrowsAsync<IOException>(() =>
GoogleLensOcrDownloadService.DownloadAndVerifyFileAsync(
httpClient,
download,
fileName,
progress: null,
TestContext.Current.CancellationToken));

Assert.False(File.Exists(fileName));
}

[Fact]
public async Task DownloadAndVerifyFileAsync_ValidPayload_PreservesFile()
{
var payload = Encoding.ASCII.GetBytes("abc");
var expected = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
var download = new GoogleLensOcrDownloadService.DownloadInfo("https://example.test/google-lens.7z", expected);
var fileName = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".7z");

try
{
using var httpClient = new HttpClient(new StaticResponseHandler(payload));

await GoogleLensOcrDownloadService.DownloadAndVerifyFileAsync(
httpClient,
download,
fileName,
progress: null,
TestContext.Current.CancellationToken);

Assert.True(File.Exists(fileName));
Assert.Equal(payload, await File.ReadAllBytesAsync(fileName, TestContext.Current.CancellationToken));
}
finally
{
File.Delete(fileName);
}
}

[Fact]
public async Task DownloadAndVerifyFileAsync_MissingDigest_FailsBeforeHttp()
{
var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("unused"));
using var httpClient = new HttpClient(handler);
var download = new GoogleLensOcrDownloadService.DownloadInfo("https://example.test/google-lens.7z", string.Empty);
var fileName = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".7z");

await Assert.ThrowsAsync<InvalidOperationException>(() =>
GoogleLensOcrDownloadService.DownloadAndVerifyFileAsync(
httpClient,
download,
fileName,
progress: null,
TestContext.Current.CancellationToken));

Assert.Equal(0, handler.RequestCount);
Assert.False(File.Exists(fileName));
}

[Fact]
public async Task VerifyStreamAsync_TamperedPayload_RewindsStream()
{
var expectedPayload = Encoding.ASCII.GetBytes("expected");
var expected = Convert.ToHexString(SHA256.HashData(expectedPayload)).ToLowerInvariant();
await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered"));

await Assert.ThrowsAsync<IOException>(() =>
GoogleLensOcrDownloadService.VerifyStreamAsync(
stream,
expected,
TestContext.Current.CancellationToken));

Assert.Equal(0, stream.Position);
}

[Fact]
public async Task VerifyStreamAsync_NonSeekableStream_FailsClosed()
{
await using var stream = new NonSeekableReadStream(Encoding.ASCII.GetBytes("data"));

await Assert.ThrowsAsync<InvalidOperationException>(() =>
GoogleLensOcrDownloadService.VerifyStreamAsync(
stream,
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
TestContext.Current.CancellationToken));
}

private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler
{
public int RequestCount { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestCount++;
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();
}
}