diff --git a/src/ui/Logic/Download/LibMpvDownloadService .cs b/src/ui/Logic/Download/LibMpvDownloadService .cs index bc919ed6ce2..0a188ddf5d5 100644 --- a/src/ui/Logic/Download/LibMpvDownloadService .cs +++ b/src/ui/Logic/Download/LibMpvDownloadService .cs @@ -1,9 +1,11 @@ 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; @@ -24,6 +26,15 @@ public class LibMpvDownloadService : ILibMpvDownloadService private const string MacUrl = ""; private const string MacUrlArm = ""; + // GitHub-published release-asset digests for the exact archives above. Keep this map in sync + // with the pinned URLs so a future asset bump cannot silently disable integrity verification. + internal static readonly IReadOnlyDictionary KnownSha256 = + new Dictionary(StringComparer.Ordinal) + { + ["libmpv2-win64.zip"] = "ce99ee7a9cab0ada2f696b04132def67b0978157d5f4a1a7966d04c92aebbfec", + ["libmpv2-win-arm64.zip"] = "d8be93f69eb102026ba81d5d237887b858701510c9e5e26996a2d30f9829df00", + }; + public LibMpvDownloadService(HttpClient httpClient) { _httpClient = httpClient; @@ -59,13 +70,85 @@ private static string GetUrl() //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 libmpv asset '{assetName}'."); + } + + return expectedSha256; + } + + internal static async Task VerifyChecksumAsync(Stream stream, string expectedSha256, CancellationToken cancellationToken) + { + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException("libmpv 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( + $"libmpv 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 DownloadLibMpv(string destinationFileName, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), destinationFileName, progress, cancellationToken); + var url = GetUrl(); + 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 DownloadLibMpv(Stream stream, IProgress? progress, CancellationToken cancellationToken) { - await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), stream, progress, cancellationToken); + var url = GetUrl(); + var expectedSha256 = GetExpectedSha256(url); + + await DownloadAndVerifyAsync(stream, url, expectedSha256, progress, cancellationToken); } -} \ No newline at end of file +} diff --git a/tests/UI/Logic/Download/LibMpvDownloadServiceTests.cs b/tests/UI/Logic/Download/LibMpvDownloadServiceTests.cs new file mode 100644 index 00000000000..161d3d177fa --- /dev/null +++ b/tests/UI/Logic/Download/LibMpvDownloadServiceTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class LibMpvDownloadServiceTests +{ + [Theory] + [InlineData( + "https://github.com/SubtitleEdit/support-files/releases/download/libmpv-2026-08-14b/libmpv2-win64.zip", + "ce99ee7a9cab0ada2f696b04132def67b0978157d5f4a1a7966d04c92aebbfec")] + [InlineData( + "https://github.com/SubtitleEdit/support-files/releases/download/libmpv-2026-08-14b/libmpv2-win-arm64.zip", + "d8be93f69eb102026ba81d5d237887b858701510c9e5e26996a2d30f9829df00")] + public void GetExpectedSha256_KnownAsset_ReturnsPinnedDigest(string url, string expected) + { + Assert.Equal(expected, LibMpvDownloadService.GetExpectedSha256(url)); + } + + [Fact] + public void KnownSha256_ContainsOnlyValidHexDigests() + { + Assert.Equal(2, LibMpvDownloadService.KnownSha256.Count); + foreach (var hash in LibMpvDownloadService.KnownSha256.Values) + { + Assert.Matches("^[0-9a-f]{64}$", hash); + } + } + + [Fact] + public void GetExpectedSha256_UnknownAsset_FailsClosed() + { + Assert.Throws(() => + LibMpvDownloadService.GetExpectedSha256( + "https://github.com/SubtitleEdit/support-files/releases/download/libmpv-future/libmpv2-win64-future.zip")); + } + + [Fact] + public async Task VerifyChecksumAsync_KnownDigest_SucceedsAndRewindsStream() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await LibMpvDownloadService.VerifyChecksumAsync( + stream, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + 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 LibMpvDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadAndVerifyAsync( + stream, + "https://example.test/libmpv.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), + }); + } + } +}