aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-05 19:34:24 +0300
committerGitHub <noreply@github.com>2026-08-05 19:34:24 +0300
commit2863b715132fe29e53089e06f105943d1df0b3b4 (patch)
tree6b4af2f6fdec6de2888fd2bff3c0829d6719b727 /src
parenteb93ec8ce62b05de776f751f4d2f52ff60f93f16 (diff)
feat(windows): support remote attach to unix hosts (#2329)
* feat(windows): support remote attach to unix hosts * fix(remote): preserve unix bridge behavior
Diffstat (limited to 'src')
-rw-r--r--src/client/mod.rs247
-rw-r--r--src/ipc.rs100
-rw-r--r--src/main.rs1
-rw-r--r--src/platform/fallback.rs79
-rw-r--r--src/platform/linux.rs6
-rw-r--r--src/platform/macos.rs6
-rw-r--r--src/platform/mod.rs16
-rw-r--r--src/platform/unix_common.rs131
-rw-r--r--src/platform/windows.rs201
-rw-r--r--src/platform/windows/clipboard_image.rs409
-rw-r--r--src/remote.rs144
-rw-r--r--src/remote/attach.rs (renamed from src/remote/unix.rs)632
-rw-r--r--src/remote/host_unix.rs69
-rw-r--r--src/update.rs5
14 files changed, 1626 insertions, 420 deletions
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<crate::platform::ClipboardImage> {
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<crate::platform::ClipboardImage> {
+ 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<crate::platform::ClipboardImage> {
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")
@@ -2364,6 +2426,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!(
should_draw_host_cursor(crate::config::HostCursorModeConfig::Auto),
@@ -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<LocalListener> {
+ #[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::<GenericNamespaced>()?;
+ 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<LocalStreamRead> {
+ 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<LocalStreamReadCount> {
#[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),
},
}
@@ -306,6 +365,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() {
let path = temp_socket_marker_path("same-len-marker");
let _ = fs::remove_file(&path);
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<PathBuf> {
+ 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<std::fs::File> {
+ 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<u8>,
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<std::path::PathBuf>,
+ pub(crate) system_config: Option<std::path::PathBuf>,
+ 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<PathBuf> {
+ 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<std::fs::File> {
+ 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<Mutex<HashMap<u32, CachedProcessRu
static GIT_BASH_PROCESS_CACHE: LazyLock<Mutex<HashMap<u32, CachedGitBashProcess>>> =
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<PathBuf> {
+ 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::File> {
+ 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::<SECURITY_ATTRIBUTES>()).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<Vec<u16>> {
+ use std::os::windows::ffi::OsStrExt as _;
+
+ let path = std::path::absolute(path)?;
+ let wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
+ 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<Vec<u8>> {
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<ClipboardImage> {
+ 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<Vec<u8>> {
+ static PNG_FORMAT: LazyLock<u32> = 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<Vec<u8>> {
+ 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::<u8>(), bytes.as_mut_ptr(), size);
+ GlobalUnlock(handle);
+ }
+ Some(bytes)
+}
+
pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result<bool> {
let title = title.to_owned();
let body = body.unwrap_or(&title).to_owned();
@@ -1788,6 +1964,21 @@ mod tests {
};
#[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(
crossterm::event::KeyCode::Esc,
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<Vec<u8>> {
+ 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<usize> {
+ 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<Vec<u8>> {
+ 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<ChannelMask>,
+ },
+}
+
+#[derive(Clone, Copy)]
+struct ChannelMask {
+ mask: u32,
+ shift: u32,
+ maximum: u32,
+}
+
+impl ChannelMask {
+ fn parse(mask: u32) -> Option<Self> {
+ 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<Self> {
+ 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<Vec<u8>> {
+ 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<u8>,
+ limit: usize,
+}
+
+impl LimitedWriter {
+ fn new(limit: usize) -> Self {
+ Self {
+ bytes: Vec::new(),
+ limit,
+ }
+ }
+
+ fn into_inner(self) -> Vec<u8> {
+ self.bytes
+ }
+}
+
+impl io::Write for LimitedWriter {
+ fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
+ 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<u16> {
+ Some(u16::from_le_bytes(
+ bytes.get(offset..offset + 2)?.try_into().ok()?,
+ ))
+}
+
+fn read_u32_le(bytes: &[u8], offset: usize) -> Option<u32> {
+ Some(u32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_i32_le(bytes: &[u8], offset: usize) -> Option<i32> {
+ Some(i32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_u32_be(bytes: &[u8], offset: usize) -> Option<u32> {
+ 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<u8> {
+ 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<u8> {
+ 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<Self, String> {
- 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<String>, Option<RemoteLaunch>), 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/unix.rs b/src/remote/attach.rs
index 6fe927c1..4121d468 100644
--- a/src/remote/unix.rs
+++ b/src/remote/attach.rs
@@ -1,12 +1,17 @@
//! 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::os::unix::net::{UnixListener, UnixStream};
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},
@@ -16,6 +21,8 @@ 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);
@@ -191,53 +198,6 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
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,
@@ -435,7 +395,7 @@ struct PreparedRemoteHerdr {
#[derive(Clone)]
struct ManagedSshOptions {
config_path: PathBuf,
- control_path: PathBuf,
+ control_path: Option<PathBuf>,
}
struct ManagedSshConfig {
@@ -613,9 +573,14 @@ fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String {
impl Drop for RemoteSsh {
fn drop(&mut self) {
- if self.managed_config.is_none() {
+ 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()
@@ -636,15 +601,16 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO
return;
};
- command
- .arg("-F")
- .arg(&options.config_path)
- .arg("-S")
- .arg(&options.control_path)
- .arg("-o")
- .arg("ControlMaster=auto")
- .arg("-o")
- .arg("ControlPersist=yes");
+ 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 {
@@ -1577,14 +1543,15 @@ fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
}
fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
- let base = std::env::temp_dir();
+ 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 fs::create_dir(&dir) {
+ 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),
@@ -1651,8 +1618,9 @@ fn reattach_command(
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));
+ 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());
@@ -1662,27 +1630,11 @@ fn reattach_command(
}
if session_name != crate::session::DEFAULT_SESSION_NAME {
command.push_str(" --session ");
- command.push_str(&shell_quote(session_name));
+ command.push_str(&crate::platform::remote_reattach_argument(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();
@@ -1695,6 +1647,7 @@ fn command_failed(context: &str, output: &Output) -> io::Error {
struct SshStdioBridge {
local_socket: PathBuf,
+ socket_identity: crate::ipc::SocketFileIdentity,
should_stop: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
@@ -1707,10 +1660,21 @@ impl SshStdioBridge {
session_name: String,
ssh_options: Option<&ManagedSshOptions>,
) -> io::Result<Self> {
- let _ = std::fs::remove_file(&local_socket);
- let listener = UnixListener::bind(&local_socket)?;
- crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?;
- listener.set_nonblocking(true)?;
+ 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);
@@ -1718,19 +1682,14 @@ impl SshStdioBridge {
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;
- }
+ 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}");
}
@@ -1748,6 +1707,7 @@ impl SshStdioBridge {
Ok(Self {
local_socket,
+ socket_identity,
should_stop,
thread: Some(thread),
})
@@ -1757,100 +1717,64 @@ impl SshStdioBridge {
impl Drop for SshStdioBridge {
fn drop(&mut self) {
self.should_stop.store(true, Ordering::Release);
- let _ = std::fs::remove_file(&self.local_socket);
+ #[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);
}
}
-/// 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<PathBuf> {
- 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<ManagedSshConfig> {
- use std::os::unix::fs::OpenOptionsExt;
+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)
+ }
+}
- let dir = private_ssh_config_dir()?;
+/// 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<ManagedSshConfig> {
+ 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 = dir.join(SSH_CONTROL_SOCKET_NAME);
+ let control_path = paths
+ .multiplexing
+ .then(|| 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 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 Path::new("/etc/ssh/ssh_config").is_file() {
- contents.push_str("Include /etc/ssh/ssh_config\n");
+ 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 mut file = fs::OpenOptions::new()
- .write(true)
- .create_new(true)
- .mode(BRIDGE_SOCKET_PERMISSION_MODE)
- .open(&path)?;
- file.write_all(contents.as_bytes())?;
+ 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,
@@ -1859,20 +1783,21 @@ fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> {
})
}
+#[cfg(unix)]
fn bridge_connection(
- stream: UnixStream,
+ stream: crate::ipc::LocalStream,
target: &str,
remote_herdr: &RemoteHerdr,
session_name: &str,
ssh_options: Option<&ManagedSshOptions>,
+ _bridge_stop: &Arc<AtomicBool>,
) -> 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
+ .arg(remote_bridge_command(remote_herdr, session_name))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
@@ -1896,7 +1821,7 @@ fn bridge_connection(
});
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 _ = crate::ipc::shutdown_local_stream_write(&child_to_stream);
});
let status = child.wait()?;
@@ -1913,6 +1838,157 @@ fn bridge_connection(
}
}
+#[cfg(windows)]
+fn bridge_connection(
+ stream: crate::ipc::LocalStream,
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+ session_name: &str,
+ ssh_options: Option<&ManagedSshOptions>,
+ bridge_stop: &Arc<AtomicBool>,
+) -> 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<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
let mut buffer = [0_u8; 16 * 1024];
let mut total = 0;
@@ -1931,6 +2007,80 @@ fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::
}
}
+#[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<R: io::Read>(
+ reader: &mut R,
+ stream: &mut crate::ipc::LocalStream,
+ connection_stop: &AtomicBool,
+ bridge_stop: &AtomicBool,
+) -> io::Result<u64> {
+ 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<W: io::Write>(
+ mut stream: crate::ipc::LocalStream,
+ writer: &mut W,
+ connection_stop: &AtomicBool,
+ bridge_stop: &AtomicBool,
+ client_closed: &AtomicBool,
+) -> io::Result<u64> {
+ 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,
@@ -1966,37 +2116,18 @@ 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 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");
- 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)
+ 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;
- // 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
+
+ path.as_os_str().as_bytes().len() <= 103
}
fn short_socket_hash(target: &str, session: &str) -> String {
@@ -2028,6 +2159,7 @@ fn sanitize_path_component(input: &str) -> String {
mod tests {
use super::*;
+ #[cfg(unix)]
#[test]
fn bridge_socket_is_user_only() {
use std::os::unix::fs::PermissionsExt;
@@ -2056,13 +2188,42 @@ mod tests {
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();
+ 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...
@@ -2124,11 +2285,16 @@ mod tests {
);
}
+ #[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();
+ 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),
@@ -2157,6 +2323,45 @@ mod tests {
);
}
+ #[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::<Vec<_>>();
+ 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 {
@@ -2379,6 +2584,7 @@ mod tests {
assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none());
}
+ #[cfg(unix)]
#[test]
fn reattach_command_includes_remote_and_session() {
assert_eq!(
@@ -2423,6 +2629,25 @@ mod tests {
);
}
+ #[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 {
@@ -3009,16 +3234,29 @@ mod tests {
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::Mutex<()>> = 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();
@@ -3043,6 +3281,7 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn local_forward_socket_path_fits_in_sun_path() {
let _guard = remote_env_lock().lock().unwrap();
@@ -3060,6 +3299,7 @@ mod tests {
);
}
+ #[cfg(unix)]
#[test]
fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() {
let _guard = remote_env_lock().lock().unwrap();
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<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
+ let mut buffer = [0_u8; 16 * 1024];
+ let mut total = 0;
+ loop {
+ let 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/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;