a73x

2e8fe285

Cover the control-socket overflow that broke a sync outright

a73x   2026-09-06 08:53

Commit message
Cover the control-socket overflow that broke a sync outright

tests/sync_ssh_connection_test.rs
Old New
@@ -1,7 +1,41 @@
1 mod common; 1 mod common;
2 2
3 use std::path::Path;
4 use std::process::Output;
5
3 use common::ServerHarness; 6 use common::ServerHarness;
4 7
8 /// Set a repo up with an `srv` SSH remote, collab refspec, autosync off and one
9 /// issue to push, then run one sync with `TMPDIR` pointed at `tmp`.
10 fn sync_with_tmpdir(harness: &ServerHarness, name: &str, tmp: &Path) -> Output {
11 let key = harness.ssh_client_key();
12 harness.push_head();
13
14 let repo = harness.work_repo();
15 let url = harness.ssh_url_for(harness.repo_name());
16 repo.git(&["remote", "add", "srv", &url]);
17 repo.git(&[
18 "config",
19 "--add",
20 "remote.srv.fetch",
21 "+refs/collab/*:refs/collab/sync/srv/*",
22 ]);
23 repo.git(&["config", "collab.autoSync", "false"]);
24 repo.issue_open(name);
25
26 let ssh = format!(
27 "ssh -i {} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
28 -o IdentitiesOnly=yes -o BatchMode=yes -o ConnectTimeout=5",
29 key.display()
30 );
31 repo.cli_command()
32 .args(["sync", "--remote", "srv"])
33 .env("GIT_SSH_COMMAND", &ssh)
34 .env("TMPDIR", tmp)
35 .output()
36 .expect("failed to run git-collab sync")
37 }
38
5 /// A sync fetches and then pushes. Run as two independent `git` invocations 39 /// A sync fetches and then pushes. Run as two independent `git` invocations
6 /// those are two SSH connections, each paying a handshake, an auth round and 40 /// those are two SSH connections, each paying a handshake, an auth round and
7 /// whatever the network charges. The push should ride the connection the 41 /// whatever the network charges. The push should ride the connection the
@@ -59,3 +93,61 @@ fn a_sync_authenticates_to_the_server_once() {
59 "one sync should log in once, saw {logins} logins:\n{log}" 93 "one sync should log in once, saw {logins} logins:\n{log}"
60 ); 94 );
61 } 95 }
96
97 /// A `TMPDIR` too long to hold a control socket must cost the *sharing*, not
98 /// the sync.
99 ///
100 /// This is the regression test for the bug the sharing test above was masking
101 /// (fixed 2026-09-06): `ssh_share::socket_dir` budgeted for the 40-character
102 /// `%C` hash but not for the `.XXXXXXXXXXXXXXXX` ssh appends while bringing the
103 /// master up, so a directory of 51-59 bytes passed the guard, ssh refused the
104 /// path it was handed, and `git fetch` died with exit 128. A sync that cannot
105 /// share has to fall back to two connections and still finish.
106 #[test]
107 fn a_tmpdir_too_long_for_a_control_socket_still_syncs() {
108 let harness = ServerHarness::new("long-tmpdir");
109
110 // The length matters, and "long" is not enough: a *very* long TMPDIR was
111 // refused by the old budget too, so a fixture like that passes either way
112 // and proves nothing. The bug lived in a band — paths the old budget
113 // accepted and ssh then refused — and this fixture has to sit in it.
114 //
115 // The socket directory is `<TMPDIR>/git-collab-ssh-<pid>`. At 34 bytes of
116 // TMPDIR that directory is 55-57 bytes for any pid of 5 to 7 digits, so
117 // the old budget (dir + "/" + 40) came to 96-98 and passed, while the name
118 // ssh actually opens (another 17 bytes) is 113-115 and exceeds every
119 // platform's `sun_path`. Built under /tmp so the length is this test's
120 // choice and not the environment's.
121 const TARGET_TMPDIR_LEN: usize = 34;
122 let root = tempfile::TempDir::new_in("/tmp").expect("temp dir under /tmp");
123 let pad = TARGET_TMPDIR_LEN
124 .checked_sub(root.path().as_os_str().len() + "/".len())
125 .expect("temp root is already longer than the target length");
126 let long = root.path().join("d".repeat(pad));
127 std::fs::create_dir_all(&long).unwrap();
128 assert_eq!(
129 long.as_os_str().len(),
130 TARGET_TMPDIR_LEN,
131 "this fixture only reproduces the bug at exactly this length"
132 );
133
134 let output = sync_with_tmpdir(&harness, "Synced without sharing", &long);
135
136 assert!(
137 output.status.success(),
138 "a sync that cannot share a connection must still succeed, got {:?}:\n{}{}",
139 output.status.code(),
140 String::from_utf8_lossy(&output.stdout),
141 String::from_utf8_lossy(&output.stderr),
142 );
143
144 // And it really did decline to share: fetch and push logged in separately.
145 // Asserting the count, not just success, keeps this from passing for the
146 // wrong reason if sharing ever starts working under a long path.
147 let log = harness.server_log();
148 let logins = log.matches("Public key auth accepted").count();
149 assert_eq!(
150 logins, 2,
151 "expected the unshared fallback to log in twice, saw {logins}:\n{log}"
152 );
153 }