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
91 changes: 84 additions & 7 deletions src/ui/Logic/Download/TtsDownloadService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Nikse.SubtitleEdit.UiLogic;

namespace Nikse.SubtitleEdit.Logic.Download;

Expand Down Expand Up @@ -91,23 +92,99 @@ public TtsDownloadService(HttpClient httpClient)

public async Task DownloadPiper(string destinationFileName, IProgress<float>? progress, CancellationToken cancellationToken)
{
var url = OperatingSystem.IsWindows() ? WindowsPiperUrl : MacPiperUrl;
await DownloadHelper.DownloadFileAsync(_httpClient, url, destinationFileName, progress, cancellationToken);
await DownloadHelper.DownloadFileAsync(_httpClient, GetPiperUrl(), destinationFileName, progress, cancellationToken);
await VerifyPiperFileAsync(destinationFileName, cancellationToken);
}

public async Task DownloadPiper(Stream stream, IProgress<float>? progress, CancellationToken cancellationToken)
{
var url = WindowsPiperUrl;
await DownloadHelper.DownloadFileAsync(_httpClient, GetPiperUrl(), stream, progress, cancellationToken);
await VerifyPiperArchiveAsync(stream, cancellationToken);
}

private static string GetPiperUrl()
{
if (OperatingSystem.IsWindows())
{
return WindowsPiperUrl;
}

if (OperatingSystem.IsLinux())
{
url = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? LinuxPiperArmUrl : LinuxPiperUrl;
return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? LinuxPiperArmUrl : LinuxPiperUrl;
}
else if (OperatingSystem.IsMacOS())

if (OperatingSystem.IsMacOS())
{
url = MacPiperUrl;
return MacPiperUrl;
}

await DownloadHelper.DownloadFileAsync(_httpClient, url, stream, progress, cancellationToken);
throw new PlatformNotSupportedException();
}

internal static async Task VerifyPiperArchiveAsync(Stream stream, CancellationToken cancellationToken)
{
var key = DownloadHashManager.ResolvePiperKey();
if (string.IsNullOrEmpty(key))
{
throw new InvalidOperationException("No SHA-256 key is registered for the Piper runtime on this platform.");
}

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

if (!stream.CanRead || !stream.CanSeek)
{
throw new InvalidOperationException("Piper 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(
$"Piper runtime download failed integrity check (expected SHA-256 {expected}, got {actual}).");
}
}

internal static async Task VerifyPiperFileAsync(string fileName, CancellationToken cancellationToken)
{
try
{
await using var stream = new FileStream(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
bufferSize: 4096,
useAsync: true);
await VerifyPiperArchiveAsync(stream, cancellationToken);
}
catch
{
try
{
File.Delete(fileName);
}
catch
{
// Preserve the integrity error; cleanup is best-effort.
}

throw;
}
}

public async Task DownloadPiperModel(string destinationFileName, PiperVoice voice, IProgress<float>? progress, CancellationToken cancellationToken)
Expand Down
69 changes: 69 additions & 0 deletions tests/UI/Logic/Download/TtsDownloadServicePiperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Net;
using System.Text;
using Nikse.SubtitleEdit.Logic.Download;

namespace UITests.Logic.Download;

public class TtsDownloadServicePiperTests
{
[Fact]
public async Task DownloadPiper_TamperedPayload_RejectsDownloadedBytes()
{
using var httpClient = new HttpClient(new StaticResponseHandler(Encoding.ASCII.GetBytes("tampered")));
var service = new TtsDownloadService(httpClient);
await using var stream = new MemoryStream();

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

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

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

await Assert.ThrowsAsync<IOException>(() =>
TtsDownloadService.VerifyPiperArchiveAsync(
stream,
TestContext.Current.CancellationToken));

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

[Fact]
public async Task VerifyPiperFileAsync_TamperedPayload_DeletesFile()
{
var fileName = Path.Combine(Path.GetTempPath(), $"subtitleedit-piper-{Guid.NewGuid():N}.tmp");
await File.WriteAllTextAsync(fileName, "tampered", TestContext.Current.CancellationToken);

try
{
await Assert.ThrowsAsync<IOException>(() =>
TtsDownloadService.VerifyPiperFileAsync(
fileName,
TestContext.Current.CancellationToken));

Assert.False(File.Exists(fileName));
}
finally
{
File.Delete(fileName);
}
}

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