1use crate::{
2 RemoteClientDelegate, RemotePlatform,
3 remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
4};
5use anyhow::{Context as _, Result, anyhow};
6use async_trait::async_trait;
7use collections::HashMap;
8use futures::{
9 AsyncReadExt as _, FutureExt as _,
10 channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
11 select_biased,
12};
13use gpui::{App, AppContext as _, AsyncApp, Task};
14use parking_lot::Mutex;
15use paths::remote_server_dir_relative;
16use release_channel::{AppVersion, ReleaseChannel};
17use rpc::proto::Envelope;
18use semver::Version;
19pub use settings::SshPortForwardOption;
20use smol::{
21 fs,
22 process::{self, Child, Stdio},
23};
24use std::{
25 path::{Path, PathBuf},
26 sync::Arc,
27 time::Instant,
28};
29use tempfile::TempDir;
30use util::{
31 paths::{PathStyle, RemotePathBuf},
32 rel_path::RelPath,
33 shell::ShellKind,
34};
35
36pub(crate) struct SshRemoteConnection {
37 socket: SshSocket,
38 master_process: Mutex<Option<MasterProcess>>,
39 remote_binary_path: Option<Arc<RelPath>>,
40 ssh_platform: RemotePlatform,
41 ssh_path_style: PathStyle,
42 ssh_shell: String,
43 ssh_shell_kind: ShellKind,
44 ssh_default_system_shell: String,
45 _temp_dir: TempDir,
46}
47
48#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
49pub struct SshConnectionOptions {
50 pub host: String,
51 pub username: Option<String>,
52 pub port: Option<u16>,
53 pub password: Option<String>,
54 pub args: Option<Vec<String>>,
55 pub port_forwards: Option<Vec<SshPortForwardOption>>,
56
57 pub nickname: Option<String>,
58 pub upload_binary_over_ssh: bool,
59}
60
61impl From<settings::SshConnection> for SshConnectionOptions {
62 fn from(val: settings::SshConnection) -> Self {
63 SshConnectionOptions {
64 host: val.host.into(),
65 username: val.username,
66 port: val.port,
67 password: None,
68 args: Some(val.args),
69 nickname: val.nickname,
70 upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
71 port_forwards: val.port_forwards,
72 }
73 }
74}
75
76struct SshSocket {
77 connection_options: SshConnectionOptions,
78 #[cfg(not(target_os = "windows"))]
79 socket_path: std::path::PathBuf,
80 envs: HashMap<String, String>,
81 #[cfg(target_os = "windows")]
82 _proxy: askpass::PasswordProxy,
83}
84
85struct MasterProcess {
86 process: Child,
87}
88
89#[cfg(not(target_os = "windows"))]
90impl MasterProcess {
91 pub fn new(
92 askpass_script_path: &std::ffi::OsStr,
93 additional_args: Vec<String>,
94 socket_path: &std::path::Path,
95 url: &str,
96 ) -> Result<Self> {
97 let args = [
98 "-N",
99 "-o",
100 "ControlPersist=no",
101 "-o",
102 "ControlMaster=yes",
103 "-o",
104 ];
105
106 let mut master_process = util::command::new_smol_command("ssh");
107 master_process
108 .kill_on_drop(true)
109 .stdin(Stdio::null())
110 .stdout(Stdio::piped())
111 .stderr(Stdio::piped())
112 .env("SSH_ASKPASS_REQUIRE", "force")
113 .env("SSH_ASKPASS", askpass_script_path)
114 .args(additional_args)
115 .args(args);
116
117 master_process.arg(format!("ControlPath='{}'", socket_path.display()));
118
119 let process = master_process.arg(&url).spawn()?;
120
121 Ok(MasterProcess { process })
122 }
123
124 pub async fn wait_connected(&mut self) -> Result<()> {
125 let Some(mut stdout) = self.process.stdout.take() else {
126 anyhow::bail!("ssh process stdout capture failed");
127 };
128
129 let mut output = Vec::new();
130 stdout.read_to_end(&mut output).await?;
131 Ok(())
132 }
133}
134
135#[cfg(target_os = "windows")]
136impl MasterProcess {
137 const CONNECTION_ESTABLISHED_MAGIC: &str = "ZED_SSH_CONNECTION_ESTABLISHED";
138
139 pub fn new(
140 askpass_script_path: &std::ffi::OsStr,
141 additional_args: Vec<String>,
142 url: &str,
143 ) -> Result<Self> {
144 // On Windows, `ControlMaster` and `ControlPath` are not supported:
145 // https://github.com/PowerShell/Win32-OpenSSH/issues/405
146 // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
147 //
148 // Using an ugly workaround to detect connection establishment
149 // -N doesn't work with JumpHosts as windows openssh never closes stdin in that case
150 let args = [
151 "-t",
152 &format!("echo '{}'; exec $0", Self::CONNECTION_ESTABLISHED_MAGIC),
153 ];
154
155 let mut master_process = util::command::new_smol_command("ssh");
156 master_process
157 .kill_on_drop(true)
158 .stdin(Stdio::null())
159 .stdout(Stdio::piped())
160 .stderr(Stdio::piped())
161 .env("SSH_ASKPASS_REQUIRE", "force")
162 .env("SSH_ASKPASS", askpass_script_path)
163 .args(additional_args)
164 .arg(url)
165 .args(args);
166
167 let process = master_process.spawn()?;
168
169 Ok(MasterProcess { process })
170 }
171
172 pub async fn wait_connected(&mut self) -> Result<()> {
173 use smol::io::AsyncBufReadExt;
174
175 let Some(stdout) = self.process.stdout.take() else {
176 anyhow::bail!("ssh process stdout capture failed");
177 };
178
179 let mut reader = smol::io::BufReader::new(stdout);
180
181 let mut line = String::new();
182
183 loop {
184 let n = reader.read_line(&mut line).await?;
185 if n == 0 {
186 anyhow::bail!("ssh process exited before connection established");
187 }
188
189 if line.contains(Self::CONNECTION_ESTABLISHED_MAGIC) {
190 return Ok(());
191 }
192 }
193 }
194}
195
196impl AsRef<Child> for MasterProcess {
197 fn as_ref(&self) -> &Child {
198 &self.process
199 }
200}
201
202impl AsMut<Child> for MasterProcess {
203 fn as_mut(&mut self) -> &mut Child {
204 &mut self.process
205 }
206}
207
208#[async_trait(?Send)]
209impl RemoteConnection for SshRemoteConnection {
210 async fn kill(&self) -> Result<()> {
211 let Some(mut process) = self.master_process.lock().take() else {
212 return Ok(());
213 };
214 process.as_mut().kill().ok();
215 process.as_mut().status().await?;
216 Ok(())
217 }
218
219 fn has_been_killed(&self) -> bool {
220 self.master_process.lock().is_none()
221 }
222
223 fn connection_options(&self) -> RemoteConnectionOptions {
224 RemoteConnectionOptions::Ssh(self.socket.connection_options.clone())
225 }
226
227 fn shell(&self) -> String {
228 self.ssh_shell.clone()
229 }
230
231 fn default_system_shell(&self) -> String {
232 self.ssh_default_system_shell.clone()
233 }
234
235 fn build_command(
236 &self,
237 input_program: Option<String>,
238 input_args: &[String],
239 input_env: &HashMap<String, String>,
240 working_dir: Option<String>,
241 port_forward: Option<(u16, String, u16)>,
242 ) -> Result<CommandTemplate> {
243 let Self {
244 ssh_path_style,
245 socket,
246 ssh_shell_kind,
247 ssh_shell,
248 ..
249 } = self;
250 let env = socket.envs.clone();
251 build_command(
252 input_program,
253 input_args,
254 input_env,
255 working_dir,
256 port_forward,
257 env,
258 *ssh_path_style,
259 ssh_shell,
260 *ssh_shell_kind,
261 socket.ssh_args(),
262 )
263 }
264
265 fn build_forward_ports_command(
266 &self,
267 forwards: Vec<(u16, String, u16)>,
268 ) -> Result<CommandTemplate> {
269 let Self { socket, .. } = self;
270 let mut args = socket.ssh_args();
271 args.push("-N".into());
272 for (local_port, host, remote_port) in forwards {
273 args.push("-L".into());
274 args.push(format!("{local_port}:{host}:{remote_port}"));
275 }
276 Ok(CommandTemplate {
277 program: "ssh".into(),
278 args,
279 env: Default::default(),
280 })
281 }
282
283 fn upload_directory(
284 &self,
285 src_path: PathBuf,
286 dest_path: RemotePathBuf,
287 cx: &App,
288 ) -> Task<Result<()>> {
289 let dest_path_str = dest_path.to_string();
290 let src_path_display = src_path.display().to_string();
291
292 let mut sftp_command = self.build_sftp_command();
293 let mut scp_command =
294 self.build_scp_command(&src_path, &dest_path_str, Some(&["-C", "-r"]));
295
296 cx.background_spawn(async move {
297 // We will try SFTP first, and if that fails, we will fall back to SCP.
298 // If SCP fails also, we give up and return an error.
299 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
300 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
301 // This is for example the case on Windows as evidenced by this implementation snippet:
302 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
303 if Self::is_sftp_available().await {
304 log::debug!("using SFTP for directory upload");
305 let mut child = sftp_command.spawn()?;
306 if let Some(mut stdin) = child.stdin.take() {
307 use futures::AsyncWriteExt;
308 let sftp_batch = format!("put -r \"{src_path_display}\" \"{dest_path_str}\"\n");
309 stdin.write_all(sftp_batch.as_bytes()).await?;
310 stdin.flush().await?;
311 }
312
313 let output = child.output().await?;
314 if output.status.success() {
315 return Ok(());
316 }
317
318 let stderr = String::from_utf8_lossy(&output.stderr);
319 log::debug!("failed to upload directory via SFTP {src_path_display} -> {dest_path_str}: {stderr}");
320 }
321
322 log::debug!("using SCP for directory upload");
323 let output = scp_command.output().await?;
324
325 if output.status.success() {
326 return Ok(());
327 }
328
329 let stderr = String::from_utf8_lossy(&output.stderr);
330 log::debug!("failed to upload directory via SCP {src_path_display} -> {dest_path_str}: {stderr}");
331
332 anyhow::bail!(
333 "failed to upload directory via SFTP/SCP {} -> {}: {}",
334 src_path_display,
335 dest_path_str,
336 stderr,
337 );
338 })
339 }
340
341 fn start_proxy(
342 &self,
343 unique_identifier: String,
344 reconnect: bool,
345 incoming_tx: UnboundedSender<Envelope>,
346 outgoing_rx: UnboundedReceiver<Envelope>,
347 connection_activity_tx: Sender<()>,
348 delegate: Arc<dyn RemoteClientDelegate>,
349 cx: &mut AsyncApp,
350 ) -> Task<Result<i32>> {
351 delegate.set_status(Some("Starting proxy"), cx);
352
353 let Some(remote_binary_path) = self.remote_binary_path.clone() else {
354 return Task::ready(Err(anyhow!("Remote binary path not set")));
355 };
356
357 let mut proxy_args = vec![];
358 for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
359 if let Some(value) = std::env::var(env_var).ok() {
360 proxy_args.push(format!("{}='{}'", env_var, value));
361 }
362 }
363 proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
364 proxy_args.push("proxy".to_owned());
365 proxy_args.push("--identifier".to_owned());
366 proxy_args.push(unique_identifier);
367
368 if reconnect {
369 proxy_args.push("--reconnect".to_owned());
370 }
371
372 let ssh_proxy_process = match self
373 .socket
374 .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
375 // IMPORTANT: we kill this process when we drop the task that uses it.
376 .kill_on_drop(true)
377 .spawn()
378 {
379 Ok(process) => process,
380 Err(error) => {
381 return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
382 }
383 };
384
385 super::handle_rpc_messages_over_child_process_stdio(
386 ssh_proxy_process,
387 incoming_tx,
388 outgoing_rx,
389 connection_activity_tx,
390 cx,
391 )
392 }
393
394 fn path_style(&self) -> PathStyle {
395 self.ssh_path_style
396 }
397
398 fn has_wsl_interop(&self) -> bool {
399 false
400 }
401}
402
403impl SshRemoteConnection {
404 pub(crate) async fn new(
405 connection_options: SshConnectionOptions,
406 delegate: Arc<dyn RemoteClientDelegate>,
407 cx: &mut AsyncApp,
408 ) -> Result<Self> {
409 use askpass::AskPassResult;
410
411 let url = connection_options.ssh_url();
412
413 let temp_dir = tempfile::Builder::new()
414 .prefix("zed-ssh-session")
415 .tempdir()?;
416 let askpass_delegate = askpass::AskPassDelegate::new(cx, {
417 let delegate = delegate.clone();
418 move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
419 });
420
421 let mut askpass =
422 askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
423
424 delegate.set_status(Some("Connecting"), cx);
425
426 // Start the master SSH process, which does not do anything except for establish
427 // the connection and keep it open, allowing other ssh commands to reuse it
428 // via a control socket.
429 #[cfg(not(target_os = "windows"))]
430 let socket_path = temp_dir.path().join("ssh.sock");
431
432 #[cfg(target_os = "windows")]
433 let mut master_process = MasterProcess::new(
434 askpass.script_path().as_ref(),
435 connection_options.additional_args(),
436 &url,
437 )?;
438 #[cfg(not(target_os = "windows"))]
439 let mut master_process = MasterProcess::new(
440 askpass.script_path().as_ref(),
441 connection_options.additional_args(),
442 &socket_path,
443 &url,
444 )?;
445
446 let result = select_biased! {
447 result = askpass.run().fuse() => {
448 match result {
449 AskPassResult::CancelledByUser => {
450 master_process.as_mut().kill().ok();
451 anyhow::bail!("SSH connection canceled")
452 }
453 AskPassResult::Timedout => {
454 anyhow::bail!("connecting to host timed out")
455 }
456 }
457 }
458 _ = master_process.wait_connected().fuse() => {
459 anyhow::Ok(())
460 }
461 };
462
463 if let Err(e) = result {
464 return Err(e.context("Failed to connect to host"));
465 }
466
467 if master_process.as_mut().try_status()?.is_some() {
468 let mut output = Vec::new();
469 output.clear();
470 let mut stderr = master_process.as_mut().stderr.take().unwrap();
471 stderr.read_to_end(&mut output).await?;
472
473 let error_message = format!(
474 "failed to connect: {}",
475 String::from_utf8_lossy(&output).trim()
476 );
477 anyhow::bail!(error_message);
478 }
479
480 #[cfg(not(target_os = "windows"))]
481 let socket = SshSocket::new(connection_options, socket_path).await?;
482 #[cfg(target_os = "windows")]
483 let socket = SshSocket::new(
484 connection_options,
485 askpass
486 .get_password()
487 .or_else(|| askpass::EncryptedPassword::try_from("").ok())
488 .context("Failed to fetch askpass password")?,
489 cx.background_executor().clone(),
490 )
491 .await?;
492 drop(askpass);
493
494 let ssh_shell = socket.shell().await;
495 log::info!("Remote shell discovered: {}", ssh_shell);
496 let ssh_platform = socket.platform(ShellKind::new(&ssh_shell, false)).await?;
497 log::info!("Remote platform discovered: {:?}", ssh_platform);
498 let ssh_path_style = match ssh_platform.os {
499 "windows" => PathStyle::Windows,
500 _ => PathStyle::Posix,
501 };
502 let ssh_default_system_shell = String::from("/bin/sh");
503 let ssh_shell_kind = ShellKind::new(
504 &ssh_shell,
505 match ssh_platform.os {
506 "windows" => true,
507 _ => false,
508 },
509 );
510
511 let mut this = Self {
512 socket,
513 master_process: Mutex::new(Some(master_process)),
514 _temp_dir: temp_dir,
515 remote_binary_path: None,
516 ssh_path_style,
517 ssh_platform,
518 ssh_shell,
519 ssh_shell_kind,
520 ssh_default_system_shell,
521 };
522
523 let (release_channel, version) =
524 cx.update(|cx| (ReleaseChannel::global(cx), AppVersion::global(cx)))?;
525 this.remote_binary_path = Some(
526 this.ensure_server_binary(&delegate, release_channel, version, cx)
527 .await?,
528 );
529
530 Ok(this)
531 }
532
533 async fn ensure_server_binary(
534 &self,
535 delegate: &Arc<dyn RemoteClientDelegate>,
536 release_channel: ReleaseChannel,
537 version: Version,
538 cx: &mut AsyncApp,
539 ) -> Result<Arc<RelPath>> {
540 let version_str = match release_channel {
541 ReleaseChannel::Dev => "build".to_string(),
542 _ => version.to_string(),
543 };
544 let binary_name = format!(
545 "zed-remote-server-{}-{}",
546 release_channel.dev_name(),
547 version_str
548 );
549 let dst_path =
550 paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
551
552 #[cfg(debug_assertions)]
553 if let Some(remote_server_path) =
554 super::build_remote_server_from_source(&self.ssh_platform, delegate.as_ref(), cx)
555 .await?
556 {
557 let tmp_path = paths::remote_server_dir_relative().join(
558 RelPath::unix(&format!(
559 "download-{}-{}",
560 std::process::id(),
561 remote_server_path.file_name().unwrap().to_string_lossy()
562 ))
563 .unwrap(),
564 );
565 self.upload_local_server_binary(&remote_server_path, &tmp_path, delegate, cx)
566 .await?;
567 self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
568 .await?;
569 return Ok(dst_path);
570 }
571
572 if self
573 .socket
574 .run_command(
575 self.ssh_shell_kind,
576 &dst_path.display(self.path_style()),
577 &["version"],
578 true,
579 )
580 .await
581 .is_ok()
582 {
583 return Ok(dst_path);
584 }
585
586 let wanted_version = cx.update(|cx| match release_channel {
587 ReleaseChannel::Nightly => Ok(None),
588 ReleaseChannel::Dev => {
589 anyhow::bail!(
590 "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
591 dst_path
592 )
593 }
594 _ => Ok(Some(AppVersion::global(cx))),
595 })??;
596
597 let tmp_path_gz = remote_server_dir_relative().join(
598 RelPath::unix(&format!(
599 "{}-download-{}.gz",
600 binary_name,
601 std::process::id()
602 ))
603 .unwrap(),
604 );
605 if !self.socket.connection_options.upload_binary_over_ssh
606 && let Some(url) = delegate
607 .get_download_url(
608 self.ssh_platform,
609 release_channel,
610 wanted_version.clone(),
611 cx,
612 )
613 .await?
614 {
615 match self
616 .download_binary_on_server(&url, &tmp_path_gz, delegate, cx)
617 .await
618 {
619 Ok(_) => {
620 self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
621 .await
622 .context("extracting server binary")?;
623 return Ok(dst_path);
624 }
625 Err(e) => {
626 log::error!(
627 "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
628 )
629 }
630 }
631 }
632
633 let src_path = delegate
634 .download_server_binary_locally(
635 self.ssh_platform,
636 release_channel,
637 wanted_version.clone(),
638 cx,
639 )
640 .await
641 .context("downloading server binary locally")?;
642 self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
643 .await
644 .context("uploading server binary")?;
645 self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
646 .await
647 .context("extracting server binary")?;
648 Ok(dst_path)
649 }
650
651 async fn download_binary_on_server(
652 &self,
653 url: &str,
654 tmp_path_gz: &RelPath,
655 delegate: &Arc<dyn RemoteClientDelegate>,
656 cx: &mut AsyncApp,
657 ) -> Result<()> {
658 if let Some(parent) = tmp_path_gz.parent() {
659 self.socket
660 .run_command(
661 self.ssh_shell_kind,
662 "mkdir",
663 &["-p", parent.display(self.path_style()).as_ref()],
664 true,
665 )
666 .await?;
667 }
668
669 delegate.set_status(Some("Downloading remote development server on host"), cx);
670
671 const CONNECT_TIMEOUT_SECS: &str = "10";
672
673 match self
674 .socket
675 .run_command(
676 self.ssh_shell_kind,
677 "curl",
678 &[
679 "-f",
680 "-L",
681 "--connect-timeout",
682 CONNECT_TIMEOUT_SECS,
683 url,
684 "-o",
685 &tmp_path_gz.display(self.path_style()),
686 ],
687 true,
688 )
689 .await
690 {
691 Ok(_) => {}
692 Err(e) => {
693 if self
694 .socket
695 .run_command(self.ssh_shell_kind, "which", &["curl"], true)
696 .await
697 .is_ok()
698 {
699 return Err(e);
700 }
701
702 log::info!("curl is not available, trying wget");
703 match self
704 .socket
705 .run_command(
706 self.ssh_shell_kind,
707 "wget",
708 &[
709 "--connect-timeout",
710 CONNECT_TIMEOUT_SECS,
711 "--tries",
712 "1",
713 url,
714 "-O",
715 &tmp_path_gz.display(self.path_style()),
716 ],
717 true,
718 )
719 .await
720 {
721 Ok(_) => {}
722 Err(e) => {
723 if self
724 .socket
725 .run_command(self.ssh_shell_kind, "which", &["wget"], true)
726 .await
727 .is_ok()
728 {
729 return Err(e);
730 } else {
731 anyhow::bail!("Neither curl nor wget is available");
732 }
733 }
734 }
735 }
736 }
737
738 Ok(())
739 }
740
741 async fn upload_local_server_binary(
742 &self,
743 src_path: &Path,
744 tmp_path_gz: &RelPath,
745 delegate: &Arc<dyn RemoteClientDelegate>,
746 cx: &mut AsyncApp,
747 ) -> Result<()> {
748 if let Some(parent) = tmp_path_gz.parent() {
749 self.socket
750 .run_command(
751 self.ssh_shell_kind,
752 "mkdir",
753 &["-p", parent.display(self.path_style()).as_ref()],
754 true,
755 )
756 .await?;
757 }
758
759 let src_stat = fs::metadata(&src_path).await?;
760 let size = src_stat.len();
761
762 let t0 = Instant::now();
763 delegate.set_status(Some("Uploading remote development server"), cx);
764 log::info!(
765 "uploading remote development server to {:?} ({}kb)",
766 tmp_path_gz,
767 size / 1024
768 );
769 self.upload_file(src_path, tmp_path_gz)
770 .await
771 .context("failed to upload server binary")?;
772 log::info!("uploaded remote development server in {:?}", t0.elapsed());
773 Ok(())
774 }
775
776 async fn extract_server_binary(
777 &self,
778 dst_path: &RelPath,
779 tmp_path: &RelPath,
780 delegate: &Arc<dyn RemoteClientDelegate>,
781 cx: &mut AsyncApp,
782 ) -> Result<()> {
783 delegate.set_status(Some("Extracting remote development server"), cx);
784 let server_mode = 0o755;
785
786 let shell_kind = ShellKind::Posix;
787 let orig_tmp_path = tmp_path.display(self.path_style());
788 let server_mode = format!("{:o}", server_mode);
789 let server_mode = shell_kind
790 .try_quote(&server_mode)
791 .context("shell quoting")?;
792 let dst_path = dst_path.display(self.path_style());
793 let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
794 let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
795 let orig_tmp_path = shell_kind
796 .try_quote(&orig_tmp_path)
797 .context("shell quoting")?;
798 let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
799 format!(
800 "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
801 )
802 } else {
803 let orig_tmp_path = shell_kind
804 .try_quote(&orig_tmp_path)
805 .context("shell quoting")?;
806 format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
807 };
808 let args = shell_kind.args_for_shell(false, script.to_string());
809 self.socket
810 .run_command(shell_kind, "sh", &args, true)
811 .await?;
812 Ok(())
813 }
814
815 fn build_scp_command(
816 &self,
817 src_path: &Path,
818 dest_path_str: &str,
819 args: Option<&[&str]>,
820 ) -> process::Command {
821 let mut command = util::command::new_smol_command("scp");
822 self.socket.ssh_options(&mut command, false).args(
823 self.socket
824 .connection_options
825 .port
826 .map(|port| vec!["-P".to_string(), port.to_string()])
827 .unwrap_or_default(),
828 );
829 if let Some(args) = args {
830 command.args(args);
831 }
832 command.arg(src_path).arg(format!(
833 "{}:{}",
834 self.socket.connection_options.scp_url(),
835 dest_path_str
836 ));
837 command
838 }
839
840 fn build_sftp_command(&self) -> process::Command {
841 let mut command = util::command::new_smol_command("sftp");
842 self.socket.ssh_options(&mut command, false).args(
843 self.socket
844 .connection_options
845 .port
846 .map(|port| vec!["-P".to_string(), port.to_string()])
847 .unwrap_or_default(),
848 );
849 command.arg("-b").arg("-");
850 command.arg(self.socket.connection_options.scp_url());
851 command.stdin(Stdio::piped());
852 command
853 }
854
855 async fn upload_file(&self, src_path: &Path, dest_path: &RelPath) -> Result<()> {
856 log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
857
858 let src_path_display = src_path.display().to_string();
859 let dest_path_str = dest_path.display(self.path_style());
860
861 // We will try SFTP first, and if that fails, we will fall back to SCP.
862 // If SCP fails also, we give up and return an error.
863 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
864 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
865 // This is for example the case on Windows as evidenced by this implementation snippet:
866 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
867 if Self::is_sftp_available().await {
868 log::debug!("using SFTP for file upload");
869 let mut command = self.build_sftp_command();
870 let sftp_batch = format!("put {src_path_display} {dest_path_str}\n");
871
872 let mut child = command.spawn()?;
873 if let Some(mut stdin) = child.stdin.take() {
874 use futures::AsyncWriteExt;
875 stdin.write_all(sftp_batch.as_bytes()).await?;
876 stdin.flush().await?;
877 }
878
879 let output = child.output().await?;
880 if output.status.success() {
881 return Ok(());
882 }
883
884 let stderr = String::from_utf8_lossy(&output.stderr);
885 log::debug!(
886 "failed to upload file via SFTP {src_path_display} -> {dest_path_str}: {stderr}"
887 );
888 }
889
890 log::debug!("using SCP for file upload");
891 let mut command = self.build_scp_command(src_path, &dest_path_str, None);
892 let output = command.output().await?;
893
894 if output.status.success() {
895 return Ok(());
896 }
897
898 let stderr = String::from_utf8_lossy(&output.stderr);
899 log::debug!(
900 "failed to upload file via SCP {src_path_display} -> {dest_path_str}: {stderr}",
901 );
902 anyhow::bail!(
903 "failed to upload file via STFP/SCP {} -> {}: {}",
904 src_path_display,
905 dest_path_str,
906 stderr,
907 );
908 }
909
910 async fn is_sftp_available() -> bool {
911 which::which("sftp").is_ok()
912 }
913}
914
915impl SshSocket {
916 #[cfg(not(target_os = "windows"))]
917 async fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
918 Ok(Self {
919 connection_options: options,
920 envs: HashMap::default(),
921 socket_path,
922 })
923 }
924
925 #[cfg(target_os = "windows")]
926 async fn new(
927 options: SshConnectionOptions,
928 password: askpass::EncryptedPassword,
929 executor: gpui::BackgroundExecutor,
930 ) -> Result<Self> {
931 let mut envs = HashMap::default();
932 let get_password =
933 move |_| Task::ready(std::ops::ControlFlow::Continue(Ok(password.clone())));
934
935 let _proxy = askpass::PasswordProxy::new(get_password, executor).await?;
936 envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
937 envs.insert(
938 "SSH_ASKPASS".into(),
939 _proxy.script_path().as_ref().display().to_string(),
940 );
941
942 Ok(Self {
943 connection_options: options,
944 envs,
945 _proxy,
946 })
947 }
948
949 // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
950 // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
951 // and passes -l as an argument to sh, not to ls.
952 // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
953 // into a machine. You must use `cd` to get back to $HOME.
954 // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
955 fn ssh_command(
956 &self,
957 shell_kind: ShellKind,
958 program: &str,
959 args: &[impl AsRef<str>],
960 allow_pseudo_tty: bool,
961 ) -> process::Command {
962 let mut command = util::command::new_smol_command("ssh");
963 let program = shell_kind.prepend_command_prefix(program);
964 let mut to_run = shell_kind
965 .try_quote_prefix_aware(&program)
966 .expect("shell quoting")
967 .into_owned();
968 for arg in args {
969 // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
970 debug_assert!(
971 !arg.as_ref().contains('\n'),
972 "multiline arguments do not work in all shells"
973 );
974 to_run.push(' ');
975 to_run.push_str(&shell_kind.try_quote(arg.as_ref()).expect("shell quoting"));
976 }
977 let separator = shell_kind.sequential_commands_separator();
978 let to_run = format!("cd{separator} {to_run}");
979 self.ssh_options(&mut command, true)
980 .arg(self.connection_options.ssh_url());
981 if !allow_pseudo_tty {
982 command.arg("-T");
983 }
984 command.arg(to_run);
985 log::debug!("ssh {:?}", command);
986 command
987 }
988
989 async fn run_command(
990 &self,
991 shell_kind: ShellKind,
992 program: &str,
993 args: &[impl AsRef<str>],
994 allow_pseudo_tty: bool,
995 ) -> Result<String> {
996 let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
997 let output = command.output().await?;
998 anyhow::ensure!(
999 output.status.success(),
1000 "failed to run command {command:?}: {}",
1001 String::from_utf8_lossy(&output.stderr)
1002 );
1003 Ok(String::from_utf8_lossy(&output.stdout).to_string())
1004 }
1005
1006 #[cfg(not(target_os = "windows"))]
1007 fn ssh_options<'a>(
1008 &self,
1009 command: &'a mut process::Command,
1010 include_port_forwards: bool,
1011 ) -> &'a mut process::Command {
1012 let args = if include_port_forwards {
1013 self.connection_options.additional_args()
1014 } else {
1015 self.connection_options.additional_args_for_scp()
1016 };
1017
1018 command
1019 .stdin(Stdio::piped())
1020 .stdout(Stdio::piped())
1021 .stderr(Stdio::piped())
1022 .args(args)
1023 .args(["-o", "ControlMaster=no", "-o"])
1024 .arg(format!("ControlPath={}", self.socket_path.display()))
1025 }
1026
1027 #[cfg(target_os = "windows")]
1028 fn ssh_options<'a>(
1029 &self,
1030 command: &'a mut process::Command,
1031 include_port_forwards: bool,
1032 ) -> &'a mut process::Command {
1033 let args = if include_port_forwards {
1034 self.connection_options.additional_args()
1035 } else {
1036 self.connection_options.additional_args_for_scp()
1037 };
1038
1039 command
1040 .stdin(Stdio::piped())
1041 .stdout(Stdio::piped())
1042 .stderr(Stdio::piped())
1043 .args(args)
1044 .envs(self.envs.clone())
1045 }
1046
1047 // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
1048 // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
1049 #[cfg(not(target_os = "windows"))]
1050 fn ssh_args(&self) -> Vec<String> {
1051 let mut arguments = self.connection_options.additional_args();
1052 arguments.extend(vec![
1053 "-o".to_string(),
1054 "ControlMaster=no".to_string(),
1055 "-o".to_string(),
1056 format!("ControlPath={}", self.socket_path.display()),
1057 self.connection_options.ssh_url(),
1058 ]);
1059 arguments
1060 }
1061
1062 #[cfg(target_os = "windows")]
1063 fn ssh_args(&self) -> Vec<String> {
1064 let mut arguments = self.connection_options.additional_args();
1065 arguments.push(self.connection_options.ssh_url());
1066 arguments
1067 }
1068
1069 async fn platform(&self, shell: ShellKind) -> Result<RemotePlatform> {
1070 let output = self.run_command(shell, "uname", &["-sm"], false).await?;
1071 parse_platform(&output)
1072 }
1073
1074 async fn shell(&self) -> String {
1075 match self
1076 .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
1077 .await
1078 {
1079 Ok(output) => parse_shell(&output),
1080 Err(e) => {
1081 log::error!("Failed to get shell: {e}");
1082 DEFAULT_SHELL.to_owned()
1083 }
1084 }
1085 }
1086}
1087
1088const DEFAULT_SHELL: &str = "sh";
1089
1090/// Parses the output of `uname -sm` to determine the remote platform.
1091/// Takes the last line to skip possible shell initialization output.
1092fn parse_platform(output: &str) -> Result<RemotePlatform> {
1093 let output = output.trim();
1094 let uname = output.rsplit_once('\n').map_or(output, |(_, last)| last);
1095 let Some((os, arch)) = uname.split_once(" ") else {
1096 anyhow::bail!("unknown uname: {uname:?}")
1097 };
1098
1099 let os = match os {
1100 "Darwin" => "macos",
1101 "Linux" => "linux",
1102 _ => anyhow::bail!(
1103 "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
1104 ),
1105 };
1106
1107 // exclude armv5,6,7 as they are 32-bit.
1108 let arch = if arch.starts_with("armv8")
1109 || arch.starts_with("armv9")
1110 || arch.starts_with("arm64")
1111 || arch.starts_with("aarch64")
1112 {
1113 "aarch64"
1114 } else if arch.starts_with("x86") {
1115 "x86_64"
1116 } else {
1117 anyhow::bail!(
1118 "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
1119 )
1120 };
1121
1122 Ok(RemotePlatform { os, arch })
1123}
1124
1125/// Parses the output of `echo $SHELL` to determine the remote shell.
1126/// Takes the last line to skip possible shell initialization output.
1127fn parse_shell(output: &str) -> String {
1128 let output = output.trim();
1129 let shell = output.rsplit_once('\n').map_or(output, |(_, last)| last);
1130 if shell.is_empty() {
1131 log::error!("$SHELL is not set, falling back to {DEFAULT_SHELL}");
1132 DEFAULT_SHELL.to_owned()
1133 } else {
1134 shell.to_owned()
1135 }
1136}
1137
1138fn parse_port_number(port_str: &str) -> Result<u16> {
1139 port_str
1140 .parse()
1141 .with_context(|| format!("parsing port number: {port_str}"))
1142}
1143
1144fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
1145 let parts: Vec<&str> = spec.split(':').collect();
1146
1147 match parts.len() {
1148 4 => {
1149 let local_port = parse_port_number(parts[1])?;
1150 let remote_port = parse_port_number(parts[3])?;
1151
1152 Ok(SshPortForwardOption {
1153 local_host: Some(parts[0].to_string()),
1154 local_port,
1155 remote_host: Some(parts[2].to_string()),
1156 remote_port,
1157 })
1158 }
1159 3 => {
1160 let local_port = parse_port_number(parts[0])?;
1161 let remote_port = parse_port_number(parts[2])?;
1162
1163 Ok(SshPortForwardOption {
1164 local_host: None,
1165 local_port,
1166 remote_host: Some(parts[1].to_string()),
1167 remote_port,
1168 })
1169 }
1170 _ => anyhow::bail!("Invalid port forward format"),
1171 }
1172}
1173
1174impl SshConnectionOptions {
1175 pub fn parse_command_line(input: &str) -> Result<Self> {
1176 let input = input.trim_start_matches("ssh ");
1177 let mut hostname: Option<String> = None;
1178 let mut username: Option<String> = None;
1179 let mut port: Option<u16> = None;
1180 let mut args = Vec::new();
1181 let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
1182
1183 // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
1184 const ALLOWED_OPTS: &[&str] = &[
1185 "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
1186 ];
1187 const ALLOWED_ARGS: &[&str] = &[
1188 "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
1189 "-w",
1190 ];
1191
1192 let mut tokens = ShellKind::Posix
1193 .split(input)
1194 .context("invalid input")?
1195 .into_iter();
1196
1197 'outer: while let Some(arg) = tokens.next() {
1198 if ALLOWED_OPTS.contains(&(&arg as &str)) {
1199 args.push(arg.to_string());
1200 continue;
1201 }
1202 if arg == "-p" {
1203 port = tokens.next().and_then(|arg| arg.parse().ok());
1204 continue;
1205 } else if let Some(p) = arg.strip_prefix("-p") {
1206 port = p.parse().ok();
1207 continue;
1208 }
1209 if arg == "-l" {
1210 username = tokens.next();
1211 continue;
1212 } else if let Some(l) = arg.strip_prefix("-l") {
1213 username = Some(l.to_string());
1214 continue;
1215 }
1216 if arg == "-L" || arg.starts_with("-L") {
1217 let forward_spec = if arg == "-L" {
1218 tokens.next()
1219 } else {
1220 Some(arg.strip_prefix("-L").unwrap().to_string())
1221 };
1222
1223 if let Some(spec) = forward_spec {
1224 port_forwards.push(parse_port_forward_spec(&spec)?);
1225 } else {
1226 anyhow::bail!("Missing port forward format");
1227 }
1228 }
1229
1230 for a in ALLOWED_ARGS {
1231 if arg == *a {
1232 args.push(arg);
1233 if let Some(next) = tokens.next() {
1234 args.push(next);
1235 }
1236 continue 'outer;
1237 } else if arg.starts_with(a) {
1238 args.push(arg);
1239 continue 'outer;
1240 }
1241 }
1242 if arg.starts_with("-") || hostname.is_some() {
1243 anyhow::bail!("unsupported argument: {:?}", arg);
1244 }
1245 let mut input = &arg as &str;
1246 // Destination might be: username1@username2@ip2@ip1
1247 if let Some((u, rest)) = input.rsplit_once('@') {
1248 input = rest;
1249 username = Some(u.to_string());
1250 }
1251 if let Some((rest, p)) = input.split_once(':') {
1252 input = rest;
1253 port = p.parse().ok()
1254 }
1255 hostname = Some(input.to_string())
1256 }
1257
1258 let Some(hostname) = hostname else {
1259 anyhow::bail!("missing hostname");
1260 };
1261
1262 let port_forwards = match port_forwards.len() {
1263 0 => None,
1264 _ => Some(port_forwards),
1265 };
1266
1267 Ok(Self {
1268 host: hostname,
1269 username,
1270 port,
1271 port_forwards,
1272 args: Some(args),
1273 password: None,
1274 nickname: None,
1275 upload_binary_over_ssh: false,
1276 })
1277 }
1278
1279 pub fn ssh_url(&self) -> String {
1280 let mut result = String::from("ssh://");
1281 if let Some(username) = &self.username {
1282 // Username might be: username1@username2@ip2
1283 let username = urlencoding::encode(username);
1284 result.push_str(&username);
1285 result.push('@');
1286 }
1287 result.push_str(&self.host);
1288 if let Some(port) = self.port {
1289 result.push(':');
1290 result.push_str(&port.to_string());
1291 }
1292 result
1293 }
1294
1295 pub fn additional_args_for_scp(&self) -> Vec<String> {
1296 self.args.iter().flatten().cloned().collect::<Vec<String>>()
1297 }
1298
1299 pub fn additional_args(&self) -> Vec<String> {
1300 let mut args = self.additional_args_for_scp();
1301
1302 if let Some(forwards) = &self.port_forwards {
1303 args.extend(forwards.iter().map(|pf| {
1304 let local_host = match &pf.local_host {
1305 Some(host) => host,
1306 None => "localhost",
1307 };
1308 let remote_host = match &pf.remote_host {
1309 Some(host) => host,
1310 None => "localhost",
1311 };
1312
1313 format!(
1314 "-L{}:{}:{}:{}",
1315 local_host, pf.local_port, remote_host, pf.remote_port
1316 )
1317 }));
1318 }
1319
1320 args
1321 }
1322
1323 fn scp_url(&self) -> String {
1324 if let Some(username) = &self.username {
1325 format!("{}@{}", username, self.host)
1326 } else {
1327 self.host.clone()
1328 }
1329 }
1330
1331 pub fn connection_string(&self) -> String {
1332 let host = if let Some(username) = &self.username {
1333 format!("{}@{}", username, self.host)
1334 } else {
1335 self.host.clone()
1336 };
1337 if let Some(port) = &self.port {
1338 format!("{}:{}", host, port)
1339 } else {
1340 host
1341 }
1342 }
1343}
1344
1345fn build_command(
1346 input_program: Option<String>,
1347 input_args: &[String],
1348 input_env: &HashMap<String, String>,
1349 working_dir: Option<String>,
1350 port_forward: Option<(u16, String, u16)>,
1351 ssh_env: HashMap<String, String>,
1352 ssh_path_style: PathStyle,
1353 ssh_shell: &str,
1354 ssh_shell_kind: ShellKind,
1355 ssh_args: Vec<String>,
1356) -> Result<CommandTemplate> {
1357 use std::fmt::Write as _;
1358
1359 let mut exec = String::new();
1360 if let Some(working_dir) = working_dir {
1361 let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1362
1363 // shlex will wrap the command in single quotes (''), disabling ~ expansion,
1364 // replace with something that works
1365 const TILDE_PREFIX: &'static str = "~/";
1366 if working_dir.starts_with(TILDE_PREFIX) {
1367 let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
1368 write!(
1369 exec,
1370 "cd \"$HOME/{working_dir}\" {} ",
1371 ssh_shell_kind.sequential_and_commands_separator()
1372 )?;
1373 } else {
1374 write!(
1375 exec,
1376 "cd \"{working_dir}\" {} ",
1377 ssh_shell_kind.sequential_and_commands_separator()
1378 )?;
1379 }
1380 } else {
1381 write!(
1382 exec,
1383 "cd {} ",
1384 ssh_shell_kind.sequential_and_commands_separator()
1385 )?;
1386 };
1387 write!(exec, "exec env ")?;
1388
1389 for (k, v) in input_env.iter() {
1390 write!(
1391 exec,
1392 "{}={} ",
1393 k,
1394 ssh_shell_kind.try_quote(v).context("shell quoting")?
1395 )?;
1396 }
1397
1398 if let Some(input_program) = input_program {
1399 write!(
1400 exec,
1401 "{}",
1402 ssh_shell_kind
1403 .try_quote_prefix_aware(&input_program)
1404 .context("shell quoting")?
1405 )?;
1406 for arg in input_args {
1407 let arg = ssh_shell_kind.try_quote(&arg).context("shell quoting")?;
1408 write!(exec, " {}", &arg)?;
1409 }
1410 } else {
1411 write!(exec, "{ssh_shell} -l")?;
1412 };
1413
1414 let mut args = Vec::new();
1415 args.extend(ssh_args);
1416
1417 if let Some((local_port, host, remote_port)) = port_forward {
1418 args.push("-L".into());
1419 args.push(format!("{local_port}:{host}:{remote_port}"));
1420 }
1421
1422 args.push("-t".into());
1423 args.push(exec);
1424 Ok(CommandTemplate {
1425 program: "ssh".into(),
1426 args,
1427 env: ssh_env,
1428 })
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433 use super::*;
1434
1435 #[test]
1436 fn test_build_command() -> Result<()> {
1437 let mut input_env = HashMap::default();
1438 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1439 let mut env = HashMap::default();
1440 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1441
1442 let command = build_command(
1443 Some("remote_program".to_string()),
1444 &["arg1".to_string(), "arg2".to_string()],
1445 &input_env,
1446 Some("~/work".to_string()),
1447 None,
1448 env.clone(),
1449 PathStyle::Posix,
1450 "/bin/fish",
1451 ShellKind::Fish,
1452 vec!["-p".to_string(), "2222".to_string()],
1453 )?;
1454
1455 assert_eq!(command.program, "ssh");
1456 assert_eq!(
1457 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1458 [
1459 "-p",
1460 "2222",
1461 "-t",
1462 "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
1463 ]
1464 );
1465 assert_eq!(command.env, env);
1466
1467 let mut input_env = HashMap::default();
1468 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1469 let mut env = HashMap::default();
1470 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1471
1472 let command = build_command(
1473 None,
1474 &["arg1".to_string(), "arg2".to_string()],
1475 &input_env,
1476 None,
1477 Some((1, "foo".to_owned(), 2)),
1478 env.clone(),
1479 PathStyle::Posix,
1480 "/bin/fish",
1481 ShellKind::Fish,
1482 vec!["-p".to_string(), "2222".to_string()],
1483 )?;
1484
1485 assert_eq!(command.program, "ssh");
1486 assert_eq!(
1487 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1488 [
1489 "-p",
1490 "2222",
1491 "-L",
1492 "1:foo:2",
1493 "-t",
1494 "cd && exec env INPUT_VA=val /bin/fish -l"
1495 ]
1496 );
1497 assert_eq!(command.env, env);
1498
1499 Ok(())
1500 }
1501
1502 #[test]
1503 fn scp_args_exclude_port_forward_flags() {
1504 let options = SshConnectionOptions {
1505 host: "example.com".into(),
1506 args: Some(vec![
1507 "-p".to_string(),
1508 "2222".to_string(),
1509 "-o".to_string(),
1510 "StrictHostKeyChecking=no".to_string(),
1511 ]),
1512 port_forwards: Some(vec![SshPortForwardOption {
1513 local_host: Some("127.0.0.1".to_string()),
1514 local_port: 8080,
1515 remote_host: Some("127.0.0.1".to_string()),
1516 remote_port: 80,
1517 }]),
1518 ..Default::default()
1519 };
1520
1521 let ssh_args = options.additional_args();
1522 assert!(
1523 ssh_args.iter().any(|arg| arg.starts_with("-L")),
1524 "expected ssh args to include port-forward: {ssh_args:?}"
1525 );
1526
1527 let scp_args = options.additional_args_for_scp();
1528 assert_eq!(
1529 scp_args,
1530 vec![
1531 "-p".to_string(),
1532 "2222".to_string(),
1533 "-o".to_string(),
1534 "StrictHostKeyChecking=no".to_string(),
1535 ]
1536 );
1537 }
1538
1539 #[test]
1540 fn test_parse_platform() {
1541 let result = parse_platform("Linux x86_64\n").unwrap();
1542 assert_eq!(result.os, "linux");
1543 assert_eq!(result.arch, "x86_64");
1544
1545 let result = parse_platform("Darwin arm64\n").unwrap();
1546 assert_eq!(result.os, "macos");
1547 assert_eq!(result.arch, "aarch64");
1548
1549 let result = parse_platform("Linux x86_64").unwrap();
1550 assert_eq!(result.os, "linux");
1551 assert_eq!(result.arch, "x86_64");
1552
1553 let result = parse_platform("some shell init output\nLinux aarch64\n").unwrap();
1554 assert_eq!(result.os, "linux");
1555 assert_eq!(result.arch, "aarch64");
1556
1557 let result = parse_platform("some shell init output\nLinux aarch64").unwrap();
1558 assert_eq!(result.os, "linux");
1559 assert_eq!(result.arch, "aarch64");
1560
1561 assert_eq!(parse_platform("Linux armv8l\n").unwrap().arch, "aarch64");
1562 assert_eq!(parse_platform("Linux aarch64\n").unwrap().arch, "aarch64");
1563 assert_eq!(parse_platform("Linux x86_64\n").unwrap().arch, "x86_64");
1564
1565 let result = parse_platform(
1566 r#"Linux x86_64 - What you're referring to as Linux, is in fact, GNU/Linux...\n"#,
1567 )
1568 .unwrap();
1569 assert_eq!(result.os, "linux");
1570 assert_eq!(result.arch, "x86_64");
1571
1572 assert!(parse_platform("Windows x86_64\n").is_err());
1573 assert!(parse_platform("Linux armv7l\n").is_err());
1574 }
1575
1576 #[test]
1577 fn test_parse_shell() {
1578 assert_eq!(parse_shell("/bin/bash\n"), "/bin/bash");
1579 assert_eq!(parse_shell("/bin/zsh\n"), "/bin/zsh");
1580
1581 assert_eq!(parse_shell("/bin/bash"), "/bin/bash");
1582 assert_eq!(
1583 parse_shell("some shell init output\n/bin/bash\n"),
1584 "/bin/bash"
1585 );
1586 assert_eq!(
1587 parse_shell("some shell init output\n/bin/bash"),
1588 "/bin/bash"
1589 );
1590 assert_eq!(parse_shell(""), "sh");
1591 assert_eq!(parse_shell("\n"), "sh");
1592 }
1593}