diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 00:07:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 16:29:44 +0300 |
| commit | 3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch) | |
| tree | a52280bccc824ca656f8bc986a756f30901974e4 /src/remote.rs | |
| parent | 5d7e56737ef30d9de5802b0684a9428c40131dda (diff) | |
feat: add native windows beta support
Diffstat (limited to 'src/remote.rs')
| -rw-r--r-- | src/remote.rs | 2390 |
1 files changed, 21 insertions, 2369 deletions
diff --git a/src/remote.rs b/src/remote.rs index ce56ba8a..edfb9269 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1,38 +1,22 @@ -//! Remote thin-client launcher over SSH command stdio. +#[cfg(unix)] +mod unix; -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}; +#[cfg(unix)] +pub(crate) use unix::*; -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"; +#[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<Self, String> { match value { @@ -41,15 +25,9 @@ impl RemoteKeybindings { _ => Err("--remote-keybindings must be 'local' or 'server'".to_string()), } } - - fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Server => "server", - } - } } +#[cfg(windows)] #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RemoteLaunch { pub(crate) target: String, @@ -57,6 +35,7 @@ pub(crate) struct RemoteLaunch { pub(crate) live_handoff: bool, } +#[cfg(windows)] pub(crate) fn extract_remote_args( args: &[String], ) -> Result<(Vec<String>, Option<RemoteLaunch>), String> { @@ -141,6 +120,7 @@ pub(crate) fn extract_remote_args( 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()); @@ -151,2346 +131,18 @@ fn validate_remote_target(target: &str) -> Result<&str, 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 prepared_remote = prepare_remote_herdr(&remote.target, remote.live_handoff)?; - ensure_remote_server_ready( - &remote.target, - &prepared_remote.remote_herdr, - prepared_remote.installed_or_replaced, - prepared_remote.stop_after_install_approved, - remote.live_handoff, - )?; - - let manage_ssh_config = crate::config::Config::load() - .config - .remote - .manage_ssh_config; - let _bridge = SshStdioBridge::start( - remote.target, - prepared_remote.remote_herdr, - local_socket.clone(), - session_name, - manage_ssh_config, - )?; - - 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<Self> { - 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<String> }, -} - -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<u32>, - assets: BTreeMap<String, RemoteAssetRef>, - #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] - releases: BTreeMap<String, RemoteReleaseMetadata>, -} - -#[derive(Deserialize)] -struct RemoteReleaseMetadata { - protocol: Option<u32>, - #[serde(default)] - assets: BTreeMap<String, RemoteAssetRef>, -} - -#[derive(Deserialize)] -struct RemotePreviewManifest { - build_id: String, - protocol: u32, - assets: BTreeMap<String, RemoteAssetRef>, - #[serde(default)] - builds: BTreeMap<String, RemotePreviewBuildMetadata>, -} - -#[derive(Deserialize)] -struct RemotePreviewBuildMetadata { - protocol: u32, - assets: BTreeMap<String, RemoteAssetRef>, -} - -fn deserialize_remote_manifest_releases<'de, D>( - deserializer: D, -) -> Result<BTreeMap<String, RemoteReleaseMetadata>, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = Option::<serde_json::Value>::deserialize(deserializer)?; - Ok(match value { - Some(serde_json::Value::Object(object)) => object - .into_iter() - .filter_map(|(version, release)| { - serde_json::from_value::<RemoteReleaseMetadata>(release) - .ok() - .map(|metadata| (version, metadata)) - }) - .collect(), - _ => BTreeMap::new(), - }) -} - -impl RemoteUpdateManifest { - fn release_for_version(&self, version: &str) -> Option<RemoteManifestReleaseRef<'_>> { - 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<u32>, - assets: &'a BTreeMap<String, RemoteAssetRef>, -} - -fn current_version() -> String { - crate::build_info::version() -} - -fn current_channel() -> &'static str { - crate::build_info::channel() -} - -struct InstallSource { - path: PathBuf, - temporary_dir: Option<PathBuf>, -} - -struct RemoteReleaseAsset { - url: String, - sha256: Option<String>, -} - -struct PreparedRemoteHerdr { - remote_herdr: RemoteHerdr, - installed_or_replaced: bool, - stop_after_install_approved: bool, -} - -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( - target: &str, - live_handoff_enabled: bool, -) -> io::Result<PreparedRemoteHerdr> { - let platform = detect_remote_platform(target)?; - let remote_herdr = RemoteHerdr::for_platform(platform); - let override_binary = remote_binary_override_path()?; - let path_remote_herdr = remote_binary_on_path_any(target, &remote_herdr)?; - - if override_binary.is_none() { - if let Some(path_remote_herdr) = path_remote_herdr - .as_ref() - .filter(|candidate| remote_binary_matches(target, candidate).unwrap_or(false)) - { - return Ok(PreparedRemoteHerdr { - remote_herdr: path_remote_herdr.clone(), - installed_or_replaced: false, - stop_after_install_approved: false, - }); - } - if remote_binary_matches(target, &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) = path_remote_herdr.as_ref().or_else(|| { - remote_binary_exists(target, &remote_herdr) - .ok() - .and_then(|exists| exists.then_some(&remote_herdr)) - }) { - stop_after_install_approved = confirm_remote_install_with_running_server( - target, - status_probe_herdr, - live_handoff_enabled, - )?; - } - confirm_remote_install( - 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 = install_remote_herdr(target, &remote_herdr, &source.path); - source.cleanup(); - install_result?; - - if !remote_binary_matches(target, &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(target)?; - - Ok(PreparedRemoteHerdr { - remote_herdr, - installed_or_replaced: true, - stop_after_install_approved, - }) -} - -fn detect_remote_platform(target: &str) -> io::Result<RemotePlatform> { - let output = ssh_sh_output(target, "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_on_path_any( - target: &str, - remote_herdr: &RemoteHerdr, -) -> io::Result<Option<RemoteHerdr>> { - let output = ssh_user_shell_output(target, "command -v herdr")?; - 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_herdr_from_path_discovery( - remote_herdr: &RemoteHerdr, - stdout: &str, -) -> Option<RemoteHerdr> { - let mut lines = stdout.lines(); - let path = lines.next()?; - if !path.starts_with('/') { - return None; - } - Some(remote_herdr.clone().with_shell_path(shell_quote(path))) -} - -fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> { - let command = format!( - "test -x {0} && {0} --version && {0} status client --json", - remote_herdr.shell_path - ); - let output = ssh_sh_output(target, &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(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> { - let command = format!("test -x {}", remote_herdr.shell_path); - Ok(ssh_sh_output(target, &command)?.status.success()) -} - -fn remote_binary_override_path() -> io::Result<Option<PathBuf>> { - 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<PathBuf>, -) -> io::Result<InstallSource> { - 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<String>, - protocol: Option<u32>, - live_handoff: bool, - }, - NotRunning, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RemoteServerRestartReason { - ProtocolMismatch, - BinaryUpdated, - VersionMismatch, -} - -fn ensure_remote_server_ready( - target: &str, - remote_herdr: &RemoteHerdr, - remote_binary_changed: bool, - stop_after_install_approved: bool, - live_handoff_enabled: bool, -) -> io::Result<()> { - let status = remote_server_status(target, remote_herdr)?; - let RemoteServerStatus::Running { - version, - protocol, - live_handoff, - } = status - else { - return Ok(()); - }; - - let Some(reason) = - remote_server_restart_reason(version.as_deref(), protocol, remote_binary_changed) - else { - return Ok(()); - }; - - if live_handoff_enabled && live_handoff { - match live_handoff_remote_server(target, 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(target, remote_herdr)?; - return Ok(()); - } - - if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? { - stop_remote_server(target, remote_herdr)?; - } - Ok(()) -} - -fn remote_server_restart_reason( - version: Option<&str>, - protocol: Option<u32>, - remote_binary_changed: bool, -) -> Option<RemoteServerRestartReason> { - if protocol != Some(CURRENT_PROTOCOL) { - return Some(RemoteServerRestartReason::ProtocolMismatch); - } - if remote_binary_changed { - return Some(RemoteServerRestartReason::BinaryUpdated); - } - if version != Some(current_version().as_str()) { - return Some(RemoteServerRestartReason::VersionMismatch); - } - None -} - -fn confirm_remote_install_with_running_server( - target: &str, - remote_herdr: &RemoteHerdr, - live_handoff_enabled: bool, -) -> io::Result<bool> { - let status = match remote_server_status(target, 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, - } = status - else { - return Ok(false); - }; - if !io::stdin().is_terminal() { - if live_handoff_enabled && live_handoff { - return Ok(false); - } - 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()) - ))); - } - - if live_handoff_enabled && live_handoff { - 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_server_status( - target: &str, - remote_herdr: &RemoteHerdr, -) -> io::Result<RemoteServerStatus> { - let command = format!("{} status server --json", remote_herdr.shell_path); - let output = ssh_sh_output(target, &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<String>, - protocol: Option<u32>, - capabilities: Option<RemoteServerCapabilitiesJson>, -} - -#[derive(Debug, Deserialize)] -struct RemoteServerCapabilitiesJson { - live_handoff: bool, -} - -fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> { - serde_json::from_str(status).ok() -} - -fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatus> { - 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); - } - - Ok(RemoteServerStatus::Running { - version: parsed.version, - protocol: parsed.protocol, - live_handoff: parsed - .capabilities - .is_some_and(|capabilities| capabilities.live_handoff), - }) -} - -fn confirm_remote_server_stop( - target: &str, - version: Option<&str>, - _protocol: Option<u32>, - reason: RemoteServerRestartReason, -) -> io::Result<bool> { - 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::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(target: &str, 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(target, &command)?; - if !output.status.success() { - return Err(command_failed("remote server live handoff failed", &output)); - } - - eprintln!( - "handed off the remote herdr server on {target}; reconnecting to the prepared server." - ); - Ok(()) -} - -fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let command = format!("{} server stop", remote_herdr.shell_path); - let output = ssh_sh_output(target, &command)?; - if !output.status.success() { - return Err(command_failed("remote server stop failed", &output)); - } - - wait_for_remote_server_shutdown(target, remote_herdr)?; - eprintln!("stopped the remote herdr server on {target}; it will restart when the remote client bridge attaches."); - Ok(()) -} - -fn wait_for_remote_server_shutdown(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> { - let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT; - loop { - if remote_server_status(target, 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() - ), - )); - } - 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(target: &str) -> io::Result<()> { - let output = ssh_user_shell_output(target, "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<InstallSource> { - 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 = Command::new("curl") - .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<Vec<u8>> { - let output = Command::new("curl") - .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<String, RemoteAssetRef>)> { - 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<RemoteReleaseAsset> { - 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<PathBuf> { - 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", +#[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", )) } -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 install_remote_herdr( - target: &str, - remote_herdr: &RemoteHerdr, - source_path: &Path, -) -> io::Result<()> { - let script = format!( - r#"dest="$HOME/{install_suffix}" -dir="${{dest%/*}}" -mkdir -p "$dir" -tmp="${{dest}}.tmp.$$" -cat > "$tmp" -chmod 755 "$tmp" -mv "$tmp" "$dest" -"#, - install_suffix = remote_herdr.install_suffix - ); - - let mut child = Command::new("ssh") - .arg("-T") - .arg(target) - .arg(format!("/bin/sh -eu -c {}", shell_quote(&script))) - .stdin(Stdio::piped()) - .stdout(Stdio::inherit()) - .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() { - Ok(()) - } else { - Err(io::Error::other(format!( - "remote install exited with {status}" - ))) - } -} - -fn ssh_sh_output(target: &str, script: &str) -> io::Result<Output> { - // Feed POSIX bootstrap scripts to /bin/sh so the user's login shell only - // has to parse a simple executable invocation. - let mut child = Command::new("ssh") - .arg("-T") - .arg(target) - .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 ssh_user_shell_output(target: &str, command: &str) -> io::Result<Output> { - Command::new("ssh") - .arg("-T") - .arg(target) - .arg(command) - .output() -} - -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, - keepalive_ssh_config: Option<PathBuf>, - should_stop: Arc<AtomicBool>, - thread: Option<JoinHandle<()>>, -} - -impl SshStdioBridge { - fn start( - target: String, - remote_herdr: RemoteHerdr, - local_socket: PathBuf, - session_name: String, - manage_ssh_config: bool, - ) -> io::Result<Self> { - 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 keepalive_ssh_config = if manage_ssh_config { - write_keepalive_ssh_config() - .inspect_err(|err| { - tracing::debug!(%err, "could not write ssh keepalive config; using plain ssh"); - }) - .ok() - } else { - None - }; - - let should_stop = Arc::new(AtomicBool::new(false)); - let thread_stop = Arc::clone(&should_stop); - let thread_ssh_config = keepalive_ssh_config.clone(); - 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_config.as_deref(), - ) { - 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, - keepalive_ssh_config, - 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(); - } - // Remove the generated ssh config only after the bridge thread has - // joined, so it can never start a connection with a config path that - // was just deleted. - if let Some(dir) = self.keepalive_ssh_config.as_deref().and_then(Path::parent) { - let _ = std::fs::remove_dir_all(dir); - } - } -} - -/// Creates a fresh user-only (`0700`) directory under the temp dir for the -/// bridge's generated ssh config, 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<PathBuf> { - use std::os::unix::fs::DirBuilderExt; - - let base = std::env::temp_dir(); - for attempt in 0..100 { - let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); - match fs::DirBuilder::new().mode(0o700).create(&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 ssh config directory", +#[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", )) } - -/// 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 that keeps the bridge tunnel alive 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 values apply only when the user has -/// none. -fn write_keepalive_ssh_config() -> io::Result<PathBuf> { - use std::os::unix::fs::OpenOptionsExt; - - let path = private_ssh_config_dir()?.join("config"); - - 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(path) -} - -fn bridge_connection( - stream: UnixStream, - target: &str, - remote_herdr: &RemoteHerdr, - session_name: &str, - keepalive_ssh_config: Option<&Path>, -) -> io::Result<()> { - let mut command = Command::new("ssh"); - // Use the generated keepalive ssh config when present; otherwise plain ssh. - if let Some(ssh_config) = keepalive_ssh_config { - command.arg("-F").arg(ssh_config); - } - 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<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> { - 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(), - false, - ) - .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 keepalive_ssh_config_includes_user_config_then_fallback() { - use std::os::unix::fs::PermissionsExt; - - let path = write_keepalive_ssh_config().expect("write keepalive config"); - let contents = std::fs::read_to_string(&path).expect("read keepalive config"); - - // herdr's fallback keepalive is 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}" - ); - // ...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"); - - let _ = std::fs::remove_dir_all(dir); - } - - #[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 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_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}}"# - ) - .unwrap(), - RemoteServerStatus::Running { - version: Some("0.6.0".into()), - protocol: Some(8), - live_handoff: true - } - ); - } - - #[test] - fn parse_remote_server_status_json_treats_missing_capability_as_no_handoff() { - 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 - } - ); - } - - #[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), false), - Some(RemoteServerRestartReason::ProtocolMismatch) - ); - } - - #[test] - fn remote_server_restart_reason_offers_restart_after_binary_update() { - assert_eq!( - remote_server_restart_reason(Some(¤t_version()), Some(CURRENT_PROTOCOL), 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), false), - Some(RemoteServerRestartReason::VersionMismatch) - ); - assert_eq!( - remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), 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), false), - None - ); - } - - #[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::Mutex<()>> = 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()); - } -} |
