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