use std::path::Path; use lazy_static::lazy_static; use regex::Regex; /// Converts a multiline bash or zsh script to one line by turning newlines into semicolons or /// deleting them, as appropriate. /// /// Extra semicolons are a syntax error in bash, so this is careful to avoid adding them except /// where necessary. /// /// This function exists because there's a strange macOS ssh server bug where sending a lot of data /// containing newlines to a shell results in data corruption. pub fn convert_script_to_one_line(script: &str) -> String { lazy_static! { static ref EXTRA_SPACES_REGEX: Regex = Regex::new(r"\n+\s*").expect("invalid regex"); static ref NO_SEMICOLON_REGEX: Regex = Regex::new("(; ?|\\{|do|then|else|in)\n").expect("invalid regex"); static ref REMOVE_COMMENTS_REGEX: Regex = Regex::new(r"(?m)^ *#.*").expect("invalid regex"); static ref REMOVE_LEADING_NEWLINES: Regex = Regex::new(r"^\n*").expect("invalid regex"); }; let script = REMOVE_COMMENTS_REGEX.replace_all(script, ""); let script = REMOVE_LEADING_NEWLINES.replace_all(&script, ""); let script = EXTRA_SPACES_REGEX.replace_all(&script, "\n"); let script = NO_SEMICOLON_REGEX.replace_all(&script, "$1 "); let mut script = script.replace('\n', ";"); script.push('\n'); script } pub enum SshLoginState { LastLogin, NonSshOutput, Authenticating, PromptDetected, } /// Reads the contents of the output grid to determine SSH login state. Returns [SshLoginState::LastLogin] if /// "Last login:" is detected in the output. Returns [SshLoginState::NonSshOutput] if certain keywords /// known to be a part of ssh login prompts are found in the current last line of command output. The /// "password" and "Password" are for password authentication. "passphrase" is intended to cover authentication /// by public key. And "yes/no" relates to trust-on-first-use prompts for host-based authentication. pub fn check_ssh_login_state(block_output: &str) -> SshLoginState { lazy_static! { // Common final prompt characters followed by a space. static ref PROMPT_REGEX: Regex = Regex::new(r"[$#%>❯│⟫»▶λ→] $").expect("invalid regex"); }; let mut last_line = None; for line in block_output.lines() { if line.starts_with("Last login:") { return SshLoginState::LastLogin; } // With an iterator, there's no way to know if it's the last element so // we overwrite last_line at each iteration. last_line = Some(line); } last_line.map_or(SshLoginState::Authenticating, |line| { if line.contains("password") || line.contains("Password") || line.contains("passphrase") || line.contains("yes/no") || line.contains("Please type") || line.contains("'yes'") || line.contains("Confirm user presence") || line.starts_with("Enter ") || line.starts_with("Allow ") { SshLoginState::Authenticating } else if PROMPT_REGEX.is_match(line) { SshLoginState::PromptDetected } else { SshLoginState::NonSshOutput } }) } /// Represents the parsed components of an interactive SSH command. /// For some [`SshWarpifyCommand`]s, we do not support parsing /// a host or port In these cases, we can still parse to a valid /// empty `InteractiveSshCommand` to indicate that we did /// successfully detect an interactive SSH command. #[derive(Clone, Debug, Default)] pub struct InteractiveSshCommand { pub host: Option, pub port: Option, } impl InteractiveSshCommand { /// Parses ssh commands of the form `ssh ...`. /// Only returns an `InteractiveSshCommand` if we determine the command is interactive. fn parse_ssh_command(command: &str) -> Option { let command = if let Some(suffix) = command.strip_prefix("command ") { suffix } else { command }; let tokens = parse_ssh_command_tokens(command)?; let mut host: Option = None; let mut port: Option = None; let mut i = 1; while i < tokens.len() { match tokens[i].as_str() { // -T or -W imply a non-interactive session. "-T" | "-W" => return None, "-p" => { i += 1; if i < tokens.len() { port = Some(tokens[i].clone()); } else { return None; } } // SSH option that doesn't change interactivity and require an argument: Skip the next item. "-B" | "-b" | "-c" | "-D" | "-E" | "-e" | "-F" | "-I" | "-i" | "-J" | "-L" | "-l" | "-m" | "-O" | "-o" | "-P" | "-Q" | "-R" | "-S" | "-w" => { i += 1; } // SSH option(s) that don't change interactivity. arg if arg.starts_with('-') => {} // Otherwise, it's a positional argument (e.g., hostname, command to run) pos_arg => { // If we detect mutliple positional args, there's some type of unknown command formulation. if host.is_some() { return None; } host = Some(pos_arg.to_string()); } } i += 1; } Some(InteractiveSshCommand { host, port }) } } pub enum SshLikeCommand { Gcloud, ElasticBeanstalk, DigitalOceanDroplet, } /// TMUX SSH Warpification can be triggered by any command that /// we determine to be an interactive SSH command. This enum /// represents the different types of SSH commands we support /// for TMUX Warpification. `Ssh` means a literal `ssh` command, /// where all other commands are categorized as SSH-like commands. pub enum SshWarpifyCommand { Ssh, SshLike(SshLikeCommand), } impl SshWarpifyCommand { /// Not a literal `ssh` command, but another command that starts an interactive SSH /// session that we can Warpify with TMUX. pub fn is_ssh_like_command(&self) -> bool { matches!(self, SshWarpifyCommand::SshLike(_)) } } lazy_static! { static ref INTERACTIVE_SSH: Regex = Regex::new(r"^ssh\s+").expect("interactive SSH regex invalid"); /// Matches "gcloud compute ssh" for connecting to GCP VMs. static ref GCLOUD_REGEX: Regex = Regex::new(r"^gcloud\s+compute\s+ssh\s.+").expect("gcloud SSH regex invalid"); /// Matches "eb ssh" for connecting to AWS Elastic Beanstalk VMs. static ref ELASTIC_BEANSTALK_REGEX: Regex = Regex::new(r"^eb\s+ssh\s.+").expect("elastic beanstalk SSH regex invalid"); /// Matches "doctl compute ssh" for connecting to a digital ocean droplet. static ref DIGITAL_OCEAN_DROPLET_REGEX: Regex = Regex::new(r"^doctl\s+compute\s+ssh\s.+").expect("digital ocean SSH regex invalid"); } impl SshWarpifyCommand { pub fn matches(command: &str) -> Option { let command = if let Some(suffix) = command.strip_prefix("command ") { suffix } else { command }; if INTERACTIVE_SSH.is_match(command) { Some(SshWarpifyCommand::Ssh) } else if GCLOUD_REGEX.is_match(command) { Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud)) } else if ELASTIC_BEANSTALK_REGEX.is_match(command) { Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk)) } else if DIGITAL_OCEAN_DROPLET_REGEX.is_match(command) { Some(SshWarpifyCommand::SshLike( SshLikeCommand::DigitalOceanDroplet, )) } else { None } } } pub fn parse_interactive_ssh_command(command: &str) -> Option { match SshWarpifyCommand::matches(command) { Some(SshWarpifyCommand::Ssh) => InteractiveSshCommand::parse_ssh_command(command), Some(SshWarpifyCommand::SshLike(SshLikeCommand::Gcloud)) => { Some(InteractiveSshCommand::default()) } Some(SshWarpifyCommand::SshLike(SshLikeCommand::ElasticBeanstalk)) => { Some(InteractiveSshCommand::default()) } Some(SshWarpifyCommand::SshLike(SshLikeCommand::DigitalOceanDroplet)) => { Some(InteractiveSshCommand::default()) } None => None, } } fn parse_ssh_command_tokens(command: &str) -> Option> { let Ok(tokens) = shell_words::split(command) else { return None; }; // Cases: "", "ls", "ssh-add-key" if tokens.is_empty() || tokens[0] != "ssh" { return None; } Some(tokens) } /// Creates an sftp command that copies a given local file into the pwd in the warpified ssh session. pub fn transfer_file_sftp_command( local_file_path: String, ssh_host: String, ssh_port: Option, pwd: Option, ) -> Option { // "sftp " let mut command = String::from("sftp "); // "sftp -P 2222" if let Some(port) = ssh_port { command += &format!("-P {port} "); } // "sftp -P 2222 sshuser@127.0.0.1 <<< "put " command += &ssh_host; command += " <<< \"put "; // "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r" let is_dir = Path::new(&local_file_path) .metadata() .is_ok_and(|m| m.is_dir()); if is_dir { command += "-r " } // "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r \"path/to/local/file\"" command += &format!("\\\"{}\\\"", &local_file_path); // "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r path/to/local/file pwd/on/remote" if let Some(pwd) = pwd { command += " "; command += &format!("\\\"{}\\\"", &pwd); } // "sftp -P 2222 sshuser@127.0.0.1 <<< "put -r path/to/local/file pwd/on/remote"" command += "\""; Some(command) } #[cfg(test)] mod tests { use super::*; #[test] fn ssh_gcloud_ssh_parsing() { assert!(parse_interactive_ssh_command("gcloud").is_none()); assert!(parse_interactive_ssh_command("gcloud compute").is_none()); assert!(parse_interactive_ssh_command("gcloud compute ss").is_none()); assert!(parse_interactive_ssh_command("gcloud compute ssh").is_none()); assert!(parse_interactive_ssh_command("command gcloud compute ssh").is_none()); assert!( parse_interactive_ssh_command("command gcloud compute ssh --zone us-west1-a").is_some() ); assert!(parse_interactive_ssh_command("gcloud compute ssh --zone us-west1-a").is_some()); assert!( parse_interactive_ssh_command("gcloud compute ssh --zone us-west1-a my-instance") .is_some() ); assert!(parse_interactive_ssh_command( "gcloud compute ssh --zone us-west1-a my-instance --project my-project" ) .is_some()); } #[test] fn ssh_elastic_beanstalk_parsing() { assert!(parse_interactive_ssh_command("eb").is_none()); assert!(parse_interactive_ssh_command("eb ss").is_none()); assert!(parse_interactive_ssh_command("eb ssh").is_none()); assert!(parse_interactive_ssh_command("command eb ssh").is_none()); assert!(parse_interactive_ssh_command("command eb ssh --profile my-profile").is_some()); assert!(parse_interactive_ssh_command("eb ssh --profile my-profile").is_some()); assert!(parse_interactive_ssh_command("eb ssh --profile my-profile my-env").is_some()); } #[test] fn ssh_digital_ocean_droplet_parsing() { assert!(parse_interactive_ssh_command("doctl").is_none()); assert!(parse_interactive_ssh_command("doctl compute").is_none()); assert!(parse_interactive_ssh_command("doctl compute ss").is_none()); assert!(parse_interactive_ssh_command("doctl compute ssh").is_none()); assert!(parse_interactive_ssh_command("command doctl compute ssh").is_none()); assert!(parse_interactive_ssh_command("command doctl compute ssh --region nyc1").is_some()); assert!(parse_interactive_ssh_command("doctl compute ssh --region nyc1").is_some()); assert!( parse_interactive_ssh_command("doctl compute ssh --region nyc1 my-droplet").is_some() ); } /// Verifies that commands resulting from shell alias expansion are correctly /// detected as interactive SSH commands. When a user types an alias (e.g. /// `myssh`), the terminal view expands it to the alias value before passing /// it to `parse_interactive_ssh_command`. These tests cover representative /// expanded forms. #[test] fn ssh_alias_expanded_commands() { // Simple alias: alias myssh='ssh user@host' assert_eq!( parse_interactive_ssh_command("ssh user@host").unwrap().host, Some("user@host".to_string()) ); // Alias with key and user: alias company1='ssh -i /path/to/key user@server' assert_eq!( parse_interactive_ssh_command("ssh -i /path/to/key user@server") .unwrap() .host, Some("user@server".to_string()) ); // Alias with extra args appended by the user: alias myssh='ssh -i key' // then the user types `myssh user@host` which expands to `ssh -i key user@host` assert_eq!( parse_interactive_ssh_command("ssh -i key user@host") .unwrap() .host, Some("user@host".to_string()) ); // Alias that isn't SSH should not match assert!(parse_interactive_ssh_command("ls -la").is_none()); } #[test] fn ssh_interactive_shell_parsing() { assert!(parse_interactive_ssh_command("").is_none()); assert!(parse_interactive_ssh_command("ls").is_none()); assert!(parse_interactive_ssh_command("ssh-add-key").is_none()); // Basic interactive command assert!( parse_interactive_ssh_command("ssh localhost").unwrap().host == Some("localhost".to_string()) ); assert!( parse_interactive_ssh_command("command ssh localhost") .unwrap() .host == Some("localhost".to_string()) ); assert!( parse_interactive_ssh_command("ssh root@127.14.80.1 -p 2222") .unwrap() .host == Some("root@127.14.80.1".to_string()) ); assert!( parse_interactive_ssh_command("ssh -4vw root@127.14.80.1 -p 2222") .unwrap() .host == Some("root@127.14.80.1".to_string()) ); // Commands with -T or -W, which are non-interactive assert!(parse_interactive_ssh_command("ssh -T user@host").is_none()); assert!(parse_interactive_ssh_command("ssh -v user@host -W localhost:22").is_none()); assert!( parse_interactive_ssh_command("ssh -o IdentityFile=/etc/file -T user@host").is_none() ); // Commands with multiple positional arguments, implying non-interactive assert!(parse_interactive_ssh_command("ssh user@host ls").is_none()); assert!(parse_interactive_ssh_command("ssh user@host echo 'Hello, World!'").is_none()); // Weird spacing and shell characters shouldn't matter assert!( parse_interactive_ssh_command("ssh user@host") .unwrap() .host == Some("user@host".to_string()) ); assert!( parse_interactive_ssh_command("ssh -4 -- localhost") .unwrap() .host == Some("localhost".to_string()) ); } }