From 7ad4bc1c8b426d19b1e6cabddc5a3f5a870486f1 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Sun, 13 Sep 2026 09:12:33 +0200 Subject: [PATCH] Verify FFmpeg downloads before install --- .../Logic/Download/FfmpegDownloadService.cs | 94 +++++++++++++++++- .../Download/FfmpegDownloadServiceTests.cs | 96 +++++++++++++++++++ 2 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 tests/UI/Logic/Download/FfmpegDownloadServiceTests.cs diff --git a/src/ui/Logic/Download/FfmpegDownloadService.cs b/src/ui/Logic/Download/FfmpegDownloadService.cs index 0f7f6101450..1f02ce5bcca 100644 --- a/src/ui/Logic/Download/FfmpegDownloadService.cs +++ b/src/ui/Logic/Download/FfmpegDownloadService.cs @@ -1,9 +1,11 @@ -using System; +using System; +using System.Collections.Generic; using System.IO; using System.Net.Http; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Nikse.SubtitleEdit.UiLogic; namespace Nikse.SubtitleEdit.Logic.Download; @@ -22,7 +24,17 @@ public class FfmpegDownloadService : IFfmpegDownloadService // Intel build past 8.0. private const string MacUrl = "https://github.com/SubtitleEdit/support-files/releases/download/ffmpeg-v8/ffmpeg80intel.zip"; private const string MacUrlArm = "https://github.com/SubtitleEdit/support-files/releases/download/ffmpeg-v9-1/ffmpeg90arm.zip"; - + + // GitHub-published release-asset digests for the exact archives above. Keep this map in sync + // with the pinned URLs so a future URL bump cannot silently disable integrity verification. + internal static readonly IReadOnlyDictionary KnownSha256 = + new Dictionary(StringComparer.Ordinal) + { + ["ffmpeg901.zip"] = "89575634e89298191693e74d97f2a01fdb251bdfe95f4cb64f8eaa9883da9844", + ["ffmpeg80intel.zip"] = "439c92ccbc6cf3116c4713d1724c3765f4fc68ad2351be6fa5709d7b52e1f063", + ["ffmpeg90arm.zip"] = "21721909d4a24544359aff1ac5ce0dded8a947a2abc4c939c8d525a7c6cc881b", + }; + public FfmpegDownloadService(HttpClient httpClient) { _httpClient = httpClient; @@ -51,13 +63,85 @@ private static string GetFfmpegUrl() throw new PlatformNotSupportedException(); } + internal static string GetExpectedSha256(string url) + { + var assetName = Path.GetFileName(new Uri(url).AbsolutePath); + if (!KnownSha256.TryGetValue(assetName, out var expectedSha256)) + { + throw new InvalidOperationException($"No SHA-256 is pinned for FFmpeg asset '{assetName}'."); + } + + return expectedSha256; + } + + internal static async Task VerifyChecksumAsync(Stream stream, string expectedSha256, CancellationToken cancellationToken) + { + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("FFmpeg integrity verification requires a readable, seekable stream."); + } + + string actualSha256; + stream.Position = 0; + try + { + actualSha256 = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + finally + { + stream.Position = 0; + } + + if (!string.Equals(expectedSha256, actualSha256, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException( + $"FFmpeg download failed integrity check (expected SHA-256 {expectedSha256}, got {actualSha256})."); + } + } + + internal async Task DownloadAndVerifyAsync( + Stream stream, + string url, + string expectedSha256, + IProgress? progress, + CancellationToken cancellationToken) + { + await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken); + await VerifyChecksumAsync(stream, expectedSha256, cancellationToken); + } + public async Task DownloadFfmpeg(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, GetFfmpegUrl(), destinationFileName, progress, cancellationToken); + var url = GetFfmpegUrl(); + var expectedSha256 = GetExpectedSha256(url); + + await DownloadHelper.DownloadFileAsync(_httpClient, url, destinationFileName, progress, cancellationToken); + + try + { + await using var stream = File.OpenRead(destinationFileName); + await VerifyChecksumAsync(stream, expectedSha256, cancellationToken); + } + catch + { + try + { + File.Delete(destinationFileName); + } + catch + { + // Best effort: verification failure must remain the primary error. + } + + throw; + } } public async Task DownloadFfmpeg(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, GetFfmpegUrl(), stream, progress, cancellationToken); + var url = GetFfmpegUrl(); + var expectedSha256 = GetExpectedSha256(url); + + await DownloadAndVerifyAsync(stream, url, expectedSha256, progress, cancellationToken); } -} \ No newline at end of file +} diff --git a/tests/UI/Logic/Download/FfmpegDownloadServiceTests.cs b/tests/UI/Logic/Download/FfmpegDownloadServiceTests.cs new file mode 100644 index 00000000000..301cda572dd --- /dev/null +++ b/tests/UI/Logic/Download/FfmpegDownloadServiceTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class FfmpegDownloadServiceTests +{ + [Fact] + public void KnownSha256_ContainsEveryPinnedFfmpegAsset() + { + var expected = new Dictionary + { + ["ffmpeg901.zip"] = "89575634e89298191693e74d97f2a01fdb251bdfe95f4cb64f8eaa9883da9844", + ["ffmpeg80intel.zip"] = "439c92ccbc6cf3116c4713d1724c3765f4fc68ad2351be6fa5709d7b52e1f063", + ["ffmpeg90arm.zip"] = "21721909d4a24544359aff1ac5ce0dded8a947a2abc4c939c8d525a7c6cc881b", + }; + + Assert.Equal(expected.Count, FfmpegDownloadService.KnownSha256.Count); + foreach (var (assetName, sha256) in expected) + { + Assert.True(FfmpegDownloadService.KnownSha256.TryGetValue(assetName, out var actual)); + Assert.Equal(sha256, actual); + Assert.Matches("^[0-9a-f]{64}$", actual); + } + } + + [Fact] + public void GetExpectedSha256_UnknownAsset_FailsClosed() + { + Assert.Throws(() => + FfmpegDownloadService.GetExpectedSha256( + "https://github.com/SubtitleEdit/support-files/releases/download/ffmpeg-v99/ffmpeg99.zip")); + } + + [Fact] + public async Task VerifyChecksumAsync_KnownDigest_SucceedsAndRewindsStream() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await FfmpegDownloadService.VerifyChecksumAsync( + stream, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + TestContext.Current.CancellationToken); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyChecksumAsync_Mismatch_ThrowsAndRewindsStream() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered")); + + await Assert.ThrowsAsync(() => + FfmpegDownloadService.VerifyChecksumAsync( + stream, + new string('0', 64), + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task DownloadAndVerifyAsync_TamperedPayload_RejectsDownloadedBytes() + { + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new FfmpegDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadAndVerifyAsync( + stream, + "https://example.test/ffmpeg.zip", + new string('0', 64), + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Head) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + } + } +}