a73x

src/ssh_share.rs

Ref:   Size: 7.2 KiB   History

//! One SSH connection for the whole of a sync.
//!
//! A sync runs `git fetch` and then `git push`, two separate processes that
//! each open their own SSH connection: two handshakes, two authentications,
//! two of whatever the network charges. Nothing between them needs the
//! remote, so the push can ride the connection the fetch already opened.
//!
//! OpenSSH's ControlMaster does exactly that. The first `ssh` becomes a
//! master that stays up after its client exits (`ControlPersist`), later
//! invocations with the same `ControlPath` multiplex over it, and `ssh -O
//! exit` tears it down. [`SharedSsh`] appends those options to whatever ssh
//! command git would run anyway, so a user's own `GIT_SSH_COMMAND` or
//! `core.sshCommand` (their key, their known-hosts policy) still applies.

use std::path::PathBuf;
use std::process::Command;

use git2::Repository;

use crate::remote_ssh::{resolve_ssh_command, SshCommandSources};

/// A control-socket directory whose masters are closed on drop.
pub struct SharedSsh {
    dir: PathBuf,
    ssh_command: String,
}

impl SharedSsh {
    /// Start sharing for the commands `apply` is later called on. `None` when
    /// sharing cannot safely be set up, in which case each command opens its
    /// own connection as before:
    ///
    /// - git would not run OpenSSH at all (`GIT_SSH` names a program and
    ///   nothing overrides it, or the configured command is not `ssh`), since
    ///   the options below are OpenSSH's;
    /// - the socket directory cannot be created.
    ///
    /// Non-Unix platforms never share: control sockets are a Unix feature.
    pub fn start(repo: &Repository) -> Option<SharedSsh> {
        if !cfg!(unix) {
            return None;
        }
        let git_env = std::env::var("GIT_SSH_COMMAND").ok();
        if git_env.is_none() && std::env::var_os("GIT_SSH").is_some() {
            return None;
        }
        let base = resolve_ssh_command(SshCommandSources {
            collab_env: std::env::var("GIT_COLLAB_SSH_COMMAND").ok(),
            git_env,
            config: repo
                .config()
                .ok()
                .and_then(|cfg| cfg.get_string("core.sshCommand").ok()),
        });
        let program = base.split_whitespace().next()?;
        if std::path::Path::new(program).file_name()? != "ssh" {
            return None;
        }

        let dir = socket_dir(&std::env::temp_dir(), std::process::id())?;
        std::fs::create_dir_all(&dir).ok()?;
        // `%C` is a fixed-length hash of host, port and user, so the path
        // stays inside the socket length limit whatever the remote is named.
        let ssh_command = format!(
            "{base} -o ControlMaster=auto -o ControlPath={} -o ControlPersist=60",
            dir.join("%C").display()
        );
        Some(SharedSsh { dir, ssh_command })
    }

    /// Point a git command at the shared connection.
    pub fn apply(&self, cmd: &mut Command) {
        cmd.env("GIT_SSH_COMMAND", &self.ssh_command);
    }
}

/// A Unix socket path is limited to about 104 bytes on the platforms that
/// have them. Anything longer makes ssh fail outright, which would turn a
/// long `TMPDIR` into a sync that cannot fetch at all.
const SOCKET_PATH_LIMIT: usize = 100;

/// `%C` in a ControlPath expands to a 40-character hash of host, port and
/// user, which is why it is used: the name is a fixed length whatever the
/// remote is called.
const CONTROL_PATH_HASH: usize = 40;

/// What ssh adds to that name while it brings the master up: a `.` and 16
/// random characters, listened on and then renamed onto the real path.
///
/// The budget has to include it, because the temporary name is the one that
/// has to fit in `sun_path` — and it is 17 bytes longer than the path we
/// chose. Leaving it out (until 2026-09-06) let a 59-byte directory pass a
/// budget of exactly 100 and then hand ssh a 117-byte path, so `ssh` refused
/// with `unix_listener: path "…" too long for Unix domain socket` and the
/// fetch exited 128. That is worse than not sharing a connection: the whole
/// sync fails. `tests/sync_ssh_connection_test.rs` is the end-to-end check.
const SSH_MASTER_TEMP_SUFFIX: usize = ".".len() + 16;

/// Where the control sockets go, or `None` when a socket under it could not
/// be opened — in which case the caller simply does not share a connection.
fn socket_dir(tmp: &std::path::Path, pid: u32) -> Option<PathBuf> {
    let dir = tmp.join(format!("git-collab-ssh-{pid}"));
    let socket_len = dir.as_os_str().len() + "/".len() + CONTROL_PATH_HASH + SSH_MASTER_TEMP_SUFFIX;
    (socket_len <= SOCKET_PATH_LIMIT).then_some(dir)
}

impl Drop for SharedSsh {
    fn drop(&mut self) {
        // `-O exit` needs only the socket; the host argument is never
        // resolved, it merely satisfies ssh's argument parsing.
        if let Ok(entries) = std::fs::read_dir(&self.dir) {
            for entry in entries.flatten() {
                let _ = Command::new("ssh")
                    .args(["-O", "exit", "-o"])
                    .arg(format!("ControlPath={}", entry.path().display()))
                    .arg("git-collab-shared-connection")
                    .output();
            }
        }
        let _ = std::fs::remove_dir_all(&self.dir);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_socket_dir_that_would_overflow_the_path_limit_is_refused() {
        let long = std::path::Path::new("/").join("x".repeat(120));
        assert!(socket_dir(&long, 1).is_none());
    }

    #[test]
    fn a_short_socket_dir_is_accepted() {
        let dir = socket_dir(std::path::Path::new("/tmp"), 42).unwrap();
        assert_eq!(dir, std::path::Path::new("/tmp/git-collab-ssh-42"));
    }

    /// The boundary the old budget got wrong: this directory is 59 bytes, so
    /// path + `/` + `%C` came to exactly the 100-byte limit and was accepted,
    /// but the name ssh actually listens on is 117 bytes and it refused to
    /// start. Refusing to share here is correct; failing the sync was not.
    #[test]
    fn a_dir_that_only_fits_without_sshs_temporary_suffix_is_refused() {
        let tmp = std::path::Path::new("/tmp/delta-terminal-.delta-fs-foQjAw");
        let dir = tmp.join("git-collab-ssh-3571731");
        assert_eq!(dir.as_os_str().len(), 59, "the case this pins moved");
        assert_eq!(dir.as_os_str().len() + 1 + CONTROL_PATH_HASH, 100);
        assert!(socket_dir(tmp, 3571731).is_none());
    }

    /// And the largest directory that still leaves room for the real name,
    /// derived from the constants rather than counted by hand.
    #[test]
    fn the_largest_dir_that_fits_the_whole_socket_name_is_accepted() {
        let room_for_dir =
            SOCKET_PATH_LIMIT - "/".len() - CONTROL_PATH_HASH - SSH_MASTER_TEMP_SUFFIX;
        // socket_dir appends "/git-collab-ssh-1" to whatever it is given.
        let appended = "/git-collab-ssh-1".len();
        let tmp = std::path::Path::new("/").join("x".repeat(room_for_dir - appended - "/".len()));

        let dir = socket_dir(&tmp, 1).unwrap();
        assert_eq!(dir.as_os_str().len(), room_for_dir);
        assert_eq!(
            dir.as_os_str().len() + "/".len() + CONTROL_PATH_HASH + SSH_MASTER_TEMP_SUFFIX,
            SOCKET_PATH_LIMIT,
            "this case must sit exactly on the limit"
        );
    }
}