aboutsummaryrefslogtreecommitdiff
path: root/src/remote.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 00:07:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:44 +0300
commit3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch)
treea52280bccc824ca656f8bc986a756f30901974e4 /src/remote.rs
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src/remote.rs')
-rw-r--r--src/remote.rs2390
1 files changed, 21 insertions, 2369 deletions
diff --git a/src/remote.rs b/src/remote.rs
index ce56ba8a..edfb9269 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -1,38 +1,22 @@
-//! Remote thin-client launcher over SSH command stdio.
+#[cfg(unix)]
+mod unix;
-use std::collections::BTreeMap;
-use std::fs::{self, File};
-use std::io::{self, IsTerminal, Write as _};
-use std::os::unix::net::{UnixListener, UnixStream};
-use std::path::{Path, PathBuf};
-use std::process::{Command, Output, Stdio};
+#[cfg(unix)]
+pub(crate) use unix::*;
-use serde::Deserialize;
-use std::sync::{
- atomic::{AtomicBool, Ordering},
- Arc,
-};
-use std::thread::{self, JoinHandle};
-use std::time::{Duration, Instant};
-
-const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50);
-const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600;
-const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
-const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
-const CURRENT_PROTOCOL: u32 = crate::protocol::PROTOCOL_VERSION;
-const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
-const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
-const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
+#[cfg(windows)]
pub(crate) const REATTACH_COMMAND_ENV_VAR: &str = "HERDR_REATTACH_COMMAND";
-
+#[cfg(windows)]
pub(crate) const REMOTE_KEYBINDINGS_ENV_VAR: &str = "HERDR_REMOTE_KEYBINDINGS";
+#[cfg(windows)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RemoteKeybindings {
Local,
Server,
}
+#[cfg(windows)]
impl RemoteKeybindings {
fn parse(value: &str) -> Result<Self, String> {
match value {
@@ -41,15 +25,9 @@ impl RemoteKeybindings {
_ => Err("--remote-keybindings must be 'local' or 'server'".to_string()),
}
}
-
- fn as_str(self) -> &'static str {
- match self {
- Self::Local => "local",
- Self::Server => "server",
- }
- }
}
+#[cfg(windows)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteLaunch {
pub(crate) target: String,
@@ -57,6 +35,7 @@ pub(crate) struct RemoteLaunch {
pub(crate) live_handoff: bool,
}
+#[cfg(windows)]
pub(crate) fn extract_remote_args(
args: &[String],
) -> Result<(Vec<String>, Option<RemoteLaunch>), String> {
@@ -141,6 +120,7 @@ pub(crate) fn extract_remote_args(
Ok((cleaned, remote))
}
+#[cfg(windows)]
fn validate_remote_target(target: &str) -> Result<&str, String> {
if target.is_empty() {
return Err("missing value for --remote".to_string());
@@ -151,2346 +131,18 @@ fn validate_remote_target(target: &str) -> Result<&str, String> {
Ok(target)
}
-pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
- let session_name = crate::session::active_name()
- .unwrap_or_else(|| crate::session::DEFAULT_SESSION_NAME.to_string());
- let local_socket = local_forward_socket_path(&remote.target, &session_name);
- let program = std::env::args()
- .next()
- .unwrap_or_else(|| "herdr".to_string());
- let reattach_command = reattach_command(
- &program,
- &remote.target,
- &session_name,
- remote.keybindings,
- remote.live_handoff,
- );
- let prepared_remote = prepare_remote_herdr(&remote.target, remote.live_handoff)?;
- ensure_remote_server_ready(
- &remote.target,
- &prepared_remote.remote_herdr,
- prepared_remote.installed_or_replaced,
- prepared_remote.stop_after_install_approved,
- remote.live_handoff,
- )?;
-
- let manage_ssh_config = crate::config::Config::load()
- .config
- .remote
- .manage_ssh_config;
- let _bridge = SshStdioBridge::start(
- remote.target,
- prepared_remote.remote_herdr,
- local_socket.clone(),
- session_name,
- manage_ssh_config,
- )?;
-
- run_client_process(&local_socket, &reattach_command, remote.keybindings)
-}
-
-pub(crate) fn run_remote_client_bridge() -> io::Result<()> {
- ensure_remote_server_running()?;
-
- let socket_path = crate::server::socket_paths::client_socket_path();
- let stream = UnixStream::connect(&socket_path).map_err(|err| {
- io::Error::new(
- err.kind(),
- format!(
- "failed to connect to remote Herdr client socket {}: {err}",
- socket_path.display()
- ),
- )
- })?;
-
- let mut stdout = io::stdout().lock();
- let mut socket_to_stdout = stream.try_clone()?;
- let mut stdin_to_socket = stream;
-
- let _upload = thread::spawn(move || {
- let mut stdin = io::stdin();
- let _ = copy_flush(&mut stdin, &mut stdin_to_socket);
- let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write);
- });
-
- copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ())
-}
-
-fn ensure_remote_server_running() -> io::Result<()> {
- let socket_path = crate::server::socket_paths::client_socket_path();
- if crate::server::autodetect::is_server_listening() {
- let status = crate::api::read_runtime_status_at(
- &crate::api::socket_path(),
- Duration::from_millis(500),
- )?
- .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?;
- if status.protocol == Some(CURRENT_PROTOCOL) {
- return Ok(());
- }
- return Err(io::Error::other(
- "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it",
- ));
- }
-
- crate::server::autodetect::spawn_server_daemon()?;
- crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5))
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct RemotePlatform {
- os: &'static str,
- arch: &'static str,
-}
-
-impl RemotePlatform {
- fn from_uname(os: &str, arch: &str) -> Option<Self> {
- let os = match os.trim() {
- "Linux" => "linux",
- "Darwin" => "macos",
- _ => return None,
- };
- let arch = match arch.trim() {
- "x86_64" | "amd64" => "x86_64",
- "aarch64" | "arm64" => "aarch64",
- _ => return None,
- };
- Some(Self { os, arch })
- }
-
- fn local() -> Self {
- let os = if cfg!(target_os = "linux") {
- "linux"
- } else if cfg!(target_os = "macos") {
- "macos"
- } else {
- "unknown"
- };
-
- let arch = if cfg!(target_arch = "x86_64") {
- "x86_64"
- } else if cfg!(target_arch = "aarch64") {
- "aarch64"
- } else {
- "unknown"
- };
-
- Self { os, arch }
- }
-
- fn asset_key(&self) -> String {
- format!("{}-{}", self.os, self.arch)
- }
-}
-
-#[derive(Debug, Clone)]
-struct RemoteHerdr {
- install_suffix: String,
- shell_path: String,
- platform: RemotePlatform,
-}
-
-impl RemoteHerdr {
- fn for_platform(platform: RemotePlatform) -> Self {
- let install_suffix = ".local/bin/herdr".to_string();
- let shell_path = format!("\"$HOME/{install_suffix}\"");
- Self {
- install_suffix,
- shell_path,
- platform,
- }
- }
-
- fn with_shell_path(mut self, shell_path: String) -> Self {
- self.shell_path = shell_path;
- self
- }
-}
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(untagged)]
-enum RemoteAssetRef {
- Url(String),
- Object { url: String, sha256: Option<String> },
-}
-
-impl RemoteAssetRef {
- fn url(&self) -> &str {
- match self {
- Self::Url(url) => url,
- Self::Object { url, .. } => url,
- }
- }
-
- fn sha256(&self) -> Option<&str> {
- match self {
- Self::Url(_) => None,
- Self::Object { sha256, .. } => {
- sha256.as_deref().filter(|value| !value.trim().is_empty())
- }
- }
- }
-}
-
-#[derive(Deserialize)]
-struct RemoteUpdateManifest {
- version: String,
- protocol: Option<u32>,
- assets: BTreeMap<String, RemoteAssetRef>,
- #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")]
- releases: BTreeMap<String, RemoteReleaseMetadata>,
-}
-
-#[derive(Deserialize)]
-struct RemoteReleaseMetadata {
- protocol: Option<u32>,
- #[serde(default)]
- assets: BTreeMap<String, RemoteAssetRef>,
-}
-
-#[derive(Deserialize)]
-struct RemotePreviewManifest {
- build_id: String,
- protocol: u32,
- assets: BTreeMap<String, RemoteAssetRef>,
- #[serde(default)]
- builds: BTreeMap<String, RemotePreviewBuildMetadata>,
-}
-
-#[derive(Deserialize)]
-struct RemotePreviewBuildMetadata {
- protocol: u32,
- assets: BTreeMap<String, RemoteAssetRef>,
-}
-
-fn deserialize_remote_manifest_releases<'de, D>(
- deserializer: D,
-) -> Result<BTreeMap<String, RemoteReleaseMetadata>, D::Error>
-where
- D: serde::Deserializer<'de>,
-{
- let value = Option::<serde_json::Value>::deserialize(deserializer)?;
- Ok(match value {
- Some(serde_json::Value::Object(object)) => object
- .into_iter()
- .filter_map(|(version, release)| {
- serde_json::from_value::<RemoteReleaseMetadata>(release)
- .ok()
- .map(|metadata| (version, metadata))
- })
- .collect(),
- _ => BTreeMap::new(),
- })
-}
-
-impl RemoteUpdateManifest {
- fn release_for_version(&self, version: &str) -> Option<RemoteManifestReleaseRef<'_>> {
- if self.version.trim_start_matches('v') == version {
- return Some(RemoteManifestReleaseRef {
- protocol: self.protocol,
- assets: &self.assets,
- });
- }
-
- self.releases.get(version).and_then(|release| {
- (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef {
- protocol: release.protocol,
- assets: &release.assets,
- })
- })
- }
-}
-
-#[derive(Clone, Copy)]
-struct RemoteManifestReleaseRef<'a> {
- protocol: Option<u32>,
- assets: &'a BTreeMap<String, RemoteAssetRef>,
-}
-
-fn current_version() -> String {
- crate::build_info::version()
-}
-
-fn current_channel() -> &'static str {
- crate::build_info::channel()
-}
-
-struct InstallSource {
- path: PathBuf,
- temporary_dir: Option<PathBuf>,
-}
-
-struct RemoteReleaseAsset {
- url: String,
- sha256: Option<String>,
-}
-
-struct PreparedRemoteHerdr {
- remote_herdr: RemoteHerdr,
- installed_or_replaced: bool,
- stop_after_install_approved: bool,
-}
-
-impl InstallSource {
- fn persistent(path: PathBuf) -> Self {
- Self {
- path,
- temporary_dir: None,
- }
- }
-
- fn temporary(path: PathBuf, temporary_dir: PathBuf) -> Self {
- Self {
- path,
- temporary_dir: Some(temporary_dir),
- }
- }
-
- fn cleanup(&self) {
- if let Some(dir) = &self.temporary_dir {
- let _ = fs::remove_dir_all(dir);
- }
- }
-}
-
-fn prepare_remote_herdr(
- target: &str,
- live_handoff_enabled: bool,
-) -> io::Result<PreparedRemoteHerdr> {
- let platform = detect_remote_platform(target)?;
- let remote_herdr = RemoteHerdr::for_platform(platform);
- let override_binary = remote_binary_override_path()?;
- let path_remote_herdr = remote_binary_on_path_any(target, &remote_herdr)?;
-
- if override_binary.is_none() {
- if let Some(path_remote_herdr) = path_remote_herdr
- .as_ref()
- .filter(|candidate| remote_binary_matches(target, candidate).unwrap_or(false))
- {
- return Ok(PreparedRemoteHerdr {
- remote_herdr: path_remote_herdr.clone(),
- installed_or_replaced: false,
- stop_after_install_approved: false,
- });
- }
- if remote_binary_matches(target, &remote_herdr)? {
- return Ok(PreparedRemoteHerdr {
- remote_herdr,
- installed_or_replaced: false,
- stop_after_install_approved: false,
- });
- }
- }
-
- let mut stop_after_install_approved = false;
- if let Some(status_probe_herdr) = path_remote_herdr.as_ref().or_else(|| {
- remote_binary_exists(target, &remote_herdr)
- .ok()
- .and_then(|exists| exists.then_some(&remote_herdr))
- }) {
- stop_after_install_approved = confirm_remote_install_with_running_server(
- target,
- status_probe_herdr,
- live_handoff_enabled,
- )?;
- }
- confirm_remote_install(
- target,
- &remote_herdr,
- &install_source_description(&remote_herdr.platform, override_binary.as_deref()),
- )?;
- let source = resolve_install_source(&remote_herdr.platform, override_binary)?;
- let install_result = install_remote_herdr(target, &remote_herdr, &source.path);
- source.cleanup();
- install_result?;
-
- if !remote_binary_matches(target, &remote_herdr)? {
- return Err(io::Error::other(format!(
- "installed remote herdr at {}, but it did not report version {}",
- remote_herdr.shell_path,
- current_version()
- )));
- }
- warn_if_remote_bin_not_on_path(target)?;
-
- Ok(PreparedRemoteHerdr {
- remote_herdr,
- installed_or_replaced: true,
- stop_after_install_approved,
- })
-}
-
-fn detect_remote_platform(target: &str) -> io::Result<RemotePlatform> {
- let output = ssh_sh_output(target, "uname -s\nuname -m\n")?;
- if !output.status.success() {
- return Err(command_failed("remote platform detection failed", &output));
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- let mut lines = stdout.lines();
- let os = lines.next().unwrap_or_default();
- let arch = lines.next().unwrap_or_default();
- RemotePlatform::from_uname(os, arch).ok_or_else(|| {
- io::Error::other(format!(
- "unsupported remote platform: {} {}",
- os.trim(),
- arch.trim()
- ))
- })
-}
-
-fn remote_binary_on_path_any(
- target: &str,
- remote_herdr: &RemoteHerdr,
-) -> io::Result<Option<RemoteHerdr>> {
- let output = ssh_user_shell_output(target, "command -v herdr")?;
- if !output.status.success() {
- return Ok(None);
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- Ok(remote_herdr_from_path_discovery(remote_herdr, &stdout))
-}
-
-fn remote_herdr_from_path_discovery(
- remote_herdr: &RemoteHerdr,
- stdout: &str,
-) -> Option<RemoteHerdr> {
- let mut lines = stdout.lines();
- let path = lines.next()?;
- if !path.starts_with('/') {
- return None;
- }
- Some(remote_herdr.clone().with_shell_path(shell_quote(path)))
-}
-
-fn remote_binary_matches(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
- let command = format!(
- "test -x {0} && {0} --version && {0} status client --json",
- remote_herdr.shell_path
- );
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Ok(false);
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- let mut lines = stdout.lines();
- let version = lines.next().unwrap_or_default().trim();
- let status = lines.next().unwrap_or_default();
- Ok(version == format!("herdr {}", current_version())
- && parse_client_status_json(status)
- .map(|status| status.protocol == CURRENT_PROTOCOL)
- .unwrap_or(false))
-}
-
-fn remote_binary_exists(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
- let command = format!("test -x {}", remote_herdr.shell_path);
- Ok(ssh_sh_output(target, &command)?.status.success())
-}
-
-fn remote_binary_override_path() -> io::Result<Option<PathBuf>> {
- let Some(value) = std::env::var_os(REMOTE_BINARY_ENV_VAR) else {
- return Ok(None);
- };
- if value.is_empty() {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- format!("{REMOTE_BINARY_ENV_VAR} must not be empty"),
- ));
- }
-
- let path = PathBuf::from(value);
- let metadata = fs::metadata(&path).map_err(|err| {
- io::Error::new(
- err.kind(),
- format!(
- "failed to inspect {REMOTE_BINARY_ENV_VAR} path {}: {err}",
- path.display()
- ),
- )
- })?;
- if !metadata.is_file() {
- return Err(io::Error::new(
- io::ErrorKind::InvalidInput,
- format!(
- "{REMOTE_BINARY_ENV_VAR} path is not a file: {}",
- path.display()
- ),
- ));
- }
-
- Ok(Some(path))
-}
-
-fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String {
- install_source_description_for(
- platform,
- override_binary,
- local_binary_can_seed_remote(platform),
- )
-}
-
-fn install_source_description_for(
- platform: &RemotePlatform,
- override_binary: Option<&Path>,
- local_binary_can_seed_remote: bool,
-) -> String {
- if let Some(path) = override_binary {
- return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display());
- }
-
- if local_binary_can_seed_remote {
- "the current local herdr binary".to_string()
- } else {
- format!(
- "the {} {} asset for {}",
- current_version(),
- current_channel(),
- platform.asset_key()
- )
- }
-}
-
-fn resolve_install_source(
- platform: &RemotePlatform,
- override_binary: Option<PathBuf>,
-) -> io::Result<InstallSource> {
- if let Some(path) = override_binary {
- return Ok(InstallSource::persistent(path));
- }
-
- if *platform == RemotePlatform::local() {
- let path = std::env::current_exe()?;
- if !crate::update::is_package_manager_managed_exe_path(&path) {
- return Ok(InstallSource::persistent(path));
- }
- }
-
- download_release_asset(platform)
-}
-
-fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
- if *platform != RemotePlatform::local() {
- return false;
- }
-
- std::env::current_exe()
- .map(|path| !crate::update::is_package_manager_managed_exe_path(&path))
- .unwrap_or(false)
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-enum RemoteServerStatus {
- Running {
- version: Option<String>,
- protocol: Option<u32>,
- live_handoff: bool,
- },
- NotRunning,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum RemoteServerRestartReason {
- ProtocolMismatch,
- BinaryUpdated,
- VersionMismatch,
-}
-
-fn ensure_remote_server_ready(
- target: &str,
- remote_herdr: &RemoteHerdr,
- remote_binary_changed: bool,
- stop_after_install_approved: bool,
- live_handoff_enabled: bool,
-) -> io::Result<()> {
- let status = remote_server_status(target, remote_herdr)?;
- let RemoteServerStatus::Running {
- version,
- protocol,
- live_handoff,
- } = status
- else {
- return Ok(());
- };
-
- let Some(reason) =
- remote_server_restart_reason(version.as_deref(), protocol, remote_binary_changed)
- else {
- return Ok(());
- };
-
- if live_handoff_enabled && live_handoff {
- match live_handoff_remote_server(target, remote_herdr) {
- Ok(()) => return Ok(()),
- Err(err) => {
- eprintln!("remote live handoff failed: {err}");
- eprintln!("falling back to remote server restart.");
- }
- }
- }
-
- if stop_after_install_approved {
- stop_remote_server(target, remote_herdr)?;
- return Ok(());
- }
-
- if confirm_remote_server_stop(target, version.as_deref(), protocol, reason)? {
- stop_remote_server(target, remote_herdr)?;
- }
- Ok(())
-}
-
-fn remote_server_restart_reason(
- version: Option<&str>,
- protocol: Option<u32>,
- remote_binary_changed: bool,
-) -> Option<RemoteServerRestartReason> {
- if protocol != Some(CURRENT_PROTOCOL) {
- return Some(RemoteServerRestartReason::ProtocolMismatch);
- }
- if remote_binary_changed {
- return Some(RemoteServerRestartReason::BinaryUpdated);
- }
- if version != Some(current_version().as_str()) {
- return Some(RemoteServerRestartReason::VersionMismatch);
- }
- None
-}
-
-fn confirm_remote_install_with_running_server(
- target: &str,
- remote_herdr: &RemoteHerdr,
- live_handoff_enabled: bool,
-) -> io::Result<bool> {
- let status = match remote_server_status(target, remote_herdr) {
- Ok(status) => status,
- Err(err) => {
- if !io::stdin().is_terminal() {
- return Err(io::Error::other(format!(
- "could not inspect the running remote herdr server on {target} before installing: {err}; run from an interactive terminal to approve updating the remote binary"
- )));
- }
- eprintln!(
- "could not inspect the running remote herdr server on {target} before installing: {err}"
- );
- eprint!("continue installing the remote herdr binary? [y/N] ");
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer != "y" && answer != "yes" {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr install cancelled",
- ));
- }
- return Ok(false);
- }
- };
- let RemoteServerStatus::Running {
- version,
- protocol: _,
- live_handoff,
- } = status
- else {
- return Ok(false);
- };
- if !io::stdin().is_terminal() {
- if live_handoff_enabled && live_handoff {
- return Ok(false);
- }
- return Err(io::Error::other(format!(
- "remote herdr server on {target} is running v{}; run from an interactive terminal to approve stopping it for the update",
- version_label(version.as_deref())
- )));
- }
-
- if live_handoff_enabled && live_handoff {
- eprintln!("remote herdr server on {target} is currently running:");
- eprintln!(" server: v{}", version_label(version.as_deref()));
- eprintln!(
- "Herdr will install {} and hand off live pane processes to the prepared server.",
- current_version()
- );
- return Ok(false);
- }
-
- eprintln!("remote herdr server on {target} is currently running:");
- eprintln!(" server: v{}", version_label(version.as_deref()));
- eprintln!(
- "To complete the remote update, Herdr must stop the running remote server after installing."
- );
- eprintln!("This stops active remote pane processes, including shells, dev servers, and tests.");
- eprintln!();
- eprint!(
- "Install {} and stop the remote server now? [y/N] ",
- current_version()
- );
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer != "y" && answer != "yes" {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr install cancelled",
- ));
- }
-
- Ok(true)
-}
-
-fn remote_server_status(
- target: &str,
- remote_herdr: &RemoteHerdr,
-) -> io::Result<RemoteServerStatus> {
- let command = format!("{} status server --json", remote_herdr.shell_path);
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Err(command_failed("remote server status failed", &output));
- }
-
- let stdout = String::from_utf8_lossy(&output.stdout);
- parse_remote_server_status_json(stdout.trim())
-}
-
-#[derive(Debug, Deserialize)]
-struct RemoteClientStatusJson {
- protocol: u32,
-}
-
-#[derive(Debug, Deserialize)]
-struct RemoteServerStatusJson {
- running: bool,
- version: Option<String>,
- protocol: Option<u32>,
- capabilities: Option<RemoteServerCapabilitiesJson>,
-}
-
-#[derive(Debug, Deserialize)]
-struct RemoteServerCapabilitiesJson {
- live_handoff: bool,
-}
-
-fn parse_client_status_json(status: &str) -> Option<RemoteClientStatusJson> {
- serde_json::from_str(status).ok()
-}
-
-fn parse_remote_server_status_json(status: &str) -> io::Result<RemoteServerStatus> {
- let parsed: RemoteServerStatusJson = serde_json::from_str(status).map_err(|err| {
- io::Error::other(format!(
- "could not parse remote server status JSON from `{status}`: {err}"
- ))
- })?;
- if !parsed.running {
- return Ok(RemoteServerStatus::NotRunning);
- }
-
- Ok(RemoteServerStatus::Running {
- version: parsed.version,
- protocol: parsed.protocol,
- live_handoff: parsed
- .capabilities
- .is_some_and(|capabilities| capabilities.live_handoff),
- })
-}
-
-fn confirm_remote_server_stop(
- target: &str,
- version: Option<&str>,
- _protocol: Option<u32>,
- reason: RemoteServerRestartReason,
-) -> io::Result<bool> {
- if !io::stdin().is_terminal() {
- if reason == RemoteServerRestartReason::ProtocolMismatch {
- return Err(io::Error::other(format!(
- "remote herdr server on {target} must stop before this client can attach; run from an interactive terminal to approve stopping it"
- )));
- }
-
- eprintln!(
- "remote herdr server on {target} is still running v{}; it will use {} after it restarts.",
- version_label(version),
- current_version()
- );
- return Ok(false);
- }
-
- eprintln!("remote herdr server on {target} is currently running:");
- eprintln!(" server: v{}", version_label(version));
- eprintln!(" prepared binary: {}", current_version());
- eprintln!();
-
- match reason {
- RemoteServerRestartReason::ProtocolMismatch => {
- eprintln!("the remote server must stop before this client can attach.");
- }
- RemoteServerRestartReason::BinaryUpdated => {
- eprintln!(
- "the remote herdr binary was installed or replaced. restart the remote server so it uses the prepared binary."
- );
- }
- RemoteServerRestartReason::VersionMismatch => {
- eprintln!(
- "the remote server is still running a different herdr version. restart it so it uses the prepared binary."
- );
- }
- }
-
- let prompt = if reason == RemoteServerRestartReason::ProtocolMismatch {
- "stop the remote server and continue attaching? [Y/n] "
- } else {
- "restart the remote server now? [y/N] "
- };
- eprint!("{prompt}");
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer == "y" || answer == "yes" {
- return Ok(true);
- }
- if answer.is_empty() && reason == RemoteServerRestartReason::ProtocolMismatch {
- return Ok(true);
- }
- if reason == RemoteServerRestartReason::ProtocolMismatch {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr server stop cancelled",
- ));
- }
-
- Ok(false)
-}
-
-fn live_handoff_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let command = format!(
- "{} server live-handoff --import-exe {} --expected-protocol {} --expected-version {}",
- remote_herdr.shell_path,
- remote_herdr.shell_path,
- CURRENT_PROTOCOL,
- current_version()
- );
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Err(command_failed("remote server live handoff failed", &output));
- }
-
- eprintln!(
- "handed off the remote herdr server on {target}; reconnecting to the prepared server."
- );
- Ok(())
-}
-
-fn stop_remote_server(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let command = format!("{} server stop", remote_herdr.shell_path);
- let output = ssh_sh_output(target, &command)?;
- if !output.status.success() {
- return Err(command_failed("remote server stop failed", &output));
- }
-
- wait_for_remote_server_shutdown(target, remote_herdr)?;
- eprintln!("stopped the remote herdr server on {target}; it will restart when the remote client bridge attaches.");
- Ok(())
-}
-
-fn wait_for_remote_server_shutdown(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<()> {
- let deadline = Instant::now() + REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT;
- loop {
- if remote_server_status(target, remote_herdr)? == RemoteServerStatus::NotRunning {
- return Ok(());
- }
- if Instant::now() >= deadline {
- return Err(io::Error::new(
- io::ErrorKind::TimedOut,
- format!(
- "shutdown was requested, but the old remote herdr server on {target} is still responding after {} seconds",
- REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT.as_secs()
- ),
- ));
- }
- thread::sleep(REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL);
- }
-}
-
-fn version_label(version: Option<&str>) -> &str {
- version.unwrap_or("unknown")
-}
-
-fn warn_if_remote_bin_not_on_path(target: &str) -> io::Result<()> {
- let output = ssh_user_shell_output(target, "command -v herdr")?;
- if output.status.success()
- && remote_shell_resolves_managed_install(&String::from_utf8_lossy(&output.stdout))
- {
- return Ok(());
- }
-
- eprintln!(
- "herdr: installed remote binary to ~/.local/bin/herdr, but the remote shell does not resolve `herdr` to that path"
- );
- Ok(())
-}
-
-fn remote_shell_resolves_managed_install(stdout: &str) -> bool {
- stdout
- .lines()
- .next()
- .map(str::trim)
- .is_some_and(|path| path.ends_with("/.local/bin/herdr"))
-}
-
-fn download_release_asset(platform: &RemotePlatform) -> io::Result<InstallSource> {
- let asset_key = platform.asset_key();
- let asset = remote_release_asset(&asset_key)?;
-
- let dir = private_download_dir(&asset_key)?;
- let path = dir.join("herdr.tmp");
- let status = Command::new("curl")
- .args(["-sfL", "--max-time", "120", "-o"])
- .arg(&path)
- .arg(&asset.url)
- .status()
- .map_err(|err| io::Error::new(err.kind(), format!("download failed: {err}")))?;
- if !status.success() {
- let _ = fs::remove_dir_all(&dir);
- return Err(io::Error::other("download failed"));
- }
- if let Some(expected) = &asset.sha256 {
- if let Err(err) = crate::checksum::verify_sha256(&path, expected) {
- let _ = fs::remove_dir_all(&dir);
- return Err(io::Error::new(
- err.kind(),
- format!("downloaded remote asset checksum verification failed: {err}"),
- ));
- }
- }
-
- Ok(InstallSource::temporary(path, dir))
-}
-
-fn fetch_remote_manifest(url: &str) -> io::Result<Vec<u8>> {
- let output = Command::new("curl")
- .args([
- "-sfL",
- "--retry",
- "3",
- "--connect-timeout",
- "10",
- "--max-time",
- "20",
- url,
- ])
- .output()
- .map_err(|err| io::Error::new(err.kind(), format!("curl failed: {err}")))?;
- if !output.status.success() {
- return Err(command_failed("failed to fetch update manifest", &output));
- }
- Ok(output.stdout)
-}
-
-fn remote_asset_info(asset: &RemoteAssetRef) -> RemoteReleaseAsset {
- RemoteReleaseAsset {
- url: asset.url().to_string(),
- sha256: asset.sha256().map(str::to_string),
- }
-}
-
-fn preview_assets_for_build<'a>(
- manifest: &'a RemotePreviewManifest,
- build_id: &str,
-) -> io::Result<(u32, &'a BTreeMap<String, RemoteAssetRef>)> {
- if manifest.build_id == build_id {
- return Ok((manifest.protocol, &manifest.assets));
- }
- let build = manifest.builds.get(build_id).ok_or_else(|| {
- io::Error::other(format!(
- "preview manifest no longer includes build {build_id}; run `herdr update` locally or set {REMOTE_BINARY_ENV_VAR}=target/release/herdr"
- ))
- })?;
- Ok((build.protocol, &build.assets))
-}
-
-fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> {
- if crate::build_info::is_preview() {
- let build_id = crate::build_info::build_id().ok_or_else(|| {
- io::Error::other("preview client has no build id; set HERDR_REMOTE_BINARY or install Herdr on the remote manually")
- })?;
- let manifest_bytes = fetch_remote_manifest(PREVIEW_UPDATE_MANIFEST_URL)?;
- let manifest: RemotePreviewManifest =
- serde_json::from_slice(&manifest_bytes).map_err(|err| {
- io::Error::other(format!("failed to parse preview manifest JSON: {err}"))
- })?;
- let (protocol, assets) = preview_assets_for_build(&manifest, build_id)?;
- if protocol != CURRENT_PROTOCOL {
- return Err(io::Error::other(format!(
- "preview manifest has build {build_id} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching Herdr on the remote host manually"
- )));
- }
- return assets.get(asset_key).map(remote_asset_info).ok_or_else(|| {
- io::Error::other(format!(
- "no {asset_key} binary in the preview manifest for build {build_id}"
- ))
- });
- }
-
- let current_version = current_version();
- let manifest_bytes = fetch_remote_manifest(STABLE_UPDATE_MANIFEST_URL)?;
- let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_bytes)
- .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?;
- let release = manifest.release_for_version(&current_version).ok_or_else(|| {
- io::Error::other(format!(
- "release manifest does not include herdr {current_version}; build herdr for {} or install it there manually",
- asset_key
- ))
- })?;
- if let Some(protocol) = release.protocol {
- if protocol != CURRENT_PROTOCOL {
- return Err(io::Error::other(format!(
- "release manifest has herdr {current_version} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually"
- )));
- }
- }
- release
- .assets
- .get(asset_key)
- .map(remote_asset_info)
- .ok_or_else(|| {
- io::Error::other(format!(
- "no {asset_key} binary in the release manifest for herdr {current_version}"
- ))
- })
-}
-
-fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> {
- let base = std::env::temp_dir();
- for attempt in 0..100 {
- let dir = base.join(format!(
- "herdr-remote-{}-{}-{attempt}",
- std::process::id(),
- asset_key
- ));
- match fs::create_dir(&dir) {
- Ok(()) => return Ok(dir),
- Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
- Err(err) => return Err(err),
- }
- }
-
- Err(io::Error::new(
- io::ErrorKind::AlreadyExists,
- "failed to create private herdr remote download directory",
+#[cfg(windows)]
+pub(crate) fn run_remote(_remote: RemoteLaunch) -> std::io::Result<()> {
+ debug_assert!(!crate::platform::capabilities().remote_attach);
+ Err(std::io::Error::other(
+ "remote mode is not supported on Windows yet",
))
}
-fn confirm_remote_install(
- target: &str,
- remote_herdr: &RemoteHerdr,
- source_description: &str,
-) -> io::Result<()> {
- if !io::stdin().is_terminal() {
- return Err(io::Error::other(format!(
- "matching remote herdr {} is not installed at {}; run from an interactive terminal to approve installation",
- current_version(),
- remote_herdr.shell_path
- )));
- }
-
- eprintln!(
- "matching herdr {} is not installed on {target} for {}.",
- current_version(),
- remote_herdr.platform.asset_key()
- );
- eprint!(
- "Install {} to {}? [Y/n] ",
- source_description, remote_herdr.shell_path
- );
- io::stderr().flush()?;
-
- let mut answer = String::new();
- io::stdin().read_line(&mut answer)?;
- let answer = answer.trim().to_ascii_lowercase();
- if answer == "n" || answer == "no" {
- return Err(io::Error::new(
- io::ErrorKind::Interrupted,
- "remote herdr installation cancelled",
- ));
- }
-
- Ok(())
-}
-
-fn install_remote_herdr(
- target: &str,
- remote_herdr: &RemoteHerdr,
- source_path: &Path,
-) -> io::Result<()> {
- let script = format!(
- r#"dest="$HOME/{install_suffix}"
-dir="${{dest%/*}}"
-mkdir -p "$dir"
-tmp="${{dest}}.tmp.$$"
-cat > "$tmp"
-chmod 755 "$tmp"
-mv "$tmp" "$dest"
-"#,
- install_suffix = remote_herdr.install_suffix
- );
-
- let mut child = Command::new("ssh")
- .arg("-T")
- .arg(target)
- .arg(format!("/bin/sh -eu -c {}", shell_quote(&script)))
- .stdin(Stdio::piped())
- .stdout(Stdio::inherit())
- .stderr(Stdio::inherit())
- .spawn()
- .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh install: {err}")))?;
-
- let mut source = File::open(source_path)?;
- let copy_result = if let Some(mut stdin) = child.stdin.take() {
- io::copy(&mut source, &mut stdin).map(|_| ())
- } else {
- Err(io::Error::new(
- io::ErrorKind::BrokenPipe,
- "ssh install stdin missing",
- ))
- };
- let status = child.wait()?;
- copy_result?;
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::other(format!(
- "remote install exited with {status}"
- )))
- }
-}
-
-fn ssh_sh_output(target: &str, script: &str) -> io::Result<Output> {
- // Feed POSIX bootstrap scripts to /bin/sh so the user's login shell only
- // has to parse a simple executable invocation.
- let mut child = Command::new("ssh")
- .arg("-T")
- .arg(target)
- .arg("/bin/sh -s")
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
- .spawn()?;
-
- let write_result = if let Some(mut stdin) = child.stdin.take() {
- stdin.write_all(script.as_bytes())
- } else {
- Err(io::Error::new(
- io::ErrorKind::BrokenPipe,
- "ssh bootstrap stdin missing",
- ))
- };
- let output = child.wait_with_output()?;
- write_result?;
- Ok(output)
-}
-
-fn ssh_user_shell_output(target: &str, command: &str) -> io::Result<Output> {
- Command::new("ssh")
- .arg("-T")
- .arg(target)
- .arg(command)
- .output()
-}
-
-fn remote_bridge_command(remote_herdr: &RemoteHerdr, session_name: &str) -> String {
- let mut command = format!("exec {}", remote_herdr.shell_path);
- if session_name != crate::session::DEFAULT_SESSION_NAME {
- command.push_str(" --session ");
- command.push_str(&shell_quote(session_name));
- }
- command.push_str(" remote-client-bridge");
- command
-}
-
-fn reattach_command(
- program: &str,
- target: &str,
- session_name: &str,
- keybindings: RemoteKeybindings,
- live_handoff: bool,
-) -> String {
- let program = if program.is_empty() { "herdr" } else { program };
- let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target));
- if keybindings != RemoteKeybindings::Local {
- command.push_str(" --remote-keybindings ");
- command.push_str(keybindings.as_str());
- }
- if live_handoff {
- command.push_str(" --handoff");
- }
- if session_name != crate::session::DEFAULT_SESSION_NAME {
- command.push_str(" --session ");
- command.push_str(&shell_quote(session_name));
- }
- command
-}
-
-fn shell_quote(value: &str) -> String {
- if !value.is_empty()
- && value.chars().all(|ch| {
- ch.is_ascii_alphanumeric()
- || matches!(
- ch,
- '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-'
- )
- })
- {
- return value.to_string();
- }
-
- format!("'{}'", value.replace('\'', "'\\''"))
-}
-
-fn command_failed(context: &str, output: &Output) -> io::Error {
- let stderr = String::from_utf8_lossy(&output.stderr);
- let stderr = stderr.trim();
- if stderr.is_empty() {
- io::Error::other(format!("{context}: {}", output.status))
- } else {
- io::Error::other(format!("{context}: {stderr}"))
- }
-}
-
-struct SshStdioBridge {
- local_socket: PathBuf,
- keepalive_ssh_config: Option<PathBuf>,
- should_stop: Arc<AtomicBool>,
- thread: Option<JoinHandle<()>>,
-}
-
-impl SshStdioBridge {
- fn start(
- target: String,
- remote_herdr: RemoteHerdr,
- local_socket: PathBuf,
- session_name: String,
- manage_ssh_config: bool,
- ) -> io::Result<Self> {
- let _ = std::fs::remove_file(&local_socket);
- let listener = UnixListener::bind(&local_socket)?;
- crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?;
- listener.set_nonblocking(true)?;
-
- let keepalive_ssh_config = if manage_ssh_config {
- write_keepalive_ssh_config()
- .inspect_err(|err| {
- tracing::debug!(%err, "could not write ssh keepalive config; using plain ssh");
- })
- .ok()
- } else {
- None
- };
-
- let should_stop = Arc::new(AtomicBool::new(false));
- let thread_stop = Arc::clone(&should_stop);
- let thread_ssh_config = keepalive_ssh_config.clone();
- let thread = thread::spawn(move || {
- while !thread_stop.load(Ordering::Acquire) {
- match listener.accept() {
- Ok((stream, _addr)) => {
- if let Err(err) = stream.set_nonblocking(false) {
- eprintln!(
- "herdr: remote bridge failed to prepare client socket: {err}"
- );
- continue;
- }
- if let Err(err) = bridge_connection(
- stream,
- &target,
- &remote_herdr,
- &session_name,
- thread_ssh_config.as_deref(),
- ) {
- eprintln!("herdr: remote bridge failed: {err}");
- }
- }
- Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
- thread::sleep(BRIDGE_ACCEPT_POLL);
- }
- Err(err) => {
- eprintln!("herdr: remote bridge listener failed: {err}");
- break;
- }
- }
- }
- });
-
- Ok(Self {
- local_socket,
- keepalive_ssh_config,
- should_stop,
- thread: Some(thread),
- })
- }
-}
-
-impl Drop for SshStdioBridge {
- fn drop(&mut self) {
- self.should_stop.store(true, Ordering::Release);
- let _ = std::fs::remove_file(&self.local_socket);
- if let Some(thread) = self.thread.take() {
- let _ = thread.join();
- }
- // Remove the generated ssh config only after the bridge thread has
- // joined, so it can never start a connection with a config path that
- // was just deleted.
- if let Some(dir) = self.keepalive_ssh_config.as_deref().and_then(Path::parent) {
- let _ = std::fs::remove_dir_all(dir);
- }
- }
-}
-
-/// Creates a fresh user-only (`0700`) directory under the temp dir for the
-/// bridge's generated ssh config, returning its path.
-///
-/// Using a private directory created with fail-if-exists semantics — rather
-/// than a predictable file in the world-writable temp dir — stops a local user
-/// from pre-planting a symlink or world-writable file that herdr would write
-/// and `ssh -F` would then read.
-fn private_ssh_config_dir() -> io::Result<PathBuf> {
- use std::os::unix::fs::DirBuilderExt;
-
- let base = std::env::temp_dir();
- for attempt in 0..100 {
- let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id()));
- match fs::DirBuilder::new().mode(0o700).create(&dir) {
- Ok(()) => return Ok(dir),
- Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
- Err(err) => return Err(err),
- }
- }
-
- Err(io::Error::new(
- io::ErrorKind::AlreadyExists,
- "failed to create private herdr ssh config directory",
+#[cfg(windows)]
+pub(crate) fn run_remote_client_bridge() -> std::io::Result<()> {
+ debug_assert!(!crate::platform::capabilities().remote_attach);
+ Err(std::io::Error::other(
+ "remote client bridge is not supported on Windows yet",
))
}
-
-/// Quotes a path for an ssh_config `Include` so a path containing spaces (or
-/// glob metacharacters) is treated as one literal token instead of being split
-/// or expanded by ssh — otherwise the user's config might not be Included and
-/// herdr's fallback would wrongly take effect.
-fn ssh_config_quote(path: &str) -> String {
- format!("\"{path}\"")
-}
-
-/// Builds a temporary ssh config that keeps the bridge tunnel alive without
-/// overriding the user's own settings, returning its path.
-///
-/// The file `Include`s the user's real ssh config first, so ssh's
-/// first-value-wins rule keeps any `ServerAlive*` the user set there (including
-/// an explicit `0` to disable it); herdr's values apply only when the user has
-/// none.
-fn write_keepalive_ssh_config() -> io::Result<PathBuf> {
- use std::os::unix::fs::OpenOptionsExt;
-
- let path = private_ssh_config_dir()?.join("config");
-
- let mut contents = String::new();
- if let Some(home) = std::env::var_os("HOME") {
- let user_config = PathBuf::from(home).join(".ssh").join("config");
- if user_config.is_file() {
- contents.push_str(&format!(
- "Include {}\n",
- ssh_config_quote(&user_config.to_string_lossy())
- ));
- }
- }
- if Path::new("/etc/ssh/ssh_config").is_file() {
- contents.push_str("Include /etc/ssh/ssh_config\n");
- }
- contents.push_str("Host *\n");
- contents.push_str(" ServerAliveInterval 15\n");
- contents.push_str(" ServerAliveCountMax 4\n");
-
- let mut file = fs::OpenOptions::new()
- .write(true)
- .create_new(true)
- .mode(BRIDGE_SOCKET_PERMISSION_MODE)
- .open(&path)?;
- file.write_all(contents.as_bytes())?;
- Ok(path)
-}
-
-fn bridge_connection(
- stream: UnixStream,
- target: &str,
- remote_herdr: &RemoteHerdr,
- session_name: &str,
- keepalive_ssh_config: Option<&Path>,
-) -> io::Result<()> {
- let mut command = Command::new("ssh");
- // Use the generated keepalive ssh config when present; otherwise plain ssh.
- if let Some(ssh_config) = keepalive_ssh_config {
- command.arg("-F").arg(ssh_config);
- }
- command
- .arg("-T")
- .arg(target)
- .arg(remote_bridge_command(remote_herdr, session_name));
- command
- .stdin(Stdio::piped())
- .stdout(Stdio::piped())
- .stderr(Stdio::inherit());
-
- let mut child = command
- .spawn()
- .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?;
- let mut child_stdin = child
- .stdin
- .take()
- .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdin missing"))?;
- let mut child_stdout = child
- .stdout
- .take()
- .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "ssh bridge stdout missing"))?;
- let mut stream_to_child = stream.try_clone()?;
- let mut child_to_stream = stream;
-
- let upload = thread::spawn(move || {
- let _ = copy_flush(&mut stream_to_child, &mut child_stdin);
- });
- let download = thread::spawn(move || {
- let _ = copy_flush(&mut child_stdout, &mut child_to_stream);
- let _ = child_to_stream.shutdown(std::net::Shutdown::Write);
- });
-
- let status = child.wait()?;
- let _ = upload.join();
- let _ = download.join();
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::new(
- io::ErrorKind::ConnectionAborted,
- format!("ssh bridge exited with {status}"),
- ))
- }
-}
-
-fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> {
- let mut buffer = [0_u8; 16 * 1024];
- let mut total = 0;
-
- loop {
- let bytes_read = match reader.read(&mut buffer) {
- Ok(0) => return Ok(total),
- Ok(bytes_read) => bytes_read,
- Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
- Err(err) => return Err(err),
- };
-
- writer.write_all(&buffer[..bytes_read])?;
- writer.flush()?;
- total += bytes_read as u64;
- }
-}
-
-fn run_client_process(
- local_socket: &Path,
- reattach_command: &str,
- keybindings: RemoteKeybindings,
-) -> io::Result<()> {
- let exe = std::env::current_exe()?;
- let status = Command::new(exe)
- .arg("client")
- .env(
- crate::server::socket_paths::CLIENT_SOCKET_PATH_ENV_VAR,
- local_socket,
- )
- .env("HERDR_RENDER_ENCODING", "terminal-ansi")
- .env(REATTACH_COMMAND_ENV_VAR, reattach_command)
- .env(REMOTE_KEYBINDINGS_ENV_VAR, keybindings.as_str())
- .env_remove(crate::api::SOCKET_PATH_ENV_VAR)
- .stdin(Stdio::inherit())
- .stdout(Stdio::inherit())
- .stderr(Stdio::inherit())
- .status()?;
-
- if status.success() {
- Ok(())
- } else {
- Err(io::Error::new(
- io::ErrorKind::Interrupted,
- format!("remote client exited with {status}"),
- ))
- }
-}
-
-fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf {
- let pid = std::process::id();
- let target_clean = sanitize_path_component(target);
- let session_clean = sanitize_path_component(session_name);
-
- let tmpdir = std::env::temp_dir();
- let readable = tmpdir.join(format!(
- "herdr-remote-{pid}-{target_clean}-{session_clean}.sock"
- ));
- if fits_unix_socket_path(&readable) {
- return readable;
- }
-
- // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the
- // readable name past sun_path's 104-byte ceiling. Fall back to a hashed
- // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself
- // is longer than the budget. The hash covers the full unsanitized
- // target/session so uniqueness does not depend on the prefix truncation;
- // the prefix is kept only for debuggability.
- let target_prefix: String = target_clean.chars().take(8).collect();
- let hash = short_socket_hash(target, session_name);
- let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock");
- let short_in_tmp = tmpdir.join(&short_name);
- if fits_unix_socket_path(&short_in_tmp) {
- return short_in_tmp;
- }
- PathBuf::from("/tmp").join(short_name)
-}
-
-fn fits_unix_socket_path(path: &Path) -> bool {
- use std::os::unix::ffi::OsStrExt;
- // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve
- // 1 byte for the trailing NUL and use the smaller cap for portability.
- const MAX: usize = 103;
- path.as_os_str().as_bytes().len() <= MAX
-}
-
-fn short_socket_hash(target: &str, session: &str) -> String {
- use std::collections::hash_map::DefaultHasher;
- use std::hash::{Hash, Hasher};
- let mut hasher = DefaultHasher::new();
- target.hash(&mut hasher);
- 0u8.hash(&mut hasher);
- session.hash(&mut hasher);
- format!("{:016x}", hasher.finish())
-}
-
-fn sanitize_path_component(input: &str) -> String {
- let sanitized: String = input
- .chars()
- .map(|ch| {
- if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-') {
- ch
- } else {
- '-'
- }
- })
- .collect();
-
- sanitized.trim_matches('-').chars().take(32).collect()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn bridge_socket_is_user_only() {
- use std::os::unix::fs::PermissionsExt;
-
- let socket = std::env::temp_dir().join(format!(
- "herdr-bridge-permissions-test-{}.sock",
- std::process::id()
- ));
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let bridge = SshStdioBridge::start(
- "example".to_string(),
- remote_herdr,
- socket.clone(),
- "default".to_string(),
- false,
- )
- .expect("start bridge listener");
-
- let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
- assert_eq!(mode, BRIDGE_SOCKET_PERMISSION_MODE);
-
- drop(bridge);
- let _ = std::fs::remove_file(socket);
- }
-
- #[test]
- fn keepalive_ssh_config_includes_user_config_then_fallback() {
- use std::os::unix::fs::PermissionsExt;
-
- let path = write_keepalive_ssh_config().expect("write keepalive config");
- let contents = std::fs::read_to_string(&path).expect("read keepalive config");
-
- // herdr's fallback keepalive is present...
- assert!(
- contents.contains("Host *"),
- "config should add a Host * fallback block: {contents}"
- );
- assert!(
- contents.contains("ServerAliveInterval 15"),
- "config should set the keepalive interval: {contents}"
- );
- assert!(
- contents.contains("ServerAliveCountMax 4"),
- "config should set the keepalive count: {contents}"
- );
- // ...and any user config is Included (quoted) BEFORE it so first-value-wins
- // keeps the user's own settings.
- if let Some(home) = std::env::var_os("HOME") {
- let user_config = PathBuf::from(home).join(".ssh").join("config");
- if user_config.is_file() {
- let include = format!(
- "Include {}",
- ssh_config_quote(&user_config.to_string_lossy())
- );
- let include_at = contents.find(&include).expect("user config Included");
- let fallback_at = contents.find("Host *").expect("fallback present");
- assert!(
- include_at < fallback_at,
- "user config must be Included before herdr's fallback: {contents}"
- );
- }
- }
-
- let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
- assert_eq!(
- mode, BRIDGE_SOCKET_PERMISSION_MODE,
- "keepalive config must be user-only"
- );
- // The config lives in a private 0700 dir, not a predictable temp path.
- let dir = path.parent().expect("config has a parent dir");
- let dir_mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
- assert_eq!(dir_mode, 0o700, "ssh config dir must be user-only");
-
- let _ = std::fs::remove_dir_all(dir);
- }
-
- #[test]
- fn ssh_config_quote_wraps_path_with_spaces() {
- assert_eq!(
- ssh_config_quote("/home/a b/.ssh/config"),
- "\"/home/a b/.ssh/config\""
- );
- }
-
- #[test]
- fn extract_remote_args_removes_space_form() {
- let args = vec![
- "herdr".into(),
- "--remote".into(),
- "dev".into(),
- "--help".into(),
- ];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr", "--help"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "dev");
- assert_eq!(remote.keybindings, RemoteKeybindings::Local);
- }
-
- #[test]
- fn extract_remote_args_removes_equals_form() {
- let args = vec!["herdr".into(), "--remote=user@host".into()];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "user@host");
- assert_eq!(remote.keybindings, RemoteKeybindings::Local);
- }
-
- #[test]
- fn extract_remote_args_accepts_remote_keybindings_server() {
- let args = vec![
- "herdr".into(),
- "--remote".into(),
- "dev".into(),
- "--remote-keybindings=server".into(),
- ];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "dev");
- assert_eq!(remote.keybindings, RemoteKeybindings::Server);
- }
-
- #[test]
- fn extract_remote_args_accepts_remote_keybindings_space_form() {
- let args = vec![
- "herdr".into(),
- "--remote=dev".into(),
- "--remote-keybindings".into(),
- "server".into(),
- ];
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
- assert_eq!(cleaned, vec!["herdr"]);
- assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server);
- }
-
- #[test]
- fn extract_remote_args_accepts_explicit_handoff() {
- let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()];
-
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
-
- assert_eq!(cleaned, vec!["herdr"]);
- let remote = remote.unwrap();
- assert_eq!(remote.target, "dev");
- assert!(remote.live_handoff);
- }
-
- #[test]
- fn extract_remote_args_preserves_child_remote_options_after_separator() {
- let args = vec![
- "herdr".into(),
- "agent".into(),
- "start".into(),
- "repro".into(),
- "--".into(),
- "child".into(),
- "--remote".into(),
- "dev".into(),
- "--remote-keybindings=server".into(),
- "--handoff".into(),
- ];
-
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
-
- assert_eq!(cleaned, args);
- assert!(remote.is_none());
- }
-
- #[test]
- fn extract_remote_args_preserves_handoff_without_remote() {
- let args = vec!["herdr".into(), "update".into(), "--handoff".into()];
-
- let (cleaned, remote) = extract_remote_args(&args).unwrap();
-
- assert_eq!(cleaned, args);
- assert!(remote.is_none());
- }
-
- #[test]
- fn extract_remote_args_rejects_remote_keybindings_without_remote() {
- let args = vec!["herdr".into(), "--remote-keybindings=server".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote-keybindings requires --remote");
- }
-
- #[test]
- fn extract_remote_args_rejects_duplicate_remote_keybindings() {
- let args = vec![
- "herdr".into(),
- "--remote=dev".into(),
- "--remote-keybindings=local".into(),
- "--remote-keybindings=server".into(),
- ];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote-keybindings can only be specified once");
- }
-
- #[test]
- fn extract_remote_args_requires_value() {
- let args = vec!["herdr".into(), "--remote".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "missing value for --remote");
- }
-
- #[test]
- fn extract_remote_args_rejects_empty_value() {
- let args = vec!["herdr".into(), "--remote=".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "missing value for --remote");
- }
-
- #[test]
- fn extract_remote_args_rejects_duplicate_values() {
- let args = vec![
- "herdr".into(),
- "--remote=dev".into(),
- "--remote=prod".into(),
- ];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote can only be specified once");
- }
-
- #[test]
- fn extract_remote_args_rejects_option_like_target() {
- let args = vec!["herdr".into(), "--remote".into(), "-oProxyCommand=x".into()];
- let err = extract_remote_args(&args).unwrap_err();
- assert_eq!(err, "--remote target must not start with '-'");
- }
-
- #[test]
- fn sanitize_path_component_removes_shell_sensitive_chars() {
- assert_eq!(sanitize_path_component("user@host:22"), "user-host-22");
- }
-
- #[test]
- fn remote_platform_maps_uname_values() {
- assert_eq!(
- RemotePlatform::from_uname("Linux", "amd64")
- .unwrap()
- .asset_key(),
- "linux-x86_64"
- );
- assert_eq!(
- RemotePlatform::from_uname("Darwin", "arm64")
- .unwrap()
- .asset_key(),
- "macos-aarch64"
- );
- assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none());
- }
-
- #[test]
- fn reattach_command_includes_remote_and_session() {
- assert_eq!(
- reattach_command(
- "target/release/herdr",
- "user@host",
- "work",
- RemoteKeybindings::Local,
- false,
- ),
- "target/release/herdr --remote user@host --session work"
- );
- assert_eq!(
- reattach_command(
- "herdr",
- "host name",
- crate::session::DEFAULT_SESSION_NAME,
- RemoteKeybindings::Local,
- false,
- ),
- "herdr --remote 'host name'"
- );
- assert_eq!(
- reattach_command(
- "herdr",
- "host",
- crate::session::DEFAULT_SESSION_NAME,
- RemoteKeybindings::Server,
- false,
- ),
- "herdr --remote host --remote-keybindings server"
- );
- assert_eq!(
- reattach_command(
- "herdr",
- "host",
- crate::session::DEFAULT_SESSION_NAME,
- RemoteKeybindings::Local,
- true,
- ),
- "herdr --remote host --handoff"
- );
- }
-
- #[test]
- fn remote_bridge_command_uses_installed_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec \"$HOME/.local/bin/herdr\" remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_uses_path_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "/usr/bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec /usr/bin/herdr remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_quotes_discovered_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr =
- remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec '/opt/herdr bin/herdr' remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_uses_macos_path_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "macos",
- arch: "aarch64",
- });
- let remote_herdr =
- remote_herdr_from_path_discovery(&remote_herdr, "/opt/homebrew/bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec /opt/homebrew/bin/herdr remote-client-bridge"
- );
- assert_eq!(remote_herdr.platform.asset_key(), "macos-aarch64");
- }
-
- #[test]
- fn remote_path_discovery_quotes_single_quotes_in_discovered_binary() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr =
- remote_herdr_from_path_discovery(&remote_herdr, "/opt/herdr's/bin/herdr\n")
- .expect("path binary");
-
- assert_eq!(
- remote_bridge_command(&remote_herdr, crate::session::DEFAULT_SESSION_NAME),
- "exec '/opt/herdr'\\''s/bin/herdr' remote-client-bridge"
- );
- }
-
- #[test]
- fn remote_path_discovery_ignores_relative_paths() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "bin/herdr\n");
-
- assert!(remote_herdr.is_none());
- }
-
- #[test]
- fn remote_path_discovery_ignores_empty_output() {
- let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
- os: "linux",
- arch: "x86_64",
- });
- let remote_herdr = remote_herdr_from_path_discovery(&remote_herdr, "\n");
-
- assert!(remote_herdr.is_none());
- }
-
- #[test]
- fn remote_shell_path_warning_accepts_managed_install() {
- assert!(remote_shell_resolves_managed_install(
- "/home/can/.local/bin/herdr\n"
- ));
- assert!(remote_shell_resolves_managed_install(
- "/Users/can/.local/bin/herdr\n"
- ));
- assert!(!remote_shell_resolves_managed_install(
- "/usr/local/bin/herdr\n"
- ));
- assert!(!remote_shell_resolves_managed_install(""));
- }
-
- #[test]
- fn parse_client_status_json_reads_protocol() {
- assert_eq!(
- parse_client_status_json(r#"{"version":"x","protocol":8,"binary":"/bin/herdr"}"#)
- .map(|status| status.protocol),
- Some(8)
- );
- assert!(parse_client_status_json(r#"{"protocol":"unknown"}"#).is_none());
- }
-
- #[test]
- fn parse_remote_server_status_json_reads_running_server() {
- assert_eq!(
- parse_remote_server_status_json(
- r#"{"status":"running","running":true,"version":"0.6.0","protocol":8,"capabilities":{"live_handoff":true}}"#
- )
- .unwrap(),
- RemoteServerStatus::Running {
- version: Some("0.6.0".into()),
- protocol: Some(8),
- live_handoff: true
- }
- );
- }
-
- #[test]
- fn parse_remote_server_status_json_treats_missing_capability_as_no_handoff() {
- assert_eq!(
- parse_remote_server_status_json(
- r#"{"status":"running","running":true,"version":"0.6.0","protocol":8}"#
- )
- .unwrap(),
- RemoteServerStatus::Running {
- version: Some("0.6.0".into()),
- protocol: Some(8),
- live_handoff: false
- }
- );
- }
-
- #[test]
- fn parse_remote_server_status_json_reads_stopped_server() {
- assert_eq!(
- parse_remote_server_status_json(
- r#"{"status":"not_running","running":false,"version":null,"protocol":null}"#
- )
- .unwrap(),
- RemoteServerStatus::NotRunning
- );
- }
-
- #[test]
- fn remote_update_manifest_uses_root_assets_for_latest_version() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.3",
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.assets.get("linux-x86_64"))
- .map(RemoteAssetRef::url),
- Some("https://example.com/latest")
- );
- }
-
- #[test]
- fn remote_update_manifest_reads_archived_release_assets() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.4",
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "notes": "ignored",
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.assets.get("linux-x86_64"))
- .map(RemoteAssetRef::url),
- Some("https://example.com/archive")
- );
- }
-
- #[test]
- fn remote_update_manifest_uses_archived_release_protocol() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.4",
- "protocol": 42,
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "notes": "ignored",
- "protocol": 41,
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.protocol),
- Some(41)
- );
- }
-
- #[test]
- fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() {
- let manifest: RemoteUpdateManifest = serde_json::from_str(
- r#"{
- "version": "1.2.4",
- "protocol": 42,
- "assets": {
- "linux-x86_64": "https://example.com/latest"
- },
- "releases": {
- "1.2.3": {
- "notes": "ignored",
- "assets": {
- "linux-x86_64": "https://example.com/archive"
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- assert_eq!(
- manifest
- .release_for_version("1.2.3")
- .and_then(|release| release.protocol),
- None
- );
- }
-
- #[test]
- fn remote_preview_manifest_falls_back_to_archived_exact_build_assets() {
- let manifest: RemotePreviewManifest = serde_json::from_str(
- r#"{
- "build_id": "2026-06-06-new",
- "protocol": 12,
- "assets": {
- "linux-x86_64": {
- "url": "https://example.com/new",
- "sha256": "new"
- }
- },
- "builds": {
- "2026-06-02-old": {
- "protocol": 11,
- "assets": {
- "linux-x86_64": {
- "url": "https://example.com/old",
- "sha256": "old"
- }
- }
- }
- }
- }"#,
- )
- .unwrap();
-
- let (protocol, assets) =
- preview_assets_for_build(&manifest, "2026-06-02-old").expect("archived build");
- let asset = assets.get("linux-x86_64").expect("asset");
- assert_eq!(protocol, 11);
- assert_eq!(asset.url(), "https://example.com/old");
- assert_eq!(asset.sha256(), Some("old"));
- }
-
- #[test]
- fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() {
- assert_eq!(
- remote_server_restart_reason(Some(&current_version()), Some(0), false),
- Some(RemoteServerRestartReason::ProtocolMismatch)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_offers_restart_after_binary_update() {
- assert_eq!(
- remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), true),
- Some(RemoteServerRestartReason::BinaryUpdated)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_offers_restart_for_version_mismatch() {
- assert_eq!(
- remote_server_restart_reason(Some("0.0.0"), Some(CURRENT_PROTOCOL), false),
- Some(RemoteServerRestartReason::VersionMismatch)
- );
- assert_eq!(
- remote_server_restart_reason(None, Some(CURRENT_PROTOCOL), false),
- Some(RemoteServerRestartReason::VersionMismatch)
- );
- }
-
- #[test]
- fn remote_server_restart_reason_allows_current_server() {
- assert_eq!(
- remote_server_restart_reason(Some(&current_version()), Some(CURRENT_PROTOCOL), false),
- None
- );
- }
-
- #[test]
- fn install_source_description_uses_override_binary() {
- let platform = RemotePlatform {
- os: "linux",
- arch: "aarch64",
- };
- assert_eq!(
- install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false),
- "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)"
- );
- }
-
- #[test]
- fn install_source_description_uses_local_binary_when_allowed() {
- let platform = RemotePlatform::local();
-
- assert_eq!(
- install_source_description_for(&platform, None, true),
- "the current local herdr binary"
- );
- }
-
- #[test]
- fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() {
- let platform = RemotePlatform::local();
-
- assert_eq!(
- install_source_description_for(&platform, None, false),
- format!(
- "the {} {} asset for {}",
- current_version(),
- current_channel(),
- platform.asset_key()
- )
- );
- }
-
- #[test]
- fn resolve_install_source_uses_override_binary_without_temporary_cleanup() {
- let platform = RemotePlatform {
- os: "linux",
- arch: "aarch64",
- };
- let source = resolve_install_source(&platform, Some(PathBuf::from("/tmp/herdr-aarch64")))
- .expect("override source");
- assert_eq!(source.path, PathBuf::from("/tmp/herdr-aarch64"));
- assert!(source.temporary_dir.is_none());
- }
-
- fn remote_env_lock() -> &'static std::sync::Mutex<()> {
- static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
- LOCK.get_or_init(|| std::sync::Mutex::new(()))
- }
-
- fn socket_path_byte_len(path: &Path) -> usize {
- use std::os::unix::ffi::OsStrExt;
- path.as_os_str().as_bytes().len()
- }
-
- #[test]
- fn local_forward_socket_path_uses_readable_name_when_it_fits() {
- let _guard = remote_env_lock().lock().unwrap();
- // Short target + session leave plenty of room — keep the human-
- // readable form so the socket path stays grep-friendly.
- let path = local_forward_socket_path("dev", "default");
- let filename = path
- .file_name()
- .and_then(|s| s.to_str())
- .unwrap_or("")
- .to_string();
- assert!(
- filename.starts_with("herdr-remote-"),
- "expected readable name, got {filename}"
- );
- assert!(filename.contains("-dev-default."), "got {filename}");
- assert!(
- fits_unix_socket_path(&path),
- "socket path too long: {} ({} bytes)",
- path.display(),
- socket_path_byte_len(&path)
- );
- }
-
- #[test]
- fn local_forward_socket_path_fits_in_sun_path() {
- let _guard = remote_env_lock().lock().unwrap();
- // Worst case for the readable form: macOS-style 49-char TMPDIR +
- // max-length sanitized components. Should fall back to the hashed
- // short name, which fits under TMPDIR.
- let target = "longish-host.example.com";
- let session = "a-fairly-long-session-name-here";
- let path = local_forward_socket_path(target, session);
- assert!(
- fits_unix_socket_path(&path),
- "socket path too long for sun_path: {} ({} bytes)",
- path.display(),
- socket_path_byte_len(&path)
- );
- }
-
- #[test]
- fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() {
- let _guard = remote_env_lock().lock().unwrap();
- // Force a TMPDIR long enough that even the hashed short name cannot
- // fit inside it. The fallback should drop to /tmp.
- let prior = std::env::var_os("TMPDIR");
- let long_dir = std::env::temp_dir().join("a".repeat(80));
- let _ = fs::create_dir_all(&long_dir);
- std::env::set_var("TMPDIR", &long_dir);
-
- let path = local_forward_socket_path("longish-host.example.com", "default");
- let fits = fits_unix_socket_path(&path);
- let parent = path.parent().map(Path::to_path_buf);
- let filename = path
- .file_name()
- .and_then(|s| s.to_str())
- .unwrap_or("")
- .to_string();
-
- match prior {
- Some(v) => std::env::set_var("TMPDIR", v),
- None => std::env::remove_var("TMPDIR"),
- }
- let _ = fs::remove_dir_all(&long_dir);
-
- assert!(fits, "fallback path still overflows: {}", path.display());
- assert_eq!(parent.as_deref(), Some(Path::new("/tmp")));
- assert!(
- filename.starts_with("herdr-r-"),
- "expected hashed fallback, got {filename}"
- );
- }
-
- #[test]
- fn install_source_cleanup_removes_temporary_directory() {
- let dir = std::env::temp_dir().join(format!(
- "herdr-install-source-cleanup-test-{}",
- std::process::id()
- ));
- let _ = fs::remove_dir_all(&dir);
- fs::create_dir(&dir).expect("create temp dir");
- let path = dir.join("herdr.tmp");
- fs::write(&path, b"test").expect("write temp file");
-
- InstallSource::temporary(path, dir.clone()).cleanup();
-
- assert!(!dir.exists());
- }
-}