Send remote commands in a form every login shell reads the same way
This commit is contained in:
@@ -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 <path>` 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 <path>` 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 <path> <args>` 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'");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user