#[derive(Debug, Clone)]
pub struct SshInvocation {
pub host: String,
pub path: String,
pub is_push: bool,
}
pub fn parse_ssh_args(args: &[String]) -> Option<SshInvocation> {
let ssh_opts_with_value = "bcDEeFIiJLlmOopQRSWw";
let mut i = 0;
let mut host_user: Option<String> = None;
let mut remaining_after_host: Vec<String> = Vec::new();
let mut found_host = false;
while i < args.len() {
let arg = &args[i];
if found_host {
remaining_after_host.push(arg.clone());
i += 1;
continue;
}
if arg.starts_with('-') {
let opt_char = arg.chars().nth(1);
if let Some(c) = opt_char {
if ssh_opts_with_value.contains(c) {
if arg.len() == 2 {
i += 2;
} else {
i += 1;
}
continue;
}
}
i += 1;
continue;
}
let host = if let Some(at_pos) = arg.find('@') {
arg[at_pos + 1..].to_string()
} else {
arg.clone()
};
host_user = Some(host);
found_host = true;
i += 1;
}
let host = host_user?;
let path = extract_path(&remaining_after_host);
let is_push = remaining_after_host
.iter()
.any(|a| a.contains("git-receive-pack"));
let path = path.strip_suffix(".git").unwrap_or(&path).to_string();
let path = path.strip_prefix('/').unwrap_or(&path).to_string();
Some(SshInvocation {
host,
path,
is_push,
})
}
fn extract_path(args: &[String]) -> String {
let combined = args.join(" ");
if let Some(start) = combined.rfind('\'') {
let before = &combined[..start];
if let Some(open) = before.rfind('\'') {
return combined[open + 1..start].to_string();
}
}
if let Some(start) = combined.rfind('"') {
let before = &combined[..start];
if let Some(open) = before.rfind('"') {
return combined[open + 1..start].to_string();
}
}
args.last()
.map(|p| p.trim_matches('\'').trim_matches('"').to_string())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect()
}
#[test]
fn parse_basic_github_clone() {
let a = args("git@github.com git-upload-pack 'VolvoGroup-Internal/repo.git'");
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "github.com");
assert_eq!(inv.path, "VolvoGroup-Internal/repo");
}
#[test]
fn parse_with_ssh_options() {
let a =
args("-o StrictHostKeyChecking=no -p 22 git@github.com git-upload-pack 'Org/repo.git'");
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "github.com");
assert_eq!(inv.path, "Org/repo");
}
#[test]
fn parse_with_identity_flag() {
let a = args("-i /tmp/key git@gitlab.com git-upload-pack 'group/subgroup/repo.git'");
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "gitlab.com");
assert_eq!(inv.path, "group/subgroup/repo");
}
#[test]
fn parse_azure_devops() {
let a = args("git@ssh.dev.azure.com git-receive-pack 'v3/ClientX/Project/Repo.git'");
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "ssh.dev.azure.com");
assert_eq!(inv.path, "v3/ClientX/Project/Repo");
}
#[test]
fn parse_github_ssh_over_https_port_443_push() {
let a = args("-p 443 git@ssh.github.com git-receive-pack 'Org/repo.git'");
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "ssh.github.com");
assert_eq!(inv.path, "Org/repo");
assert!(inv.is_push);
}
#[test]
fn parse_no_user() {
let a = args("example.com git-upload-pack 'test/repo.git'");
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "example.com");
}
#[test]
fn parse_leading_slash() {
let a = args("git@github.com git-upload-pack '/Org/repo.git'");
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.path, "Org/repo");
}
#[test]
fn parse_combined_command_and_path() {
let a = vec![
"-o".to_string(),
"SendEnv=GIT_PROTOCOL".to_string(),
"git@github.com".to_string(),
"git-upload-pack 'VolvoGroup-Internal/repo.git'".to_string(),
];
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "github.com");
assert_eq!(inv.path, "VolvoGroup-Internal/repo");
}
#[test]
fn parse_combined_with_leading_slash() {
let a = vec![
"git@hem-assistans.duckdns.org".to_string(),
"git-upload-pack '/simeon/villajakt.git'".to_string(),
];
let inv = parse_ssh_args(&a).unwrap();
assert_eq!(inv.host, "hem-assistans.duckdns.org");
assert_eq!(inv.path, "simeon/villajakt");
}
}