From c6b809520438710d0ffdd9c2c9624026710c97bb Mon Sep 17 00:00:00 2001 From: Joshua Peddle Date: Mon, 24 Aug 2026 11:35:28 -0230 Subject: [PATCH] Preserve file extensions so msix can be signed, and fail when signing does not happen https://github.com/TownSuite/TownSuite.IssueTracker/issues/15101 Uploads were stored under a generated id with no extension, so signtool did not recognize an msix and failed the whole batch, leaving the msi and exe unsigned as well. The failure was then invisible: batch errors were written under the batch id but polled for under the file id, and the client treated its own timeout as success, so builds published unsigned installers. - Client sends the original extension as X-FileExtension on upload and poll, and the service keeps it on the working file so signtool recognizes msix and appx. An absent header keeps the previous behaviour. - Validate that extension against a short alphanumeric pattern before it reaches a signtool command line. - GET /sign/batch checks the batch level error file as well as the per-file one, so a batch failure returns 500 instead of 425 until the client gives up. - DownloadSignedFiles reports files still outstanding when the batch timeout expires, so a timeout exits non-zero. - Add coverage, including an end to end msix sign through the batch endpoint using real makeappx and signtool. --- TownSuite.CodeSigning.Client/SigningClient.cs | 22 +- .../SigningClientTimeoutTest.cs | 114 ++++++++ .../BatchedSigning.cs | 51 +++- TownSuite.CodeSigning.Service/ISigner.cs | 2 +- TownSuite.CodeSigning.Service/Signer.cs | 4 +- .../SignerDetached.cs | 6 +- .../BatachedDetachedSignatureTest.cs | 4 +- .../BatchedSigningExtensionTest.cs | 138 +++++++++ .../MsixSigningTest.cs | 264 ++++++++++++++++++ 9 files changed, 591 insertions(+), 14 deletions(-) create mode 100644 TownSuite.CodeSigning.ClientTests/SigningClientTimeoutTest.cs create mode 100644 TownSuite.CodeSigning.Tests/BatchedSigningExtensionTest.cs create mode 100644 TownSuite.CodeSigning.Tests/MsixSigningTest.cs diff --git a/TownSuite.CodeSigning.Client/SigningClient.cs b/TownSuite.CodeSigning.Client/SigningClient.cs index b33065b..859d41c 100644 --- a/TownSuite.CodeSigning.Client/SigningClient.cs +++ b/TownSuite.CodeSigning.Client/SigningClient.cs @@ -96,11 +96,21 @@ public SigningClient(HttpClient client, string baseUrl) return failedUploads.ToArray(); } + private static void AddFileExtensionHeader(HttpRequestMessage request, string filepath) + { + string extension = Path.GetExtension(filepath); + if (!string.IsNullOrEmpty(extension)) + { + request.Headers.Add("X-FileExtension", extension); + } + } + private async Task SendRequest(bool quickFail, bool ignoreFailures, List<(string FailedFile, string Message)> failedUploads, string filepath, HttpRequestMessage request) { try { await semaphore.WaitAsync(); + AddFileExtensionHeader(request, filepath); using var fs = File.OpenRead(filepath); request.Content = new StreamContent(fs); Console.WriteLine($"Uploading file: {filepath}"); @@ -151,12 +161,21 @@ private async Task SendRequest(bool quickFail, bool ignoreFailures, List<(string } foreach (var file in results.Failures) { - TrackedFiles.Remove(TrackedFiles.First(x => x.FilePath == file.FailedFile)); + var tracked = TrackedFiles.FirstOrDefault(x => x.FilePath == file.FailedFile); + if (tracked.FilePath is not null) TrackedFiles.Remove(tracked); } await Task.Delay(1000); count++; } + foreach (var file in TrackedFiles.ToArray()) + { + string message = $"Timed out after {batchTimeoutInSeconds} seconds waiting for the signing service to return a signed file."; + failedUploads.Add((file.FilePath, message)); + Console.WriteLine($"Timed out waiting for: {file.FilePath}"); + } + TrackedFiles.Clear(); + return failedUploads.ToArray(); } @@ -178,6 +197,7 @@ private async Task SendRequest(bool quickFail, bool ignoreFailures, List<(string var request = new HttpRequestMessage(HttpMethod.Get, pollUrl); request.Headers.Add("X-BatchId", batchId); + AddFileExtensionHeader(request, file.FilePath); if (batchDetached) request.Headers.Add("X-Detached", "true"); var response = await _client.SendAsync(request); diff --git a/TownSuite.CodeSigning.ClientTests/SigningClientTimeoutTest.cs b/TownSuite.CodeSigning.ClientTests/SigningClientTimeoutTest.cs new file mode 100644 index 0000000..5f4c291 --- /dev/null +++ b/TownSuite.CodeSigning.ClientTests/SigningClientTimeoutTest.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using System.Net; +using TownSuite.CodeSigning.Client; + +namespace TownSuite.CodeSigning.ClientTests +{ + [TestFixture] + public class SigningClientTimeoutTest + { + private string _tempDir = string.Empty; + + [SetUp] + public void SetUp() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + try + { + if (Directory.Exists(_tempDir)) Directory.Delete(_tempDir, true); + } + catch { } + } + + private sealed class NeverReadyHandler : HttpMessageHandler + { + public int PollCount; + public readonly List UploadExtensionHeaders = new(); + public readonly List PollExtensionHeaders = new(); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + string path = request.RequestUri!.AbsolutePath; + + if (request.Method == HttpMethod.Get && path == "/healthz") + { + return Respond(HttpStatusCode.OK, "Healthy"); + } + if (request.Method == HttpMethod.Post && path == "/sign/batch") + { + UploadExtensionHeaders.Add(ReadExtension(request)); + return Respond(HttpStatusCode.OK, $"\"{Guid.NewGuid()}\""); + } + if (request.Method == HttpMethod.Get && path == "/sign/batch") + { + PollExtensionHeaders.Add(ReadExtension(request)); + Interlocked.Increment(ref PollCount); + return Respond((HttpStatusCode)425, "The file has not been signed yet"); + } + + return Respond(HttpStatusCode.NotFound, string.Empty); + } + + private static string? ReadExtension(HttpRequestMessage request) => + request.Headers.TryGetValues("X-FileExtension", out var values) ? values.FirstOrDefault() : null; + + private static Task Respond(HttpStatusCode code, string body) => + Task.FromResult(new HttpResponseMessage(code) { Content = new StringContent(body) }); + } + + private static (SigningClient Client, NeverReadyHandler Handler) Build() + { + var handler = new NeverReadyHandler(); + var httpClient = new HttpClient(handler); + return (new SigningClient(httpClient, "http://localhost:5000/sign"), handler); + } + + [Test] + public async Task ServiceNeverReturnsSignedFile_TimeoutIsReportedAsFailure() + { + string filePath = Path.Combine(_tempDir, "installer.msix"); + File.WriteAllBytes(filePath, new byte[] { 1, 2, 3 }); + + var (client, handler) = Build(); + + var uploadFailures = await client.UploadFiles(quickFail: false, ignoreFailures: false, new[] { filePath }, detached: false); + Assert.That(uploadFailures, Is.Empty, "upload should succeed against the fake service"); + + var failures = await client.DownloadSignedFiles(quickFail: false, ignoreFailures: false, batchTimeoutInSeconds: 2); + + Assert.Multiple(() => + { + Assert.That(handler.PollCount, Is.GreaterThan(0), "the client should have polled at least once"); + Assert.That(failures, Has.Length.EqualTo(1)); + Assert.That(failures[0].FailedFile, Is.EqualTo(filePath)); + Assert.That(failures[0].Message, Does.Contain("Timed out")); + }); + } + + [Test] + public async Task OriginalExtensionIsSentOnUploadAndPoll() + { + string filePath = Path.Combine(_tempDir, "installer.msix"); + File.WriteAllBytes(filePath, new byte[] { 1, 2, 3 }); + + var (client, handler) = Build(); + + await client.UploadFiles(quickFail: false, ignoreFailures: false, new[] { filePath }, detached: false); + await client.DownloadSignedFiles(quickFail: false, ignoreFailures: false, batchTimeoutInSeconds: 2); + + Assert.Multiple(() => + { + Assert.That(handler.UploadExtensionHeaders, Is.Not.Empty); + Assert.That(handler.UploadExtensionHeaders, Has.All.EqualTo(".msix")); + Assert.That(handler.PollExtensionHeaders, Is.Not.Empty); + Assert.That(handler.PollExtensionHeaders, Has.All.EqualTo(".msix")); + }); + } + } +} diff --git a/TownSuite.CodeSigning.Service/BatchedSigning.cs b/TownSuite.CodeSigning.Service/BatchedSigning.cs index d0f5189..a32bd95 100644 --- a/TownSuite.CodeSigning.Service/BatchedSigning.cs +++ b/TownSuite.CodeSigning.Service/BatchedSigning.cs @@ -2,11 +2,31 @@ using Microsoft.Extensions.Primitives; using System.Collections; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; namespace TownSuite.CodeSigning.Service { public static class BatchedSigning { + private static readonly Regex SafeExtension = new(@"^\.[A-Za-z0-9]{1,15}$", RegexOptions.Compiled); + + public static string GetSanitizedExtension(StringValues fileExtension) + { + string value = fileExtension.ToString(); + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + value = value.Trim(); + if (!value.StartsWith('.')) + { + value = "." + value; + } + + return SafeExtension.IsMatch(value) ? value.ToLowerInvariant() : string.Empty; + } + public static string GetTempFolder() { string? envTemp = Environment.GetEnvironmentVariable("TOWNSUITE_CODESIGNING_TEMP"); @@ -22,12 +42,14 @@ public static async Task Sign(Dictionary headers, { headers.TryGetValue("X-BatchId", out var batchId); headers.TryGetValue("X-BatchReady", out var batchReady); + headers.TryGetValue("X-FileExtension", out var fileExtension); bool isBatchJob = VerifyBatchId(batchId); string id = Guid.NewGuid().ToString(); + string extension = GetSanitizedExtension(fileExtension); var workingFolder = new DirectoryInfo(Path.Combine(GetTempFolder(), isBatchJob ? batchId : id)); - string workingFilePath = System.IO.Path.Combine(workingFolder.FullName, $"{id}.workingfile"); + string workingFilePath = System.IO.Path.Combine(workingFolder.FullName, $"{id}.workingfile{extension}"); try { workingFolder.CreateIfNotExists(); @@ -109,14 +131,20 @@ private static void ProcessFile(ISigner signer, ILogger logger, string id, Direc private static string[] GetBatchFiles(DirectoryInfo workingFolder) { - return workingFolder.GetFiles("*.workingfile").Where(p => p.Length>0).Select(p => p.Name).ToArray(); + return workingFolder.GetFiles("*.workingfile*") + .Where(p => p.Length > 0) + .Where(p => !p.Name.EndsWith(".sig", StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Name) + .ToArray(); } public static async Task Get(Dictionary headers, string id, ISigner signer) { headers.TryGetValue("X-BatchId", out var batchId); + headers.TryGetValue("X-FileExtension", out var fileExtension); bool isBatchJob = VerifyBatchId(batchId); + string extension = GetSanitizedExtension(fileExtension); var workingFolder = new DirectoryInfo(Path.Combine(GetTempFolder(), isBatchJob ? batchId : id)); if (!workingFolder.Exists) @@ -127,19 +155,32 @@ public static async Task Get(Dictionary headers, string signedFilesIndicator = isBatchJob ? $"{batchId}.signed" : $"{id}.signed"; if (System.IO.File.Exists(System.IO.Path.Combine(workingFolder.FullName, signedFilesIndicator))) { - var workingFile = System.IO.Path.Combine(workingFolder.FullName, signer.GetFileName(id)); + var workingFile = System.IO.Path.Combine(workingFolder.FullName, signer.GetFileName(id, extension)); var workingStream = new TempFileStream(workingFile, !isBatchJob ? workingFolder : null); return Results.Stream(workingStream); } - if (System.IO.File.Exists(System.IO.Path.Combine(workingFolder.FullName, $"{id}.error"))) + string? errorFile = FindErrorFile(workingFolder, isBatchJob ? batchId.ToString() : null, id); + if (errorFile is not null) { - return Results.Problem(title: "Failure to sign", detail: await File.ReadAllTextAsync(System.IO.Path.Combine(workingFolder.FullName, $"{id}.error")), statusCode: 500); + return Results.Problem(title: "Failure to sign", detail: await File.ReadAllTextAsync(errorFile), statusCode: 500); } return Results.Problem(title: "Not Signed", detail: "The file has not been signed yet", statusCode: 425); } + private static string? FindErrorFile(DirectoryInfo workingFolder, string? batchId, string id) + { + var candidates = new[] { $"{id}.error", batchId is null ? null : $"{batchId}.error" }; + foreach (var candidate in candidates) + { + if (candidate is null) continue; + string path = System.IO.Path.Combine(workingFolder.FullName, candidate); + if (System.IO.File.Exists(path)) return path; + } + return null; + } + static void CleanupDir(DirectoryInfo dir, ILogger logger) { try diff --git a/TownSuite.CodeSigning.Service/ISigner.cs b/TownSuite.CodeSigning.Service/ISigner.cs index f703602..e579bf1 100644 --- a/TownSuite.CodeSigning.Service/ISigner.cs +++ b/TownSuite.CodeSigning.Service/ISigner.cs @@ -3,6 +3,6 @@ public interface ISigner { Task<(bool IsSigned, string Message)> SignAsync(string workingDir, string[] files); - string GetFileName(string id); + string GetFileName(string id, string extension); } } \ No newline at end of file diff --git a/TownSuite.CodeSigning.Service/Signer.cs b/TownSuite.CodeSigning.Service/Signer.cs index b48fdc7..cb4e947 100644 --- a/TownSuite.CodeSigning.Service/Signer.cs +++ b/TownSuite.CodeSigning.Service/Signer.cs @@ -104,9 +104,9 @@ private string MessageSnapshot() return (p.ExitCode == 0, output); } - public string GetFileName(string id) + public string GetFileName(string id, string extension) { - return $"{id}.workingfile"; + return $"{id}.workingfile{extension}"; } private void process_ErrorDataReceived(object sender, DataReceivedEventArgs e) diff --git a/TownSuite.CodeSigning.Service/SignerDetached.cs b/TownSuite.CodeSigning.Service/SignerDetached.cs index 0b8b222..5cf4e19 100644 --- a/TownSuite.CodeSigning.Service/SignerDetached.cs +++ b/TownSuite.CodeSigning.Service/SignerDetached.cs @@ -201,14 +201,14 @@ private void CleanupOriginalFiles(string workingDir, string[] files) } } - public string GetFileName(string id) + public string GetFileName(string id, string extension) { if (string.IsNullOrWhiteSpace(_settings.OpenSSL.OsslSignCodePath) || string.IsNullOrWhiteSpace(_settings.OpenSSL.TimestampOptions)) { - return $"{id}.workingfile.sig"; + return $"{id}.workingfile{extension}.sig"; } - return $"{id}.workingfile.timestamped.sig"; + return $"{id}.workingfile{extension}.timestamped.sig"; } private void process_ErrorDataReceived(object sender, DataReceivedEventArgs e) diff --git a/TownSuite.CodeSigning.Tests/BatachedDetachedSignatureTest.cs b/TownSuite.CodeSigning.Tests/BatachedDetachedSignatureTest.cs index 108078d..e382e66 100644 --- a/TownSuite.CodeSigning.Tests/BatachedDetachedSignatureTest.cs +++ b/TownSuite.CodeSigning.Tests/BatachedDetachedSignatureTest.cs @@ -59,7 +59,7 @@ public async Task Test1(string[] srcFiles) { var signer = new SignerDetached(settings, NSubstitute.Substitute.For>()); string id = ids[i]; - string signaturePath = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id)); + string signaturePath = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id, string.Empty)); var dr = await BatchedSigning.Get(new Dictionary(), id, signer); if (dr is Microsoft.AspNetCore.Http.HttpResults.FileStreamHttpResult streamResult) @@ -96,7 +96,7 @@ public async Task Test1(string[] srcFiles) var id = ids[i]; var originalFile = Path.Combine(AppContext.BaseDirectory, srcFiles[i]); var signer = new SignerDetached(settings, NSubstitute.Substitute.For>()); - var signatureFile = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id)); + var signatureFile = Path.Combine(AppContext.BaseDirectory, signer.GetFileName(id, string.Empty)); var valid = Certs.ValidateDetachedSignature(originalFile, signatureFile, OneTimeUnitTestSetup.certPath, OneTimeUnitTestSetup.password); if (!valid) diff --git a/TownSuite.CodeSigning.Tests/BatchedSigningExtensionTest.cs b/TownSuite.CodeSigning.Tests/BatchedSigningExtensionTest.cs new file mode 100644 index 0000000..facd6b6 --- /dev/null +++ b/TownSuite.CodeSigning.Tests/BatchedSigningExtensionTest.cs @@ -0,0 +1,138 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using TownSuite.CodeSigning.Service; + +namespace TownSuite.CodeSigning.Tests +{ + [TestFixture] + public class BatchedSigningExtensionTest + { + private readonly List _foldersToClean = new(); + + [TearDown] + public void TearDown() + { + foreach (var folder in _foldersToClean) + { + try + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + catch { } + } + _foldersToClean.Clear(); + } + + private sealed class RecordingSigner : ISigner + { + public readonly List ReceivedFiles = new(); + public bool IsSigned = true; + public string Message = string.Empty; + + public Task<(bool IsSigned, string Message)> SignAsync(string workingDir, string[] files) + { + lock (ReceivedFiles) ReceivedFiles.AddRange(files); + return Task.FromResult((IsSigned, Message)); + } + + public string GetFileName(string id, string extension) => $"{id}.workingfile{extension}"; + } + + private static Dictionary Headers(string batchId, string? extension, bool ready) + { + var headers = new Dictionary { ["X-BatchId"] = batchId }; + if (ready) headers["X-BatchReady"] = "true"; + if (extension is not null) headers["X-FileExtension"] = extension; + return headers; + } + + private async Task<(string Id, string BatchId)> Upload(RecordingSigner signer, string? extension) + { + string batchId = Guid.NewGuid().ToString(); + _foldersToClean.Add(Path.Combine(BatchedSigning.GetTempFolder(), batchId)); + + using var body = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + var result = await BatchedSigning.Sign(Headers(batchId, extension, ready: true), body, + NSubstitute.Substitute.For(), signer); + + var ok = result as Microsoft.AspNetCore.Http.HttpResults.Ok; + Assert.That(ok, Is.Not.Null); + return (ok!.Value!.Replace("\"", ""), batchId); + } + + private static async Task WaitForSigner(RecordingSigner signer) + { + for (int i = 0; i < 100; i++) + { + lock (signer.ReceivedFiles) + { + if (signer.ReceivedFiles.Count > 0) return; + } + await Task.Delay(100); + } + Assert.Fail("the background queue never invoked the signer"); + } + + [Test] + public async Task UploadedExtensionReachesTheSigner() + { + var signer = new RecordingSigner(); + await Upload(signer, ".msix"); + await WaitForSigner(signer); + + Assert.That(signer.ReceivedFiles.Single(), Does.EndWith(".workingfile.msix")); + } + + [Test] + public async Task MissingExtensionKeepsTheLegacyWorkingFileName() + { + var signer = new RecordingSigner(); + await Upload(signer, null); + await WaitForSigner(signer); + + Assert.That(signer.ReceivedFiles.Single(), Does.EndWith(".workingfile")); + } + + [Test] + public async Task BatchSigningFailureIsReturnedToThePollerInsteadOf425() + { + var signer = new RecordingSigner { IsSigned = false, Message = "signtool refused the package" }; + var (id, batchId) = await Upload(signer, ".msix"); + await WaitForSigner(signer); + + Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult? problem = null; + for (int i = 0; i < 100; i++) + { + var result = await BatchedSigning.Get(Headers(batchId, ".msix", ready: false), id, signer); + problem = result as Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult; + if (problem?.StatusCode != 425) break; + await Task.Delay(100); + } + + Assert.Multiple(() => + { + Assert.That(problem, Is.Not.Null); + Assert.That(problem!.StatusCode, Is.EqualTo(500)); + Assert.That(problem.ProblemDetails.Detail, Does.Contain("signtool refused the package")); + }); + } + + [TestCase(".msix", ".msix")] + [TestCase(".MSIX", ".msix")] + [TestCase("msix", ".msix")] + [TestCase(".exe", ".exe")] + [TestCase("", "")] + [TestCase(" ", "")] + [TestCase(".", "")] + [TestCase("..", "")] + [TestCase(".msix.exe", "")] + [TestCase("../../etc/passwd", "")] + [TestCase(".msix /f evil.pfx", "")] + [TestCase(".msix\"", "")] + [TestCase(".waytoolongextensionvalue", "")] + public void OnlySafeExtensionsAreAccepted(string header, string expected) + { + Assert.That(BatchedSigning.GetSanitizedExtension(header), Is.EqualTo(expected)); + } + } +} diff --git a/TownSuite.CodeSigning.Tests/MsixSigningTest.cs b/TownSuite.CodeSigning.Tests/MsixSigningTest.cs new file mode 100644 index 0000000..70d464f --- /dev/null +++ b/TownSuite.CodeSigning.Tests/MsixSigningTest.cs @@ -0,0 +1,264 @@ +using System.IO.Compression; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using TownSuite.CodeSigning.Service; + +namespace TownSuite.CodeSigning.Tests +{ + [TestFixture] + public class MsixSigningTest + { + private const string PublisherMatchingTestCert = "CN=TestCertificate"; + + private string _layout = string.Empty; + private string _msix = string.Empty; + private readonly List _foldersToClean = new(); + + [SetUp] + public void SetUp() + { + if (!OperatingSystem.IsWindows()) + { + Assert.Ignore("msix packaging requires the Windows SDK"); + } + + string makeappx = FindMakeAppx() ?? string.Empty; + if (string.IsNullOrEmpty(makeappx)) + { + Assert.Ignore("makeappx.exe not found; install the Windows 10/11 SDK or set MAKEAPPX_PATH"); + } + + string mainExecutable = Path.Combine(Environment.SystemDirectory, "where.exe"); + if (!File.Exists(mainExecutable)) + { + Assert.Ignore("no sample executable available to pack"); + } + + _layout = Path.Combine(Path.GetTempPath(), "msixtest-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_layout); + _foldersToClean.Add(_layout); + + File.Copy(mainExecutable, Path.Combine(_layout, "App.exe"), true); + WriteManifest(_layout); + + _msix = Path.Combine(Path.GetTempPath(), $"msixtest-{Guid.NewGuid():N}.msix"); + RunMakeAppx(makeappx, _layout, _msix); + } + + [TearDown] + public void TearDown() + { + foreach (var folder in _foldersToClean) + { + try + { + if (Directory.Exists(folder)) Directory.Delete(folder, true); + } + catch { } + } + _foldersToClean.Clear(); + + try + { + if (!string.IsNullOrEmpty(_msix) && File.Exists(_msix)) File.Delete(_msix); + } + catch { } + } + + private static string? FindMakeAppx() => FindSdkTool("MAKEAPPX_PATH", "makeappx.exe"); + + private static string? FindSignTool() => FindSdkTool("SIGNTOOL_PATH", "signtool.exe"); + + private static string? FindSdkTool(string environmentVariable, string exeName) + { + string? configured = Environment.GetEnvironmentVariable(environmentVariable); + if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) return configured; + + string kits = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Windows Kits", "10", "bin"); + if (!Directory.Exists(kits)) return null; + + return Directory.EnumerateFiles(kits, exeName, SearchOption.AllDirectories) + .Where(p => p.Contains(Path.DirectorySeparatorChar + "x64" + Path.DirectorySeparatorChar)) + .OrderByDescending(p => p) + .FirstOrDefault(); + } + + private static Settings BuildSignToolSettings() + { + string? signtool = FindSignTool(); + if (string.IsNullOrEmpty(signtool)) + { + Assert.Ignore("signtool.exe not found; install the Windows 10/11 SDK or set SIGNTOOL_PATH"); + } + + var baseSettings = OneTimeUnitTestSetup.SignToolSettings!; + return new Settings + { + SignToolPath = signtool!, + SignToolOptions = baseSettings.SignToolOptions, + SigntoolTimeoutInMs = 60000, + MaxRequestBodySize = baseSettings.MaxRequestBodySize, + SemaphoreSlimProcessPerCpuLimit = baseSettings.SemaphoreSlimProcessPerCpuLimit, + OpenSSL = baseSettings.OpenSSL + }; + } + + private static void WriteManifest(string layout) + { + string manifest = $""" + + + + + Signing Test + TownSuite + Assets\StoreLogo.png + + + + + + + + + + + + + """; + + File.WriteAllText(Path.Combine(layout, "AppxManifest.xml"), manifest); + + string assets = Path.Combine(layout, "Assets"); + Directory.CreateDirectory(assets); + foreach (var name in new[] { "StoreLogo.png", "Square150x150Logo.png", "Square44x44Logo.png" }) + { + File.WriteAllBytes(Path.Combine(assets, name), MinimalPng); + } + } + + private static readonly byte[] MinimalPng = + { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82 + }; + + private static void RunMakeAppx(string makeappx, string layout, string outFile) + { + using var p = new System.Diagnostics.Process(); + p.StartInfo.FileName = makeappx; + p.StartInfo.Arguments = $"pack /d \"{layout}\" /p \"{outFile}\" /o"; + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.RedirectStandardError = true; + p.Start(); + string output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); + p.WaitForExit(); + + if (p.ExitCode != 0) + { + Assert.Ignore($"makeappx could not build a test package: {output}"); + } + } + + private static bool HasAppxSignature(string path) + { + using var zip = ZipFile.OpenRead(path); + return zip.Entries.Any(e => e.FullName.Equals("AppxSignature.p7x", StringComparison.OrdinalIgnoreCase)); + } + + private async Task SignThroughBatchEndpoint(string? extension) + { + string batchId = Guid.NewGuid().ToString(); + _foldersToClean.Add(Path.Combine(BatchedSigning.GetTempFolder(), batchId)); + + var headers = new Dictionary + { + ["X-BatchId"] = batchId, + ["X-BatchReady"] = "true" + }; + if (extension is not null) headers["X-FileExtension"] = extension; + + var signer = new Signer(BuildSignToolSettings(), NSubstitute.Substitute.For>()); + + await using var body = File.OpenRead(_msix); + var uploadResult = await BatchedSigning.Sign(headers, body, NSubstitute.Substitute.For(), signer); + var ok = uploadResult as Microsoft.AspNetCore.Http.HttpResults.Ok; + Assert.That(ok, Is.Not.Null, "upload should be accepted"); + + string id = ok!.Value!.Replace("\"", ""); + + var pollHeaders = new Dictionary { ["X-BatchId"] = batchId }; + if (extension is not null) pollHeaders["X-FileExtension"] = extension; + + for (int i = 0; i < 200; i++) + { + var result = await BatchedSigning.Get(pollHeaders, id, signer); + if (result is Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult problem + && problem.StatusCode == 425) + { + await Task.Delay(100); + continue; + } + return result; + } + + Assert.Fail("the signing service never reached a terminal state"); + return null!; + } + + [Test] + public async Task MsixKeepingItsExtensionIsSigned() + { + var result = await SignThroughBatchEndpoint(".msix"); + + Assert.That(result, Is.InstanceOf(), + "signtool should accept an msix that still has its extension"); + + string downloaded = Path.Combine(Path.GetTempPath(), $"signed-{Guid.NewGuid():N}.msix"); + try + { + var streamResult = (Microsoft.AspNetCore.Http.HttpResults.FileStreamHttpResult)result; + await using (var source = streamResult.FileStream) + await using (var destination = File.OpenWrite(downloaded)) + { + await source.CopyToAsync(destination); + } + + Assert.That(HasAppxSignature(downloaded), Is.True, + "a signed msix carries an AppxSignature.p7x entry"); + } + finally + { + try { if (File.Exists(downloaded)) File.Delete(downloaded); } catch { } + } + } + + [Test] + public async Task MsixWithoutItsExtensionReportsAFailureRatherThanStalling() + { + var result = await SignThroughBatchEndpoint(null); + + var problem = result as Microsoft.AspNetCore.Http.HttpResults.ProblemHttpResult; + + Assert.Multiple(() => + { + Assert.That(problem, Is.Not.Null, "an unsignable package must not look like a success"); + Assert.That(problem!.StatusCode, Is.EqualTo(500)); + Assert.That(problem.ProblemDetails.Detail, Does.Contain("not recognized")); + }); + } + } +}