tests/sync_ssh_connection_test.rs
Ref: Size: 6.0 KiB History
mod common;
use std::path::Path;
use std::process::Output;
use common::ServerHarness;
/// Set a repo up with an `srv` SSH remote, collab refspec, autosync off and one
/// issue to push, then run one sync with `TMPDIR` pointed at `tmp`.
fn sync_with_tmpdir(harness: &ServerHarness, name: &str, tmp: &Path) -> Output {
let key = harness.ssh_client_key();
harness.push_head();
let repo = harness.work_repo();
let url = harness.ssh_url_for(harness.repo_name());
repo.git(&["remote", "add", "srv", &url]);
repo.git(&[
"config",
"--add",
"remote.srv.fetch",
"+refs/collab/*:refs/collab/sync/srv/*",
]);
repo.git(&["config", "collab.autoSync", "false"]);
repo.issue_open(name);
let ssh = format!(
"ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5",
key.display()
);
repo.cli_command()
.args(["sync", "--remote", "srv"])
.env("GIT_SSH_COMMAND", &ssh)
.env("TMPDIR", tmp)
.output()
.expect("failed to run git-collab sync")
}
/// A sync fetches and then pushes. Run as two independent `git` invocations
/// those are two SSH connections, each paying a handshake, an auth round and
/// whatever the network charges. The push should ride the connection the
/// fetch already authenticated, so the server sees one login per sync.
#[test]
fn a_sync_authenticates_to_the_server_once() {
let harness = ServerHarness::new("one-login");
let key = harness.ssh_client_key();
harness.push_head();
let repo = harness.work_repo();
let url = harness.ssh_url_for(harness.repo_name());
repo.git(&["remote", "add", "srv", &url]);
repo.git(&[
"config",
"--add",
"remote.srv.fetch",
"+refs/collab/*:refs/collab/sync/srv/*",
]);
repo.git(&["config", "collab.autoSync", "false"]);
repo.issue_open("Counted once");
let ssh = format!(
"ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5",
key.display()
);
// Sharing a connection means a control socket under `TMPDIR`, and a Unix
// socket path is capped around 104 bytes. Declining to share under a long
// `TMPDIR` is correct behaviour (see `ssh_share::socket_dir`), so a test
// that inherited the ambient one would assert sharing on some machines and
// fail on others — CI containers and Delta worktrees both hand out temp
// paths long enough to trip it. Pin a short root so this test measures the
// sharing behaviour and not the length of the environment's temp path.
let short_tmp = tempfile::TempDir::new_in("/tmp").expect("short temp dir under /tmp");
let output = repo
.cli_command()
.args(["sync", "--remote", "srv"])
.env("GIT_SSH_COMMAND", &ssh)
.env("TMPDIR", short_tmp.path())
.output()
.expect("failed to run git-collab sync");
assert!(
output.status.success(),
"sync failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let log = harness.server_log();
let logins = log.matches("Public key auth accepted").count();
assert_eq!(
logins, 1,
"one sync should log in once, saw {logins} logins:\n{log}"
);
}
/// A `TMPDIR` too long to hold a control socket must cost the *sharing*, not
/// the sync.
///
/// This is the regression test for the bug the sharing test above was masking
/// (fixed 2026-09-06): `ssh_share::socket_dir` budgeted for the 40-character
/// `%C` hash but not for the `.XXXXXXXXXXXXXXXX` ssh appends while bringing the
/// master up, so a directory of 51-59 bytes passed the guard, ssh refused the
/// path it was handed, and `git fetch` died with exit 128. A sync that cannot
/// share has to fall back to two connections and still finish.
#[test]
fn a_tmpdir_too_long_for_a_control_socket_still_syncs() {
let harness = ServerHarness::new("long-tmpdir");
// The length matters, and "long" is not enough: a *very* long TMPDIR was
// refused by the old budget too, so a fixture like that passes either way
// and proves nothing. The bug lived in a band — paths the old budget
// accepted and ssh then refused — and this fixture has to sit in it.
//
// The socket directory is `<TMPDIR>/git-collab-ssh-<pid>`. At 34 bytes of
// TMPDIR that directory is 55-57 bytes for any pid of 5 to 7 digits, so
// the old budget (dir + "/" + 40) came to 96-98 and passed, while the name
// ssh actually opens (another 17 bytes) is 113-115 and exceeds every
// platform's `sun_path`. Built under /tmp so the length is this test's
// choice and not the environment's.
const TARGET_TMPDIR_LEN: usize = 34;
let root = tempfile::TempDir::new_in("/tmp").expect("temp dir under /tmp");
let pad = TARGET_TMPDIR_LEN
.checked_sub(root.path().as_os_str().len() + "/".len())
.expect("temp root is already longer than the target length");
let long = root.path().join("d".repeat(pad));
std::fs::create_dir_all(&long).unwrap();
assert_eq!(
long.as_os_str().len(),
TARGET_TMPDIR_LEN,
"this fixture only reproduces the bug at exactly this length"
);
let output = sync_with_tmpdir(&harness, "Synced without sharing", &long);
assert!(
output.status.success(),
"a sync that cannot share a connection must still succeed, got {:?}:\n{}{}",
output.status.code(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
// And it really did decline to share: fetch and push logged in separately.
// Asserting the count, not just success, keeps this from passing for the
// wrong reason if sharing ever starts working under a long path.
let log = harness.server_log();
let logins = log.matches("Public key auth accepted").count();
assert_eq!(
logins, 2,
"expected the unshared fallback to log in twice, saw {logins}:\n{log}"
);
}