From fbe2f98a0a1ca938514dcdb6e56384ae1a732289 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Sat, 12 Sep 2026 14:51:59 +0200 Subject: [PATCH 1/2] Fail closed on unknown llama.cpp runtime hashes --- .../Logic/Download/LlamaCppDownloadService.cs | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/ui/Logic/Download/LlamaCppDownloadService.cs b/src/ui/Logic/Download/LlamaCppDownloadService.cs index 24372c23d7e..872730e5c6c 100644 --- a/src/ui/Logic/Download/LlamaCppDownloadService.cs +++ b/src/ui/Logic/Download/LlamaCppDownloadService.cs @@ -40,25 +40,37 @@ public async Task DownloadEngine(Stream stream, string variant, IProgress await VerifyArchive(stream, DownloadHashManager.ResolveLlamaCppKey(variant), "engine", cancellationToken); } - // Compares the downloaded bytes against the known SHA-256 for this key and throws on mismatch - // so the caller's IsFaulted branch surfaces "Download failed" instead of silently unpacking a - // truncated or tampered file. Mirrors Qwen3TtsCppDownloadService.VerifyArchive. - private static async Task VerifyArchive(Stream stream, string? key, string label, CancellationToken cancellationToken) + // Compares the downloaded bytes against the current registered SHA-256 and fails closed + // if the key/digest cannot be resolved. The caller must never unpack bytes whose expected + // identity is unknown. + internal static async Task VerifyArchive(Stream stream, string? key, string label, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(key) || stream.Length == 0) + if (string.IsNullOrEmpty(key)) { - return; + throw new InvalidOperationException($"No SHA-256 key is registered for llama.cpp {label}."); } var expected = DownloadHashManager.GetLatestKnownHash(key); if (string.IsNullOrEmpty(expected)) { - return; + throw new InvalidOperationException($"No SHA-256 is registered for llama.cpp {label} key '{key}'."); } + if (!stream.CanRead || !stream.CanSeek) + { + throw new InvalidOperationException($"llama.cpp {label} integrity verification requires a readable, seekable stream."); + } + + string actual; stream.Position = 0; - var actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); - stream.Position = 0; + try + { + actual = await Sha256Util.ComputeSha256Async(stream, cancellationToken); + } + finally + { + stream.Position = 0; + } if (!string.Equals(expected, actual, StringComparison.OrdinalIgnoreCase)) { @@ -133,4 +145,4 @@ private static string GetEngineUrl(string variant) throw new PlatformNotSupportedException("llama.cpp download is not supported on this platform."); } -} +} \ No newline at end of file From db9a251f410f5b607ef2244a85ac914ec7e26120 Mon Sep 17 00:00:00 2001 From: BlackSpirits Date: Sat, 12 Sep 2026 14:52:20 +0200 Subject: [PATCH 2/2] Add llama.cpp runtime integrity regressions --- .../Download/LlamaCppDownloadServiceTests.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/UI/Logic/Download/LlamaCppDownloadServiceTests.cs diff --git a/tests/UI/Logic/Download/LlamaCppDownloadServiceTests.cs b/tests/UI/Logic/Download/LlamaCppDownloadServiceTests.cs new file mode 100644 index 00000000000..90c484d3c72 --- /dev/null +++ b/tests/UI/Logic/Download/LlamaCppDownloadServiceTests.cs @@ -0,0 +1,98 @@ +using System.Net; +using System.Text; +using Nikse.SubtitleEdit.Logic.Download; + +namespace UITests.Logic.Download; + +public class LlamaCppDownloadServiceTests +{ + [Theory] + [InlineData(DownloadHashManager.LlamaCpp.WindowsCpu, "7063dfc6b874e7eee0ddf601bdf8e70e6f4a3d708926641ffad046ec51e8e30b")] + [InlineData(DownloadHashManager.LlamaCpp.WindowsVulkan, "a435eeaa106e4861559457fe5532aa1422f0877e7cd2bd6934c085d1d1388731")] + [InlineData(DownloadHashManager.LlamaCpp.WindowsCuda, "8cf247aeebf5f1c9d06e9476279a9c93cfaee71d112eb7bebd68966af8a7c9bc")] + [InlineData(DownloadHashManager.LlamaCpp.WindowsCuda13, "3e3e8c463d1d92beddca6f69d60956cdd5fad7b4d7a589ea0aecf00b51f54fbe")] + [InlineData(DownloadHashManager.LlamaCpp.LinuxCpu, "f19a877b0d2b16cfcf19612319d06194687e9310b19b23e35abb046f55903f67")] + [InlineData(DownloadHashManager.LlamaCpp.LinuxVulkan, "1ed6791cfe5921f8050b7af763d82aa0cb637bf97580aa4413b1b64d08eae06d")] + [InlineData(DownloadHashManager.LlamaCpp.LinuxArm64Cpu, "301b201d85cf7e76bbf7fef5e0569bb9166325c78635ad8893c57585216c8b3b")] + [InlineData(DownloadHashManager.LlamaCpp.LinuxArm64Vulkan, "d198a87a93142299359e4117b7447b49372cd62073e8b165cd79f6cee3f2e10f")] + [InlineData(DownloadHashManager.LlamaCpp.MacOsArm64, "848b6cc2817aa09e615fed0813b01fc3abbc43cd4d4773cc4aff4d7ef5733784")] + [InlineData(DownloadHashManager.LlamaCpp.MacOsX64, "980f239850ddb6d27e35bc973fcbfa1912c744a6770dbb376669ec91168e26ce")] + [InlineData(DownloadHashManager.LlamaCpp.WindowsCudaRuntime, "8c79a9b226de4b3cacfd1f83d24f962d0773be79f1e7b75c6af4ded7e32ae1d6")] + [InlineData(DownloadHashManager.LlamaCpp.WindowsCuda13Runtime, "1462a050eb4c684921ba51dcc4cc488a036674c3e73e9945ee705b854808d03e")] + public void RegistryHash_MatchesPublishedReleaseDigest(string key, string expected) + { + Assert.Equal(expected, DownloadHashManager.GetLatestKnownHash(key)); + } + + [Fact] + public async Task DownloadEngine_TamperedPayload_IsRejectedAndRewound() + { + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + Assert.Skip("llama.cpp runtime is not supported on this operating system."); + } + + using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered"))); + var service = new LlamaCppDownloadService(httpClient); + await using var stream = new MemoryStream(); + + await Assert.ThrowsAsync(() => + service.DownloadEngine( + stream, + LlamaCppDownloadService.VariantCpu, + progress: null, + TestContext.Current.CancellationToken)); + + Assert.Equal(0, stream.Position); + } + + [Fact] + public async Task VerifyArchive_UnknownKey_FailsClosed() + { + await using var stream = new MemoryStream(Encoding.ASCII.GetBytes("abc")); + + await Assert.ThrowsAsync(() => + LlamaCppDownloadService.VerifyArchive( + stream, + "LlamaCpp.Unknown", + "engine", + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VerifyArchive_NonSeekableStream_FailsClosed() + { + await using var stream = new NonSeekableReadStream(Encoding.ASCII.GetBytes("data")); + + await Assert.ThrowsAsync(() => + LlamaCppDownloadService.VerifyArchive( + stream, + DownloadHashManager.LlamaCpp.LinuxCpu, + "engine", + TestContext.Current.CancellationToken)); + } + + private sealed class StaticResponseHandler(byte[] payload) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(payload), + }); + } + } + + private sealed class NonSeekableReadStream(byte[] data) : MemoryStream(data) + { + public override bool CanSeek => false; + + public override long Position + { + get => base.Position; + set => throw new NotSupportedException(); + } + + public override long Seek(long offset, SeekOrigin loc) => throw new NotSupportedException(); + } +}