Send remote commands in a form every login shell reads the same way

This commit is contained in:
2026-10-15 15:07:31 +02:00
parent 157a4063c7
commit 121fe1bc2f
+60 -7
View File
@@ -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'");
}
}