diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index 1a1d173..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 @@ -162,6 +166,12 @@ 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 + + - 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 @@ -319,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 @@ -382,6 +395,36 @@ 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: 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 @@ -518,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: @@ -899,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: @@ -914,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 @@ -938,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 @@ -971,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 @@ -1017,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. @@ -1045,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 @@ -1123,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: > @@ -1171,6 +1353,7 @@ jobs: - msix - msi - nuget + - nuget-macos - nuget-pack - release-metadata runs-on: ubuntu-latest 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..1da94eb 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -65,6 +65,13 @@ 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. +`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 708def0..0a549f8 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. @@ -301,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/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/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-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-MacOsNuGetPayload.ps1 b/scripts/Test-MacOsNuGetPayload.ps1 new file mode 100644 index 0000000..5a1b283 --- /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 - --xml $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..b96c867 --- /dev/null +++ b/scripts/Test-MacOsNuGetSigning.ps1 @@ -0,0 +1,65 @@ +#!/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', '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'))" +$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 + $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 $fixtureBinary, (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-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/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.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/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 +} 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