diff --git a/src/ui/Logic/Download/GoogleLensOcrDownloadService.cs b/src/ui/Logic/Download/GoogleLensOcrDownloadService.cs index e11da13d8c9..11fc196d41b 100644 --- a/src/ui/Logic/Download/GoogleLensOcrDownloadService.cs +++ b/src/ui/Logic/Download/GoogleLensOcrDownloadService.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; namespace Nikse.SubtitleEdit.Logic.Download; @@ -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? 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? 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? 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); + } } \ No newline at end of file diff --git a/tests/UI/Logic/Download/GoogleLensOcrDownloadServiceTests.cs b/tests/UI/Logic/Download/GoogleLensOcrDownloadServiceTests.cs new file mode 100644 index 00000000000..aa6f99fa976 --- /dev/null +++ b/tests/UI/Logic/Download/GoogleLensOcrDownloadServiceTests.cs @@ -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(() => + 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(() => + 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(() => + GoogleLensOcrDownloadService.VerifyStreamAsync( + stream, + expected, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + } + } +}