Skip to content
Closed
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
107 changes: 97 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,121 @@ 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())
try
{
return WindowsUrl;
}
await DownloadHelper.DownloadFileAsync(
client,
download.Url,
destinationFileName,
progress,
cancellationToken);

if (OperatingSystem.IsLinux())
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 (!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);
}
}
89 changes: 89 additions & 0 deletions tests/UI/Logic/Download/GoogleLensOcrDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 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);
}

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),
});
}
}
}
Loading