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
69 changes: 60 additions & 9 deletions src/ui/Logic/Download/CrispEmbedDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Nikse.SubtitleEdit.UiLogic;

namespace Nikse.SubtitleEdit.Logic.Download;

Expand Down Expand Up @@ -41,49 +42,99 @@ public CrispEmbedDownloadService(HttpClient httpClient)

public async Task DownloadEngine(Stream stream, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, GetUrl(), stream, progress, cancellationToken);
var download = GetDefaultDownload();
await DownloadAndVerifyAsync(download.Url, download.HashKey, stream, progress, cancellationToken);
}

public async Task DownloadEngineWindowsCuda(Stream stream, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCudaUrl, stream, progress, cancellationToken);
await DownloadAndVerifyAsync(WindowsCudaUrl, DownloadHashManager.CrispEmbed.WindowsCuda, stream, progress, cancellationToken);
}

public async Task DownloadEngineWindowsVulkan(Stream stream, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, WindowsVulkanUrl, stream, progress, cancellationToken);
await DownloadAndVerifyAsync(WindowsVulkanUrl, DownloadHashManager.CrispEmbed.WindowsVulkan, stream, progress, cancellationToken);
}

public async Task DownloadEngineWindowsCpu(Stream stream, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, WindowsCpuUrl, stream, progress, cancellationToken);
await DownloadAndVerifyAsync(WindowsCpuUrl, DownloadHashManager.CrispEmbed.WindowsCpu, stream, progress, cancellationToken);
}

public async Task DownloadEngineLinuxCuda(Stream stream, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, LinuxCudaUrl, stream, progress, cancellationToken);
await DownloadAndVerifyAsync(LinuxCudaUrl, DownloadHashManager.CrispEmbed.LinuxCuda, stream, progress, cancellationToken);
}

public async Task DownloadModel(string url, string destinationFileName, IProgress<float>? progress, CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, url, destinationFileName, progress, cancellationToken);
}

private static string GetUrl()
private async Task DownloadAndVerifyAsync(
string url,
string hashKey,
Stream stream,
IProgress<float>? progress,
CancellationToken cancellationToken)
{
await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken);
await VerifyArchiveAsync(stream, hashKey, cancellationToken);
}

internal static async Task VerifyArchiveAsync(Stream stream, string? hashKey, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(hashKey))
{
throw new InvalidOperationException("No SHA-256 key is registered for the CrispEmbed runtime.");
}

var expected = DownloadHashManager.GetLatestKnownHash(hashKey);
if (string.IsNullOrEmpty(expected))
{
throw new InvalidOperationException($"No SHA-256 is registered for CrispEmbed runtime key '{hashKey}'.");
}

if (!stream.CanRead || !stream.CanSeek)
{
throw new InvalidOperationException("CrispEmbed runtime integrity verification requires a readable, seekable stream.");
}

string actual;
stream.Position = 0;
try
{
actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken);
}
finally
{
stream.Position = 0;
}

if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase))
{
throw new IOException(
$"CrispEmbed runtime download failed integrity check (expected SHA-256 {expected}, got {actual}).");
}
}

private static (string Url, string HashKey) GetDefaultDownload()
{
if (OperatingSystem.IsWindows())
{
return WindowsVulkanUrl;
return (WindowsVulkanUrl, DownloadHashManager.CrispEmbed.WindowsVulkan);
}

if (OperatingSystem.IsLinux())
{
return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? LinuxArmUrl : LinuxUrl;
return RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? (LinuxArmUrl, DownloadHashManager.CrispEmbed.LinuxArm)
: (LinuxUrl, DownloadHashManager.CrispEmbed.Linux);
}

if (OperatingSystem.IsMacOS())
{
return MacUrl;
return (MacUrl, DownloadHashManager.CrispEmbed.MacOs);
}

throw new PlatformNotSupportedException();
Expand Down
79 changes: 79 additions & 0 deletions tests/UI/Logic/Download/CrispEmbedDownloadServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Net;
using System.Text;
using Nikse.SubtitleEdit.Logic.Download;

namespace UITests.Logic.Download;

public class CrispEmbedDownloadServiceTests
{
[Theory]
[InlineData(DownloadHashManager.CrispEmbed.WindowsCuda, "89bcdde6d81634461278bd877e6e35f477cd6771dec7de40f0cfeeca1c5b732a")]
[InlineData(DownloadHashManager.CrispEmbed.WindowsVulkan, "ce2d3eb91b6dda3b9a50b96cf26e70010854c0b28fa38413c87cefe732dd9fc2")]
[InlineData(DownloadHashManager.CrispEmbed.WindowsCpu, "dabf9483f1a2e6e801b85a3fe67a6dab2b55359fc604c91e80a44756a21e4b07")]
[InlineData(DownloadHashManager.CrispEmbed.MacOs, "b85b636dfc5dfb2e9d7b7d6403931864bc42a756629a3962396cf750d6f604ca")]
[InlineData(DownloadHashManager.CrispEmbed.Linux, "775b138650a60064b66f976da3e22c8f5b36fa605f7ebd767967fb0c3b412984")]
[InlineData(DownloadHashManager.CrispEmbed.LinuxCuda, "3e1447bc93f8f7039c94b91d4bb97c739cc3b754d9b1ee3006616dbd445a1b6f")]
[InlineData(DownloadHashManager.CrispEmbed.LinuxArm, "247915ad0c870814498a81731ddc037a79d00be5f05aee7c7c85a5dd5fae9c40")]
public void RegistryHash_MatchesPublishedReleaseDigest(string key, string expected)
{
Assert.Equal(expected, DownloadHashManager.GetLatestKnownHash(key));
}

[Fact]
public async Task DownloadEngine_TamperedPayload_RejectsDownloadedBytes()
{
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
{
Assert.Skip("CrispEmbed runtime is not supported on this operating system.");
}

using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered")));
var service = new CrispEmbedDownloadService(httpClient);
await using var stream = new MemoryStream();

await Assert.ThrowsAsync<IOException>(() =>
service.DownloadEngine(
stream,
progress: null,
TestContext.Current.CancellationToken));

Assert.Equal(0, stream.Position);
}

[Fact]
public async Task VerifyArchiveAsync_UnknownKey_FailsClosed()
{
await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc"));

await Assert.ThrowsAsync<InvalidOperationException>(() =>
CrispEmbedDownloadService.VerifyArchiveAsync(
stream,
"CrispEmbed.Unknown",
TestContext.Current.CancellationToken));
}

[Fact]
public async Task VerifyArchiveAsync_TamperedPayload_RewindsStream()
{
await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("tampered"));

await Assert.ThrowsAsync<IOException>(() =>
CrispEmbedDownloadService.VerifyArchiveAsync(
stream,
DownloadHashManager.CrispEmbed.Linux,
TestContext.Current.CancellationToken));

Assert.Equal(0, stream.Position);
}

private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(payload),
});
}
}
}
Loading