Skip to content
Closed
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
61 changes: 56 additions & 5 deletions src/ui/Logic/Download/FfmpegLibsDownloadService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Nikse.SubtitleEdit.Logic.VideoPlayers.Ffmpeg;
using Nikse.SubtitleEdit.UiLogic;
using System;
using System.IO;
using System.Net.Http;
Expand All @@ -21,21 +22,71 @@ public interface IFfmpegLibsDownloadService
/// </summary>
public class FfmpegLibsDownloadService(HttpClient httpClient) : IFfmpegLibsDownloadService
{
private static readonly string WindowsX64Url =
$"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n{FfmpegLibraries.MajorVersion}-latest-win64-lgpl-shared-{FfmpegLibraries.MajorVersion}.zip";
// Pin one dated autobuild rather than BtbN's mutable "latest" alias. A future FFmpeg update
// must deliberately update both this URL and its GitHub-published SHA-256 together.
internal const string WindowsX64Url =
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-11-13-20/ffmpeg-n9.0.1-29-gad500d59cb-win64-lgpl-shared-9.0.zip";
internal const string WindowsX64Sha256 =
"40eec25b2f55dcad7e4d4e640919b920d29818b56fdaf9353ce1fd8adefc9d6b";

public async Task DownloadFfmpegLibs(string destinationFileName, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(httpClient, GetUrl(), destinationFileName, progress, cancellationToken);
var download = GetDownload();
await DownloadAndVerifyAsync(httpClient, download.Url, download.Sha256, destinationFileName, progress, cancellationToken);
}

private static string GetUrl()
internal static async Task DownloadAndVerifyAsync(
HttpClient client,
string url,
string expectedSha256,
string destinationFileName,
IProgress<float>? progress,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(expectedSha256))
{
throw new InvalidOperationException("No SHA-256 is registered for the FFmpeg shared-library archive.");
}

try
{
await DownloadHelper.DownloadFileAsync(client, url, destinationFileName, progress, cancellationToken);
var actual = await Sha256Util.ComputeSha256Async(destinationFileName, cancellationToken);
if (string.IsNullOrEmpty(actual) || !string.Equals(expectedSha256, actual, StringComparison.OrdinalIgnoreCase))
{
throw new IOException(
$"FFmpeg shared-library download failed integrity check (expected SHA-256 {expectedSha256}, got {actual ?? "<missing>"}).");
}
}
catch
{
TryDelete(destinationFileName);
throw;
}
}

private static (string Url, string Sha256) GetDownload()
{
if (OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.X64)
{
return WindowsX64Url;
return (WindowsX64Url, WindowsX64Sha256);
}

throw new PlatformNotSupportedException("FFmpeg shared library download is only available for Windows x64; install FFmpeg from your package manager instead.");
}

private static void TryDelete(string fileName)
{
try
{
if (File.Exists(fileName))
{
File.Delete(fileName);
}
}
catch
{
// Best effort: preserve the original download/integrity failure.
}
}
}
115 changes: 115 additions & 0 deletions tests/UI/Logic/Download/FfmpegLibsDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Net;
using System.Text;
using Nikse.SubtitleEdit.Logic.Download;

namespace UITests.Logic.Download;

public class FfmpegLibsDownloadServiceTests
{
[Fact]
public void PinnedAsset_MatchesPublishedReleaseDigest()
{
Assert.Equal(
"https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-09-11-13-20/ffmpeg-n9.0.1-29-gad500d59cb-win64-lgpl-shared-9.0.zip",
FfmpegLibsDownloadService.WindowsX64Url);
Assert.Equal(
"40eec25b2f55dcad7e4d4e640919b920d29818b56fdaf9353ce1fd8adefc9d6b",
FfmpegLibsDownloadService.WindowsX64Sha256);
Assert.False(FfmpegLibsDownloadService.WindowsX64Url.Contains("/latest/", StringComparison.Ordinal));
}

[Fact]
public async Task DownloadAndVerifyAsync_TamperedPayload_RejectsAndDeletesFile()
{
var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"));
using var httpClient = new HttpClient(handler);
var destination = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".zip");

try
{
await Assert.ThrowsAsync<IOException>(() =>
FfmpegLibsDownloadService.DownloadAndVerifyAsync(
httpClient,
FfmpegLibsDownloadService.WindowsX64Url,
FfmpegLibsDownloadService.WindowsX64Sha256,
destination,
progress: null,
TestContext.Current.CancellationToken));

Assert.False(File.Exists(destination));
Assert.Equal(new[] { HttpMethod.Head, HttpMethod.Get }, handler.RequestMethods);
}
finally
{
if (File.Exists(destination))
{
File.Delete(destination);
}
}
}

[Fact]
public async Task DownloadAndVerifyAsync_ValidPayload_PreservesFile()
{
var payload = Encoding.ASCII.GetBytes("abc");
var handler = new StaticResponseHandler(payload);
using var httpClient = new HttpClient(handler);
var destination = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".zip");

try
{
await FfmpegLibsDownloadService.DownloadAndVerifyAsync(
httpClient,
"https://example.invalid/ffmpeg-test.zip",
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
destination,
progress: null,
TestContext.Current.CancellationToken);

Assert.True(File.Exists(destination));
Assert.Equal(payload, await File.ReadAllBytesAsync(destination, TestContext.Current.CancellationToken));
Assert.Equal(new[] { HttpMethod.Head, HttpMethod.Get }, handler.RequestMethods);
}
finally
{
if (File.Exists(destination))
{
File.Delete(destination);
}
}
}

[Fact]
public async Task DownloadAndVerifyAsync_MissingDigest_FailsClosedBeforeRequest()
{
var handler = new StaticResponseHandler(Encoding.ASCII.GetBytes("unused"));
using var httpClient = new HttpClient(handler);
var destination = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".zip");

await Assert.ThrowsAsync<InvalidOperationException>(() =>
FfmpegLibsDownloadService.DownloadAndVerifyAsync(
httpClient,
"https://example.invalid/ffmpeg-future.zip",
string.Empty,
destination,
progress: null,
TestContext.Current.CancellationToken));

Assert.Empty(handler.RequestMethods);
Assert.False(File.Exists(destination));
}

private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler
{
public List<HttpMethod> RequestMethods { get; } = new();

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestMethods.Add(request.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload),
});
}
}
}
Loading