Skip to content
Draft
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
89 changes: 86 additions & 3 deletions src/ui/Logic/Download/LibMpvDownloadService .cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<string, string> KnownSha256 =
new Dictionary<string, string>(StringComparer.Ordinal)
{
["libmpv2-win64.zip"] = "ce99ee7a9cab0ada2f696b04132def67b0978157d5f4a1a7966d04c92aebbfec",
["libmpv2-win-arm64.zip"] = "d8be93f69eb102026ba81d5d237887b858701510c9e5e26996a2d30f9829df00",
};

public LibMpvDownloadService(HttpClient httpClient)
{
_httpClient = httpClient;
Expand Down Expand Up @@ -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<float>? progress,
CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken);
await VerifyChecksumAsync(stream, expectedSha256, cancellationToken);
}

public async Task DownloadLibMpv(string destinationFileName, IProgress<float>? 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<float>? 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);
}
}
}
85 changes: 85 additions & 0 deletions tests/UI/Logic/Download/LibMpvDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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<InvalidOperationException>(() =>
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<IOException>(() =>
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<HttpResponseMessage> 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),
});
}
}
}
Loading