From 8afd52ae57f9237f4f46138ad224dfc621d7ba59 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Sat, 25 Jul 2026 02:40:14 +0300 Subject: fix: bundle modern ConPTY on Windows (#1828) refs #1533 refs #1644 --- scripts/package_windows_conpty.ps1 | 53 +++ scripts/package_windows_conpty.py | 245 ++++++++++++++ scripts/preview.py | 6 +- scripts/test_package_windows_conpty.py | 171 ++++++++++ scripts/test_preview.py | 29 +- scripts/test_vendor_portable_pty.py | 13 +- scripts/windows_conpty_enhanced_input_probe.ps1 | 418 ++++++++++++++++++++++++ scripts/windows_install_conpty_package_test.ps1 | 146 +++++++++ scripts/windows_smoke_conpty_path.ps1 | 36 +- 9 files changed, 1108 insertions(+), 9 deletions(-) create mode 100644 scripts/package_windows_conpty.ps1 create mode 100644 scripts/package_windows_conpty.py create mode 100644 scripts/test_package_windows_conpty.py create mode 100644 scripts/windows_conpty_enhanced_input_probe.ps1 create mode 100644 scripts/windows_install_conpty_package_test.ps1 (limited to 'scripts') 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(" 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(" 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(" 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 = """ + + + Microsoft.Windows.Console.ConPTY + 1.24.260710001 + MIT + + +""" + 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 } -- cgit v1.2.3