diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-07-25 02:40:14 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-25 02:40:14 +0300 |
| commit | 8afd52ae57f9237f4f46138ad224dfc621d7ba59 (patch) | |
| tree | 349bbbd86c57863539ebc4c2eacf34550e076fa2 | |
| parent | 5f1957c27bd23f73b339265d3acd32f4a21878a3 (diff) | |
fix: bundle modern ConPTY on Windows (#1828)
refs #1533
refs #1644
32 files changed, 2694 insertions, 133 deletions
diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..64835a8d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +packaging/windows/licenses/* binary diff --git a/.github/workflows/build-artifacts-manual.yml b/.github/workflows/build-artifacts-manual.yml index 3896138c..7fc2e9b8 100644 --- a/.github/workflows/build-artifacts-manual.yml +++ b/.github/workflows/build-artifacts-manual.yml @@ -224,19 +224,26 @@ jobs: - name: Package artifact shell: pwsh run: | - Copy-Item target\x86_64-pc-windows-msvc\release\herdr.exe herdr-windows-x86_64.exe + $ErrorActionPreference = "Stop" + $package = Join-Path $env:RUNNER_TEMP "Microsoft.Windows.Console.ConPTY.nupkg" + $stage = Join-Path $env:RUNNER_TEMP "herdr-windows-x86_64" + .\scripts\package_windows_conpty.ps1 ` + -HerdrExe target\x86_64-pc-windows-msvc\release\herdr.exe ` + -PackagePath $package ` + -StageDir $stage ` + -OutputPath herdr-windows-x86_64.zip "commit=$env:GITHUB_SHA" | Out-File -Encoding utf8 BUILD_INFO.txt "target=x86_64-pc-windows-msvc" | Out-File -Encoding utf8 -Append BUILD_INFO.txt "libghostty_vt_optimize=$env:LIBGHOSTTY_VT_OPTIMIZE" | Out-File -Encoding utf8 -Append BUILD_INFO.txt "libghostty_vt_simd=$env:LIBGHOSTTY_VT_SIMD" | Out-File -Encoding utf8 -Append BUILD_INFO.txt - $hash = (Get-FileHash -Algorithm SHA256 herdr-windows-x86_64.exe).Hash.ToLowerInvariant() - "sha256=$hash herdr-windows-x86_64.exe" | Out-File -Encoding utf8 -Append BUILD_INFO.txt + $hash = (Get-FileHash -Algorithm SHA256 herdr-windows-x86_64.zip).Hash.ToLowerInvariant() + "sha256=$hash herdr-windows-x86_64.zip" | Out-File -Encoding utf8 -Append BUILD_INFO.txt - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: herdr-windows-x86_64-${{ inputs.libghostty_optimize }}-simd-${{ inputs.libghostty_simd }} path: | - herdr-windows-x86_64.exe + herdr-windows-x86_64.zip BUILD_INFO.txt retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc715802..458c92c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,3 +173,106 @@ jobs: $ErrorActionPreference = "Stop" $exe = Join-Path $PWD "target\x86_64-pc-windows-msvc\debug\herdr.exe" .\scripts\windows_smoke_conpty_path.ps1 -ExePath $exe -Session "ci-windows-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + + windows-conpty-package: + name: Windows ConPTY package + runs-on: windows-2022 + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1 + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + targets: x86_64-pc-windows-msvc + + - name: Install Zig + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 + with: + version: 0.15.2 + use-cache: false + + - name: Restore Cargo cache + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + cache-bin: false + key: conpty-package-windows-2022 + + - name: Build Herdr + run: cargo build --locked --target x86_64-pc-windows-msvc + + - name: Verify invalid bundle is rejected and system override recovers + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + Remove-Item Env:HERDR_SOCKET_PATH, Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue + $exe = Join-Path $PWD "target\x86_64-pc-windows-msvc\debug\herdr.exe" + $bundle = Join-Path (Split-Path -Parent $exe) "conpty" + New-Item -ItemType Directory -Force -Path (Join-Path $bundle "x64"), (Join-Path $bundle "arm64") | Out-Null + Set-Content -LiteralPath (Join-Path $bundle "herdr-conpty.json") -Value "{}" -Encoding ascii + [System.IO.File]::WriteAllBytes((Join-Path $bundle "conpty.dll"), [byte[]](0x48, 0x45, 0x52, 0x44, 0x52)) + [System.IO.File]::WriteAllBytes((Join-Path $bundle "x64\OpenConsole.exe"), [byte[]](0x48, 0x45, 0x52, 0x44, 0x52)) + [System.IO.File]::WriteAllBytes((Join-Path $bundle "arm64\OpenConsole.exe"), [byte[]](0x48, 0x45, 0x52, 0x44, 0x52)) + try { + $rejected = $false + try { + .\scripts\windows_smoke_conpty_path.ps1 ` + -ExePath $exe ` + -Session "ci-conpty-invalid-windows-2022-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + } catch { + if ($_.Exception.Message -notlike "workspace create failed with exit code*") { + throw + } + $rejected = $true + } + if (-not $rejected) { + throw "Herdr accepted a tampered app-local ConPTY bundle" + } + + $env:HERDR_WINDOWS_CONPTY = "system" + .\scripts\windows_smoke_conpty_path.ps1 ` + -ExePath $exe ` + -Session "ci-conpty-system-windows-2022-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + } finally { + Remove-Item Env:HERDR_WINDOWS_CONPTY -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $bundle -Recurse -Force -ErrorAction SilentlyContinue + } + + - name: Build and verify official ConPTY package + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $package = Join-Path $env:RUNNER_TEMP "Microsoft.Windows.Console.ConPTY.nupkg" + $stage = Join-Path $env:RUNNER_TEMP "herdr-windows-x86_64" + New-Item -ItemType Directory -Force -Path artifacts | Out-Null + .\scripts\package_windows_conpty.ps1 ` + -HerdrExe target\x86_64-pc-windows-msvc\debug\herdr.exe ` + -PackagePath $package ` + -StageDir $stage ` + -OutputPath artifacts\herdr-windows-x86_64.zip + "HERDR_CONPTY_PACKAGE_DIR=$stage" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: Probe enhanced pane input with bundled ConPTY + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $exe = Join-Path $env:HERDR_CONPTY_PACKAGE_DIR "herdr.exe" + $consoleHost = Join-Path $env:HERDR_CONPTY_PACKAGE_DIR "conpty\x64\OpenConsole.exe" + .\scripts\windows_conpty_enhanced_input_probe.ps1 ` + -ExePath $exe ` + -Session "ci-conpty-bundled-windows-2022-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" ` + -ExpectedConsoleHostPath $consoleHost + + - name: Test packaged installer and repair with Windows PowerShell 5.1 + shell: pwsh + run: | + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File ` + .\scripts\windows_install_conpty_package_test.ps1 ` + -ArchivePath artifacts\herdr-windows-x86_64.zip + if ($LASTEXITCODE -ne 0) { + throw "Windows PowerShell installer test failed with exit code $LASTEXITCODE" + } diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 370028fe..6704505b 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -139,7 +139,7 @@ jobs: name: herdr-macos-aarch64 - target: x86_64-pc-windows-msvc os: windows-latest - name: herdr-windows-x86_64.exe + name: herdr-windows-x86_64.zip runs-on: ${{ matrix.os }} env: LIBGHOSTTY_VT_OPTIMIZE: ReleaseFast @@ -258,7 +258,14 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - Copy-Item target\${{ matrix.target }}\release\herdr.exe ${{ matrix.name }} + $ErrorActionPreference = "Stop" + $package = Join-Path $env:RUNNER_TEMP "Microsoft.Windows.Console.ConPTY.nupkg" + $stage = Join-Path $env:RUNNER_TEMP "herdr-windows-x86_64" + .\scripts\package_windows_conpty.ps1 ` + -HerdrExe target\${{ matrix.target }}\release\herdr.exe ` + -PackagePath $package ` + -StageDir $stage ` + -OutputPath ${{ matrix.name }} $hash = (Get-FileHash -Algorithm SHA256 ${{ matrix.name }}).Hash.ToLowerInvariant() "$hash ${{ matrix.name }}" | Out-File -Encoding ascii ${{ matrix.name }}.sha256 "commit=${{ needs.preflight.outputs.commit }}" | Out-File -Encoding utf8 BUILD_INFO.txt @@ -324,7 +331,7 @@ jobs: result = {} for path in pathlib.Path('artifacts').glob('herdr-*/*.sha256'): digest, name = path.read_text().split()[:2] - target = name.removeprefix('herdr-').removesuffix('.exe') + target = name.removeprefix('herdr-').removesuffix('.exe').removesuffix('.zip') result[target] = digest pathlib.Path('preview-sha256.json').write_text(json.dumps(result, indent=2) + '\n') PY @@ -344,7 +351,7 @@ jobs: artifacts/herdr-linux-aarch64/herdr-linux-aarch64 artifacts/herdr-macos-x86_64/herdr-macos-x86_64 artifacts/herdr-macos-aarch64/herdr-macos-aarch64 - artifacts/herdr-windows-x86_64.exe/herdr-windows-x86_64.exe + artifacts/herdr-windows-x86_64.zip/herdr-windows-x86_64.zip - name: Update preview manifest run: | @@ -1126,7 +1126,7 @@ dependencies = [ "log", "nix 0.28.0", "serial2", - "shared_library", + "sha2", "shell-words", "winapi", "winreg", @@ -1518,16 +1518,6 @@ dependencies = [ ] [[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] name = "shell-words" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/docs/next/website/src/content/docs/install.mdx b/docs/next/website/src/content/docs/install.mdx index 5de3677d..3312397d 100644 --- a/docs/next/website/src/content/docs/install.mdx +++ b/docs/next/website/src/content/docs/install.mdx @@ -96,7 +96,9 @@ Windows binaries are published only on preview releases while native Windows sup | System | Asset | | --- | --- | -| Windows x86_64 beta | `herdr-windows-x86_64.exe` | +| Windows x86_64 beta | `herdr-windows-x86_64.zip` | + +The archive contains `herdr.exe` and its app-local ConPTY runtime. Keep the extracted directory together; do not copy only `herdr.exe`. ## Verify diff --git a/docs/next/website/src/content/docs/ja/install.mdx b/docs/next/website/src/content/docs/ja/install.mdx index 2947034a..de37c9ca 100644 --- a/docs/next/website/src/content/docs/ja/install.mdx +++ b/docs/next/website/src/content/docs/ja/install.mdx @@ -96,7 +96,9 @@ mv herdr-linux-x86_64 ~/.local/bin/herdr | システム | アセット | | --- | --- | -| Windows x86_64 ベータ | `herdr-windows-x86_64.exe` | +| Windows x86_64 ベータ | `herdr-windows-x86_64.zip` | + +アーカイブには `herdr.exe` とアプリローカルの ConPTY ランタイムが含まれます。展開したディレクトリをまとめて保持し、`herdr.exe` だけをコピーしないでください。 ## 動作確認 diff --git a/docs/next/website/src/content/docs/ja/windows-beta.mdx b/docs/next/website/src/content/docs/ja/windows-beta.mdx index fa526872..ee0f8567 100644 --- a/docs/next/website/src/content/docs/ja/windows-beta.mdx +++ b/docs/next/website/src/content/docs/ja/windows-beta.mdx @@ -79,6 +79,8 @@ host_cursor = "native" Windows のターミナルは、修飾キー付きのキーをすべて同じ形で報告するわけではありません。Herdr は Windows Terminal と Windows 上の Alacritty ではマウス報告と `ctrl+j` を保持しますが、`shift+enter` は外側ターミナルが修飾付き Enter として区別して報告する場合にだけ動作します。Windows またはターミナルがそれを通常の Enter として報告する場合、Herdr は通常の Enter を転送します。 +古い Windows 10 ビルドのシステム ConPTY は、Kimi や Pi などのエージェントが使用する Kitty キーボードプロトコルのシーケンスを失うため、プレビューパッケージには Microsoft の最新のアプリローカル ConPTY ランタイムが含まれます。バンドル版ランタイムとの互換性問題を診断する場合に限り、Herdr を起動する前に `HERDR_WINDOWS_CONPTY=system` を設定してください。 + ## コピーと貼り付け Herdr のペインテキストコピーは Windows ベータで動作します。ペイン内でテキストをドラッグ選択すると Herdr 経由でコピーされます。 diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx index 696a77be..6533ca6f 100644 --- a/docs/next/website/src/content/docs/windows-beta.mdx +++ b/docs/next/website/src/content/docs/windows-beta.mdx @@ -79,6 +79,8 @@ Native mode restores the IME anchor, but it can reintroduce occasional cursor fl Windows terminals do not all report modified keys in the same shape. Herdr preserves mouse reporting and `ctrl+j` in Windows Terminal and Alacritty on Windows, but `shift+enter` only works when the outer terminal reports it as a distinct modified Enter key. If Windows or the terminal reports it as plain Enter, Herdr forwards plain Enter. +Preview packages include Microsoft's current app-local ConPTY runtime because the system ConPTY on older Windows 10 builds drops Kitty keyboard protocol sequences used by agents such as Kimi and Pi. Set `HERDR_WINDOWS_CONPTY=system` before starting Herdr only when diagnosing a compatibility problem with the bundled runtime. + ## Copy and paste Herdr's pane text copy works on Windows beta. Drag-select text inside a pane to copy through Herdr. diff --git a/docs/next/website/src/content/docs/zh-cn/install.mdx b/docs/next/website/src/content/docs/zh-cn/install.mdx index 60a8f8bd..3a66ab3b 100644 --- a/docs/next/website/src/content/docs/zh-cn/install.mdx +++ b/docs/next/website/src/content/docs/zh-cn/install.mdx @@ -96,7 +96,9 @@ mv herdr-linux-x86_64 ~/.local/bin/herdr | 系统 | 产物 | | --- | --- | -| Windows x86_64 测试版 | `herdr-windows-x86_64.exe` | +| Windows x86_64 测试版 | `herdr-windows-x86_64.zip` | + +压缩包包含 `herdr.exe` 及其应用本地 ConPTY 运行时。请保留完整的解压目录,不要只复制 `herdr.exe`。 ## 验证 diff --git a/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx b/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx index 5e922c3a..781a6f96 100644 --- a/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx +++ b/docs/next/website/src/content/docs/zh-cn/windows-beta.mdx @@ -79,6 +79,8 @@ host_cursor = "native" Windows 终端并不都以相同形式报告带修饰键的按键。Herdr 在 Windows Terminal 和 Windows 上的 Alacritty 中保留鼠标报告和 `ctrl+j`,但 `shift+enter` 只有在外层终端把它报告为独立的修饰 Enter 键时才有效。如果 Windows 或终端把它报告为普通 Enter,Herdr 会转发普通 Enter。 +旧版 Windows 10 的系统 ConPTY 会丢弃 Kimi 和 Pi 等智能体使用的 Kitty 键盘协议序列,因此预览包包含 Microsoft 当前的应用本地 ConPTY 运行时。仅在诊断捆绑运行时的兼容性问题时,才应在启动 Herdr 前设置 `HERDR_WINDOWS_CONPTY=system`。 + ## 复制与粘贴 Herdr 的窗格文本复制在 Windows 测试版上可用。在窗格内拖选文本即可通过 Herdr 复制。 @@ -3,7 +3,7 @@ # Run tests test: cargo nextest run --locked --status-level fail --final-status-level fail --failure-output final --success-output never - python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty + python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty just integration-assets-test just plugin-marketplace-test @@ -29,7 +29,7 @@ windows-lint: # Check formatting + run unit tests + Windows target lint + maintenance script tests check: ci windows-lint - python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty + python3 -m unittest scripts.test_agent_detection_manifest_check scripts.test_changelog scripts.test_config_reference_check scripts.test_docs_translation_parity scripts.test_hermes_integration_asset scripts.test_package_windows_conpty scripts.test_preview scripts.test_vendor_libghostty_vt scripts.test_vendor_portable_pty @echo "docs reminder: if this changes user-facing behavior, make sure the relevant release docs are updated or called out before release." # Install repo-local git hooks diff --git a/packaging/windows/conpty.json b/packaging/windows/conpty.json new file mode 100644 index 00000000..9bb77473 --- /dev/null +++ b/packaging/windows/conpty.json @@ -0,0 +1,48 @@ +{ + "schema_version": 1, + "package": { + "id": "Microsoft.Windows.Console.ConPTY", + "version": "1.24.260710001", + "release_tag": "v1.24.11911.0", + "url": "https://github.com/microsoft/terminal/releases/download/v1.24.11911.0/Microsoft.Windows.Console.ConPTY.1.24.260710001.nupkg", + "sha256": "9382ad7becb7e4d84e300578d8e4f4df28f43d979d9055d978c42913c47e0e9d", + "license": "MIT" + }, + "bundles": { + "x86_64": { + "target_triple": "x86_64-pc-windows-msvc", + "files": [ + { + "source": "runtimes/win-x64/native/conpty.dll", + "destination": "conpty/conpty.dll", + "sha256": "39fba2713e2495117b1591ae8c32a3b904bea7aa66069cf7815e2844c76d75d8", + "pe_machine": "0x8664" + }, + { + "source": "build/native/runtimes/x64/OpenConsole.exe", + "destination": "conpty/x64/OpenConsole.exe", + "sha256": "b7fd936c2668b87b9ecf7b3366dc6568afc1c6f981874cba3e955a1c35cf8160", + "pe_machine": "0x8664" + }, + { + "source": "build/native/runtimes/arm64/OpenConsole.exe", + "destination": "conpty/arm64/OpenConsole.exe", + "sha256": "ed7622fd0d3bedc9ab9f122f5e58edf0def9e7999224f52dd395ba9f54edbe09", + "pe_machine": "0xaa64" + } + ] + } + }, + "notices": [ + { + "source": "packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-LICENSE.txt", + "destination": "THIRD-PARTY-NOTICES/Microsoft.Windows.Console.ConPTY-LICENSE.txt", + "sha256": "5d177f23ecfeb0ea8e050b6a5a16355e1ae9a0b286436ca8f83ed08b3795be6b" + }, + { + "source": "packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-NOTICE.md", + "destination": "THIRD-PARTY-NOTICES/Microsoft.Windows.Console.ConPTY-NOTICE.md", + "sha256": "e7fbaadee6ab20c28b87730a510ee5f5815d8fb4bd88d1d54d282dc2a74c0726" + } + ] +} diff --git a/packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-LICENSE.txt b/packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-LICENSE.txt new file mode 100644 index 00000000..017b9885 --- /dev/null +++ b/packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. All rights reserved.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-NOTICE.md b/packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-NOTICE.md new file mode 100644 index 00000000..efe962bc --- /dev/null +++ b/packaging/windows/licenses/Microsoft.Windows.Console.ConPTY-NOTICE.md @@ -0,0 +1,535 @@ +# NOTICES AND INFORMATION
+Do Not Translate or Localize
+
+This software incorporates material from third parties. Microsoft makes certain
+open source code available at [http://3rdpartysource.microsoft.com](http://3rdpartysource.microsoft.com), or you may
+send a check or money order for US $5.00, including the product name, the open
+source component name, and version number, to:
+
+```
+Source Code Compliance Team
+Microsoft Corporation
+One Microsoft Way
+Redmond, WA 98052
+USA
+```
+
+Notwithstanding any other terms, you may reverse engineer this software to the
+extent required to debug changes to any libraries licensed under the GNU Lesser
+General Public License.
+
+## jsoncpp
+
+**Source**: [https://github.com/open-source-parsers/jsoncpp](https://github.com/open-source-parsers/jsoncpp)
+
+### License
+
+```
+Copyright (c) 2007-2010 Baptiste Lepilleur and The JsonCpp Authors
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use, copy,
+modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## chromium/base/numerics
+
+**Source**: [https://github.com/chromium/chromium/tree/master/base/numerics](https://github.com/chromium/chromium/tree/master/base/numerics)
+
+### License
+
+```
+Copyright 2015 The Chromium Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+## \{fmt\}
+
+**Source**: [https://github.com/fmtlib/fmt](https://github.com/fmtlib/fmt)
+
+### License
+
+```
+MIT License
+
+Copyright (c) 2012 - present, Victor Zverovich
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+--- Optional exception to the license ---
+
+As an exception, if, as a result of your compiling your source code, portions
+of this Software are embedded into a machine-executable object form of such
+source code, you may redistribute such embedded portions in such object form
+without including the above copyright and permission notices.
+```
+
+## interval_tree
+
+**Source**: [https://github.com/ekg/IntervalTree](https://github.com/ekg/IntervalTree)
+
+### License
+
+```
+Copyright (c) 2011 Erik Garrison
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## PCG Random Number Generation
+
+**Source**: [https://github.com/imneme/pcg-cpp](https://github.com/imneme/pcg-cpp)
+
+### License
+
+```
+Copyright (c) 2014-2017 Melissa O'Neill and PCG Project contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## wyhash
+
+**Source**: [https://github.com/wangyi-fudan/wyhash](https://github.com/wangyi-fudan/wyhash)
+
+### License
+
+```
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <http://unlicense.org/>
+```
+
+## stb
+
+**Source**: [https://github.com/nothings/stb](https://github.com/nothings/stb)
+
+### License
+
+```
+This is free and unencumbered software released into the public domain.
+Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
+software, either in source code form or as a compiled binary, for any purpose,
+commercial or non-commercial, and by any means.
+In jurisdictions that recognize copyright laws, the author or authors of this
+software dedicate any and all copyright interest in the software to the public
+domain. We make this dedication for the benefit of the public at large and to
+the detriment of our heirs and successors. We intend this dedication to be an
+overt act of relinquishment in perpetuity of all present and future rights to
+this software under copyright law.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
+
+## Oklab
+**Source**: [https://bottosson.github.io/posts/oklab/](https://bottosson.github.io/posts/oklab/)
+
+### License
+
+```
+Copyright (c) 2020 Björn Ottosson
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## ColorBrewer
+**Source**: [https://colorbrewer2.org/](https://colorbrewer2.org/)
+
+### License
+
+```
+Apache-Style Software License for ColorBrewer software and ColorBrewer Color Schemes
+
+Copyright (c) 2002 Cynthia Brewer, Mark Harrower, and The Pennsylvania State University.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+```
+
+## cmark
+**Source**: [https://github.com/commonmark/cmark](https://github.com/commonmark/cmark)
+
+### License
+
+```
+Copyright (c) 2014, John MacFarlane
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+-----
+
+houdini.h, houdini_href_e.c, houdini_html_e.c, houdini_html_u.c
+
+derive from https://github.com/vmg/houdini (with some modifications)
+
+Copyright (C) 2012 Vicent Martí
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+-----
+
+buffer.h, buffer.c, chunk.h
+
+are derived from code (C) 2012 Github, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+-----
+
+utf8.c and utf8.c
+
+are derived from utf8proc
+(<http://www.public-software-group.org/utf8proc>),
+(C) 2009 Public Software Group e. V., Berlin, Germany.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of this software and associated documentation files (the "Software"),
+to deal in the Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, sublicense,
+and/or sell copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+-----
+
+The normalization code in normalize.py was derived from the
+markdowntest project, Copyright 2013 Karl Dubost:
+
+The MIT License (MIT)
+
+Copyright (c) 2013 Karl Dubost
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+-----
+
+The CommonMark spec (test/spec.txt) is
+
+Copyright (C) 2014-15 John MacFarlane
+
+Released under the Creative Commons CC-BY-SA 4.0 license:
+<http://creativecommons.org/licenses/by-sa/4.0/>.
+
+-----
+
+The test software in test/ is
+
+Copyright (c) 2014, John MacFarlane
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+## fzf
+
+### License
+
+```
+The MIT License (MIT)
+
+Copyright (c) 2013-2024 Junegunn Choi
+Copyright (c) 2021-2025 Simon Hauser
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+```
+
+# Microsoft Open Source
+
+This product also incorporates source code from other Microsoft open source projects, all licensed under the MIT license.
+
+## `GSL`
+
+**Source**: [https://github.com/microsoft/GSL](https://github.com/microsoft/GSL)
+
+## `Microsoft-UI-XAML`
+
+**Source**: [https://github.com/microsoft/Microsoft-UI-XAML](https://github.com/microsoft/Microsoft-UI-XAML)
+
+## `VirtualDesktopUtils`
+
+**Source**: [https://github.com/microsoft/PowerToys](https://github.com/microsoft/PowerToys)
+
+## `wil`
+
+**Source**: [https://github.com/microsoft/wil](https://github.com/microsoft/wil)
+
+### License
+
+```
+The MIT License
+
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+```
diff --git a/scripts/package_windows_conpty.ps1 b/scripts/package_windows_conpty.ps1 new file mode 100644 index 00000000..a94b609e --- /dev/null +++ b/scripts/package_windows_conpty.ps1 @@ -0,0 +1,53 @@ +param( + [Parameter(Mandatory = $true)] + [string]$HerdrExe, + + [Parameter(Mandatory = $true)] + [string]$PackagePath, + + [Parameter(Mandatory = $true)] + [string]$StageDir, + + [Parameter(Mandatory = $true)] + [string]$OutputPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Invoke-NativeChecked { + param( + [string]$Command, + [string[]]$Arguments + ) + + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "$Command failed with exit code $LASTEXITCODE" + } +} + +$packager = Join-Path $PSScriptRoot "package_windows_conpty.py" +Invoke-NativeChecked python @( + $packager, + "stage", + "--package", $PackagePath, + "--herdr-exe", $HerdrExe, + "--output-dir", $StageDir +) +Invoke-NativeChecked dotnet @("nuget", "verify", "--all", $PackagePath) + +foreach ($relative in @("conpty\conpty.dll", "conpty\x64\OpenConsole.exe", "conpty\arm64\OpenConsole.exe")) { + $signature = Get-AuthenticodeSignature (Join-Path $StageDir $relative) + $subject = if ($null -eq $signature.SignerCertificate) { "" } else { $signature.SignerCertificate.Subject } + if ($signature.Status -ne "Valid" -or $subject -notlike "*Microsoft Corporation*") { + throw "Invalid Microsoft signature for $relative`: $($signature.Status) $subject" + } +} + +Invoke-NativeChecked python @( + $packager, + "archive", + "--stage-dir", $StageDir, + "--output", $OutputPath +) diff --git a/scripts/package_windows_conpty.py b/scripts/package_windows_conpty.py new file mode 100644 index 00000000..2fc51a1e --- /dev/null +++ b/scripts/package_windows_conpty.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import struct +import tempfile +import urllib.request +import xml.etree.ElementTree as ET +import zipfile +from pathlib import Path, PurePosixPath +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_METADATA = PROJECT_ROOT / "packaging" / "windows" / "conpty.json" +MARKER_PATH = PurePosixPath("conpty/herdr-conpty.json") +DOWNLOAD_TIMEOUT_SECONDS = 60 + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def load_metadata(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if data.get("schema_version") != 1: + raise ValueError("unsupported ConPTY metadata schema") + return data + + +def pe_machine(data: bytes) -> int: + if len(data) < 0x40 or data[:2] != b"MZ": + raise ValueError("file is not a PE image") + pe_offset = struct.unpack_from("<I", data, 0x3C)[0] + if pe_offset + 6 > len(data) or data[pe_offset : pe_offset + 4] != b"PE\0\0": + raise ValueError("file has an invalid PE header") + return struct.unpack_from("<H", data, pe_offset + 4)[0] + + +def validate_nuspec(archive: zipfile.ZipFile, package: dict[str, Any]) -> None: + nuspec_name = f"{package['id']}.nuspec" + try: + root = ET.fromstring(archive.read(nuspec_name).decode("utf-8-sig")) + except KeyError as error: + raise ValueError(f"package is missing {nuspec_name}") from error + namespace = {"n": "http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"} + metadata = root.find("n:metadata", namespace) + if metadata is None: + raise ValueError("package NuSpec is missing metadata") + expected = { + "id": package["id"], + "version": package["version"], + "license": package["license"], + } + for field, expected_value in expected.items(): + element = metadata.find(f"n:{field}", namespace) + actual = element.text.strip() if element is not None and element.text else None + if actual != expected_value: + raise ValueError( + f"package NuSpec {field} mismatch: expected {expected_value!r}, got {actual!r}" + ) + + +def acquire_package(package: dict[str, Any], package_path: Path) -> None: + package_path.parent.mkdir(parents=True, exist_ok=True) + if not package_path.exists(): + with urllib.request.urlopen( + package["url"], timeout=DOWNLOAD_TIMEOUT_SECONDS + ) as response, package_path.open("wb") as output: + shutil.copyfileobj(response, output) + actual = sha256_file(package_path) + if actual != package["sha256"]: + raise ValueError( + f"ConPTY package hash mismatch: expected {package['sha256']}, got {actual}" + ) + + +def marker_data(metadata: dict[str, Any], architecture: str) -> bytes: + bundle = metadata["bundles"][architecture] + marker = { + "schema_version": 1, + "package": metadata["package"]["id"], + "version": metadata["package"]["version"], + "architecture": architecture, + "files": { + item["destination"]: item["sha256"] for item in bundle["files"] + }, + } + return (json.dumps(marker, indent=2, sort_keys=True) + "\n").encode() + + +def stage_bundle( + metadata_path: Path, + architecture: str, + package_path: Path, + herdr_exe: Path, + output_dir: Path, +) -> None: + metadata = load_metadata(metadata_path) + if architecture not in metadata["bundles"]: + raise ValueError(f"unsupported Windows architecture: {architecture}") + if output_dir.exists(): + raise ValueError(f"output directory already exists: {output_dir}") + if not herdr_exe.is_file(): + raise ValueError(f"Herdr executable does not exist: {herdr_exe}") + + acquire_package(metadata["package"], package_path) + bundle = metadata["bundles"][architecture] + metadata_root = metadata_path.resolve().parent + + with zipfile.ZipFile(package_path) as archive, tempfile.TemporaryDirectory( + prefix="herdr-conpty-stage-", dir=output_dir.parent + ) as temporary: + validate_nuspec(archive, metadata["package"]) + staging = Path(temporary) / "bundle" + staging.mkdir() + shutil.copy2(herdr_exe, staging / "herdr.exe") + + for item in bundle["files"]: + try: + payload = archive.read(item["source"]) + except KeyError as error: + raise ValueError(f"package is missing {item['source']}") from error + actual_hash = sha256_bytes(payload) + if actual_hash != item["sha256"]: + raise ValueError( + f"hash mismatch for {item['source']}: expected {item['sha256']}, got {actual_hash}" + ) + actual_machine = pe_machine(payload) + expected_machine = int(item["pe_machine"], 16) + if actual_machine != expected_machine: + raise ValueError( + f"PE machine mismatch for {item['source']}: " + f"expected {item['pe_machine']}, got 0x{actual_machine:04x}" + ) + destination = staging / PurePosixPath(item["destination"]) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(payload) + + marker = staging / MARKER_PATH + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_bytes(marker_data(metadata, architecture)) + + for notice in metadata["notices"]: + source = (metadata_root.parent.parent / notice["source"]).resolve() + actual_hash = sha256_file(source) + if actual_hash != notice["sha256"]: + raise ValueError( + f"notice hash mismatch for {source}: expected {notice['sha256']}, got {actual_hash}" + ) + destination = staging / PurePosixPath(notice["destination"]) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + staging.rename(output_dir) + + +def expected_stage_files(metadata: dict[str, Any], architecture: str) -> set[str]: + files = {"herdr.exe", MARKER_PATH.as_posix()} + files.update(item["destination"] for item in metadata["bundles"][architecture]["files"]) + files.update(item["destination"] for item in metadata["notices"]) + return files + + +def validate_stage(metadata_path: Path, architecture: str, stage_dir: Path) -> None: + metadata = load_metadata(metadata_path) + actual = { + path.relative_to(stage_dir).as_posix() + for path in stage_dir.rglob("*") + if path.is_file() + } + expected = expected_stage_files(metadata, architecture) + if actual != expected: + raise ValueError( + f"bundle layout mismatch; missing={sorted(expected - actual)}, unexpected={sorted(actual - expected)}" + ) + if (stage_dir / MARKER_PATH).read_bytes() != marker_data(metadata, architecture): + raise ValueError("bundle marker does not match pinned ConPTY metadata") + for item in metadata["bundles"][architecture]["files"]: + path = stage_dir / PurePosixPath(item["destination"]) + actual_hash = sha256_file(path) + if actual_hash != item["sha256"]: + raise ValueError(f"staged file hash mismatch for {path}") + for notice in metadata["notices"]: + path = stage_dir / PurePosixPath(notice["destination"]) + actual_hash = sha256_file(path) + if actual_hash != notice["sha256"]: + raise ValueError(f"staged notice hash mismatch for {path}") + + +def archive_bundle( + metadata_path: Path, architecture: str, stage_dir: Path, output_path: Path +) -> None: + validate_stage(metadata_path, architecture, stage_dir) + output_path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for path in sorted(stage_dir.rglob("*")): + if path.is_file(): + archive.write(path, path.relative_to(stage_dir).as_posix()) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Package Herdr with Microsoft's pinned ConPTY runtime") + parser.add_argument("--metadata", type=Path, default=DEFAULT_METADATA) + subparsers = parser.add_subparsers(dest="command", required=True) + + stage = subparsers.add_parser("stage") + stage.add_argument("--architecture", choices=("x86_64",), default="x86_64") + stage.add_argument("--package", type=Path, required=True) + stage.add_argument("--herdr-exe", type=Path, required=True) + stage.add_argument("--output-dir", type=Path, required=True) + + archive = subparsers.add_parser("archive") + archive.add_argument("--architecture", choices=("x86_64",), default="x86_64") + archive.add_argument("--stage-dir", type=Path, required=True) + archive.add_argument("--output", type=Path, required=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.command == "stage": + stage_bundle( + args.metadata, + args.architecture, + args.package, + args.herdr_exe, + args.output_dir, + ) + else: + archive_bundle(args.metadata, args.architecture, args.stage_dir, args.output) + + +if __name__ == "__main__": + main() diff --git a/scripts/preview.py b/scripts/preview.py index e173f865..4c9f0167 100644 --- a/scripts/preview.py +++ b/scripts/preview.py @@ -17,7 +17,7 @@ ASSET_TARGETS = ( ) EXPECTED_ASSET_NAMES = { **{target: f"herdr-{target}" for target in ASSET_TARGETS}, - "windows-x86_64": "herdr-windows-x86_64.exe", + "windows-x86_64": "herdr-windows-x86_64.zip", } HIDDEN_SUBJECTS = ( "docs: update website manifest", @@ -188,6 +188,10 @@ def asset_objects(urls: dict[str, str], shas: dict[str, str]) -> dict[str, dict[ sha = shas.get(target) if sha: entry["sha256"] = sha + if target.startswith("windows-"): + if not sha or not re.fullmatch(r"[0-9a-fA-F]{64}", sha): + raise ValueError(f"{target} requires a SHA-256 digest") + entry["format"] = "zip" assets[target] = entry return assets diff --git a/scripts/test_package_windows_conpty.py b/scripts/test_package_windows_conpty.py new file mode 100644 index 00000000..b8d73a6c --- /dev/null +++ b/scripts/test_package_windows_conpty.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import hashlib +import io +import json +import struct +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +from scripts import package_windows_conpty as package + + +class WindowsConptyPackageTests(unittest.TestCase): + def test_pinned_metadata_and_notices_are_consistent(self) -> None: + metadata = package.load_metadata(package.DEFAULT_METADATA) + self.assertEqual(metadata["package"]["id"], "Microsoft.Windows.Console.ConPTY") + self.assertEqual(metadata["package"]["version"], "1.24.260710001") + self.assertEqual( + {item["destination"] for item in metadata["bundles"]["x86_64"]["files"]}, + { + "conpty/conpty.dll", + "conpty/x64/OpenConsole.exe", + "conpty/arm64/OpenConsole.exe", + }, + ) + loader = ( + package.PROJECT_ROOT / "vendor/portable-pty/src/win/psuedocon.rs" + ).read_text(encoding="utf-8") + installer = (package.PROJECT_ROOT / "website/install.ps1").read_text( + encoding="utf-8" + ) + for item in metadata["bundles"]["x86_64"]["files"]: + self.assertIn(item["sha256"], loader) + self.assertNotIn(item["sha256"], installer) + self.assertIn('Get-Content -LiteralPath $markerPath -Raw', installer) + self.assertIn('$filesProperty.Value.PSObject.Properties[$relative]', installer) + for notice in metadata["notices"]: + source = package.PROJECT_ROOT / notice["source"] + self.assertEqual(package.sha256_file(source), notice["sha256"]) + + def test_powershell_wrapper_verifies_package_and_signatures(self) -> None: + wrapper = (package.PROJECT_ROOT / "scripts/package_windows_conpty.ps1").read_text( + encoding="utf-8" + ) + self.assertIn('"nuget", "verify", "--all"', wrapper) + self.assertIn("Get-AuthenticodeSignature", wrapper) + self.assertIn('conpty\\arm64\\OpenConsole.exe', wrapper) + self.assertIn('conpty\\x64\\OpenConsole.exe', wrapper) + self.assertIn('conpty\\conpty.dll', wrapper) + self.assertIn('"*Microsoft Corporation*"', wrapper) + + def test_package_download_has_a_finite_timeout(self) -> None: + payload = b"package" + with tempfile.TemporaryDirectory() as temporary: + destination = Path(temporary) / "conpty.nupkg" + metadata = { + "url": "https://example.invalid/conpty.nupkg", + "sha256": hashlib.sha256(payload).hexdigest(), + } + with mock.patch.object( + package.urllib.request, "urlopen", return_value=io.BytesIO(payload) + ) as urlopen: + package.acquire_package(metadata, destination) + + urlopen.assert_called_once_with( + metadata["url"], timeout=package.DOWNLOAD_TIMEOUT_SECONDS + ) + + def test_stage_and_archive_validate_exact_package(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + dll = self._pe(0x8664, b"dll") + x64_host = self._pe(0x8664, b"x64") + arm64_host = self._pe(0xAA64, b"arm64") + files = [ + self._file("runtimes/win-x64/native/conpty.dll", "conpty/conpty.dll", dll, "0x8664"), + self._file( + "build/native/runtimes/x64/OpenConsole.exe", + "conpty/x64/OpenConsole.exe", + x64_host, + "0x8664", + ), + self._file( + "build/native/runtimes/arm64/OpenConsole.exe", + "conpty/arm64/OpenConsole.exe", + arm64_host, + "0xaa64", + ), + ] + nupkg = root / "conpty.nupkg" + self._write_package(nupkg, files, {item["source"]: data for item, data in zip(files, (dll, x64_host, arm64_host))}) + metadata_path = root / "conpty.json" + metadata_path.write_text( + json.dumps( + { + "schema_version": 1, + "package": { + "id": "Microsoft.Windows.Console.ConPTY", + "version": "1.24.260710001", + "url": nupkg.as_uri(), + "sha256": package.sha256_file(nupkg), + "license": "MIT", + }, + "bundles": {"x86_64": {"files": files}}, + "notices": [], + } + ), + encoding="utf-8", + ) + herdr = root / "input-herdr.exe" + herdr.write_bytes(b"herdr") + stage = root / "stage" + package.stage_bundle(metadata_path, "x86_64", nupkg, herdr, stage) + package.validate_stage(metadata_path, "x86_64", stage) + + output = root / "herdr.zip" + package.archive_bundle(metadata_path, "x86_64", stage, output) + with zipfile.ZipFile(output) as archive: + self.assertEqual( + set(archive.namelist()), + package.expected_stage_files( + package.load_metadata(metadata_path), "x86_64" + ), + ) + + (stage / "conpty" / "conpty.dll").write_bytes(b"tampered") + with self.assertRaisesRegex(ValueError, "staged file hash mismatch"): + package.validate_stage(metadata_path, "x86_64", stage) + + @staticmethod + def _pe(machine: int, payload: bytes) -> bytes: + data = bytearray(0x80) + data[:2] = b"MZ" + struct.pack_into("<I", data, 0x3C, 0x40) + data[0x40:0x44] = b"PE\0\0" + struct.pack_into("<H", data, 0x44, machine) + return bytes(data) + payload + + @staticmethod + def _file(source: str, destination: str, data: bytes, machine: str) -> dict[str, str]: + return { + "source": source, + "destination": destination, + "sha256": hashlib.sha256(data).hexdigest(), + "pe_machine": machine, + } + + @staticmethod + def _write_package( + path: Path, files: list[dict[str, str]], payloads: dict[str, bytes] + ) -> None: + nuspec = """<?xml version="1.0" encoding="utf-8"?> +<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd"> + <metadata> + <id>Microsoft.Windows.Console.ConPTY</id> + <version>1.24.260710001</version> + <license type="expression">MIT</license> + </metadata> +</package> +""" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("Microsoft.Windows.Console.ConPTY.nuspec", nuspec) + for item in files: + archive.writestr(item["source"], payloads[item["source"]]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_preview.py b/scripts/test_preview.py index e11af67c..7b11d4c7 100644 --- a/scripts/test_preview.py +++ b/scripts/test_preview.py @@ -39,7 +39,10 @@ class PreviewNotesTests(unittest.TestCase): base_version="0.6.6", protocol=12, notes=notes, - shas={"linux-x86_64": "deadbeef"}, + shas={ + "linux-x86_64": "deadbeef", + "windows-x86_64": "a" * 64, + }, retain=30, ) data = json.loads(content) @@ -51,10 +54,32 @@ class PreviewNotesTests(unittest.TestCase): ) self.assertEqual( data["assets"]["windows-x86_64"]["url"], - "https://github.com/ogulcancelik/herdr/releases/download/preview-2026-06-02-abcdef123456/herdr-windows-x86_64.exe", + "https://github.com/ogulcancelik/herdr/releases/download/preview-2026-06-02-abcdef123456/herdr-windows-x86_64.zip", ) + self.assertEqual( + data["assets"]["windows-x86_64"]["sha256"], + "a" * 64, + ) + self.assertEqual(data["assets"]["windows-x86_64"]["format"], "zip") self.assertIn("2026-06-02-abcdef123456", data["builds"]) + def test_windows_preview_asset_requires_sha256(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaisesRegex(ValueError, "windows-x86_64 requires"): + preview.build_manifest( + output=Path(tmp) / "preview.json", + repo="ogulcancelik/herdr", + tag="preview-test", + build_id="test", + commit="abcdef", + built_at="2026-06-02T03:00:00Z", + base_version="0.6.6", + protocol=12, + notes="test", + shas={}, + retain=1, + ) + def test_hidden_subjects_include_preview_manifest_commits(self): self.assertTrue(preview.hidden_subject("docs: update preview manifest")) self.assertTrue(preview.hidden_subject("docs: update website manifest")) diff --git a/scripts/test_vendor_portable_pty.py b/scripts/test_vendor_portable_pty.py index 87537d41..cf3b791a 100644 --- a/scripts/test_vendor_portable_pty.py +++ b/scripts/test_vendor_portable_pty.py @@ -103,13 +103,22 @@ class VendorPortablePtyTests(unittest.TestCase): f"stderr:\n{result.stderr}", ) - def test_windows_conpty_loader_does_not_probe_path_conpty_dll(self) -> None: + def test_windows_conpty_loader_uses_only_controlled_sources(self) -> None: project_root = Path(__file__).resolve().parent.parent source = project_root / "vendor" / "portable-pty" / "src" / "win" / "psuedocon.rs" text = source.read_text() - self.assertIn('ConPtyFuncs::open(Path::new("kernel32.dll"))', text) + self.assertIn("std::env::current_exe()", text) + self.assertIn('.join("conpty")', text) + self.assertIn('"x64/OpenConsole.exe"', text) + self.assertIn('"arm64/OpenConsole.exe"', text) + self.assertIn("LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR", text) + self.assertIn("LOAD_LIBRARY_SEARCH_SYSTEM32", text) + self.assertIn("GetModuleHandleW", text) + self.assertIn("HERDR_WINDOWS_CONPTY", text) + self.assertIn("Sha256::new()", text) self.assertNotIn('Path::new("conpty.dll")', text) + self.assertNotIn("shared_library", text) if __name__ == "__main__": diff --git a/scripts/windows_conpty_enhanced_input_probe.ps1 b/scripts/windows_conpty_enhanced_input_probe.ps1 new file mode 100644 index 00000000..bbd2b40b --- /dev/null +++ b/scripts/windows_conpty_enhanced_input_probe.ps1 @@ -0,0 +1,418 @@ +param( + [Parameter(Mandatory = $true)] + [string] $ExePath, + + [string] $Session = "conpty-input-$([guid]::NewGuid().ToString('N'))", + + [string] $ExpectedConsoleHostPath = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Invoke-Checked { + param([string] $Command, [string[]] $Arguments) + & $Command @Arguments + if ($LASTEXITCODE -ne 0) { + throw "command failed with exit code $LASTEXITCODE`: $Command $($Arguments -join ' ')" + } +} + +function Read-Pane { + param([string] $PaneId) + $output = & $script:Exe pane read $PaneId --source recent-unwrapped --lines 200 --format text 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "pane read failed with exit code $LASTEXITCODE`: $($output -join "`n")" + } + return $output -join "`n" +} + +function Wait-PaneText { + param( + [string] $PaneId, + [string] $Needle, + [int] $TimeoutSeconds = 10 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $text = Read-Pane -PaneId $PaneId + if ($text.Contains($Needle)) { + return $text + } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + return $text +} + +function New-ProbePane { + param([string] $Mode) + $created = & $script:Exe workspace create --cwd $PWD.Path 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "workspace create failed with exit code $LASTEXITCODE`: $($created -join "`n")" + } + $paneId = (($created -join "`n") | ConvertFrom-Json).result.root_pane.pane_id + if ([string]::IsNullOrWhiteSpace($paneId)) { + throw "workspace create did not return a root pane id: $($created -join "`n")" + } + + $command = "& '$script:ProbeExe' $Mode" + Invoke-Checked $script:Exe @("pane", "run", $paneId, $command) + $ready = "PROBE_READY_$($Mode.ToUpperInvariant())" + $text = Wait-PaneText -PaneId $paneId -Needle $ready + if (-not $text.Contains($ready)) { + throw "$Mode probe did not become ready: $text" + } + return $paneId +} + +function Get-LatestProbeHex { + param([string] $PaneText) + $hexMatches = [regex]::Matches($PaneText, "PROBE_ALL:([0-9a-f]+)") + if ($hexMatches.Count -eq 0) { + return "" + } + return $hexMatches[$hexMatches.Count - 1].Groups[1].Value +} + +function Wait-PaneHexAppend { + param( + [string] $PaneId, + [string] $PreviousHex, + [string] $ExpectedHex, + [int] $TimeoutSeconds = 4 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + do { + $paneText = Read-Pane -PaneId $PaneId + $latestHex = Get-LatestProbeHex -PaneText $paneText + if ($latestHex.StartsWith($PreviousHex)) { + $appendedHex = $latestHex.Substring($PreviousHex.Length) + if ($appendedHex -eq $ExpectedHex) { + return [ordered]@{ delivered = $true; pane = $paneText } + } + } + Start-Sleep -Milliseconds 200 + } while ((Get-Date) -lt $deadline) + return [ordered]@{ delivered = $false; pane = $paneText } +} + +function Send-KeyAndObserve { + param( + [string] $PaneId, + [string] $Key, + [string] $ExpectedHex + ) + $before = Get-LatestProbeHex -PaneText (Read-Pane -PaneId $PaneId) + Invoke-Checked $script:Exe @("pane", "send-keys", $PaneId, $Key) + $observed = Wait-PaneHexAppend -PaneId $PaneId -PreviousHex $before -ExpectedHex $ExpectedHex + return [ordered]@{ + key = $Key + expected_hex = $ExpectedHex + delivered = $observed.delivered + pane = $observed.pane + } +} + +function Send-RawAndObserve { + param( + [string] $PaneId, + [string] $Text, + [string] $ExpectedHex + ) + $before = Get-LatestProbeHex -PaneText (Read-Pane -PaneId $PaneId) + & $script:Exe pane send-text $PaneId $Text | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "pane send-text failed with exit code $LASTEXITCODE" + } + $observed = Wait-PaneHexAppend -PaneId $PaneId -PreviousHex $before -ExpectedHex $ExpectedHex + return [ordered]@{ + expected_hex = $ExpectedHex + delivered = $observed.delivered + pane = $observed.pane + } +} + +$script:Exe = (Resolve-Path $ExePath).Path +$workDir = Join-Path ([System.IO.Path]::GetTempPath()) "herdr-conpty-input-$([guid]::NewGuid().ToString('N'))" +$probeSource = Join-Path $workDir "probe.rs" +$script:ProbeExe = Join-Path $workDir "probe.exe" +$oldSession = $env:HERDR_SESSION +$oldSocket = $env:HERDR_SOCKET_PATH +$oldClientSocket = $env:HERDR_CLIENT_SOCKET_PATH +$server = $null +$report = [ordered]@{} +$failed = $false +$initialConsoleHostIds = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue | ForEach-Object { $_.Id }) +$expectedConsoleHost = if ([string]::IsNullOrWhiteSpace($ExpectedConsoleHostPath)) { + $null +} else { + (Resolve-Path $ExpectedConsoleHostPath).Path +} + +try { + New-Item -ItemType Directory -Force -Path $workDir | Out-Null +@' +use std::ffi::c_void; + +const STD_INPUT_HANDLE: u32 = -10i32 as u32; +const STD_OUTPUT_HANDLE: u32 = -11i32 as u32; +const ENABLE_PROCESSED_INPUT: u32 = 0x0001; +const ENABLE_LINE_INPUT: u32 = 0x0002; +const ENABLE_ECHO_INPUT: u32 = 0x0004; +const ENABLE_VIRTUAL_TERMINAL_INPUT: u32 = 0x0200; + +type Handle = *mut c_void; + +#[link(name = "Kernel32")] +extern "system" { + fn GetStdHandle(kind: u32) -> Handle; + fn GetConsoleMode(handle: Handle, mode: *mut u32) -> i32; + fn SetConsoleMode(handle: Handle, mode: u32) -> i32; + fn ReadFile( + handle: Handle, + buffer: *mut c_void, + bytes_to_read: u32, + bytes_read: *mut u32, + overlapped: *mut c_void, + ) -> i32; + fn WriteFile( + handle: Handle, + buffer: *const c_void, + bytes_to_write: u32, + bytes_written: *mut u32, + overlapped: *mut c_void, + ) -> i32; +} + +fn write_all(handle: Handle, mut bytes: &[u8]) { + while !bytes.is_empty() { + let mut written = 0; + let ok = unsafe { + WriteFile( + handle, + bytes.as_ptr().cast(), + bytes.len() as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 || written == 0 { + std::process::exit(3); + } + bytes = &bytes[written as usize..]; + } +} + +fn main() { + let mode = std::env::args().nth(1).unwrap_or_else(|| "legacy".to_string()); + let input = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + let output = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }; + let mut console_mode = 0; + if unsafe { GetConsoleMode(input, &mut console_mode) } == 0 { + write_all(output, b"PROBE_ERROR_GET_MODE\r\n"); + std::process::exit(1); + } + let raw_vt_mode = (console_mode | ENABLE_VIRTUAL_TERMINAL_INPUT) + & !(ENABLE_PROCESSED_INPUT | ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT); + if unsafe { SetConsoleMode(input, raw_vt_mode) } == 0 { + write_all(output, b"PROBE_ERROR_SET_MODE\r\n"); + std::process::exit(2); + } + + if mode == "kitty" { + write_all(output, b"\x1b[>7u\x1b[?u\x1b[cPROBE_READY_KITTY\r\n"); + } else { + write_all(output, b"PROBE_READY_LEGACY\r\n"); + } + + let mut all = Vec::new(); + let mut buffer = [0u8; 256]; + loop { + let mut read = 0; + let ok = unsafe { + ReadFile( + input, + buffer.as_mut_ptr().cast(), + buffer.len() as u32, + &mut read, + std::ptr::null_mut(), + ) + }; + if ok == 0 || read == 0 { + break; + } + all.extend_from_slice(&buffer[..read as usize]); + let mut line = String::from("PROBE_ALL:"); + for byte in &all { + use std::fmt::Write as _; + let _ = write!(&mut line, "{byte:02x}"); + } + line.push_str("\r\n"); + write_all(output, line.as_bytes()); + } +} +'@ | Set-Content -NoNewline -Encoding utf8 $probeSource + + Invoke-Checked rustc @("--edition", "2021", $probeSource, "-o", $script:ProbeExe) + + $env:HERDR_SESSION = $Session + Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue + Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue + + $os = Get-CimInstance Win32_OperatingSystem + $report.os = [ordered]@{ + caption = $os.Caption + version = $os.Version + build = $os.BuildNumber + } + + Invoke-Checked $script:Exe @("--version") + & $script:Exe --default-config | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "command failed with exit code $LASTEXITCODE`: $script:Exe --default-config" + } + + $server = Start-Process -FilePath $script:Exe -ArgumentList "server" -PassThru -WindowStyle Hidden + $deadline = (Get-Date).AddSeconds(10) + $serverReady = $false + do { + Start-Sleep -Milliseconds 250 + $status = & $script:Exe status server 2>&1 + if ($LASTEXITCODE -eq 0 -and (($status -join "`n") -match "status: running")) { + $serverReady = $true + break + } + } while ((Get-Date) -lt $deadline) + if (-not $serverReady) { + throw "server did not become ready: $($status -join "`n")" + } + + $legacyPane = New-ProbePane -Mode "legacy" + $report.legacy_alt_v = Send-KeyAndObserve -PaneId $legacyPane -Key "alt+v" -ExpectedHex "1b76" + + $kittyPane = New-ProbePane -Mode "kitty" + $report.kitty_initial = Wait-PaneText -PaneId $kittyPane -Needle "1b5b3f3775" + $kittyInitialHex = Get-LatestProbeHex -PaneText $report.kitty_initial + $report.device_attributes_response = $kittyInitialHex -match "1b5b3f(?:3[0-9]|3b)+63" + $report.kitty_query_response = $kittyInitialHex.Contains("1b5b3f3775") + $report.kitty_alt_v = Send-KeyAndObserve -PaneId $kittyPane -Key "alt+v" -ExpectedHex "1b5b3131383b333a3175" + $report.kitty_ctrl_u = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+u" -ExpectedHex "1b5b3131373b353a3175" + $report.kitty_ctrl_v = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+v" -ExpectedHex "1b5b3131383b353a3175" + $report.kitty_shift_enter = Send-KeyAndObserve -PaneId $kittyPane -Key "shift+enter" -ExpectedHex "1b5b31333b3275" + $report.kitty_ctrl_backspace = Send-KeyAndObserve -PaneId $kittyPane -Key "ctrl+backspace" -ExpectedHex "1b5b3132373b3575" + $report.kitty_up = Send-KeyAndObserve -PaneId $kittyPane -Key "up" -ExpectedHex "1b5b313b313a3141" + $report.kitty_escape = Send-KeyAndObserve -PaneId $kittyPane -Key "esc" -ExpectedHex "1b5b323775" + $report.raw_kitty_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[118;3:1u") -ExpectedHex "1b5b3131383b333a3175" + $report.raw_kitty_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[117;5:1u") -ExpectedHex "1b5b3131373b353a3175" + $report.raw_kitty_ctrl_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[118;5:1u") -ExpectedHex "1b5b3131383b353a3175" + $report.raw_kitty_shift_enter = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[13;2u") -ExpectedHex "1b5b31333b3275" + $report.raw_kitty_ctrl_backspace = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[127;5u") -ExpectedHex "1b5b3132373b3575" + $report.raw_kitty_ctrl_delete = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "[57426;5u") -ExpectedHex "1b5b35373432363b3575" + $report.raw_alt_v = Send-RawAndObserve -PaneId $kittyPane -Text ([char]27 + "v") -ExpectedHex "1b76" + $report.raw_ctrl_u = Send-RawAndObserve -PaneId $kittyPane -Text ([string][char]0x15) -ExpectedHex "15" + + $consoleHosts = @(Get-Process -Name conhost, OpenConsole -ErrorAction SilentlyContinue | + Where-Object { $initialConsoleHostIds -notcontains $_.Id } | + ForEach-Object { + $path = "" + $version = "" + try { $path = $_.Path } catch {} + try { $version = $_.MainModule.FileVersionInfo.FileVersion } catch {} + [ordered]@{ + id = $_.Id + name = $_.ProcessName + path = $path + version = $version + } + }) + $report.new_console_hosts = $consoleHosts + $appLocalHostRequired = $null -ne $expectedConsoleHost + if ($appLocalHostRequired) { + $report.expected_console_host = $expectedConsoleHost + $report.app_local_console_host = @($consoleHosts | Where-Object { + $_.name -ieq "OpenConsole" -and $_.path -ieq $expectedConsoleHost + }).Count -gt 0 + } else { + $report.app_local_console_host = $null + } + + $failed = -not $report.legacy_alt_v.delivered ` + -or -not $report.device_attributes_response ` + -or -not $report.kitty_query_response ` + -or -not $report.kitty_alt_v.delivered ` + -or -not $report.kitty_ctrl_u.delivered ` + -or -not $report.kitty_ctrl_v.delivered ` + -or -not $report.kitty_shift_enter.delivered ` + -or -not $report.kitty_ctrl_backspace.delivered ` + -or -not $report.kitty_up.delivered ` + -or -not $report.kitty_escape.delivered ` + -or -not $report.raw_kitty_alt_v.delivered ` + -or -not $report.raw_kitty_ctrl_u.delivered ` + -or -not $report.raw_kitty_ctrl_v.delivered ` + -or -not $report.raw_kitty_shift_enter.delivered ` + -or -not $report.raw_kitty_ctrl_backspace.delivered ` + -or -not $report.raw_kitty_ctrl_delete.delivered ` + -or -not $report.raw_alt_v.delivered ` + -or -not $report.raw_ctrl_u.delivered ` + -or ($appLocalHostRequired -and -not $report.app_local_console_host) +} finally { + if ($null -ne $server) { + try { + $stopOutput = & $script:Exe server stop 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Host "server stop during cleanup failed: $($stopOutput -join "`n")" + } + } catch { + Write-Host "server stop during cleanup failed: $($_.Exception.Message)" + } + Wait-Process -Id $server.Id -Timeout 10 -ErrorAction SilentlyContinue + $server.Refresh() + if (-not $server.HasExited) { + & taskkill.exe /PID $server.Id /T /F 2>&1 | Out-Null + } + } + if ($null -ne $expectedConsoleHost) { + $hostExitDeadline = (Get-Date).AddSeconds(5) + do { + $remainingHosts = @(Get-Process -Name OpenConsole -ErrorAction SilentlyContinue | + Where-Object { $initialConsoleHostIds -notcontains $_.Id } | + Where-Object { + $path = "" + try { $path = $_.Path } catch {} + $path -ieq $expectedConsoleHost + }) + if ($remainingHosts.Count -eq 0) { + break + } + Start-Sleep -Milliseconds 100 + } while ((Get-Date) -lt $hostExitDeadline) + $report.app_local_console_host_exited = $remainingHosts.Count -eq 0 + if (-not $report.app_local_console_host_exited) { + $failed = $true + } + } + $global:LASTEXITCODE = 0 + if ($null -eq $oldSession) { + Remove-Item Env:HERDR_SESSION -ErrorAction SilentlyContinue + } else { + $env:HERDR_SESSION = $oldSession + } + if ($null -eq $oldSocket) { + Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue + } else { + $env:HERDR_SOCKET_PATH = $oldSocket + } + if ($null -eq $oldClientSocket) { + Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue + } else { + $env:HERDR_CLIENT_SOCKET_PATH = $oldClientSocket + } + + $json = $report | ConvertTo-Json -Depth 8 + Write-Host $json + Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue +} + +if ($failed) { + throw "enhanced Windows ConPTY input probe failed; see the JSON report above" +} diff --git a/scripts/windows_install_conpty_package_test.ps1 b/scripts/windows_install_conpty_package_test.ps1 new file mode 100644 index 00000000..062c2cfe --- /dev/null +++ b/scripts/windows_install_conpty_package_test.ps1 @@ -0,0 +1,146 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ArchivePath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$archive = (Resolve-Path -LiteralPath $ArchivePath).Path +$root = Join-Path $env:RUNNER_TEMP ("herdr-installer-test-" + [Guid]::NewGuid().ToString("N")) +$webRoot = Join-Path $root "web" +$herdrHome = Join-Path $root "home" +$installDir = Join-Path $root "bin" +New-Item -ItemType Directory -Force -Path $webRoot | Out-Null +Copy-Item -LiteralPath $archive -Destination (Join-Path $webRoot "herdr-windows-x86_64.zip") +$hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant() + +$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) +$listener.Start() +$port = ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port +$listener.Stop() +$manifest = @{ + channel = "preview" + base_version = "0.0.0" + build_id = "installer-test" + assets = @{ + "windows-x86_64" = @{ + url = "http://127.0.0.1:$port/herdr-windows-x86_64.zip" + sha256 = $hash + format = "zip" + } + } +} | ConvertTo-Json -Depth 5 +$manifestPath = Join-Path $webRoot "preview.json" +$manifest | Out-File -LiteralPath $manifestPath -Encoding utf8 + +$server = $null +$oldHerdrHome = $env:HERDR_HOME +try { + $server = Start-Process python -ArgumentList @("-m", "http.server", "$port", "--bind", "127.0.0.1", "--directory", $webRoot) -PassThru -WindowStyle Hidden + $env:HERDR_HOME = Join-Path $root "unused\..\home" + $manifestUrl = "http://127.0.0.1:$port/preview.json" + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + Invoke-WebRequest -Uri $manifestUrl -UseBasicParsing | Out-Null + break + } catch { + if ($attempt -eq 19) { throw } + Start-Sleep -Milliseconds 100 + } + } + + & "$PSScriptRoot\..\website\install.ps1" ` + -ManifestUrl $manifestUrl ` + -InstallDir $installDir ` + -ExpectedBuildId "installer-test" + + $required = @( + "herdr.exe", + "conpty\herdr-conpty.json", + "conpty\conpty.dll", + "conpty\x64\OpenConsole.exe", + "conpty\arm64\OpenConsole.exe", + "THIRD-PARTY-NOTICES\Microsoft.Windows.Console.ConPTY-LICENSE.txt", + "THIRD-PARTY-NOTICES\Microsoft.Windows.Console.ConPTY-NOTICE.md" + ) + foreach ($relative in $required) { + if (-not (Test-Path -LiteralPath (Join-Path $installDir $relative) -PathType Leaf)) { + throw "installer did not activate required file $relative" + } + } + + $releaseDir = Get-ChildItem -LiteralPath (Join-Path $herdrHome "packages\standalone\releases") -Directory | + Where-Object { -not $_.Name.StartsWith(".staging.") } | + Select-Object -First 1 + if ($null -eq $releaseDir) { + throw "installer did not create a versioned release" + } + Remove-Item -LiteralPath (Join-Path $releaseDir.FullName "conpty\conpty.dll") -Force + + $badManifest = $manifest | ConvertFrom-Json + $badManifest.assets."windows-x86_64".url = "http://127.0.0.1:$port/missing.zip" + $badManifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath $manifestPath -Encoding utf8 + $downloadFailed = $false + try { + & "$PSScriptRoot\..\website\install.ps1" ` + -ManifestUrl $manifestUrl ` + -InstallDir $installDir ` + -ExpectedBuildId "installer-test" + } catch { + if ($_.Exception.Message -notmatch "(?i)(404|not found)") { + throw + } + $downloadFailed = $true + } + if (-not $downloadFailed) { + throw "installer repair unexpectedly accepted a missing archive" + } + if (-not (Test-Path -LiteralPath (Join-Path $releaseDir.FullName "herdr.exe") -PathType Leaf)) { + throw "failed repair removed the existing release" + } + + $manifest | Out-File -LiteralPath $manifestPath -Encoding utf8 + & "$PSScriptRoot\..\website\install.ps1" ` + -ManifestUrl $manifestUrl ` + -InstallDir $installDir ` + -ExpectedBuildId "installer-test" + if (-not (Test-Path -LiteralPath (Join-Path $installDir "conpty\conpty.dll") -PathType Leaf)) { + throw "installer did not repair an incomplete release" + } + + $x64HostDir = Join-Path $releaseDir.FullName "conpty\x64" + $junctionTarget = Join-Path $root "junction-target" + Move-Item -LiteralPath $x64HostDir -Destination $junctionTarget + New-Item -ItemType Junction -Path $x64HostDir -Target $junctionTarget | Out-Null + & "$PSScriptRoot\..\website\install.ps1" ` + -ManifestUrl $manifestUrl ` + -InstallDir $installDir ` + -ExpectedBuildId "installer-test" + $repairedHostDir = Get-Item -LiteralPath (Join-Path $installDir "conpty\x64") -Force + if ($repairedHostDir.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "installer accepted a reparse-point ConPTY directory" + } + + $rejected = $false + try { + & "$PSScriptRoot\..\website\install.ps1" ` + -ManifestUrl $manifestUrl ` + -InstallDir $installDir ` + -ExpectedBuildId "different-build" + } catch { + if ($_.Exception.Message -notlike "Preview manifest changed while updating.*") { + throw + } + $rejected = $true + } + if (-not $rejected) { + throw "installer accepted a manifest that did not match the updater-selected build" + } +} finally { + $env:HERDR_HOME = $oldHerdrHome + if ($null -ne $server -and -not $server.HasExited) { + Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/windows_smoke_conpty_path.ps1 b/scripts/windows_smoke_conpty_path.ps1 index ce54dd45..bec93b66 100644 --- a/scripts/windows_smoke_conpty_path.ps1 +++ b/scripts/windows_smoke_conpty_path.ps1 @@ -59,8 +59,11 @@ Invoke-Checked rustc @("--crate-type", "cdylib", "--edition", "2021", $fakeSourc $oldPath = $env:PATH $oldSession = $env:HERDR_SESSION +$oldSocket = $env:HERDR_SOCKET_PATH +$oldClientSocket = $env:HERDR_CLIENT_SOCKET_PATH $env:PATH = "$fakeDir;$oldPath" $env:HERDR_SESSION = $Session +Remove-Item Env:HERDR_SOCKET_PATH, Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue $server = $null try { @@ -84,9 +87,16 @@ try { throw "server did not become ready" } - $created = & $exe workspace create --cwd $PWD.Path 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "workspace create failed with exit code $LASTEXITCODE`: $($created -join "`n")" + $savedErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $created = & $exe workspace create --cwd $PWD.Path 2>&1 + $createdExitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $savedErrorActionPreference + } + if ($createdExitCode -ne 0) { + throw "workspace create failed with exit code $createdExitCode`: $($created -join "`n")" } $paneId = (($created -join "`n") | ConvertFrom-Json).result.root_pane.pane_id if ([string]::IsNullOrWhiteSpace($paneId)) { @@ -126,9 +136,27 @@ try { Write-Host "server stop during cleanup failed: $($_.Exception.Message)" } Wait-Process -Id $server.Id -Timeout 10 -ErrorAction SilentlyContinue + $server.Refresh() + if (-not $server.HasExited) { + & taskkill.exe /PID $server.Id /T /F 2>&1 | Out-Null + } } $global:LASTEXITCODE = 0 $env:PATH = $oldPath - $env:HERDR_SESSION = $oldSession + if ($null -eq $oldSession) { + Remove-Item Env:HERDR_SESSION -ErrorAction SilentlyContinue + } else { + $env:HERDR_SESSION = $oldSession + } + if ($null -eq $oldSocket) { + Remove-Item Env:HERDR_SOCKET_PATH -ErrorAction SilentlyContinue + } else { + $env:HERDR_SOCKET_PATH = $oldSocket + } + if ($null -eq $oldClientSocket) { + Remove-Item Env:HERDR_CLIENT_SOCKET_PATH -ErrorAction SilentlyContinue + } else { + $env:HERDR_CLIENT_SOCKET_PATH = $oldClientSocket + } Remove-Item -Recurse -Force $fakeDir -ErrorAction SilentlyContinue } diff --git a/src/update.rs b/src/update.rs index eac917bf..3455841d 100644 --- a/src/update.rs +++ b/src/update.rs @@ -628,8 +628,12 @@ fn install_downloaded_update(mut update: DownloadedUpdate) -> Result<(), String> } #[cfg(windows)] -fn install_windows_update_with_installer(channel: UpdateChannel) -> Result<(), String> { - let status = Command::new("powershell") +fn install_windows_update_with_installer( + channel: UpdateChannel, + expected_build_id: Option<&str>, +) -> Result<(), String> { + let mut command = Command::new("powershell"); + command .args([ "-NoProfile", "-ExecutionPolicy", @@ -643,7 +647,11 @@ fn install_windows_update_with_installer(channel: UpdateChannel) -> Result<(), S // PowerShell 5.1 (this `powershell`) fails to autoload cmdlets like // Get-FileHash. Removing it lets 5.1 compute its own default path. // See PowerShell/PowerShell#8635. - .env_remove("PSModulePath") + .env_remove("PSModulePath"); + if let Some(build_id) = expected_build_id { + command.env("HERDR_EXPECTED_BUILD_ID", build_id); + } + let status = command .status() .map_err(|err| format!("failed to run Windows installer: {err}"))?; @@ -2019,7 +2027,7 @@ pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> { if let Some(sha256) = &release.sha256 { tracing::debug!(sha256 = %sha256, "selected Windows update asset has checksum"); } - install_windows_update_with_installer(channel)?; + install_windows_update_with_installer(channel, release.build_id.as_deref())?; let updated_exe = windows_installed_herdr_exe_path()?; eprintln!("installed {}", release.label()); print_outdated_integration_notice_with_updated_binary(&updated_exe); diff --git a/vendor/patches/portable-pty/0001-control-conpty-loading.patch b/vendor/patches/portable-pty/0001-control-conpty-loading.patch new file mode 100644 index 00000000..4c49300e --- /dev/null +++ b/vendor/patches/portable-pty/0001-control-conpty-loading.patch @@ -0,0 +1,357 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers <maintainers@herdr.dev> +Date: Tue, 23 Jun 2026 00:00:00 +0000 +Subject: [PATCH] control ConPTY loading + +Load Herdr's hash-verified app-local ConPTY with a constrained DLL search, +otherwise use the exports from the already loaded kernel32 module. Never +probe for a bare conpty.dll through the DLL search path. + +Herdr issues: https://github.com/ogulcancelik/herdr/issues/761 + https://github.com/ogulcancelik/herdr/issues/1533 +Vendored base: portable-pty 0.9.0 +--- +diff --git a/vendor/portable-pty/Cargo.toml b/vendor/portable-pty/Cargo.toml +index b8d11230..8a33abf9 100644 +--- a/vendor/portable-pty/Cargo.toml ++++ b/vendor/portable-pty/Cargo.toml +@@ -102,8 +102,8 @@ version = "1.3" + [target."cfg(windows)".dependencies.lazy_static] + version = "1.4" + +-[target."cfg(windows)".dependencies.shared_library] +-version = "0.1" ++[target."cfg(windows)".dependencies.sha2] ++version = "0.10" + + [target."cfg(windows)".dependencies.winapi] + version = "0.3" +@@ -112,6 +112,7 @@ features = [ + "consoleapi", + "handleapi", + "fileapi", ++ "libloaderapi", + "namedpipeapi", + "synchapi", + ] +diff --git a/vendor/portable-pty/Cargo.toml.orig b/vendor/portable-pty/Cargo.toml.orig +index 0c3d6b8d..21abe3b6 100644 +--- a/vendor/portable-pty/Cargo.toml.orig ++++ b/vendor/portable-pty/Cargo.toml.orig +@@ -27,12 +27,13 @@ serde_support = ["serde", "serde_derive"] + [target."cfg(windows)".dependencies] + bitflags = "1.3" + lazy_static = "1.4" +-shared_library = "0.1" ++sha2 = "0.10" + winapi = { version = "0.3", features = [ + "winuser", + "consoleapi", + "handleapi", + "fileapi", ++ "libloaderapi", + "namedpipeapi", + "synchapi", + ]} +diff --git a/vendor/portable-pty/src/win/psuedocon.rs b/vendor/portable-pty/src/win/psuedocon.rs +index 133eab52..d4f111bb 100644 +--- a/vendor/portable-pty/src/win/psuedocon.rs ++++ b/vendor/portable-pty/src/win/psuedocon.rs +@@ -4,17 +4,24 @@ use crate::win::procthreadattr::ProcThreadAttributeList; + use anyhow::{bail, ensure, Error}; + use filedescriptor::{FileDescriptor, OwnedHandle}; + use lazy_static::lazy_static; +-use shared_library::shared_library; +-use std::ffi::OsString; +-use std::io::Error as IoError; +-use std::os::windows::ffi::OsStringExt; ++use sha2::{Digest, Sha256}; ++use std::collections::BTreeSet; ++use std::ffi::{OsStr, OsString}; ++use std::fs::File; ++use std::io::{Error as IoError, Read}; ++use std::os::windows::ffi::{OsStrExt, OsStringExt}; ++use std::os::windows::fs::MetadataExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; +-use std::path::Path; ++use std::path::{Path, PathBuf}; + use std::sync::Mutex; + use std::{mem, ptr}; +-use winapi::shared::minwindef::DWORD; ++use winapi::shared::minwindef::{DWORD, HMODULE}; + use winapi::shared::winerror::{HRESULT, S_OK}; + use winapi::um::handleapi::*; ++use winapi::um::libloaderapi::{ ++ FreeLibrary, GetModuleHandleW, GetProcAddress, LoadLibraryExW, ++ LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, LOAD_LIBRARY_SEARCH_SYSTEM32, ++}; + use winapi::um::processthreadsapi::*; + use winapi::um::winbase::{ + CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, STARTF_USESTDHANDLES, STARTUPINFOEXW, +@@ -30,26 +37,249 @@ pub const PSEUDOCONSOLE_WIN32_INPUT_MODE: DWORD = 0x4; + #[allow(dead_code)] + pub const PSEUDOCONSOLE_PASSTHROUGH_MODE: DWORD = 0x8; + +-shared_library!(ConPtyFuncs, +- pub fn CreatePseudoConsole( +- size: COORD, +- hInput: HANDLE, +- hOutput: HANDLE, +- flags: DWORD, +- hpc: *mut HPCON +- ) -> HRESULT, +- pub fn ResizePseudoConsole(hpc: HPCON, size: COORD) -> HRESULT, +- pub fn ClosePseudoConsole(hpc: HPCON), +-); ++type CreatePseudoConsoleFn = unsafe extern "system" fn( ++ size: COORD, ++ h_input: HANDLE, ++ h_output: HANDLE, ++ flags: DWORD, ++ hpc: *mut HPCON, ++) -> HRESULT; ++type ResizePseudoConsoleFn = unsafe extern "system" fn(hpc: HPCON, size: COORD) -> HRESULT; ++type ClosePseudoConsoleFn = unsafe extern "system" fn(hpc: HPCON); ++ ++// These fields intentionally mirror the exported Win32 symbol names. ++#[allow(non_snake_case)] ++struct ConPtyFuncs { ++ module: HMODULE, ++ owned: bool, ++ CreatePseudoConsole: CreatePseudoConsoleFn, ++ ResizePseudoConsole: ResizePseudoConsoleFn, ++ ClosePseudoConsole: ClosePseudoConsoleFn, ++} ++ ++unsafe impl Send for ConPtyFuncs {} ++unsafe impl Sync for ConPtyFuncs {} ++ ++impl Drop for ConPtyFuncs { ++ fn drop(&mut self) { ++ if self.owned { ++ unsafe { FreeLibrary(self.module) }; ++ } ++ } ++} ++ ++impl ConPtyFuncs { ++ unsafe fn from_module(module: HMODULE, owned: bool) -> Result<Self, String> { ++ if module.is_null() { ++ return Err(IoError::last_os_error().to_string()); ++ } ++ Ok(Self { ++ module, ++ owned, ++ CreatePseudoConsole: load_symbol(module, b"CreatePseudoConsole\0")?, ++ ResizePseudoConsole: load_symbol(module, b"ResizePseudoConsole\0")?, ++ ClosePseudoConsole: load_symbol(module, b"ClosePseudoConsole\0")?, ++ }) ++ } ++} ++ ++unsafe fn load_symbol<T: Copy>(module: HMODULE, name: &'static [u8]) -> Result<T, String> { ++ let symbol = GetProcAddress(module, name.as_ptr().cast()); ++ if symbol.is_null() { ++ return Err(format!( ++ "missing symbol {}", ++ String::from_utf8_lossy(&name[..name.len() - 1]) ++ )); ++ } ++ Ok(mem::transmute_copy(&symbol)) ++} ++ ++fn load_system_conpty() -> Result<ConPtyFuncs, String> { ++ let kernel_name = wide_string(OsStr::new("kernel32.dll")); ++ let module = unsafe { GetModuleHandleW(kernel_name.as_ptr()) }; ++ unsafe { ConPtyFuncs::from_module(module, false) } ++} ++ ++fn load_app_local_conpty(path: &Path) -> Result<ConPtyFuncs, String> { ++ let wide_path = wide_string(path.as_os_str()); ++ let module = unsafe { ++ LoadLibraryExW( ++ wide_path.as_ptr(), ++ ptr::null_mut(), ++ LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, ++ ) ++ }; ++ match unsafe { ConPtyFuncs::from_module(module, true) } { ++ Ok(functions) => Ok(functions), ++ Err(error) => { ++ if !module.is_null() { ++ unsafe { FreeLibrary(module) }; ++ } ++ Err(error) ++ } ++ } ++} ++ ++fn wide_string(value: &OsStr) -> Vec<u16> { ++ value.encode_wide().chain(Some(0)).collect() ++} + + fn load_conpty() -> ConPtyFuncs { + // If the kernel doesn't export these functions then their system is + // too old and we cannot run. +- let kernel = ConPtyFuncs::open(Path::new("kernel32.dll")).expect( +- "this system does not support conpty. Windows 10 October 2018 or newer is required", +- ); ++ let system = load_system_conpty().unwrap_or_else(|error| { ++ panic!( ++ "this system does not support conpty. Windows 10 October 2018 or newer is required: {}", ++ error ++ ) ++ }); ++ ++ if std::env::var("HERDR_WINDOWS_CONPTY").as_deref() == Ok("system") { ++ log::warn!("HERDR_WINDOWS_CONPTY=system disables Herdr's bundled ConPTY"); ++ return system; ++ } ++ ++ match app_local_conpty_path() { ++ Ok(Some(path)) => load_app_local_conpty(&path).unwrap_or_else(|error| { ++ panic!( ++ "failed to load verified app-local ConPTY from {}: {error}; set HERDR_WINDOWS_CONPTY=system to use the Windows system ConPTY", ++ path.display() ++ ) ++ }), ++ Ok(None) => system, ++ Err(error) => panic!( ++ "Herdr's app-local ConPTY bundle is invalid: {}; reinstall Herdr or set HERDR_WINDOWS_CONPTY=system to use the Windows system ConPTY", ++ error ++ ), ++ } ++} ++ ++const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; ++const CONPTY_FILES: &[(&str, &str)] = &[ ++ ( ++ "conpty.dll", ++ "39fba2713e2495117b1591ae8c32a3b904bea7aa66069cf7815e2844c76d75d8", ++ ), ++ ( ++ "x64/OpenConsole.exe", ++ "b7fd936c2668b87b9ecf7b3366dc6568afc1c6f981874cba3e955a1c35cf8160", ++ ), ++ ( ++ "arm64/OpenConsole.exe", ++ "ed7622fd0d3bedc9ab9f122f5e58edf0def9e7999224f52dd395ba9f54edbe09", ++ ), ++]; ++ ++fn app_local_conpty_path() -> Result<Option<PathBuf>, String> { ++ if std::env::consts::ARCH != "x86_64" { ++ return Ok(None); ++ } ++ let executable = std::env::current_exe() ++ .map_err(|error| format!("cannot locate the Herdr executable: {error}"))?; ++ let executable_dir = executable ++ .parent() ++ .ok_or_else(|| "Herdr executable has no parent directory".to_string())?; ++ let bundle = executable_dir.join("conpty"); ++ if !bundle.exists() { ++ return Ok(None); ++ } ++ reject_reparse_point(&bundle)?; ++ ++ let mut expected = BTreeSet::from(["herdr-conpty.json".to_string()]); ++ for (relative, expected_hash) in CONPTY_FILES { ++ expected.insert((*relative).to_string()); ++ verify_bundle_file(&bundle, relative, expected_hash)?; ++ } ++ let marker = bundle.join("herdr-conpty.json"); ++ verify_regular_file(&marker)?; ++ ++ let actual = bundle_files(&bundle)?; ++ if actual != expected { ++ return Err(format!( ++ "unexpected bundle layout; expected {expected:?}, found {actual:?}" ++ )); ++ } ++ Ok(Some(bundle.join("conpty.dll"))) ++} ++ ++fn verify_bundle_file(root: &Path, relative: &str, expected_hash: &str) -> Result<(), String> { ++ let path = root.join(relative); ++ verify_regular_file(&path)?; ++ let mut file = File::open(&path).map_err(|error| format!("cannot open {}: {error}", path.display()))?; ++ let mut digest = Sha256::new(); ++ let mut buffer = [0; 64 * 1024]; ++ loop { ++ let read = file ++ .read(&mut buffer) ++ .map_err(|error| format!("cannot read {}: {error}", path.display()))?; ++ if read == 0 { ++ break; ++ } ++ digest.update(&buffer[..read]); ++ } ++ let actual_hash = format!("{:x}", digest.finalize()); ++ if actual_hash != expected_hash { ++ return Err(format!( ++ "{} has hash {actual_hash}, expected {expected_hash}", ++ path.display() ++ )); ++ } ++ Ok(()) ++} ++ ++fn verify_regular_file(path: &Path) -> Result<(), String> { ++ let metadata = path ++ .symlink_metadata() ++ .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; ++ if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { ++ return Err(format!("{} is not a regular file", path.display())); ++ } ++ Ok(()) ++} ++ ++fn reject_reparse_point(path: &Path) -> Result<(), String> { ++ let metadata = path ++ .symlink_metadata() ++ .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; ++ if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { ++ return Err(format!("{} is not a regular directory", path.display())); ++ } ++ Ok(()) ++} ++ ++fn bundle_files(root: &Path) -> Result<BTreeSet<String>, String> { ++ fn visit(root: &Path, directory: &Path, files: &mut BTreeSet<String>) -> Result<(), String> { ++ reject_reparse_point(directory)?; ++ for entry in std::fs::read_dir(directory) ++ .map_err(|error| format!("cannot read {}: {error}", directory.display()))? ++ { ++ let entry = entry.map_err(|error| error.to_string())?; ++ let path = entry.path(); ++ let metadata = path ++ .symlink_metadata() ++ .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; ++ if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { ++ return Err(format!("{} is a reparse point", path.display())); ++ } ++ if metadata.is_dir() { ++ visit(root, &path, files)?; ++ } else if metadata.is_file() { ++ let relative = path ++ .strip_prefix(root) ++ .map_err(|error| error.to_string())? ++ .to_string_lossy() ++ .replace('\\', "/"); ++ files.insert(relative); ++ } else { ++ return Err(format!("{} is not a regular file", path.display())); ++ } ++ } ++ Ok(()) ++ } + +- kernel ++ let mut files = BTreeSet::new(); ++ visit(root, root, &mut files)?; ++ Ok(files) + } + + lazy_static! { +-- +2.50.1 diff --git a/vendor/patches/portable-pty/0001-force-system-conpty.patch b/vendor/patches/portable-pty/0001-force-system-conpty.patch deleted file mode 100644 index 6074df57..00000000 --- a/vendor/patches/portable-pty/0001-force-system-conpty.patch +++ /dev/null @@ -1,37 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: herdr maintainers <maintainers@herdr.dev> -Date: Tue, 23 Jun 2026 00:00:00 +0000 -Subject: [PATCH] force system ConPTY - -Herdr should use the ConPTY functions exported by kernel32.dll and must not -load a bare conpty.dll from PATH. portable-pty's sideload probe is intentional -for WezTerm's bundled OpenConsole.exe/conpty.dll pair, but Herdr does not ship -that pair. - -Herdr issue: https://github.com/ogulcancelik/herdr/issues/761 -Vendored base: portable-pty 0.9.0 ---- - vendor/portable-pty/src/win/psuedocon.rs | 9 +-------- - 1 file changed, 1 insertion(+), 8 deletions(-) - -diff --git a/vendor/portable-pty/src/win/psuedocon.rs b/vendor/portable-pty/src/win/psuedocon.rs -index 0000000..0000000 100644 ---- a/vendor/portable-pty/src/win/psuedocon.rs -+++ b/vendor/portable-pty/src/win/psuedocon.rs -@@ -49,14 +49,7 @@ fn load_conpty() -> ConPtyFuncs { - "this system does not support conpty. Windows 10 October 2018 or newer is required", - ); -- -- // We prefer to use a sideloaded conpty.dll and openconsole.exe host deployed -- // alongside the application. We check for this after checking for kernel -- // support so that we don't try to proceed and do something crazy. -- if let Ok(sideloaded) = ConPtyFuncs::open(Path::new("conpty.dll")) { -- sideloaded -- } else { -- kernel -- } -+ -+ kernel - } - - lazy_static! { diff --git a/vendor/portable-pty.patches.md b/vendor/portable-pty.patches.md index 7e6d2743..83976ac5 100644 --- a/vendor/portable-pty.patches.md +++ b/vendor/portable-pty.patches.md @@ -4,15 +4,18 @@ This file tracks intentional local changes applied on top of the vendored `portable-pty` source. Remove a patch only when the upstream crate contains an equivalent fix or exposes an option that lets Herdr keep the same behavior. -## 0001 force system ConPTY +## 0001 control ConPTY loading status: active -patch: `vendor/patches/portable-pty/0001-force-system-conpty.patch` +patch: `vendor/patches/portable-pty/0001-control-conpty-loading.patch` -herdr issue: https://github.com/ogulcancelik/herdr/issues/761 +herdr issues: -upstream discussion: none found +- https://github.com/ogulcancelik/herdr/issues/761 +- https://github.com/ogulcancelik/herdr/issues/1533 + +upstream discussion: https://github.com/microsoft/terminal/issues/17452 upstream pr: none @@ -20,16 +23,23 @@ vendored base: `portable-pty 0.9.0` local files: +- `vendor/portable-pty/Cargo.toml` +- `vendor/portable-pty/Cargo.toml.orig` - `vendor/portable-pty/src/win/psuedocon.rs` -reason: `portable-pty` intentionally probes a bare `conpty.dll` after verifying -that `kernel32.dll` exports the ConPTY API. That is useful for WezTerm's bundled -`OpenConsole.exe` and `conpty.dll` pair, but Herdr does not ship that pair and -must not load another application's `conpty.dll` from `PATH`. +reason: `portable-pty` intentionally probes a bare `conpty.dll` through the DLL +search path. Herdr must never load another application's DLL from `PATH`. The +Windows package ships a pinned Microsoft ConPTY runtime under `conpty/`; Herdr +verifies the exact DLL and x64/ARM64 host hashes, rejects reparse points and +unexpected files, then loads the DLL by absolute path with its dependency search +limited to that directory and System32. Installations without a bundle continue +using the ConPTY exports from the already loaded `kernel32.dll`. Set +`HERDR_WINDOWS_CONPTY=system` to bypass the bundle during compatibility +recovery. -remove when: upstream `portable-pty` no longer loads bare `conpty.dll` from the -DLL search path, upstream exposes a way for consumers to force system ConPTY, or -Herdr replaces the Windows PTY backend. +remove when: upstream `portable-pty` exposes hash-verified app-local and system +ConPTY selection with constrained DLL loading and no bare DLL search, or Herdr +replaces the Windows PTY backend. verification: @@ -37,8 +47,9 @@ verification: python3 -m unittest scripts.test_vendor_portable_pty ``` -On Windows, also verify that pane creation succeeds when `PATH` contains a -directory with `conpty.dll`. +On Windows, run the enhanced-input CI probe against the packaged `conpty/` +bundle. It verifies the exact OpenConsole process path on Server 2022 and 2025, +rejects a tampered bundle, and verifies the explicit system fallback. ## 0002 expose Windows raw command tails diff --git a/vendor/portable-pty/Cargo.toml b/vendor/portable-pty/Cargo.toml index b8d11230..8a33abf9 100644 --- a/vendor/portable-pty/Cargo.toml +++ b/vendor/portable-pty/Cargo.toml @@ -102,8 +102,8 @@ version = "1.3" [target."cfg(windows)".dependencies.lazy_static] version = "1.4" -[target."cfg(windows)".dependencies.shared_library] -version = "0.1" +[target."cfg(windows)".dependencies.sha2] +version = "0.10" [target."cfg(windows)".dependencies.winapi] version = "0.3" @@ -112,6 +112,7 @@ features = [ "consoleapi", "handleapi", "fileapi", + "libloaderapi", "namedpipeapi", "synchapi", ] diff --git a/vendor/portable-pty/Cargo.toml.orig b/vendor/portable-pty/Cargo.toml.orig index 0c3d6b8d..21abe3b6 100644 --- a/vendor/portable-pty/Cargo.toml.orig +++ b/vendor/portable-pty/Cargo.toml.orig @@ -27,12 +27,13 @@ serde_support = ["serde", "serde_derive"] [target."cfg(windows)".dependencies] bitflags = "1.3" lazy_static = "1.4" -shared_library = "0.1" +sha2 = "0.10" winapi = { version = "0.3", features = [ "winuser", "consoleapi", "handleapi", "fileapi", + "libloaderapi", "namedpipeapi", "synchapi", ]} diff --git a/vendor/portable-pty/src/win/psuedocon.rs b/vendor/portable-pty/src/win/psuedocon.rs index 133eab52..21700f70 100644 --- a/vendor/portable-pty/src/win/psuedocon.rs +++ b/vendor/portable-pty/src/win/psuedocon.rs @@ -4,17 +4,24 @@ use crate::win::procthreadattr::ProcThreadAttributeList; use anyhow::{bail, ensure, Error}; use filedescriptor::{FileDescriptor, OwnedHandle}; use lazy_static::lazy_static; -use shared_library::shared_library; -use std::ffi::OsString; -use std::io::Error as IoError; -use std::os::windows::ffi::OsStringExt; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::io::{Error as IoError, Read}; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::os::windows::fs::MetadataExt; use std::os::windows::io::{AsRawHandle, FromRawHandle}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::{mem, ptr}; -use winapi::shared::minwindef::DWORD; +use winapi::shared::minwindef::{DWORD, HMODULE}; use winapi::shared::winerror::{HRESULT, S_OK}; use winapi::um::handleapi::*; +use winapi::um::libloaderapi::{ + FreeLibrary, GetModuleHandleW, GetProcAddress, LoadLibraryExW, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; use winapi::um::processthreadsapi::*; use winapi::um::winbase::{ CREATE_UNICODE_ENVIRONMENT, EXTENDED_STARTUPINFO_PRESENT, STARTF_USESTDHANDLES, STARTUPINFOEXW, @@ -30,26 +37,249 @@ pub const PSEUDOCONSOLE_WIN32_INPUT_MODE: DWORD = 0x4; #[allow(dead_code)] pub const PSEUDOCONSOLE_PASSTHROUGH_MODE: DWORD = 0x8; -shared_library!(ConPtyFuncs, - pub fn CreatePseudoConsole( - size: COORD, - hInput: HANDLE, - hOutput: HANDLE, - flags: DWORD, - hpc: *mut HPCON - ) -> HRESULT, - pub fn ResizePseudoConsole(hpc: HPCON, size: COORD) -> HRESULT, - pub fn ClosePseudoConsole(hpc: HPCON), -); +type CreatePseudoConsoleFn = unsafe extern "system" fn( + size: COORD, + h_input: HANDLE, + h_output: HANDLE, + flags: DWORD, + hpc: *mut HPCON, +) -> HRESULT; +type ResizePseudoConsoleFn = unsafe extern "system" fn(hpc: HPCON, size: COORD) -> HRESULT; +type ClosePseudoConsoleFn = unsafe extern "system" fn(hpc: HPCON); + +// These fields intentionally mirror the exported Win32 symbol names. +#[allow(non_snake_case)] +struct ConPtyFuncs { + module: HMODULE, + owned: bool, + CreatePseudoConsole: CreatePseudoConsoleFn, + ResizePseudoConsole: ResizePseudoConsoleFn, + ClosePseudoConsole: ClosePseudoConsoleFn, +} + +unsafe impl Send for ConPtyFuncs {} +unsafe impl Sync for ConPtyFuncs {} + +impl Drop for ConPtyFuncs { + fn drop(&mut self) { + if self.owned { + unsafe { FreeLibrary(self.module) }; + } + } +} + +impl ConPtyFuncs { + unsafe fn from_module(module: HMODULE, owned: bool) -> Result<Self, String> { + if module.is_null() { + return Err(IoError::last_os_error().to_string()); + } + Ok(Self { + module, + owned, + CreatePseudoConsole: load_symbol(module, b"CreatePseudoConsole\0")?, + ResizePseudoConsole: load_symbol(module, b"ResizePseudoConsole\0")?, + ClosePseudoConsole: load_symbol(module, b"ClosePseudoConsole\0")?, + }) + } +} + +unsafe fn load_symbol<T: Copy>(module: HMODULE, name: &'static [u8]) -> Result<T, String> { + let symbol = GetProcAddress(module, name.as_ptr().cast()); + if symbol.is_null() { + return Err(format!( + "missing symbol {}", + String::from_utf8_lossy(&name[..name.len() - 1]) + )); + } + Ok(mem::transmute_copy(&symbol)) +} + +fn load_system_conpty() -> Result<ConPtyFuncs, String> { + let kernel_name = wide_string(OsStr::new("kernel32.dll")); + let module = unsafe { GetModuleHandleW(kernel_name.as_ptr()) }; + unsafe { ConPtyFuncs::from_module(module, false) } +} + +fn load_app_local_conpty(path: &Path) -> Result<ConPtyFuncs, String> { + let wide_path = wide_string(path.as_os_str()); + let module = unsafe { + LoadLibraryExW( + wide_path.as_ptr(), + ptr::null_mut(), + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + match unsafe { ConPtyFuncs::from_module(module, true) } { + Ok(functions) => Ok(functions), + Err(error) => { + if !module.is_null() { + unsafe { FreeLibrary(module) }; + } + Err(error) + } + } +} + +fn wide_string(value: &OsStr) -> Vec<u16> { + value.encode_wide().chain(Some(0)).collect() +} fn load_conpty() -> ConPtyFuncs { // If the kernel doesn't export these functions then their system is // too old and we cannot run. - let kernel = ConPtyFuncs::open(Path::new("kernel32.dll")).expect( - "this system does not support conpty. Windows 10 October 2018 or newer is required", - ); + let system = load_system_conpty().unwrap_or_else(|error| { + panic!( + "this system does not support conpty. Windows 10 October 2018 or newer is required: {}", + error + ) + }); + + if std::env::var("HERDR_WINDOWS_CONPTY").as_deref() == Ok("system") { + log::warn!("HERDR_WINDOWS_CONPTY=system disables Herdr's bundled ConPTY"); + return system; + } + + match app_local_conpty_path() { + Ok(Some(path)) => load_app_local_conpty(&path).unwrap_or_else(|error| { + panic!( + "failed to load verified app-local ConPTY from {}: {error}; set HERDR_WINDOWS_CONPTY=system to use the Windows system ConPTY", + path.display() + ) + }), + Ok(None) => system, + Err(error) => panic!( + "Herdr's app-local ConPTY bundle is invalid: {}; reinstall Herdr or set HERDR_WINDOWS_CONPTY=system to use the Windows system ConPTY", + error + ), + } +} + +const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; +const CONPTY_FILES: &[(&str, &str)] = &[ + ( + "conpty.dll", + "39fba2713e2495117b1591ae8c32a3b904bea7aa66069cf7815e2844c76d75d8", + ), + ( + "x64/OpenConsole.exe", + "b7fd936c2668b87b9ecf7b3366dc6568afc1c6f981874cba3e955a1c35cf8160", + ), + ( + "arm64/OpenConsole.exe", + "ed7622fd0d3bedc9ab9f122f5e58edf0def9e7999224f52dd395ba9f54edbe09", + ), +]; + +fn app_local_conpty_path() -> Result<Option<PathBuf>, String> { + if std::env::consts::ARCH != "x86_64" { + return Ok(None); + } + let executable = std::env::current_exe() + .map_err(|error| format!("cannot locate the Herdr executable: {error}"))?; + let executable_dir = executable + .parent() + .ok_or_else(|| "Herdr executable has no parent directory".to_string())?; + let bundle = executable_dir.join("conpty"); + if !bundle.exists() { + return Ok(None); + } + reject_reparse_point(&bundle)?; + + let mut expected = BTreeSet::from(["herdr-conpty.json".to_string()]); + for (relative, expected_hash) in CONPTY_FILES { + expected.insert((*relative).to_string()); + verify_bundle_file(&bundle, relative, expected_hash)?; + } + let marker = bundle.join("herdr-conpty.json"); + verify_regular_file(&marker)?; + + let actual = bundle_files(&bundle)?; + if actual != expected { + return Err(format!( + "unexpected bundle layout; expected {expected:?}, found {actual:?}" + )); + } + Ok(Some(bundle.join("conpty.dll"))) +} + +fn verify_bundle_file(root: &Path, relative: &str, expected_hash: &str) -> Result<(), String> { + let path = root.join(relative); + verify_regular_file(&path)?; + let mut file = File::open(&path).map_err(|error| format!("cannot open {}: {error}", path.display()))?; + let mut digest = Sha256::new(); + let mut buffer = [0; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .map_err(|error| format!("cannot read {}: {error}", path.display()))?; + if read == 0 { + break; + } + digest.update(&buffer[..read]); + } + let actual_hash = format!("{:x}", digest.finalize()); + if actual_hash != expected_hash { + return Err(format!( + "{} has hash {actual_hash}, expected {expected_hash}", + path.display() + )); + } + Ok(()) +} + +fn verify_regular_file(path: &Path) -> Result<(), String> { + let metadata = path + .symlink_metadata() + .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; + if !metadata.is_file() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!("{} is not a regular file", path.display())); + } + Ok(()) +} + +fn reject_reparse_point(path: &Path) -> Result<(), String> { + let metadata = path + .symlink_metadata() + .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!("{} is not a regular directory", path.display())); + } + Ok(()) +} + +fn bundle_files(root: &Path) -> Result<BTreeSet<String>, String> { + fn visit(root: &Path, directory: &Path, files: &mut BTreeSet<String>) -> Result<(), String> { + reject_reparse_point(directory)?; + for entry in std::fs::read_dir(directory) + .map_err(|error| format!("cannot read {}: {error}", directory.display()))? + { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let metadata = path + .symlink_metadata() + .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(format!("{} is a reparse point", path.display())); + } + if metadata.is_dir() { + visit(root, &path, files)?; + } else if metadata.is_file() { + let relative = path + .strip_prefix(root) + .map_err(|error| error.to_string())? + .to_string_lossy() + .replace('\\', "/"); + files.insert(relative); + } else { + return Err(format!("{} is not a regular file", path.display())); + } + } + Ok(()) + } - kernel + let mut files = BTreeSet::new(); + visit(root, root, &mut files)?; + Ok(files) } lazy_static! { diff --git a/website/install.ps1 b/website/install.ps1 index 8698b014..ce510e7b 100644 --- a/website/install.ps1 +++ b/website/install.ps1 @@ -3,6 +3,7 @@ param( [string]$Channel = $env:HERDR_CHANNEL, [string]$ManifestUrl = $env:HERDR_MANIFEST_URL, [string]$InstallDir = $env:HERDR_INSTALL_DIR, + [string]$ExpectedBuildId = $env:HERDR_EXPECTED_BUILD_ID, [int]$Retain = 3 ) @@ -106,19 +107,40 @@ function Get-ManifestAsset { $asset = $property.Value if ($asset -is [string]) { + $url = [string]$asset return [PSCustomObject]@{ - Url = $asset + Url = $url Sha256 = $null + Format = if ($url.EndsWith(".zip", [System.StringComparison]::OrdinalIgnoreCase)) { "zip" } else { "exe" } } } - if ([string]::IsNullOrWhiteSpace([string]$asset.url)) { + $urlProperty = $asset.PSObject.Properties["url"] + if ($null -eq $urlProperty -or [string]::IsNullOrWhiteSpace([string]$urlProperty.Value)) { throw "Release manifest asset $Target is missing a URL." } + $url = [string]$urlProperty.Value + $formatProperty = $asset.PSObject.Properties["format"] + $format = if ($null -eq $formatProperty -or [string]::IsNullOrWhiteSpace([string]$formatProperty.Value)) { + if ($url.EndsWith(".zip", [System.StringComparison]::OrdinalIgnoreCase)) { "zip" } else { "exe" } + } else { + [string]$formatProperty.Value + } + if ($format -notin @("zip", "exe")) { + throw "Release manifest asset $Target has unsupported format '$format'." + } + $shaProperty = $asset.PSObject.Properties["sha256"] + $sha256 = if ($null -eq $shaProperty -or [string]::IsNullOrWhiteSpace([string]$shaProperty.Value)) { + $null + } else { + [string]$shaProperty.Value + } + return [PSCustomObject]@{ - Url = [string]$asset.url - Sha256 = if ([string]::IsNullOrWhiteSpace([string]$asset.sha256)) { $null } else { [string]$asset.sha256 } + Url = $url + Sha256 = $sha256 + Format = $format } } @@ -130,7 +152,8 @@ function ConvertTo-ManifestObject { } $json = $Manifest.TrimStart([char]0xFEFF) - if ($json.StartsWith("")) { + $utf8BomDecodedAsLatin1 = [string]::Concat([char]0x00EF, [char]0x00BB, [char]0x00BF) + if ($json.StartsWith($utf8BomDecodedAsLatin1)) { $json = $json.Substring(3) } @@ -144,7 +167,10 @@ function Test-FileDigest { ) if ([string]::IsNullOrWhiteSpace($ExpectedDigest)) { - return + throw "A SHA-256 checksum is required for $Path." + } + if ($ExpectedDigest -notmatch '^[0-9a-fA-F]{64}$') { + throw "Invalid SHA-256 checksum for $Path." } $sha256 = [System.Security.Cryptography.SHA256]::Create() @@ -159,6 +185,118 @@ function Test-FileDigest { } } +function Test-RegularFile { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + return $false + } + $item = Get-Item -LiteralPath $Path -Force + return -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) +} + +function Test-RegularDirectory { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + return $false + } + $item = Get-Item -LiteralPath $Path -Force + return -not ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) +} + +function Test-HerdrReleaseComplete { + param( + [string]$ReleaseDir, + [string]$Format + ) + + if (-not (Test-RegularDirectory -Path $ReleaseDir)) { + return $false + } + $herdrExe = Join-Path $ReleaseDir "herdr.exe" + if (-not (Test-RegularFile -Path $herdrExe)) { + return $false + } + if ($Format -eq "exe") { + return $true + } + + $conptyRoot = Join-Path $ReleaseDir "conpty" + if (-not (Test-RegularDirectory -Path $conptyRoot) -or + -not (Test-RegularDirectory -Path (Join-Path $conptyRoot "x64")) -or + -not (Test-RegularDirectory -Path (Join-Path $conptyRoot "arm64"))) { + return $false + } + $markerPath = Join-Path $conptyRoot "herdr-conpty.json" + $required = @( + "conpty/conpty.dll", + "conpty/x64/OpenConsole.exe", + "conpty/arm64/OpenConsole.exe", + "THIRD-PARTY-NOTICES/Microsoft.Windows.Console.ConPTY-LICENSE.txt", + "THIRD-PARTY-NOTICES/Microsoft.Windows.Console.ConPTY-NOTICE.md" + ) + foreach ($relative in $required) { + if (-not (Test-RegularFile -Path (Join-Path $ReleaseDir ($relative -replace '/', '\')))) { + return $false + } + } + if (-not (Test-RegularFile -Path $markerPath)) { + return $false + } + + try { + $marker = ConvertTo-ManifestObject -Manifest (Get-Content -LiteralPath $markerPath -Raw) + $schemaProperty = $marker.PSObject.Properties["schema_version"] + $packageProperty = $marker.PSObject.Properties["package"] + $versionProperty = $marker.PSObject.Properties["version"] + $architectureProperty = $marker.PSObject.Properties["architecture"] + $filesProperty = $marker.PSObject.Properties["files"] + if ($null -eq $schemaProperty -or [int]$schemaProperty.Value -ne 1 -or + $null -eq $packageProperty -or [string]$packageProperty.Value -ne "Microsoft.Windows.Console.ConPTY" -or + $null -eq $versionProperty -or [string]::IsNullOrWhiteSpace([string]$versionProperty.Value) -or + $null -eq $architectureProperty -or [string]$architectureProperty.Value -ne "x86_64" -or + $null -eq $filesProperty) { + return $false + } + + $expectedConptyFiles = @( + "conpty/conpty.dll", + "conpty/x64/OpenConsole.exe", + "conpty/arm64/OpenConsole.exe" + ) + $markerFileNames = @($filesProperty.Value.PSObject.Properties | ForEach-Object { $_.Name }) + if (@(Compare-Object $expectedConptyFiles $markerFileNames).Count -ne 0) { + return $false + } + + $bundleEntries = @(Get-ChildItem -LiteralPath $conptyRoot -Force -Recurse) + if (@($bundleEntries | Where-Object { + $_.Attributes -band [IO.FileAttributes]::ReparsePoint + }).Count -ne 0) { + return $false + } + $releaseRoot = [System.IO.Path]::GetFullPath($ReleaseDir).TrimEnd('\') + $actualBundleFiles = @($bundleEntries | Where-Object { -not $_.PSIsContainer } | ForEach-Object { + $_.FullName.Substring($releaseRoot.Length + 1).Replace('\', '/') + }) + $expectedBundleFiles = @($expectedConptyFiles) + "conpty/herdr-conpty.json" + if (@(Compare-Object $expectedBundleFiles $actualBundleFiles).Count -ne 0) { + return $false + } + foreach ($relative in $expectedConptyFiles) { + $digestProperty = $filesProperty.Value.PSObject.Properties[$relative] + if ($null -eq $digestProperty) { + return $false + } + Test-FileDigest -Path (Join-Path $ReleaseDir ($relative -replace '/', '\')) -ExpectedDigest ([string]$digestProperty.Value) + } + } catch { + return $false + } + return $true +} + function Invoke-WithInstallLock { param( [string]$LockPath, @@ -286,7 +424,7 @@ function Remove-OldReleases { $currentFullPath = [System.IO.Path]::GetFullPath($CurrentReleaseDir) $releaseDirs = Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -ErrorAction SilentlyContinue | - Where-Object { -not $_.Name.StartsWith(".staging.") } | + Where-Object { -not $_.Name.StartsWith(".staging.") -and -not $_.Name.StartsWith(".backup.") } | Sort-Object LastWriteTimeUtc -Descending $kept = 0 foreach ($dir in $releaseDirs) { @@ -367,6 +505,7 @@ $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) { } else { $env:HERDR_HOME } +$herdrHome = [System.IO.Path]::GetFullPath($herdrHome) $standaloneRoot = Join-Path $herdrHome "packages\standalone" $releasesDir = Join-Path $standaloneRoot "releases" $currentDir = Join-Path $standaloneRoot "current" @@ -396,6 +535,9 @@ if (-not [string]::IsNullOrWhiteSpace($existingHerdr) -and -not (Test-PathStarts Write-Step "Fetching Herdr $Channel manifest" $manifest = ConvertTo-ManifestObject -Manifest (Invoke-RestMethod -Uri $ManifestUrl) +if (-not [string]::IsNullOrWhiteSpace($ExpectedBuildId) -and [string]$manifest.build_id -ne $ExpectedBuildId) { + throw "Preview manifest changed while updating. Expected build $ExpectedBuildId but found $($manifest.build_id). Run herdr update again." +} $versionIdentity = Resolve-HerdrVersion -Manifest $manifest -SelectedChannel $Channel $asset = Get-ManifestAsset -Manifest $manifest -Target $target $safeVersionIdentity = $versionIdentity -replace '[^0-9A-Za-z._-]', '-' @@ -410,31 +552,56 @@ try { Invoke-WithInstallLock -LockPath $lockPath -Script { Remove-StaleInstallArtifacts -ReleasesDir $releasesDir - if (-not (Test-Path -LiteralPath (Join-Path $releaseDir "herdr.exe") -PathType Leaf)) { - if (Test-Path -LiteralPath $releaseDir) { - Remove-Item -LiteralPath $releaseDir -Recurse -Force - } - - $downloadPath = Join-Path $tempDir "herdr.exe" + if (-not (Test-HerdrReleaseComplete -ReleaseDir $releaseDir -Format $asset.Format)) { + $downloadPath = Join-Path $tempDir "herdr-download.$($asset.Format)" $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID" Write-Step "Downloading Herdr" Invoke-WebRequest -Uri $asset.Url -OutFile $downloadPath Test-FileDigest -Path $downloadPath -ExpectedDigest $asset.Sha256 - New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null - Copy-Item -LiteralPath $downloadPath -Destination (Join-Path $stagingDir "herdr.exe") - Move-Item -LiteralPath $stagingDir -Destination $releaseDir + if ($asset.Format -eq "zip") { + Expand-Archive -LiteralPath $downloadPath -DestinationPath $stagingDir + } else { + New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + Copy-Item -LiteralPath $downloadPath -Destination (Join-Path $stagingDir "herdr.exe") + } + if (-not (Test-HerdrReleaseComplete -ReleaseDir $stagingDir -Format $asset.Format)) { + throw "Downloaded Herdr package is incomplete or failed ConPTY verification." + } + $stagedHerdr = Join-Path $stagingDir "herdr.exe" + & $stagedHerdr --version *> $null + if ($LASTEXITCODE -ne 0) { + throw "Downloaded Herdr command failed verification: $stagedHerdr --version" + } + $backupDir = $null + if (Test-Path -LiteralPath $releaseDir) { + $backupDir = Join-Path $releasesDir ".backup.$releaseName.$([System.Guid]::NewGuid().ToString('N'))" + Move-Item -LiteralPath $releaseDir -Destination $backupDir + } + try { + Move-Item -LiteralPath $stagingDir -Destination $releaseDir + } catch { + if ($null -ne $backupDir -and -not (Test-Path -LiteralPath $releaseDir)) { + Move-Item -LiteralPath $backupDir -Destination $releaseDir + } + throw + } + if ($null -ne $backupDir) { + Remove-Item -LiteralPath $backupDir -Recurse -Force -ErrorAction SilentlyContinue + } + } + + $releaseHerdr = Join-Path $releaseDir "herdr.exe" + & $releaseHerdr --version *> $null + if ($LASTEXITCODE -ne 0) { + throw "Installed Herdr command failed verification: $releaseHerdr --version" } + Get-ChildItem -LiteralPath $releasesDir -Force -Directory -Filter ".backup.$releaseName.*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue Set-ManagedJunction -LinkPath $currentDir -TargetPath $releaseDir -ManagedTargetPrefix $releasesDir Set-ManagedJunction -LinkPath $visibleBinDir -TargetPath $releaseDir -ManagedTargetPrefix $standaloneRoot -AllowLegacyHerdrBinMigration $allowLegacyVisibleBinMigration - $herdrCommand = Join-Path $visibleBinDir "herdr.exe" - & $herdrCommand --version *> $null - if ($LASTEXITCODE -ne 0) { - throw "Installed Herdr command failed verification: $herdrCommand --version" - } - Remove-OldReleases -ReleasesDir $releasesDir -CurrentReleaseDir $releaseDir -Keep $Retain } } finally { |
