diff --git a/src/ui/Features/Ocr/Download/DownloadPaddleOcrViewModel.cs b/src/ui/Features/Ocr/Download/DownloadPaddleOcrViewModel.cs index e018d166e63..a84793aad84 100644 --- a/src/ui/Features/Ocr/Download/DownloadPaddleOcrViewModel.cs +++ b/src/ui/Features/Ocr/Download/DownloadPaddleOcrViewModel.cs @@ -7,6 +7,7 @@ using Nikse.SubtitleEdit.Logic.Config; using Nikse.SubtitleEdit.Logic.Download; using Nikse.SubtitleEdit.Logic.SevenZipExtractor; +using Nikse.SubtitleEdit.UiLogic; using Nikse.SubtitleEdit.UiLogic.Http; using System; using System.Collections.Generic; @@ -36,7 +37,7 @@ public partial class DownloadPaddleOcrViewModel : ObservableObject, IClosingClea private string _tempFolder; private Task? _downloadTask; private int _downloadTaskIndex; - private List _downloadTaskUrls; + private List _downloadTaskAssets; private Timer _timer = new Timer(500); private bool _done; private readonly CancellationTokenSource _cancellationTokenSource; @@ -52,7 +53,7 @@ public DownloadPaddleOcrViewModel() Error = string.Empty; _tempFolder = string.Empty; _downloadType = PaddleOcrDownloadType.Models; - _downloadTaskUrls = new List(); + _downloadTaskAssets = new List(); _downloadTaskIndex = 0; } @@ -92,15 +93,15 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) { _timer.Stop(); - if (_downloadTaskIndex < _downloadTaskUrls.Count - 1) + if (_downloadTaskIndex < _downloadTaskAssets.Count - 1) { _downloadTaskIndex++; Dispatcher.UIThread.Post(() => { - ProgressText = $"Starting download {_downloadTaskIndex + 1} of {_downloadTaskUrls.Count}..."; - var url = _downloadTaskUrls[_downloadTaskIndex]; - var fileName = Path.Combine(_tempFolder, Path.GetFileName(url)); - _downloadTask = DownloadHelper.DownloadFileAsync(HttpClientFactoryWithProxy.CreateHttpClientWithProxy(), url, fileName, new Progress(number => + ProgressText = $"Starting download {_downloadTaskIndex + 1} of {_downloadTaskAssets.Count}..."; + var asset = _downloadTaskAssets[_downloadTaskIndex]; + var fileName = Path.Combine(_tempFolder, Path.GetFileName(asset.Url)); + _downloadTask = DownloadAssetAsync(asset, fileName, new Progress(number => { var percentage = (int)Math.Round(number * 100.0, MidpointRounding.AwayFromZero); var pctString = percentage.ToString(CultureInfo.InvariantCulture); @@ -129,7 +130,7 @@ private void OnTimerOnElapsed(object? sender, ElapsedEventArgs args) try { - var firstFile = Path.Combine(_tempFolder, Path.GetFileName(_downloadTaskUrls[0])); + var firstFile = Path.Combine(_tempFolder, Path.GetFileName(_downloadTaskAssets[0].Url)); var isModels = _downloadType == PaddleOcrDownloadType.Models; var archive = PaddleOcr.GetArchive(_downloadType); @@ -234,11 +235,70 @@ private static void DeleteLegacyInstallFolders() } } + private async Task DownloadAssetAsync( + PaddleOcr.PaddleOcrAsset asset, + string destinationFileName, + IProgress? progress, + CancellationToken cancellationToken) + { + using var httpClient = HttpClientFactoryWithProxy.CreateHttpClientWithProxy(); + await DownloadAndVerifyAssetAsync(httpClient, asset, destinationFileName, progress, cancellationToken); + } + + internal static async Task DownloadAndVerifyAssetAsync( + HttpClient httpClient, + PaddleOcr.PaddleOcrAsset asset, + string destinationFileName, + IProgress? progress, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(asset.Sha256)) + { + throw new InvalidOperationException($"No SHA-256 is pinned for Paddle OCR asset '{asset.Url}'."); + } + + try + { + await DownloadHelper.DownloadFileAsync( + httpClient, + asset.Url, + destinationFileName, + progress, + cancellationToken); + + string actual; + await using (var stream = File.OpenRead(destinationFileName)) + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + + if (!string.Equals(asset.Sha256, actual, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"Paddle OCR download failed integrity check for {Path.GetFileName(asset.Url)} " + + $"(expected SHA-256 {asset.Sha256}, got {actual})."); + } + } + catch + { + try + { + File.Delete(destinationFileName); + } + catch + { + // Preserve the download/integrity error; cleanup is best-effort. + } + + throw; + } + } + private bool AllFileExists() { - foreach (var url in _downloadTaskUrls) + foreach (var asset in _downloadTaskAssets) { - var fileName = Path.Combine(_tempFolder, Path.GetFileName(url)); + var fileName = Path.Combine(_tempFolder, Path.GetFileName(asset.Url)); if (!File.Exists(fileName)) { Se.LogError($"Expected file not found after download: {fileName}"); @@ -308,12 +368,12 @@ public void StartDownload() _tempFolder = Path.Combine(folder, $"{Guid.NewGuid()}"); Directory.CreateDirectory(_tempFolder); _downloadTaskIndex = 0; - _downloadTaskUrls = new List(); + _downloadTaskAssets = new List(); - List urls; + List assets; try { - urls = PaddleOcr.GetArchive(_downloadType).Urls.ToList(); + assets = PaddleOcr.GetArchive(_downloadType).Assets.ToList(); } catch (ArgumentOutOfRangeException exception) { @@ -323,10 +383,10 @@ public void StartDownload() return; } - _downloadTaskUrls.AddRange(urls); - var firstUrl = _downloadTaskUrls[_downloadTaskIndex]; - var firstFileName = Path.Combine(_tempFolder, Path.GetFileName(firstUrl)); - _downloadTask = DownloadHelper.DownloadFileAsync(HttpClientFactoryWithProxy.CreateHttpClientWithProxy(), firstUrl, firstFileName, downloadProgress, _cancellationTokenSource.Token); + _downloadTaskAssets.AddRange(assets); + var firstAsset = _downloadTaskAssets[_downloadTaskIndex]; + var firstFileName = Path.Combine(_tempFolder, Path.GetFileName(firstAsset.Url)); + _downloadTask = DownloadAssetAsync(firstAsset, firstFileName, downloadProgress, _cancellationTokenSource.Token); _timer.Elapsed += OnTimerOnElapsed; _timer.Start(); diff --git a/src/ui/Features/Ocr/Engines/PaddleOcr.cs b/src/ui/Features/Ocr/Engines/PaddleOcr.cs index 3c8dc212b62..ef27564b67a 100644 --- a/src/ui/Features/Ocr/Engines/PaddleOcr.cs +++ b/src/ui/Features/Ocr/Engines/PaddleOcr.cs @@ -49,25 +49,45 @@ public partial class PaddleOcr /// the archive that the extractor has to strip. Keeping the two together is what stops a /// version bump from updating the URL but leaving the unpack looking for the old folder. /// - public sealed record PaddleOcrArchive(IReadOnlyList Urls, string RootFolderInArchive); + public sealed record PaddleOcrAsset(string Url, string Sha256); + + public sealed record PaddleOcrArchive(IReadOnlyList Assets, string RootFolderInArchive) + { + public IReadOnlyList Urls => Assets.Select(asset => asset.Url).ToArray(); + } public static PaddleOcrArchive GetArchive(PaddleOcrDownloadType downloadType) { return downloadType switch { - PaddleOcrDownloadType.Models => Archive("PaddleOCR.PP-OCRv6.support.files.VideOCR.7z", "PaddleOCR.PP-OCRv6.support.files"), - PaddleOcrDownloadType.EngineCpu => Archive("PaddleOCR-CPU-v3.7.0.7z"), - PaddleOcrDownloadType.EngineGpu11 => Archive("PaddleOCR-GPU-v3.7.0-CUDA-11.8.7z"), - PaddleOcrDownloadType.EngineGpu12 => Archive("PaddleOCR-GPU-v3.7.0-CUDA-12.9.7z"), - PaddleOcrDownloadType.EngineCpuLinux => Archive("PaddleOCR-CPU-v3.7.0-Linux.7z"), - PaddleOcrDownloadType.EngineGpu11Linux => Archive("PaddleOCR-GPU-v3.7.0-CUDA-11.8-Linux.7z"), + PaddleOcrDownloadType.Models => Archive( + "PaddleOCR.PP-OCRv6.support.files.VideOCR.7z", + "7f98a187a1d8d9b5291f3be7cd6a6b693b32ddffd75d39c05d333d8f0b3ee145", + "PaddleOCR.PP-OCRv6.support.files"), + PaddleOcrDownloadType.EngineCpu => Archive( + "PaddleOCR-CPU-v3.7.0.7z", + "a1b597f5620d1a86cec606b50908a12fc1b215adf6807be5538b7ca6bddc9d20"), + PaddleOcrDownloadType.EngineGpu11 => Archive( + "PaddleOCR-GPU-v3.7.0-CUDA-11.8.7z", + "5bfe2009cab89ce7f6b70f43f8250460ce6ccc6ccf176b95e0c363079bc4da50"), + PaddleOcrDownloadType.EngineGpu12 => Archive( + "PaddleOCR-GPU-v3.7.0-CUDA-12.9.7z", + "6a2c1f17f093403c8f2f4c4c7b81148b29abe710604aca8fac403af2be173cab"), + PaddleOcrDownloadType.EngineCpuLinux => Archive( + "PaddleOCR-CPU-v3.7.0-Linux.7z", + "1d2bd1db1d534dcd433c2d658f1c9ed13beb92fc7201a7049d376bd15e8fc39e"), + PaddleOcrDownloadType.EngineGpu11Linux => Archive( + "PaddleOCR-GPU-v3.7.0-CUDA-11.8-Linux.7z", + "3850afef8ba8bf9f65911e855a866f9df0de06b0b8f0030dbd827162819d7158"), // Split into two volumes upstream. Both have to land in the same folder before the // .001 is handed to the extractor - the download queue takes care of that. PaddleOcrDownloadType.EngineGpu12Linux => Archive( "PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux.7z.001", + "e154edaa5f80913d2a3aba0c05110ebf09f5d100f9db1b11e2d2d2b61bff4212", "PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux", - "PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux.7z.002"), + ("PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux.7z.002", + "900200376f77a85fc4fc2562b1832fc547092eaa6951772894666be87585bf89")), _ => throw new ArgumentOutOfRangeException(nameof(downloadType), downloadType, "Unknown Paddle OCR download type"), }; @@ -76,15 +96,24 @@ public static PaddleOcrArchive GetArchive(PaddleOcrDownloadType downloadType) // The engine archives all wrap their content in a folder named after the archive itself, // so the root folder is derived rather than repeated; the models archive is the one that // does not follow that rule (".VideOCR" is in the file name only) and passes it in. - private static PaddleOcrArchive Archive(string fileName, string? rootFolderInArchive = null, params string[] extraFileNames) + private static PaddleOcrArchive Archive( + string fileName, + string sha256, + string? rootFolderInArchive = null, + params (string FileName, string Sha256)[] extraAssets) { - var urls = new List(1 + extraFileNames.Length) { StandaloneRelease + fileName }; - foreach (var extraFileName in extraFileNames) + var assets = new List(1 + extraAssets.Length) + { + new(StandaloneRelease + fileName, sha256), + }; + foreach (var extraAsset in extraAssets) { - urls.Add(StandaloneRelease + extraFileName); + assets.Add(new PaddleOcrAsset(StandaloneRelease + extraAsset.FileName, extraAsset.Sha256)); } - return new PaddleOcrArchive(urls, rootFolderInArchive ?? fileName[..fileName.IndexOf(".7z", StringComparison.Ordinal)]); + return new PaddleOcrArchive( + assets, + rootFolderInArchive ?? fileName[..fileName.IndexOf(".7z", StringComparison.Ordinal)]); } // Model-name mapping lives in libse (PaddleOcrModels) so seconv launches the same models. diff --git a/tests/UI/Features/Ocr/Download/DownloadPaddleOcrIntegrityTests.cs b/tests/UI/Features/Ocr/Download/DownloadPaddleOcrIntegrityTests.cs new file mode 100644 index 00000000000..26e25611ebb --- /dev/null +++ b/tests/UI/Features/Ocr/Download/DownloadPaddleOcrIntegrityTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Features.Ocr; +using Nikse.SubtitleEdit.Features.Ocr.Download; + +namespace UITests.Features.Ocr.Download; + +public class DownloadPaddleOcrIntegrityTests +{ + [Fact] + public async Task DownloadAndVerifyAssetAsync_TamperedPayload_RejectsAndDeletesFile() + { + var fileName = Path.Combine(Path.GetTempPath(), $"subtitleedit-paddle-{Guid.NewGuid():N}.7z"); + var asset = new PaddleOcr.PaddleOcrAsset( + "https://example.invalid/PaddleOCR-CPU-v3.7.0.7z", + "a1b597f5620d1a86cec606b50908a12fc1b215adf6807be5538b7ca6bddc9d20"); + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + + try + { + await Assert.ThrowsAsync(() => + DownloadPaddleOcrViewModel.DownloadAndVerifyAssetAsync( + httpClient, + asset, + fileName, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(fileName)); + } + finally + { + File.Delete(fileName); + } + } + + [Fact] + public async Task DownloadAndVerifyAssetAsync_MatchingPayload_KeepsFile() + { + var fileName = Path.Combine(Path.GetTempPath(), $"subtitleedit-paddle-{Guid.NewGuid():N}.7z"); + var payload = Encoding.ASCII.GetBytes("abc"); + var asset = new PaddleOcr.PaddleOcrAsset( + "https://example.invalid/PaddleOCR-test.7z", + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + using var httpClient = new HttpClient(new StaticResponseHandler(payload)); + + try + { + await DownloadPaddleOcrViewModel.DownloadAndVerifyAssetAsync( + httpClient, + asset, + fileName, + progress: null, + TestContext.Current.CancellationToken); + + Assert.Equal(payload, await File.ReadAllBytesAsync(fileName, TestContext.Current.CancellationToken)); + } + finally + { + File.Delete(fileName); + } + } + + [Fact] + public async Task DownloadAndVerifyAssetAsync_MissingDigest_FailsClosed() + { + var fileName = Path.Combine(Path.GetTempPath(), $"subtitleedit-paddle-{Guid.NewGuid():N}.7z"); + var asset = new PaddleOcr.PaddleOcrAsset( + "https://example.invalid/PaddleOCR-test.7z", + string.Empty); + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("unused"))); + + await Assert.ThrowsAsync(() => + DownloadPaddleOcrViewModel.DownloadAndVerifyAssetAsync( + httpClient, + asset, + fileName, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.False(File.Exists(fileName)); + } + + 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), + }); + } + } +} diff --git a/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs b/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs index 86f7ecd8346..81b2069859a 100644 --- a/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs +++ b/tests/UI/Features/Ocr/Engines/PaddleOcrDownloadArchiveTests.cs @@ -30,9 +30,33 @@ public void EveryDownloadType_HasAnArchive(PaddleOcrDownloadType downloadType) { var archive = PaddleOcr.GetArchive(downloadType); + Assert.NotEmpty(archive.Assets); Assert.NotEmpty(archive.Urls); Assert.NotEmpty(archive.RootFolderInArchive); - Assert.All(archive.Urls, url => Assert.StartsWith("https://github.com/timminator/PaddleOCR-Standalone/releases/download/", url, StringComparison.Ordinal)); + Assert.All(archive.Assets, asset => + { + Assert.StartsWith("https://github.com/timminator/PaddleOCR-Standalone/releases/download/", asset.Url, StringComparison.Ordinal); + Assert.Matches("^[0-9a-f]{64}$", asset.Sha256); + }); + } + + [Theory] + [InlineData("PaddleOCR.PP-OCRv6.support.files.VideOCR.7z", "7f98a187a1d8d9b5291f3be7cd6a6b693b32ddffd75d39c05d333d8f0b3ee145")] + [InlineData("PaddleOCR-CPU-v3.7.0.7z", "a1b597f5620d1a86cec606b50908a12fc1b215adf6807be5538b7ca6bddc9d20")] + [InlineData("PaddleOCR-GPU-v3.7.0-CUDA-11.8.7z", "5bfe2009cab89ce7f6b70f43f8250460ce6ccc6ccf176b95e0c363079bc4da50")] + [InlineData("PaddleOCR-GPU-v3.7.0-CUDA-12.9.7z", "6a2c1f17f093403c8f2f4c4c7b81148b29abe710604aca8fac403af2be173cab")] + [InlineData("PaddleOCR-CPU-v3.7.0-Linux.7z", "1d2bd1db1d534dcd433c2d658f1c9ed13beb92fc7201a7049d376bd15e8fc39e")] + [InlineData("PaddleOCR-GPU-v3.7.0-CUDA-11.8-Linux.7z", "3850afef8ba8bf9f65911e855a866f9df0de06b0b8f0030dbd827162819d7158")] + [InlineData("PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux.7z.001", "e154edaa5f80913d2a3aba0c05110ebf09f5d100f9db1b11e2d2d2b61bff4212")] + [InlineData("PaddleOCR-GPU-v3.7.0-CUDA-12.9-Linux.7z.002", "900200376f77a85fc4fc2562b1832fc547092eaa6951772894666be87585bf89")] + public void PublishedAssetDigest_IsPinned(string fileName, string expected) + { + var asset = Assert.Single( + Enum.GetValues() + .SelectMany(downloadType => PaddleOcr.GetArchive(downloadType).Assets) + .Where(candidate => candidate.Url.EndsWith("/" + fileName, StringComparison.Ordinal))); + + Assert.Equal(expected, asset.Sha256); } [Theory]