diff --git a/src-tauri/src/location.rs b/src-tauri/src/location.rs index 46a629d..cf2497d 100644 --- a/src-tauri/src/location.rs +++ b/src-tauri/src/location.rs @@ -12,11 +12,11 @@ pub enum Location { /// Reads `[user@]host:path` as a remote location. Everything else is local: text where the /// part before the first `:` is a single character (a Windows drive like `C:`) or contains -/// `/` or `\`, and text that is the whole name of a folder that exists inside `cwd`. +/// `/` or `\`, text starting with `-` (ssh would read it as an option), and text that is the whole name of a folder that exists inside `cwd`. pub fn parse(text: &str, cwd: &Path) -> Location { let local = || Location::Local(PathBuf::from(text)); let Some((target, path)) = text.split_once(':') else { return local() }; - if target.chars().count() <= 1 || target.contains('/') || target.contains('\\') || cwd.join(text).exists() { + if target.chars().count() <= 1 || target.starts_with('-') || target.contains('/') || target.contains('\\') || cwd.join(text).exists() { return local(); } Location::Ssh { target: target.to_string(), path: path.to_string() } @@ -45,10 +45,10 @@ pub fn quote(word: &str) -> String { format!("'{}'", word.replace('\'', r"'\''")) } -/// The command line to run on the remote machine: `git -C ` followed by `args`. -/// A path starting with `~/` keeps the `~` outside the quotes so the shell still +/// The command for a shell that reads quotes the standard way: `git -C ` followed by +/// `args`. A path starting with `~/` keeps the `~` outside the quotes so the shell still /// replaces it with the home folder. -pub fn remote_command(path: &str, args: &[&str]) -> String { +pub fn sh_command(path: &str, args: &[&str]) -> String { let path = match path.strip_prefix("~/") { Some(rest) => format!("~/{}", quote(rest)), None => quote(path), @@ -61,6 +61,24 @@ pub fn remote_command(path: &str, args: &[&str]) -> String { command } +/// Wraps a command so every login shell on the server (sh, bash, zsh, fish) runs it the same +/// way. Fish reads quotes differently from the others, so the command is not sent as written: +/// every byte becomes a backslash and three octal digits, which no shell changes inside single +/// quotes, and `printf` turns them back into the command for `sh` to run. +pub fn shell_neutral(sh_command: &str) -> String { + let mut wrapped = String::from("printf '"); + for byte in sh_command.bytes() { + wrapped.push_str(&format!("\\{byte:03o}")); + } + wrapped.push_str("' | sh"); + wrapped +} + +/// The command line to give to ssh to run `git -C ` on the remote machine. +pub fn remote_command(path: &str, args: &[&str]) -> String { + shell_neutral(&sh_command(path, args)) +} + #[cfg(test)] mod tests { use super::*; @@ -89,6 +107,12 @@ mod tests { } } + #[test] + fn keeps_a_target_that_ssh_would_read_as_an_option_local() { + let text = "-oProxyCommand=x:y"; + assert_eq!(parse(text, &cwd()), Location::Local(PathBuf::from(text))); + } + #[test] fn keeps_an_existing_local_folder_with_a_colon_local() { let temp = std::env::temp_dir().join(format!("gites-location-{}", std::process::id())); @@ -122,12 +146,41 @@ mod tests { } } + #[test] + fn wrapped_words_survive_every_login_shell() { + let words = [ + "plain", "with space", "it's", "\"double\"", "$HOME", "a;b", "*", "%s", "line\nbreak", "a\\b", "\\", + "a\\'b", "x\\", + ]; + let mut shells_run = Vec::new(); + for shell in ["sh", "bash", "zsh", "fish"] { + if Command::new(shell).arg("-c").arg("true").output().is_err() { + assert_ne!(shell, "sh", "sh must be installed"); + continue; + } + shells_run.push(shell); + for word in words { + let wrapper = shell_neutral(&format!("printf %s {}", quote(word))); + let out = Command::new(shell).arg("-c").arg(&wrapper).output().unwrap(); + assert_eq!(String::from_utf8(out.stdout).unwrap(), word, "{shell} with {word:?}"); + } + } + assert!(shells_run.contains(&"sh")); + } + + #[test] + fn the_remote_command_uses_only_plain_characters() { + let command = remote_command("~/x y", &["status", "it's"]); + assert!(command.starts_with("printf '") && command.ends_with("' | sh")); + assert!(command.chars().all(|c| "printf'\\0123456789 |sh".contains(c)), "{command}"); + } + #[test] fn builds_the_remote_command() { assert_eq!( - remote_command("/srv/my app", &["log", "--format=%H"]), + sh_command("/srv/my app", &["log", "--format=%H"]), "git -C '/srv/my app' 'log' '--format=%H'" ); - assert_eq!(remote_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); + assert_eq!(sh_command("~/x y", &["status"]), "git -C ~/'x y' 'status'"); } }