From 2863b715132fe29e53089e06f105943d1df0b3b4 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Wed, 5 Aug 2026 19:34:24 +0300 Subject: feat(windows): support remote attach to unix hosts (#2329) * feat(windows): support remote attach to unix hosts * fix(remote): preserve unix bridge behavior --- Cargo.lock | 1 + Cargo.toml | 4 + .../website/src/content/docs/configuration.mdx | 4 +- .../website/src/content/docs/ja/configuration.mdx | 4 +- .../src/content/docs/ja/persistence-remote.mdx | 6 +- .../website/src/content/docs/ja/windows-beta.mdx | 16 +- .../src/content/docs/persistence-remote.mdx | 6 +- .../next/website/src/content/docs/windows-beta.mdx | 16 +- .../src/content/docs/zh-cn/configuration.mdx | 4 +- .../src/content/docs/zh-cn/persistence-remote.mdx | 6 +- .../src/content/docs/zh-cn/windows-beta.mdx | 16 +- src/client/mod.rs | 247 +- src/ipc.rs | 100 +- src/main.rs | 1 - src/platform/fallback.rs | 79 + src/platform/linux.rs | 6 + src/platform/macos.rs | 6 + src/platform/mod.rs | 16 +- src/platform/unix_common.rs | 131 + src/platform/windows.rs | 201 +- src/platform/windows/clipboard_image.rs | 409 +++ src/remote.rs | 144 +- src/remote/attach.rs | 3351 ++++++++++++++++++++ src/remote/host_unix.rs | 69 + src/remote/unix.rs | 3111 ------------------ src/update.rs | 5 + 26 files changed, 4582 insertions(+), 3377 deletions(-) create mode 100644 src/platform/unix_common.rs create mode 100644 src/platform/windows/clipboard_image.rs create mode 100644 src/remote/attach.rs create mode 100644 src/remote/host_unix.rs delete mode 100644 src/remote/unix.rs diff --git a/Cargo.lock b/Cargo.lock index 980fcb5d..fbf3ebcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,6 +697,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-width", + "widestring", "windows-sys", "wmi", ] diff --git a/Cargo.toml b/Cargo.toml index 84949e2d..52455835 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,11 +49,15 @@ schemars = { version = "1.2.1", features = ["derive"] } portable-pty = { path = "vendor/portable-pty" } [target.'cfg(windows)'.dependencies] +# UTF-16 SDDL input for interprocess security descriptors. +widestring = "1.2" wmi = { version = "0.18.4", default-features = false } windows-sys = { version = "0.61.2", features = [ "Wdk_System_Threading", "Win32_Foundation", "Win32_Globalization", + "Win32_Security", + "Win32_Storage_FileSystem", "Win32_System_DataExchange", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index e0c41c28..ce4db4e2 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -98,14 +98,14 @@ Deleting a worktree checkout is explicit. Use `Delete worktree checkout...` on a ## Remote attach -Remote attach manages its SSH connection with a temporary keepalive and connection-reuse fallback by default. +Remote attach manages its SSH connection with temporary keepalives and, where supported, connection reuse by default. ```toml [remote] manage_ssh_config = true ``` -When enabled, `herdr --remote` writes a private temporary SSH config that includes your `~/.ssh/config` and `/etc/ssh/ssh_config` first, then adds fallback `ServerAliveInterval` and `ServerAliveCountMax` values. Your own keepalive settings win. Herdr also uses a private per-attach OpenSSH control socket to reuse the first authenticated connection. Set `manage_ssh_config = false` to run remote attach through plain `ssh` without Herdr's generated config or control socket. +When enabled, `herdr --remote` writes a private temporary SSH config that includes your user and system SSH configs first, then adds fallback `ServerAliveInterval` and `ServerAliveCountMax` values. Your own keepalive settings win. Linux and macOS clients also use a private per-attach OpenSSH control socket to reuse the first authenticated connection; Windows OpenSSH does not. Set `manage_ssh_config = false` to run remote attach through plain `ssh` without Herdr's generated config or control socket. ## Keybindings diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index acc958fb..9f7c0e5e 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -98,14 +98,14 @@ worktree チェックアウトの削除は明示的に行います。グルー ## リモートアタッチ -リモートアタッチは、デフォルトで一時的なキープアライブと接続再利用のフォールバックを使って SSH 接続を管理します。 +リモートアタッチは、デフォルトで一時的なキープアライブと、対応する環境では接続再利用を使って SSH 接続を管理します。 ```toml [remote] manage_ssh_config = true ``` -有効な場合、`herdr --remote` は最初に `~/.ssh/config` と `/etc/ssh/ssh_config` を取り込み、その後にフォールバックの `ServerAliveInterval` と `ServerAliveCountMax` の値を加えた、プライベートな一時 SSH 設定を書き込みます。ユーザー自身のキープアライブ設定が優先されます。Herdr は、最初に認証した接続を再利用するため、アタッチごとにプライベートな OpenSSH コントロールソケットも使います。Herdr が生成した設定やコントロールソケットを使わず、通常の `ssh` でリモートアタッチを実行するには `manage_ssh_config = false` を設定してください。 +有効な場合、`herdr --remote` は最初にユーザーとシステムの SSH 設定を取り込み、その後にフォールバックの `ServerAliveInterval` と `ServerAliveCountMax` の値を加えた、プライベートな一時 SSH 設定を書き込みます。ユーザー自身のキープアライブ設定が優先されます。Linux と macOS クライアントは、最初に認証した接続を再利用するため、アタッチごとにプライベートな OpenSSH コントロールソケットも使います。Windows OpenSSH ではこの接続再利用を使いません。Herdr が生成した設定やコントロールソケットを使わず、通常の `ssh` でリモートアタッチを実行するには `manage_ssh_config = false` を設定してください。 ## キーバインド diff --git a/docs/next/website/src/content/docs/ja/persistence-remote.mdx b/docs/next/website/src/content/docs/ja/persistence-remote.mdx index f8d3ff02..6788dd06 100644 --- a/docs/next/website/src/content/docs/ja/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/ja/persistence-remote.mdx @@ -63,11 +63,9 @@ Host workbox herdr --remote workbox ``` -リモートアタッチは x86_64 と aarch64 の Linux および macOS ホストをサポートします。Herdr はリモートのプラットフォームを確認し、リモートの `PATH` 上にある一致する `herdr` を優先し、次に `~/.local/bin/herdr` を確認します。一致するバイナリがない場合、対話的な実行では `~/.local/bin/herdr` へのインストールを提案します。非対話的な実行はホストを変更せずに失敗します。`~/.local/bin` がリモートの `PATH` にない場合、Herdr はインストール後に警告します。 +リモートアタッチは、Linux、macOS、Windows のローカルクライアントから、x86_64 と aarch64 の Linux および macOS ホストへの接続をサポートします。Herdr はリモートのプラットフォームを確認し、リモートの `PATH` 上にある一致する `herdr` を優先してから、一般的な直接インストール、Homebrew、mise、Nix プロファイルのパスを確認します。一致するバイナリがない場合、対話的な実行では `~/.local/bin/herdr` へのインストールを提案します。非対話的な実行はホストを変更せずに失敗します。`~/.local/bin` がリモートの `PATH` にない場合、Herdr はインストール後に警告します。Windows をリモートホストとして使うことはできません。 -ネイティブ Windows の `herdr --remote` は Windows ベータの範囲外です。Windows からはサーバーに SSH してそこで `herdr` を実行してください。 - -デフォルトでは、`herdr --remote` はあなたの SSH config を最初に include し、その後にフォールバックのキープアライブ設定を加えた一時的な SSH config を通してブリッジを実行します。既存のユーザーのキープアライブ設定が優先されます。Herdr が生成するブリッジ設定を使わず素の `ssh` を使うには `[remote].manage_ssh_config = false` を設定してください。 +デフォルトでは、`herdr --remote` はユーザーの SSH config を最初に include し、その後にフォールバックのキープアライブ設定を加えた一時的な SSH config を通してリモートセットアップとブリッジを実行します。既存のユーザーのキープアライブ設定が優先されます。Linux と macOS クライアントは接続再利用用のプライベートなアタッチ単位コントロールソケットも使いますが、Windows OpenSSH では使いません。Herdr が生成する設定やコントロールソケットを使わず通常の `ssh` を使うには `[remote].manage_ssh_config = false` を設定してください。 デフォルトでは、実行中のリモートサーバーの置き換えや再起動が必要な場合、リモートアタッチは通常の再起動/停止フローを使います。対応する実行中リモートサーバーで実験的なライブハンドオフにオプトインするには `--handoff` を渡します: 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 ee0f8567..8edbf7e0 100644 --- a/docs/next/website/src/content/docs/ja/windows-beta.mdx +++ b/docs/next/website/src/content/docs/ja/windows-beta.mdx @@ -26,6 +26,8 @@ Windows ベータビルドはプレビューチャンネルでのみ提供され | ローカル永続セッション | ベータ | | ConPTY によるネイティブペイン | ベータ | | Windows Terminal / PowerShell アプリからのアタッチ | ベータ | +| Linux/macOS ホストへの `herdr --remote` | ベータ | +| リモートクリップボード画像と画像ファイルのドロップ | ベータ | | `cmd.exe` ペイン | ベータ | | 起動時 cwd とワークスペースラベル | ベータ | | ペイン起動時の cwd | ベータ | @@ -47,14 +49,14 @@ Windows のエージェントプロセス検出は、ペインのシェルの子 | --- | --- | | シェルで `cd` した後のライブ cwd | 部分的 | | シェルインテグレーション/OSC7 によるライブ cwd | ベータ | -| エージェントへのクリップボード画像貼り付け | 未検証 | +| ローカルペインのエージェントへのクリップボード画像貼り付け | 未検証 | | CJK IME の変換候補位置 | 部分的 | | Kitty graphics のレンダリング | 未検証 | | ホストカーソル描画 | 部分的 | Herdr はペインを正しいディレクトリで起動でき、Herdr を起動したディレクトリから最初のワークスペースを作成できます。起動後の PowerShell のディレクトリ変更は別問題です: Herdr が調べられるプロセスのフィールドは、その後の論理的な `cd` の変化を確実には追跡しません。ライブ cwd の報告には Herdr インテグレーションかプロンプトのシェルインテグレーションを使ってください。 -Windows Terminal は特定のエージェント向けに画像貼り付け経路をサポートしているかもしれませんが、Herdr 自身のクリップボード画像リーダーはまだ Windows に配線されていません。Windows のクリップボードブリッジが実装・テストされるまで、`alt+v` の画像貼り付けは未検証として扱ってください。リモートクリップボードの画像ブリッジは別機能で、引き続き Unix/macOS の `herdr --remote` に紐づいています。 +`herdr --remote` では、設定されたリモート画像貼り付けキーが Windows のクリップボード画像を読み取り、リモートホストへ転送します。Windows Terminal にローカル画像ファイルをひとつドロップした場合も、そのファイルを転送してリモート側のパスを貼り付けます。ローカルのネイティブ Windows ペインへのクリップボード画像貼り付けは、引き続き未検証です。 Kitty graphics は実験的なままで、まだ Windows でのサポートを謳っていません。Windows Terminal での画像レンダリングを特にテストしているのでない限り、`experimental.kitty_graphics = false` のままにしてください。 @@ -92,22 +94,20 @@ Herdr のペインテキストコピーは Windows ベータで動作します | 機能 | 状況 | | --- | --- | | ダイレクトターミナルアタッチ | 未サポート | -| Windows バイナリからの `herdr --remote` | 未サポート | +| `herdr --remote` の接続先としての Windows | 未サポート | | ライブサーバーハンドオフ | 未サポート | | Unix ファイルディスクリプタのハンドオフ | 未サポート | | Unix フォアグラウンドプロセスグループ | 未サポート | -| リモートクリップボード画像ブリッジ | 未サポート | | プレフィックスによる入力ソース切り替え | 未サポート | | 署名済みバイナリ / SmartScreen 回避 | 未サポート | -Windows からのリモート作業は、サーバーに SSH してそこで `herdr` を実行してください: +Windows Terminal からは、Linux や macOS と同じリモートコマンドを使います: ```powershell -ssh you@server -herdr +herdr --remote workbox ``` -このモードでは Herdr はリモートホスト上で動きます。ネイティブ Windows の `herdr --remote` はベータの範囲外です。 +接続先ホストは Linux または macOS である必要があります。Herdr はインストール済みの Windows OpenSSH クライアントと SSH 設定を使います。Windows OpenSSH では Herdr の Unix コントロールソケットによる接続再利用を使わないため、セットアップ中の繰り返しプロンプトを避けるには Windows の `ssh-agent` による鍵認証を推奨します。 Windows のアップデートは Windows インストーラー経由で行われ、バージョン付きインストールジャンクションを更新します。アップデート後は実行中の Herdr セッションを再起動してください。ライブハンドオフは Unix 専用です。 diff --git a/docs/next/website/src/content/docs/persistence-remote.mdx b/docs/next/website/src/content/docs/persistence-remote.mdx index a365ac88..befbfb25 100644 --- a/docs/next/website/src/content/docs/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/persistence-remote.mdx @@ -63,11 +63,9 @@ Then attach with: herdr --remote workbox ``` -Remote attach supports Linux and macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a matching `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. If no matching binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. +Remote attach supports Linux, macOS, and Windows local clients connecting to Linux or macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a matching `herdr` already on the remote `PATH`, then checks common direct, Homebrew, mise, and Nix profile install paths. If no matching binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install. Windows is not supported as the remote host. -Native Windows `herdr --remote` is not part of the Windows beta. From Windows, SSH into the server and run `herdr` there. - -By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings and a private per-attach control socket for connection reuse. Existing user keepalive settings win. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket. +By default, `herdr --remote` runs remote setup and the bridge through a temporary SSH config that includes your SSH config first, then adds fallback keepalive settings. Existing user keepalive settings win. Linux and macOS clients also use a private per-attach control socket for connection reuse; Windows OpenSSH does not. Set `[remote].manage_ssh_config = false` to use plain `ssh` without Herdr's generated config or control socket. Remote attach uses your normal OpenSSH authentication. If the target uses a passphrase-protected key in a non-interactive shell, script, CI job, or mobile terminal that cannot show the passphrase prompt, load the key into ssh-agent first: diff --git a/docs/next/website/src/content/docs/windows-beta.mdx b/docs/next/website/src/content/docs/windows-beta.mdx index 6533ca6f..b21d1efb 100644 --- a/docs/next/website/src/content/docs/windows-beta.mdx +++ b/docs/next/website/src/content/docs/windows-beta.mdx @@ -26,6 +26,8 @@ For internal beta testing, `HERDR_MANIFEST_URL` can point the installer at a cus | Local persistent sessions | beta | | Native panes through ConPTY | beta | | Windows Terminal / PowerShell app attach | beta | +| `herdr --remote` to Linux/macOS hosts | beta | +| Remote clipboard images and image-file drops | beta | | `cmd.exe` panes | beta | | Startup cwd and workspace labels | beta | | Pane launch cwd | beta | @@ -47,14 +49,14 @@ Plugins support `windows` as a manifest platform in preview. GitHub install, loc | --- | --- | | Live cwd after shell `cd` | partial | | Live cwd via shell integration/OSC7 | beta | -| Clipboard image paste to agents | unverified | +| Clipboard image paste to agents in local panes | unverified | | CJK IME composition anchoring | partial | | Kitty graphics rendering | unverified | | Host cursor rendering | partial | Herdr can launch panes in the right directory and can create the initial workspace from the directory where you started Herdr. PowerShell directory changes after startup are different: the process field Herdr can inspect does not reliably track later logical `cd` changes. Use Herdr integrations or prompt shell integration for live cwd reporting. -Windows Terminal may support image paste paths for specific agents, but Herdr's own clipboard-image reader is not wired on Windows yet. Treat `alt+v` image paste as unverified until the Windows clipboard bridge is implemented and tested. Remote clipboard image bridging is separate and remains tied to Unix/macOS `herdr --remote`. +During `herdr --remote`, the configured remote image paste key reads a Windows clipboard image and transfers it to the remote host. Dropping one local image file into Windows Terminal also transfers that file and pastes its remote path. Clipboard image paste into a local native Windows pane remains unverified. Kitty graphics remains experimental and is not claimed as Windows-supported yet. Leave `experimental.kitty_graphics = false` unless you are specifically testing image rendering in Windows Terminal. @@ -92,22 +94,20 @@ For text paste, use `ctrl+shift+v` in Windows Terminal. Multiline text paste is | Capability | Status | | --- | --- | | Direct terminal attach | unsupported | -| `herdr --remote` from the Windows binary | unsupported | +| Windows as a `herdr --remote` target host | unsupported | | Live server handoff | unsupported | | Unix file-descriptor handoff | unsupported | | Unix foreground process groups | unsupported | -| Remote clipboard image bridge | unsupported | | Prefix input-source switching | unsupported | | Signed binary / SmartScreen avoidance | unsupported | -For remote work from Windows, SSH into the server and run `herdr` there: +From Windows Terminal, use the same remote command as Linux and macOS: ```powershell -ssh you@server -herdr +herdr --remote workbox ``` -That mode runs Herdr on the remote host. Native Windows `herdr --remote` is not part of the beta. +The target host must run Linux or macOS. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup. Windows updates run through the Windows installer and update the versioned install junction. Restart running Herdr sessions after updating. Live handoff is Unix-only. diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 2247dff8..0cb9ce42 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -98,14 +98,14 @@ Git 工作区行提供 worktree 操作。`New worktree` 会创建检出:如果 ## 远程连接 -远程连接默认使用临时保活设置和连接复用兜底来管理 SSH 连接。 +远程连接默认使用临时保活设置,并在支持时复用连接,以管理 SSH 连接。 ```toml [remote] manage_ssh_config = true ``` -启用后,`herdr --remote` 会写入一份私有的临时 SSH 配置:先包含你的 `~/.ssh/config` 和 `/etc/ssh/ssh_config`,再添加兜底的 `ServerAliveInterval` 和 `ServerAliveCountMax` 值。你自己的保活设置优先。Herdr 还会为每次远程连接使用私有的 OpenSSH control socket,以复用首次通过身份验证的连接。设置 `manage_ssh_config = false` 可通过普通 `ssh` 进行远程连接,不使用 Herdr 生成的配置或 control socket。 +启用后,`herdr --remote` 会写入一份私有的临时 SSH 配置:先包含用户和系统 SSH 配置,再添加兜底的 `ServerAliveInterval` 和 `ServerAliveCountMax` 值。你自己的保活设置优先。Linux 和 macOS 客户端还会为每次远程连接使用私有的 OpenSSH control socket,以复用首次通过身份验证的连接;Windows OpenSSH 不使用此复用方式。设置 `manage_ssh_config = false` 可通过普通 `ssh` 进行远程连接,不使用 Herdr 生成的配置或 control socket。 ## 按键绑定 diff --git a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx index a81a8469..f2b9fb70 100644 --- a/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx +++ b/docs/next/website/src/content/docs/zh-cn/persistence-remote.mdx @@ -63,11 +63,9 @@ Host workbox herdr --remote workbox ``` -远程连接支持 x86_64 和 aarch64 的 Linux 与 macOS 主机。Herdr 会检查远程平台,优先使用远程 `PATH` 上已有的匹配 `herdr`,然后检查 `~/.local/bin/herdr`。如果没有匹配的二进制文件,交互式运行会询问是否安装到 `~/.local/bin/herdr`;非交互式运行则直接失败,不会修改主机。如果 `~/.local/bin` 不在远程 `PATH` 上,Herdr 会在安装后发出警告。 +远程连接支持 Linux、macOS 和 Windows 本地客户端连接 x86_64 与 aarch64 的 Linux 和 macOS 主机。Herdr 会检查远程平台,优先使用远程 `PATH` 上已有的匹配 `herdr`,然后检查常见的直接安装、Homebrew、mise 和 Nix profile 路径。如果没有匹配的二进制文件,交互式运行会询问是否安装到 `~/.local/bin/herdr`;非交互式运行则直接失败,不会修改主机。如果 `~/.local/bin` 不在远程 `PATH` 上,Herdr 会在安装后发出警告。Windows 不能作为远程主机。 -Windows 原生的 `herdr --remote` 不在 Windows 测试版范围内。在 Windows 上请 SSH 到服务器并在那里运行 `herdr`。 - -默认情况下,`herdr --remote` 通过一个临时 SSH 配置运行桥接: 先包含你的 SSH 配置,再补充兜底的保活设置。已有的用户保活设置优先。设置 `[remote].manage_ssh_config = false` 可以不用 Herdr 生成的桥接配置,而使用普通 `ssh`。 +默认情况下,`herdr --remote` 通过一个临时 SSH 配置运行远程设置和桥接: 先包含你的 SSH 配置,再补充兜底的保活设置。已有的用户保活设置优先。Linux 和 macOS 客户端还会添加一个私有的单次连接 control socket 来复用连接;Windows OpenSSH 不使用此方式。设置 `[remote].manage_ssh_config = false` 可以不用 Herdr 生成的配置或 control socket,而使用普通 `ssh`。 默认情况下,如果需要替换或重启运行中的远程服务器,远程连接使用常规的重启/停止流程。要对受支持的运行中远程服务器启用实验性实时交接,加上 `--handoff`: 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 781a6f96..d5c7b7bc 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 @@ -26,6 +26,8 @@ Windows 测试版构建只通过预览通道发布。在 Windows 上,Herdr 默 | 本地持久会话 | 测试版 | | 通过 ConPTY 的原生窗格 | 测试版 | | Windows Terminal / PowerShell 应用连接 | 测试版 | +| 连接 Linux/macOS 主机的 `herdr --remote` | 测试版 | +| 远程剪贴板图像和图像文件拖放 | 测试版 | | `cmd.exe` 窗格 | 测试版 | | 启动 cwd 和工作区标签 | 测试版 | | 窗格启动 cwd | 测试版 | @@ -47,14 +49,14 @@ Windows 智能体进程检测会扫描窗格 shell 的后代进程,识别直接 | --- | --- | | shell `cd` 之后的实时 cwd | 部分支持 | | 通过 shell 集成/OSC7 的实时 cwd | 测试版 | -| 向智能体粘贴剪贴板图像 | 未验证 | +| 向本地窗格中的智能体粘贴剪贴板图像 | 未验证 | | CJK 输入法候选窗口定位 | 部分支持 | | Kitty graphics 渲染 | 未验证 | | 主机光标渲染 | 部分支持 | Herdr 可以在正确的目录中启动窗格,并能从你启动 Herdr 的目录创建初始工作区。启动之后 PowerShell 的目录变化则不同: Herdr 能检查的进程字段无法可靠跟踪后续的逻辑 `cd` 变化。实时 cwd 上报请使用 Herdr 集成或提示符 shell 集成。 -Windows Terminal 可能为特定智能体支持图像粘贴路径,但 Herdr 自己的剪贴板图像读取器尚未在 Windows 上接通。在 Windows 剪贴板桥实现并测试之前,请把 `alt+v` 图像粘贴视为未验证。远程剪贴板图像桥是独立功能,仍然绑定于 Unix/macOS 的 `herdr --remote`。 +使用 `herdr --remote` 时,配置的远程图像粘贴键会读取 Windows 剪贴板图像并传输到远程主机。把一个本地图像文件拖入 Windows Terminal 也会传输该文件并粘贴远程路径。向本地原生 Windows 窗格粘贴剪贴板图像仍未验证。 Kitty graphics 仍是实验性功能,尚未宣称支持 Windows。除非你专门在 Windows Terminal 中测试图像渲染,否则保持 `experimental.kitty_graphics = false`。 @@ -92,22 +94,20 @@ Herdr 的窗格文本复制在 Windows 测试版上可用。在窗格内拖选 | 能力 | 状态 | | --- | --- | | 直接终端附加 | 不支持 | -| Windows 二进制的 `herdr --remote` | 不支持 | +| Windows 作为 `herdr --remote` 的目标主机 | 不支持 | | 实时服务器交接 | 不支持 | | Unix 文件描述符交接 | 不支持 | | Unix 前台进程组 | 不支持 | -| 远程剪贴板图像桥 | 不支持 | | 前缀输入法切换 | 不支持 | | 签名二进制 / 规避 SmartScreen | 不支持 | -在 Windows 上进行远程工作,请 SSH 到服务器并在那里运行 `herdr`: +在 Windows Terminal 中,使用与 Linux 和 macOS 相同的远程命令: ```powershell -ssh you@server -herdr +herdr --remote workbox ``` -这种模式下 Herdr 运行在远程主机上。Windows 原生的 `herdr --remote` 不在测试版范围内。 +目标主机必须运行 Linux 或 macOS。Herdr 使用已安装的 Windows OpenSSH 客户端和你的 SSH 配置。Windows OpenSSH 不使用 Herdr 的 Unix control socket 连接复用,因此建议通过 Windows `ssh-agent` 使用密钥认证,避免远程设置期间重复提示。 Windows 更新通过 Windows 安装器进行,并更新带版本号的安装联接点。更新后请重启运行中的 Herdr 会话。实时交接仅限 Unix。 diff --git a/src/client/mod.rs b/src/client/mod.rs index c0fe73d3..fd92d188 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -37,7 +37,6 @@ use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; -#[cfg(unix)] use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, @@ -59,7 +58,6 @@ struct ClientLoopConfig { host_cursor: crate::config::HostCursorModeConfig, kitty_graphics_enabled: bool, mouse_capture_active: bool, - #[cfg(unix)] remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, } @@ -83,7 +81,6 @@ struct ClientState { #[cfg(unix)] mouse_scroll_lines: usize, /// Local-client shortcut that sends a clipboard image to a remote Herdr session. - #[cfg(unix)] remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, /// Whether outer focus gain should force a full host-terminal redraw. redraw_on_focus_gained: bool, @@ -667,7 +664,6 @@ fn requested_render_encoding() -> RenderEncoding { } } -#[cfg(unix)] fn is_remote_client_process() -> bool { std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok() } @@ -680,11 +676,9 @@ fn is_remote_client_process() -> bool { /// window; on a high-latency link that easily exceeds 5s, so it gets a far /// larger budget. See issue #753. const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5); -#[cfg(unix)] const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60); fn handshake_read_timeout() -> Duration { - #[cfg(unix)] if is_remote_client_process() { return REMOTE_HANDSHAKE_READ_TIMEOUT; } @@ -1126,7 +1120,6 @@ fn run_client_with_mode( let redraw_on_focus_gained = loaded_config.config.ui.redraw_on_focus_gained; let host_cursor = loaded_config.config.ui.host_cursor; let direct_attach_requested = attach_request.is_some(); - #[cfg(unix)] let remote_image_paste_key = client_remote_image_paste_key(&loaded_config.config); let kitty_graphics_enabled = loaded_config.config.experimental.kitty_graphics && !direct_attach_requested; @@ -1137,7 +1130,6 @@ fn run_client_with_mode( host_cursor, kitty_graphics_enabled, mouse_capture_active: mouse_capture, - #[cfg(unix)] remote_image_paste_key, }; @@ -1295,7 +1287,6 @@ async fn run_client_loop( #[cfg(windows)] let _ = config.mouse_scroll_lines; let draw_host_cursor = attach_escape.is_none() && should_draw_host_cursor(config.host_cursor); - #[cfg(unix)] let is_remote_client = is_remote_client_process(); let mut state = ClientState { @@ -1308,7 +1299,6 @@ async fn run_client_loop( attach_escape, #[cfg(unix)] mouse_scroll_lines: config.mouse_scroll_lines, - #[cfg(unix)] remote_image_paste_key: config.remote_image_paste_key, redraw_on_focus_gained: config.redraw_on_focus_gained, repaint_pending: false, @@ -1466,26 +1456,7 @@ async fn run_client_loop( state.remote_image_paste_key, ) { if let Some(image) = crate::platform::read_clipboard_image() { - if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { - warn!( - bytes = image.bytes.len(), - max = MAX_CLIPBOARD_IMAGE_PAYLOAD, - "local clipboard image is too large to bridge" - ); - continue; - } - info!( - bytes = image.bytes.len(), - extension = image.extension, - "bridging local clipboard image paste to remote server" - ); - let msg = ClientMessage::ClipboardImage { - extension: image.extension.to_owned(), - data: image.bytes, - }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { - return Err(ClientError::ConnectionLost(e)); - } + write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; continue; } info!( @@ -1493,18 +1464,7 @@ async fn run_client_loop( ); } if let Some(image) = read_image_file_from_terminal_drop(&data, is_remote_client) { - info!( - bytes = image.bytes.len(), - extension = image.extension, - "bridging local image file drop to remote server" - ); - let msg = ClientMessage::ClipboardImage { - extension: image.extension.to_owned(), - data: image.bytes, - }; - if let Err(e) = write_to_server(&mut write_stream, &msg) { - return Err(ClientError::ConnectionLost(e)); - } + write_remote_image_to_server(&mut write_stream, image, "file drop")?; continue; } let msg = ClientMessage::Input { data }; @@ -1517,6 +1477,23 @@ async fn run_client_loop( if state.attach_escape.is_some() { continue; } + if should_bridge_clipboard_image_events( + &events, + is_remote_client, + state.remote_image_paste_key, + ) { + if let Some(image) = crate::platform::read_clipboard_image() { + write_remote_image_to_server(&mut write_stream, image, "clipboard paste")?; + continue; + } + info!( + "clipboard image paste trigger received, but local clipboard has no image" + ); + } + if let Some(image) = read_image_file_from_client_events(&events, is_remote_client) { + write_remote_image_to_server(&mut write_stream, image, "file drop")?; + continue; + } let raw_events = events .iter() .map(crate::protocol::ClientInputEvent::to_raw_input_event) @@ -1614,7 +1591,6 @@ async fn run_client_loop( &mut state.sound_config, &mut state.redraw_on_focus_gained, &mut state.draw_host_cursor, - #[cfg(unix)] &mut state.remote_image_paste_key, ); } @@ -1733,11 +1709,41 @@ fn write_to_server(stream: &mut LocalStream, msg: &ClientMessage) -> io::Result< protocol::write_message(stream, msg).map_err(|e| io::Error::other(e.to_string())) } +fn write_remote_image_to_server( + stream: &mut LocalStream, + image: crate::platform::ClipboardImage, + source: &'static str, +) -> Result<(), ClientError> { + if image.bytes.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD { + warn!( + bytes = image.bytes.len(), + max = MAX_CLIPBOARD_IMAGE_PAYLOAD, + source, + "local image is too large to bridge" + ); + return Ok(()); + } + + info!( + bytes = image.bytes.len(), + extension = image.extension, + source, + "bridging local image to remote server" + ); + write_to_server( + stream, + &ClientMessage::ClipboardImage { + extension: image.extension.to_owned(), + data: image.bytes, + }, + ) + .map_err(ClientError::ConnectionLost) +} + // --------------------------------------------------------------------------- // Notifications // --------------------------------------------------------------------------- -#[cfg(unix)] fn client_remote_image_paste_key( config: &crate::config::Config, ) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> { @@ -1758,7 +1764,7 @@ fn reload_local_client_config( sound_config: &mut crate::config::SoundConfig, redraw_on_focus_gained: &mut bool, draw_host_cursor: &mut bool, - #[cfg(unix)] remote_image_paste_key: &mut Option<( + remote_image_paste_key: &mut Option<( crossterm::event::KeyCode, crossterm::event::KeyModifiers, )>, @@ -1768,15 +1774,11 @@ fn reload_local_client_config( for diagnostic in loaded.config.ui.sound.diagnostics() { warn!(diagnostic = %diagnostic, "local sound config diagnostic"); } - #[cfg(unix)] let loaded_remote_image_paste_key = client_remote_image_paste_key(&loaded.config); *sound_config = loaded.config.ui.sound; *redraw_on_focus_gained = loaded.config.ui.redraw_on_focus_gained; *draw_host_cursor = should_draw_host_cursor(loaded.config.ui.host_cursor); - #[cfg(unix)] - { - *remote_image_paste_key = loaded_remote_image_paste_key; - } + *remote_image_paste_key = loaded_remote_image_paste_key; debug!("reloaded local client config"); } Err(diagnostics) => { @@ -1874,12 +1876,68 @@ fn should_bridge_clipboard_image_paste( ) } +#[cfg(any(windows, test))] +fn should_bridge_clipboard_image_events( + events: &[crate::protocol::ClientInputEvent], + is_remote_client: bool, + remote_image_paste_key: Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, +) -> bool { + if !is_remote_client { + return false; + } + if matches!( + events, + [crate::protocol::ClientInputEvent::Paste { text }] if text.is_empty() + ) { + return true; + } + + let Some(remote_image_paste_key) = remote_image_paste_key else { + return false; + }; + matches!( + events, + [event] + if matches!( + event.to_raw_input_event(), + crate::raw_input::RawInputEvent::Key(key) + if key.kind == crossterm::event::KeyEventKind::Press + && crate::config::terminal_key_matches_combo( + &key, + remote_image_paste_key, + ) + ) + ) +} + #[cfg(unix)] fn read_image_file_from_terminal_drop( data: &[u8], is_remote_client: bool, ) -> Option { let (path, extension) = image_path_from_terminal_drop(data, is_remote_client)?; + read_image_file(path, extension) +} + +#[cfg(any(windows, test))] +fn read_image_file_from_client_events( + events: &[crate::protocol::ClientInputEvent], + is_remote_client: bool, +) -> Option { + let [crate::protocol::ClientInputEvent::Paste { text }] = events else { + return None; + }; + let text = normalized_terminal_drop_text(text)?; + // Windows events already carry native paths; Unix backslash unescaping would corrupt them. + let (path, extension) = + image_path_from_drop_text(strip_matching_path_quotes(text), is_remote_client)?; + read_image_file(path, extension) +} + +fn read_image_file( + path: std::path::PathBuf, + extension: &'static str, +) -> Option { let metadata = std::fs::metadata(&path).ok()?; if !metadata.is_file() { return None; @@ -1907,23 +1965,29 @@ fn image_path_from_terminal_drop( data: &[u8], is_remote_client: bool, ) -> Option<(std::path::PathBuf, &'static str)> { - if !is_remote_client { - return None; - } - let bytes = bracketed_paste_payload(data).unwrap_or(data); let text = std::str::from_utf8(bytes).ok()?; + let text = normalized_terminal_drop_text(text)?; + let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); + image_path_from_drop_text(&text, is_remote_client) +} + +fn normalized_terminal_drop_text(text: &str) -> Option<&str> { let text = text.trim_end_matches(['\r', '\n']); - if text.is_empty() || text.contains(['\r', '\n']) { + (!text.is_empty() && !text.contains(['\r', '\n'])).then_some(text) +} + +fn image_path_from_drop_text( + text: &str, + is_remote_client: bool, +) -> Option<(std::path::PathBuf, &'static str)> { + if !is_remote_client { return None; } - - let text = unescape_terminal_drop_path(strip_matching_path_quotes(text)); let path = std::path::PathBuf::from(text); if !path.is_absolute() { return None; } - let extension = recognized_image_extension(path.extension()?.to_str()?)?; Some((path, extension)) } @@ -1935,7 +1999,6 @@ fn bracketed_paste_payload(data: &[u8]) -> Option<&[u8]> { data.strip_prefix(START)?.strip_suffix(END) } -#[cfg(unix)] fn strip_matching_path_quotes(text: &str) -> &str { if text.len() < 2 { return text; @@ -1966,7 +2029,6 @@ fn unescape_terminal_drop_path(text: &str) -> String { unescaped } -#[cfg(unix)] fn recognized_image_extension(extension: &str) -> Option<&'static str> { if extension.eq_ignore_ascii_case("png") { Some("png") @@ -2363,6 +2425,14 @@ mod tests { } } + #[test] + fn remote_client_uses_extended_handshake_timeout() { + let _guard = env_lock().lock().unwrap(); + let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local"); + + assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT); + } + #[test] fn host_cursor_policy_auto_uses_platform_default() { assert_eq!( @@ -2421,12 +2491,10 @@ mod tests { )); } - #[cfg(unix)] struct TempImageFile { path: std::path::PathBuf, } - #[cfg(unix)] impl TempImageFile { fn new(extension: &str, bytes: &[u8]) -> Self { Self::with_name_fragment("test", extension, bytes) @@ -2446,13 +2514,64 @@ mod tests { } } - #[cfg(unix)] impl Drop for TempImageFile { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } } + #[test] + fn clipboard_image_event_bridge_matches_remote_key_and_empty_paste() { + let ctrl_v = crate::config::parse_key_combo("ctrl+v").unwrap(); + let key = crate::protocol::ClientInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('v'), + modifiers: crossterm::event::KeyModifiers::CONTROL.bits(), + kind: crate::protocol::ClientKeyKind::Press, + repeat_count: 1, + generated_text: None, + source: crate::protocol::ClientKeySource::Synthesized, + }; + let empty_paste = crate::protocol::ClientInputEvent::Paste { + text: String::new(), + }; + + assert!(should_bridge_clipboard_image_events( + std::slice::from_ref(&key), + true, + Some(ctrl_v), + )); + assert!(should_bridge_clipboard_image_events( + std::slice::from_ref(&empty_paste), + true, + None, + )); + assert!(!should_bridge_clipboard_image_events( + &[key], + false, + Some(ctrl_v), + )); + assert!(!should_bridge_clipboard_image_events( + &[crate::protocol::ClientInputEvent::Paste { + text: "text".to_string(), + }], + true, + Some(ctrl_v), + )); + } + + #[test] + fn remote_image_file_drop_bridge_reads_semantic_paste_path() { + let file = TempImageFile::new("PNG", b"image-bytes"); + let events = [crate::protocol::ClientInputEvent::Paste { + text: format!("\"{}\"", file.path.display()), + }]; + + let image = read_image_file_from_client_events(&events, true).unwrap(); + + assert_eq!(image.extension, "png"); + assert_eq!(image.bytes, b"image-bytes"); + } + #[cfg(unix)] #[test] fn remote_image_file_drop_bridge_reads_bracketed_absolute_image_path() { @@ -2963,14 +3082,12 @@ mod tests { let mut sound_config = crate::config::SoundConfig::default(); let mut redraw_on_focus_gained = true; let mut draw_host_cursor = false; - #[cfg(unix)] let mut remote_image_paste_key = None; reload_local_client_config( &mut sound_config, &mut redraw_on_focus_gained, &mut draw_host_cursor, - #[cfg(unix)] &mut remote_image_paste_key, ); diff --git a/src/ipc.rs b/src/ipc.rs index c293a278..36e69ea2 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -16,6 +16,12 @@ pub(crate) enum LocalStreamRead { Closed, } +pub(crate) enum LocalStreamReadCount { + Data(usize), + Pending, + Closed, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct SocketFileIdentity { #[cfg(unix)] @@ -125,16 +131,69 @@ pub(crate) fn set_local_stream_polling(stream: &mut LocalStream, enabled: bool) } } +#[cfg(unix)] +pub(crate) fn shutdown_local_stream_write(stream: &LocalStream) -> io::Result<()> { + match stream { + LocalStream::UdSocket(stream) => stream.inner().shutdown(std::net::Shutdown::Write), + } +} + +/// Binds a listener for private terminal traffic. Unix callers restrict the +/// socket file after binding; Windows must set the named-pipe DACL at creation. +pub(crate) fn bind_private_local_listener(path: &Path) -> io::Result { + #[cfg(unix)] + { + bind_local_listener(path) + } + + #[cfg(windows)] + { + use interprocess::local_socket::{prelude::*, GenericNamespaced, ListenerOptions}; + use interprocess::os::windows::local_socket::ListenerOptionsExt as _; + use interprocess::os::windows::security_descriptor::SecurityDescriptor; + use widestring::U16CString; + + let sddl = U16CString::from_str("D:P(A;;GA;;;SY)(A;;GA;;;OW)") + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; + let security_descriptor = SecurityDescriptor::deserialize(&sddl)?; + let name = path.to_string_lossy().to_string(); + let name = name.to_ns_name::()?; + let listener = ListenerOptions::new() + .name(name) + .reclaim_name(false) + .security_descriptor(security_descriptor) + .create_sync()?; + fs::write(path, windows_socket_marker())?; + Ok(listener) + } +} + pub(crate) fn poll_local_stream_read( stream: &mut LocalStream, buf: &mut [u8], ) -> io::Result { + match poll_local_stream_read_count(stream, buf)? { + LocalStreamReadCount::Data(read) => { + let _ = read; + Ok(LocalStreamRead::Data) + } + LocalStreamReadCount::Pending => Ok(LocalStreamRead::Pending), + LocalStreamReadCount::Closed => Ok(LocalStreamRead::Closed), + } +} + +pub(crate) fn poll_local_stream_read_count( + stream: &mut LocalStream, + buf: &mut [u8], +) -> io::Result { #[cfg(unix)] { match stream.read(buf) { - Ok(0) => Ok(LocalStreamRead::Closed), - Ok(_) => Ok(LocalStreamRead::Data), - Err(err) if err.kind() == io::ErrorKind::WouldBlock => Ok(LocalStreamRead::Pending), + Ok(0) => Ok(LocalStreamReadCount::Closed), + Ok(read) => Ok(LocalStreamReadCount::Data(read)), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + Ok(LocalStreamReadCount::Pending) + } Err(err) => Err(err), } } @@ -142,12 +201,12 @@ pub(crate) fn poll_local_stream_read( #[cfg(windows)] { match windows_named_pipe_available(stream)? { - None => Ok(LocalStreamRead::Closed), - Some(0) => Ok(LocalStreamRead::Pending), + None => Ok(LocalStreamReadCount::Closed), + Some(0) => Ok(LocalStreamReadCount::Pending), Some(_) => match stream.read(buf) { - Ok(0) => Ok(LocalStreamRead::Closed), - Ok(_) => Ok(LocalStreamRead::Data), - Err(err) if is_connection_closed_error(&err) => Ok(LocalStreamRead::Closed), + Ok(0) => Ok(LocalStreamReadCount::Closed), + Ok(read) => Ok(LocalStreamReadCount::Data(read)), + Err(err) if is_connection_closed_error(&err) => Ok(LocalStreamReadCount::Closed), Err(err) => Err(err), }, } @@ -304,6 +363,31 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn private_named_pipe_accepts_same_user() { + use std::io::Write as _; + + let path = temp_socket_marker_path("private-pipe"); + let _ = fs::remove_file(&path); + let listener = bind_private_local_listener(&path).unwrap(); + let mut client = connect_local_stream(&path).unwrap(); + let mut server = listener.accept().unwrap(); + client.write_all(b"remote").unwrap(); + + let mut buffer = [0_u8; 16]; + assert!(matches!( + poll_local_stream_read_count(&mut server, &mut buffer).unwrap(), + LocalStreamReadCount::Data(6) + )); + assert_eq!(&buffer[..6], b"remote"); + + drop(client); + drop(server); + drop(listener); + let _ = fs::remove_file(path); + } + #[cfg(windows)] #[test] fn remove_socket_file_if_owned_compares_windows_marker_contents() { diff --git a/src/main.rs b/src/main.rs index b6cc62b8..85ff264b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,7 +58,6 @@ mod agent_resume; mod api; mod app; mod build_info; -#[cfg(not(windows))] mod checksum; mod cli; mod client; diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 30bea5eb..f6380df8 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -3,6 +3,85 @@ use std::process::Command; use super::{ClipboardImage, ForegroundJob, Signal}; +pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { + super::RemoteSshConfigPaths { + user_config: std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("config")), + system_config: None, + multiplexing: false, + } +} + +pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result { + for attempt in 0..100 { + let dir = std::env::temp_dir().join(format!("herdr-ssh-{}-{attempt}", std::process::id())); + match create_remote_private_dir(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create private herdr ssh config directory", + )) +} + +pub(crate) fn create_remote_ssh_config_file( + path: &std::path::Path, +) -> std::io::Result { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path) +} + +pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> { + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(path) +} + +pub(crate) fn remote_private_temp_base() -> PathBuf { + std::env::temp_dir() +} + +pub(crate) fn remote_bridge_endpoint_path(readable_name: &str, _short_name: &str) -> PathBuf { + std::env::temp_dir().join(readable_name) +} + +pub(crate) fn remote_reattach_program(program: &str) -> String { + shell_quote(program) +} + +pub(crate) fn remote_reattach_argument(value: &str) -> String { + shell_quote(value) +} + +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + /// Unsupported platform stub. pub fn raise_server_nofile_limit() {} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 76568ad0..7932ae09 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -12,6 +12,12 @@ use super::{ LimitedRead, Signal, }; +pub(crate) use super::unix_common::{ + create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file, + remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, + remote_reattach_program, remote_ssh_config_paths, +}; + const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"]; const PROCESS_DETECTION_ENV_VAR: &str = "HERDR_PROCESS_DETECTION"; const CHILD_GROUPS_SCAN_LIMIT: usize = 64; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index e9c89ede..fdb1e2eb 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -12,6 +12,12 @@ use super::{ LimitedRead, Signal, }; +pub(crate) use super::unix_common::{ + create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file, + remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument, + remote_reattach_program, remote_ssh_config_paths, +}; + const PROC_PGRP_ONLY: u32 = 2; const SERVER_NOFILE_LIMIT_TARGET: libc::rlim_t = 8192; diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 38d797d1..d8613be4 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -52,7 +52,6 @@ fn configure_background_command_platform(_command: &mut std::process::Command) { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct PlatformCapabilities { pub(crate) live_handoff: bool, - pub(crate) remote_attach: bool, pub(crate) direct_terminal_attach: bool, pub(crate) preserve_legacy_doubled_escape_input: bool, } @@ -60,7 +59,6 @@ pub(crate) struct PlatformCapabilities { pub(crate) const fn capabilities() -> PlatformCapabilities { PlatformCapabilities { live_handoff: cfg!(unix), - remote_attach: cfg!(unix), direct_terminal_attach: cfg!(unix), preserve_legacy_doubled_escape_input: cfg!(target_os = "macos"), } @@ -137,14 +135,11 @@ pub struct ClipboardCommand { } #[derive(Debug, Clone, PartialEq, Eq)] -// Windows does not wire clipboard-image bridging into semantic input yet. -#[cfg_attr(windows, allow(dead_code))] pub struct ClipboardImage { pub bytes: Vec, pub extension: &'static str, } -#[cfg(unix)] #[derive(Debug, PartialEq, Eq)] pub(crate) enum LimitedRead { Empty, @@ -152,7 +147,6 @@ pub(crate) enum LimitedRead { Oversized, } -#[cfg(unix)] pub(crate) fn read_limited_reader( mut reader: impl std::io::Read, max_bytes: usize, @@ -190,6 +184,16 @@ pub(crate) fn read_limited_reader( } } +#[derive(Debug, Clone)] +pub(crate) struct RemoteSshConfigPaths { + pub(crate) user_config: Option, + pub(crate) system_config: Option, + pub(crate) multiplexing: bool, +} + +#[cfg(unix)] +mod unix_common; + #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "linux")] diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs new file mode 100644 index 00000000..754e2fc2 --- /dev/null +++ b/src/platform/unix_common.rs @@ -0,0 +1,131 @@ +use std::path::{Path, PathBuf}; + +pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { + super::RemoteSshConfigPaths { + user_config: std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("config")), + system_config: Some(PathBuf::from("/etc/ssh/ssh_config")), + multiplexing: true, + } +} + +pub(crate) fn create_remote_ssh_config_dir(control_socket_name: &str) -> std::io::Result { + use std::os::unix::fs::DirBuilderExt; + + let mut bases = vec![std::env::temp_dir()]; + let short_tmp = PathBuf::from("/tmp"); + if bases.first() != Some(&short_tmp) { + bases.push(short_tmp); + } + + let mut last_error = None; + let mut path_fits = false; + for base in bases { + for attempt in 0..100 { + let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); + if !fits_unix_socket_path(&dir.join(control_socket_name)) { + continue; + } + path_fits = true; + match std::fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + last_error = Some(err); + break; + } + } + } + } + + if let Some(err) = last_error { + return Err(err); + } + let message = if path_fits { + "failed to create private herdr ssh config directory" + } else { + "SSH control socket path exceeds the Unix socket length limit" + }; + Err(std::io::Error::new( + if path_fits { + std::io::ErrorKind::AlreadyExists + } else { + std::io::ErrorKind::InvalidInput + }, + message, + )) +} + +pub(crate) fn create_remote_ssh_config_file(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +pub(crate) fn create_remote_private_dir(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::DirBuilderExt; + + std::fs::DirBuilder::new().mode(0o700).create(path) +} + +pub(crate) fn remote_private_temp_base() -> PathBuf { + std::env::temp_dir() +} + +pub(crate) fn remote_bridge_endpoint_path(readable_name: &str, short_name: &str) -> PathBuf { + let tmp = std::env::temp_dir(); + let readable = tmp.join(readable_name); + if fits_unix_socket_path(&readable) { + return readable; + } + let short = tmp.join(short_name); + if fits_unix_socket_path(&short) { + return short; + } + PathBuf::from("/tmp").join(short_name) +} + +pub(crate) fn remote_reattach_program(program: &str) -> String { + shell_quote(if program.is_empty() { "herdr" } else { program }) +} + +pub(crate) fn remote_reattach_argument(value: &str) -> String { + shell_quote(value) +} + +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn fits_unix_socket_path(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + path.as_os_str().as_bytes().len() <= 103 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_ssh_config_dir_rejects_overlong_control_socket_name() { + let err = create_remote_ssh_config_dir(&"x".repeat(200)).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 55e0fd0b..0c9971fb 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -12,6 +12,8 @@ use std::{ time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; +mod clipboard_image; + use windows_sys::{ Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, Win32::{ @@ -20,9 +22,14 @@ use windows_sys::{ NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, }, Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}, + Security::SECURITY_ATTRIBUTES, + Storage::FileSystem::CreateDirectoryW, System::{ Console::GetConsoleWindow, - DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData}, + DataExchange::{ + CloseClipboard, EmptyClipboard, GetClipboardData, OpenClipboard, + RegisterClipboardFormatW, SetClipboardData, + }, Diagnostics::{ Debug::ReadProcessMemory, ToolHelp::{ @@ -35,10 +42,10 @@ use windows_sys::{ JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }, Memory::{ - GlobalAlloc, GlobalLock, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE, + GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE, MEMORY_BASIC_INFORMATION, }, - Ole::CF_UNICODETEXT, + Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT}, Threading::{ GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, QueryFullProcessImageNameW, TerminateProcess, CREATE_NO_WINDOW, DETACHED_PROCESS, @@ -83,6 +90,111 @@ static PROCESS_RUNTIME_MARKER_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { + super::RemoteSshConfigPaths { + user_config: std::env::var_os("USERPROFILE") + .map(PathBuf::from) + .map(|home| home.join(".ssh").join("config")), + system_config: std::env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .map(|dir| dir.join("ssh").join("ssh_config")), + multiplexing: false, + } +} + +pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result { + let base = remote_private_temp_base(); + std::fs::create_dir_all(&base)?; + for attempt in 0..100 { + let dir = base.join(format!("ssh-{}-{attempt}", std::process::id())); + match create_remote_private_dir(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create private herdr ssh config directory", + )) +} + +pub(crate) fn create_remote_ssh_config_file( + path: &std::path::Path, +) -> std::io::Result { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) +} + +pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> { + use interprocess::os::windows::security_descriptor::{ + AsSecurityDescriptorExt as _, SecurityDescriptor, + }; + use widestring::U16CString; + + let sddl = U16CString::from_str("D:P(A;OICI;GA;;;SY)(A;OICI;GA;;;OW)") + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?; + let security_descriptor = SecurityDescriptor::deserialize(&sddl)?; + let mut security_attributes = SECURITY_ATTRIBUTES { + nLength: u32::try_from(size_of::()).unwrap_or(u32::MAX), + lpSecurityDescriptor: null_mut(), + bInheritHandle: 0, + }; + security_descriptor.write_to_security_attributes(&mut security_attributes); + let path = extended_length_path(path)?; + if unsafe { CreateDirectoryW(path.as_ptr(), &security_attributes) } != 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +fn extended_length_path(path: &std::path::Path) -> std::io::Result> { + use std::os::windows::ffi::OsStrExt as _; + + let path = std::path::absolute(path)?; + let wide = path.as_os_str().encode_wide().collect::>(); + let mut extended = if wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]) + || wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'.' as u16, b'\\' as u16]) + { + wide + } else if wide.starts_with(&[b'\\' as u16, b'\\' as u16]) { + "\\\\?\\UNC\\" + .encode_utf16() + .chain(wide.into_iter().skip(2)) + .collect() + } else { + "\\\\?\\".encode_utf16().chain(wide).collect() + }; + extended.push(0); + Ok(extended) +} + +pub(crate) fn remote_private_temp_base() -> PathBuf { + crate::config::state_dir().join("remote") +} + +pub(crate) fn remote_bridge_endpoint_path(_readable_name: &str, short_name: &str) -> PathBuf { + remote_private_temp_base().join(short_name) +} + +pub(crate) fn remote_reattach_program(program: &str) -> String { + let path = std::env::current_exe() + .ok() + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| PathBuf::from(program)); + format!( + "& {}", + remote_reattach_argument(&path.display().to_string()) + ) +} + +pub(crate) fn remote_reattach_argument(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + /// Encode native or targeted semantic Win32 input for a compatible ConPTY destination. pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option> { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; @@ -1242,12 +1354,76 @@ pub fn open_url(url: &str) -> std::io::Result<()> { } } -// Windows does not wire clipboard-image bridging into semantic input yet. -#[cfg_attr(windows, allow(dead_code))] pub fn read_clipboard_image() -> Option { + for attempt in 0..10 { + if unsafe { OpenClipboard(null_mut()) } != 0 { + let _clipboard = ClipboardGuard; + if let Some(bytes) = read_registered_png_clipboard() { + return Some(ClipboardImage { + bytes, + extension: "png", + }); + } + for format in [CF_DIBV5 as u32, CF_DIB as u32] { + if let Some(bytes) = + clipboard_global_bytes(format, clipboard_image::MAX_CLIPBOARD_ALLOCATION) + { + if let Some(bytes) = clipboard_image::dib_to_png(&bytes) { + return Some(ClipboardImage { + bytes, + extension: "png", + }); + } + } + } + return None; + } + if attempt < 9 { + std::thread::sleep(Duration::from_millis(5)); + } + } None } +fn read_registered_png_clipboard() -> Option> { + static PNG_FORMAT: LazyLock = LazyLock::new(|| { + let name = wide_null("PNG"); + unsafe { RegisterClipboardFormatW(name.as_ptr()) } + }); + if *PNG_FORMAT == 0 { + return None; + } + let bytes = clipboard_global_bytes( + *PNG_FORMAT, + crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD + 64 * 1024, + )?; + clipboard_image::validated_png(&bytes) +} + +fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option> { + let handle = unsafe { GetClipboardData(format) }; + if handle.is_null() { + return None; + } + let data = unsafe { GlobalLock(handle) }; + if data.is_null() { + return None; + } + let size = unsafe { GlobalSize(handle) }; + if size == 0 || size > max_bytes { + unsafe { + GlobalUnlock(handle); + } + return None; + } + let mut bytes = vec![0_u8; size]; + unsafe { + copy_nonoverlapping(data.cast::(), bytes.as_mut_ptr(), size); + GlobalUnlock(handle); + } + Some(bytes) +} + pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result { let title = title.to_owned(); let body = body.unwrap_or(&title).to_owned(); @@ -1787,6 +1963,21 @@ mod tests { AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow, }; + #[test] + fn private_remote_directory_supports_long_paths() { + let base = std::env::temp_dir().join(format!( + "herdr-private-remote-dir-test-{}", + std::process::id() + )); + fs::create_dir_all(&base).expect("create test base"); + let private = base.join("x".repeat(240)); + + super::create_remote_private_dir(&private).expect("create private long-path directory"); + fs::write(private.join("probe"), b"ok").expect("write inherited private file"); + + fs::remove_dir_all(base).expect("remove test directory"); + } + #[test] fn windows_conpty_native_encoder_uses_canonical_phase_and_repeat_count() { let key = crate::input::TerminalKey::new( diff --git a/src/platform/windows/clipboard_image.rs b/src/platform/windows/clipboard_image.rs new file mode 100644 index 00000000..f8e17aed --- /dev/null +++ b/src/platform/windows/clipboard_image.rs @@ -0,0 +1,409 @@ +use std::io::{self, Cursor, Write as _}; + +use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; + +pub(super) const MAX_CLIPBOARD_ALLOCATION: usize = 64 * 1024 * 1024; + +const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; +const MAX_IMAGE_DIMENSION: u32 = 16_384; +const MAX_IMAGE_PIXELS: usize = 16 * 1024 * 1024; +const BI_RGB: u32 = 0; +const BI_BITFIELDS: u32 = 3; +const BI_ALPHABITFIELDS: u32 = 6; + +pub(super) fn validated_png(bytes: &[u8]) -> Option> { + let logical_len = png_logical_len(bytes)?; + if logical_len > MAX_CLIPBOARD_IMAGE_PAYLOAD { + return None; + } + let bytes = &bytes[..logical_len]; + let mut decoder = png::Decoder::new_with_limits( + Cursor::new(bytes), + png::Limits { + bytes: MAX_CLIPBOARD_IMAGE_PAYLOAD, + }, + ); + decoder.set_ignore_text_chunk(true); + decoder.set_ignore_iccp_chunk(true); + let mut reader = decoder.read_info().ok()?; + validate_dimensions(reader.info().width, reader.info().height)?; + if reader.info().animation_control.is_some() { + return None; + } + while reader.next_interlaced_row().ok()?.is_some() {} + reader.finish().ok()?; + Some(bytes.to_vec()) +} + +fn png_logical_len(bytes: &[u8]) -> Option { + if !bytes.starts_with(PNG_SIGNATURE) { + return None; + } + let mut offset = PNG_SIGNATURE.len(); + while offset < bytes.len() { + let length = usize::try_from(read_u32_be(bytes, offset)?).ok()?; + let kind_start = offset.checked_add(4)?; + let data_start = kind_start.checked_add(4)?; + let data_end = data_start.checked_add(length)?; + let chunk_end = data_end.checked_add(4)?; + if chunk_end > bytes.len() { + return None; + } + if bytes.get(kind_start..data_start)? == b"IEND" { + return (length == 0).then_some(chunk_end); + } + offset = chunk_end; + } + None +} + +pub(super) fn dib_to_png(bytes: &[u8]) -> Option> { + Dib::parse(bytes)?.encode_png() +} + +struct Dib<'a> { + width: u32, + height: u32, + top_down: bool, + stride: usize, + pixels: &'a [u8], + format: PixelFormat, +} + +#[derive(Clone, Copy)] +enum PixelFormat { + Bgr24, + Bgrx32, + Masked32 { + red: ChannelMask, + green: ChannelMask, + blue: ChannelMask, + alpha: Option, + }, +} + +#[derive(Clone, Copy)] +struct ChannelMask { + mask: u32, + shift: u32, + maximum: u32, +} + +impl ChannelMask { + fn parse(mask: u32) -> Option { + if mask == 0 { + return None; + } + let shift = mask.trailing_zeros(); + let maximum = mask >> shift; + if maximum & maximum.wrapping_add(1) != 0 { + return None; + } + Some(Self { + mask, + shift, + maximum, + }) + } + + fn extract(self, pixel: u32) -> u8 { + let value = (pixel & self.mask) >> self.shift; + ((u64::from(value) * 255 + u64::from(self.maximum) / 2) / u64::from(self.maximum)) as u8 + } +} + +impl<'a> Dib<'a> { + fn parse(bytes: &'a [u8]) -> Option { + let header_size = usize::try_from(read_u32_le(bytes, 0)?).ok()?; + if !matches!(header_size, 40 | 52 | 56 | 108 | 124) || bytes.len() < header_size { + return None; + } + let signed_width = read_i32_le(bytes, 4)?; + let signed_height = read_i32_le(bytes, 8)?; + if signed_width <= 0 || signed_height == 0 || signed_height == i32::MIN { + return None; + } + let width = u32::try_from(signed_width).ok()?; + let height = signed_height.unsigned_abs(); + validate_dimensions(width, height)?; + if read_u16_le(bytes, 12)? != 1 { + return None; + } + + let bit_count = read_u16_le(bytes, 14)?; + let compression = read_u32_le(bytes, 16)?; + let colors_used = usize::try_from(read_u32_le(bytes, 32)?).ok()?; + let (format, external_mask_bytes) = + pixel_format(bytes, header_size, bit_count, compression)?; + let palette_bytes = colors_used.checked_mul(4)?; + let pixel_offset = header_size + .checked_add(external_mask_bytes)? + .checked_add(palette_bytes)?; + let row_bits = usize::try_from(width) + .ok()? + .checked_mul(usize::from(bit_count))?; + let stride = row_bits.checked_add(31)?.checked_div(32)?.checked_mul(4)?; + let image_size = stride.checked_mul(usize::try_from(height).ok()?)?; + let pixels = bytes.get(pixel_offset..pixel_offset.checked_add(image_size)?)?; + + Some(Self { + width, + height, + top_down: signed_height < 0, + stride, + pixels, + format, + }) + } + + fn encode_png(&self) -> Option> { + let mut output = LimitedWriter::new(MAX_CLIPBOARD_IMAGE_PAYLOAD); + { + let mut encoder = png::Encoder::new(&mut output, self.width, self.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().ok()?; + let mut stream = writer.stream_writer().ok()?; + let mut rgba = vec![0_u8; usize::try_from(self.width).ok()?.checked_mul(4)?]; + for output_row in 0..usize::try_from(self.height).ok()? { + let source_row = if self.top_down { + output_row + } else { + usize::try_from(self.height) + .ok()? + .checked_sub(output_row + 1)? + }; + let start = source_row.checked_mul(self.stride)?; + let source = self.pixels.get(start..start.checked_add(self.stride)?)?; + self.decode_row(source, &mut rgba)?; + stream.write_all(&rgba).ok()?; + } + stream.finish().ok()?; + writer.finish().ok()?; + } + Some(output.into_inner()) + } + + fn decode_row(&self, source: &[u8], output: &mut [u8]) -> Option<()> { + match self.format { + PixelFormat::Bgr24 => { + for (pixel, rgba) in source + .chunks_exact(3) + .take(usize::try_from(self.width).ok()?) + .zip(output.chunks_exact_mut(4)) + { + rgba.copy_from_slice(&[pixel[2], pixel[1], pixel[0], 255]); + } + } + PixelFormat::Bgrx32 => { + for (pixel, rgba) in source + .chunks_exact(4) + .take(usize::try_from(self.width).ok()?) + .zip(output.chunks_exact_mut(4)) + { + rgba.copy_from_slice(&[pixel[2], pixel[1], pixel[0], 255]); + } + } + PixelFormat::Masked32 { + red, + green, + blue, + alpha, + } => { + for (pixel, rgba) in source + .chunks_exact(4) + .take(usize::try_from(self.width).ok()?) + .zip(output.chunks_exact_mut(4)) + { + let pixel = u32::from_le_bytes(pixel.try_into().ok()?); + rgba.copy_from_slice(&[ + red.extract(pixel), + green.extract(pixel), + blue.extract(pixel), + alpha.map_or(255, |mask| mask.extract(pixel)), + ]); + } + } + } + Some(()) + } +} + +fn pixel_format( + bytes: &[u8], + header_size: usize, + bit_count: u16, + compression: u32, +) -> Option<(PixelFormat, usize)> { + match (bit_count, compression) { + (24, BI_RGB) => return Some((PixelFormat::Bgr24, 0)), + (32, BI_RGB) => return Some((PixelFormat::Bgrx32, 0)), + (32, BI_BITFIELDS | BI_ALPHABITFIELDS) => {} + _ => return None, + } + + let (offset, external_mask_bytes) = if header_size == 40 { + let count = if compression == BI_ALPHABITFIELDS { + 4 + } else { + 3 + }; + (40, count * 4) + } else { + if header_size < 52 || (compression == BI_ALPHABITFIELDS && header_size < 56) { + return None; + } + (40, 0) + }; + let red = ChannelMask::parse(read_u32_le(bytes, offset)?)?; + let green = ChannelMask::parse(read_u32_le(bytes, offset + 4)?)?; + let blue = ChannelMask::parse(read_u32_le(bytes, offset + 8)?)?; + if red.mask & green.mask != 0 || red.mask & blue.mask != 0 || green.mask & blue.mask != 0 { + return None; + } + let alpha = if compression == BI_ALPHABITFIELDS || header_size >= 56 { + match read_u32_le(bytes, offset + 12).filter(|mask| *mask != 0) { + Some(mask) => Some(ChannelMask::parse(mask)?), + None => None, + } + } else { + None + }; + if alpha.is_some_and(|alpha| alpha.mask & (red.mask | green.mask | blue.mask) != 0) { + return None; + } + Some(( + PixelFormat::Masked32 { + red, + green, + blue, + alpha, + }, + external_mask_bytes, + )) +} + +fn validate_dimensions(width: u32, height: u32) -> Option<()> { + if width == 0 || height == 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION { + return None; + } + let pixels = usize::try_from(width) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + (pixels <= MAX_IMAGE_PIXELS).then_some(()) +} + +struct LimitedWriter { + bytes: Vec, + limit: usize, +} + +impl LimitedWriter { + fn new(limit: usize) -> Self { + Self { + bytes: Vec::new(), + limit, + } + } + + fn into_inner(self) -> Vec { + self.bytes + } +} + +impl io::Write for LimitedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() > self.limit.saturating_sub(self.bytes.len()) { + return Err(io::Error::new( + io::ErrorKind::FileTooLarge, + "encoded clipboard image exceeds protocol limit", + )); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn read_u16_le(bytes: &[u8], offset: usize) -> Option { + Some(u16::from_le_bytes( + bytes.get(offset..offset + 2)?.try_into().ok()?, + )) +} + +fn read_u32_le(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_i32_le(bytes: &[u8], offset: usize) -> Option { + Some(i32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_u32_be(bytes: &[u8], offset: usize) -> Option { + Some(u32::from_be_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info_header(width: i32, height: i32, bit_count: u16, compression: u32) -> Vec { + let mut header = vec![0_u8; 40]; + header[0..4].copy_from_slice(&40_u32.to_le_bytes()); + header[4..8].copy_from_slice(&width.to_le_bytes()); + header[8..12].copy_from_slice(&height.to_le_bytes()); + header[12..14].copy_from_slice(&1_u16.to_le_bytes()); + header[14..16].copy_from_slice(&bit_count.to_le_bytes()); + header[16..20].copy_from_slice(&compression.to_le_bytes()); + header + } + + fn decode_rgba(bytes: &[u8]) -> Vec { + let mut decoder = png::Decoder::new(Cursor::new(bytes)); + decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16); + let mut reader = decoder.read_info().unwrap(); + let mut output = vec![0_u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut output).unwrap(); + output.truncate(info.buffer_size()); + output + } + + #[test] + fn converts_bottom_up_24_bit_dib_to_png() { + let mut dib = info_header(2, 2, 24, BI_RGB); + dib.extend_from_slice(&[ + 255, 0, 0, 255, 255, 255, 0, 0, // bottom: blue, white, padding + 0, 0, 255, 0, 255, 0, 0, 0, // top: red, green, padding + ]); + + let png = dib_to_png(&dib).unwrap(); + assert_eq!( + decode_rgba(&png), + vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,] + ); + } + + #[test] + fn strips_registered_png_allocator_tail() { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&[1, 2, 3, 255]).unwrap(); + writer.finish().unwrap(); + let expected = bytes.clone(); + bytes.extend_from_slice(&[0; 32]); + + assert_eq!(validated_png(&bytes), Some(expected)); + } +} diff --git a/src/remote.rs b/src/remote.rs index ce4ca91b..19904ef2 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,149 +1,15 @@ +mod attach; #[cfg(unix)] -mod unix; +mod host_unix; +pub(crate) use attach::*; #[cfg(unix)] -pub(crate) use unix::*; - -#[cfg(windows)] -pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; -#[cfg(windows)] -pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; - -#[cfg(windows)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RemoteKeybindings { - Local, - Server, -} - -#[cfg(windows)] -impl RemoteKeybindings { - fn parse(value: &str) -> Result { - match value { - "local" => Ok(Self::Local), - "server" => Ok(Self::Server), - _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), - } - } -} - -#[cfg(windows)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RemoteLaunch { - pub(crate) target: String, - pub(crate) keybindings: RemoteKeybindings, - pub(crate) live_handoff: bool, -} - -#[cfg(windows)] -pub(crate) fn extract_remote_args( - args: &[String], -) -> Result<(Vec, Option), String> { - let mut cleaned = Vec::with_capacity(args.len()); - if let Some(program) = args.first() { - cleaned.push(program.clone()); - } - - let mut remote_target = None; - let mut keybindings = RemoteKeybindings::Local; - let mut keybindings_seen = false; - let mut live_handoff = false; - let mut index = 1; - while index < args.len() { - let arg = &args[index]; - if arg == "--" { - cleaned.extend_from_slice(&args[index..]); - break; - } - if arg == "--handoff" { - live_handoff = true; - index += 1; - continue; - } - if arg == "--remote" { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote".to_string()); - }; - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote=") { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 1; - continue; - } - if arg == "--remote-keybindings" { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote-keybindings".to_string()); - }; - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote-keybindings=") { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 1; - continue; - } - - cleaned.push(arg.clone()); - index += 1; - } - - let remote = remote_target.map(|target| RemoteLaunch { - target, - keybindings, - live_handoff, - }); - if remote.is_none() && keybindings_seen { - return Err("--remote-keybindings requires --remote".to_string()); - } - if remote.is_none() && live_handoff { - cleaned.push("--handoff".to_string()); - } - - Ok((cleaned, remote)) -} - -#[cfg(windows)] -fn validate_remote_target(target: &str) -> Result<&str, String> { - if target.is_empty() { - return Err("missing value for --remote".to_string()); - } - if target.starts_with('-') { - return Err("--remote target must not start with '-'".to_string()); - } - Ok(target) -} - -#[cfg(windows)] -pub(crate) fn run_remote(_remote: RemoteLaunch) -> std::io::Result<()> { - debug_assert!(!crate::platform::capabilities().remote_attach); - Err(std::io::Error::other( - "remote mode is not supported on Windows yet", - )) -} +pub(crate) use host_unix::run_remote_client_bridge; #[cfg(windows)] pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> { - debug_assert!(!crate::platform::capabilities().remote_attach); Err(std::io::Error::other( - "remote client bridge is not supported on Windows yet", + "remote Windows hosts are not supported yet", )) } diff --git a/src/remote/attach.rs b/src/remote/attach.rs new file mode 100644 index 00000000..4121d468 --- /dev/null +++ b/src/remote/attach.rs @@ -0,0 +1,3351 @@ +//! Remote thin-client launcher over SSH command stdio. + +use super::shell_quote; +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::{self, IsTerminal, Write as _}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +use interprocess::local_socket::traits::Listener as _; +#[cfg(windows)] +use interprocess::local_socket::traits::Stream as _; +use interprocess::local_socket::ListenerNonblockingMode; +use interprocess::TryClone as _; +use serde::Deserialize; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); +#[cfg(windows)] +const BRIDGE_IO_POLL: Duration = Duration::from_millis(1); +const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; +const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); +const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); +const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION; +const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; +const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; +const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; +const SSH_CONTROL_SOCKET_NAME: &str = "ctl"; +pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; + +pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteKeybindings { + Local, + Server, +} + +impl RemoteKeybindings { + fn parse(value: &str) -> Result { + match value { + "local" => Ok(Self::Local), + "server" => Ok(Self::Server), + _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Server => "server", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteLaunch { + pub(crate) target: String, + pub(crate) keybindings: RemoteKeybindings, + pub(crate) live_handoff: bool, +} + +pub(crate) fn extract_remote_args( + args: &[String], +) -> Result<(Vec, Option), String> { + let mut cleaned = Vec::with_capacity(args.len()); + if let Some(program) = args.first() { + cleaned.push(program.clone()); + } + + let mut remote_target = None; + let mut keybindings = RemoteKeybindings::Local; + let mut keybindings_seen = false; + let mut live_handoff = false; + let mut index = 1; + while index < args.len() { + let arg = &args[index]; + if arg == "--" { + cleaned.extend_from_slice(&args[index..]); + break; + } + if arg == "--handoff" { + live_handoff = true; + index += 1; + continue; + } + if arg == "--remote" { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote".to_string()); + }; + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote=") { + if remote_target.is_some() { + return Err("--remote can only be specified once".to_string()); + } + remote_target = Some(validate_remote_target(value)?.to_owned()); + index += 1; + continue; + } + if arg == "--remote-keybindings" { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --remote-keybindings".to_string()); + }; + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 2; + continue; + } + if let Some(value) = arg.strip_prefix("--remote-keybindings=") { + if keybindings_seen { + return Err("--remote-keybindings can only be specified once".to_string()); + } + keybindings = RemoteKeybindings::parse(value)?; + keybindings_seen = true; + index += 1; + continue; + } + + cleaned.push(arg.clone()); + index += 1; + } + + let remote = remote_target.map(|target| RemoteLaunch { + target, + keybindings, + live_handoff, + }); + if remote.is_none() && keybindings_seen { + return Err("--remote-keybindings requires --remote".to_string()); + } + if remote.is_none() && live_handoff { + cleaned.push("--handoff".to_string()); + } + + Ok((cleaned, remote)) +} + +fn validate_remote_target(target: &str) -> Result<&str, String> { + if target.is_empty() { + return Err("missing value for --remote".to_string()); + } + if target.starts_with('-') { + return Err("--remote target must not start with '-'".to_string()); + } + Ok(target) +} + +pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { + let session_name = crate::session::active_name() + .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()); + let local_socket = local_forward_socket_path(&remote.target, &session_name); + let program = std::env::args() + .next() + .unwrap_or_else(|| "herdr".to_string()); + let reattach_command = reattach_command( + &program, + &remote.target, + &session_name, + remote.keybindings, + remote.live_handoff, + ); + let manage_ssh_config = crate::config::Config::load() + .config + .remote + .manage_ssh_config; + let remote_ssh = RemoteSsh::new(remote.target.clone(), manage_ssh_config); + let prepared_remote = prepare_remote_herdr(&remote_ssh, remote.live_handoff)?; + ensure_remote_server_ready( + &remote_ssh, + &prepared_remote.remote_herdr, + prepared_remote.installed_or_replaced, + prepared_remote.stop_after_install_approved, + remote.live_handoff, + )?; + + let _bridge = SshStdioBridge::start( + remote.target, + prepared_remote.remote_herdr, + local_socket.clone(), + session_name, + remote_ssh.options(), + )?; + + run_client_process(&local_socket, &reattach_command, remote.keybindings) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RemotePlatform { + os: &'static str, + arch: &'static str, +} + +impl RemotePlatform { + fn from_uname(os: &str, arch: &str) -> Option { + let os = match os.trim() { + "Linux" => "linux", + "Darwin" => "macos", + _ => return None, + }; + let arch = match arch.trim() { + "x86_64" | "amd64" => "x86_64", + "aarch64" | "arm64" => "aarch64", + _ => return None, + }; + Some(Self { os, arch }) + } + + fn local() -> Self { + let os = if cfg!(target_os = "linux") { + "linux" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "unknown" + }; + + let arch = if cfg!(target_arch = "x86_64") { + "x86_64" + } else if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "unknown" + }; + + Self { os, arch } + } + + fn asset_key(&self) -> String { + format!("{}-{}", self.os, self.arch) + } +} + +#[derive(Debug, Clone)] +struct RemoteHerdr { + install_suffix: String, + shell_path: String, + platform: RemotePlatform, +} + +impl RemoteHerdr { + fn for_platform(platform: RemotePlatform) -> Self { + let install_suffix = ".local/bin/herdr".to_string(); + let shell_path = format!("\"$HOME/{install_suffix}\""); + Self { + install_suffix, + shell_path, + platform, + } + } + + fn with_shell_path(mut self, shell_path: String) -> Self { + self.shell_path = shell_path; + self + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum RemoteAssetRef { + Url(String), + Object { url: String, sha256: Option }, +} + +impl RemoteAssetRef { + fn url(&self) -> &str { + match self { + Self::Url(url) => url, + Self::Object { url, .. } => url, + } + } + + fn sha256(&self) -> Option<&str> { + match self { + Self::Url(_) => None, + Self::Object { sha256, .. } => { + sha256.as_deref().filter(|value| !value.trim().is_empty()) + } + } + } +} + +#[derive(Deserialize)] +struct RemoteUpdateManifest { + version: String, + protocol: Option, + assets: BTreeMap, + #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] + releases: BTreeMap, +} + +#[derive(Deserialize)] +struct RemoteReleaseMetadata { + protocol: Option, + #[serde(default)] + assets: BTreeMap, +} + +#[derive(Deserialize)] +struct RemotePreviewManifest { + build_id: String, + protocol: u32, + assets: BTreeMap, + #[serde(default)] + builds: BTreeMap, +} + +#[derive(Deserialize)] +struct RemotePreviewBuildMetadata { + protocol: u32, + assets: BTreeMap, +} + +fn deserialize_remote_manifest_releases<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::Object(object)) => object + .into_iter() + .filter_map(|(version, release)| { + serde_json::from_value::(release) + .ok() + .map(|metadata| (version, metadata)) + }) + .collect(), + _ => BTreeMap::new(), + }) +} + +impl RemoteUpdateManifest { + fn release_for_version(&self, version: &str) -> Option> { + if self.version.trim_start_matches('v') == version { + return Some(RemoteManifestReleaseRef { + protocol: self.protocol, + assets: &self.assets, + }); + } + + self.releases.get(version).and_then(|release| { + (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef { + protocol: release.protocol, + assets: &release.assets, + }) + }) + } +} + +#[derive(Clone, Copy)] +struct RemoteManifestReleaseRef<'a> { + protocol: Option, + assets: &'a BTreeMap, +} + +fn current_version() -> String { + crate::build_info::version() +} + +fn current_channel() -> &'static str { + crate::build_info::channel() +} + +struct InstallSource { + path: PathBuf, + temporary_dir: Option, +} + +struct RemoteReleaseAsset { + url: String, + sha256: Option, +} + +struct PreparedRemoteHerdr { + remote_herdr: RemoteHerdr, + installed_or_replaced: bool, + stop_after_install_approved: bool, +} + +#[derive(Clone)] +struct ManagedSshOptions { + config_path: PathBuf, + control_path: Option, +} + +struct ManagedSshConfig { + options: ManagedSshOptions, +} + +impl Drop for ManagedSshConfig { + fn drop(&mut self) { + if let Some(dir) = self.options.config_path.parent() { + let _ = fs::remove_dir_all(dir); + } + } +} + +struct RemoteSsh { + target: String, + managed_config: Option, +} + +impl RemoteSsh { + fn new(target: String, manage_ssh_config: bool) -> Self { + let managed_config = if manage_ssh_config { + write_managed_ssh_config() + .inspect_err(|err| { + tracing::debug!(%err, "could not write managed ssh config; using plain ssh"); + }) + .ok() + } else { + None + }; + + Self { + target, + managed_config, + } + } + + fn target(&self) -> &str { + &self.target + } + + fn options(&self) -> Option<&ManagedSshOptions> { + self.managed_config.as_ref().map(|config| &config.options) + } + + fn command(&self) -> Command { + let mut command = self.base_command(); + command.arg("-T").arg(&self.target); + command + } + + fn base_command(&self) -> Command { + let mut command = Command::new("ssh"); + apply_managed_ssh_options(&mut command, self.options()); + command + } + + fn sh_output(&self, script: &str) -> io::Result { + let mut child = self + .command() + .arg("/bin/sh -s") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let write_result = if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(script.as_bytes()) + } else { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "ssh bootstrap stdin missing", + )) + }; + let output = child.wait_with_output()?; + write_result?; + Ok(output) + } + + fn user_shell_output(&self, command: &str) -> io::Result { + self.command().arg(command).output() + } + + fn install_herdr(&self, remote_herdr: &RemoteHerdr, source_path: &Path) -> io::Result<()> { + let output = self.sh_output(&remote_install_prepare_script(remote_herdr))?; + if !output.status.success() { + return Err(command_failed("remote install preparation failed", &output)); + } + let (tmp_path, dest_path) = parse_remote_install_paths(&output.stdout)?; + + let mut child = self + .command() + .arg(remote_install_stream_command(&tmp_path)) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|err| { + io::Error::new(err.kind(), format!("failed to start ssh install: {err}")) + })?; + + let mut source = File::open(source_path)?; + let copy_result = if let Some(mut stdin) = child.stdin.take() { + io::copy(&mut source, &mut stdin).map(|_| ()) + } else { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "ssh install stdin missing", + )) + }; + let status = child.wait()?; + copy_result?; + + if status.success() { + let output = self.sh_output(&remote_install_commit_script(&tmp_path, &dest_path))?; + if output.status.success() { + Ok(()) + } else { + Err(command_failed("remote install commit failed", &output)) + } + } else { + Err(io::Error::other(format!( + "remote install exited with {status}" + ))) + } + } +} + +fn remote_install_prepare_script(remote_herdr: &RemoteHerdr) -> String { + format!( + r#"set -eu +dest="$HOME/{install_suffix}" +dir="${{dest%/*}}" +mkdir -p "$dir" +tmp="${{dest}}.tmp.$$" +printf '%s\0%s\0' "$tmp" "$dest" +"#, + install_suffix = remote_herdr.install_suffix + ) +} + +fn parse_remote_install_paths(stdout: &[u8]) -> io::Result<(String, String)> { + let mut parts = stdout.split(|byte| *byte == 0); + let tmp_path = parts.next().unwrap_or_default(); + let dest_path = parts.next().unwrap_or_default(); + if tmp_path.is_empty() || dest_path.is_empty() { + return Err(io::Error::other( + "remote install preparation did not return destination paths", + )); + } + let tmp_path = String::from_utf8(tmp_path.to_vec()).map_err(|err| { + io::Error::other(format!( + "remote install temporary path is not valid UTF-8: {err}" + )) + })?; + let dest_path = String::from_utf8(dest_path.to_vec()).map_err(|err| { + io::Error::other(format!( + "remote install destination path is not valid UTF-8: {err}" + )) + })?; + Ok((tmp_path, dest_path)) +} + +fn remote_install_stream_command(tmp_path: &str) -> String { + format!("tee {}", shell_quote(tmp_path)) +} + +fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String { + format!( + "set -eu\nchmod 755 {tmp_path}\nmv {tmp_path} {dest_path}\n", + tmp_path = shell_quote(tmp_path), + dest_path = shell_quote(dest_path) + ) +} + +impl Drop for RemoteSsh { + fn drop(&mut self) { + let Some(_options) = self + .managed_config + .as_ref() + .map(|config| &config.options) + .filter(|options| options.control_path.is_some()) + else { + return; + }; + + let _ = self + .base_command() + .arg("-O") + .arg("exit") + .arg("-o") + .arg("BatchMode=yes") + .arg(&self.target) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshOptions>) { + let Some(options) = options else { + return; + }; + + command.arg("-F").arg(&options.config_path); + if let Some(control_path) = &options.control_path { + command + .arg("-S") + .arg(control_path) + .arg("-o") + .arg("ControlMaster=auto") + .arg("-o") + .arg("ControlPersist=yes"); + } +} + +impl InstallSource { + fn persistent(path: PathBuf) -> Self { + Self { + path, + temporary_dir: None, + } + } + + fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self { + Self { + path, + temporary_dir: Some(temporary_dir), + } + } + + fn cleanup(&self) { + if let Some(dir) = &self.temporary_dir { + let _ = fs::remove_dir_all(dir); + } + } +} + +fn prepare_remote_herdr( + ssh: &RemoteSsh, + live_handoff_enabled: bool, +) -> io::Result { + let platform = detect_remote_platform(ssh)?; + let remote_herdr = RemoteHerdr::for_platform(platform); + let override_binary = remote_binary_override_path()?; + let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?; + + if override_binary.is_none() { + for candidate in &remote_binary_candidates { + if remote_binary_matches(ssh, candidate).unwrap_or(false) { + return Ok(PreparedRemoteHerdr { + remote_herdr: candidate.clone(), + installed_or_replaced: false, + stop_after_install_approved: false, + }); + } + } + if remote_binary_matches(ssh, &remote_herdr)? { + return Ok(PreparedRemoteHerdr { + remote_herdr, + installed_or_replaced: false, + stop_after_install_approved: false, + }); + } + } + + let mut stop_after_install_approved = false; + if let Some(status_probe_herdr) = remote_binary_candidates.first().or_else(|| { + remote_binary_exists(ssh, &remote_herdr) + .ok() + .and_then(|exists| exists.then_some(&remote_herdr)) + }) { + stop_after_install_approved = confirm_remote_install_with_running_server( + ssh, + status_probe_herdr, + live_handoff_enabled, + )?; + } + confirm_remote_install( + ssh.target(), + &remote_herdr, + &install_source_description(&remote_herdr.platform, override_binary.as_deref()), + )?; + let source = resolve_install_source(&remote_herdr.platform, override_binary)?; + let install_result = ssh.install_herdr(&remote_herdr, &source.path); + source.cleanup(); + install_result?; + + if !remote_binary_matches(ssh, &remote_herdr)? { + return Err(io::Error::other(format!( + "installed remote herdr at {}, but it did not report version {}", + remote_herdr.shell_path, + current_version() + ))); + } + warn_if_remote_bin_not_on_path(ssh)?; + + Ok(PreparedRemoteHerdr { + remote_herdr, + installed_or_replaced: true, + stop_after_install_approved, + }) +} + +fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result { + let output = ssh.sh_output("uname -s\nuname -m\n")?; + if !output.status.success() { + return Err(command_failed("remote platform detection failed", &output)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let os = lines.next().unwrap_or_default(); + let arch = lines.next().unwrap_or_default(); + RemotePlatform::from_uname(os, arch).ok_or_else(|| { + io::Error::other(format!( + "unsupported remote platform: {} {}", + os.trim(), + arch.trim() + )) + }) +} + +fn remote_binary_candidates( + ssh: &RemoteSsh, + remote_herdr: &RemoteHerdr, +) -> io::Result> { + let mut candidates = Vec::new(); + + if let Some(path_candidate) = remote_binary_on_path_any(ssh, remote_herdr)? { + push_if_new_remote_binary_candidate(&mut candidates, path_candidate); + } + + let output = ssh.sh_output(&known_remote_binary_candidate_script( + &remote_herdr.platform, + ))?; + if !output.status.success() { + return Err(command_failed("remote binary discovery failed", &output)); + } + let stdout = String::from_utf8_lossy(&output.stdout); + for candidate in remote_herdrs_from_path_discovery(remote_herdr, &stdout) { + push_if_new_remote_binary_candidate(&mut candidates, candidate); + } + + Ok(candidates) +} + +fn push_if_new_remote_binary_candidate(candidates: &mut Vec, candidate: RemoteHerdr) { + if !candidates + .iter() + .any(|existing| existing.shell_path == candidate.shell_path) + { + candidates.push(candidate); + } +} + +fn known_remote_binary_candidate_script(platform: &RemotePlatform) -> String { + let mut script = String::from( + r#"home=${HOME:-} +user=${USER:-} +version="#, + ); + script.push_str(&shell_quote(¤t_version())); + script.push_str( + r#" +emit() { + path=$1 + if [ -n "$path" ] && [ -x "$path" ]; then + printf '%s\n' "$path" + fi +} +if [ -n "$home" ]; then + emit "$home/.local/bin/herdr" +fi +"#, + ); + if platform.os == "macos" { + script.push_str( + r#" emit "/opt/homebrew/bin/herdr" + emit "/usr/local/bin/herdr" +"#, + ); + } else if platform.os == "linux" { + script.push_str( + r#" emit "/home/linuxbrew/.linuxbrew/bin/herdr" +"#, + ); + } + script.push_str( + r#"if [ -n "$home" ]; then + emit "$home/.local/share/mise/installs/herdr/$version/bin/herdr" + emit "$home/.local/share/mise/installs/herdr/$version/herdr" + emit "$home/.local/share/mise/installs/github-ogulcancelik-herdr/$version/herdr" + emit "$home/.nix-profile/bin/herdr" +fi +if [ -n "$user" ]; then + emit "/etc/profiles/per-user/$user/bin/herdr" +fi +emit "/nix/var/nix/profiles/default/bin/herdr" +emit "/run/current-system/sw/bin/herdr" +"#, + ); + + script +} + +fn remote_binary_on_path_any( + ssh: &RemoteSsh, + remote_herdr: &RemoteHerdr, +) -> io::Result> { + let output = ssh.user_shell_output("command -v herdr")?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(candidate) = remote_herdr_from_path_discovery(remote_herdr, &stdout) { + return Ok(Some(candidate)); + } + } + + // Non-POSIX login shells such as xonsh reject `command -v`; retry through + // /bin/sh while retaining the login-shell probe for shell-initialized PATHs. + let output = ssh.sh_output("command -v herdr\n")?; + if !output.status.success() { + return Ok(None); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout)) +} + +fn remote_herdrs_from_path_discovery(remote_herdr: &RemoteHerdr, stdout: &str) -> Vec { + stdout + .lines() + .filter_map(|path| remote_herdr_from_path(remote_herdr, path)) + .collect() +} + +fn remote_herdr_from_path_discovery( + remote_herdr: &RemoteHerdr, + stdout: &str, +) -> Option { + stdout + .lines() + .find_map(|path| remote_herdr_from_path(remote_herdr, path)) +} + +fn remote_herdr_from_path(remote_herdr: &RemoteHerdr, path: &str) -> Option { + let path = path.trim(); + if !path.starts_with('/') { + return None; + } + if is_mise_shim_path(path) { + return None; + } + Some(remote_herdr.clone().with_shell_path(shell_quote(path))) +} + +fn is_mise_shim_path(path: &str) -> bool { + path.ends_with("/mise/shims/herdr") +} + +fn remote_binary_matches(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result { + let command = format!( + "test -x {0} && {0} --version && {0} status client --json", + remote_herdr.shell_path + ); + let output = ssh.sh_output(&command)?; + if !output.status.success() { + return Ok(false); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut lines = stdout.lines(); + let version = lines.next().unwrap_or_default().trim(); + let status = lines.next().unwrap_or_default(); + Ok(version == format!("herdr {}", current_version()) + && parse_client_status_json(status) + .map(|status| status.protocol == CURRENT_PROTOCOL) + .unwrap_or(false)) +} + +fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result { + let command = format!("test -x {}", remote_herdr.shell_path); + Ok(ssh.sh_output(&command)?.status.success()) +} + +fn remote_binary_override_path() -> io::Result> { + let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else { + return Ok(None); + }; + if value.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("{REMOTE_BINARY_ENV_VAR} must not be empty"), + )); + } + + let path = PathBuf::from(value); + let metadata = fs::metadata(&path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}", + path.display() + ), + ) + })?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "{REMOTE_BINARY_ENV_VAR} path is not a file: {}", + path.display() + ), + )); + } + + Ok(Some(path)) +} + +fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String { + install_source_description_for( + platform, + override_binary, + local_binary_can_seed_remote(platform), + ) +} + +fn install_source_description_for( + platform: &RemotePlatform, + override_binary: Option<&Path>, + local_binary_can_seed_remote: bool, +) -> String { + if let Some(path) = override_binary { + return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display()); + } + + if local_binary_can_seed_remote { + "the current local herdr binary".to_string() + } else { + format!( + "the {} {} asset for {}", + current_version(), + current_channel(), + platform.asset_key() + ) + } +} + +fn resolve_install_source( + platform: &RemotePlatform, + override_binary: Option, +) -> io::Result { + if let Some(path) = override_binary { + return Ok(InstallSource::persistent(path)); + } + + if *platform == RemotePlatform::local() { + let path = std::env::current_exe()?; + if !crate::update::is_package_manager_managed_exe_path(&path) { + return Ok(InstallSource::persistent(path)); + } + } + + download_release_asset(platform) +} + +fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { + if *platform != RemotePlatform::local() { + return false; + } + + std::env::current_exe() + .map(|path| !crate::update::is_package_manager_managed_exe_path(&path)) + .unwrap_or(false) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RemoteServerStatus { + Running { + version: Option, + protocol: Option, + live_handoff: bool, + detached_server_daemon: bool, + }, + NotRunning, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteServerRestartReason { + ProtocolMismatch, + DaemonDetachMissing, + BinaryUpdated, + VersionMismatch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteInstallRunningServerPlan { + KeepRunning, + LiveHandoff, + StopRequired(RemoteServerRestartReason), +} + +fn ensure_remote_server_ready( + ssh: &RemoteSsh, + remote_herdr: &RemoteHerdr, + remote_binary_changed: bool, + stop_after_install_approved: bool, + live_handoff_enabled: bool, +) -> io::Result<()> { + let status = remote_server_status(ssh, remote_herdr)?; + let RemoteServerStatus::Running { + version, + protocol, + live_handoff, + detached_server_daemon, + } = status + else { + return Ok(()); + }; + + let Some(reason) = remote_server_restart_reason( + version.as_deref(), + protocol, + detached_server_daemon, + remote_binary_changed, + ) else { + return Ok(()); + }; + + if live_handoff_enabled && live_handoff { + match live_handoff_remote_server(ssh, remote_herdr) { + Ok(()) => return Ok(()), + Err(err) => { + eprintln!("remote live handoff failed: {err}"); + eprintln!("falling back to remote server restart."); + } + } + } + + if stop_after_install_approved { + stop_remote_server(ssh, remote_herdr)?; + return Ok(()); + } + + if confirm_remote_server_stop(ssh.target(), version.as_deref(), protocol, reason)? { + stop_remote_server(ssh, remote_herdr)?; + } + Ok(()) +} + +fn remote_server_restart_reason( + version: Option<&str>, + protocol: Option, + detached_server_daemon: bool, + remote_binary_changed: bool, +) -> Option { + if protocol != Some(CURRENT_PROTOCOL) { + return Some(RemoteServerRestartReason::ProtocolMismatch); + } + if !detached_server_daemon { + return Some(RemoteServerRestartReason::DaemonDetachMissing); + } + if version != Some(current_version().as_str()) { + return Some(RemoteServerRestartReason::VersionMismatch); + } + if remote_binary_changed { + return Some(RemoteServerRestartReason::BinaryUpdated); + } + None +} + +fn confirm_remote_install_with_running_server( + ssh: &RemoteSsh, + remote_herdr: &RemoteHerdr, + live_handoff_enabled: bool, +) -> io::Result { + let target = ssh.target(); + let status = match remote_server_status(ssh, remote_herdr) { + Ok(status) => status, + Err(err) => { + if !io::stdin().is_terminal() { + return Err(io::Error::other(format!( + "could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary" + ))); + } + eprintln!( + "could not inspect the running remote herdr server on {target} before installing: {err}" + ); + eprint!("continue installing the remote herdr binary? [y/N] "); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer != "y" && answer != "yes" { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr install cancelled", + )); + } + return Ok(false); + } + }; + let RemoteServerStatus::Running { + version, + protocol, + live_handoff, + detached_server_daemon, + } = &status + else { + return Ok(false); + }; + let plan = remote_install_running_server_plan( + version.as_deref(), + *protocol, + *detached_server_daemon, + true, + *live_handoff, + live_handoff_enabled, + ); + + if plan == RemoteInstallRunningServerPlan::KeepRunning { + if io::stdin().is_terminal() { + eprintln!("remote herdr server on {target} is already compatible:"); + eprintln!(" server: v{}", version_label(version.as_deref())); + eprintln!( + "Herdr will install {} without stopping the running remote server.", + current_version() + ); + } + return Ok(false); + } + + if !io::stdin().is_terminal() { + match plan { + RemoteInstallRunningServerPlan::LiveHandoff => return Ok(false), + RemoteInstallRunningServerPlan::StopRequired(_) => { + return Err(io::Error::other(format!( + "remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update", + version_label(version.as_deref()) + ))); + } + RemoteInstallRunningServerPlan::KeepRunning => return Ok(false), + } + } + + if plan == RemoteInstallRunningServerPlan::LiveHandoff { + eprintln!("remote herdr server on {target} is currently running:"); + eprintln!(" server: v{}", version_label(version.as_deref())); + eprintln!( + "Herdr will install {} and hand off live pane processes to the prepared server.", + current_version() + ); + return Ok(false); + } + + eprintln!("remote herdr server on {target} is currently running:"); + eprintln!(" server: v{}", version_label(version.as_deref())); + eprintln!( + "To complete the remote update, Herdr must stop the running remote server after installing." + ); + eprintln!("This stops active remote pane processes, including shells, dev servers, and tests."); + eprintln!(); + eprint!( + "Install {} and stop the remote server now? [y/N] ", + current_version() + ); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer != "y" && answer != "yes" { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr install cancelled", + )); + } + + Ok(true) +} + +fn remote_install_running_server_plan( + version: Option<&str>, + protocol: Option, + detached_server_daemon: bool, + remote_binary_changed: bool, + live_handoff: bool, + live_handoff_enabled: bool, +) -> RemoteInstallRunningServerPlan { + let Some(reason) = remote_server_restart_reason( + version, + protocol, + detached_server_daemon, + remote_binary_changed, + ) else { + return RemoteInstallRunningServerPlan::KeepRunning; + }; + + if live_handoff_enabled && live_handoff { + return RemoteInstallRunningServerPlan::LiveHandoff; + } + + RemoteInstallRunningServerPlan::StopRequired(reason) +} + +fn remote_server_status( + ssh: &RemoteSsh, + remote_herdr: &RemoteHerdr, +) -> io::Result { + let command = format!("{} status server --json", remote_herdr.shell_path); + let output = ssh.sh_output(&command)?; + if !output.status.success() { + return Err(command_failed("remote server status failed", &output)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_remote_server_status_json(stdout.trim()) +} + +#[derive(Debug, Deserialize)] +struct RemoteClientStatusJson { + protocol: u32, +} + +#[derive(Debug, Deserialize)] +struct RemoteServerStatusJson { + running: bool, + version: Option, + protocol: Option, + capabilities: Option, +} + +#[derive(Debug, Deserialize)] +struct RemoteServerCapabilitiesJson { + live_handoff: bool, + #[serde(default)] + detached_server_daemon: bool, +} + +fn parse_client_status_json(status: &str) -> Option { + serde_json::from_str(status).ok() +} + +fn parse_remote_server_status_json(status: &str) -> io::Result { + let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| { + io::Error::other(format!( + "could not parse remote server status JSON from `{status}`: {err}" + )) + })?; + if !parsed.running { + return Ok(RemoteServerStatus::NotRunning); + } + + let capabilities = parsed.capabilities; + + Ok(RemoteServerStatus::Running { + version: parsed.version, + protocol: parsed.protocol, + live_handoff: capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.live_handoff), + detached_server_daemon: capabilities + .as_ref() + .is_some_and(|capabilities| capabilities.detached_server_daemon), + }) +} + +fn confirm_remote_server_stop( + target: &str, + version: Option<&str>, + _protocol: Option, + reason: RemoteServerRestartReason, +) -> io::Result { + if !io::stdin().is_terminal() { + if reason == RemoteServerRestartReason::ProtocolMismatch { + return Err(io::Error::other(format!( + "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it" + ))); + } + + eprintln!( + "remote herdr server on {target} is still running v{}; it will use {} after it restarts.", + version_label(version), + current_version() + ); + return Ok(false); + } + + eprintln!("remote herdr server on {target} is currently running:"); + eprintln!(" server: v{}", version_label(version)); + eprintln!(" prepared binary: {}", current_version()); + eprintln!(); + + match reason { + RemoteServerRestartReason::ProtocolMismatch => { + eprintln!("the remote server must stop before this client can attach."); + } + RemoteServerRestartReason::DaemonDetachMissing => { + eprintln!( + "the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client." + ); + } + RemoteServerRestartReason::BinaryUpdated => { + eprintln!( + "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary." + ); + } + RemoteServerRestartReason::VersionMismatch => { + eprintln!( + "the remote server is still running a different herdr version. restart it so it uses the prepared binary." + ); + } + } + + let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch { + "stop the remote server and continue attaching? [Y/n] " + } else { + "restart the remote server now? [y/N] " + }; + eprint!("{prompt}"); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer == "y" || answer == "yes" { + return Ok(true); + } + if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch { + return Ok(true); + } + if reason == RemoteServerRestartReason::ProtocolMismatch { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr server stop cancelled", + )); + } + + Ok(false) +} + +fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let command = format!( + "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", + remote_herdr.shell_path, + remote_herdr.shell_path, + CURRENT_PROTOCOL, + current_version() + ); + let output = ssh.sh_output(&command)?; + if !output.status.success() { + return Err(command_failed("remote server live handoff failed", &output)); + } + + eprintln!( + "handed off the remote herdr server on {}; reconnecting to the prepared server.", + ssh.target() + ); + Ok(()) +} + +fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let command = format!("{} server stop", remote_herdr.shell_path); + let output = ssh.sh_output(&command)?; + if !output.status.success() { + return Err(command_failed("remote server stop failed", &output)); + } + + wait_for_remote_server_shutdown(ssh, remote_herdr)?; + eprintln!( + "stopped the remote herdr server on {}; it will restart when the remote client bridge attaches.", + ssh.target() + ); + Ok(()) +} + +fn wait_for_remote_server_shutdown(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { + let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT; + loop { + if remote_server_status(ssh, remote_herdr)? == RemoteServerStatus::NotRunning { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds", + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs(), + target = ssh.target() + ), + )); + } + thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL); + } +} + +fn version_label(version: Option<&str>) -> &str { + version.unwrap_or("unknown") +} + +fn warn_if_remote_bin_not_on_path(ssh: &RemoteSsh) -> io::Result<()> { + let output = ssh.user_shell_output("command -v herdr")?; + if output.status.success() + && remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout)) + { + return Ok(()); + } + + eprintln!( + "herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path" + ); + Ok(()) +} + +fn remote_shell_resolves_managed_install(stdout: &str) -> bool { + stdout + .lines() + .next() + .map(str::trim) + .is_some_and(|path| path.ends_with("/.local/bin/herdr")) +} + +fn download_release_asset(platform: &RemotePlatform) -> io::Result { + let asset_key = platform.asset_key(); + let asset = remote_release_asset(&asset_key)?; + + let dir = private_download_dir(&asset_key)?; + let path = dir.join("herdr.tmp"); + let status = crate::noninteractive_process::curl_command() + .args(["-sfL", "--max-time", "120", "-o"]) + .arg(&path) + .arg(&asset.url) + .status() + .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?; + if !status.success() { + let _ = fs::remove_dir_all(&dir); + return Err(io::Error::other("download failed")); + } + if let Some(expected) = &asset.sha256 { + if let Err(err) = crate::checksum::verify_sha256(&path, expected) { + let _ = fs::remove_dir_all(&dir); + return Err(io::Error::new( + err.kind(), + format!("downloaded remote asset checksum verification failed: {err}"), + )); + } + } + + Ok(InstallSource::temporary(path, dir)) +} + +fn fetch_remote_manifest(url: &str) -> io::Result> { + let output = crate::noninteractive_process::curl_command() + .args([ + "-sfL", + "--retry", + "3", + "--connect-timeout", + "10", + "--max-time", + "20", + url, + ]) + .output() + .map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?; + if !output.status.success() { + return Err(command_failed("failed to fetch update manifest", &output)); + } + Ok(output.stdout) +} + +fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset { + RemoteReleaseAsset { + url: asset.url().to_string(), + sha256: asset.sha256().map(str::to_string), + } +} + +fn preview_assets_for_build<'a>( + manifest: &'a RemotePreviewManifest, + build_id: &str, +) -> io::Result<(u32, &'a BTreeMap)> { + if manifest.build_id == build_id { + return Ok((manifest.protocol, &manifest.assets)); + } + let build = manifest.builds.get(build_id).ok_or_else(|| { + io::Error::other(format!( + "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr" + )) + })?; + Ok((build.protocol, &build.assets)) +} + +fn remote_release_asset(asset_key: &str) -> io::Result { + if crate::build_info::is_preview() { + let build_id = crate::build_info::build_id().ok_or_else(|| { + io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually") + })?; + let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?; + let manifest: RemotePreviewManifest = + serde_json::from_slice(&manifest_bytes).map_err(|err| { + io::Error::other(format!("failed to parse preview manifest JSON: {err}")) + })?; + let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?; + if protocol != CURRENT_PROTOCOL { + return Err(io::Error::other(format!( + "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually" + ))); + } + return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| { + io::Error::other(format!( + "no {asset_key} binary in the preview manifest for build {build_id}" + )) + }); + } + + let current_version = current_version(); + let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?; + let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?; + let release = manifest.release_for_version(¤t_version).ok_or_else(|| { + io::Error::other(format!( + "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually", + asset_key + )) + })?; + if let Some(protocol) = release.protocol { + if protocol != CURRENT_PROTOCOL { + return Err(io::Error::other(format!( + "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually" + ))); + } + } + release + .assets + .get(asset_key) + .map(remote_asset_info) + .ok_or_else(|| { + io::Error::other(format!( + "no {asset_key} binary in the release manifest for herdr {current_version}" + )) + }) +} + +fn private_download_dir(asset_key: &str) -> io::Result { + let base = crate::platform::remote_private_temp_base(); + fs::create_dir_all(&base)?; + for attempt in 0..100 { + let dir = base.join(format!( + "herdr-remote-{}-{}-{attempt}", + std::process::id(), + asset_key + )); + match crate::platform::create_remote_private_dir(&dir) { + Ok(()) => return Ok(dir), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create private herdr remote download directory", + )) +} + +fn confirm_remote_install( + target: &str, + remote_herdr: &RemoteHerdr, + source_description: &str, +) -> io::Result<()> { + if !io::stdin().is_terminal() { + return Err(io::Error::other(format!( + "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation", + current_version(), + remote_herdr.shell_path + ))); + } + + eprintln!( + "matching herdr {} is not installed on {target} for {}.", + current_version(), + remote_herdr.platform.asset_key() + ); + eprint!( + "Install {} to {}? [Y/n] ", + source_description, remote_herdr.shell_path + ); + io::stderr().flush()?; + + let mut answer = String::new(); + io::stdin().read_line(&mut answer)?; + let answer = answer.trim().to_ascii_lowercase(); + if answer == "n" || answer == "no" { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "remote herdr installation cancelled", + )); + } + + Ok(()) +} + +fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { + let mut command = format!("exec {}", remote_herdr.shell_path); + if session_name != crate::session::DEFAULT_SESSION_NAME { + command.push_str(" --session "); + command.push_str(&shell_quote(session_name)); + } + command.push_str(" remote-client-bridge"); + command +} + +fn reattach_command( + program: &str, + target: &str, + session_name: &str, + keybindings: RemoteKeybindings, + live_handoff: bool, +) -> String { + let program = crate::platform::remote_reattach_program(program); + let target = crate::platform::remote_reattach_argument(target); + let mut command = format!("{program} --remote {target}"); + if keybindings != RemoteKeybindings::Local { + command.push_str(" --remote-keybindings "); + command.push_str(keybindings.as_str()); + } + if live_handoff { + command.push_str(" --handoff"); + } + if session_name != crate::session::DEFAULT_SESSION_NAME { + command.push_str(" --session "); + command.push_str(&crate::platform::remote_reattach_argument(session_name)); + } + command +} + +fn command_failed(context: &str, output: &Output) -> io::Error { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + if stderr.is_empty() { + io::Error::other(format!("{context}: {}", output.status)) + } else { + io::Error::other(format!("{context}: {stderr}")) + } +} + +struct SshStdioBridge { + local_socket: PathBuf, + socket_identity: crate::ipc::SocketFileIdentity, + should_stop: Arc, + thread: Option>, +} + +impl SshStdioBridge { + fn start( + target: String, + remote_herdr: RemoteHerdr, + local_socket: PathBuf, + session_name: String, + ssh_options: Option<&ManagedSshOptions>, + ) -> io::Result { + crate::ipc::prepare_socket_path(&local_socket, |path| { + format!("remote bridge is already listening at {}", path.display()) + })?; + let listener = crate::ipc::bind_private_local_listener(&local_socket)?; + let socket_identity = crate::ipc::socket_file_identity(&local_socket)?; + if let Err(err) = + crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE) + { + let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity); + return Err(err); + } + if let Err(err) = listener.set_nonblocking(ListenerNonblockingMode::Accept) { + let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity); + return Err(err); + } + + let should_stop = Arc::new(AtomicBool::new(false)); + let thread_stop = Arc::clone(&should_stop); + let thread_ssh_options = ssh_options.cloned(); + let thread = thread::spawn(move || { + while !thread_stop.load(Ordering::Acquire) { + match listener.accept() { + Ok(stream) => { + if let Err(err) = bridge_connection( + stream, + &target, + &remote_herdr, + &session_name, + thread_ssh_options.as_ref(), + &thread_stop, + ) { + eprintln!("herdr: remote bridge failed: {err}"); + } + } + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(BRIDGE_ACCEPT_POLL); + } + Err(err) => { + eprintln!("herdr: remote bridge listener failed: {err}"); + break; + } + } + } + }); + + Ok(Self { + local_socket, + socket_identity, + should_stop, + thread: Some(thread), + }) + } +} + +impl Drop for SshStdioBridge { + fn drop(&mut self) { + self.should_stop.store(true, Ordering::Release); + #[cfg(unix)] + let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + #[cfg(windows)] + let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity); + } +} + +fn ssh_config_quote(path: &str) -> String { + format!("\"{path}\"") +} + +fn ssh_config_include_path(path: &Path) -> String { + let path = path.to_string_lossy(); + if std::path::MAIN_SEPARATOR == '\\' { + ssh_config_quote(&path.replace('\\', "/")) + } else { + ssh_config_quote(&path) + } +} + +/// Builds a temporary ssh config that includes the user's settings first, so +/// OpenSSH's first-value-wins behavior preserves explicit user keepalives. +fn write_managed_ssh_config() -> io::Result { + let paths = crate::platform::remote_ssh_config_paths(); + let dir = crate::platform::create_remote_ssh_config_dir(SSH_CONTROL_SOCKET_NAME)?; + let path = dir.join("config"); + let control_path = paths + .multiplexing + .then(|| dir.join(SSH_CONTROL_SOCKET_NAME)); + + let mut contents = String::new(); + if let Some(user_config) = paths.user_config.filter(|path| path.is_file()) { + contents.push_str(&format!( + "Include {}\n", + ssh_config_include_path(&user_config) + )); + } + if let Some(system_config) = paths.system_config.filter(|path| path.is_file()) { + contents.push_str(&format!( + "Include {}\n", + ssh_config_include_path(&system_config) + )); + } + contents.push_str("Host *\n"); + contents.push_str(" ServerAliveInterval 15\n"); + contents.push_str(" ServerAliveCountMax 4\n"); + + let write_result = (|| { + let mut file = crate::platform::create_remote_ssh_config_file(&path)?; + file.write_all(contents.as_bytes()) + })(); + if let Err(err) = write_result { + let _ = fs::remove_dir_all(&dir); + return Err(err); + } + Ok(ManagedSshConfig { + options: ManagedSshOptions { + config_path: path, + control_path, + }, + }) +} + +#[cfg(unix)] +fn bridge_connection( + stream: crate::ipc::LocalStream, + target: &str, + remote_herdr: &RemoteHerdr, + session_name: &str, + ssh_options: Option<&ManagedSshOptions>, + _bridge_stop: &Arc, +) -> io::Result<()> { + let mut command = Command::new("ssh"); + apply_managed_ssh_options(&mut command, ssh_options); + command + .arg("-T") + .arg(target) + .arg(remote_bridge_command(remote_herdr, session_name)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = command + .spawn() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; + let mut child_stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?; + let mut child_stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?; + let mut stream_to_child = stream.try_clone()?; + let mut child_to_stream = stream; + + let upload = thread::spawn(move || { + let _ = copy_flush(&mut stream_to_child, &mut child_stdin); + }); + let download = thread::spawn(move || { + let _ = copy_flush(&mut child_stdout, &mut child_to_stream); + let _ = crate::ipc::shutdown_local_stream_write(&child_to_stream); + }); + + let status = child.wait()?; + let _ = upload.join(); + let _ = download.join(); + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + format!("ssh bridge exited with {status}"), + )) + } +} + +#[cfg(windows)] +fn bridge_connection( + stream: crate::ipc::LocalStream, + target: &str, + remote_herdr: &RemoteHerdr, + session_name: &str, + ssh_options: Option<&ManagedSshOptions>, + bridge_stop: &Arc, +) -> io::Result<()> { + let mut command = Command::new("ssh"); + apply_managed_ssh_options(&mut command, ssh_options); + command + .arg("-T") + .arg(target) + .arg(remote_bridge_command(remote_herdr, session_name)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = command + .spawn() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; + let mut child_stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => return terminate_bridge_child(child, "ssh bridge stdin missing"), + }; + let mut child_stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => return terminate_bridge_child(child, "ssh bridge stdout missing"), + }; + let stream_to_child = match stream.try_clone() { + Ok(stream) => stream, + Err(err) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(err); + } + }; + if let Err(err) = stream.set_nonblocking(true) { + let _ = child.kill(); + let _ = child.wait(); + return Err(err); + } + let mut child_to_stream = stream; + + let connection_stop = Arc::new(AtomicBool::new(false)); + let upload_stop = Arc::new(AtomicBool::new(false)); + let upload_failed = Arc::new(AtomicBool::new(false)); + let download_done = Arc::new(AtomicBool::new(false)); + let client_closed = Arc::new(AtomicBool::new(false)); + let upload_cancel = Arc::clone(&upload_stop); + let upload_bridge_stop = Arc::clone(bridge_stop); + let upload_failed_worker = Arc::clone(&upload_failed); + let upload_client_closed = Arc::clone(&client_closed); + let upload = thread::spawn(move || { + let result = copy_local_stream_to_writer( + stream_to_child, + &mut child_stdin, + &upload_cancel, + &upload_bridge_stop, + &upload_client_closed, + ); + upload_failed_worker.store(result.is_err(), Ordering::Release); + result + }); + let download_stop = Arc::clone(&connection_stop); + let download_bridge_stop = Arc::clone(bridge_stop); + let download_done_worker = Arc::clone(&download_done); + let download_upload_stop = Arc::clone(&upload_stop); + let download = thread::spawn(move || { + let result = copy_reader_to_local_stream( + &mut child_stdout, + &mut child_to_stream, + &download_stop, + &download_bridge_stop, + ); + download_done_worker.store(true, Ordering::Release); + download_upload_stop.store(true, Ordering::Release); + result + }); + + let mut stopped_at = None; + let (status_result, child_exited) = loop { + match child.try_wait() { + Ok(Some(status)) => { + upload_stop.store(true, Ordering::Release); + break (Ok(status), true); + } + Ok(None) => {} + Err(err) => { + connection_stop.store(true, Ordering::Release); + upload_stop.store(true, Ordering::Release); + let _ = child.kill(); + let _ = child.wait(); + break (Err(err), false); + } + } + if bridge_stop.load(Ordering::Acquire) { + connection_stop.store(true, Ordering::Release); + upload_stop.store(true, Ordering::Release); + let _ = child.kill(); + break (child.wait(), false); + } + if client_closed.load(Ordering::Acquire) + || upload_failed.load(Ordering::Acquire) + || download_done.load(Ordering::Acquire) + { + upload_stop.store(true, Ordering::Release); + let stopped_at = stopped_at.get_or_insert_with(Instant::now); + if stopped_at.elapsed() >= Duration::from_millis(250) { + connection_stop.store(true, Ordering::Release); + let _ = child.kill(); + break (child.wait(), false); + } + } + thread::sleep(BRIDGE_ACCEPT_POLL); + }; + upload_stop.store(true, Ordering::Release); + if !child_exited { + connection_stop.store(true, Ordering::Release); + } + let upload_result = upload + .join() + .map_err(|_| io::Error::other("remote bridge upload worker panicked"))?; + let download_result = download + .join() + .map_err(|_| io::Error::other("remote bridge download worker panicked"))?; + let status = status_result?; + + let stopping = bridge_stop.load(Ordering::Acquire); + let client_closed = client_closed.load(Ordering::Acquire); + if !stopping && !client_closed { + upload_result.map_err(|err| { + io::Error::new(err.kind(), format!("remote bridge upload failed: {err}")) + })?; + download_result.map_err(|err| { + io::Error::new(err.kind(), format!("remote bridge download failed: {err}")) + })?; + } + + if status.success() || stopping || client_closed { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + format!("ssh bridge exited with {status}"), + )) + } +} + +#[cfg(unix)] +fn copy_flush(reader: &mut R, writer: &mut W) -> io::Result { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + + loop { + let bytes_read = match reader.read(&mut buffer) { + Ok(0) => return Ok(total), + Ok(bytes_read) => bytes_read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + + writer.write_all(&buffer[..bytes_read])?; + writer.flush()?; + total += bytes_read as u64; + } +} + +#[cfg(windows)] +fn terminate_bridge_child(mut child: std::process::Child, message: &'static str) -> io::Result<()> { + let _ = child.kill(); + let _ = child.wait(); + Err(io::Error::new(io::ErrorKind::BrokenPipe, message)) +} + +#[cfg(windows)] +fn copy_reader_to_local_stream( + reader: &mut R, + stream: &mut crate::ipc::LocalStream, + connection_stop: &AtomicBool, + bridge_stop: &AtomicBool, +) -> io::Result { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + + loop { + let read = match reader.read(&mut buffer) { + Ok(0) => return Ok(total), + Ok(read) => read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + let mut written = 0; + while written < read { + if connection_stop.load(Ordering::Acquire) || bridge_stop.load(Ordering::Acquire) { + return Ok(total); + } + let chunk_len = (read - written).min(4 * 1024); + match stream.write(&buffer[written..written + chunk_len]) { + Ok(0) => thread::sleep(BRIDGE_IO_POLL), + Ok(count) => written += count, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(BRIDGE_IO_POLL); + } + Err(err) => return Err(err), + } + } + stream.flush()?; + total += read as u64; + } +} + +#[cfg(windows)] +fn copy_local_stream_to_writer( + mut stream: crate::ipc::LocalStream, + writer: &mut W, + connection_stop: &AtomicBool, + bridge_stop: &AtomicBool, + client_closed: &AtomicBool, +) -> io::Result { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + + while !connection_stop.load(Ordering::Acquire) && !bridge_stop.load(Ordering::Acquire) { + match crate::ipc::poll_local_stream_read_count(&mut stream, &mut buffer)? { + crate::ipc::LocalStreamReadCount::Data(read) => { + writer.write_all(&buffer[..read])?; + writer.flush()?; + total += read as u64; + } + crate::ipc::LocalStreamReadCount::Pending => thread::sleep(BRIDGE_IO_POLL), + crate::ipc::LocalStreamReadCount::Closed => { + client_closed.store(true, Ordering::Release); + break; + } + } + } + + Ok(total) +} + +fn run_client_process( + local_socket: &Path, + reattach_command: &str, + keybindings: RemoteKeybindings, +) -> io::Result<()> { + let exe = std::env::current_exe()?; + let status = Command::new(exe) + .arg("client") + .env( + crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, + local_socket, + ) + .env("HERDR_RENDER_ENCODING", "terminal-ansi") + .env(REATTACH_COMMAND_ENV_VAR, reattach_command) + .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) + .env_remove(crate::api::SOCKET_PATH_ENV_VAR) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status()?; + + if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::Interrupted, + format!("remote client exited with {status}"), + )) + } +} + +fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { + let pid = std::process::id(); + let target_clean = sanitize_path_component(target); + let session_clean = sanitize_path_component(session_name); + let readable_name = format!("herdr-remote-{pid}-{target_clean}-{session_clean}.sock"); + let target_prefix: String = target_clean.chars().take(8).collect(); + let hash = short_socket_hash(target, session_name); + let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock"); + crate::platform::remote_bridge_endpoint_path(&readable_name, &short_name) +} + +#[cfg(all(test, unix))] +fn fits_unix_socket_path(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + path.as_os_str().as_bytes().len() <= 103 +} + +fn short_socket_hash(target: &str, session: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + target.hash(&mut hasher); + 0u8.hash(&mut hasher); + session.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn sanitize_path_component(input: &str) -> String { + let sanitized: String = input + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { + ch + } else { + '-' + } + }) + .collect(); + + sanitized.trim_matches('-').chars().take(32).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn bridge_socket_is_user_only() { + use std::os::unix::fs::PermissionsExt; + + let socket = std::env::temp_dir().join(format!( + "herdr-bridge-permissions-test-{}.sock", + std::process::id() + )); + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let bridge = SshStdioBridge::start( + "example".to_string(), + remote_herdr, + socket.clone(), + "default".to_string(), + None, + ) + .expect("start bridge listener"); + + let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE); + + drop(bridge); + let _ = std::fs::remove_file(socket); + } + + #[cfg(windows)] + #[test] + fn windows_bridge_drop_while_waiting_for_client_is_bounded() { + let socket = local_forward_socket_path("drop-test", "default"); + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let bridge = SshStdioBridge::start( + "example".to_string(), + remote_herdr, + socket.clone(), + "default".to_string(), + None, + ) + .expect("start bridge listener"); + let started = Instant::now(); + + drop(bridge); + + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(!socket.exists()); + } + + #[cfg(unix)] + #[test] + fn managed_ssh_config_includes_user_config_then_fallback() { + use std::os::unix::fs::PermissionsExt; + + let managed_config = write_managed_ssh_config().expect("write managed config"); + let path = managed_config.options.config_path.clone(); + let control_path = managed_config + .options + .control_path + .clone() + .expect("Unix managed config has a control path"); + let contents = std::fs::read_to_string(&path).expect("read keepalive config"); + + // herdr's fallback transport settings are present... + assert!( + contents.contains("Host *"), + "config should add a Host * fallback block: {contents}" + ); + assert!( + contents.contains("ServerAliveInterval 15"), + "config should set the keepalive interval: {contents}" + ); + assert!( + contents.contains("ServerAliveCountMax 4"), + "config should set the keepalive count: {contents}" + ); + assert!(!contents.contains("ControlMaster")); + assert!(!contents.contains("ControlPersist")); + assert!(!contents.contains("ControlPath")); + // ...and any user config is Included (quoted) BEFORE it so + // first-value-wins keeps the user's own settings. + if let Some(home) = std::env::var_os("HOME") { + let user_config = PathBuf::from(home).join(".ssh").join("config"); + if user_config.is_file() { + let include = format!( + "Include {}", + ssh_config_quote(&user_config.to_string_lossy()) + ); + let include_at = contents.find(&include).expect("user config Included"); + let fallback_at = contents.find("Host *").expect("fallback present"); + assert!( + include_at < fallback_at, + "user config must be Included before herdr's fallback: {contents}" + ); + } + } + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, BRIDGE_SOCKET_PERMISSION_MODE, + "keepalive config must be user-only" + ); + // The config lives in a private 0700 dir, not a predictable temp path. + let dir = path.parent().expect("config has a parent dir"); + let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only"); + assert!( + fits_unix_socket_path(&control_path), + "control socket path must fit portable Unix socket limits" + ); + + drop(managed_config); + } + + #[test] + fn ssh_config_quote_wraps_path_with_spaces() { + assert_eq!( + ssh_config_quote("/home/a b/.ssh/config"), + "\"/home/a b/.ssh/config\"" + ); + } + + #[cfg(unix)] + #[test] + fn remote_ssh_command_uses_managed_config_when_present() { + let managed_config = write_managed_ssh_config().expect("write managed config"); + let config_path = managed_config.options.config_path.clone(); + let control_path = managed_config + .options + .control_path + .clone() + .expect("Unix managed config has a control path"); + let ssh = RemoteSsh { + target: "example".to_string(), + managed_config: Some(managed_config), + }; + + let command = ssh.command(); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!( + args, + vec![ + "-F".to_string(), + config_path.to_string_lossy().into_owned(), + "-S".to_string(), + control_path.to_string_lossy().into_owned(), + "-o".to_string(), + "ControlMaster=auto".to_string(), + "-o".to_string(), + "ControlPersist=yes".to_string(), + "-T".to_string(), + "example".to_string(), + ] + ); + } + + #[cfg(windows)] + #[test] + fn windows_managed_ssh_config_uses_keepalives_without_control_socket() { + let managed_config = write_managed_ssh_config().expect("write managed config"); + let config_path = managed_config.options.config_path.clone(); + assert!(managed_config.options.control_path.is_none()); + let contents = std::fs::read_to_string(&config_path).expect("read managed config"); + assert!(contents.contains("ServerAliveInterval 15")); + assert!(contents.contains("ServerAliveCountMax 4")); + + let ssh = RemoteSsh { + target: "example".to_string(), + managed_config: Some(managed_config), + }; + let args = ssh + .command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!( + args, + vec![ + "-F".to_string(), + config_path.to_string_lossy().into_owned(), + "-T".to_string(), + "example".to_string(), + ] + ); + } + + #[cfg(windows)] + #[test] + fn windows_ssh_config_include_uses_forward_slashes() { + assert_eq!( + ssh_config_include_path(Path::new(r"C:\Users\A B\.ssh\config")), + r#""C:/Users/A B/.ssh/config""# + ); + } + + #[test] + fn remote_ssh_command_is_plain_without_managed_config() { + let ssh = RemoteSsh { + target: "example".to_string(), + managed_config: None, + }; + + let command = ssh.command(); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!(args, vec!["-T".to_string(), "example".to_string()]); + } + + #[test] + fn remote_install_stream_command_avoids_shell_c_wrapper() { + let command = remote_install_stream_command("/home/a b/.local/bin/herdr.tmp.123"); + + assert_eq!(command, "tee '/home/a b/.local/bin/herdr.tmp.123'"); + } + + #[test] + fn remote_install_prepare_and_commit_scripts_quote_paths() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let prepare = remote_install_prepare_script(&remote_herdr); + + assert!(prepare.contains("mkdir -p \"$dir\"")); + assert!(prepare.contains("printf '%s\\0%s\\0' \"$tmp\" \"$dest\"")); + assert_eq!( + parse_remote_install_paths(b"/home/a b/herdr.tmp.42\0/home/a b/herdr\0").unwrap(), + ( + "/home/a b/herdr.tmp.42".to_string(), + "/home/a b/herdr".to_string() + ) + ); + assert_eq!( + parse_remote_install_paths(b"/home/a b\n/herdr.tmp.42\0/home/a b\n/herdr\0").unwrap(), + ( + "/home/a b\n/herdr.tmp.42".to_string(), + "/home/a b\n/herdr".to_string() + ) + ); + assert_eq!( + remote_install_commit_script("/home/a b/herdr.tmp.42", "/home/a b/herdr"), + "set -eu\nchmod 755 '/home/a b/herdr.tmp.42'\nmv '/home/a b/herdr.tmp.42' '/home/a b/herdr'\n" + ); + } + + #[test] + fn extract_remote_args_removes_space_form() { + let args = vec![ + "herdr".into(), + "--remote".into(), + "dev".into(), + "--help".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr", "--help"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert_eq!(remote.keybindings, RemoteKeybindings::Local); + } + + #[test] + fn extract_remote_args_removes_equals_form() { + let args = vec!["herdr".into(), "--remote=user@host".into()]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "user@host"); + assert_eq!(remote.keybindings, RemoteKeybindings::Local); + } + + #[test] + fn extract_remote_args_accepts_remote_keybindings_server() { + let args = vec![ + "herdr".into(), + "--remote".into(), + "dev".into(), + "--remote-keybindings=server".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert_eq!(remote.keybindings, RemoteKeybindings::Server); + } + + #[test] + fn extract_remote_args_accepts_remote_keybindings_space_form() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote-keybindings".into(), + "server".into(), + ]; + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + assert_eq!(cleaned, vec!["herdr"]); + assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server); + } + + #[test] + fn extract_remote_args_accepts_explicit_handoff() { + let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()]; + + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + + assert_eq!(cleaned, vec!["herdr"]); + let remote = remote.unwrap(); + assert_eq!(remote.target, "dev"); + assert!(remote.live_handoff); + } + + #[test] + fn extract_remote_args_preserves_child_remote_options_after_separator() { + let args = vec![ + "herdr".into(), + "agent".into(), + "start".into(), + "repro".into(), + "--".into(), + "child".into(), + "--remote".into(), + "dev".into(), + "--remote-keybindings=server".into(), + "--handoff".into(), + ]; + + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + + assert_eq!(cleaned, args); + assert!(remote.is_none()); + } + + #[test] + fn extract_remote_args_preserves_handoff_without_remote() { + let args = vec!["herdr".into(), "update".into(), "--handoff".into()]; + + let (cleaned, remote) = extract_remote_args(&args).unwrap(); + + assert_eq!(cleaned, args); + assert!(remote.is_none()); + } + + #[test] + fn extract_remote_args_rejects_remote_keybindings_without_remote() { + let args = vec!["herdr".into(), "--remote-keybindings=server".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote-keybindings requires --remote"); + } + + #[test] + fn extract_remote_args_rejects_duplicate_remote_keybindings() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote-keybindings=local".into(), + "--remote-keybindings=server".into(), + ]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote-keybindings can only be specified once"); + } + + #[test] + fn extract_remote_args_requires_value() { + let args = vec!["herdr".into(), "--remote".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "missing value for --remote"); + } + + #[test] + fn extract_remote_args_rejects_empty_value() { + let args = vec!["herdr".into(), "--remote=".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "missing value for --remote"); + } + + #[test] + fn extract_remote_args_rejects_duplicate_values() { + let args = vec![ + "herdr".into(), + "--remote=dev".into(), + "--remote=prod".into(), + ]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote can only be specified once"); + } + + #[test] + fn extract_remote_args_rejects_option_like_target() { + let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()]; + let err = extract_remote_args(&args).unwrap_err(); + assert_eq!(err, "--remote target must not start with '-'"); + } + + #[test] + fn sanitize_path_component_removes_shell_sensitive_chars() { + assert_eq!(sanitize_path_component("user@host:22"), "user-host-22"); + } + + #[test] + fn remote_platform_maps_uname_values() { + assert_eq!( + RemotePlatform::from_uname("Linux", "amd64") + .unwrap() + .asset_key(), + "linux-x86_64" + ); + assert_eq!( + RemotePlatform::from_uname("Darwin", "arm64") + .unwrap() + .asset_key(), + "macos-aarch64" + ); + assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none()); + } + + #[cfg(unix)] + #[test] + fn reattach_command_includes_remote_and_session() { + assert_eq!( + reattach_command( + "target/release/herdr", + "user@host", + "work", + RemoteKeybindings::Local, + false, + ), + "target/release/herdr --remote user@host --session work" + ); + assert_eq!( + reattach_command( + "herdr", + "host name", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Local, + false, + ), + "herdr --remote 'host name'" + ); + assert_eq!( + reattach_command( + "herdr", + "host", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Server, + false, + ), + "herdr --remote host --remote-keybindings server" + ); + assert_eq!( + reattach_command( + "herdr", + "host", + crate::session::DEFAULT_SESSION_NAME, + RemoteKeybindings::Local, + true, + ), + "herdr --remote host --handoff" + ); + } + + #[cfg(windows)] + #[test] + fn windows_reattach_command_uses_current_executable() { + let executable = std::env::current_exe().expect("current test executable"); + assert_eq!( + reattach_command( + r"C:\Program Files\Herdr\herdr.exe", + "host'name", + "work'name", + RemoteKeybindings::Local, + false, + ), + format!( + "& '{}' --remote 'host''name' --session 'work''name'", + executable.display().to_string().replace('\'', "''") + ) + ); + } + + #[test] + fn remote_bridge_command_uses_installed_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec \"$HOME/.local/bin/herdr\" remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_uses_path_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec /usr/bin/herdr remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_quotes_discovered_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = + remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec '/opt/herdr bin/herdr' remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_uses_macos_path_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "macos", + arch: "aarch64", + }); + let remote_herdr = + remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec /opt/homebrew/bin/herdr remote-client-bridge" + ); + assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64"); + } + + #[test] + fn remote_path_discovery_reads_multiple_absolute_paths() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let candidates = remote_herdrs_from_path_discovery( + &remote_herdr, + "/usr/bin/herdr\nbin/herdr\n /opt/herdr bin/herdr\n", + ); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].shell_path, "/usr/bin/herdr"); + assert_eq!(candidates[1].shell_path, "'/opt/herdr bin/herdr'"); + } + + #[test] + fn remote_path_discovery_ignores_mise_shims() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let candidates = remote_herdrs_from_path_discovery( + &remote_herdr, + "/home/can/.local/share/mise/shims/herdr\n/home/can/.local/share/mise/installs/herdr/0.7.1/bin/herdr\n", + ); + + assert_eq!(candidates.len(), 1); + assert_eq!( + candidates[0].shell_path, + "/home/can/.local/share/mise/installs/herdr/0.7.1/bin/herdr" + ); + } + + #[test] + fn known_remote_binary_candidate_script_includes_mise_and_nix_paths() { + let script = known_remote_binary_candidate_script(&RemotePlatform { + os: "linux", + arch: "x86_64", + }); + + assert!(script.contains("emit \"$home/.local/bin/herdr\"")); + assert!(!script.contains("mise/shims/herdr")); + assert!(script.contains(&format!("version={}", shell_quote(¤t_version())))); + assert!( + script.contains("emit \"$home/.local/share/mise/installs/herdr/$version/bin/herdr\"") + ); + assert!(script.contains("emit \"$home/.local/share/mise/installs/herdr/$version/herdr\"")); + assert!(script.contains( + "emit \"$home/.local/share/mise/installs/github-ogulcancelik-herdr/$version/herdr\"" + )); + assert!(script.contains("emit \"$home/.nix-profile/bin/herdr\"")); + assert!(script.contains("emit \"/etc/profiles/per-user/$user/bin/herdr\"")); + assert!(script.contains("emit \"/run/current-system/sw/bin/herdr\"")); + assert!(script.contains("emit \"/home/linuxbrew/.linuxbrew/bin/herdr\"")); + assert!(!script.contains("emit \"/opt/homebrew/bin/herdr\"")); + } + + #[test] + fn known_remote_binary_candidate_script_includes_macos_homebrew_paths() { + let script = known_remote_binary_candidate_script(&RemotePlatform { + os: "macos", + arch: "aarch64", + }); + + assert!(script.contains("emit \"/opt/homebrew/bin/herdr\"")); + assert!(script.contains("emit \"/usr/local/bin/herdr\"")); + assert!(!script.contains("emit \"/home/linuxbrew/.linuxbrew/bin/herdr\"")); + } + + #[test] + fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = + remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n") + .expect("path binary"); + + assert_eq!( + remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), + "exec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge" + ); + } + + #[test] + fn remote_path_discovery_ignores_relative_paths() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n"); + + assert!(remote_herdr.is_none()); + } + + #[test] + fn remote_path_discovery_ignores_empty_output() { + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n"); + + assert!(remote_herdr.is_none()); + } + + #[test] + fn remote_shell_path_warning_accepts_managed_install() { + assert!(remote_shell_resolves_managed_install( + "/home/can/.local/bin/herdr\n" + )); + assert!(remote_shell_resolves_managed_install( + "/Users/can/.local/bin/herdr\n" + )); + assert!(!remote_shell_resolves_managed_install( + "/usr/local/bin/herdr\n" + )); + assert!(!remote_shell_resolves_managed_install("")); + } + + #[test] + fn parse_client_status_json_reads_protocol() { + assert_eq!( + parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#) + .map(|status| status.protocol), + Some(8) + ); + assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none()); + } + + #[test] + fn parse_remote_server_status_json_reads_running_server() { + assert_eq!( + parse_remote_server_status_json( + r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true}}"# + ) + .unwrap(), + RemoteServerStatus::Running { + version: Some("0.6.0".into()), + protocol: Some(8), + live_handoff: true, + detached_server_daemon: true + } + ); + } + + #[test] + fn parse_remote_server_status_json_treats_missing_capability_as_old_server() { + assert_eq!( + parse_remote_server_status_json( + r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"# + ) + .unwrap(), + RemoteServerStatus::Running { + version: Some("0.6.0".into()), + protocol: Some(8), + live_handoff: false, + detached_server_daemon: false + } + ); + } + + #[test] + fn parse_remote_server_status_json_reads_stopped_server() { + assert_eq!( + parse_remote_server_status_json( + r#"{"status":"not_running","running":false,"version":null,"protocol":null}"# + ) + .unwrap(), + RemoteServerStatus::NotRunning + ); + } + + #[test] + fn remote_update_manifest_uses_root_assets_for_latest_version() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.3", + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.assets.get("linux-x86_64")) + .map(RemoteAssetRef::url), + Some("https://example.com/latest") + ); + } + + #[test] + fn remote_update_manifest_reads_archived_release_assets() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.assets.get("linux-x86_64")) + .map(RemoteAssetRef::url), + Some("https://example.com/archive") + ); + } + + #[test] + fn remote_update_manifest_uses_archived_release_protocol() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "protocol": 42, + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "protocol": 41, + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.protocol), + Some(41) + ); + } + + #[test] + fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "protocol": 42, + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.protocol), + None + ); + } + + #[test] + fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() { + let manifest: RemotePreviewManifest = serde_json::from_str( + r#"{ + "build_id": "2026-06-06-new", + "protocol": 12, + "assets": { + "linux-x86_64": { + "url": "https://example.com/new", + "sha256": "new" + } + }, + "builds": { + "2026-06-02-old": { + "protocol": 11, + "assets": { + "linux-x86_64": { + "url": "https://example.com/old", + "sha256": "old" + } + } + } + } + }"#, + ) + .unwrap(); + + let (protocol, assets) = + preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build"); + let asset = assets.get("linux-x86_64").expect("asset"); + assert_eq!(protocol, 11); + assert_eq!(asset.url(), "https://example.com/old"); + assert_eq!(asset.sha256(), Some("old")); + } + + #[test] + fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() { + assert_eq!( + remote_server_restart_reason(Some(¤t_version()), Some(0), true, false), + Some(RemoteServerRestartReason::ProtocolMismatch) + ); + } + + #[test] + fn remote_server_restart_reason_allows_unchanged_compatible_server() { + assert_eq!( + remote_server_restart_reason( + Some(¤t_version()), + Some(CURRENT_PROTOCOL), + true, + false + ), + None + ); + } + + #[test] + fn remote_server_restart_reason_requires_restart_for_old_daemon() { + assert_eq!( + remote_server_restart_reason( + Some(¤t_version()), + Some(CURRENT_PROTOCOL), + false, + false + ), + Some(RemoteServerRestartReason::DaemonDetachMissing) + ); + } + + #[test] + fn remote_server_restart_reason_requires_restart_after_helper_update() { + assert_eq!( + remote_server_restart_reason( + Some(¤t_version()), + Some(CURRENT_PROTOCOL), + true, + true + ), + Some(RemoteServerRestartReason::BinaryUpdated) + ); + } + + #[test] + fn remote_server_restart_reason_offers_restart_for_version_mismatch() { + assert_eq!( + remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), true, false), + Some(RemoteServerRestartReason::VersionMismatch) + ); + assert_eq!( + remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), true, false), + Some(RemoteServerRestartReason::VersionMismatch) + ); + } + + #[test] + fn remote_server_restart_reason_allows_current_server() { + assert_eq!( + remote_server_restart_reason( + Some(¤t_version()), + Some(CURRENT_PROTOCOL), + true, + false + ), + None + ); + } + + #[test] + fn remote_install_plan_keeps_compatible_running_server() { + assert_eq!( + remote_install_running_server_plan( + Some(¤t_version()), + Some(CURRENT_PROTOCOL), + true, + false, + false, + false + ), + RemoteInstallRunningServerPlan::KeepRunning + ); + } + + #[test] + fn remote_install_plan_requires_stop_for_old_daemon() { + assert_eq!( + remote_install_running_server_plan( + Some(¤t_version()), + Some(CURRENT_PROTOCOL), + false, + true, + false, + false + ), + RemoteInstallRunningServerPlan::StopRequired( + RemoteServerRestartReason::DaemonDetachMissing + ) + ); + } + + #[test] + fn remote_install_plan_requires_stop_after_helper_update() { + assert_eq!( + remote_install_running_server_plan( + Some(¤t_version()), + Some(CURRENT_PROTOCOL), + true, + true, + false, + false + ), + RemoteInstallRunningServerPlan::StopRequired(RemoteServerRestartReason::BinaryUpdated) + ); + } + + #[test] + fn remote_install_plan_requires_stop_for_incompatible_running_server() { + assert_eq!( + remote_install_running_server_plan( + Some("0.0.0"), + Some(CURRENT_PROTOCOL), + true, + true, + false, + false + ), + RemoteInstallRunningServerPlan::StopRequired( + RemoteServerRestartReason::VersionMismatch + ) + ); + } + + #[test] + fn remote_install_plan_uses_live_handoff_for_incompatible_running_server() { + assert_eq!( + remote_install_running_server_plan( + Some("0.0.0"), + Some(CURRENT_PROTOCOL), + true, + true, + true, + true + ), + RemoteInstallRunningServerPlan::LiveHandoff + ); + } + + #[test] + fn install_source_description_uses_override_binary() { + let platform = RemotePlatform { + os: "linux", + arch: "aarch64", + }; + assert_eq!( + install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false), + "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)" + ); + } + + #[test] + fn install_source_description_uses_local_binary_when_allowed() { + let platform = RemotePlatform::local(); + + assert_eq!( + install_source_description_for(&platform, None, true), + "the current local herdr binary" + ); + } + + #[test] + fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() { + let platform = RemotePlatform::local(); + + assert_eq!( + install_source_description_for(&platform, None, false), + format!( + "the {} {} asset for {}", + current_version(), + current_channel(), + platform.asset_key() + ) + ); + } + + #[test] + fn resolve_install_source_uses_override_binary_without_temporary_cleanup() { + let platform = RemotePlatform { + os: "linux", + arch: "aarch64", + }; + let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64"))) + .expect("override source"); + assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64")); + assert!(source.temporary_dir.is_none()); + } + + #[cfg(windows)] + #[test] + fn windows_local_forward_endpoint_uses_private_state_dir() { + let path = local_forward_socket_path("user@example.com", "work"); + assert!(path.starts_with(crate::platform::remote_private_temp_base())); + assert!(path + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("herdr-r-"))); + } + + #[cfg(unix)] + fn remote_env_lock() -> &'static std::sync::Mutex<()> { + static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); + LOCK.get_or_init(|| std::sync::Mutex::new(())) + } + + #[cfg(unix)] + fn socket_path_byte_len(path: &Path) -> usize { + use std::os::unix::ffi::OsStrExt; + path.as_os_str().as_bytes().len() + } + + #[cfg(unix)] + #[test] + fn local_forward_socket_path_uses_readable_name_when_it_fits() { + let _guard = remote_env_lock().lock().unwrap(); + // Short target + session leave plenty of room — keep the human- + // readable form so the socket path stays grep-friendly. + let path = local_forward_socket_path("dev", "default"); + let filename = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + assert!( + filename.starts_with("herdr-remote-"), + "expected readable name, got {filename}" + ); + assert!(filename.contains("-dev-default."), "got {filename}"); + assert!( + fits_unix_socket_path(&path), + "socket path too long: {} ({} bytes)", + path.display(), + socket_path_byte_len(&path) + ); + } + + #[cfg(unix)] + #[test] + fn local_forward_socket_path_fits_in_sun_path() { + let _guard = remote_env_lock().lock().unwrap(); + // Worst case for the readable form: macOS-style 49-char TMPDIR + + // max-length sanitized components. Should fall back to the hashed + // short name, which fits under TMPDIR. + let target = "longish-host.example.com"; + let session = "a-fairly-long-session-name-here"; + let path = local_forward_socket_path(target, session); + assert!( + fits_unix_socket_path(&path), + "socket path too long for sun_path: {} ({} bytes)", + path.display(), + socket_path_byte_len(&path) + ); + } + + #[cfg(unix)] + #[test] + fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() { + let _guard = remote_env_lock().lock().unwrap(); + // Force a TMPDIR long enough that even the hashed short name cannot + // fit inside it. The fallback should drop to /tmp. + let prior = std::env::var_os("TMPDIR"); + let long_dir = std::env::temp_dir().join("a".repeat(80)); + let _ = fs::create_dir_all(&long_dir); + std::env::set_var("TMPDIR", &long_dir); + + let path = local_forward_socket_path("longish-host.example.com", "default"); + let fits = fits_unix_socket_path(&path); + let parent = path.parent().map(Path::to_path_buf); + let filename = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + + match prior { + Some(v) => std::env::set_var("TMPDIR", v), + None => std::env::remove_var("TMPDIR"), + } + let _ = fs::remove_dir_all(&long_dir); + + assert!(fits, "fallback path still overflows: {}", path.display()); + assert_eq!(parent.as_deref(), Some(Path::new("/tmp"))); + assert!( + filename.starts_with("herdr-r-"), + "expected hashed fallback, got {filename}" + ); + } + + #[test] + fn install_source_cleanup_removes_temporary_directory() { + let dir = std::env::temp_dir().join(format!( + "herdr-install-source-cleanup-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir(&dir).expect("create temp dir"); + let path = dir.join("herdr.tmp"); + fs::write(&path, b"test").expect("write temp file"); + + InstallSource::temporary(path, dir.clone()).cleanup(); + + assert!(!dir.exists()); + } +} diff --git a/src/remote/host_unix.rs b/src/remote/host_unix.rs new file mode 100644 index 00000000..e04b0e00 --- /dev/null +++ b/src/remote/host_unix.rs @@ -0,0 +1,69 @@ +//! Unix remote-host side of the SSH stdio bridge. + +use std::io; +use std::os::unix::net::UnixStream; +use std::thread; +use std::time::Duration; + +pub(crate) fn run_remote_client_bridge() -> io::Result<()> { + ensure_remote_server_running()?; + + let socket_path = crate::server::socket_paths::client_socket_path(); + let stream = UnixStream::connect(&socket_path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to connect to remote Herdr client socket {}: {err}", + socket_path.display() + ), + ) + })?; + + let mut stdout = io::stdout().lock(); + let mut socket_to_stdout = stream.try_clone()?; + let mut stdin_to_socket = stream; + + let _upload = thread::spawn(move || { + let mut stdin = io::stdin(); + let _ = copy_flush(&mut stdin, &mut stdin_to_socket); + let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write); + }); + + copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ()) +} + +fn copy_flush(reader: &mut R, writer: &mut W) -> io::Result { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + loop { + let read = match reader.read(&mut buffer) { + Ok(0) => return Ok(total), + Ok(read) => read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + writer.write_all(&buffer[..read])?; + writer.flush()?; + total += read as u64; + } +} + +fn ensure_remote_server_running() -> io::Result<()> { + let socket_path = crate::server::socket_paths::client_socket_path(); + if crate::server::autodetect::is_server_listening() { + let status = crate::api::read_runtime_status_at( + &crate::api::socket_path(), + Duration::from_millis(500), + )? + .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?; + if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) { + return Ok(()); + } + return Err(io::Error::other( + "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it", + )); + } + + crate::server::autodetect::spawn_server_daemon()?; + crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5)) +} diff --git a/src/remote/unix.rs b/src/remote/unix.rs deleted file mode 100644 index 6fe927c1..00000000 --- a/src/remote/unix.rs +++ /dev/null @@ -1,3111 +0,0 @@ -//! Remote thin-client launcher over SSH command stdio. - -use std::collections::BTreeMap; -use std::fs::{self, File}; -use std::io::{self, IsTerminal, Write as _}; -use std::os::unix::net::{UnixListener, UnixStream}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; - -use serde::Deserialize; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; - -const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); -const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; -const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); -const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); -const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION; -const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json"; -const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json"; -const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY"; -const SSH_CONTROL_SOCKET_NAME: &str = "ctl"; -pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND"; - -pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RemoteKeybindings { - Local, - Server, -} - -impl RemoteKeybindings { - fn parse(value: &str) -> Result { - match value { - "local" => Ok(Self::Local), - "server" => Ok(Self::Server), - _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Server => "server", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RemoteLaunch { - pub(crate) target: String, - pub(crate) keybindings: RemoteKeybindings, - pub(crate) live_handoff: bool, -} - -pub(crate) fn extract_remote_args( - args: &[String], -) -> Result<(Vec, Option), String> { - let mut cleaned = Vec::with_capacity(args.len()); - if let Some(program) = args.first() { - cleaned.push(program.clone()); - } - - let mut remote_target = None; - let mut keybindings = RemoteKeybindings::Local; - let mut keybindings_seen = false; - let mut live_handoff = false; - let mut index = 1; - while index < args.len() { - let arg = &args[index]; - if arg == "--" { - cleaned.extend_from_slice(&args[index..]); - break; - } - if arg == "--handoff" { - live_handoff = true; - index += 1; - continue; - } - if arg == "--remote" { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote".to_string()); - }; - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote=") { - if remote_target.is_some() { - return Err("--remote can only be specified once".to_string()); - } - remote_target = Some(validate_remote_target(value)?.to_owned()); - index += 1; - continue; - } - if arg == "--remote-keybindings" { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - let Some(value) = args.get(index + 1) else { - return Err("missing value for --remote-keybindings".to_string()); - }; - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 2; - continue; - } - if let Some(value) = arg.strip_prefix("--remote-keybindings=") { - if keybindings_seen { - return Err("--remote-keybindings can only be specified once".to_string()); - } - keybindings = RemoteKeybindings::parse(value)?; - keybindings_seen = true; - index += 1; - continue; - } - - cleaned.push(arg.clone()); - index += 1; - } - - let remote = remote_target.map(|target| RemoteLaunch { - target, - keybindings, - live_handoff, - }); - if remote.is_none() && keybindings_seen { - return Err("--remote-keybindings requires --remote".to_string()); - } - if remote.is_none() && live_handoff { - cleaned.push("--handoff".to_string()); - } - - Ok((cleaned, remote)) -} - -fn validate_remote_target(target: &str) -> Result<&str, String> { - if target.is_empty() { - return Err("missing value for --remote".to_string()); - } - if target.starts_with('-') { - return Err("--remote target must not start with '-'".to_string()); - } - Ok(target) -} - -pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { - let session_name = crate::session::active_name() - .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string()); - let local_socket = local_forward_socket_path(&remote.target, &session_name); - let program = std::env::args() - .next() - .unwrap_or_else(|| "herdr".to_string()); - let reattach_command = reattach_command( - &program, - &remote.target, - &session_name, - remote.keybindings, - remote.live_handoff, - ); - let manage_ssh_config = crate::config::Config::load() - .config - .remote - .manage_ssh_config; - let remote_ssh = RemoteSsh::new(remote.target.clone(), manage_ssh_config); - let prepared_remote = prepare_remote_herdr(&remote_ssh, remote.live_handoff)?; - ensure_remote_server_ready( - &remote_ssh, - &prepared_remote.remote_herdr, - prepared_remote.installed_or_replaced, - prepared_remote.stop_after_install_approved, - remote.live_handoff, - )?; - - let _bridge = SshStdioBridge::start( - remote.target, - prepared_remote.remote_herdr, - local_socket.clone(), - session_name, - remote_ssh.options(), - )?; - - run_client_process(&local_socket, &reattach_command, remote.keybindings) -} - -pub(crate) fn run_remote_client_bridge() -> io::Result<()> { - ensure_remote_server_running()?; - - let socket_path = crate::server::socket_paths::client_socket_path(); - let stream = UnixStream::connect(&socket_path).map_err(|err| { - io::Error::new( - err.kind(), - format!( - "failed to connect to remote Herdr client socket {}: {err}", - socket_path.display() - ), - ) - })?; - - let mut stdout = io::stdout().lock(); - let mut socket_to_stdout = stream.try_clone()?; - let mut stdin_to_socket = stream; - - let _upload = thread::spawn(move || { - let mut stdin = io::stdin(); - let _ = copy_flush(&mut stdin, &mut stdin_to_socket); - let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write); - }); - - copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ()) -} - -fn ensure_remote_server_running() -> io::Result<()> { - let socket_path = crate::server::socket_paths::client_socket_path(); - if crate::server::autodetect::is_server_listening() { - let status = crate::api::read_runtime_status_at( - &crate::api::socket_path(), - Duration::from_millis(500), - )? - .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?; - if status.protocol == Some(CURRENT_PROTOCOL) { - return Ok(()); - } - return Err(io::Error::other( - "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it", - )); - } - - crate::server::autodetect::spawn_server_daemon()?; - crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5)) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RemotePlatform { - os: &'static str, - arch: &'static str, -} - -impl RemotePlatform { - fn from_uname(os: &str, arch: &str) -> Option { - let os = match os.trim() { - "Linux" => "linux", - "Darwin" => "macos", - _ => return None, - }; - let arch = match arch.trim() { - "x86_64" | "amd64" => "x86_64", - "aarch64" | "arm64" => "aarch64", - _ => return None, - }; - Some(Self { os, arch }) - } - - fn local() -> Self { - let os = if cfg!(target_os = "linux") { - "linux" - } else if cfg!(target_os = "macos") { - "macos" - } else { - "unknown" - }; - - let arch = if cfg!(target_arch = "x86_64") { - "x86_64" - } else if cfg!(target_arch = "aarch64") { - "aarch64" - } else { - "unknown" - }; - - Self { os, arch } - } - - fn asset_key(&self) -> String { - format!("{}-{}", self.os, self.arch) - } -} - -#[derive(Debug, Clone)] -struct RemoteHerdr { - install_suffix: String, - shell_path: String, - platform: RemotePlatform, -} - -impl RemoteHerdr { - fn for_platform(platform: RemotePlatform) -> Self { - let install_suffix = ".local/bin/herdr".to_string(); - let shell_path = format!("\"$HOME/{install_suffix}\""); - Self { - install_suffix, - shell_path, - platform, - } - } - - fn with_shell_path(mut self, shell_path: String) -> Self { - self.shell_path = shell_path; - self - } -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -enum RemoteAssetRef { - Url(String), - Object { url: String, sha256: Option }, -} - -impl RemoteAssetRef { - fn url(&self) -> &str { - match self { - Self::Url(url) => url, - Self::Object { url, .. } => url, - } - } - - fn sha256(&self) -> Option<&str> { - match self { - Self::Url(_) => None, - Self::Object { sha256, .. } => { - sha256.as_deref().filter(|value| !value.trim().is_empty()) - } - } - } -} - -#[derive(Deserialize)] -struct RemoteUpdateManifest { - version: String, - protocol: Option, - assets: BTreeMap, - #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] - releases: BTreeMap, -} - -#[derive(Deserialize)] -struct RemoteReleaseMetadata { - protocol: Option, - #[serde(default)] - assets: BTreeMap, -} - -#[derive(Deserialize)] -struct RemotePreviewManifest { - build_id: String, - protocol: u32, - assets: BTreeMap, - #[serde(default)] - builds: BTreeMap, -} - -#[derive(Deserialize)] -struct RemotePreviewBuildMetadata { - protocol: u32, - assets: BTreeMap, -} - -fn deserialize_remote_manifest_releases<'de, D>( - deserializer: D, -) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - Ok(match value { - Some(serde_json::Value::Object(object)) => object - .into_iter() - .filter_map(|(version, release)| { - serde_json::from_value::(release) - .ok() - .map(|metadata| (version, metadata)) - }) - .collect(), - _ => BTreeMap::new(), - }) -} - -impl RemoteUpdateManifest { - fn release_for_version(&self, version: &str) -> Option> { - if self.version.trim_start_matches('v') == version { - return Some(RemoteManifestReleaseRef { - protocol: self.protocol, - assets: &self.assets, - }); - } - - self.releases.get(version).and_then(|release| { - (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef { - protocol: release.protocol, - assets: &release.assets, - }) - }) - } -} - -#[derive(Clone, Copy)] -struct RemoteManifestReleaseRef<'a> { - protocol: Option, - assets: &'a BTreeMap, -} - -fn current_version() -> String { - crate::build_info::version() -} - -fn current_channel() -> &'static str { - crate::build_info::channel() -} - -struct InstallSource { - path: PathBuf, - temporary_dir: Option, -} - -struct RemoteReleaseAsset { - url: String, - sha256: Option, -} - -struct PreparedRemoteHerdr { - remote_herdr: RemoteHerdr, - installed_or_replaced: bool, - stop_after_install_approved: bool, -} - -#[derive(Clone)] -struct ManagedSshOptions { - config_path: PathBuf, - control_path: PathBuf, -} - -struct ManagedSshConfig { - options: ManagedSshOptions, -} - -impl Drop for ManagedSshConfig { - fn drop(&mut self) { - if let Some(dir) = self.options.config_path.parent() { - let _ = fs::remove_dir_all(dir); - } - } -} - -struct RemoteSsh { - target: String, - managed_config: Option, -} - -impl RemoteSsh { - fn new(target: String, manage_ssh_config: bool) -> Self { - let managed_config = if manage_ssh_config { - write_managed_ssh_config() - .inspect_err(|err| { - tracing::debug!(%err, "could not write managed ssh config; using plain ssh"); - }) - .ok() - } else { - None - }; - - Self { - target, - managed_config, - } - } - - fn target(&self) -> &str { - &self.target - } - - fn options(&self) -> Option<&ManagedSshOptions> { - self.managed_config.as_ref().map(|config| &config.options) - } - - fn command(&self) -> Command { - let mut command = self.base_command(); - command.arg("-T").arg(&self.target); - command - } - - fn base_command(&self) -> Command { - let mut command = Command::new("ssh"); - apply_managed_ssh_options(&mut command, self.options()); - command - } - - fn sh_output(&self, script: &str) -> io::Result { - let mut child = self - .command() - .arg("/bin/sh -s") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - - let write_result = if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(script.as_bytes()) - } else { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "ssh bootstrap stdin missing", - )) - }; - let output = child.wait_with_output()?; - write_result?; - Ok(output) - } - - fn user_shell_output(&self, command: &str) -> io::Result { - self.command().arg(command).output() - } - - fn install_herdr(&self, remote_herdr: &RemoteHerdr, source_path: &Path) -> io::Result<()> { - let output = self.sh_output(&remote_install_prepare_script(remote_herdr))?; - if !output.status.success() { - return Err(command_failed("remote install preparation failed", &output)); - } - let (tmp_path, dest_path) = parse_remote_install_paths(&output.stdout)?; - - let mut child = self - .command() - .arg(remote_install_stream_command(&tmp_path)) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::inherit()) - .spawn() - .map_err(|err| { - io::Error::new(err.kind(), format!("failed to start ssh install: {err}")) - })?; - - let mut source = File::open(source_path)?; - let copy_result = if let Some(mut stdin) = child.stdin.take() { - io::copy(&mut source, &mut stdin).map(|_| ()) - } else { - Err(io::Error::new( - io::ErrorKind::BrokenPipe, - "ssh install stdin missing", - )) - }; - let status = child.wait()?; - copy_result?; - - if status.success() { - let output = self.sh_output(&remote_install_commit_script(&tmp_path, &dest_path))?; - if output.status.success() { - Ok(()) - } else { - Err(command_failed("remote install commit failed", &output)) - } - } else { - Err(io::Error::other(format!( - "remote install exited with {status}" - ))) - } - } -} - -fn remote_install_prepare_script(remote_herdr: &RemoteHerdr) -> String { - format!( - r#"set -eu -dest="$HOME/{install_suffix}" -dir="${{dest%/*}}" -mkdir -p "$dir" -tmp="${{dest}}.tmp.$$" -printf '%s\0%s\0' "$tmp" "$dest" -"#, - install_suffix = remote_herdr.install_suffix - ) -} - -fn parse_remote_install_paths(stdout: &[u8]) -> io::Result<(String, String)> { - let mut parts = stdout.split(|byte| *byte == 0); - let tmp_path = parts.next().unwrap_or_default(); - let dest_path = parts.next().unwrap_or_default(); - if tmp_path.is_empty() || dest_path.is_empty() { - return Err(io::Error::other( - "remote install preparation did not return destination paths", - )); - } - let tmp_path = String::from_utf8(tmp_path.to_vec()).map_err(|err| { - io::Error::other(format!( - "remote install temporary path is not valid UTF-8: {err}" - )) - })?; - let dest_path = String::from_utf8(dest_path.to_vec()).map_err(|err| { - io::Error::other(format!( - "remote install destination path is not valid UTF-8: {err}" - )) - })?; - Ok((tmp_path, dest_path)) -} - -fn remote_install_stream_command(tmp_path: &str) -> String { - format!("tee {}", shell_quote(tmp_path)) -} - -fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String { - format!( - "set -eu\nchmod 755 {tmp_path}\nmv {tmp_path} {dest_path}\n", - tmp_path = shell_quote(tmp_path), - dest_path = shell_quote(dest_path) - ) -} - -impl Drop for RemoteSsh { - fn drop(&mut self) { - if self.managed_config.is_none() { - return; - } - - let _ = self - .base_command() - .arg("-O") - .arg("exit") - .arg("-o") - .arg("BatchMode=yes") - .arg(&self.target) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } -} - -fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshOptions>) { - let Some(options) = options else { - return; - }; - - command - .arg("-F") - .arg(&options.config_path) - .arg("-S") - .arg(&options.control_path) - .arg("-o") - .arg("ControlMaster=auto") - .arg("-o") - .arg("ControlPersist=yes"); -} - -impl InstallSource { - fn persistent(path: PathBuf) -> Self { - Self { - path, - temporary_dir: None, - } - } - - fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self { - Self { - path, - temporary_dir: Some(temporary_dir), - } - } - - fn cleanup(&self) { - if let Some(dir) = &self.temporary_dir { - let _ = fs::remove_dir_all(dir); - } - } -} - -fn prepare_remote_herdr( - ssh: &RemoteSsh, - live_handoff_enabled: bool, -) -> io::Result { - let platform = detect_remote_platform(ssh)?; - let remote_herdr = RemoteHerdr::for_platform(platform); - let override_binary = remote_binary_override_path()?; - let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?; - - if override_binary.is_none() { - for candidate in &remote_binary_candidates { - if remote_binary_matches(ssh, candidate).unwrap_or(false) { - return Ok(PreparedRemoteHerdr { - remote_herdr: candidate.clone(), - installed_or_replaced: false, - stop_after_install_approved: false, - }); - } - } - if remote_binary_matches(ssh, &remote_herdr)? { - return Ok(PreparedRemoteHerdr { - remote_herdr, - installed_or_replaced: false, - stop_after_install_approved: false, - }); - } - } - - let mut stop_after_install_approved = false; - if let Some(status_probe_herdr) = remote_binary_candidates.first().or_else(|| { - remote_binary_exists(ssh, &remote_herdr) - .ok() - .and_then(|exists| exists.then_some(&remote_herdr)) - }) { - stop_after_install_approved = confirm_remote_install_with_running_server( - ssh, - status_probe_herdr, - live_handoff_enabled, - )?; - } - confirm_remote_install( - ssh.target(), - &remote_herdr, - &install_source_description(&remote_herdr.platform, override_binary.as_deref()), - )?; - let source = resolve_install_source(&remote_herdr.platform, override_binary)?; - let install_result = ssh.install_herdr(&remote_herdr, &source.path); - source.cleanup(); - install_result?; - - if !remote_binary_matches(ssh, &remote_herdr)? { - return Err(io::Error::other(format!( - "installed remote herdr at {}, but it did not report version {}", - remote_herdr.shell_path, - current_version() - ))); - } - warn_if_remote_bin_not_on_path(ssh)?; - - Ok(PreparedRemoteHerdr { - remote_herdr, - installed_or_replaced: true, - stop_after_install_approved, - }) -} - -fn detect_remote_platform(ssh: &RemoteSsh) -> io::Result { - let output = ssh.sh_output("uname -s\nuname -m\n")?; - if !output.status.success() { - return Err(command_failed("remote platform detection failed", &output)); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut lines = stdout.lines(); - let os = lines.next().unwrap_or_default(); - let arch = lines.next().unwrap_or_default(); - RemotePlatform::from_uname(os, arch).ok_or_else(|| { - io::Error::other(format!( - "unsupported remote platform: {} {}", - os.trim(), - arch.trim() - )) - }) -} - -fn remote_binary_candidates( - ssh: &RemoteSsh, - remote_herdr: &RemoteHerdr, -) -> io::Result> { - let mut candidates = Vec::new(); - - if let Some(path_candidate) = remote_binary_on_path_any(ssh, remote_herdr)? { - push_if_new_remote_binary_candidate(&mut candidates, path_candidate); - } - - let output = ssh.sh_output(&known_remote_binary_candidate_script( - &remote_herdr.platform, - ))?; - if !output.status.success() { - return Err(command_failed("remote binary discovery failed", &output)); - } - let stdout = String::from_utf8_lossy(&output.stdout); - for candidate in remote_herdrs_from_path_discovery(remote_herdr, &stdout) { - push_if_new_remote_binary_candidate(&mut candidates, candidate); - } - - Ok(candidates) -} - -fn push_if_new_remote_binary_candidate(candidates: &mut Vec, candidate: RemoteHerdr) { - if !candidates - .iter() - .any(|existing| existing.shell_path == candidate.shell_path) - { - candidates.push(candidate); - } -} - -fn known_remote_binary_candidate_script(platform: &RemotePlatform) -> String { - let mut script = String::from( - r#"home=${HOME:-} -user=${USER:-} -version="#, - ); - script.push_str(&shell_quote(¤t_version())); - script.push_str( - r#" -emit() { - path=$1 - if [ -n "$path" ] && [ -x "$path" ]; then - printf '%s\n' "$path" - fi -} -if [ -n "$home" ]; then - emit "$home/.local/bin/herdr" -fi -"#, - ); - if platform.os == "macos" { - script.push_str( - r#" emit "/opt/homebrew/bin/herdr" - emit "/usr/local/bin/herdr" -"#, - ); - } else if platform.os == "linux" { - script.push_str( - r#" emit "/home/linuxbrew/.linuxbrew/bin/herdr" -"#, - ); - } - script.push_str( - r#"if [ -n "$home" ]; then - emit "$home/.local/share/mise/installs/herdr/$version/bin/herdr" - emit "$home/.local/share/mise/installs/herdr/$version/herdr" - emit "$home/.local/share/mise/installs/github-ogulcancelik-herdr/$version/herdr" - emit "$home/.nix-profile/bin/herdr" -fi -if [ -n "$user" ]; then - emit "/etc/profiles/per-user/$user/bin/herdr" -fi -emit "/nix/var/nix/profiles/default/bin/herdr" -emit "/run/current-system/sw/bin/herdr" -"#, - ); - - script -} - -fn remote_binary_on_path_any( - ssh: &RemoteSsh, - remote_herdr: &RemoteHerdr, -) -> io::Result> { - let output = ssh.user_shell_output("command -v herdr")?; - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - if let Some(candidate) = remote_herdr_from_path_discovery(remote_herdr, &stdout) { - return Ok(Some(candidate)); - } - } - - // Non-POSIX login shells such as xonsh reject `command -v`; retry through - // /bin/sh while retaining the login-shell probe for shell-initialized PATHs. - let output = ssh.sh_output("command -v herdr\n")?; - if !output.status.success() { - return Ok(None); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout)) -} - -fn remote_herdrs_from_path_discovery(remote_herdr: &RemoteHerdr, stdout: &str) -> Vec { - stdout - .lines() - .filter_map(|path| remote_herdr_from_path(remote_herdr, path)) - .collect() -} - -fn remote_herdr_from_path_discovery( - remote_herdr: &RemoteHerdr, - stdout: &str, -) -> Option { - stdout - .lines() - .find_map(|path| remote_herdr_from_path(remote_herdr, path)) -} - -fn remote_herdr_from_path(remote_herdr: &RemoteHerdr, path: &str) -> Option { - let path = path.trim(); - if !path.starts_with('/') { - return None; - } - if is_mise_shim_path(path) { - return None; - } - Some(remote_herdr.clone().with_shell_path(shell_quote(path))) -} - -fn is_mise_shim_path(path: &str) -> bool { - path.ends_with("/mise/shims/herdr") -} - -fn remote_binary_matches(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result { - let command = format!( - "test -x {0} && {0} --version && {0} status client --json", - remote_herdr.shell_path - ); - let output = ssh.sh_output(&command)?; - if !output.status.success() { - return Ok(false); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let mut lines = stdout.lines(); - let version = lines.next().unwrap_or_default().trim(); - let status = lines.next().unwrap_or_default(); - Ok(version == format!("herdr {}", current_version()) - && parse_client_status_json(status) - .map(|status| status.protocol == CURRENT_PROTOCOL) - .unwrap_or(false)) -} - -fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result { - let command = format!("test -x {}", remote_herdr.shell_path); - Ok(ssh.sh_output(&command)?.status.success()) -} - -fn remote_binary_override_path() -> io::Result> { - let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else { - return Ok(None); - }; - if value.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("{REMOTE_BINARY_ENV_VAR} must not be empty"), - )); - } - - let path = PathBuf::from(value); - let metadata = fs::metadata(&path).map_err(|err| { - io::Error::new( - err.kind(), - format!( - "failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}", - path.display() - ), - ) - })?; - if !metadata.is_file() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "{REMOTE_BINARY_ENV_VAR} path is not a file: {}", - path.display() - ), - )); - } - - Ok(Some(path)) -} - -fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String { - install_source_description_for( - platform, - override_binary, - local_binary_can_seed_remote(platform), - ) -} - -fn install_source_description_for( - platform: &RemotePlatform, - override_binary: Option<&Path>, - local_binary_can_seed_remote: bool, -) -> String { - if let Some(path) = override_binary { - return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display()); - } - - if local_binary_can_seed_remote { - "the current local herdr binary".to_string() - } else { - format!( - "the {} {} asset for {}", - current_version(), - current_channel(), - platform.asset_key() - ) - } -} - -fn resolve_install_source( - platform: &RemotePlatform, - override_binary: Option, -) -> io::Result { - if let Some(path) = override_binary { - return Ok(InstallSource::persistent(path)); - } - - if *platform == RemotePlatform::local() { - let path = std::env::current_exe()?; - if !crate::update::is_package_manager_managed_exe_path(&path) { - return Ok(InstallSource::persistent(path)); - } - } - - download_release_asset(platform) -} - -fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { - if *platform != RemotePlatform::local() { - return false; - } - - std::env::current_exe() - .map(|path| !crate::update::is_package_manager_managed_exe_path(&path)) - .unwrap_or(false) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RemoteServerStatus { - Running { - version: Option, - protocol: Option, - live_handoff: bool, - detached_server_daemon: bool, - }, - NotRunning, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteServerRestartReason { - ProtocolMismatch, - DaemonDetachMissing, - BinaryUpdated, - VersionMismatch, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteInstallRunningServerPlan { - KeepRunning, - LiveHandoff, - StopRequired(RemoteServerRestartReason), -} - -fn ensure_remote_server_ready( - ssh: &RemoteSsh, - remote_herdr: &RemoteHerdr, - remote_binary_changed: bool, - stop_after_install_approved: bool, - live_handoff_enabled: bool, -) -> io::Result<()> { - let status = remote_server_status(ssh, remote_herdr)?; - let RemoteServerStatus::Running { - version, - protocol, - live_handoff, - detached_server_daemon, - } = status - else { - return Ok(()); - }; - - let Some(reason) = remote_server_restart_reason( - version.as_deref(), - protocol, - detached_server_daemon, - remote_binary_changed, - ) else { - return Ok(()); - }; - - if live_handoff_enabled && live_handoff { - match live_handoff_remote_server(ssh, remote_herdr) { - Ok(()) => return Ok(()), - Err(err) => { - eprintln!("remote live handoff failed: {err}"); - eprintln!("falling back to remote server restart."); - } - } - } - - if stop_after_install_approved { - stop_remote_server(ssh, remote_herdr)?; - return Ok(()); - } - - if confirm_remote_server_stop(ssh.target(), version.as_deref(), protocol, reason)? { - stop_remote_server(ssh, remote_herdr)?; - } - Ok(()) -} - -fn remote_server_restart_reason( - version: Option<&str>, - protocol: Option, - detached_server_daemon: bool, - remote_binary_changed: bool, -) -> Option { - if protocol != Some(CURRENT_PROTOCOL) { - return Some(RemoteServerRestartReason::ProtocolMismatch); - } - if !detached_server_daemon { - return Some(RemoteServerRestartReason::DaemonDetachMissing); - } - if version != Some(current_version().as_str()) { - return Some(RemoteServerRestartReason::VersionMismatch); - } - if remote_binary_changed { - return Some(RemoteServerRestartReason::BinaryUpdated); - } - None -} - -fn confirm_remote_install_with_running_server( - ssh: &RemoteSsh, - remote_herdr: &RemoteHerdr, - live_handoff_enabled: bool, -) -> io::Result { - let target = ssh.target(); - let status = match remote_server_status(ssh, remote_herdr) { - Ok(status) => status, - Err(err) => { - if !io::stdin().is_terminal() { - return Err(io::Error::other(format!( - "could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary" - ))); - } - eprintln!( - "could not inspect the running remote herdr server on {target} before installing: {err}" - ); - eprint!("continue installing the remote herdr binary? [y/N] "); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer != "y" && answer != "yes" { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr install cancelled", - )); - } - return Ok(false); - } - }; - let RemoteServerStatus::Running { - version, - protocol, - live_handoff, - detached_server_daemon, - } = &status - else { - return Ok(false); - }; - let plan = remote_install_running_server_plan( - version.as_deref(), - *protocol, - *detached_server_daemon, - true, - *live_handoff, - live_handoff_enabled, - ); - - if plan == RemoteInstallRunningServerPlan::KeepRunning { - if io::stdin().is_terminal() { - eprintln!("remote herdr server on {target} is already compatible:"); - eprintln!(" server: v{}", version_label(version.as_deref())); - eprintln!( - "Herdr will install {} without stopping the running remote server.", - current_version() - ); - } - return Ok(false); - } - - if !io::stdin().is_terminal() { - match plan { - RemoteInstallRunningServerPlan::LiveHandoff => return Ok(false), - RemoteInstallRunningServerPlan::StopRequired(_) => { - return Err(io::Error::other(format!( - "remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update", - version_label(version.as_deref()) - ))); - } - RemoteInstallRunningServerPlan::KeepRunning => return Ok(false), - } - } - - if plan == RemoteInstallRunningServerPlan::LiveHandoff { - eprintln!("remote herdr server on {target} is currently running:"); - eprintln!(" server: v{}", version_label(version.as_deref())); - eprintln!( - "Herdr will install {} and hand off live pane processes to the prepared server.", - current_version() - ); - return Ok(false); - } - - eprintln!("remote herdr server on {target} is currently running:"); - eprintln!(" server: v{}", version_label(version.as_deref())); - eprintln!( - "To complete the remote update, Herdr must stop the running remote server after installing." - ); - eprintln!("This stops active remote pane processes, including shells, dev servers, and tests."); - eprintln!(); - eprint!( - "Install {} and stop the remote server now? [y/N] ", - current_version() - ); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer != "y" && answer != "yes" { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr install cancelled", - )); - } - - Ok(true) -} - -fn remote_install_running_server_plan( - version: Option<&str>, - protocol: Option, - detached_server_daemon: bool, - remote_binary_changed: bool, - live_handoff: bool, - live_handoff_enabled: bool, -) -> RemoteInstallRunningServerPlan { - let Some(reason) = remote_server_restart_reason( - version, - protocol, - detached_server_daemon, - remote_binary_changed, - ) else { - return RemoteInstallRunningServerPlan::KeepRunning; - }; - - if live_handoff_enabled && live_handoff { - return RemoteInstallRunningServerPlan::LiveHandoff; - } - - RemoteInstallRunningServerPlan::StopRequired(reason) -} - -fn remote_server_status( - ssh: &RemoteSsh, - remote_herdr: &RemoteHerdr, -) -> io::Result { - let command = format!("{} status server --json", remote_herdr.shell_path); - let output = ssh.sh_output(&command)?; - if !output.status.success() { - return Err(command_failed("remote server status failed", &output)); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - parse_remote_server_status_json(stdout.trim()) -} - -#[derive(Debug, Deserialize)] -struct RemoteClientStatusJson { - protocol: u32, -} - -#[derive(Debug, Deserialize)] -struct RemoteServerStatusJson { - running: bool, - version: Option, - protocol: Option, - capabilities: Option, -} - -#[derive(Debug, Deserialize)] -struct RemoteServerCapabilitiesJson { - live_handoff: bool, - #[serde(default)] - detached_server_daemon: bool, -} - -fn parse_client_status_json(status: &str) -> Option { - serde_json::from_str(status).ok() -} - -fn parse_remote_server_status_json(status: &str) -> io::Result { - let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| { - io::Error::other(format!( - "could not parse remote server status JSON from `{status}`: {err}" - )) - })?; - if !parsed.running { - return Ok(RemoteServerStatus::NotRunning); - } - - let capabilities = parsed.capabilities; - - Ok(RemoteServerStatus::Running { - version: parsed.version, - protocol: parsed.protocol, - live_handoff: capabilities - .as_ref() - .is_some_and(|capabilities| capabilities.live_handoff), - detached_server_daemon: capabilities - .as_ref() - .is_some_and(|capabilities| capabilities.detached_server_daemon), - }) -} - -fn confirm_remote_server_stop( - target: &str, - version: Option<&str>, - _protocol: Option, - reason: RemoteServerRestartReason, -) -> io::Result { - if !io::stdin().is_terminal() { - if reason == RemoteServerRestartReason::ProtocolMismatch { - return Err(io::Error::other(format!( - "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it" - ))); - } - - eprintln!( - "remote herdr server on {target} is still running v{}; it will use {} after it restarts.", - version_label(version), - current_version() - ); - return Ok(false); - } - - eprintln!("remote herdr server on {target} is currently running:"); - eprintln!(" server: v{}", version_label(version)); - eprintln!(" prepared binary: {}", current_version()); - eprintln!(); - - match reason { - RemoteServerRestartReason::ProtocolMismatch => { - eprintln!("the remote server must stop before this client can attach."); - } - RemoteServerRestartReason::DaemonDetachMissing => { - eprintln!( - "the remote server was started by a herdr build that may not survive SSH connection loss. restart it so network drops disconnect only this client." - ); - } - RemoteServerRestartReason::BinaryUpdated => { - eprintln!( - "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary." - ); - } - RemoteServerRestartReason::VersionMismatch => { - eprintln!( - "the remote server is still running a different herdr version. restart it so it uses the prepared binary." - ); - } - } - - let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch { - "stop the remote server and continue attaching? [Y/n] " - } else { - "restart the remote server now? [y/N] " - }; - eprint!("{prompt}"); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "y" || answer == "yes" { - return Ok(true); - } - if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch { - return Ok(true); - } - if reason == RemoteServerRestartReason::ProtocolMismatch { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr server stop cancelled", - )); - } - - Ok(false) -} - -fn live_handoff_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let command = format!( - "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}", - remote_herdr.shell_path, - remote_herdr.shell_path, - CURRENT_PROTOCOL, - current_version() - ); - let output = ssh.sh_output(&command)?; - if !output.status.success() { - return Err(command_failed("remote server live handoff failed", &output)); - } - - eprintln!( - "handed off the remote herdr server on {}; reconnecting to the prepared server.", - ssh.target() - ); - Ok(()) -} - -fn stop_remote_server(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let command = format!("{} server stop", remote_herdr.shell_path); - let output = ssh.sh_output(&command)?; - if !output.status.success() { - return Err(command_failed("remote server stop failed", &output)); - } - - wait_for_remote_server_shutdown(ssh, remote_herdr)?; - eprintln!( - "stopped the remote herdr server on {}; it will restart when the remote client bridge attaches.", - ssh.target() - ); - Ok(()) -} - -fn wait_for_remote_server_shutdown(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT; - loop { - if remote_server_status(ssh, remote_herdr)? == RemoteServerStatus::NotRunning { - return Ok(()); - } - if Instant::now() >= deadline { - return Err(io::Error::new( - io::ErrorKind::TimedOut, - format!( - "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds", - REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs(), - target = ssh.target() - ), - )); - } - thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL); - } -} - -fn version_label(version: Option<&str>) -> &str { - version.unwrap_or("unknown") -} - -fn warn_if_remote_bin_not_on_path(ssh: &RemoteSsh) -> io::Result<()> { - let output = ssh.user_shell_output("command -v herdr")?; - if output.status.success() - && remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout)) - { - return Ok(()); - } - - eprintln!( - "herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path" - ); - Ok(()) -} - -fn remote_shell_resolves_managed_install(stdout: &str) -> bool { - stdout - .lines() - .next() - .map(str::trim) - .is_some_and(|path| path.ends_with("/.local/bin/herdr")) -} - -fn download_release_asset(platform: &RemotePlatform) -> io::Result { - let asset_key = platform.asset_key(); - let asset = remote_release_asset(&asset_key)?; - - let dir = private_download_dir(&asset_key)?; - let path = dir.join("herdr.tmp"); - let status = crate::noninteractive_process::curl_command() - .args(["-sfL", "--max-time", "120", "-o"]) - .arg(&path) - .arg(&asset.url) - .status() - .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?; - if !status.success() { - let _ = fs::remove_dir_all(&dir); - return Err(io::Error::other("download failed")); - } - if let Some(expected) = &asset.sha256 { - if let Err(err) = crate::checksum::verify_sha256(&path, expected) { - let _ = fs::remove_dir_all(&dir); - return Err(io::Error::new( - err.kind(), - format!("downloaded remote asset checksum verification failed: {err}"), - )); - } - } - - Ok(InstallSource::temporary(path, dir)) -} - -fn fetch_remote_manifest(url: &str) -> io::Result> { - let output = crate::noninteractive_process::curl_command() - .args([ - "-sfL", - "--retry", - "3", - "--connect-timeout", - "10", - "--max-time", - "20", - url, - ]) - .output() - .map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?; - if !output.status.success() { - return Err(command_failed("failed to fetch update manifest", &output)); - } - Ok(output.stdout) -} - -fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset { - RemoteReleaseAsset { - url: asset.url().to_string(), - sha256: asset.sha256().map(str::to_string), - } -} - -fn preview_assets_for_build<'a>( - manifest: &'a RemotePreviewManifest, - build_id: &str, -) -> io::Result<(u32, &'a BTreeMap)> { - if manifest.build_id == build_id { - return Ok((manifest.protocol, &manifest.assets)); - } - let build = manifest.builds.get(build_id).ok_or_else(|| { - io::Error::other(format!( - "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr" - )) - })?; - Ok((build.protocol, &build.assets)) -} - -fn remote_release_asset(asset_key: &str) -> io::Result { - if crate::build_info::is_preview() { - let build_id = crate::build_info::build_id().ok_or_else(|| { - io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually") - })?; - let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?; - let manifest: RemotePreviewManifest = - serde_json::from_slice(&manifest_bytes).map_err(|err| { - io::Error::other(format!("failed to parse preview manifest JSON: {err}")) - })?; - let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?; - if protocol != CURRENT_PROTOCOL { - return Err(io::Error::other(format!( - "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually" - ))); - } - return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| { - io::Error::other(format!( - "no {asset_key} binary in the preview manifest for build {build_id}" - )) - }); - } - - let current_version = current_version(); - let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?; - let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes) - .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?; - let release = manifest.release_for_version(¤t_version).ok_or_else(|| { - io::Error::other(format!( - "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually", - asset_key - )) - })?; - if let Some(protocol) = release.protocol { - if protocol != CURRENT_PROTOCOL { - return Err(io::Error::other(format!( - "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually" - ))); - } - } - release - .assets - .get(asset_key) - .map(remote_asset_info) - .ok_or_else(|| { - io::Error::other(format!( - "no {asset_key} binary in the release manifest for herdr {current_version}" - )) - }) -} - -fn private_download_dir(asset_key: &str) -> io::Result { - let base = std::env::temp_dir(); - for attempt in 0..100 { - let dir = base.join(format!( - "herdr-remote-{}-{}-{attempt}", - std::process::id(), - asset_key - )); - match fs::create_dir(&dir) { - Ok(()) => return Ok(dir), - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, - Err(err) => return Err(err), - } - } - - Err(io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create private herdr remote download directory", - )) -} - -fn confirm_remote_install( - target: &str, - remote_herdr: &RemoteHerdr, - source_description: &str, -) -> io::Result<()> { - if !io::stdin().is_terminal() { - return Err(io::Error::other(format!( - "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation", - current_version(), - remote_herdr.shell_path - ))); - } - - eprintln!( - "matching herdr {} is not installed on {target} for {}.", - current_version(), - remote_herdr.platform.asset_key() - ); - eprint!( - "Install {} to {}? [Y/n] ", - source_description, remote_herdr.shell_path - ); - io::stderr().flush()?; - - let mut answer = String::new(); - io::stdin().read_line(&mut answer)?; - let answer = answer.trim().to_ascii_lowercase(); - if answer == "n" || answer == "no" { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "remote herdr installation cancelled", - )); - } - - Ok(()) -} - -fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String { - let mut command = format!("exec {}", remote_herdr.shell_path); - if session_name != crate::session::DEFAULT_SESSION_NAME { - command.push_str(" --session "); - command.push_str(&shell_quote(session_name)); - } - command.push_str(" remote-client-bridge"); - command -} - -fn reattach_command( - program: &str, - target: &str, - session_name: &str, - keybindings: RemoteKeybindings, - live_handoff: bool, -) -> String { - let program = if program.is_empty() { "herdr" } else { program }; - let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target)); - if keybindings != RemoteKeybindings::Local { - command.push_str(" --remote-keybindings "); - command.push_str(keybindings.as_str()); - } - if live_handoff { - command.push_str(" --handoff"); - } - if session_name != crate::session::DEFAULT_SESSION_NAME { - command.push_str(" --session "); - command.push_str(&shell_quote(session_name)); - } - command -} - -fn shell_quote(value: &str) -> String { - if !value.is_empty() - && value.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!( - ch, - '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' - ) - }) - { - return value.to_string(); - } - - format!("'{}'", value.replace('\'', "'\\''")) -} - -fn command_failed(context: &str, output: &Output) -> io::Error { - let stderr = String::from_utf8_lossy(&output.stderr); - let stderr = stderr.trim(); - if stderr.is_empty() { - io::Error::other(format!("{context}: {}", output.status)) - } else { - io::Error::other(format!("{context}: {stderr}")) - } -} - -struct SshStdioBridge { - local_socket: PathBuf, - should_stop: Arc, - thread: Option>, -} - -impl SshStdioBridge { - fn start( - target: String, - remote_herdr: RemoteHerdr, - local_socket: PathBuf, - session_name: String, - ssh_options: Option<&ManagedSshOptions>, - ) -> io::Result { - let _ = std::fs::remove_file(&local_socket); - let listener = UnixListener::bind(&local_socket)?; - crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?; - listener.set_nonblocking(true)?; - - let should_stop = Arc::new(AtomicBool::new(false)); - let thread_stop = Arc::clone(&should_stop); - let thread_ssh_options = ssh_options.cloned(); - let thread = thread::spawn(move || { - while !thread_stop.load(Ordering::Acquire) { - match listener.accept() { - Ok((stream, _addr)) => { - if let Err(err) = stream.set_nonblocking(false) { - eprintln!( - "herdr: remote bridge failed to prepare client socket: {err}" - ); - continue; - } - if let Err(err) = bridge_connection( - stream, - &target, - &remote_herdr, - &session_name, - thread_ssh_options.as_ref(), - ) { - eprintln!("herdr: remote bridge failed: {err}"); - } - } - Err(err) if err.kind() == io::ErrorKind::WouldBlock => { - thread::sleep(BRIDGE_ACCEPT_POLL); - } - Err(err) => { - eprintln!("herdr: remote bridge listener failed: {err}"); - break; - } - } - } - }); - - Ok(Self { - local_socket, - should_stop, - thread: Some(thread), - }) - } -} - -impl Drop for SshStdioBridge { - fn drop(&mut self) { - self.should_stop.store(true, Ordering::Release); - let _ = std::fs::remove_file(&self.local_socket); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -/// Creates a fresh user-only (`0700`) directory for the generated ssh config -/// and control socket, returning its path. -/// -/// Using a private directory created with fail-if-exists semantics — rather -/// than a predictable file in the world-writable temp dir — stops a local user -/// from pre-planting a symlink or world-writable file that herdr would write -/// and `ssh -F` would then read. -fn private_ssh_config_dir() -> io::Result { - use std::os::unix::fs::DirBuilderExt; - - let mut bases = vec![std::env::temp_dir()]; - let short_tmp = PathBuf::from("/tmp"); - if bases.first() != Some(&short_tmp) { - bases.push(short_tmp); - } - - let mut last_error = None; - for base in bases { - for attempt in 0..100 { - let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); - if !fits_unix_socket_path(&dir.join(SSH_CONTROL_SOCKET_NAME)) { - continue; - } - match fs::DirBuilder::new().mode(0o700).create(&dir) { - Ok(()) => return Ok(dir), - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, - Err(err) => { - last_error = Some(err); - break; - } - } - } - } - - Err(last_error.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create private herdr ssh config directory", - ) - })) -} - -/// Quotes a path for an ssh_config `Include` so a path containing spaces (or -/// glob metacharacters) is treated as one literal token instead of being split -/// or expanded by ssh — otherwise the user's config might not be Included and -/// herdr's fallback would wrongly take effect. -fn ssh_config_quote(path: &str) -> String { - format!("\"{path}\"") -} - -/// Builds a temporary ssh config for remote attach commands without overriding -/// the user's own settings, returning its path. -/// -/// The file `Include`s the user's real ssh config first, so ssh's -/// first-value-wins rule keeps any `ServerAlive*` the user set there (including -/// an explicit `0` to disable it). Herdr's keepalive values apply only when -/// the user has none. -fn write_managed_ssh_config() -> io::Result { - use std::os::unix::fs::OpenOptionsExt; - - let dir = private_ssh_config_dir()?; - let path = dir.join("config"); - let control_path = dir.join(SSH_CONTROL_SOCKET_NAME); - - let mut contents = String::new(); - if let Some(home) = std::env::var_os("HOME") { - let user_config = PathBuf::from(home).join(".ssh").join("config"); - if user_config.is_file() { - contents.push_str(&format!( - "Include {}\n", - ssh_config_quote(&user_config.to_string_lossy()) - )); - } - } - if Path::new("/etc/ssh/ssh_config").is_file() { - contents.push_str("Include /etc/ssh/ssh_config\n"); - } - contents.push_str("Host *\n"); - contents.push_str(" ServerAliveInterval 15\n"); - contents.push_str(" ServerAliveCountMax 4\n"); - - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(BRIDGE_SOCKET_PERMISSION_MODE) - .open(&path)?; - file.write_all(contents.as_bytes())?; - Ok(ManagedSshConfig { - options: ManagedSshOptions { - config_path: path, - control_path, - }, - }) -} - -fn bridge_connection( - stream: UnixStream, - target: &str, - remote_herdr: &RemoteHerdr, - session_name: &str, - ssh_options: Option<&ManagedSshOptions>, -) -> io::Result<()> { - let mut command = Command::new("ssh"); - apply_managed_ssh_options(&mut command, ssh_options); - command - .arg("-T") - .arg(target) - .arg(remote_bridge_command(remote_herdr, session_name)); - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); - - let mut child = command - .spawn() - .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; - let mut child_stdin = child - .stdin - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?; - let mut child_stdout = child - .stdout - .take() - .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?; - let mut stream_to_child = stream.try_clone()?; - let mut child_to_stream = stream; - - let upload = thread::spawn(move || { - let _ = copy_flush(&mut stream_to_child, &mut child_stdin); - }); - let download = thread::spawn(move || { - let _ = copy_flush(&mut child_stdout, &mut child_to_stream); - let _ = child_to_stream.shutdown(std::net::Shutdown::Write); - }); - - let status = child.wait()?; - let _ = upload.join(); - let _ = download.join(); - - if status.success() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::ConnectionAborted, - format!("ssh bridge exited with {status}"), - )) - } -} - -fn copy_flush(reader: &mut R, writer: &mut W) -> io::Result { - let mut buffer = [0_u8; 16 * 1024]; - let mut total = 0; - - loop { - let bytes_read = match reader.read(&mut buffer) { - Ok(0) => return Ok(total), - Ok(bytes_read) => bytes_read, - Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, - Err(err) => return Err(err), - }; - - writer.write_all(&buffer[..bytes_read])?; - writer.flush()?; - total += bytes_read as u64; - } -} - -fn run_client_process( - local_socket: &Path, - reattach_command: &str, - keybindings: RemoteKeybindings, -) -> io::Result<()> { - let exe = std::env::current_exe()?; - let status = Command::new(exe) - .arg("client") - .env( - crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR, - local_socket, - ) - .env("HERDR_RENDER_ENCODING", "terminal-ansi") - .env(REATTACH_COMMAND_ENV_VAR, reattach_command) - .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str()) - .env_remove(crate::api::SOCKET_PATH_ENV_VAR) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .status()?; - - if status.success() { - Ok(()) - } else { - Err(io::Error::new( - io::ErrorKind::Interrupted, - format!("remote client exited with {status}"), - )) - } -} - -fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { - let pid = std::process::id(); - let target_clean = sanitize_path_component(target); - let session_clean = sanitize_path_component(session_name); - - let tmpdir = std::env::temp_dir(); - let readable = tmpdir.join(format!( - "herdr-remote-{pid}-{target_clean}-{session_clean}.sock" - )); - if fits_unix_socket_path(&readable) { - return readable; - } - - // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the - // readable name past sun_path's 104-byte ceiling. Fall back to a hashed - // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself - // is longer than the budget. The hash covers the full unsanitized - // target/session so uniqueness does not depend on the prefix truncation; - // the prefix is kept only for debuggability. - let target_prefix: String = target_clean.chars().take(8).collect(); - let hash = short_socket_hash(target, session_name); - let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock"); - let short_in_tmp = tmpdir.join(&short_name); - if fits_unix_socket_path(&short_in_tmp) { - return short_in_tmp; - } - PathBuf::from("/tmp").join(short_name) -} - -fn fits_unix_socket_path(path: &Path) -> bool { - use std::os::unix::ffi::OsStrExt; - // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve - // 1 byte for the trailing NUL and use the smaller cap for portability. - const MAX: usize = 103; - path.as_os_str().as_bytes().len() <= MAX -} - -fn short_socket_hash(target: &str, session: &str) -> String { - use std::collections::hash_map::DefaultHasher; - use std::hash::{Hash, Hasher}; - let mut hasher = DefaultHasher::new(); - target.hash(&mut hasher); - 0u8.hash(&mut hasher); - session.hash(&mut hasher); - format!("{:016x}", hasher.finish()) -} - -fn sanitize_path_component(input: &str) -> String { - let sanitized: String = input - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') { - ch - } else { - '-' - } - }) - .collect(); - - sanitized.trim_matches('-').chars().take(32).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn bridge_socket_is_user_only() { - use std::os::unix::fs::PermissionsExt; - - let socket = std::env::temp_dir().join(format!( - "herdr-bridge-permissions-test-{}.sock", - std::process::id() - )); - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let bridge = SshStdioBridge::start( - "example".to_string(), - remote_herdr, - socket.clone(), - "default".to_string(), - None, - ) - .expect("start bridge listener"); - - let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE); - - drop(bridge); - let _ = std::fs::remove_file(socket); - } - - #[test] - fn managed_ssh_config_includes_user_config_then_fallback() { - use std::os::unix::fs::PermissionsExt; - - let managed_config = write_managed_ssh_config().expect("write managed config"); - let path = managed_config.options.config_path.clone(); - let control_path = managed_config.options.control_path.clone(); - let contents = std::fs::read_to_string(&path).expect("read keepalive config"); - - // herdr's fallback transport settings are present... - assert!( - contents.contains("Host *"), - "config should add a Host * fallback block: {contents}" - ); - assert!( - contents.contains("ServerAliveInterval 15"), - "config should set the keepalive interval: {contents}" - ); - assert!( - contents.contains("ServerAliveCountMax 4"), - "config should set the keepalive count: {contents}" - ); - assert!(!contents.contains("ControlMaster")); - assert!(!contents.contains("ControlPersist")); - assert!(!contents.contains("ControlPath")); - // ...and any user config is Included (quoted) BEFORE it so - // first-value-wins keeps the user's own settings. - if let Some(home) = std::env::var_os("HOME") { - let user_config = PathBuf::from(home).join(".ssh").join("config"); - if user_config.is_file() { - let include = format!( - "Include {}", - ssh_config_quote(&user_config.to_string_lossy()) - ); - let include_at = contents.find(&include).expect("user config Included"); - let fallback_at = contents.find("Host *").expect("fallback present"); - assert!( - include_at < fallback_at, - "user config must be Included before herdr's fallback: {contents}" - ); - } - } - - let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!( - mode, BRIDGE_SOCKET_PERMISSION_MODE, - "keepalive config must be user-only" - ); - // The config lives in a private 0700 dir, not a predictable temp path. - let dir = path.parent().expect("config has a parent dir"); - let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777; - assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only"); - assert!( - fits_unix_socket_path(&control_path), - "control socket path must fit portable Unix socket limits" - ); - - drop(managed_config); - } - - #[test] - fn ssh_config_quote_wraps_path_with_spaces() { - assert_eq!( - ssh_config_quote("/home/a b/.ssh/config"), - "\"/home/a b/.ssh/config\"" - ); - } - - #[test] - fn remote_ssh_command_uses_managed_config_when_present() { - let managed_config = write_managed_ssh_config().expect("write managed config"); - let config_path = managed_config.options.config_path.clone(); - let control_path = managed_config.options.control_path.clone(); - let ssh = RemoteSsh { - target: "example".to_string(), - managed_config: Some(managed_config), - }; - - let command = ssh.command(); - let args = command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); - - assert_eq!( - args, - vec![ - "-F".to_string(), - config_path.to_string_lossy().into_owned(), - "-S".to_string(), - control_path.to_string_lossy().into_owned(), - "-o".to_string(), - "ControlMaster=auto".to_string(), - "-o".to_string(), - "ControlPersist=yes".to_string(), - "-T".to_string(), - "example".to_string(), - ] - ); - } - - #[test] - fn remote_ssh_command_is_plain_without_managed_config() { - let ssh = RemoteSsh { - target: "example".to_string(), - managed_config: None, - }; - - let command = ssh.command(); - let args = command - .get_args() - .map(|arg| arg.to_string_lossy().into_owned()) - .collect::>(); - - assert_eq!(args, vec!["-T".to_string(), "example".to_string()]); - } - - #[test] - fn remote_install_stream_command_avoids_shell_c_wrapper() { - let command = remote_install_stream_command("/home/a b/.local/bin/herdr.tmp.123"); - - assert_eq!(command, "tee '/home/a b/.local/bin/herdr.tmp.123'"); - } - - #[test] - fn remote_install_prepare_and_commit_scripts_quote_paths() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let prepare = remote_install_prepare_script(&remote_herdr); - - assert!(prepare.contains("mkdir -p \"$dir\"")); - assert!(prepare.contains("printf '%s\\0%s\\0' \"$tmp\" \"$dest\"")); - assert_eq!( - parse_remote_install_paths(b"/home/a b/herdr.tmp.42\0/home/a b/herdr\0").unwrap(), - ( - "/home/a b/herdr.tmp.42".to_string(), - "/home/a b/herdr".to_string() - ) - ); - assert_eq!( - parse_remote_install_paths(b"/home/a b\n/herdr.tmp.42\0/home/a b\n/herdr\0").unwrap(), - ( - "/home/a b\n/herdr.tmp.42".to_string(), - "/home/a b\n/herdr".to_string() - ) - ); - assert_eq!( - remote_install_commit_script("/home/a b/herdr.tmp.42", "/home/a b/herdr"), - "set -eu\nchmod 755 '/home/a b/herdr.tmp.42'\nmv '/home/a b/herdr.tmp.42' '/home/a b/herdr'\n" - ); - } - - #[test] - fn extract_remote_args_removes_space_form() { - let args = vec![ - "herdr".into(), - "--remote".into(), - "dev".into(), - "--help".into(), - ]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr", "--help"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "dev"); - assert_eq!(remote.keybindings, RemoteKeybindings::Local); - } - - #[test] - fn extract_remote_args_removes_equals_form() { - let args = vec!["herdr".into(), "--remote=user@host".into()]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "user@host"); - assert_eq!(remote.keybindings, RemoteKeybindings::Local); - } - - #[test] - fn extract_remote_args_accepts_remote_keybindings_server() { - let args = vec![ - "herdr".into(), - "--remote".into(), - "dev".into(), - "--remote-keybindings=server".into(), - ]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "dev"); - assert_eq!(remote.keybindings, RemoteKeybindings::Server); - } - - #[test] - fn extract_remote_args_accepts_remote_keybindings_space_form() { - let args = vec![ - "herdr".into(), - "--remote=dev".into(), - "--remote-keybindings".into(), - "server".into(), - ]; - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - assert_eq!(cleaned, vec!["herdr"]); - assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server); - } - - #[test] - fn extract_remote_args_accepts_explicit_handoff() { - let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()]; - - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - - assert_eq!(cleaned, vec!["herdr"]); - let remote = remote.unwrap(); - assert_eq!(remote.target, "dev"); - assert!(remote.live_handoff); - } - - #[test] - fn extract_remote_args_preserves_child_remote_options_after_separator() { - let args = vec![ - "herdr".into(), - "agent".into(), - "start".into(), - "repro".into(), - "--".into(), - "child".into(), - "--remote".into(), - "dev".into(), - "--remote-keybindings=server".into(), - "--handoff".into(), - ]; - - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - - assert_eq!(cleaned, args); - assert!(remote.is_none()); - } - - #[test] - fn extract_remote_args_preserves_handoff_without_remote() { - let args = vec!["herdr".into(), "update".into(), "--handoff".into()]; - - let (cleaned, remote) = extract_remote_args(&args).unwrap(); - - assert_eq!(cleaned, args); - assert!(remote.is_none()); - } - - #[test] - fn extract_remote_args_rejects_remote_keybindings_without_remote() { - let args = vec!["herdr".into(), "--remote-keybindings=server".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote-keybindings requires --remote"); - } - - #[test] - fn extract_remote_args_rejects_duplicate_remote_keybindings() { - let args = vec![ - "herdr".into(), - "--remote=dev".into(), - "--remote-keybindings=local".into(), - "--remote-keybindings=server".into(), - ]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote-keybindings can only be specified once"); - } - - #[test] - fn extract_remote_args_requires_value() { - let args = vec!["herdr".into(), "--remote".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "missing value for --remote"); - } - - #[test] - fn extract_remote_args_rejects_empty_value() { - let args = vec!["herdr".into(), "--remote=".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "missing value for --remote"); - } - - #[test] - fn extract_remote_args_rejects_duplicate_values() { - let args = vec![ - "herdr".into(), - "--remote=dev".into(), - "--remote=prod".into(), - ]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote can only be specified once"); - } - - #[test] - fn extract_remote_args_rejects_option_like_target() { - let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()]; - let err = extract_remote_args(&args).unwrap_err(); - assert_eq!(err, "--remote target must not start with '-'"); - } - - #[test] - fn sanitize_path_component_removes_shell_sensitive_chars() { - assert_eq!(sanitize_path_component("user@host:22"), "user-host-22"); - } - - #[test] - fn remote_platform_maps_uname_values() { - assert_eq!( - RemotePlatform::from_uname("Linux", "amd64") - .unwrap() - .asset_key(), - "linux-x86_64" - ); - assert_eq!( - RemotePlatform::from_uname("Darwin", "arm64") - .unwrap() - .asset_key(), - "macos-aarch64" - ); - assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none()); - } - - #[test] - fn reattach_command_includes_remote_and_session() { - assert_eq!( - reattach_command( - "target/release/herdr", - "user@host", - "work", - RemoteKeybindings::Local, - false, - ), - "target/release/herdr --remote user@host --session work" - ); - assert_eq!( - reattach_command( - "herdr", - "host name", - crate::session::DEFAULT_SESSION_NAME, - RemoteKeybindings::Local, - false, - ), - "herdr --remote 'host name'" - ); - assert_eq!( - reattach_command( - "herdr", - "host", - crate::session::DEFAULT_SESSION_NAME, - RemoteKeybindings::Server, - false, - ), - "herdr --remote host --remote-keybindings server" - ); - assert_eq!( - reattach_command( - "herdr", - "host", - crate::session::DEFAULT_SESSION_NAME, - RemoteKeybindings::Local, - true, - ), - "herdr --remote host --handoff" - ); - } - - #[test] - fn remote_bridge_command_uses_installed_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec \"$HOME/.local/bin/herdr\" remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_uses_path_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec /usr/bin/herdr remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_quotes_discovered_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = - remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec '/opt/herdr bin/herdr' remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_uses_macos_path_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "macos", - arch: "aarch64", - }); - let remote_herdr = - remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec /opt/homebrew/bin/herdr remote-client-bridge" - ); - assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64"); - } - - #[test] - fn remote_path_discovery_reads_multiple_absolute_paths() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let candidates = remote_herdrs_from_path_discovery( - &remote_herdr, - "/usr/bin/herdr\nbin/herdr\n /opt/herdr bin/herdr\n", - ); - - assert_eq!(candidates.len(), 2); - assert_eq!(candidates[0].shell_path, "/usr/bin/herdr"); - assert_eq!(candidates[1].shell_path, "'/opt/herdr bin/herdr'"); - } - - #[test] - fn remote_path_discovery_ignores_mise_shims() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let candidates = remote_herdrs_from_path_discovery( - &remote_herdr, - "/home/can/.local/share/mise/shims/herdr\n/home/can/.local/share/mise/installs/herdr/0.7.1/bin/herdr\n", - ); - - assert_eq!(candidates.len(), 1); - assert_eq!( - candidates[0].shell_path, - "/home/can/.local/share/mise/installs/herdr/0.7.1/bin/herdr" - ); - } - - #[test] - fn known_remote_binary_candidate_script_includes_mise_and_nix_paths() { - let script = known_remote_binary_candidate_script(&RemotePlatform { - os: "linux", - arch: "x86_64", - }); - - assert!(script.contains("emit \"$home/.local/bin/herdr\"")); - assert!(!script.contains("mise/shims/herdr")); - assert!(script.contains(&format!("version={}", shell_quote(¤t_version())))); - assert!( - script.contains("emit \"$home/.local/share/mise/installs/herdr/$version/bin/herdr\"") - ); - assert!(script.contains("emit \"$home/.local/share/mise/installs/herdr/$version/herdr\"")); - assert!(script.contains( - "emit \"$home/.local/share/mise/installs/github-ogulcancelik-herdr/$version/herdr\"" - )); - assert!(script.contains("emit \"$home/.nix-profile/bin/herdr\"")); - assert!(script.contains("emit \"/etc/profiles/per-user/$user/bin/herdr\"")); - assert!(script.contains("emit \"/run/current-system/sw/bin/herdr\"")); - assert!(script.contains("emit \"/home/linuxbrew/.linuxbrew/bin/herdr\"")); - assert!(!script.contains("emit \"/opt/homebrew/bin/herdr\"")); - } - - #[test] - fn known_remote_binary_candidate_script_includes_macos_homebrew_paths() { - let script = known_remote_binary_candidate_script(&RemotePlatform { - os: "macos", - arch: "aarch64", - }); - - assert!(script.contains("emit \"/opt/homebrew/bin/herdr\"")); - assert!(script.contains("emit \"/usr/local/bin/herdr\"")); - assert!(!script.contains("emit \"/home/linuxbrew/.linuxbrew/bin/herdr\"")); - } - - #[test] - fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = - remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n") - .expect("path binary"); - - assert_eq!( - remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME), - "exec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge" - ); - } - - #[test] - fn remote_path_discovery_ignores_relative_paths() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n"); - - assert!(remote_herdr.is_none()); - } - - #[test] - fn remote_path_discovery_ignores_empty_output() { - let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { - os: "linux", - arch: "x86_64", - }); - let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n"); - - assert!(remote_herdr.is_none()); - } - - #[test] - fn remote_shell_path_warning_accepts_managed_install() { - assert!(remote_shell_resolves_managed_install( - "/home/can/.local/bin/herdr\n" - )); - assert!(remote_shell_resolves_managed_install( - "/Users/can/.local/bin/herdr\n" - )); - assert!(!remote_shell_resolves_managed_install( - "/usr/local/bin/herdr\n" - )); - assert!(!remote_shell_resolves_managed_install("")); - } - - #[test] - fn parse_client_status_json_reads_protocol() { - assert_eq!( - parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#) - .map(|status| status.protocol), - Some(8) - ); - assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none()); - } - - #[test] - fn parse_remote_server_status_json_reads_running_server() { - assert_eq!( - parse_remote_server_status_json( - r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true,"detached_server_daemon":true}}"# - ) - .unwrap(), - RemoteServerStatus::Running { - version: Some("0.6.0".into()), - protocol: Some(8), - live_handoff: true, - detached_server_daemon: true - } - ); - } - - #[test] - fn parse_remote_server_status_json_treats_missing_capability_as_old_server() { - assert_eq!( - parse_remote_server_status_json( - r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"# - ) - .unwrap(), - RemoteServerStatus::Running { - version: Some("0.6.0".into()), - protocol: Some(8), - live_handoff: false, - detached_server_daemon: false - } - ); - } - - #[test] - fn parse_remote_server_status_json_reads_stopped_server() { - assert_eq!( - parse_remote_server_status_json( - r#"{"status":"not_running","running":false,"version":null,"protocol":null}"# - ) - .unwrap(), - RemoteServerStatus::NotRunning - ); - } - - #[test] - fn remote_update_manifest_uses_root_assets_for_latest_version() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.3", - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.assets.get("linux-x86_64")) - .map(RemoteAssetRef::url), - Some("https://example.com/latest") - ); - } - - #[test] - fn remote_update_manifest_reads_archived_release_assets() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.4", - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "notes": "ignored", - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.assets.get("linux-x86_64")) - .map(RemoteAssetRef::url), - Some("https://example.com/archive") - ); - } - - #[test] - fn remote_update_manifest_uses_archived_release_protocol() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.4", - "protocol": 42, - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "notes": "ignored", - "protocol": 41, - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.protocol), - Some(41) - ); - } - - #[test] - fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() { - let manifest: RemoteUpdateManifest = serde_json::from_str( - r#"{ - "version": "1.2.4", - "protocol": 42, - "assets": { - "linux-x86_64": "https://example.com/latest" - }, - "releases": { - "1.2.3": { - "notes": "ignored", - "assets": { - "linux-x86_64": "https://example.com/archive" - } - } - } - }"#, - ) - .unwrap(); - - assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.protocol), - None - ); - } - - #[test] - fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() { - let manifest: RemotePreviewManifest = serde_json::from_str( - r#"{ - "build_id": "2026-06-06-new", - "protocol": 12, - "assets": { - "linux-x86_64": { - "url": "https://example.com/new", - "sha256": "new" - } - }, - "builds": { - "2026-06-02-old": { - "protocol": 11, - "assets": { - "linux-x86_64": { - "url": "https://example.com/old", - "sha256": "old" - } - } - } - } - }"#, - ) - .unwrap(); - - let (protocol, assets) = - preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build"); - let asset = assets.get("linux-x86_64").expect("asset"); - assert_eq!(protocol, 11); - assert_eq!(asset.url(), "https://example.com/old"); - assert_eq!(asset.sha256(), Some("old")); - } - - #[test] - fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() { - assert_eq!( - remote_server_restart_reason(Some(¤t_version()), Some(0), true, false), - Some(RemoteServerRestartReason::ProtocolMismatch) - ); - } - - #[test] - fn remote_server_restart_reason_allows_unchanged_compatible_server() { - assert_eq!( - remote_server_restart_reason( - Some(¤t_version()), - Some(CURRENT_PROTOCOL), - true, - false - ), - None - ); - } - - #[test] - fn remote_server_restart_reason_requires_restart_for_old_daemon() { - assert_eq!( - remote_server_restart_reason( - Some(¤t_version()), - Some(CURRENT_PROTOCOL), - false, - false - ), - Some(RemoteServerRestartReason::DaemonDetachMissing) - ); - } - - #[test] - fn remote_server_restart_reason_requires_restart_after_helper_update() { - assert_eq!( - remote_server_restart_reason( - Some(¤t_version()), - Some(CURRENT_PROTOCOL), - true, - true - ), - Some(RemoteServerRestartReason::BinaryUpdated) - ); - } - - #[test] - fn remote_server_restart_reason_offers_restart_for_version_mismatch() { - assert_eq!( - remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), true, false), - Some(RemoteServerRestartReason::VersionMismatch) - ); - assert_eq!( - remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), true, false), - Some(RemoteServerRestartReason::VersionMismatch) - ); - } - - #[test] - fn remote_server_restart_reason_allows_current_server() { - assert_eq!( - remote_server_restart_reason( - Some(¤t_version()), - Some(CURRENT_PROTOCOL), - true, - false - ), - None - ); - } - - #[test] - fn remote_install_plan_keeps_compatible_running_server() { - assert_eq!( - remote_install_running_server_plan( - Some(¤t_version()), - Some(CURRENT_PROTOCOL), - true, - false, - false, - false - ), - RemoteInstallRunningServerPlan::KeepRunning - ); - } - - #[test] - fn remote_install_plan_requires_stop_for_old_daemon() { - assert_eq!( - remote_install_running_server_plan( - Some(¤t_version()), - Some(CURRENT_PROTOCOL), - false, - true, - false, - false - ), - RemoteInstallRunningServerPlan::StopRequired( - RemoteServerRestartReason::DaemonDetachMissing - ) - ); - } - - #[test] - fn remote_install_plan_requires_stop_after_helper_update() { - assert_eq!( - remote_install_running_server_plan( - Some(¤t_version()), - Some(CURRENT_PROTOCOL), - true, - true, - false, - false - ), - RemoteInstallRunningServerPlan::StopRequired(RemoteServerRestartReason::BinaryUpdated) - ); - } - - #[test] - fn remote_install_plan_requires_stop_for_incompatible_running_server() { - assert_eq!( - remote_install_running_server_plan( - Some("0.0.0"), - Some(CURRENT_PROTOCOL), - true, - true, - false, - false - ), - RemoteInstallRunningServerPlan::StopRequired( - RemoteServerRestartReason::VersionMismatch - ) - ); - } - - #[test] - fn remote_install_plan_uses_live_handoff_for_incompatible_running_server() { - assert_eq!( - remote_install_running_server_plan( - Some("0.0.0"), - Some(CURRENT_PROTOCOL), - true, - true, - true, - true - ), - RemoteInstallRunningServerPlan::LiveHandoff - ); - } - - #[test] - fn install_source_description_uses_override_binary() { - let platform = RemotePlatform { - os: "linux", - arch: "aarch64", - }; - assert_eq!( - install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false), - "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)" - ); - } - - #[test] - fn install_source_description_uses_local_binary_when_allowed() { - let platform = RemotePlatform::local(); - - assert_eq!( - install_source_description_for(&platform, None, true), - "the current local herdr binary" - ); - } - - #[test] - fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() { - let platform = RemotePlatform::local(); - - assert_eq!( - install_source_description_for(&platform, None, false), - format!( - "the {} {} asset for {}", - current_version(), - current_channel(), - platform.asset_key() - ) - ); - } - - #[test] - fn resolve_install_source_uses_override_binary_without_temporary_cleanup() { - let platform = RemotePlatform { - os: "linux", - arch: "aarch64", - }; - let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64"))) - .expect("override source"); - assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64")); - assert!(source.temporary_dir.is_none()); - } - - fn remote_env_lock() -> &'static std::sync::Mutex<()> { - static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); - LOCK.get_or_init(|| std::sync::Mutex::new(())) - } - - fn socket_path_byte_len(path: &Path) -> usize { - use std::os::unix::ffi::OsStrExt; - path.as_os_str().as_bytes().len() - } - - #[test] - fn local_forward_socket_path_uses_readable_name_when_it_fits() { - let _guard = remote_env_lock().lock().unwrap(); - // Short target + session leave plenty of room — keep the human- - // readable form so the socket path stays grep-friendly. - let path = local_forward_socket_path("dev", "default"); - let filename = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - assert!( - filename.starts_with("herdr-remote-"), - "expected readable name, got {filename}" - ); - assert!(filename.contains("-dev-default."), "got {filename}"); - assert!( - fits_unix_socket_path(&path), - "socket path too long: {} ({} bytes)", - path.display(), - socket_path_byte_len(&path) - ); - } - - #[test] - fn local_forward_socket_path_fits_in_sun_path() { - let _guard = remote_env_lock().lock().unwrap(); - // Worst case for the readable form: macOS-style 49-char TMPDIR + - // max-length sanitized components. Should fall back to the hashed - // short name, which fits under TMPDIR. - let target = "longish-host.example.com"; - let session = "a-fairly-long-session-name-here"; - let path = local_forward_socket_path(target, session); - assert!( - fits_unix_socket_path(&path), - "socket path too long for sun_path: {} ({} bytes)", - path.display(), - socket_path_byte_len(&path) - ); - } - - #[test] - fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() { - let _guard = remote_env_lock().lock().unwrap(); - // Force a TMPDIR long enough that even the hashed short name cannot - // fit inside it. The fallback should drop to /tmp. - let prior = std::env::var_os("TMPDIR"); - let long_dir = std::env::temp_dir().join("a".repeat(80)); - let _ = fs::create_dir_all(&long_dir); - std::env::set_var("TMPDIR", &long_dir); - - let path = local_forward_socket_path("longish-host.example.com", "default"); - let fits = fits_unix_socket_path(&path); - let parent = path.parent().map(Path::to_path_buf); - let filename = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - - match prior { - Some(v) => std::env::set_var("TMPDIR", v), - None => std::env::remove_var("TMPDIR"), - } - let _ = fs::remove_dir_all(&long_dir); - - assert!(fits, "fallback path still overflows: {}", path.display()); - assert_eq!(parent.as_deref(), Some(Path::new("/tmp"))); - assert!( - filename.starts_with("herdr-r-"), - "expected hashed fallback, got {filename}" - ); - } - - #[test] - fn install_source_cleanup_removes_temporary_directory() { - let dir = std::env::temp_dir().join(format!( - "herdr-install-source-cleanup-test-{}", - std::process::id() - )); - let _ = fs::remove_dir_all(&dir); - fs::create_dir(&dir).expect("create temp dir"); - let path = dir.join("herdr.tmp"); - fs::write(&path, b"test").expect("write temp file"); - - InstallSource::temporary(path, dir.clone()).cleanup(); - - assert!(!dir.exists()); - } -} diff --git a/src/update.rs b/src/update.rs index 3455841d..2120fb3c 100644 --- a/src/update.rs +++ b/src/update.rs @@ -1830,6 +1830,11 @@ pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool { || is_nix_store_exe_path_following_links(path) } +#[cfg(not(unix))] +pub(crate) fn is_package_manager_managed_exe_path(_path: &Path) -> bool { + false +} + fn is_homebrew_managed_exe_path_following_links(path: &Path) -> bool { if is_homebrew_managed_exe_path(path) { return true; -- cgit v1.2.3