use std::path::{Path, PathBuf}; pub(crate) fn classify_child_exit(status: &portable_pty::ExitStatus) -> super::ChildExitReason { if status.signal().is_some() { super::ChildExitReason::Interrupted } else { super::ChildExitReason::Exited } } pub(crate) fn read_fd(fd: std::os::fd::RawFd, data: &mut [u8]) -> std::io::Result { let result = unsafe { libc::read(fd, data.as_mut_ptr().cast(), data.len()) }; if result < 0 { Err(std::io::Error::last_os_error()) } else { Ok(result as usize) } } pub(crate) fn poll_fd_readable(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result { let mut descriptor = libc::pollfd { fd, events: libc::POLLIN, revents: 0, }; let result = unsafe { libc::poll(&mut descriptor, 1, timeout_ms) }; if result < 0 { Err(std::io::Error::last_os_error()) } else { Ok(result > 0) } } pub(crate) fn shutdown_client_stream(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { let crate::ipc::LocalStream::UdSocket(stream) = stream; stream.inner().shutdown(std::net::Shutdown::Both) } pub(crate) struct ClientStreamReader<'a>(pub(crate) &'a mut crate::ipc::LocalStream); impl std::io::Read for ClientStreamReader<'_> { fn read(&mut self, data: &mut [u8]) -> std::io::Result { use std::os::fd::AsRawFd as _; loop { match self.0.read(data) { Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { let crate::ipc::LocalStream::UdSocket(stream) = &*self.0; let mut descriptor = libc::pollfd { fd: stream.inner().as_raw_fd(), events: libc::POLLIN, revents: 0, }; // Sleep until input or shutdown, without polling quiet observers. if unsafe { libc::poll(&mut descriptor, 1, -1) } < 0 { let error = std::io::Error::last_os_error(); if error.kind() != std::io::ErrorKind::Interrupted { return Err(error); } } } result => return result, } } } } pub(crate) fn write_client_stream( stream: &crate::ipc::LocalStream, mut data: &[u8], ) -> std::io::Result<()> { use std::io::{self, Write as _}; use std::os::fd::AsRawFd as _; use std::time::Instant; let crate::ipc::LocalStream::UdSocket(socket) = stream; let mut socket = socket.inner(); let Some(timeout) = socket.write_timeout()? else { return socket.write_all(data); }; let timed_out = || { // Dropping the writer clone alone would leave the reader blocked. let _ = shutdown_client_stream(stream); io::Error::new( io::ErrorKind::TimedOut, "terminal observer stopped receiving output", ) }; let mut progress = Instant::now(); while !data.is_empty() { match socket.write(data) { Ok(0) => return Err(io::ErrorKind::WriteZero.into()), Ok(written) => { data = &data[written..]; progress = Instant::now(); continue; } Err(error) if matches!( error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) => {} Err(error) => return Err(error), } let remaining = timeout .checked_sub(progress.elapsed()) .ok_or_else(timed_out)?; let mut descriptor = libc::pollfd { fd: socket.as_raw_fd(), events: libc::POLLOUT, revents: 0, }; let wait_ms = remaining.as_millis().clamp(1, i32::MAX as u128) as i32; let ready = unsafe { libc::poll(&mut descriptor, 1, wait_ms) }; if ready == 0 { return Err(timed_out()); } if ready < 0 { let error = io::Error::last_os_error(); if error.kind() != io::ErrorKind::Interrupted { return Err(error); } } } Ok(()) } pub(crate) fn wait_client_stream_readable(stream: &crate::ipc::LocalStream) -> std::io::Result<()> { use std::os::fd::{AsFd as _, AsRawFd as _}; let crate::ipc::LocalStream::UdSocket(stream) = stream; let mut descriptor = libc::pollfd { fd: stream.as_fd().as_raw_fd(), events: libc::POLLIN, revents: 0, }; // Bound cancellation latency without polling idle connections hundreds of times per second. let result = unsafe { libc::poll(&mut descriptor, 1, 100) }; if result < 0 { let error = std::io::Error::last_os_error(); if error.kind() != std::io::ErrorKind::Interrupted { return Err(error); } } Ok(()) } pub(crate) fn forward_remote_bridge_stdio( stream: crate::ipc::LocalStream, idle_timeout: bool, ) -> std::io::Result<()> { forward_remote_bridge_stdio_with_timeout( stream, idle_timeout.then_some(super::remote_bridge::IDLE_TIMEOUT), ) } pub(super) fn forward_remote_bridge_stdio_with_timeout( stream: crate::ipc::LocalStream, idle_timeout: Option, ) -> std::io::Result<()> { use super::remote_bridge::{Activity, TrackedIo}; use interprocess::TryClone as _; let activity = idle_timeout.map(Activity::start).transpose()?; let mut stdout = TrackedIo::new(std::io::stdout().lock(), activity.clone()); let mut socket_to_stdout = TrackedIo::new(stream.try_clone()?, activity.clone()); let mut stdin_to_socket = stream; let _upload = std::thread::spawn(move || { let mut stdin = TrackedIo::new(std::io::stdin(), activity.clone()); let _ = copy_flush( &mut stdin, &mut TrackedIo::new(&mut stdin_to_socket, activity), ); let crate::ipc::LocalStream::UdSocket(stream) = stdin_to_socket; let _ = stream.inner().shutdown(std::net::Shutdown::Write); }); copy_flush(&mut socket_to_stdout, &mut stdout) } fn copy_flush( reader: &mut R, writer: &mut W, ) -> std::io::Result<()> { let mut buffer = [0_u8; 16 * 1024]; loop { let read = match reader.read(&mut buffer) { Ok(0) => return Ok(()), Ok(read) => read, Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, Err(err) => return Err(err), }; writer.write_all(&buffer[..read])?; writer.flush()?; } } pub(crate) struct RemoteBridgeWake { reader: std::os::unix::net::UnixStream, writer: std::os::unix::net::UnixStream, } impl RemoteBridgeWake { pub(crate) fn new() -> std::io::Result { let (reader, writer) = std::os::unix::net::UnixStream::pair()?; Ok(Self { reader, writer }) } pub(crate) fn cancel(&self) -> std::io::Result<()> { // EOF stays readable, including when cancellation precedes the wait. self.writer.shutdown(std::net::Shutdown::Write) } pub(crate) fn wait(&self, stream: &crate::ipc::LocalStream) -> std::io::Result<()> { use std::os::fd::{AsFd as _, AsRawFd as _}; let crate::ipc::LocalStream::UdSocket(stream) = stream; let mut descriptors = [ libc::pollfd { fd: stream.as_fd().as_raw_fd(), events: libc::POLLIN, revents: 0, }, libc::pollfd { fd: self.reader.as_raw_fd(), events: libc::POLLIN, revents: 0, }, ]; loop { // SAFETY: both descriptors remain borrowed and the array has two entries. if unsafe { libc::poll(descriptors.as_mut_ptr(), 2, -1) } >= 0 { return Ok(()); } let error = std::io::Error::last_os_error(); if error.kind() != std::io::ErrorKind::Interrupted { return Err(error); } } } } pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> { crossterm::terminal::window_size().map(|size| (size.columns, size.rows)) } fn set_sigpipe_disposition(handler: libc::sighandler_t) { let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; action.sa_sigaction = handler; unsafe { libc::sigemptyset(&mut action.sa_mask); // Rust starts with SIGPIPE ignored. If this best-effort transition // fails, stdout retains the existing Rust behavior. libc::sigaction(libc::SIGPIPE, &action, std::ptr::null_mut()); } } pub(crate) fn begin_cli_output() { set_sigpipe_disposition(libc::SIG_DFL); } pub(crate) fn end_cli_output() { set_sigpipe_disposition(libc::SIG_IGN); } 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 } /// The machine's node name, as shown by tmux's `#h`. pub(crate) fn hostname() -> Option { let mut buffer = [0_u8; 256]; let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast::(), buffer.len()) }; if result != 0 { return None; } let end = buffer .iter() .position(|&byte| byte == 0) .unwrap_or(buffer.len()); let name = String::from_utf8_lossy(&buffer[..end]).into_owned(); (!name.is_empty()).then_some(name) } pub(crate) fn local_datetime() -> Option { let mut timestamp: libc::time_t = 0; if unsafe { libc::time(&mut timestamp) } == -1 { return None; } let mut local: libc::tm = unsafe { std::mem::zeroed() }; if unsafe { libc::localtime_r(×tamp, &mut local) }.is_null() { return None; } datetime_from_tm(&local) } pub(crate) fn status_commands_supported() -> bool { true } pub(crate) fn configure_status_command(process: &mut std::process::Command) { use std::os::unix::process::CommandExt; process.process_group(0); } pub(crate) struct StatusCommandGuard { process_group_id: Option, } impl StatusCommandGuard { pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result { let process_id = child .id() .ok_or_else(|| std::io::Error::other("status command has no process id"))?; let process_group_id = i32::try_from(process_id) .map_err(|_| std::io::Error::other("status command process id exceeds i32"))?; Ok(Self { process_group_id: Some(process_group_id), }) } } impl StatusCommandGuard { pub(crate) fn terminate(&mut self) { if let Some(process_group_id) = self.process_group_id.take() { // The command was spawned as this process group's leader. Killing the // group also cleans up background descendants on completion/cancellation. unsafe { libc::kill(-process_group_id, libc::SIGKILL); } } } } impl Drop for StatusCommandGuard { fn drop(&mut self) { self.terminate(); } } fn datetime_from_tm(value: &libc::tm) -> Option { let month = time::Month::try_from(u8::try_from(value.tm_mon + 1).ok()?).ok()?; let date = time::Date::from_calendar_date( value.tm_year + 1900, month, u8::try_from(value.tm_mday).ok()?, ) .ok()?; let time = time::Time::from_hms( u8::try_from(value.tm_hour).ok()?, u8::try_from(value.tm_min).ok()?, u8::try_from(value.tm_sec).ok()?, ) .ok()?; Some(time::PrimitiveDateTime::new(date, time)) } pub(crate) fn set_default_plugin_pane_pwd(env: &mut Vec<(String, String)>, cwd: &std::path::Path) { if !env.iter().any(|(key, _)| key == "PWD") { env.push(("PWD".to_string(), cwd.display().to_string())); } } #[cfg(test)] mod tests { use super::*; #[test] fn plugin_pane_pwd_defaults_to_cwd_without_overriding_explicit_env() { let cwd = Path::new("/plugin-cwd"); let mut derived = vec![("OTHER".to_string(), "value".to_string())]; set_default_plugin_pane_pwd(&mut derived, cwd); assert!(derived.contains(&("PWD".to_string(), "/plugin-cwd".to_string()))); let mut explicit = vec![("PWD".to_string(), "/caller-pwd".to_string())]; set_default_plugin_pane_pwd(&mut explicit, cwd); assert_eq!(explicit, [("PWD".to_string(), "/caller-pwd".to_string())]); } #[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); } }