herdr/src/remote.rs

224 lines
6.5 KiB
Rust

#[cfg(unix)]
mod unix;
#[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",
))
}
#[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",
))
}
pub(crate) fn print_remote_error_hint(err: &std::io::Error, target: &str) {
if is_remote_auth_error(err) {
eprintln!(
"hint: verify SSH access first with `{}`.",
ssh_check_command(target)
);
eprintln!(
"hint: if your SSH key has a passphrase, load it into ssh-agent with `ssh-add` before running `herdr --remote`."
);
}
}
fn is_remote_auth_error(err: &std::io::Error) -> bool {
let message = err.to_string();
message.contains("Permission denied")
&& (message.contains("(publickey")
|| message.contains("(keyboard-interactive")
|| message.contains("(password"))
}
fn ssh_check_command(target: &str) -> String {
format!("ssh {}", shell_quote(target))
}
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('\'', "'\\''"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remote_auth_error_matches_ssh_auth_denied() {
let err = std::io::Error::other(
"remote platform detection failed: user@host: Permission denied (publickey).",
);
assert!(is_remote_auth_error(&err));
}
#[test]
fn remote_auth_error_matches_keyboard_interactive_denied() {
let err = std::io::Error::other(
"remote server status failed: user@host: Permission denied (keyboard-interactive).",
);
assert!(is_remote_auth_error(&err));
}
#[test]
fn remote_auth_error_ignores_non_auth_errors() {
let err = std::io::Error::other("remote platform detection failed: unsupported platform");
assert!(!is_remote_auth_error(&err));
}
#[test]
fn ssh_check_command_quotes_remote_target() {
assert_eq!(ssh_check_command("host name"), "ssh 'host name'");
}
}