2e8fe285
Cover the control-socket overflow that broke a sync outright
a73x 2026-09-06 08:53
Commit message
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 | } | ||