use std::net::SocketAddr;
use std::path::Path;
use super::helper::COMMAND;
use super::socks::USERNAME;
use super::{PROXY_ENV, TOKEN_ENV};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitEnv {
pub set: Vec<(String, String)>,
pub remove: Vec<&'static str>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SshSettings<'a> {
pub git_ssh_command: Option<&'a str>,
pub core_ssh_command: Option<&'a str>,
pub git_ssh: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HomeHttp<'a> {
pub urls: &'a [&'a str],
pub remotes: &'a [&'a str],
}
pub fn build(
proxy: SocketAddr,
token: &str,
exe: &Path,
ssh: SshSettings<'_>,
home: HomeHttp<'_>,
existing_count: Option<&str>,
) -> Result<GitEnv, String> {
let exe = exe
.to_str()
.ok_or_else(|| format!("gitaway's path isn't valid UTF-8: {}", exe.display()))?;
let helper = format!("{} {COMMAND}", shell_quote(exe));
let proxy_url = proxy_url(proxy, token);
let mut config = vec![
("http.proxy".to_owned(), proxy_url.clone()),
(
format!("credential.socks5h://{proxy}.helper"),
String::new(),
),
];
for url in home.urls {
config.push((format!("http.{url}.proxy"), proxy_url.clone()));
}
for remote in home.remotes {
config.push((format!("remote.{remote}.proxy"), proxy_url.clone()));
}
let first = match existing_count.map(str::trim) {
None | Some("") => 0,
Some(count) => count
.parse::<usize>()
.map_err(|_| format!("GIT_CONFIG_COUNT is `{count}`, not a number"))?,
};
let mut set = Vec::new();
for (i, (key, value)) in config.iter().cloned().enumerate() {
set.push((format!("GIT_CONFIG_KEY_{}", first + i), key));
set.push((format!("GIT_CONFIG_VALUE_{}", first + i), value));
}
set.push((
"GIT_CONFIG_COUNT".to_owned(),
(first + config.len()).to_string(),
));
let proxy_command = format!("ProxyCommand={helper} %h %p %n");
set.push((
"GIT_SSH_COMMAND".to_owned(),
format!("{} -o {}", ssh_base(ssh), shell_quote(&proxy_command)),
));
set.push((PROXY_ENV.to_owned(), proxy.to_string()));
set.push((TOKEN_ENV.to_owned(), token.to_owned()));
Ok(GitEnv {
set,
remove: vec!["NO_PROXY", "no_proxy"],
})
}
pub fn proxy_url(proxy: SocketAddr, token: &str) -> String {
format!("socks5h://{USERNAME}:{token}@{proxy}")
}
pub fn check_ssh(ssh: SshSettings<'_>, host: &str) -> Result<(), String> {
let (setting, command) = match (
non_empty(ssh.git_ssh_command),
non_empty(ssh.core_ssh_command),
) {
(Some(command), _) => ("GIT_SSH_COMMAND", command),
(None, Some(command)) => ("core.sshCommand", command),
(None, None) => return Ok(()),
};
let Some(option) = proxy_option(command) else {
return Ok(());
};
Err(format!(
"your ssh command ({setting}) sets {option}. ssh uses the first proxy it's given, so it \
would take {host} around gitaway's tunnel. Move it to ~/.ssh/config, where gitaway \
outranks it for home hosts while you're away and it still applies everywhere else:\n\n \
Host <the hosts that need it>\n {option} <its value>\n\n\
then remove it from {setting}."
))
}
fn proxy_option(command: &str) -> Option<&'static str> {
let mut words = command
.split_whitespace()
.map(|word| word.trim_matches(['\'', '"']));
while let Some(word) = words.next() {
if word.starts_with("-J") {
return Some("ProxyJump");
}
let option = match word.strip_prefix("-o") {
Some("") => words.next().unwrap_or_default(),
Some(rest) => rest,
None => continue,
}
.to_ascii_lowercase();
if option.starts_with("proxycommand") {
return Some("ProxyCommand");
}
if option.starts_with("proxyjump") {
return Some("ProxyJump");
}
}
None
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.filter(|v| !v.trim().is_empty())
}
fn ssh_base(ssh: SshSettings<'_>) -> String {
if let Some(command) = non_empty(ssh.git_ssh_command) {
return command.to_owned();
}
if let Some(command) = non_empty(ssh.core_ssh_command) {
return command.to_owned();
}
if let Some(program) = non_empty(ssh.git_ssh) {
return shell_quote(program);
}
"ssh".to_owned()
}
pub fn shell_quote(s: &str) -> String {
let plain = !s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"/-_.,:=@%+".contains(&b));
if plain {
s.to_owned()
} else {
format!("'{}'", s.replace('\'', r"'\''"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn proxy() -> SocketAddr {
"127.0.0.1:41234".parse().unwrap()
}
fn value<'a>(env: &'a GitEnv, name: &str) -> &'a str {
env.set
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
.unwrap_or_else(|| panic!("{name} not set"))
}
fn config(env: &GitEnv) -> Vec<(String, String)> {
let count: usize = value(env, "GIT_CONFIG_COUNT").parse().unwrap();
(0..count)
.filter(|i| {
env.set
.iter()
.any(|(k, _)| k == &format!("GIT_CONFIG_KEY_{i}"))
})
.map(|i| {
(
value(env, &format!("GIT_CONFIG_KEY_{i}")).to_owned(),
value(env, &format!("GIT_CONFIG_VALUE_{i}")).to_owned(),
)
})
.collect()
}
#[test]
fn git_gets_the_proxy_for_http_and_ssh() {
let env = build(
proxy(),
"tok",
Path::new("/usr/local/bin/gitaway"),
SshSettings::default(),
HomeHttp::default(),
None,
)
.unwrap();
assert_eq!(
config(&env),
[
(
"http.proxy".into(),
"socks5h://gitaway:tok@127.0.0.1:41234".into()
),
(
"credential.socks5h://127.0.0.1:41234.helper".into(),
String::new()
),
]
);
assert_eq!(
value(&env, "GIT_SSH_COMMAND"),
"ssh -o 'ProxyCommand=/usr/local/bin/gitaway __proxy-connect %h %p %n'"
);
assert_eq!(value(&env, PROXY_ENV), "127.0.0.1:41234");
assert_eq!(value(&env, TOKEN_ENV), "tok");
assert_eq!(env.remove, ["NO_PROXY", "no_proxy"]);
}
#[test]
fn home_urls_and_their_remotes_get_the_proxy_too() {
let home = HomeHttp {
urls: &["https://git.example.com/alice/app.git"],
remotes: &["origin"],
};
let env = build(
proxy(),
"tok",
Path::new("/g"),
SshSettings::default(),
home,
None,
)
.unwrap();
let proxy_url = "socks5h://gitaway:tok@127.0.0.1:41234".to_owned();
let config = config(&env);
assert!(config.contains(&(
"http.https://git.example.com/alice/app.git.proxy".into(),
proxy_url.clone()
)));
assert!(config.contains(&("remote.origin.proxy".into(), proxy_url)));
}
#[test]
fn an_ssh_command_with_its_own_proxy_is_refused() {
let check = |git_ssh_command, core_ssh_command| {
check_ssh(
SshSettings {
git_ssh_command,
core_ssh_command,
git_ssh: None,
},
"git.example.com",
)
};
for command in [
"ssh -J bastion.example.net",
"ssh -Jbastion.example.net",
"ssh -o ProxyJump=bastion.example.net",
"ssh -oProxyCommand='nc %h %p'",
"ssh -o 'proxycommand nc %h %p'",
] {
let error = check(Some(command), None).unwrap_err();
assert!(error.contains("GIT_SSH_COMMAND"), "{command}: {error}");
assert!(error.contains("~/.ssh/config"), "{command}: {error}");
}
assert!(
check(None, Some("ssh -J b"))
.unwrap_err()
.contains("core.sshCommand")
);
assert!(check(Some("ssh -i ~/.ssh/id_work -o IdentitiesOnly=yes"), None).is_ok());
assert!(check(None, None).is_ok());
assert!(check(Some("ssh"), Some("ssh -J b")).is_ok());
}
#[test]
fn existing_config_from_the_environment_is_kept() {
let env = build(
proxy(),
"tok",
Path::new("/g"),
SshSettings::default(),
HomeHttp::default(),
Some("2"),
)
.unwrap();
assert_eq!(value(&env, "GIT_CONFIG_COUNT"), "4");
assert_eq!(value(&env, "GIT_CONFIG_KEY_2"), "http.proxy");
assert!(
!env.set.iter().any(|(k, _)| k == "GIT_CONFIG_KEY_0"),
"the user's own are untouched"
);
assert!(
build(
proxy(),
"tok",
Path::new("/g"),
SshSettings::default(),
HomeHttp::default(),
Some("x")
)
.is_err()
);
}
#[test]
fn the_users_ssh_command_is_kept_in_gits_order() {
let ssh = |git_ssh_command, core_ssh_command, git_ssh| {
let settings = SshSettings {
git_ssh_command,
core_ssh_command,
git_ssh,
};
let env = build(
proxy(),
"t",
Path::new("/g"),
settings,
HomeHttp::default(),
None,
)
.unwrap();
value(&env, "GIT_SSH_COMMAND")
.split(" -o 'ProxyCommand")
.next()
.unwrap()
.to_owned()
};
assert_eq!(
ssh(Some("ssh -i ~/.ssh/id_work"), Some("ssh -v"), Some("/x")),
"ssh -i ~/.ssh/id_work"
);
assert_eq!(ssh(None, Some("ssh -v"), Some("/x")), "ssh -v");
assert_eq!(ssh(None, None, Some("/opt/my ssh")), "'/opt/my ssh'");
assert_eq!(ssh(Some(""), None, None), "ssh");
}
#[cfg(unix)]
#[test]
fn paths_with_spaces_and_quotes_survive_two_shells() {
let env = build(
proxy(),
"t",
Path::new("/Users/alice/My Tools/it's/gitaway"),
SshSettings::default(),
HomeHttp::default(),
None,
)
.unwrap();
let command = value(&env, "GIT_SSH_COMMAND");
let after_git_shell = run_sh(&format!(
"printf %s {}",
command.split_once(" -o ").unwrap().1
));
assert_eq!(
after_git_shell,
r"ProxyCommand='/Users/alice/My Tools/it'\''s/gitaway' __proxy-connect %h %p %n"
);
let program = after_git_shell
.strip_prefix("ProxyCommand=")
.unwrap()
.strip_suffix(" __proxy-connect %h %p %n")
.unwrap();
assert_eq!(
run_sh(&format!("printf %s {program}")),
"/Users/alice/My Tools/it's/gitaway"
);
}
#[cfg(unix)]
fn run_sh(script: &str) -> String {
let out = std::process::Command::new("sh")
.args(["-c", script])
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap()
}
#[test]
fn shell_quoting() {
assert_eq!(shell_quote("ssh"), "ssh");
assert_eq!(shell_quote("/usr/bin/ssh"), "/usr/bin/ssh");
assert_eq!(shell_quote("a b"), "'a b'");
assert_eq!(shell_quote("it's"), r"'it'\''s'");
assert_eq!(shell_quote(""), "''");
assert_eq!(shell_quote("$(rm -rf /)"), "'$(rm -rf /)'");
}
}