From 0e941bd53439eb67148f54877f4fbb16becf5c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 30 Sep 2026 11:53:59 -0400 Subject: [PATCH 1/4] Fix release payload isolation and ConPTY worker starvation Exclude Windows ConPTY hosts at the final Unix publish boundary, validate nested macOS code, and exercise the release signer on both actual NativeAOT bundles in ordinary CI without credentials. Preserve uploaded payloads and checksums. Use cancellable asynchronous host-side pipes and registered process-exit waits so idle sessions and blocked input cannot exhaust the worker pool. Add an isolated constrained-worker regression verified to fail against the old implementation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-terminal.yml | 17 +++ Directory.Build.targets | 12 ++ docs/macos.md | 3 + docs/release.md | 2 + scripts/Sign-MacOsPackage.ps1 | 6 +- scripts/Test-ConPtyPublishLayout.ps1 | 47 ++++++++ scripts/Test-MacOsCodeSigning.ps1 | 23 ++++ scripts/Test-MacOsPackage.ps1 | 10 ++ .../ConPtyConnection.cs | 91 +++++++++----- .../Native/ConPty.cs | 2 +- .../ConPtySchedulingTests.cs | 113 ++++++++++++++++++ .../ConnectionContractTests.cs | 5 + 12 files changed, 296 insertions(+), 35 deletions(-) create mode 100644 Directory.Build.targets create mode 100644 scripts/Test-ConPtyPublishLayout.ps1 create mode 100644 tests/Devolutions.Terminal.Connection.Tests/ConPtySchedulingTests.cs diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index 1a1d173..5ae850b 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -162,6 +162,9 @@ jobs: - name: Test macOS legal notice layout run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1 + - name: Test ConPTY publish layouts + run: pwsh -NoLogo -NoProfile -File scripts/Test-ConPtyPublishLayout.ps1 + nuget-pack: name: Pack Devolutions.Terminal.Control NuGet package runs-on: windows-latest @@ -382,6 +385,20 @@ jobs: - name: Validate package without launching UI run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsPackage.ps1 ${{ matrix.rid }} artifacts/macos-packages/*.zip + - name: Test release signing on the actual app bundle + # Exercise the release signer on both architectures in ordinary CI, + # without Developer ID credentials or notarization. + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + $testDirectory = "artifacts/macos-signing-validation/${{ matrix.rid }}" + New-Item -ItemType Directory -Force -Path $testDirectory | Out-Null + $testApp = Join-Path $testDirectory 'Devolutions Terminal.app' + # Leave the uploaded app, zip, and their checksum manifest unchanged. + & /bin/cp -a "artifacts/macos-packages/Devolutions Terminal.app" $testApp + ./scripts/Sign-MacOsPackage.ps1 $testApp - + - name: Run native non-UI gates # NativeAOT osx-x64 binaries are cross-compiled on the arm64 runner and # cannot be executed here (no Rosetta on Actions macOS images); only the diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..2a1f39c --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/docs/macos.md b/docs/macos.md index 54b98cf..97bad09 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -65,6 +65,9 @@ bundle and writes a zip plus SHA-256 manifest. Published `THIRD-PARTY-NOTICES*.txt` files (including transitive dependency notices) are preserved in `Contents/Resources` alongside `LICENSE`, not in the code-only `Contents/MacOS` directory. `Test-MacOsLegalNotices.ps1` checks this layout without requiring Apple signing credentials. +Unix publishes exclude Windows-only ConPTY `OpenConsole.exe` hosts from the final publish list, including files supplied by RID-less project references. +Package validation and signing check every file under `Contents/MacOS`, not just top-level files. +Ordinary CI runs the release signing script with an ad-hoc identity on both actual NativeAOT app bundles, so nested-code failures are caught before a credentialed release. ```bash open "artifacts/packages/Devolutions Terminal.app" diff --git a/docs/release.md b/docs/release.md index 708def0..113b6de 100644 --- a/docs/release.md +++ b/docs/release.md @@ -22,6 +22,8 @@ the ARM64 host required by x64 processes running under emulation. Because output root once a RID is applied, the hosts are emitted in both layouts; a RID does not reliably flow to referenced projects, so the placement cannot depend on it. +Unix RID-specific publishes remove the Windows-only `OpenConsole.exe` hosts from the final publish list; RID-less builds and Windows publishes retain both host layouts. +ConPTY uses asynchronous host-side pipes and registered process-exit waits, with synchronous pipe endpoints for the console host, so idle sessions and blocked input do not exhaust the worker pool. Linux and macOS local sessions use the bundled `forkpty` relay. The Avalonia shell, settings, renderer, and terminal engines are shared. diff --git a/scripts/Sign-MacOsPackage.ps1 b/scripts/Sign-MacOsPackage.ps1 index 01103dd..dfd95db 100644 --- a/scripts/Sign-MacOsPackage.ps1 +++ b/scripts/Sign-MacOsPackage.ps1 @@ -70,11 +70,13 @@ $mainExecutable = Join-Path $contents 'MacOS' $mainExecutableName $macosDirectory = Join-Path $contents 'MacOS' $invalidMacOsFiles = @( - Get-ChildItem -LiteralPath $macosDirectory -File | + Get-ChildItem -LiteralPath $macosDirectory -Recurse -Force -File | Where-Object { -not (Test-MachO -Path $_.FullName) } ) if ($invalidMacOsFiles.Count -gt 0) { - $invalidNames = ($invalidMacOsFiles.Name | Sort-Object) -join ', ' + $invalidNames = ($invalidMacOsFiles | ForEach-Object { + [IO.Path]::GetRelativePath($macosDirectory, $_.FullName) + } | Sort-Object) -join ', ' throw "Contents/MacOS may contain only Mach-O code; move or remove these data files before signing: $invalidNames" } diff --git a/scripts/Test-ConPtyPublishLayout.ps1 b/scripts/Test-ConPtyPublishLayout.ps1 new file mode 100644 index 0000000..d6bb46c --- /dev/null +++ b/scripts/Test-ConPtyPublishLayout.ps1 @@ -0,0 +1,47 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +$targets = [Security.SecurityElement]::Escape((Join-Path $repoRoot 'Directory.Build.targets')) +$work = Join-Path ([IO.Path]::GetTempPath()) "conpty-publish-test-$([guid]::NewGuid().ToString('N'))" + +try { + New-Item -ItemType Directory -Path $work | Out-Null + $project = Join-Path $work 'PublishLayout.proj' + @" + + + + + + + +"@ | Set-Content -LiteralPath $project -Encoding utf8 + + foreach ($rid in @('', 'win-x86', 'win-x64', 'win-arm64', 'osx-x64', 'osx-arm64', 'linux-x64', 'linux-arm64')) { + $output = & dotnet msbuild $project -t:ComputeFilesToPublish "-p:RuntimeIdentifier=$rid" ` + -getItem:ResolvedFileToPublish -verbosity:quiet + if ($LASTEXITCODE -ne 0) { + throw "Publish layout evaluation failed for '$rid': $output" + } + $items = ($output -join "`n" | ConvertFrom-Json).Items.ResolvedFileToPublish + $hosts = @($items | Where-Object { "$($_.Filename)$($_.Extension)" -eq 'OpenConsole.exe' }) + $expectedHosts = if ($rid -match '^(osx|linux)-') { 0 } else { 9 } + if ($hosts.Count -ne $expectedHosts -or $items.Count -ne $expectedHosts + 3) { + throw "Unexpected publish layout for '$rid': expected $expectedHosts Windows hosts and 3 retained files, got $($hosts.Count) hosts and $($items.Count) files." + } + foreach ($retained in @('THIRD-PARTY-NOTICES-CONPTY.txt', 'dt', 'libghostty-vt.dylib')) { + if (@($items | Where-Object { "$($_.Filename)$($_.Extension)" -ceq $retained }).Count -ne 1) { + throw "Publish layout for '$rid' lost $retained." + } + } + Write-Host "ConPtyPublishLayout ($rid): $expectedHosts Windows hosts; legal notice and native payload preserved." + } +} +finally { + Remove-Item -LiteralPath $work -Recurse -Force +} diff --git a/scripts/Test-MacOsCodeSigning.ps1 b/scripts/Test-MacOsCodeSigning.ps1 index 56717e0..37a4588 100644 --- a/scripts/Test-MacOsCodeSigning.ps1 +++ b/scripts/Test-MacOsCodeSigning.ps1 @@ -99,6 +99,29 @@ try { $resources = Join-Path $contents 'Resources' New-Item -ItemType Directory -Path $resources | Out-Null Move-MacOsLegalNotices -MacOsDirectory $macosDir -ResourcesDirectory $resources + foreach ($relativePath in @( + 'x64/OpenConsole.exe', + 'runtimes/win-arm64/native/arm64/OpenConsole.exe' + )) { + $nestedCode = Join-Path $macosDir $relativePath + New-Item -ItemType Directory -Path (Split-Path -Parent $nestedCode) -Force | Out-Null + [IO.File]::WriteAllBytes($nestedCode, [byte[]](0x4D, 0x5A, 0, 0)) + $nestedCodeRejected = $false + try { + & (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-' + } + catch { + if ($_.Exception.Message -notmatch 'Contents/MacOS may contain only Mach-O code.*OpenConsole\.exe') { + throw + } + $nestedCodeRejected = $true + } + if (-not $nestedCodeRejected) { + throw "Sign-MacOsPackage.ps1 accepted nested Windows code: $relativePath." + } + Remove-Item -LiteralPath $nestedCode -Force + } + Remove-Item -LiteralPath (Join-Path $macosDir 'x64'), (Join-Path $macosDir 'runtimes') -Recurse -Force & (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-' if ($LASTEXITCODE -ne 0) { throw "Sign-MacOsPackage.ps1 failed with exit code $LASTEXITCODE." diff --git a/scripts/Test-MacOsPackage.ps1 b/scripts/Test-MacOsPackage.ps1 index eb73006..629caf7 100644 --- a/scripts/Test-MacOsPackage.ps1 +++ b/scripts/Test-MacOsPackage.ps1 @@ -159,6 +159,16 @@ function Test-MacOsAppBundle { if ($dsyms) { throw "$label contains dSYM bundles." } + $invalidCodeFiles = @( + Get-ChildItem -LiteralPath $macosDir -Recurse -Force -File | + Where-Object { (& file -b $_.FullName) -notmatch 'Mach-O' } + ) + if ($invalidCodeFiles.Count -gt 0) { + $invalidNames = ($invalidCodeFiles | ForEach-Object { + [IO.Path]::GetRelativePath($macosDir, $_.FullName) + } | Sort-Object) -join ', ' + throw "$label Contents/MacOS contains non-Mach-O files: $invalidNames" + } $runtimeConfig = Get-ChildItem -LiteralPath $macosDir -File -Filter '*.runtimeconfig.json' if ($runtimeConfig) { throw "$label contains NativeAOT runtime configuration in Contents/MacOS." diff --git a/src/Devolutions.Terminal.Connection/ConPtyConnection.cs b/src/Devolutions.Terminal.Connection/ConPtyConnection.cs index d4d98da..e3ce03b 100644 --- a/src/Devolutions.Terminal.Connection/ConPtyConnection.cs +++ b/src/Devolutions.Terminal.Connection/ConPtyConnection.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Diagnostics; +using System.IO.Pipes; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; @@ -199,14 +200,12 @@ public async ValueTask DisposeAsync() private void StartCore(TerminalLaunchOptions options, CancellationToken cancellationToken) { - SafeFileHandle? inputRead = null; - SafeFileHandle? inputWrite = null; - SafeFileHandle? outputRead = null; - SafeFileHandle? outputWrite = null; + NamedPipeClientStream? inputRead = null; + NamedPipeClientStream? outputWrite = null; SafePseudoConsoleHandle? pseudoConsole = null; SafeKernelObjectHandle? process = null; - FileStream? inputStream = null; - FileStream? outputStream = null; + NamedPipeServerStream? inputStream = null; + NamedPipeServerStream? outputStream = null; CancellationTokenSource? lifetime = null; lock (_stateLock) @@ -217,15 +216,16 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella try { - CreatePipe(out inputRead, out inputWrite); - CreatePipe(out outputRead, out outputWrite); + CreatePipe(PipeDirection.Out, out inputStream, out inputRead); + CreatePipe(PipeDirection.In, out outputStream, out outputWrite); var size = new Kernel32.Coord { X = (short)options.Columns, Y = (short)options.Rows, }; - var hr = ConPty.CreatePseudoConsole(size, inputRead, outputWrite, 0, out var pseudoConsoleValue); + var hr = ConPty.CreatePseudoConsole( + size, inputRead.SafePipeHandle, outputWrite.SafePipeHandle, 0, out var pseudoConsoleValue); if (hr != 0) { Marshal.ThrowExceptionForHR(hr); @@ -235,10 +235,6 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella var processResult = StartProcess(options, pseudoConsole); process = processResult.Handle; - inputStream = new FileStream(inputWrite, FileAccess.Write, 4096, isAsync: false); - inputWrite = null; - outputStream = new FileStream(outputRead, FileAccess.Read, 4096, isAsync: false); - outputRead = null; lifetime = new CancellationTokenSource(); var generation = ++_generation; @@ -296,8 +292,8 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella outputStream = null; lifetime = null; - session.ReadTask = Task.Run(() => ReadLoop(session)); - session.WaitTask = Task.Run(() => WaitLoop(session)); + session.ReadTask = ReadLoopAsync(session); + session.WaitTask = WaitLoopAsync(session); session.CancellationRegistration = cancellationToken.Register( static state => { @@ -329,8 +325,6 @@ private void StartCore(TerminalLaunchOptions options, CancellationToken cancella inputStream?.Dispose(); outputStream?.Dispose(); inputRead?.Dispose(); - inputWrite?.Dispose(); - outputRead?.Dispose(); outputWrite?.Dispose(); process?.Dispose(); pseudoConsole?.Dispose(); @@ -455,14 +449,15 @@ private OrderedInputWriter GetWriter() } } - private void ReadLoop(SessionResources session) + private async Task ReadLoopAsync(SessionResources session) { var buffer = new byte[16 * 1024]; try { while (!session.Lifetime.IsCancellationRequested) { - var read = session.Output.Read(buffer); + var read = await session.Output.ReadAsync( + buffer, session.Lifetime.Token).ConfigureAwait(false); if (read == 0) { break; @@ -486,13 +481,26 @@ private void ReadLoop(SessionResources session) } } - private void WaitLoop(SessionResources session) + private async Task WaitLoopAsync(SessionResources session) { - var waitResult = Kernel32.WaitForSingleObject(session.Process, Kernel32.Infinite); - if (waitResult == Kernel32.WaitFailed) - { - PublishFault(session, new Win32Exception(Marshal.GetLastPInvokeError())); - return; + // A registered wait observes process exit without holding a pool worker + // (or retaining a dedicated thread's handles) for the session lifetime. + using (var processExited = new EventWaitHandle(false, EventResetMode.AutoReset)) + { + processExited.SafeWaitHandle = new SafeWaitHandle( + session.Process.DangerousGetHandle(), ownsHandle: false); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var registration = ThreadPool.RegisterWaitForSingleObject( + processExited, static (state, _) => ((TaskCompletionSource)state!).TrySetResult(), + completion, Timeout.Infinite, executeOnlyOnce: true); + try + { + await completion.Task.ConfigureAwait(false); + } + finally + { + registration.Unregister(null); + } } if (!Kernel32.GetExitCodeProcess(session.Process, out var code)) @@ -777,11 +785,30 @@ private static nint CreateEnvironmentBlock(TerminalLaunchOptions options) return Marshal.StringToHGlobalUni(builder.ToString()); } - private static void CreatePipe(out SafeFileHandle read, out SafeFileHandle write) + private static void CreatePipe( + PipeDirection direction, + out NamedPipeServerStream server, + out NamedPipeClientStream client) { - if (!Kernel32.CreatePipe(out read, out write, 0, 0)) + var name = $"devolutions-terminal-{Guid.NewGuid():N}"; + // ConPTY uses synchronous handles, but our ends must support overlapped + // I/O so idle reads and blocked writes remain cancellable without workers. + server = new NamedPipeServerStream( + name, direction, 1, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + client = new NamedPipeClientStream( + ".", name, direction == PipeDirection.Out ? PipeDirection.In : PipeDirection.Out, + PipeOptions.None); + try { - throw new Win32Exception(Marshal.GetLastPInvokeError()); + client.Connect(); + server.WaitForConnection(); + } + catch + { + client.Dispose(); + server.Dispose(); + throw; } } @@ -848,8 +875,8 @@ private sealed class SessionResources( TerminalProcessMetadata metadata, SafePseudoConsoleHandle pseudoConsole, SafeKernelObjectHandle process, - FileStream input, - FileStream output, + NamedPipeServerStream input, + NamedPipeServerStream output, CancellationTokenSource lifetime) { public long Generation { get; } = generation; @@ -857,8 +884,8 @@ private sealed class SessionResources( public TerminalProcessMetadata Metadata { get; } = metadata; public SafePseudoConsoleHandle PseudoConsole { get; } = pseudoConsole; public SafeKernelObjectHandle Process { get; } = process; - public FileStream Input { get; } = input; - public FileStream Output { get; } = output; + public NamedPipeServerStream Input { get; } = input; + public NamedPipeServerStream Output { get; } = output; public CancellationTokenSource Lifetime { get; } = lifetime; public CancellationTokenRegistration CancellationRegistration { get; set; } public Task? ReadTask { get; set; } diff --git a/src/Devolutions.Terminal.Connection/Native/ConPty.cs b/src/Devolutions.Terminal.Connection/Native/ConPty.cs index 46a987f..5dfc3da 100644 --- a/src/Devolutions.Terminal.Connection/Native/ConPty.cs +++ b/src/Devolutions.Terminal.Connection/Native/ConPty.cs @@ -8,7 +8,7 @@ namespace Devolutions.Terminal.Connection.Native; internal static partial class ConPty { [LibraryImport("conpty.dll", EntryPoint = "ConptyCreatePseudoConsole")] - internal static partial int CreatePseudoConsole(Kernel32.Coord size, SafeFileHandle hInput, SafeFileHandle hOutput, uint dwFlags, out nint phPC); + internal static partial int CreatePseudoConsole(Kernel32.Coord size, SafePipeHandle hInput, SafePipeHandle hOutput, uint dwFlags, out nint phPC); [LibraryImport("conpty.dll", EntryPoint = "ConptyResizePseudoConsole")] internal static partial int ResizePseudoConsole(SafePseudoConsoleHandle hPC, Kernel32.Coord size); diff --git a/tests/Devolutions.Terminal.Connection.Tests/ConPtySchedulingTests.cs b/tests/Devolutions.Terminal.Connection.Tests/ConPtySchedulingTests.cs new file mode 100644 index 0000000..063cfee --- /dev/null +++ b/tests/Devolutions.Terminal.Connection.Tests/ConPtySchedulingTests.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text; +using Xunit; + +namespace Devolutions.Terminal.Connection.Tests; + +[SupportedOSPlatform("windows")] +public sealed class ConPtySchedulingTests +{ + private const string ProbeEnvironmentVariable = "DEVOLUTIONS_CONPTY_SCHEDULING_PROBE"; + public static bool IsWindows => OperatingSystem.IsWindows(); + + [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] + public async Task IdleSessionsDoNotStarveInputExitOrClose() + { + if (Environment.GetEnvironmentVariable(ProbeEnvironmentVariable) == "1") + { + await RunProbeAsync(); + return; + } + + // Limit workers only in a child runner, never in the shared test process. + var start = new ProcessStartInfo("dotnet") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + start.ArgumentList.Add(typeof(ConPtySchedulingTests).Assembly.Location); + start.ArgumentList.Add("-method"); + start.ArgumentList.Add($"{typeof(ConPtySchedulingTests).FullName}.{nameof(IdleSessionsDoNotStarveInputExitOrClose)}"); + start.ArgumentList.Add("-parallel"); + start.ArgumentList.Add("none"); + start.ArgumentList.Add("-noAutoReporters"); + start.Environment[ProbeEnvironmentVariable] = "1"; + start.Environment["DOTNET_PROCESSOR_COUNT"] = "2"; + using var process = Process.Start(start) ?? throw new InvalidOperationException("Scheduling probe did not start."); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + try + { + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(45)); + } + catch (TimeoutException) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + Assert.Fail($"ConPTY sessions exhausted the worker pool.\n{await stdout}\n{await stderr}"); + } + + Assert.True(process.ExitCode == 0, $"Scheduling probe failed:\n{await stdout}\n{await stderr}"); + } + + private static async Task RunProbeAsync() + { + ThreadPool.GetMinThreads(out _, out var minimumIo); + ThreadPool.GetMaxThreads(out _, out var maximumIo); + Assert.True(ThreadPool.SetMinThreads(2, minimumIo)); + Assert.True(ThreadPool.SetMaxThreads(8, maximumIo)); + var connections = new List(); + var ready = new List(); + var exits = new List>(); + try + { + for (var index = 0; index < 6; index++) + { + var connection = new ConPtyConnection(); + connections.Add(connection); + var output = new StringBuilder(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + connection.OutputReceived += (_, bytes) => + { + output.Append(Encoding.UTF8.GetString(bytes.Span)); + if (output.ToString().Contains("READY", StringComparison.Ordinal)) + { + received.TrySetResult(); + } + }; + connection.Exited += (_, code) => exited.TrySetResult(code); + connection.Faulted += (_, error) => + { + received.TrySetException(error); + exited.TrySetException(error); + }; + ready.Add(received.Task); + exits.Add(exited.Task); + var comSpec = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + await connection.StartAsync($"\"{comSpec}\" /d /q", null, 80, 24); + } + + foreach (var connection in connections) + { + connection.Write("echo READY\r"); + } + await Task.WhenAll(ready).WaitAsync(TimeSpan.FromSeconds(10)); + foreach (var connection in connections) + { + connection.Resize(132, 43); + connection.Write("exit\r"); + } + var exitCodes = await Task.WhenAll(exits).WaitAsync(TimeSpan.FromSeconds(10)); + Assert.All(exitCodes, code => Assert.Equal(0, code)); + } + finally + { + await Task.WhenAll(connections.Select(connection => connection.DisposeAsync().AsTask())) + .WaitAsync(TimeSpan.FromSeconds(5)); + } + Assert.All(connections, connection => Assert.False(connection.IsRunning)); + } +} diff --git a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs index 8364c42..822c624 100644 --- a/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs +++ b/tests/Devolutions.Terminal.Connection.Tests/ConnectionContractTests.cs @@ -741,6 +741,11 @@ public async Task ExitedSessionsReleaseHandlesBeforeConnectionDisposal() Assert.Equal(0, await exited.Task.WaitAsync(TimeSpan.FromSeconds(10))); } + // Collect managed wait/overlapped-I/O bookkeeping, while retaining + // every connection so leaked native session handles remain observable. + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); var deadline = DateTime.UtcNow.AddSeconds(10); int handleCount; do From 4a3f24b9b64ebbd96aff7d08e723616bbc266c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 30 Sep 2026 15:17:16 -0400 Subject: [PATCH 2/4] Use signed native payloads in release NuGet packages Route NuGet packaging through the Windows MSI signing layouts and standalone Developer ID-signed macOS payloads. Preserve the flat runtime contract by signing macOS native code outside the app bundle with runtime entitlements. Gate publication on signatures verified from actual restored/extracted nupkgs; fail instead of falling back to unsigned release code. Keep ordinary CI and unsigned dry runs credential-free, exclude native debug symbols, and validate both Windows signature failures and macOS standalone layout behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-terminal.yml | 210 ++++++++++++++++-- docs/macos.md | 4 + docs/release.md | 7 + scripts/Stage-MacOsNuGetPayload.ps1 | 65 ++++++ scripts/Test-MacOsNuGetPayload.ps1 | 60 +++++ scripts/Test-MacOsNuGetSigning.ps1 | 61 +++++ scripts/Test-MacOsPackagingMetadata.ps1 | 3 + .../Devolutions.Terminal.Distribution.csproj | 1 + .../README.md | 7 + .../Scripts/Build-NuGet.ps1 | 15 +- .../Scripts/Test-NuGetDistribution.ps1 | 28 ++- .../Scripts/Test-WindowsPayloadSignatures.ps1 | 62 ++++++ ...est-WindowsPayloadSignaturesRegression.ps1 | 163 ++++++++++++++ 13 files changed, 662 insertions(+), 24 deletions(-) create mode 100644 scripts/Stage-MacOsNuGetPayload.ps1 create mode 100644 scripts/Test-MacOsNuGetPayload.ps1 create mode 100644 scripts/Test-MacOsNuGetSigning.ps1 create mode 100644 src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 create mode 100644 src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1 diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index 5ae850b..a9273e5 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -53,6 +53,7 @@ jobs: msix_version: ${{ steps.resolve.outputs.msix_version }} dry_run: ${{ steps.resolve.outputs.dry_run }} sign_dry_run: ${{ steps.resolve.outputs.sign_dry_run }} + sign_packages: ${{ steps.resolve.outputs.sign_packages }} publish_environment: ${{ steps.resolve.outputs.publish_environment }} steps: - name: Checkout @@ -116,12 +117,15 @@ jobs: } $msixVersion = "$releaseVersion.0" + $signPackages = if (($env:GITHUB_EVENT_NAME -eq "workflow_dispatch" -or $env:GITHUB_REF_TYPE -eq "tag") -and + ($dryRun -eq "false" -or $signDryRun -eq "true")) { "true" } else { "false" } @( "release_tag=$tag" "release_version=$releaseVersion" "msix_version=$msixVersion" "dry_run=$dryRun" "sign_dry_run=$signDryRun" + "sign_packages=$signPackages" "publish_environment=$publishEnvironment" ) | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append @@ -165,6 +169,9 @@ jobs: - name: Test ConPTY publish layouts run: pwsh -NoLogo -NoProfile -File scripts/Test-ConPtyPublishLayout.ps1 + - name: Test Windows NuGet signature guards + run: pwsh -NoLogo -NoProfile -File src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1 + nuget-pack: name: Pack Devolutions.Terminal.Control NuGet package runs-on: windows-latest @@ -322,6 +329,9 @@ jobs: - name: Test macOS legal notice layout run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1 + - name: Test standalone macOS NuGet signing + run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsNuGetSigning.ps1 + macos-native-aot: name: macOS NativeAOT ${{ matrix.rid }} runs-on: macos-26 @@ -399,6 +409,22 @@ jobs: & /bin/cp -a "artifacts/macos-packages/Devolutions Terminal.app" $testApp ./scripts/Sign-MacOsPackage.ps1 $testApp - + - name: Stage standalone macOS NuGet payload + shell: pwsh + run: > + ./scripts/Stage-MacOsNuGetPayload.ps1 + -AppPath "artifacts/macos-signing-validation/${{ matrix.rid }}/Devolutions Terminal.app" + -OutputDirectory "artifacts/macos-nuget/${{ matrix.rid }}" + -Rid "${{ matrix.rid }}" + -Identity - + + - name: Upload unsigned macOS NuGet payload + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-${{ matrix.rid }}-nuget-payload + path: artifacts/macos-nuget/${{ matrix.rid }} + if-no-files-found: error + - name: Run native non-UI gates # NativeAOT osx-x64 binaries are cross-compiled on the arm64 runner and # cannot be executed here (no Rosetta on Actions macOS images); only the @@ -535,6 +561,25 @@ jobs: - name: Validate final package run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsPackage.ps1 ${{ matrix.rid }} artifacts/macos-signed-packages/*.zip + - name: Stage release macOS NuGet payload + shell: pwsh + env: + SHOULD_SIGN: ${{ steps.signing-mode.outputs.should_sign }} + SIGNING_IDENTITY: ${{ steps.import_certificate.outputs.identity }} + run: | + $identity = if ($env:SHOULD_SIGN -eq 'true') { $env:SIGNING_IDENTITY } else { '-' } + ./scripts/Stage-MacOsNuGetPayload.ps1 ` + -AppPath "artifacts/macos-signed-packages/Devolutions Terminal.app" ` + -OutputDirectory "artifacts/macos-nuget/${{ matrix.rid }}" ` + -Rid "${{ matrix.rid }}" -Identity $identity + + - name: Upload release macOS NuGet payload + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-${{ matrix.rid }}-signed-nuget-payload + path: artifacts/macos-nuget/${{ matrix.rid }} + if-no-files-found: error + - name: Upload final macOS package artifacts uses: actions/upload-artifact@v4 with: @@ -916,10 +961,24 @@ jobs: nuget: name: NuGet distribution package + # macos-sign is intentionally skipped outside releases; do not let that + # suppress ordinary NuGet CI, or let a failed signer fall back to raw code. + if: >- + ${{ always() && !cancelled() && + needs.release-metadata.result == 'success' && + needs.msi.result == 'success' && + needs.linux-packages.result == 'success' && + needs.macos-native-aot.result == 'success' && + (needs.macos-sign.result == 'success' || + (needs.macos-sign.result == 'skipped' && + github.event_name != 'workflow_dispatch' && + !startsWith(github.ref, 'refs/tags/'))) }} needs: - native-aot + - msi - linux-packages - macos-native-aot + - macos-sign - release-metadata runs-on: windows-latest steps: @@ -931,16 +990,22 @@ jobs: with: dotnet-version: 10.0.x - - name: Download Windows x64 publish + - name: Set up Windows signature verification + if: needs.release-metadata.outputs.sign_packages == 'true' + uses: microsoft/setup-WinAppCli@v0.1 + with: + version: v0.6.1 + + - name: Download Windows x64 NuGet payload uses: actions/download-artifact@v4 with: - name: DevolutionsTerminal-win-x64 + name: DevolutionsTerminal-win-x64-nuget-payload path: artifacts/nuget/layout/win-x64 - - name: Download Windows arm64 publish + - name: Download Windows arm64 NuGet payload uses: actions/download-artifact@v4 with: - name: DevolutionsTerminal-win-arm64 + name: DevolutionsTerminal-win-arm64-nuget-payload path: artifacts/nuget/layout/win-arm64 - name: Download Linux x64 publish @@ -955,31 +1020,53 @@ jobs: name: DevolutionsTerminal-linux-arm64 path: artifacts/nuget/layout/linux-arm64 - - name: Download macOS x64 publish + - name: Download macOS x64 NuGet payload uses: actions/download-artifact@v4 with: - name: DevolutionsTerminal-osx-x64 + name: DevolutionsTerminal-osx-x64-${{ needs.macos-sign.result == 'success' && 'signed-nuget-payload' || 'nuget-payload' }} path: artifacts/nuget/layout/osx-x64 - - name: Download macOS arm64 publish + - name: Download macOS arm64 NuGet payload uses: actions/download-artifact@v4 with: - name: DevolutionsTerminal-osx-arm64 + name: DevolutionsTerminal-osx-arm64-${{ needs.macos-sign.result == 'success' && 'signed-nuget-payload' || 'nuget-payload' }} path: artifacts/nuget/layout/osx-arm64 - name: Build NuGet distribution packages shell: pwsh - run: > - ./src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 - -SkipPublish - -Version "${{ needs.release-metadata.outputs.release_version }}" + env: + REQUIRE_SIGNATURE: ${{ needs.release-metadata.outputs.sign_packages }} + WINDOWS_BINARIES_SIGNED: ${{ needs.msi.outputs.binaries_signed }} + EXPECTED_PUBLISHER: ${{ needs.msi.outputs.publisher }} + run: | + $arguments = @{ + SkipPublish = $true + Version = '${{ needs.release-metadata.outputs.release_version }}' + } + if ($env:REQUIRE_SIGNATURE -eq 'true') { + if ($env:WINDOWS_BINARIES_SIGNED -ne 'true') { + throw 'Signed NuGet release requires signed Windows payloads.' + } + $arguments.RequireWindowsSignature = $true + $arguments.ExpectedPublisher = $env:EXPECTED_PUBLISHER + } + ./src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 @arguments - name: Smoke test NuGet package import shell: pwsh - run: > - ./src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 - -PackageDirectory ./artifacts/nuget/packages - -Version "${{ needs.release-metadata.outputs.release_version }}" + env: + REQUIRE_SIGNATURE: ${{ needs.release-metadata.outputs.sign_packages }} + EXPECTED_PUBLISHER: ${{ needs.msi.outputs.publisher }} + run: | + $arguments = @{ + PackageDirectory = './artifacts/nuget/packages' + Version = '${{ needs.release-metadata.outputs.release_version }}' + } + if ($env:REQUIRE_SIGNATURE -eq 'true') { + $arguments.RequireWindowsSignature = $true + $arguments.ExpectedPublisher = $env:EXPECTED_PUBLISHER + } + ./src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 @arguments - name: Upload NuGet distribution packages uses: actions/upload-artifact@v4 @@ -988,8 +1075,62 @@ jobs: path: artifacts/nuget/packages/*.nupkg if-no-files-found: error + - name: Upload macOS x64 NuGet package for native verification + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-osx-x64-nuget-package + path: artifacts/nuget/packages/Devolutions.Terminal.App.osx-x64.*.nupkg + if-no-files-found: error + + - name: Upload macOS arm64 NuGet package for native verification + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-osx-arm64-nuget-package + path: artifacts/nuget/packages/Devolutions.Terminal.App.osx-arm64.*.nupkg + if-no-files-found: error + + nuget-macos: + name: macOS NuGet signatures ${{ matrix.rid }} + if: ${{ !cancelled() && needs.nuget.result == 'success' && needs.release-metadata.result == 'success' }} + runs-on: macos-26 + needs: + - nuget + - release-metadata + strategy: + fail-fast: false + matrix: + rid: [osx-arm64, osx-x64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download packaged NuGet payload + uses: actions/download-artifact@v4 + with: + name: DevolutionsTerminal-${{ matrix.rid }}-nuget-package + path: artifacts/nuget-validation + + - name: Verify signatures in the actual NuGet package + shell: pwsh + env: + REQUIRE_SIGNATURE: ${{ needs.release-metadata.outputs.sign_packages }} + run: | + $package = Get-ChildItem artifacts/nuget-validation -Filter '*.nupkg' -File + if (@($package).Count -ne 1) { throw 'Expected exactly one macOS runtime package.' } + [IO.Compression.ZipFile]::ExtractToDirectory( + $package.FullName, [IO.Path]::GetFullPath('artifacts/nuget-validation/expanded')) + $arguments = @{ + PayloadDirectory = 'artifacts/nuget-validation/expanded/runtimes/${{ matrix.rid }}/native/payload' + Rid = '${{ matrix.rid }}' + } + if ($env:REQUIRE_SIGNATURE -eq 'true') { $arguments.RequireDeveloperId = $true } + ./scripts/Test-MacOsNuGetPayload.ps1 @arguments + msi: name: MSI packages + outputs: + binaries_signed: ${{ steps.signing-mode.outputs.should_sign }} + publisher: ${{ steps.signing-mode.outputs.publisher }} needs: - native-aot - release-metadata @@ -1034,6 +1175,7 @@ jobs: TRUSTED_SIGNING_ENDPOINT: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} TRUSTED_SIGNING_PROFILE_NAME: ${{ secrets.TRUSTED_SIGNING_PROFILE_NAME }} + REQUESTED_PUBLISHER: ${{ vars.TRUSTED_SIGNING_PUBLISHER }} run: | # Ordinary (non-release) CI runs never attempt binary signing: they are not gated on # signing secrets being configured, so they must not fail when those secrets are absent. @@ -1062,15 +1204,15 @@ jobs: } if ($missing.Count -gt 0) { - if ($dryRun) { - "should_sign=false" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - Write-Host "::notice::Skipping dry-run binary signing because these secrets are unavailable: $($missing -join ', ')" - exit 0 - } - throw "Missing Azure Artifact Signing secrets: $($missing -join ', ')" } + $publisher = $env:REQUESTED_PUBLISHER + if ([string]::IsNullOrWhiteSpace($publisher)) { + $publisher = "CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA" + } + if ($publisher -match '[\r\n]') { throw 'TRUSTED_SIGNING_PUBLISHER must be a single-line certificate subject.' } + "publisher=$publisher" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append "should_sign=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Install Windows psign-tool @@ -1140,6 +1282,29 @@ jobs: -ArtifactSigningAccessToken $accessToken ` -TimestampServer $timestampServer + - name: Verify signed Windows NuGet payloads + if: steps.signing-mode.outputs.should_sign == 'true' + shell: pwsh + env: + EXPECTED_PUBLISHER: ${{ steps.signing-mode.outputs.publisher }} + run: | + ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-x64 -ExpectedPublisher $env:EXPECTED_PUBLISHER + ./src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 -PayloadDirectory artifacts/msi/layout/win-arm64 -ExpectedPublisher $env:EXPECTED_PUBLISHER + + - name: Upload Windows x64 NuGet payload + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-win-x64-nuget-payload + path: artifacts/msi/layout/win-x64 + if-no-files-found: error + + - name: Upload Windows arm64 NuGet payload + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-win-arm64-nuget-payload + path: artifacts/msi/layout/win-arm64 + if-no-files-found: error + - name: Build MSI packages shell: pwsh run: > @@ -1188,6 +1353,7 @@ jobs: - msix - msi - nuget + - nuget-macos - nuget-pack - release-metadata runs-on: ubuntu-latest diff --git a/docs/macos.md b/docs/macos.md index 97bad09..1da94eb 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -68,6 +68,10 @@ Published `THIRD-PARTY-NOTICES*.txt` files (including transitive dependency noti Unix publishes exclude Windows-only ConPTY `OpenConsole.exe` hosts from the final publish list, including files supplied by RID-less project references. Package validation and signing check every file under `Contents/MacOS`, not just top-level files. Ordinary CI runs the release signing script with an ad-hoc identity on both actual NativeAOT app bundles, so nested-code failures are caught before a credentialed release. +`Stage-MacOsNuGetPayload.ps1` creates the existing flat NuGet native layout from that app and signs the copied code as standalone executables/libraries. +Signed releases use the same Developer ID identity, Hardened Runtime, application entitlements, and secure timestamps; unsigned CI uses an ad-hoc identity. +Both actual macOS NuGet packages are extracted and checked by `Test-MacOsNuGetPayload.ps1` on macOS before release publication. +The NuGet layout is not a notarized app bundle and does not inherit its stapled ticket. ```bash open "artifacts/packages/Devolutions Terminal.app" diff --git a/docs/release.md b/docs/release.md index 113b6de..0a549f8 100644 --- a/docs/release.md +++ b/docs/release.md @@ -303,6 +303,13 @@ that are not yet available. Splitting the NativeAOT payloads keeps each package below NuGet.org's 250 MB package-size limit while preserving the existing `PackageReference` and MSBuild import behavior. +For signed releases and signed dry runs, the NuGet job waits for platform signing and consumes the verified Windows MSI native layouts and standalone Developer ID-signed macOS payloads. +It never falls back to the original unsigned publish artifacts when signing fails or required credentials are missing. +The actual packaged Windows payloads are checked after consumer restore/build; both macOS `.nupkg` payloads are extracted and signature-verified on a native macOS runner before publication. +The flat macOS NuGet contract is preserved by re-signing copied native code outside the signed `.app`, with Hardened Runtime, the application entitlements, and secure timestamps. +This does not transfer the app bundle's notarization ticket; ZIP and DMG remain the notarized app distributions. +Runtime packages exclude native debug symbols, while ordinary CI and unsigned dry runs continue without protected signing credentials. + Configure the `publish-test` and `publish-prod` environments as trusted publishers for the package IDs on NuGet.org, and bootstrap the RID and pointer packages before the first publication. Dry runs do not request a NuGet API key diff --git a/scripts/Stage-MacOsNuGetPayload.ps1 b/scripts/Stage-MacOsNuGetPayload.ps1 new file mode 100644 index 0000000..363318c --- /dev/null +++ b/scripts/Stage-MacOsNuGetPayload.ps1 @@ -0,0 +1,65 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$AppPath, + [Parameter(Mandatory)][string]$OutputDirectory, + [Parameter(Mandatory)][ValidateSet('osx-arm64', 'osx-x64')][string]$Rid, + [Parameter(Mandatory)][string]$Identity +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force +Assert-Darwin +Assert-Command -Name 'codesign', 'cp', 'chmod' +$repoRoot = Split-Path -Parent $PSScriptRoot +$metadata = Import-MacOsPackageEnv -Path (Join-Path $repoRoot 'macos/package.env') +$entitlements = if ($env:MACOS_ENTITLEMENTS) { $env:MACOS_ENTITLEMENTS } else { + Join-Path $repoRoot 'macos/entitlements.plist' +} +$requirements = @{ RequireHardenedRuntime = $true } +if ($Identity -ne '-') { + $requirements.TeamIdentifier = $metadata.APPLE_TEAM_ID + $requirements.RequireTimestamp = $true + Assert-MacOsCodeSignature -Path $AppPath @requirements +} +Invoke-Native -FilePath codesign -ArgumentList '--verify', '--deep', '--strict', $AppPath +if (Test-Path -LiteralPath $OutputDirectory) { + throw "NuGet payload output directory already exists: $OutputDirectory" +} +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + +# NuGet's public contract is a flat native layout, not an .app. Bundle-level +# Info.plist/resource seals cannot follow an executable out of its bundle. +$binaries = @( + $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, + $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' +) +foreach ($name in $binaries) { + $source = Join-Path $AppPath "Contents/MacOS/$name" + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { + throw "App bundle is missing $name." + } + $destination = Join-Path $OutputDirectory $name + Invoke-Native -FilePath cp -ArgumentList '-p', $source, $destination + $arguments = @('--force', '--options', 'runtime') + if ($Identity -ne '-') { $arguments += '--timestamp' } + if ($name -in @($metadata.EXECUTABLE_NAME, $metadata.CLI_NAME)) { + $arguments += @('--entitlements', $entitlements) + } + $arguments += @('--sign', $Identity, $destination) + Invoke-Native -FilePath codesign -ArgumentList $arguments + Assert-MacOsCodeSignature -Path $destination @requirements +} +$resources = Join-Path $AppPath 'Contents/Resources' +Copy-Item -LiteralPath (Join-Path $resources 'LICENSE') -Destination $OutputDirectory +Get-ChildItem -LiteralPath $resources -File -Filter 'THIRD-PARTY-NOTICES*.txt' | + Copy-Item -Destination $OutputDirectory +Invoke-Native -FilePath chmod -ArgumentList @( + '0755', (Join-Path $OutputDirectory $metadata.EXECUTABLE_NAME), + (Join-Path $OutputDirectory $metadata.CLI_NAME), (Join-Path $OutputDirectory $metadata.PTY_HOST_NAME) +) +$testArguments = @{ PayloadDirectory = $OutputDirectory; Rid = $Rid } +if ($Identity -ne '-') { $testArguments.RequireDeveloperId = $true } +& (Join-Path $PSScriptRoot 'Test-MacOsNuGetPayload.ps1') @testArguments diff --git a/scripts/Test-MacOsNuGetPayload.ps1 b/scripts/Test-MacOsNuGetPayload.ps1 new file mode 100644 index 0000000..81ada83 --- /dev/null +++ b/scripts/Test-MacOsNuGetPayload.ps1 @@ -0,0 +1,60 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$PayloadDirectory, + [Parameter(Mandatory)][ValidateSet('osx-arm64', 'osx-x64')][string]$Rid, + [switch]$RequireDeveloperId +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force +Assert-Darwin +Assert-Command -Name 'codesign', 'file', 'lipo' +$metadata = Import-MacOsPackageEnv -Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'macos/package.env') +$expectedArch = Get-MacOsExpectedArch -Rid $Rid +$requirements = @{ RequireHardenedRuntime = $true } +if ($RequireDeveloperId) { + $requirements.TeamIdentifier = $metadata.APPLE_TEAM_ID + $requirements.RequireTimestamp = $true +} +$binaries = @( + $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, + $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' +) +foreach ($name in $binaries) { + $path = Join-Path $PayloadDirectory $name + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "NuGet payload is missing $name." } + $kind = & file -b $path + if ($LASTEXITCODE -ne 0 -or $kind -notmatch 'Mach-O') { throw "$name is not Mach-O code." } + $architectures = & lipo -archs $path + if ($LASTEXITCODE -ne 0 -or $architectures -split '\s+' -notcontains $expectedArch) { + throw "$name does not support $Rid." + } + Assert-MacOsCodeSignature -Path $path @requirements + if ($name -in @($metadata.EXECUTABLE_NAME, $metadata.CLI_NAME)) { + $entitlementText = (& codesign --display --entitlements - $path 2>$null) -join "`n" + if ($LASTEXITCODE -ne 0) { throw "Unable to inspect $name entitlements." } + [xml]$entitlements = $entitlementText + foreach ($key in @( + 'com.apple.security.cs.allow-jit', + 'com.apple.security.cs.allow-unsigned-executable-memory', + 'com.apple.security.cs.disable-library-validation' + )) { + $node = $entitlements.SelectSingleNode("/plist/dict/key[text()='$key']") + if ($null -eq $node -or $node.NextSibling.LocalName -ne 'true') { + throw "$name is missing required runtime entitlement $key." + } + } + } +} +foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) { + if (-not (Test-Path -LiteralPath (Join-Path $PayloadDirectory $name) -PathType Leaf)) { + throw "NuGet payload is missing $name." + } +} +$unexpected = @(Get-ChildItem -LiteralPath $PayloadDirectory -Recurse -Force | + Where-Object { $_.PSIsContainer -or $_.Name -match '\.(pdb|dbg|dSYM|exe|runtimeconfig\.json)$' }) +if ($unexpected.Count -gt 0) { throw "Unexpected files/directories in the standalone macOS NuGet payload: $($unexpected.Name -join ', ')" } +Write-Host "macOS $Rid standalone NuGet payload signatures and layout passed." diff --git a/scripts/Test-MacOsNuGetSigning.ps1 b/scripts/Test-MacOsNuGetSigning.ps1 new file mode 100644 index 0000000..11253df --- /dev/null +++ b/scripts/Test-MacOsNuGetSigning.ps1 @@ -0,0 +1,61 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force +Assert-Darwin +Assert-Command -Name 'codesign', 'cp' +$metadata = Import-MacOsPackageEnv -Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'macos/package.env') +$rid = if ((& uname -m) -eq 'arm64') { 'osx-arm64' } else { 'osx-x64' } +$work = Join-Path ([IO.Path]::GetTempPath()) "macos-nuget-signing-$([guid]::NewGuid().ToString('N'))" +$app = Join-Path $work 'Fixture.app' +$code = Join-Path $app 'Contents/MacOS' +$resources = Join-Path $app 'Contents/Resources' +try { + New-Item -ItemType Directory -Path $code, $resources -Force | Out-Null + foreach ($name in @( + $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, + $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' + )) { + Invoke-Native -FilePath cp -ArgumentList '/usr/bin/true', (Join-Path $code $name) + } + @" + + +CFBundleExecutable$($metadata.EXECUTABLE_NAME) +CFBundleIdentifier$($metadata.APP_ID) +CFBundlePackageTypeAPPL + +"@ | Set-Content (Join-Path $app 'Contents/Info.plist') -Encoding utf8 + foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) { + Set-Content (Join-Path $resources $name) "fixture license $name" -NoNewline + } + & (Join-Path $PSScriptRoot 'Sign-MacOsPackage.ps1') $app '-' + $sourceHash = (Get-FileHash (Join-Path $code $metadata.EXECUTABLE_NAME)).Hash + $payload = Join-Path $work 'payload' + & (Join-Path $PSScriptRoot 'Stage-MacOsNuGetPayload.ps1') -AppPath $app -OutputDirectory $payload -Rid $rid -Identity '-' + if ((Get-FileHash (Join-Path $code $metadata.EXECUTABLE_NAME)).Hash -cne $sourceHash) { + throw 'Standalone signing mutated the source app.' + } + foreach ($notice in Get-ChildItem $resources -File) { + if ((Get-FileHash (Join-Path $payload $notice.Name)).Hash -cne (Get-FileHash $notice.FullName).Hash) { + throw "Standalone payload changed legal notice $($notice.Name)." + } + } + $rejected = $false + try { + & (Join-Path $PSScriptRoot 'Test-MacOsNuGetPayload.ps1') -PayloadDirectory $payload -Rid $rid -RequireDeveloperId + } + catch { + if ($_.Exception.Message -notmatch 'not signed by Apple team') { throw } + $rejected = $true + } + if (-not $rejected) { throw 'A signed release accepted an ad-hoc NuGet payload.' } + Write-Host 'StandaloneMacOsNuGetSigning_PreservesSourceAndLicensesAndRejectsAdHocRelease passed.' +} +finally { + Remove-Item -LiteralPath $work -Recurse -Force +} diff --git a/scripts/Test-MacOsPackagingMetadata.ps1 b/scripts/Test-MacOsPackagingMetadata.ps1 index c0a8738..228169d 100644 --- a/scripts/Test-MacOsPackagingMetadata.ps1 +++ b/scripts/Test-MacOsPackagingMetadata.ps1 @@ -26,6 +26,9 @@ $scripts = @( 'Test-MacOsRuntime.ps1', 'Test-MacOsCodeSigning.ps1', 'Test-MacOsLegalNotices.ps1', + 'Stage-MacOsNuGetPayload.ps1', + 'Test-MacOsNuGetPayload.ps1', + 'Test-MacOsNuGetSigning.ps1', 'Sign-MacOsPackage.ps1', 'Build-MacOsDmg.ps1', 'Notarize-MacOsPackage.ps1', diff --git a/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj b/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj index 51af038..904ec0d 100644 --- a/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj +++ b/src/Devolutions.Terminal.Distribution/Devolutions.Terminal.Distribution.csproj @@ -24,6 +24,7 @@ diff --git a/src/Devolutions.Terminal.Distribution/README.md b/src/Devolutions.Terminal.Distribution/README.md index dea11aa..9c2e5f4 100644 --- a/src/Devolutions.Terminal.Distribution/README.md +++ b/src/Devolutions.Terminal.Distribution/README.md @@ -17,5 +17,12 @@ MSBuild targets copy only the payload matching the consuming project's `RuntimeIdentifier` into its output. Projects without a `RuntimeIdentifier` continue to receive the `win-x64` payload by default. +Signed releases and signed dry runs package the verified Windows binaries used by the MSI, not the original unsigned publish output. +macOS payloads retain the flat executable/library layout and are signed as standalone code with Developer ID, Hardened Runtime, and secure timestamps. +The macOS main executable is re-signed outside its `.app` so its signature does not depend on bundle-only `Info.plist` and resource seals. +Standalone NuGet payloads do not carry the notarized app bundle's stapled ticket; use the ZIP or DMG to distribute the notarized app. +Ordinary CI and unsigned dry runs remain credential-free; their Windows code is unsigned and macOS code is ad-hoc signed. +Native debug symbols are excluded from the runtime packages. + The command-line executable is `dt.exe` on Windows and `dt` on Linux and macOS; no `wt.exe` alias or Windows Terminal compatibility shim is installed. diff --git a/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 b/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 index 5ac3b76..fad00ec 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Build-NuGet.ps1 @@ -11,7 +11,11 @@ param( [string] $OutputDirectory, - [switch] $SkipPublish + [switch] $SkipPublish, + + [switch] $RequireWindowsSignature, + + [string] $ExpectedPublisher ) Set-StrictMode -Version Latest @@ -69,6 +73,14 @@ foreach ($runtimeIdentifier in $RuntimeIdentifiers) { throw "Published output for '$runtimeIdentifier' was not found at '$layout'." } } + + if ($RequireWindowsSignature -and $runtimeIdentifier.StartsWith("win-", [StringComparison]::Ordinal)) { + $signatureArguments = @{ PayloadDirectory = $layout } + if (-not [string]::IsNullOrWhiteSpace($ExpectedPublisher)) { + $signatureArguments.ExpectedPublisher = $ExpectedPublisher + } + & (Join-Path $PSScriptRoot "Test-WindowsPayloadSignatures.ps1") @signatureArguments + } } Get-ChildItem -LiteralPath $packageOutput -File -Filter "Devolutions.Terminal.App*.nupkg" | @@ -80,6 +92,7 @@ foreach ($runtimeIdentifier in $RuntimeIdentifiers) { "-c", $Configuration, "-p:PackageVersion=$Version", "-p:PackageOutputPath=$packageOutput\", + "-p:DevolutionsTerminalNugetLayoutRoot=$layoutRoot", "-p:DevolutionsTerminalPackageRuntimeIdentifier=$runtimeIdentifier" ) } diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 index a4ea187..b1a54e8 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Test-NuGetDistribution.ps1 @@ -5,12 +5,28 @@ param( [string] $PackageDirectory, [ValidatePattern("^\d+\.\d+\.\d+$")] - [string] $Version = "2026.3.0" + [string] $Version = "2026.3.0", + + # Validate the restored Windows native payloads in the actual consumer output. + [switch] $RequireWindowsSignature, + + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })] + [string] $ExpectedPublisher ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +if ($RequireWindowsSignature -and -not $IsWindows) { + throw "-RequireWindowsSignature requires Windows." +} + +$signatureArguments = @{} +if ($PSBoundParameters.ContainsKey("ExpectedPublisher")) { + $signatureArguments.ExpectedPublisher = $ExpectedPublisher +} + $packageSource = [IO.Path]::GetFullPath($PackageDirectory) $expectedPackageNames = @( "Devolutions.Terminal.App.$Version.nupkg" @@ -82,6 +98,11 @@ try { if ($otherPayloads.Count -ne 0) { throw "Unexpected runtime payloads were copied for '$runtimeIdentifier': $($otherPayloads.Name -join ', ')." } + + if ($RequireWindowsSignature -and $runtimeIdentifier.StartsWith("win-", [StringComparison]::Ordinal)) { + $payloadDirectory = Join-Path $outputDirectory "runtimes\$runtimeIdentifier\native\payload" + & "$PSScriptRoot\Test-WindowsPayloadSignatures.ps1" -PayloadDirectory $payloadDirectory @signatureArguments + } } @" @@ -109,6 +130,11 @@ try { if (-not (Test-Path -LiteralPath $defaultPayloadPath -PathType Leaf)) { throw "Default win-x64 package payload '$defaultPayloadPath' was not copied to the consumer output." } + + if ($RequireWindowsSignature) { + $payloadDirectory = Join-Path $testRoot "bin\Release\net10.0\runtimes\win-x64\native\payload" + & "$PSScriptRoot\Test-WindowsPayloadSignatures.ps1" -PayloadDirectory $payloadDirectory @signatureArguments + } } finally { $env:NUGET_PACKAGES = $originalNugetPackages diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 new file mode 100644 index 0000000..cb29203 --- /dev/null +++ b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignatures.ps1 @@ -0,0 +1,62 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] + [string] $PayloadDirectory, + + # Exact certificate subject for our app executables only. Third-party binaries + # may legitimately have another signer; all binaries must still be trusted. + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })] + [string] $ExpectedPublisher +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +# Inspect the native exit code ourselves, including when the caller enables this preference. +$PSNativeCommandUseErrorActionPreference = $false + +if (-not $IsWindows) { + throw "Windows payload signature validation requires Windows." +} + +$payloadPath = (Get-Item -LiteralPath $PayloadDirectory).FullName +$appExecutableNames = @("Devolutions.Terminal.exe", "dt.exe") +foreach ($name in $appExecutableNames) { + $appPath = Join-Path $payloadPath $name + if (-not (Test-Path -LiteralPath $appPath -PathType Leaf)) { + throw "Required Windows app executable '$appPath' was not found." + } +} + +Get-Command winapp -ErrorAction Stop | Out-Null + +# Include nested host layouts, not just the executables at the payload root. +$binaries = @(Get-ChildItem -LiteralPath $payloadPath -Recurse -File -Force | + Where-Object Extension -in @(".exe", ".dll") | + Sort-Object @{ Expression = { $_.Name -notin $appExecutableNames } }, FullName) + +foreach ($binary in $binaries) { + $signature = Get-AuthenticodeSignature -LiteralPath $binary.FullName + if ($signature.Status -ne "Valid" -or $null -eq $signature.SignerCertificate) { + throw "Windows payload signature validation failed for '$($binary.FullName)': Authenticode status '$($signature.Status)' (expected 'Valid'). $($signature.StatusMessage)" + } + if ($null -eq $signature.TimeStamperCertificate) { + throw "Windows payload signature validation failed for '$($binary.FullName)': timestamp certificate is missing." + } + if ($PSBoundParameters.ContainsKey("ExpectedPublisher") -and $binary.Name -in $appExecutableNames -and + $signature.SignerCertificate.Subject -cne $ExpectedPublisher) { + throw "Windows app signer for '$($binary.FullName)' is '$($signature.SignerCertificate.Subject)'; expected exact publisher '$ExpectedPublisher'." + } + + $signatureOutput = & winapp tool signtool verify /pa /all /v /tw $binary.FullName 2>&1 + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + $signatureOutput | Out-Host + throw "SignTool signature validation failed for '$($binary.FullName)' with exit code $exitCode." + } + + Write-Host "Verified trusted, timestamped Windows payload signature: $($binary.FullName)" +} + +Write-Host "Validated $($binaries.Count) Windows payload binary signatures in '$payloadPath'." diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1 new file mode 100644 index 0000000..8cc6f50 --- /dev/null +++ b/src/Devolutions.Terminal.Package/Scripts/Test-WindowsPayloadSignaturesRegression.ps1 @@ -0,0 +1,163 @@ +# Focused, dependency-free contract tests. Signature/tool responses are simulated; +# real trust and timestamp verification must also be tested with signed artifacts. +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if (-not $IsWindows) { + throw "Windows payload signature regression tests require Windows." +} + +$helperPath = Join-Path $PSScriptRoot "Test-WindowsPayloadSignatures.ps1" +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ("devolutions-terminal-signatures-" + [guid]::NewGuid()) +$appPath = Join-Path $testRoot "Devolutions.Terminal.exe" +$aliasPath = Join-Path $testRoot "dt.exe" +$libraryPath = Join-Path $testRoot "third-party.dll" +$hostPath = Join-Path $testRoot "runtimes\win-arm64\native\arm64\OpenConsole.exe" +$expectedPublisher = "CN=Fixture Publisher" +$testState = @{ + Publisher = $expectedPublisher + SignatureOverrides = @{} + SignaturePaths = [Collections.Generic.List[string]]::new() + ToolCalls = [Collections.Generic.List[object]]::new() + ToolExitCode = 0 +} + +function Get-AuthenticodeSignature { + param([string] $LiteralPath) + + $testState.SignaturePaths.Add($LiteralPath) + $subject = if ([IO.Path]::GetFileName($LiteralPath) -in @("Devolutions.Terminal.exe", "dt.exe")) { + $testState.Publisher + } else { + "CN=Third-party Publisher" + } + $signature = [pscustomobject]@{ + Status = "Valid" + StatusMessage = "Simulated signature result." + SignerCertificate = [pscustomobject]@{ Subject = $subject } + TimeStamperCertificate = [pscustomobject]@{ Subject = "CN=Timestamp Publisher" } + } + if ($testState.SignatureOverrides.ContainsKey($LiteralPath)) { + foreach ($key in $testState.SignatureOverrides[$LiteralPath].Keys) { + $signature.$key = $testState.SignatureOverrides[$LiteralPath][$key] + } + } + return $signature +} + +function winapp { + $testState.ToolCalls.Add(@($args)) + Set-Variable -Name LASTEXITCODE -Value $testState.ToolExitCode -Scope 1 + "Simulated SignTool result." +} + +function Assert-Rejected { + param( + [scriptblock] $Action, + [string] $MessagePattern + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw "Unexpected failure: $($_.Exception.Message); expected pattern '$MessagePattern'." + } + return + } + throw "Validation unexpectedly succeeded; expected pattern '$MessagePattern'." +} + +function Test-Case { + param( + [string] $Name, + [scriptblock] $Action + ) + + $testState.SignatureOverrides = @{} + $testState.SignaturePaths.Clear() + $testState.ToolCalls.Clear() + $testState.ToolExitCode = 0 + & $Action + Write-Host "PASS: $Name" +} + +try { + New-Item -ItemType Directory -Path ([IO.Path]::GetDirectoryName($hostPath)) -Force | Out-Null + foreach ($path in @($appPath, $aliasPath, $libraryPath, $hostPath)) { + [IO.File]::WriteAllBytes($path, [byte[]]@()) + } + + Test-Case "TrustedTimestampedRecursivePayload" { + & $helperPath -PayloadDirectory $testRoot -ExpectedPublisher $expectedPublisher + if ($testState.ToolCalls.Count -ne 4 -or $testState.SignaturePaths.Count -ne 4) { + throw "All four binaries, including the nested host and third-party DLL, must be checked." + } + foreach ($path in @($appPath, $aliasPath, $libraryPath, $hostPath)) { + if ($path -notin $testState.SignaturePaths) { + throw "Missing Authenticode check for '$path'." + } + $calls = @($testState.ToolCalls | Where-Object { $_[-1] -eq $path }) + if ($calls.Count -ne 1 -or ($calls[0] -join "|") -cne "tool|signtool|verify|/pa|/all|/v|/tw|$path") { + throw "Expected exactly one strict SignTool verification for '$path'." + } + } + } + foreach ($path in @($appPath, $aliasPath, $hostPath)) { + Test-Case "UnsignedBinaryRejected-$([IO.Path]::GetFileName($path))" { + $testState.SignatureOverrides[$path] = @{ Status = "NotSigned"; SignerCertificate = $null } + Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } ( + [regex]::Escape($path) + ".*Authenticode status 'NotSigned'" + ) + } + } + Test-Case "UntrustedRootRejected" { + $testState.SignatureOverrides[$appPath] = @{ Status = "NotTrusted" } + Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } "Authenticode status 'NotTrusted'" + } + Test-Case "MissingTimestampRejected" { + $testState.SignatureOverrides[$aliasPath] = @{ TimeStamperCertificate = $null } + Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } ( + [regex]::Escape($aliasPath) + ".*timestamp certificate is missing" + ) + } + Test-Case "PublisherMismatchRejected" { + $testState.SignatureOverrides[$aliasPath] = @{ + SignerCertificate = [pscustomobject]@{ Subject = "CN=Wrong Publisher" } + } + Assert-Rejected { & $helperPath -PayloadDirectory $testRoot -ExpectedPublisher $expectedPublisher } ( + [regex]::Escape($aliasPath) + ".*expected exact publisher" + ) + } + Test-Case "PublisherMatchIsCaseSensitive" { + Assert-Rejected { & $helperPath -PayloadDirectory $testRoot -ExpectedPublisher $expectedPublisher.ToLowerInvariant() } ( + "expected exact publisher" + ) + } + Test-Case "SignToolFailurePropagates" { + $PSNativeCommandUseErrorActionPreference = $true + $testState.ToolExitCode = 23 + Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } "SignTool signature validation failed.*exit code 23" + } + foreach ($path in @($appPath, $aliasPath)) { + Test-Case "MissingAppRejected-$([IO.Path]::GetFileName($path))" { + Remove-Item -LiteralPath $path + try { + Assert-Rejected { & $helperPath -PayloadDirectory $testRoot } ( + "Required Windows app executable '" + [regex]::Escape($path) + "' was not found" + ) + } + finally { + [IO.File]::WriteAllBytes($path, [byte[]]@()) + } + } + } + Write-Host "All 11 Windows payload signature regression cases passed." +} +finally { + Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue +} From e5a81a677e8b5e34f89bf4e46fe78eb2a525f5f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 30 Sep 2026 15:29:14 -0400 Subject: [PATCH 3/4] Build the signing fixture for the tested macOS architecture macOS26 system tools are universal x86_64/arm64e, not arm64. Compile the tiny fixture for the actual test RID instead of accepting the wrong architecture or weakening payload validation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Test-MacOsNuGetSigning.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/Test-MacOsNuGetSigning.ps1 b/scripts/Test-MacOsNuGetSigning.ps1 index 11253df..b96c867 100644 --- a/scripts/Test-MacOsNuGetSigning.ps1 +++ b/scripts/Test-MacOsNuGetSigning.ps1 @@ -7,7 +7,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force Assert-Darwin -Assert-Command -Name 'codesign', 'cp' +Assert-Command -Name 'codesign', 'cp', 'clang' $metadata = Import-MacOsPackageEnv -Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'macos/package.env') $rid = if ((& uname -m) -eq 'arm64') { 'osx-arm64' } else { 'osx-x64' } $work = Join-Path ([IO.Path]::GetTempPath()) "macos-nuget-signing-$([guid]::NewGuid().ToString('N'))" @@ -16,11 +16,15 @@ $code = Join-Path $app 'Contents/MacOS' $resources = Join-Path $app 'Contents/Resources' try { New-Item -ItemType Directory -Path $code, $resources -Force | Out-Null + $fixtureSource = Join-Path $work 'fixture.c' + $fixtureBinary = Join-Path $work 'fixture' + Set-Content $fixtureSource 'int main(void) { return 0; }' -NoNewline + Invoke-Native -FilePath clang -ArgumentList '-arch', (Get-MacOsExpectedArch -Rid $rid), $fixtureSource, '-o', $fixtureBinary foreach ($name in @( $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' )) { - Invoke-Native -FilePath cp -ArgumentList '/usr/bin/true', (Join-Path $code $name) + Invoke-Native -FilePath cp -ArgumentList $fixtureBinary, (Join-Path $code $name) } @" From 422a6f9273f48824ac9fdef33e6f442fdb54a64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 30 Sep 2026 15:41:00 -0400 Subject: [PATCH 4/4] Request XML when checking macOS runtime entitlements Recent codesign versions default to a human-readable dictionary dump. Ask for XML explicitly so the native NuGet signature gate can validate required entitlements without loosening its assertions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- scripts/Test-MacOsNuGetPayload.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Test-MacOsNuGetPayload.ps1 b/scripts/Test-MacOsNuGetPayload.ps1 index 81ada83..5a1b283 100644 --- a/scripts/Test-MacOsNuGetPayload.ps1 +++ b/scripts/Test-MacOsNuGetPayload.ps1 @@ -34,7 +34,7 @@ foreach ($name in $binaries) { } Assert-MacOsCodeSignature -Path $path @requirements if ($name -in @($metadata.EXECUTABLE_NAME, $metadata.CLI_NAME)) { - $entitlementText = (& codesign --display --entitlements - $path 2>$null) -join "`n" + $entitlementText = (& codesign --display --entitlements - --xml $path 2>$null) -join "`n" if ($LASTEXITCODE -ne 0) { throw "Unable to inspect $name entitlements." } [xml]$entitlements = $entitlementText foreach ($key in @(