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 match self
672 .socket
673 .run_command(
674 self.ssh_shell_kind,
675 "curl",
676 &[
677 "-f",
678 "-L",
679 url,
680 "-o",
681 &tmp_path_gz.display(self.path_style()),
682 ],
683 true,
684 )
685 .await
686 {
687 Ok(_) => {}
688 Err(e) => {
689 if self
690 .socket
691 .run_command(self.ssh_shell_kind, "which", &["curl"], true)
692 .await
693 .is_ok()
694 {
695 return Err(e);
696 }
697
698 log::info!("curl is not available, trying wget");
699 match self
700 .socket
701 .run_command(
702 self.ssh_shell_kind,
703 "wget",
704 &[url, "-O", &tmp_path_gz.display(self.path_style())],
705 true,
706 )
707 .await
708 {
709 Ok(_) => {}
710 Err(e) => {
711 if self
712 .socket
713 .run_command(self.ssh_shell_kind, "which", &["wget"], true)
714 .await
715 .is_ok()
716 {
717 return Err(e);
718 } else {
719 anyhow::bail!("Neither curl nor wget is available");
720 }
721 }
722 }
723 }
724 }
725
726 Ok(())
727 }
728
729 async fn upload_local_server_binary(
730 &self,
731 src_path: &Path,
732 tmp_path_gz: &RelPath,
733 delegate: &Arc<dyn RemoteClientDelegate>,
734 cx: &mut AsyncApp,
735 ) -> Result<()> {
736 if let Some(parent) = tmp_path_gz.parent() {
737 self.socket
738 .run_command(
739 self.ssh_shell_kind,
740 "mkdir",
741 &["-p", parent.display(self.path_style()).as_ref()],
742 true,
743 )
744 .await?;
745 }
746
747 let src_stat = fs::metadata(&src_path).await?;
748 let size = src_stat.len();
749
750 let t0 = Instant::now();
751 delegate.set_status(Some("Uploading remote development server"), cx);
752 log::info!(
753 "uploading remote development server to {:?} ({}kb)",
754 tmp_path_gz,
755 size / 1024
756 );
757 self.upload_file(src_path, tmp_path_gz)
758 .await
759 .context("failed to upload server binary")?;
760 log::info!("uploaded remote development server in {:?}", t0.elapsed());
761 Ok(())
762 }
763
764 async fn extract_server_binary(
765 &self,
766 dst_path: &RelPath,
767 tmp_path: &RelPath,
768 delegate: &Arc<dyn RemoteClientDelegate>,
769 cx: &mut AsyncApp,
770 ) -> Result<()> {
771 delegate.set_status(Some("Extracting remote development server"), cx);
772 let server_mode = 0o755;
773
774 let shell_kind = ShellKind::Posix;
775 let orig_tmp_path = tmp_path.display(self.path_style());
776 let server_mode = format!("{:o}", server_mode);
777 let server_mode = shell_kind
778 .try_quote(&server_mode)
779 .context("shell quoting")?;
780 let dst_path = dst_path.display(self.path_style());
781 let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
782 let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
783 let orig_tmp_path = shell_kind
784 .try_quote(&orig_tmp_path)
785 .context("shell quoting")?;
786 let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
787 format!(
788 "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
789 )
790 } else {
791 let orig_tmp_path = shell_kind
792 .try_quote(&orig_tmp_path)
793 .context("shell quoting")?;
794 format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
795 };
796 let args = shell_kind.args_for_shell(false, script.to_string());
797 self.socket
798 .run_command(shell_kind, "sh", &args, true)
799 .await?;
800 Ok(())
801 }
802
803 fn build_scp_command(
804 &self,
805 src_path: &Path,
806 dest_path_str: &str,
807 args: Option<&[&str]>,
808 ) -> process::Command {
809 let mut command = util::command::new_smol_command("scp");
810 self.socket.ssh_options(&mut command, false).args(
811 self.socket
812 .connection_options
813 .port
814 .map(|port| vec!["-P".to_string(), port.to_string()])
815 .unwrap_or_default(),
816 );
817 if let Some(args) = args {
818 command.args(args);
819 }
820 command.arg(src_path).arg(format!(
821 "{}:{}",
822 self.socket.connection_options.scp_url(),
823 dest_path_str
824 ));
825 command
826 }
827
828 fn build_sftp_command(&self) -> process::Command {
829 let mut command = util::command::new_smol_command("sftp");
830 self.socket.ssh_options(&mut command, false).args(
831 self.socket
832 .connection_options
833 .port
834 .map(|port| vec!["-P".to_string(), port.to_string()])
835 .unwrap_or_default(),
836 );
837 command.arg("-b").arg("-");
838 command.arg(self.socket.connection_options.scp_url());
839 command.stdin(Stdio::piped());
840 command
841 }
842
843 async fn upload_file(&self, src_path: &Path, dest_path: &RelPath) -> Result<()> {
844 log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
845
846 let src_path_display = src_path.display().to_string();
847 let dest_path_str = dest_path.display(self.path_style());
848
849 // We will try SFTP first, and if that fails, we will fall back to SCP.
850 // If SCP fails also, we give up and return an error.
851 // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
852 // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
853 // This is for example the case on Windows as evidenced by this implementation snippet:
854 // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
855 if Self::is_sftp_available().await {
856 log::debug!("using SFTP for file upload");
857 let mut command = self.build_sftp_command();
858 let sftp_batch = format!("put {src_path_display} {dest_path_str}\n");
859
860 let mut child = command.spawn()?;
861 if let Some(mut stdin) = child.stdin.take() {
862 use futures::AsyncWriteExt;
863 stdin.write_all(sftp_batch.as_bytes()).await?;
864 stdin.flush().await?;
865 }
866
867 let output = child.output().await?;
868 if output.status.success() {
869 return Ok(());
870 }
871
872 let stderr = String::from_utf8_lossy(&output.stderr);
873 log::debug!(
874 "failed to upload file via SFTP {src_path_display} -> {dest_path_str}: {stderr}"
875 );
876 }
877
878 log::debug!("using SCP for file upload");
879 let mut command = self.build_scp_command(src_path, &dest_path_str, None);
880 let output = command.output().await?;
881
882 if output.status.success() {
883 return Ok(());
884 }
885
886 let stderr = String::from_utf8_lossy(&output.stderr);
887 log::debug!(
888 "failed to upload file via SCP {src_path_display} -> {dest_path_str}: {stderr}",
889 );
890 anyhow::bail!(
891 "failed to upload file via STFP/SCP {} -> {}: {}",
892 src_path_display,
893 dest_path_str,
894 stderr,
895 );
896 }
897
898 async fn is_sftp_available() -> bool {
899 which::which("sftp").is_ok()
900 }
901}
902
903impl SshSocket {
904 #[cfg(not(target_os = "windows"))]
905 async fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
906 Ok(Self {
907 connection_options: options,
908 envs: HashMap::default(),
909 socket_path,
910 })
911 }
912
913 #[cfg(target_os = "windows")]
914 async fn new(
915 options: SshConnectionOptions,
916 password: askpass::EncryptedPassword,
917 executor: gpui::BackgroundExecutor,
918 ) -> Result<Self> {
919 let mut envs = HashMap::default();
920 let get_password =
921 move |_| Task::ready(std::ops::ControlFlow::Continue(Ok(password.clone())));
922
923 let _proxy = askpass::PasswordProxy::new(get_password, executor).await?;
924 envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
925 envs.insert(
926 "SSH_ASKPASS".into(),
927 _proxy.script_path().as_ref().display().to_string(),
928 );
929
930 Ok(Self {
931 connection_options: options,
932 envs,
933 _proxy,
934 })
935 }
936
937 // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
938 // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
939 // and passes -l as an argument to sh, not to ls.
940 // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
941 // into a machine. You must use `cd` to get back to $HOME.
942 // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
943 fn ssh_command(
944 &self,
945 shell_kind: ShellKind,
946 program: &str,
947 args: &[impl AsRef<str>],
948 allow_pseudo_tty: bool,
949 ) -> process::Command {
950 let mut command = util::command::new_smol_command("ssh");
951 let program = shell_kind.prepend_command_prefix(program);
952 let mut to_run = shell_kind
953 .try_quote_prefix_aware(&program)
954 .expect("shell quoting")
955 .into_owned();
956 for arg in args {
957 // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
958 debug_assert!(
959 !arg.as_ref().contains('\n'),
960 "multiline arguments do not work in all shells"
961 );
962 to_run.push(' ');
963 to_run.push_str(&shell_kind.try_quote(arg.as_ref()).expect("shell quoting"));
964 }
965 let separator = shell_kind.sequential_commands_separator();
966 let to_run = format!("cd{separator} {to_run}");
967 self.ssh_options(&mut command, true)
968 .arg(self.connection_options.ssh_url());
969 if !allow_pseudo_tty {
970 command.arg("-T");
971 }
972 command.arg(to_run);
973 log::debug!("ssh {:?}", command);
974 command
975 }
976
977 async fn run_command(
978 &self,
979 shell_kind: ShellKind,
980 program: &str,
981 args: &[impl AsRef<str>],
982 allow_pseudo_tty: bool,
983 ) -> Result<String> {
984 let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
985 let output = command.output().await?;
986 anyhow::ensure!(
987 output.status.success(),
988 "failed to run command {command:?}: {}",
989 String::from_utf8_lossy(&output.stderr)
990 );
991 Ok(String::from_utf8_lossy(&output.stdout).to_string())
992 }
993
994 #[cfg(not(target_os = "windows"))]
995 fn ssh_options<'a>(
996 &self,
997 command: &'a mut process::Command,
998 include_port_forwards: bool,
999 ) -> &'a mut process::Command {
1000 let args = if include_port_forwards {
1001 self.connection_options.additional_args()
1002 } else {
1003 self.connection_options.additional_args_for_scp()
1004 };
1005
1006 command
1007 .stdin(Stdio::piped())
1008 .stdout(Stdio::piped())
1009 .stderr(Stdio::piped())
1010 .args(args)
1011 .args(["-o", "ControlMaster=no", "-o"])
1012 .arg(format!("ControlPath={}", self.socket_path.display()))
1013 }
1014
1015 #[cfg(target_os = "windows")]
1016 fn ssh_options<'a>(
1017 &self,
1018 command: &'a mut process::Command,
1019 include_port_forwards: bool,
1020 ) -> &'a mut process::Command {
1021 let args = if include_port_forwards {
1022 self.connection_options.additional_args()
1023 } else {
1024 self.connection_options.additional_args_for_scp()
1025 };
1026
1027 command
1028 .stdin(Stdio::piped())
1029 .stdout(Stdio::piped())
1030 .stderr(Stdio::piped())
1031 .args(args)
1032 .envs(self.envs.clone())
1033 }
1034
1035 // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
1036 // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
1037 #[cfg(not(target_os = "windows"))]
1038 fn ssh_args(&self) -> Vec<String> {
1039 let mut arguments = self.connection_options.additional_args();
1040 arguments.extend(vec![
1041 "-o".to_string(),
1042 "ControlMaster=no".to_string(),
1043 "-o".to_string(),
1044 format!("ControlPath={}", self.socket_path.display()),
1045 self.connection_options.ssh_url(),
1046 ]);
1047 arguments
1048 }
1049
1050 #[cfg(target_os = "windows")]
1051 fn ssh_args(&self) -> Vec<String> {
1052 let mut arguments = self.connection_options.additional_args();
1053 arguments.push(self.connection_options.ssh_url());
1054 arguments
1055 }
1056
1057 async fn platform(&self, shell: ShellKind) -> Result<RemotePlatform> {
1058 let output = self.run_command(shell, "uname", &["-sm"], false).await?;
1059 parse_platform(&output)
1060 }
1061
1062 async fn shell(&self) -> String {
1063 match self
1064 .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
1065 .await
1066 {
1067 Ok(output) => parse_shell(&output),
1068 Err(e) => {
1069 log::error!("Failed to get shell: {e}");
1070 DEFAULT_SHELL.to_owned()
1071 }
1072 }
1073 }
1074}
1075
1076const DEFAULT_SHELL: &str = "sh";
1077
1078/// Parses the output of `uname -sm` to determine the remote platform.
1079/// Takes the last line to skip possible shell initialization output.
1080fn parse_platform(output: &str) -> Result<RemotePlatform> {
1081 let output = output.trim();
1082 let uname = output.rsplit_once('\n').map_or(output, |(_, last)| last);
1083 let Some((os, arch)) = uname.split_once(" ") else {
1084 anyhow::bail!("unknown uname: {uname:?}")
1085 };
1086
1087 let os = match os {
1088 "Darwin" => "macos",
1089 "Linux" => "linux",
1090 _ => anyhow::bail!(
1091 "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
1092 ),
1093 };
1094
1095 // exclude armv5,6,7 as they are 32-bit.
1096 let arch = if arch.starts_with("armv8")
1097 || arch.starts_with("armv9")
1098 || arch.starts_with("arm64")
1099 || arch.starts_with("aarch64")
1100 {
1101 "aarch64"
1102 } else if arch.starts_with("x86") {
1103 "x86_64"
1104 } else {
1105 anyhow::bail!(
1106 "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
1107 )
1108 };
1109
1110 Ok(RemotePlatform { os, arch })
1111}
1112
1113/// Parses the output of `echo $SHELL` to determine the remote shell.
1114/// Takes the last line to skip possible shell initialization output.
1115fn parse_shell(output: &str) -> String {
1116 let output = output.trim();
1117 let shell = output.rsplit_once('\n').map_or(output, |(_, last)| last);
1118 if shell.is_empty() {
1119 log::error!("$SHELL is not set, falling back to {DEFAULT_SHELL}");
1120 DEFAULT_SHELL.to_owned()
1121 } else {
1122 shell.to_owned()
1123 }
1124}
1125
1126fn parse_port_number(port_str: &str) -> Result<u16> {
1127 port_str
1128 .parse()
1129 .with_context(|| format!("parsing port number: {port_str}"))
1130}
1131
1132fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
1133 let parts: Vec<&str> = spec.split(':').collect();
1134
1135 match parts.len() {
1136 4 => {
1137 let local_port = parse_port_number(parts[1])?;
1138 let remote_port = parse_port_number(parts[3])?;
1139
1140 Ok(SshPortForwardOption {
1141 local_host: Some(parts[0].to_string()),
1142 local_port,
1143 remote_host: Some(parts[2].to_string()),
1144 remote_port,
1145 })
1146 }
1147 3 => {
1148 let local_port = parse_port_number(parts[0])?;
1149 let remote_port = parse_port_number(parts[2])?;
1150
1151 Ok(SshPortForwardOption {
1152 local_host: None,
1153 local_port,
1154 remote_host: Some(parts[1].to_string()),
1155 remote_port,
1156 })
1157 }
1158 _ => anyhow::bail!("Invalid port forward format"),
1159 }
1160}
1161
1162impl SshConnectionOptions {
1163 pub fn parse_command_line(input: &str) -> Result<Self> {
1164 let input = input.trim_start_matches("ssh ");
1165 let mut hostname: Option<String> = None;
1166 let mut username: Option<String> = None;
1167 let mut port: Option<u16> = None;
1168 let mut args = Vec::new();
1169 let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
1170
1171 // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
1172 const ALLOWED_OPTS: &[&str] = &[
1173 "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
1174 ];
1175 const ALLOWED_ARGS: &[&str] = &[
1176 "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
1177 "-w",
1178 ];
1179
1180 let mut tokens = ShellKind::Posix
1181 .split(input)
1182 .context("invalid input")?
1183 .into_iter();
1184
1185 'outer: while let Some(arg) = tokens.next() {
1186 if ALLOWED_OPTS.contains(&(&arg as &str)) {
1187 args.push(arg.to_string());
1188 continue;
1189 }
1190 if arg == "-p" {
1191 port = tokens.next().and_then(|arg| arg.parse().ok());
1192 continue;
1193 } else if let Some(p) = arg.strip_prefix("-p") {
1194 port = p.parse().ok();
1195 continue;
1196 }
1197 if arg == "-l" {
1198 username = tokens.next();
1199 continue;
1200 } else if let Some(l) = arg.strip_prefix("-l") {
1201 username = Some(l.to_string());
1202 continue;
1203 }
1204 if arg == "-L" || arg.starts_with("-L") {
1205 let forward_spec = if arg == "-L" {
1206 tokens.next()
1207 } else {
1208 Some(arg.strip_prefix("-L").unwrap().to_string())
1209 };
1210
1211 if let Some(spec) = forward_spec {
1212 port_forwards.push(parse_port_forward_spec(&spec)?);
1213 } else {
1214 anyhow::bail!("Missing port forward format");
1215 }
1216 }
1217
1218 for a in ALLOWED_ARGS {
1219 if arg == *a {
1220 args.push(arg);
1221 if let Some(next) = tokens.next() {
1222 args.push(next);
1223 }
1224 continue 'outer;
1225 } else if arg.starts_with(a) {
1226 args.push(arg);
1227 continue 'outer;
1228 }
1229 }
1230 if arg.starts_with("-") || hostname.is_some() {
1231 anyhow::bail!("unsupported argument: {:?}", arg);
1232 }
1233 let mut input = &arg as &str;
1234 // Destination might be: username1@username2@ip2@ip1
1235 if let Some((u, rest)) = input.rsplit_once('@') {
1236 input = rest;
1237 username = Some(u.to_string());
1238 }
1239 if let Some((rest, p)) = input.split_once(':') {
1240 input = rest;
1241 port = p.parse().ok()
1242 }
1243 hostname = Some(input.to_string())
1244 }
1245
1246 let Some(hostname) = hostname else {
1247 anyhow::bail!("missing hostname");
1248 };
1249
1250 let port_forwards = match port_forwards.len() {
1251 0 => None,
1252 _ => Some(port_forwards),
1253 };
1254
1255 Ok(Self {
1256 host: hostname,
1257 username,
1258 port,
1259 port_forwards,
1260 args: Some(args),
1261 password: None,
1262 nickname: None,
1263 upload_binary_over_ssh: false,
1264 })
1265 }
1266
1267 pub fn ssh_url(&self) -> String {
1268 let mut result = String::from("ssh://");
1269 if let Some(username) = &self.username {
1270 // Username might be: username1@username2@ip2
1271 let username = urlencoding::encode(username);
1272 result.push_str(&username);
1273 result.push('@');
1274 }
1275 result.push_str(&self.host);
1276 if let Some(port) = self.port {
1277 result.push(':');
1278 result.push_str(&port.to_string());
1279 }
1280 result
1281 }
1282
1283 pub fn additional_args_for_scp(&self) -> Vec<String> {
1284 self.args.iter().flatten().cloned().collect::<Vec<String>>()
1285 }
1286
1287 pub fn additional_args(&self) -> Vec<String> {
1288 let mut args = self.additional_args_for_scp();
1289
1290 if let Some(forwards) = &self.port_forwards {
1291 args.extend(forwards.iter().map(|pf| {
1292 let local_host = match &pf.local_host {
1293 Some(host) => host,
1294 None => "localhost",
1295 };
1296 let remote_host = match &pf.remote_host {
1297 Some(host) => host,
1298 None => "localhost",
1299 };
1300
1301 format!(
1302 "-L{}:{}:{}:{}",
1303 local_host, pf.local_port, remote_host, pf.remote_port
1304 )
1305 }));
1306 }
1307
1308 args
1309 }
1310
1311 fn scp_url(&self) -> String {
1312 if let Some(username) = &self.username {
1313 format!("{}@{}", username, self.host)
1314 } else {
1315 self.host.clone()
1316 }
1317 }
1318
1319 pub fn connection_string(&self) -> String {
1320 let host = if let Some(username) = &self.username {
1321 format!("{}@{}", username, self.host)
1322 } else {
1323 self.host.clone()
1324 };
1325 if let Some(port) = &self.port {
1326 format!("{}:{}", host, port)
1327 } else {
1328 host
1329 }
1330 }
1331}
1332
1333fn build_command(
1334 input_program: Option<String>,
1335 input_args: &[String],
1336 input_env: &HashMap<String, String>,
1337 working_dir: Option<String>,
1338 port_forward: Option<(u16, String, u16)>,
1339 ssh_env: HashMap<String, String>,
1340 ssh_path_style: PathStyle,
1341 ssh_shell: &str,
1342 ssh_shell_kind: ShellKind,
1343 ssh_args: Vec<String>,
1344) -> Result<CommandTemplate> {
1345 use std::fmt::Write as _;
1346
1347 let mut exec = String::new();
1348 if let Some(working_dir) = working_dir {
1349 let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1350
1351 // shlex will wrap the command in single quotes (''), disabling ~ expansion,
1352 // replace with something that works
1353 const TILDE_PREFIX: &'static str = "~/";
1354 if working_dir.starts_with(TILDE_PREFIX) {
1355 let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
1356 write!(
1357 exec,
1358 "cd \"$HOME/{working_dir}\" {} ",
1359 ssh_shell_kind.sequential_and_commands_separator()
1360 )?;
1361 } else {
1362 write!(
1363 exec,
1364 "cd \"{working_dir}\" {} ",
1365 ssh_shell_kind.sequential_and_commands_separator()
1366 )?;
1367 }
1368 } else {
1369 write!(
1370 exec,
1371 "cd {} ",
1372 ssh_shell_kind.sequential_and_commands_separator()
1373 )?;
1374 };
1375 write!(exec, "exec env ")?;
1376
1377 for (k, v) in input_env.iter() {
1378 write!(
1379 exec,
1380 "{}={} ",
1381 k,
1382 ssh_shell_kind.try_quote(v).context("shell quoting")?
1383 )?;
1384 }
1385
1386 if let Some(input_program) = input_program {
1387 write!(
1388 exec,
1389 "{}",
1390 ssh_shell_kind
1391 .try_quote_prefix_aware(&input_program)
1392 .context("shell quoting")?
1393 )?;
1394 for arg in input_args {
1395 let arg = ssh_shell_kind.try_quote(&arg).context("shell quoting")?;
1396 write!(exec, " {}", &arg)?;
1397 }
1398 } else {
1399 write!(exec, "{ssh_shell} -l")?;
1400 };
1401
1402 let mut args = Vec::new();
1403 args.extend(ssh_args);
1404
1405 if let Some((local_port, host, remote_port)) = port_forward {
1406 args.push("-L".into());
1407 args.push(format!("{local_port}:{host}:{remote_port}"));
1408 }
1409
1410 args.push("-t".into());
1411 args.push(exec);
1412 Ok(CommandTemplate {
1413 program: "ssh".into(),
1414 args,
1415 env: ssh_env,
1416 })
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::*;
1422
1423 #[test]
1424 fn test_build_command() -> Result<()> {
1425 let mut input_env = HashMap::default();
1426 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1427 let mut env = HashMap::default();
1428 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1429
1430 let command = build_command(
1431 Some("remote_program".to_string()),
1432 &["arg1".to_string(), "arg2".to_string()],
1433 &input_env,
1434 Some("~/work".to_string()),
1435 None,
1436 env.clone(),
1437 PathStyle::Posix,
1438 "/bin/fish",
1439 ShellKind::Fish,
1440 vec!["-p".to_string(), "2222".to_string()],
1441 )?;
1442
1443 assert_eq!(command.program, "ssh");
1444 assert_eq!(
1445 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1446 [
1447 "-p",
1448 "2222",
1449 "-t",
1450 "cd \"$HOME/work\" && exec env INPUT_VA=val remote_program arg1 arg2"
1451 ]
1452 );
1453 assert_eq!(command.env, env);
1454
1455 let mut input_env = HashMap::default();
1456 input_env.insert("INPUT_VA".to_string(), "val".to_string());
1457 let mut env = HashMap::default();
1458 env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
1459
1460 let command = build_command(
1461 None,
1462 &["arg1".to_string(), "arg2".to_string()],
1463 &input_env,
1464 None,
1465 Some((1, "foo".to_owned(), 2)),
1466 env.clone(),
1467 PathStyle::Posix,
1468 "/bin/fish",
1469 ShellKind::Fish,
1470 vec!["-p".to_string(), "2222".to_string()],
1471 )?;
1472
1473 assert_eq!(command.program, "ssh");
1474 assert_eq!(
1475 command.args.iter().map(String::as_str).collect::<Vec<_>>(),
1476 [
1477 "-p",
1478 "2222",
1479 "-L",
1480 "1:foo:2",
1481 "-t",
1482 "cd && exec env INPUT_VA=val /bin/fish -l"
1483 ]
1484 );
1485 assert_eq!(command.env, env);
1486
1487 Ok(())
1488 }
1489
1490 #[test]
1491 fn scp_args_exclude_port_forward_flags() {
1492 let options = SshConnectionOptions {
1493 host: "example.com".into(),
1494 args: Some(vec![
1495 "-p".to_string(),
1496 "2222".to_string(),
1497 "-o".to_string(),
1498 "StrictHostKeyChecking=no".to_string(),
1499 ]),
1500 port_forwards: Some(vec![SshPortForwardOption {
1501 local_host: Some("127.0.0.1".to_string()),
1502 local_port: 8080,
1503 remote_host: Some("127.0.0.1".to_string()),
1504 remote_port: 80,
1505 }]),
1506 ..Default::default()
1507 };
1508
1509 let ssh_args = options.additional_args();
1510 assert!(
1511 ssh_args.iter().any(|arg| arg.starts_with("-L")),
1512 "expected ssh args to include port-forward: {ssh_args:?}"
1513 );
1514
1515 let scp_args = options.additional_args_for_scp();
1516 assert_eq!(
1517 scp_args,
1518 vec![
1519 "-p".to_string(),
1520 "2222".to_string(),
1521 "-o".to_string(),
1522 "StrictHostKeyChecking=no".to_string(),
1523 ]
1524 );
1525 }
1526
1527 #[test]
1528 fn test_parse_platform() {
1529 let result = parse_platform("Linux x86_64\n").unwrap();
1530 assert_eq!(result.os, "linux");
1531 assert_eq!(result.arch, "x86_64");
1532
1533 let result = parse_platform("Darwin arm64\n").unwrap();
1534 assert_eq!(result.os, "macos");
1535 assert_eq!(result.arch, "aarch64");
1536
1537 let result = parse_platform("Linux x86_64").unwrap();
1538 assert_eq!(result.os, "linux");
1539 assert_eq!(result.arch, "x86_64");
1540
1541 let result = parse_platform("some shell init output\nLinux aarch64\n").unwrap();
1542 assert_eq!(result.os, "linux");
1543 assert_eq!(result.arch, "aarch64");
1544
1545 let result = parse_platform("some shell init output\nLinux aarch64").unwrap();
1546 assert_eq!(result.os, "linux");
1547 assert_eq!(result.arch, "aarch64");
1548
1549 assert_eq!(parse_platform("Linux armv8l\n").unwrap().arch, "aarch64");
1550 assert_eq!(parse_platform("Linux aarch64\n").unwrap().arch, "aarch64");
1551 assert_eq!(parse_platform("Linux x86_64\n").unwrap().arch, "x86_64");
1552
1553 let result = parse_platform(
1554 r#"Linux x86_64 - What you're referring to as Linux, is in fact, GNU/Linux...\n"#,
1555 )
1556 .unwrap();
1557 assert_eq!(result.os, "linux");
1558 assert_eq!(result.arch, "x86_64");
1559
1560 assert!(parse_platform("Windows x86_64\n").is_err());
1561 assert!(parse_platform("Linux armv7l\n").is_err());
1562 }
1563
1564 #[test]
1565 fn test_parse_shell() {
1566 assert_eq!(parse_shell("/bin/bash\n"), "/bin/bash");
1567 assert_eq!(parse_shell("/bin/zsh\n"), "/bin/zsh");
1568
1569 assert_eq!(parse_shell("/bin/bash"), "/bin/bash");
1570 assert_eq!(
1571 parse_shell("some shell init output\n/bin/bash\n"),
1572 "/bin/bash"
1573 );
1574 assert_eq!(
1575 parse_shell("some shell init output\n/bin/bash"),
1576 "/bin/bash"
1577 );
1578 assert_eq!(parse_shell(""), "sh");
1579 assert_eq!(parse_shell("\n"), "sh");
1580 }
1581}