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 --- 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 + 15 files changed, 4541 insertions(+), 3335 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 (limited to 'src') 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