Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:40:37.394Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

ssh.rs

2314 lines · 78.5 KB · rust
1use crate::{
2    RemoteArch, RemoteClientDelegate, RemoteOs, RemotePlatform,
3    remote_client::{CommandTemplate, Interactive, RemoteConnection, RemoteConnectionOptions},
4    transport::{parse_platform, parse_shell},
5};
6use anyhow::{Context as _, Result, anyhow};
7use async_trait::async_trait;
8use collections::HashMap;
9use futures::{
10    AsyncReadExt as _, FutureExt as _,
11    channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
12    select_biased,
13};
14use gpui::{App, AppContext as _, AsyncApp, Task};
15use parking_lot::Mutex;
16use paths::remote_server_dir_relative;
17use release_channel::{AppVersion, ReleaseChannel};
18use rpc::proto::Envelope;
19use semver::Version;
20pub use settings::SshPortForwardOption;
21use smol::fs;
22use std::{
23    net::IpAddr,
24    path::{Path, PathBuf},
25    sync::{
26        Arc,
27        atomic::{AtomicBool, Ordering},
28    },
29    time::{Duration, Instant},
30};
31use tempfile::TempDir;
32use util::command::{Child, Stdio};
33use util::{
34    paths::{PathStyle, RemotePathBuf},
35    rel_path::RelPath,
36    shell::ShellKind,
37};
38
39/// How long to wait for SSH to connect when no askpass prompt has opened.
40const SSH_CONNECTION_PROMPT_TIMEOUT: Duration = Duration::from_secs(17);
41
42pub(crate) struct SshRemoteConnection {
43    socket: SshSocket,
44    master_process: Mutex<Option<MasterProcess>>,
45    /// Whether `kill()` has been called. Separate from `master_process` because
46    /// reused ControlMaster sessions start with `master_process` as `None`.
47    killed: AtomicBool,
48    remote_binary_path: Option<Arc<RelPath>>,
49    ssh_platform: RemotePlatform,
50    ssh_os_version: Option<String>,
51    ssh_path_style: PathStyle,
52    ssh_shell: String,
53    ssh_shell_kind: ShellKind,
54    ssh_default_system_shell: String,
55    _temp_dir: TempDir,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
59pub enum SshConnectionHost {
60    IpAddr(IpAddr),
61    Hostname(String),
62}
63
64impl SshConnectionHost {
65    pub fn to_bracketed_string(&self) -> String {
66        match self {
67            Self::IpAddr(IpAddr::V4(ip)) => ip.to_string(),
68            Self::IpAddr(IpAddr::V6(ip)) => format!("[{}]", ip),
69            Self::Hostname(hostname) => hostname.clone(),
70        }
71    }
72
73    pub fn to_string(&self) -> String {
74        match self {
75            Self::IpAddr(ip) => ip.to_string(),
76            Self::Hostname(hostname) => hostname.clone(),
77        }
78    }
79}
80
81impl From<&str> for SshConnectionHost {
82    fn from(value: &str) -> Self {
83        if let Ok(address) = value.parse() {
84            Self::IpAddr(address)
85        } else {
86            Self::Hostname(value.to_string())
87        }
88    }
89}
90
91impl From<String> for SshConnectionHost {
92    fn from(value: String) -> Self {
93        if let Ok(address) = value.parse() {
94            Self::IpAddr(address)
95        } else {
96            Self::Hostname(value)
97        }
98    }
99}
100
101impl Default for SshConnectionHost {
102    fn default() -> Self {
103        Self::Hostname(Default::default())
104    }
105}
106
107fn bracket_ipv6(host: &str) -> String {
108    if host.contains(':') && !host.starts_with('[') {
109        format!("[{}]", host)
110    } else {
111        host.to_string()
112    }
113}
114
115#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
116pub struct SshConnectionOptions {
117    pub host: SshConnectionHost,
118    pub username: Option<String>,
119    pub port: Option<u16>,
120    pub password: Option<String>,
121    pub args: Option<Vec<String>>,
122    pub port_forwards: Option<Vec<SshPortForwardOption>>,
123    pub connection_timeout: Option<u16>,
124
125    pub nickname: Option<String>,
126    pub upload_binary_over_ssh: bool,
127}
128
129impl From<settings::SshConnection> for SshConnectionOptions {
130    fn from(val: settings::SshConnection) -> Self {
131        SshConnectionOptions {
132            host: val.host.to_string().into(),
133            username: val.username,
134            port: val.port,
135            password: None,
136            args: Some(val.args),
137            nickname: val.nickname,
138            upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
139            port_forwards: val.port_forwards,
140            connection_timeout: val.connection_timeout,
141        }
142    }
143}
144
145struct SshSocket {
146    connection_options: SshConnectionOptions,
147    #[cfg(not(windows))]
148    socket_path: std::path::PathBuf,
149    /// Extra environment variables needed for the ssh process
150    envs: HashMap<String, String>,
151    #[cfg(windows)]
152    _proxy: askpass::PasswordProxy,
153}
154
155struct MasterProcess {
156    process: Child,
157}
158
159#[cfg(not(windows))]
160impl MasterProcess {
161    pub fn new(
162        askpass_script_path: &std::ffi::OsStr,
163        additional_args: Vec<String>,
164        socket_path: &std::path::Path,
165        destination: &str,
166    ) -> Result<Self> {
167        let args = [
168            "-N",
169            "-o",
170            "ControlPersist=no",
171            "-o",
172            "ControlMaster=yes",
173            "-o",
174        ];
175
176        let mut master_process = util::command::new_command("ssh");
177        master_process
178            .kill_on_drop(true)
179            .stdin(Stdio::null())
180            .stdout(Stdio::piped())
181            .stderr(Stdio::piped())
182            .env("SSH_ASKPASS_REQUIRE", "force")
183            .env("SSH_ASKPASS", askpass_script_path)
184            .args(additional_args)
185            .args(args);
186
187        master_process.arg(format!("ControlPath={}", socket_path.display()));
188
189        let process = master_process.arg(&destination).spawn()?;
190
191        Ok(MasterProcess { process })
192    }
193
194    pub async fn wait_connected(&mut self) -> Result<()> {
195        let Some(mut stdout) = self.process.stdout.take() else {
196            anyhow::bail!("ssh process stdout capture failed");
197        };
198
199        let mut output = Vec::new();
200        stdout.read_to_end(&mut output).await?;
201        Ok(())
202    }
203}
204
205#[cfg(windows)]
206impl MasterProcess {
207    const CONNECTION_ESTABLISHED_MAGIC: &str = "ZED_SSH_CONNECTION_ESTABLISHED";
208
209    pub fn new(
210        askpass_script_path: &std::ffi::OsStr,
211        askpass_socket_path: &std::ffi::OsStr,
212        additional_args: Vec<String>,
213        destination: &str,
214    ) -> Result<Self> {
215        // On Windows, `ControlMaster` and `ControlPath` are not supported:
216        // https://github.com/PowerShell/Win32-OpenSSH/issues/405
217        // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
218        //
219        // Using an ugly workaround to detect connection establishment
220        // -N doesn't work with JumpHosts as windows openssh never closes stdin in that case
221        let args = [
222            "-t",
223            &format!("echo '{}'; exec $0", Self::CONNECTION_ESTABLISHED_MAGIC),
224        ];
225
226        let mut master_process = util::command::new_command("ssh");
227        master_process
228            .kill_on_drop(true)
229            .stdin(Stdio::null())
230            .stdout(Stdio::piped())
231            .stderr(Stdio::piped())
232            .env("SSH_ASKPASS_REQUIRE", "force")
233            .env("SSH_ASKPASS", askpass_script_path)
234            .env("ZED_ASKPASS_SOCKET", askpass_socket_path)
235            .args(additional_args)
236            .arg(destination)
237            .args(args);
238
239        let process = master_process.spawn()?;
240
241        Ok(MasterProcess { process })
242    }
243
244    pub async fn wait_connected(&mut self) -> Result<()> {
245        use smol::io::AsyncBufReadExt;
246
247        let Some(stdout) = self.process.stdout.take() else {
248            anyhow::bail!("ssh process stdout capture failed");
249        };
250
251        let mut reader = smol::io::BufReader::new(stdout);
252
253        let mut line = String::new();
254
255        loop {
256            let n = reader.read_line(&mut line).await?;
257            if n == 0 {
258                anyhow::bail!("ssh process exited before connection established");
259            }
260
261            if line.contains(Self::CONNECTION_ESTABLISHED_MAGIC) {
262                return Ok(());
263            }
264        }
265    }
266}
267
268impl AsRef<Child> for MasterProcess {
269    fn as_ref(&self) -> &Child {
270        &self.process
271    }
272}
273
274impl AsMut<Child> for MasterProcess {
275    fn as_mut(&mut self) -> &mut Child {
276        &mut self.process
277    }
278}
279
280#[async_trait(?Send)]
281impl RemoteConnection for SshRemoteConnection {
282    async fn kill(&self) -> Result<()> {
283        self.killed.store(true, Ordering::Release);
284        let Some(mut process) = self.master_process.lock().take() else {
285            log::debug!("no master process to kill (external ControlMaster session)");
286            return Ok(());
287        };
288        process.as_mut().kill().ok();
289        process.as_mut().status().await?;
290        Ok(())
291    }
292
293    fn has_been_killed(&self) -> bool {
294        self.killed.load(Ordering::Acquire)
295    }
296
297    fn connection_options(&self) -> RemoteConnectionOptions {
298        RemoteConnectionOptions::Ssh(self.socket.connection_options.clone())
299    }
300
301    fn shell(&self) -> String {
302        self.ssh_shell.clone()
303    }
304
305    fn default_system_shell(&self) -> String {
306        self.ssh_default_system_shell.clone()
307    }
308
309    fn build_command(
310        &self,
311        input_program: Option<String>,
312        input_args: &[String],
313        input_env: &HashMap<String, String>,
314        working_dir: Option<String>,
315        port_forward: Option<(u16, String, u16)>,
316        interactive: Interactive,
317    ) -> Result<CommandTemplate> {
318        let Self {
319            ssh_path_style,
320            socket,
321            ssh_shell_kind,
322            ssh_shell,
323            ..
324        } = self;
325        let env = socket.envs.clone();
326
327        if self.ssh_platform.os.is_windows() {
328            build_command_windows(
329                input_program,
330                input_args,
331                input_env,
332                working_dir,
333                port_forward,
334                env,
335                *ssh_path_style,
336                ssh_shell,
337                *ssh_shell_kind,
338                socket.ssh_command_options(),
339                &socket.connection_options.ssh_destination(),
340                interactive,
341            )
342        } else {
343            build_command_posix(
344                input_program,
345                input_args,
346                input_env,
347                working_dir,
348                port_forward,
349                env,
350                *ssh_path_style,
351                ssh_shell,
352                *ssh_shell_kind,
353                socket.ssh_command_options(),
354                &socket.connection_options.ssh_destination(),
355                interactive,
356            )
357        }
358    }
359
360    fn build_forward_ports_command(
361        &self,
362        forwards: Vec<(u16, String, u16)>,
363    ) -> Result<CommandTemplate> {
364        let Self { socket, .. } = self;
365        let mut args = socket.ssh_command_options();
366        args.push("-N".into());
367        for (local_port, host, remote_port) in forwards {
368            args.push("-L".into());
369            args.push(format!(
370                "{}:{}:{}",
371                local_port,
372                bracket_ipv6(&host),
373                remote_port
374            ));
375        }
376        args.push(socket.connection_options.ssh_destination());
377        Ok(CommandTemplate {
378            program: "ssh".into(),
379            args,
380            env: Default::default(),
381        })
382    }
383
384    fn upload_directory(
385        &self,
386        src_path: PathBuf,
387        dest_path: RemotePathBuf,
388        cx: &App,
389    ) -> Task<Result<()>> {
390        let dest_path_str = dest_path.to_string();
391        let src_path_display = src_path.display().to_string();
392
393        let mut sftp_command = self.build_sftp_command();
394        let mut scp_command =
395            self.build_scp_command(&src_path, &dest_path_str, Some(&["-C", "-r"]));
396
397        cx.background_spawn(async move {
398            // We will try SFTP first, and if that fails, we will fall back to SCP.
399            // If SCP fails also, we give up and return an error.
400            // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
401            // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
402            // This is for example the case on Windows as evidenced by this implementation snippet:
403            // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
404            if Self::is_sftp_available().await {
405                log::debug!("using SFTP for directory upload");
406                let mut child = sftp_command.spawn()?;
407                if let Some(mut stdin) = child.stdin.take() {
408                    use futures::AsyncWriteExt;
409                    let sftp_batch = format!("put -r \"{src_path_display}\" \"{dest_path_str}\"\n");
410                    stdin.write_all(sftp_batch.as_bytes()).await?;
411                    stdin.flush().await?;
412                }
413
414                let output = child.output().await?;
415                if output.status.success() {
416                    return Ok(());
417                }
418
419                let stderr = String::from_utf8_lossy(&output.stderr);
420                log::debug!("failed to upload directory via SFTP {src_path_display} -> {dest_path_str}: {stderr}");
421            }
422
423            log::debug!("using SCP for directory upload");
424            let output = scp_command.output().await?;
425
426            if output.status.success() {
427                return Ok(());
428            }
429
430            let stderr = String::from_utf8_lossy(&output.stderr);
431            log::debug!("failed to upload directory via SCP {src_path_display} -> {dest_path_str}: {stderr}");
432
433            anyhow::bail!(
434                "failed to upload directory via SFTP/SCP {} -> {}: {}",
435                src_path_display,
436                dest_path_str,
437                stderr,
438            );
439        })
440    }
441
442    fn start_proxy(
443        &self,
444        unique_identifier: String,
445        reconnect: bool,
446        incoming_tx: UnboundedSender<Envelope>,
447        outgoing_rx: UnboundedReceiver<Envelope>,
448        connection_activity_tx: Sender<()>,
449        delegate: Arc<dyn RemoteClientDelegate>,
450        cx: &mut AsyncApp,
451    ) -> Task<Result<i32>> {
452        const VARS: [&str; 3] = ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"];
453        delegate.set_status(Some("Starting proxy"), cx);
454
455        let Some(remote_binary_path) = self.remote_binary_path.clone() else {
456            return Task::ready(Err(anyhow!("Remote binary path not set")));
457        };
458
459        let mut ssh_command = if self.ssh_platform.os.is_windows() {
460            // TODO: Set the `VARS` environment variables, we do not have `env` on windows
461            // so this needs a different approach
462            let mut proxy_args = vec![];
463            proxy_args.push("proxy".to_owned());
464            proxy_args.push("--identifier".to_owned());
465            proxy_args.push(unique_identifier);
466
467            if reconnect {
468                proxy_args.push("--reconnect".to_owned());
469            }
470            self.socket.ssh_command(
471                self.ssh_shell_kind,
472                &remote_binary_path.display(self.path_style()),
473                &proxy_args,
474                false,
475            )
476        } else {
477            let mut proxy_args = vec![];
478            for env_var in VARS {
479                if let Some(value) = std::env::var(env_var).ok() {
480                    proxy_args.push(format!("{env_var}={value}"));
481                }
482            }
483            proxy_args.push(remote_binary_path.display(self.path_style()).into_owned());
484            proxy_args.push("proxy".to_owned());
485            proxy_args.push("--identifier".to_owned());
486            proxy_args.push(unique_identifier);
487
488            if reconnect {
489                proxy_args.push("--reconnect".to_owned());
490            }
491            self.socket
492                .ssh_command(self.ssh_shell_kind, "env", &proxy_args, false)
493        };
494
495        let ssh_proxy_process = match ssh_command
496            // IMPORTANT: we kill this process when we drop the task that uses it.
497            .kill_on_drop(true)
498            .spawn()
499        {
500            Ok(process) => process,
501            Err(error) => {
502                return Task::ready(Err(
503                    anyhow::Error::new(error).context("failed to spawn remote server")
504                ));
505            }
506        };
507
508        super::handle_rpc_messages_over_child_process_stdio(
509            ssh_proxy_process,
510            incoming_tx,
511            outgoing_rx,
512            connection_activity_tx,
513            cx,
514        )
515    }
516
517    fn path_style(&self) -> PathStyle {
518        self.ssh_path_style
519    }
520
521    fn remote_platform(&self) -> RemotePlatform {
522        self.ssh_platform
523    }
524
525    fn remote_os_version(&self) -> Option<String> {
526        self.ssh_os_version.clone()
527    }
528
529    fn has_wsl_interop(&self) -> bool {
530        false
531    }
532}
533
534/// Check if the user already has an active SSH ControlMaster session for the
535/// given destination. See: https://github.com/zed-industries/zed/issues/45271
536#[cfg(not(windows))]
537async fn find_existing_control_master(
538    destination: &str,
539    additional_args: &[String],
540) -> Option<PathBuf> {
541    // Use `ssh -G` to resolve the user's effective SSH config for this host.
542    // This expands ControlPath tokens (%h, %p, %r, %C, etc.) into actual paths.
543    let output = match util::command::new_command("ssh")
544        .args(additional_args)
545        .arg("-G")
546        .arg(destination)
547        .stdin(Stdio::null())
548        .stdout(Stdio::piped())
549        .stderr(Stdio::null())
550        .output()
551        .await
552    {
553        Ok(output) => output,
554        Err(e) => {
555            log::debug!("failed to run ssh -G: {e}");
556            return None;
557        }
558    };
559
560    if !output.status.success() {
561        log::debug!("ssh -G failed for {destination}, skipping ControlMaster reuse");
562        return None;
563    }
564
565    let stdout = String::from_utf8_lossy(&output.stdout);
566    let control_path = stdout.lines().find_map(|line| {
567        let path = line.strip_prefix("controlpath ")?.trim();
568        if path == "none" || path.is_empty() {
569            None
570        } else {
571            Some(PathBuf::from(path))
572        }
573    })?;
574
575    // Verify the master is actually alive by sending a control command.
576    let check = match util::command::new_command("ssh")
577        .args(additional_args)
578        .args(["-O", "check"])
579        .arg("-o")
580        .arg(format!("ControlPath={}", control_path.display()))
581        .arg(destination)
582        .stdin(Stdio::null())
583        .stdout(Stdio::null())
584        .stderr(Stdio::null())
585        .output()
586        .await
587    {
588        Ok(output) => output,
589        Err(e) => {
590            log::debug!("failed to run ssh -O check: {e}");
591            return None;
592        }
593    };
594
595    if check.status.success() {
596        log::info!(
597            "reusing existing SSH ControlMaster at {}",
598            control_path.display()
599        );
600        Some(control_path)
601    } else {
602        log::debug!(
603            "ControlMaster socket at {} is not alive, creating new connection",
604            control_path.display()
605        );
606        None
607    }
608}
609
610impl SshRemoteConnection {
611    pub(crate) async fn new(
612        connection_options: SshConnectionOptions,
613        delegate: Arc<dyn RemoteClientDelegate>,
614        cx: &mut AsyncApp,
615    ) -> Result<Self> {
616        use askpass::AskPassResult;
617
618        let destination = connection_options.ssh_destination();
619
620        let temp_dir = tempfile::Builder::new()
621            .prefix("omega-ssh-session")
622            .tempdir()?;
623
624        // On non-Windows, check if the user already has an active ControlMaster
625        // session for this host. If so, reuse it instead of prompting for auth.
626        #[cfg(not(windows))]
627        let reused_socket =
628            find_existing_control_master(&destination, &connection_options.additional_args()).await;
629
630        #[cfg(not(windows))]
631        let (socket, master_process_option) = if let Some(reused_path) = reused_socket {
632            delegate.set_status(Some("Connecting (reusing session)"), cx);
633            log::info!("reusing existing ControlMaster, skipping authentication");
634            let socket = SshSocket::new(connection_options, reused_path).await?;
635            (socket, None)
636        } else {
637            let askpass_delegate = askpass::AskPassDelegate::new(cx, {
638                let delegate = delegate.clone();
639                move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
640            });
641
642            let mut askpass =
643                askpass::AskPassSession::new(cx.background_executor().clone(), askpass_delegate)
644                    .await?;
645
646            delegate.set_status(Some("Connecting"), cx);
647
648            // Start the master SSH process, which does not do anything except
649            // for establish the connection and keep it open, allowing other ssh
650            // commands to reuse it via a control socket.
651            let socket_path = temp_dir.path().join("ssh.sock");
652            let mut master_process = MasterProcess::new(
653                askpass.script_path().as_ref(),
654                connection_options.additional_args(),
655                &socket_path,
656                &destination,
657            )?;
658
659            let result = select_biased! {
660                result = askpass.run(Some(SSH_CONNECTION_PROMPT_TIMEOUT)).fuse() => {
661                    match result {
662                        AskPassResult::CancelledByUser => {
663                            master_process.as_mut().kill().ok();
664                            anyhow::bail!("SSH connection canceled")
665                        }
666                        AskPassResult::Timedout => {
667                            anyhow::bail!("connecting to host timed out")
668                        }
669                    }
670                }
671                _ = master_process.wait_connected().fuse() => {
672                    anyhow::Ok(())
673                }
674            };
675
676            if let Err(e) = result {
677                return Err(e.context("Failed to connect to host"));
678            }
679
680            if master_process.as_mut().try_status()?.is_some() {
681                let mut output = Vec::new();
682                let mut stderr = master_process.as_mut().stderr.take().unwrap();
683                stderr.read_to_end(&mut output).await?;
684
685                let error_message = format!(
686                    "failed to connect: {}",
687                    String::from_utf8_lossy(&output).trim()
688                );
689                anyhow::bail!(error_message);
690            }
691
692            let socket = SshSocket::new(connection_options, socket_path).await?;
693            drop(askpass);
694            (socket, Some(master_process))
695        };
696
697        #[cfg(windows)]
698        let (socket, master_process_option) = {
699            let askpass_delegate = askpass::AskPassDelegate::new(cx, {
700                let delegate = delegate.clone();
701                move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
702            });
703
704            let mut askpass =
705                askpass::AskPassSession::new(cx.background_executor().clone(), askpass_delegate)
706                    .await?;
707
708            delegate.set_status(Some("Connecting"), cx);
709
710            let mut master_process = MasterProcess::new(
711                askpass.script_path().as_ref(),
712                askpass.socket_path().as_ref(),
713                connection_options.additional_args(),
714                &destination,
715            )?;
716
717            let result = select_biased! {
718                result = askpass.run(Some(SSH_CONNECTION_PROMPT_TIMEOUT)).fuse() => {
719                    match result {
720                        AskPassResult::CancelledByUser => {
721                            master_process.as_mut().kill().ok();
722                            anyhow::bail!("SSH connection canceled")
723                        }
724                        AskPassResult::Timedout => {
725                            anyhow::bail!("connecting to host timed out")
726                        }
727                    }
728                }
729                _ = master_process.wait_connected().fuse() => {
730                    anyhow::Ok(())
731                }
732            };
733
734            if let Err(e) = result {
735                return Err(e.context("Failed to connect to host"));
736            }
737
738            if master_process.as_mut().try_status()?.is_some() {
739                let mut output = Vec::new();
740                let mut stderr = master_process.as_mut().stderr.take().unwrap();
741                stderr.read_to_end(&mut output).await?;
742
743                let error_message = format!(
744                    "failed to connect: {}",
745                    String::from_utf8_lossy(&output).trim()
746                );
747                anyhow::bail!(error_message);
748            }
749
750            let socket = SshSocket::new(
751                connection_options,
752                askpass
753                    .get_password()
754                    .or_else(|| askpass::EncryptedPassword::try_from("").ok())
755                    .context("Failed to fetch askpass password")?,
756                cx.background_executor().clone(),
757            )
758            .await?;
759            drop(askpass);
760
761            (socket, Some(master_process))
762        };
763
764        let is_windows = socket.probe_is_windows().await;
765        log::info!("Remote is windows: {}", is_windows);
766
767        let ssh_shell = socket.shell(is_windows).await;
768        log::info!("Remote shell discovered: {}", ssh_shell);
769
770        let ssh_shell_kind = ShellKind::new(&ssh_shell, is_windows);
771        let ssh_platform = socket.platform(ssh_shell_kind, is_windows).await?;
772        log::info!("Remote platform discovered: {:?}", ssh_platform);
773
774        let ssh_os_version = socket.os_version(ssh_platform.os, ssh_shell_kind).await;
775        log::info!("Remote OS version discovered: {:?}", ssh_os_version);
776
777        let (ssh_path_style, ssh_default_system_shell) = match ssh_platform.os {
778            RemoteOs::Windows => (PathStyle::Windows, ssh_shell.clone()),
779            _ => (PathStyle::Unix, String::from("/bin/sh")),
780        };
781
782        let mut this = Self {
783            socket,
784            master_process: Mutex::new(master_process_option),
785            killed: AtomicBool::new(false),
786            _temp_dir: temp_dir,
787            remote_binary_path: None,
788            ssh_path_style,
789            ssh_platform,
790            ssh_os_version,
791            ssh_shell,
792            ssh_shell_kind,
793            ssh_default_system_shell,
794        };
795
796        let (release_channel, version) =
797            cx.update(|cx| (ReleaseChannel::global(cx), AppVersion::global(cx)));
798        this.remote_binary_path = Some(
799            this.ensure_server_binary(&delegate, release_channel, version, cx)
800                .await?,
801        );
802
803        Ok(this)
804    }
805
806    async fn ensure_server_binary(
807        &self,
808        delegate: &Arc<dyn RemoteClientDelegate>,
809        release_channel: ReleaseChannel,
810        version: Version,
811        cx: &mut AsyncApp,
812    ) -> Result<Arc<RelPath>> {
813        let version_str = match release_channel {
814            ReleaseChannel::Dev => "build".to_string(),
815            _ => version.to_string(),
816        };
817        let binary_name = format!(
818            "zed-remote-server-{}-{}{}",
819            release_channel.dev_name(),
820            version_str,
821            if self.ssh_platform.os.is_windows() {
822                ".exe"
823            } else {
824                ""
825            }
826        );
827        let dst_path =
828            paths::remote_server_dir_relative().join(RelPath::from_unix_str(&binary_name).unwrap());
829
830        let binary_exists_on_server = self
831            .socket
832            .run_command(
833                self.ssh_shell_kind,
834                &dst_path.display(self.path_style()),
835                &["version"],
836                true,
837            )
838            .await
839            .is_ok();
840
841        #[cfg(any(debug_assertions, feature = "build-remote-server-binary"))]
842        if let Some(remote_server_path) = super::build_remote_server_from_source(
843            &self.ssh_platform,
844            delegate.as_ref(),
845            binary_exists_on_server,
846            cx,
847        )
848        .await?
849        {
850            let tmp_path = paths::remote_server_dir_relative().join(
851                RelPath::from_unix_str(&format!(
852                    "download-{}-{}",
853                    std::process::id(),
854                    remote_server_path.file_name().unwrap().to_string_lossy()
855                ))
856                .unwrap(),
857            );
858            self.upload_local_server_binary(&remote_server_path, &tmp_path, delegate, cx)
859                .await?;
860            self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
861                .await?;
862            return Ok(dst_path.into());
863        }
864
865        if binary_exists_on_server {
866            return Ok(dst_path.into());
867        }
868
869        let wanted_version = cx.update(|cx| match release_channel {
870            ReleaseChannel::Nightly => Ok(None),
871            ReleaseChannel::Dev => {
872                anyhow::bail!(
873                    "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
874                    dst_path
875                )
876            }
877            _ => Ok(Some(AppVersion::global(cx))),
878        })?;
879
880        let tmp_path_compressed = remote_server_dir_relative().join(
881            RelPath::from_unix_str(&format!(
882                "{}-download-{}.{}",
883                binary_name,
884                std::process::id(),
885                if self.ssh_platform.os.is_windows() {
886                    "zip"
887                } else {
888                    "gz"
889                }
890            ))
891            .unwrap(),
892        );
893        if !self.socket.connection_options.upload_binary_over_ssh
894            && let Some(url) = delegate
895                .get_download_url(
896                    self.ssh_platform,
897                    release_channel,
898                    wanted_version.clone(),
899                    cx,
900                )
901                .await?
902        {
903            match self
904                .download_binary_on_server(&url, &tmp_path_compressed, delegate, cx)
905                .await
906            {
907                Ok(_) => {
908                    self.extract_server_binary(&dst_path, &tmp_path_compressed, delegate, cx)
909                        .await
910                        .context("extracting server binary")?;
911                    return Ok(dst_path.into());
912                }
913                Err(e) => {
914                    log::error!(
915                        "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
916                    )
917                }
918            }
919        }
920
921        let src_path = delegate
922            .download_server_binary_locally(
923                self.ssh_platform,
924                release_channel,
925                wanted_version.clone(),
926                cx,
927            )
928            .await
929            .context("downloading server binary locally")?;
930        self.upload_local_server_binary(&src_path, &tmp_path_compressed, delegate, cx)
931            .await
932            .context("uploading server binary")?;
933        self.extract_server_binary(&dst_path, &tmp_path_compressed, delegate, cx)
934            .await
935            .context("extracting server binary")?;
936        Ok(dst_path.into())
937    }
938
939    async fn download_binary_on_server(
940        &self,
941        url: &str,
942        tmp_path: &RelPath,
943        delegate: &Arc<dyn RemoteClientDelegate>,
944        cx: &mut AsyncApp,
945    ) -> Result<()> {
946        if let Some(parent) = tmp_path.parent() {
947            let res = self
948                .socket
949                .run_command(
950                    self.ssh_shell_kind,
951                    "mkdir",
952                    &["-p", parent.display(self.path_style()).as_ref()],
953                    true,
954                )
955                .await;
956            if !self.ssh_platform.os.is_windows() {
957                // mkdir fails on windows if the path already exists ...
958                res?;
959            }
960        }
961
962        delegate.set_status(Some("Downloading remote development server on host"), cx);
963
964        let connection_timeout = self
965            .socket
966            .connection_options
967            .connection_timeout
968            .unwrap_or(10)
969            .to_string();
970
971        match self
972            .socket
973            .run_command(
974                self.ssh_shell_kind,
975                "curl",
976                &[
977                    "-f",
978                    "-L",
979                    "--connect-timeout",
980                    &connection_timeout,
981                    url,
982                    "-o",
983                    &tmp_path.display(self.path_style()),
984                ],
985                true,
986            )
987            .await
988        {
989            Ok(_) => {}
990            Err(e) => {
991                if self
992                    .socket
993                    .run_command(self.ssh_shell_kind, "which", &["curl"], true)
994                    .await
995                    .is_ok()
996                {
997                    return Err(e);
998                }
999
1000                log::info!("curl is not available, trying wget");
1001                match self
1002                    .socket
1003                    .run_command(
1004                        self.ssh_shell_kind,
1005                        "wget",
1006                        &[
1007                            "--connect-timeout",
1008                            &connection_timeout,
1009                            "--tries",
1010                            "1",
1011                            url,
1012                            "-O",
1013                            &tmp_path.display(self.path_style()),
1014                        ],
1015                        true,
1016                    )
1017                    .await
1018                {
1019                    Ok(_) => {}
1020                    Err(e) => {
1021                        if self
1022                            .socket
1023                            .run_command(self.ssh_shell_kind, "which", &["wget"], true)
1024                            .await
1025                            .is_ok()
1026                        {
1027                            return Err(e);
1028                        } else {
1029                            anyhow::bail!("Neither curl nor wget is available");
1030                        }
1031                    }
1032                }
1033            }
1034        }
1035
1036        Ok(())
1037    }
1038
1039    async fn upload_local_server_binary(
1040        &self,
1041        src_path: &Path,
1042        tmp_path: &RelPath,
1043        delegate: &Arc<dyn RemoteClientDelegate>,
1044        cx: &mut AsyncApp,
1045    ) -> Result<()> {
1046        if let Some(parent) = tmp_path.parent() {
1047            let res = self
1048                .socket
1049                .run_command(
1050                    self.ssh_shell_kind,
1051                    "mkdir",
1052                    &["-p", parent.display(self.path_style()).as_ref()],
1053                    true,
1054                )
1055                .await;
1056            if !self.ssh_platform.os.is_windows() {
1057                // mkdir fails on windows if the path already exists ...
1058                res?;
1059            }
1060        }
1061
1062        let src_stat = fs::metadata(&src_path)
1063            .await
1064            .with_context(|| format!("failed to get metadata for {:?}", src_path))?;
1065        let size = src_stat.len();
1066
1067        let t0 = Instant::now();
1068        delegate.set_status(Some("Uploading remote development server"), cx);
1069        log::info!(
1070            "uploading remote development server to {:?} ({}kb)",
1071            tmp_path,
1072            size / 1024
1073        );
1074        self.upload_file(src_path, tmp_path)
1075            .await
1076            .context("failed to upload server binary")?;
1077        log::info!("uploaded remote development server in {:?}", t0.elapsed());
1078        Ok(())
1079    }
1080
1081    async fn extract_server_binary(
1082        &self,
1083        dst_path: &RelPath,
1084        tmp_path: &RelPath,
1085        delegate: &Arc<dyn RemoteClientDelegate>,
1086        cx: &mut AsyncApp,
1087    ) -> Result<()> {
1088        delegate.set_status(Some("Extracting remote development server"), cx);
1089
1090        if self.ssh_platform.os.is_windows() {
1091            self.extract_server_binary_windows(dst_path, tmp_path).await
1092        } else {
1093            self.extract_server_binary_posix(dst_path, tmp_path).await
1094        }
1095    }
1096
1097    async fn extract_server_binary_posix(
1098        &self,
1099        dst_path: &RelPath,
1100        tmp_path: &RelPath,
1101    ) -> Result<()> {
1102        let shell_kind = ShellKind::Posix;
1103        let server_mode = 0o755;
1104        let orig_tmp_path = tmp_path.display(self.path_style());
1105        let server_mode = format!("{:o}", server_mode);
1106        let server_mode = shell_kind
1107            .try_quote(&server_mode)
1108            .context("shell quoting")?;
1109        let dst_path = dst_path.display(self.path_style());
1110        let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
1111        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
1112            let orig_tmp_path = shell_kind
1113                .try_quote(&orig_tmp_path)
1114                .context("shell quoting")?;
1115            let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
1116            format!(
1117                "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
1118            )
1119        } else {
1120            let orig_tmp_path = shell_kind
1121                .try_quote(&orig_tmp_path)
1122                .context("shell quoting")?;
1123            format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
1124        };
1125        let args = shell_kind.args_for_shell(false, script.to_string());
1126        self.socket
1127            .run_command(self.ssh_shell_kind, "sh", &args, true)
1128            .await?;
1129        Ok(())
1130    }
1131
1132    async fn extract_server_binary_windows(
1133        &self,
1134        dst_path: &RelPath,
1135        tmp_path: &RelPath,
1136    ) -> Result<()> {
1137        let shell_kind = ShellKind::Pwsh;
1138        let orig_tmp_path = tmp_path.display(self.path_style());
1139        let dst_path = dst_path.display(self.path_style());
1140        let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
1141
1142        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".zip") {
1143            let orig_tmp_path = shell_kind
1144                .try_quote(&orig_tmp_path)
1145                .context("shell quoting")?;
1146            let tmp_path = shell_kind.try_quote(tmp_path).context("shell quoting")?;
1147            let tmp_exe_path = format!("{tmp_path}\\remote_server.exe");
1148            let tmp_exe_path = shell_kind
1149                .try_quote(&tmp_exe_path)
1150                .context("shell quoting")?;
1151            format!(
1152                "Expand-Archive -Force -Path {orig_tmp_path} -DestinationPath {tmp_path} -ErrorAction Stop; Move-Item -Force {tmp_exe_path} {dst_path}; Remove-Item -Force {tmp_path} -Recurse; Remove-Item -Force {orig_tmp_path}",
1153            )
1154        } else {
1155            let orig_tmp_path = shell_kind
1156                .try_quote(&orig_tmp_path)
1157                .context("shell quoting")?;
1158            format!("Move-Item -Force {orig_tmp_path} {dst_path}")
1159        };
1160
1161        let args = shell_kind.args_for_shell(false, script);
1162        self.socket
1163            .run_command(self.ssh_shell_kind, "powershell", &args, true)
1164            .await?;
1165        Ok(())
1166    }
1167
1168    fn build_scp_command(
1169        &self,
1170        src_path: &Path,
1171        dest_path_str: &str,
1172        args: Option<&[&str]>,
1173    ) -> util::command::Command {
1174        let mut command = util::command::new_command("scp");
1175        self.socket.ssh_options(&mut command, false).args(
1176            self.socket
1177                .connection_options
1178                .port
1179                .map(|port| vec!["-P".to_string(), port.to_string()])
1180                .unwrap_or_default(),
1181        );
1182        if let Some(args) = args {
1183            command.args(args);
1184        }
1185        command.arg(src_path).arg(format!(
1186            "{}:{}",
1187            self.socket.connection_options.scp_destination(),
1188            dest_path_str
1189        ));
1190        command
1191    }
1192
1193    fn build_sftp_command(&self) -> util::command::Command {
1194        let mut command = util::command::new_command("sftp");
1195        self.socket.ssh_options(&mut command, false).args(
1196            self.socket
1197                .connection_options
1198                .port
1199                .map(|port| vec!["-P".to_string(), port.to_string()])
1200                .unwrap_or_default(),
1201        );
1202        command.arg("-b").arg("-");
1203        command.arg(self.socket.connection_options.scp_destination());
1204        command.stdin(Stdio::piped());
1205        command
1206    }
1207
1208    async fn upload_file(&self, src_path: &Path, dest_path: &RelPath) -> Result<()> {
1209        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
1210
1211        let src_path_display = src_path.display().to_string();
1212        let dest_path_str = dest_path.display(self.path_style());
1213
1214        // We will try SFTP first, and if that fails, we will fall back to SCP.
1215        // If SCP fails also, we give up and return an error.
1216        // The reason we allow a fallback from SFTP to SCP is that if the user has to specify a password,
1217        // depending on the implementation of SSH stack, SFTP may disable interactive password prompts in batch mode.
1218        // This is for example the case on Windows as evidenced by this implementation snippet:
1219        // https://github.com/PowerShell/openssh-portable/blob/b8c08ef9da9450a94a9c5ef717d96a7bd83f3332/sshconnect2.c#L417
1220        if Self::is_sftp_available().await {
1221            log::debug!("using SFTP for file upload");
1222            let mut command = self.build_sftp_command();
1223            let sftp_batch = format!("put {src_path_display} {dest_path_str}\n");
1224
1225            let mut child = command.spawn()?;
1226            if let Some(mut stdin) = child.stdin.take() {
1227                use futures::AsyncWriteExt;
1228                stdin.write_all(sftp_batch.as_bytes()).await?;
1229                stdin.flush().await?;
1230            }
1231
1232            let output = child.output().await?;
1233            if output.status.success() {
1234                return Ok(());
1235            }
1236
1237            let stderr = String::from_utf8_lossy(&output.stderr);
1238            log::debug!(
1239                "failed to upload file via SFTP {src_path_display} -> {dest_path_str}: {stderr}"
1240            );
1241        }
1242
1243        log::debug!("using SCP for file upload");
1244        let mut command = self.build_scp_command(src_path, &dest_path_str, None);
1245        let output = command.output().await?;
1246
1247        if output.status.success() {
1248            return Ok(());
1249        }
1250
1251        let stderr = String::from_utf8_lossy(&output.stderr);
1252        log::debug!(
1253            "failed to upload file via SCP {src_path_display} -> {dest_path_str}: {stderr}",
1254        );
1255        anyhow::bail!(
1256            "failed to upload file via STFP/SCP {} -> {}: {}",
1257            src_path_display,
1258            dest_path_str,
1259            stderr,
1260        );
1261    }
1262
1263    async fn is_sftp_available() -> bool {
1264        which::which("sftp").is_ok()
1265    }
1266}
1267
1268impl SshSocket {
1269    #[cfg(not(windows))]
1270    async fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
1271        Ok(Self {
1272            connection_options: options,
1273            envs: HashMap::default(),
1274            socket_path,
1275        })
1276    }
1277
1278    #[cfg(windows)]
1279    async fn new(
1280        options: SshConnectionOptions,
1281        password: askpass::EncryptedPassword,
1282        executor: gpui::BackgroundExecutor,
1283    ) -> Result<Self> {
1284        let mut envs = HashMap::default();
1285        let get_password =
1286            move |_| Task::ready(std::ops::ControlFlow::Continue(Ok(password.clone())));
1287
1288        let _proxy = askpass::PasswordProxy::new(Box::new(get_password), executor).await?;
1289        envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
1290        envs.insert(
1291            "SSH_ASKPASS".into(),
1292            _proxy.script_path().as_ref().display().to_string(),
1293        );
1294        envs.insert(
1295            "ZED_ASKPASS_SOCKET".into(),
1296            _proxy.socket_path().as_ref().display().to_string(),
1297        );
1298
1299        Ok(Self {
1300            connection_options: options,
1301            envs,
1302            _proxy,
1303        })
1304    }
1305
1306    // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
1307    // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
1308    // and passes -l as an argument to sh, not to ls.
1309    // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
1310    // into a machine. You must use `cd` to get back to $HOME.
1311    // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
1312    fn ssh_command(
1313        &self,
1314        shell_kind: ShellKind,
1315        program: &str,
1316        args: &[impl AsRef<str>],
1317        allow_pseudo_tty: bool,
1318    ) -> util::command::Command {
1319        let mut command = util::command::new_command("ssh");
1320        let program = shell_kind.prepend_command_prefix(program);
1321        let mut to_run = shell_kind
1322            .try_quote_prefix_aware(&program)
1323            .expect("shell quoting")
1324            .into_owned();
1325        for arg in args {
1326            // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
1327            debug_assert!(
1328                !arg.as_ref().contains('\n'),
1329                "multiline arguments do not work in all shells"
1330            );
1331            to_run.push(' ');
1332            to_run.push_str(&shell_kind.try_quote(arg.as_ref()).expect("shell quoting"));
1333        }
1334        let to_run = if shell_kind == ShellKind::Cmd {
1335            to_run // 'cd' prints the current directory in CMD
1336        } else {
1337            let separator = shell_kind.sequential_commands_separator();
1338            format!("cd{separator} {to_run}")
1339        };
1340        self.ssh_options(&mut command, true)
1341            .arg(self.connection_options.ssh_destination());
1342        if !allow_pseudo_tty {
1343            command.arg("-T");
1344        }
1345        command.arg(to_run);
1346        log::debug!("ssh {:?}", command);
1347        command
1348    }
1349
1350    async fn run_command(
1351        &self,
1352        shell_kind: ShellKind,
1353        program: &str,
1354        args: &[impl AsRef<str>],
1355        allow_pseudo_tty: bool,
1356    ) -> Result<String> {
1357        let mut command = self.ssh_command(shell_kind, program, args, allow_pseudo_tty);
1358        let output = command.output().await?;
1359        log::debug!("{:?}: {:?}", command, output);
1360        anyhow::ensure!(
1361            output.status.success(),
1362            "failed to run command {command:?}: {}",
1363            String::from_utf8_lossy(&output.stderr)
1364        );
1365        Ok(String::from_utf8_lossy(&output.stdout).to_string())
1366    }
1367
1368    fn ssh_options<'a>(
1369        &self,
1370        command: &'a mut util::command::Command,
1371        include_port_forwards: bool,
1372    ) -> &'a mut util::command::Command {
1373        let args = if include_port_forwards {
1374            self.connection_options.additional_args()
1375        } else {
1376            self.connection_options.additional_args_for_scp()
1377        };
1378
1379        let cmd = command
1380            .stdin(Stdio::piped())
1381            .stdout(Stdio::piped())
1382            .stderr(Stdio::piped())
1383            .args(args);
1384
1385        if cfg!(windows) {
1386            cmd.envs(self.envs.clone());
1387        }
1388        #[cfg(not(windows))]
1389        {
1390            cmd.args(["-o", "ControlMaster=no", "-o"])
1391                .arg(format!("ControlPath={}", self.socket_path.display()));
1392        }
1393        cmd
1394    }
1395
1396    // Returns the SSH command-line options (without the destination) for building commands.
1397    // On Linux, this includes the ControlPath option to reuse the existing connection.
1398    // Note: The destination must be added separately after all options to ensure proper
1399    // SSH command structure: ssh [options] destination [command]
1400    fn ssh_command_options(&self) -> Vec<String> {
1401        let arguments = self.connection_options.additional_args();
1402        #[cfg(not(windows))]
1403        let arguments = {
1404            let mut args = arguments;
1405            args.extend(vec![
1406                "-o".to_string(),
1407                "ControlMaster=no".to_string(),
1408                "-o".to_string(),
1409                format!("ControlPath={}", self.socket_path.display()),
1410            ]);
1411            args
1412        };
1413        arguments
1414    }
1415
1416    async fn platform(&self, shell: ShellKind, is_windows: bool) -> Result<RemotePlatform> {
1417        if is_windows {
1418            self.platform_windows(shell).await
1419        } else {
1420            self.platform_posix(shell).await
1421        }
1422    }
1423
1424    async fn platform_posix(&self, shell: ShellKind) -> Result<RemotePlatform> {
1425        let output = self
1426            .run_command(shell, "uname", &["-sm"], false)
1427            .await
1428            .context("Failed to run 'uname -sm' to determine platform")?;
1429        parse_platform(&output)
1430    }
1431
1432    /// Best-effort detection of the remote OS version. Failures are logged and
1433    /// result in `None` rather than failing the connection, since this is only
1434    /// used for telemetry.
1435    async fn os_version(&self, os: RemoteOs, shell: ShellKind) -> Option<String> {
1436        let (program, args) = super::os_version_command(os);
1437        match self.run_command(shell, program, args, false).await {
1438            Ok(output) => super::parse_os_version(os, &output),
1439            Err(error) => {
1440                log::warn!("Failed to determine remote OS version: {error:#}");
1441                None
1442            }
1443        }
1444    }
1445
1446    async fn platform_windows(&self, shell: ShellKind) -> Result<RemotePlatform> {
1447        let output = self
1448            .run_command(
1449                shell,
1450                "cmd.exe",
1451                &["/c", "echo", "%PROCESSOR_ARCHITECTURE%"],
1452                false,
1453            )
1454            .await
1455            .context(
1456                "Failed to run 'echo %PROCESSOR_ARCHITECTURE%' to determine Windows architecture",
1457            )?;
1458
1459        Ok(RemotePlatform {
1460            os: RemoteOs::Windows,
1461            arch: match output.trim() {
1462                "AMD64" => RemoteArch::X86_64,
1463                "ARM64" => RemoteArch::Aarch64,
1464                arch => anyhow::bail!(
1465                    "Prebuilt remote servers are not yet available for windows-{arch}. See https://zed.dev/docs/remote-development"
1466                ),
1467            },
1468        })
1469    }
1470
1471    /// Probes whether the remote host is running Windows.
1472    ///
1473    /// This is done by attempting to run a simple Windows-specific command.
1474    /// If it succeeds and returns Windows-like output, we assume it's Windows.
1475    async fn probe_is_windows(&self) -> bool {
1476        match self
1477            .run_command(ShellKind::Cmd, "cmd.exe", &["/c", "ver"], false)
1478            .await
1479        {
1480            // Windows 'ver' command outputs something like "Microsoft Windows [Version 10.0.19045.5011]"
1481            Ok(output) => output.trim().contains("indows"),
1482            Err(_) => false,
1483        }
1484    }
1485
1486    async fn shell(&self, is_windows: bool) -> String {
1487        if is_windows {
1488            self.shell_windows().await
1489        } else {
1490            self.shell_posix().await
1491        }
1492    }
1493
1494    async fn shell_posix(&self) -> String {
1495        const DEFAULT_SHELL: &str = "sh";
1496        match self
1497            .run_command(ShellKind::Posix, "sh", &["-c", "echo $SHELL"], false)
1498            .await
1499        {
1500            Ok(output) => parse_shell(&output, DEFAULT_SHELL),
1501            Err(e) => {
1502                log::error!("Failed to detect remote shell: {e}");
1503                DEFAULT_SHELL.to_owned()
1504            }
1505        }
1506    }
1507
1508    async fn shell_windows(&self) -> String {
1509        const DEFAULT_SHELL: &str = "cmd.exe";
1510
1511        // We detect the shell used by the SSH session by running the following command in PowerShell:
1512        // (Get-CimInstance Win32_Process -Filter "ProcessId = $((Get-CimInstance Win32_Process -Filter ProcessId=$PID).ParentProcessId)").Name
1513        // This prints the name of PowerShell's parent process (which will be the shell that SSH launched).
1514        // We pass it as a Base64 encoded string since we don't yet know how to correctly quote that command.
1515        // (We'd need to know what the shell is to do that...)
1516        match self
1517            .run_command(
1518                ShellKind::Cmd,
1519                "powershell",
1520                &[
1521                    "-E",
1522                    "KABHAGUAdAAtAEMAaQBtAEkAbgBzAHQAYQBuAGMAZQAgAFcAaQBuADMAMgBfAFAAcgBvAGMAZQBzAHMAIAAtAEYAaQBsAHQAZQByACAAIgBQAHIAbwBjAGUAcwBzAEkAZAAgAD0AIAAkACgAKABHAGUAdAAtAEMAaQBtAEkAbgBzAHQAYQBuAGMAZQAgAFcAaQBuADMAMgBfAFAAcgBvAGMAZQBzAHMAIAAtAEYAaQBsAHQAZQByACAAUAByAG8AYwBlAHMAcwBJAGQAPQAkAFAASQBEACkALgBQAGEAcgBlAG4AdABQAHIAbwBjAGUAcwBzAEkAZAApACIAKQAuAE4AYQBtAGUA",
1523                ],
1524                false,
1525            )
1526            .await
1527        {
1528            Ok(output) => parse_shell(&output, DEFAULT_SHELL),
1529            Err(e) => {
1530                log::error!("Failed to detect remote shell: {e}");
1531                DEFAULT_SHELL.to_owned()
1532            }
1533        }
1534    }
1535}
1536
1537fn parse_port_number(port_str: &str) -> Result<u16> {
1538    port_str
1539        .parse()
1540        .with_context(|| format!("parsing port number: {port_str}"))
1541}
1542
1543fn split_port_forward_tokens(spec: &str) -> Result<Vec<String>> {
1544    let mut tokens = Vec::new();
1545    let mut chars = spec.chars().peekable();
1546
1547    while chars.peek().is_some() {
1548        if chars.peek() == Some(&'[') {
1549            chars.next();
1550            let mut bracket_content = String::new();
1551            loop {
1552                match chars.next() {
1553                    Some(']') => break,
1554                    Some(ch) => bracket_content.push(ch),
1555                    None => anyhow::bail!("Unmatched '[' in port forward spec: {spec}"),
1556                }
1557            }
1558            tokens.push(bracket_content);
1559            if chars.peek() == Some(&':') {
1560                chars.next();
1561            }
1562        } else {
1563            let mut token = String::new();
1564            for ch in chars.by_ref() {
1565                if ch == ':' {
1566                    break;
1567                }
1568                token.push(ch);
1569            }
1570            tokens.push(token);
1571        }
1572    }
1573
1574    Ok(tokens)
1575}
1576
1577fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
1578    let tokens = if spec.contains('[') {
1579        split_port_forward_tokens(spec)?
1580    } else {
1581        spec.split(':').map(String::from).collect()
1582    };
1583
1584    match tokens.len() {
1585        4 => {
1586            let local_port = parse_port_number(&tokens[1])?;
1587            let remote_port = parse_port_number(&tokens[3])?;
1588
1589            Ok(SshPortForwardOption {
1590                local_host: Some(tokens[0].clone()),
1591                local_port,
1592                remote_host: Some(tokens[2].clone()),
1593                remote_port,
1594            })
1595        }
1596        3 => {
1597            let local_port = parse_port_number(&tokens[0])?;
1598            let remote_port = parse_port_number(&tokens[2])?;
1599
1600            Ok(SshPortForwardOption {
1601                local_host: None,
1602                local_port,
1603                remote_host: Some(tokens[1].clone()),
1604                remote_port,
1605            })
1606        }
1607        _ => anyhow::bail!("Invalid port forward format: {spec}"),
1608    }
1609}
1610
1611impl SshConnectionOptions {
1612    pub fn parse_command_line(input: &str) -> Result<Self> {
1613        let input = input.trim_start_matches("ssh ");
1614        let mut hostname: Option<String> = None;
1615        let mut username: Option<String> = None;
1616        let mut port: Option<u16> = None;
1617        let mut args = Vec::new();
1618        let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
1619
1620        // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
1621        const ALLOWED_OPTS: &[&str] = &[
1622            "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
1623        ];
1624        const ALLOWED_ARGS: &[&str] = &[
1625            "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
1626            "-w",
1627        ];
1628
1629        let mut tokens = ShellKind::Posix
1630            .split(input)
1631            .context("invalid input")?
1632            .into_iter();
1633
1634        'outer: while let Some(arg) = tokens.next() {
1635            if ALLOWED_OPTS.contains(&(&arg as &str)) {
1636                args.push(arg.to_string());
1637                continue;
1638            }
1639            if arg == "-p" {
1640                port = tokens.next().and_then(|arg| arg.parse().ok());
1641                continue;
1642            } else if let Some(p) = arg.strip_prefix("-p") {
1643                port = p.parse().ok();
1644                continue;
1645            }
1646            if arg == "-l" {
1647                username = tokens.next();
1648                continue;
1649            } else if let Some(l) = arg.strip_prefix("-l") {
1650                username = Some(l.to_string());
1651                continue;
1652            }
1653            if arg == "-L" || arg.starts_with("-L") {
1654                let forward_spec = if arg == "-L" {
1655                    tokens.next()
1656                } else {
1657                    Some(arg.strip_prefix("-L").unwrap().to_string())
1658                };
1659
1660                if let Some(spec) = forward_spec {
1661                    port_forwards.push(parse_port_forward_spec(&spec)?);
1662                } else {
1663                    anyhow::bail!("Missing port forward format");
1664                }
1665            }
1666
1667            for a in ALLOWED_ARGS {
1668                if arg == *a {
1669                    args.push(arg);
1670                    if let Some(next) = tokens.next() {
1671                        args.push(next);
1672                    }
1673                    continue 'outer;
1674                } else if arg.starts_with(a) {
1675                    args.push(arg);
1676                    continue 'outer;
1677                }
1678            }
1679            if arg.starts_with("-") || hostname.is_some() {
1680                anyhow::bail!("unsupported argument: {:?}", arg);
1681            }
1682            let mut input = &arg as &str;
1683            // Destination might be: username1@username2@ip2@ip1
1684            if let Some((u, rest)) = input.rsplit_once('@') {
1685                input = rest;
1686                username = Some(u.to_string());
1687            }
1688
1689            // Handle port parsing, accounting for IPv6 addresses
1690            // IPv6 addresses can be: 2001:db8::1 or [2001:db8::1]:22
1691            if input.starts_with('[') {
1692                if let Some((rest, p)) = input.rsplit_once("]:") {
1693                    input = rest.strip_prefix('[').unwrap_or(rest);
1694                    port = p.parse().ok();
1695                } else if input.ends_with(']') {
1696                    input = input.strip_prefix('[').unwrap_or(input);
1697                    input = input.strip_suffix(']').unwrap_or(input);
1698                }
1699            } else if let Some((rest, p)) = input.rsplit_once(':')
1700                && !rest.contains(":")
1701            {
1702                input = rest;
1703                port = p.parse().ok();
1704            }
1705
1706            hostname = Some(input.to_string())
1707        }
1708
1709        let Some(hostname) = hostname else {
1710            anyhow::bail!("missing hostname");
1711        };
1712
1713        let port_forwards = match port_forwards.len() {
1714            0 => None,
1715            _ => Some(port_forwards),
1716        };
1717
1718        Ok(Self {
1719            host: hostname.into(),
1720            username,
1721            port,
1722            port_forwards,
1723            args: Some(args),
1724            password: None,
1725            nickname: None,
1726            upload_binary_over_ssh: false,
1727            connection_timeout: None,
1728        })
1729    }
1730
1731    pub fn ssh_destination(&self) -> String {
1732        let mut result = String::default();
1733        if let Some(username) = &self.username {
1734            // Username might be: username1@username2@ip2
1735            let username = urlencoding::encode(username);
1736            result.push_str(&username);
1737            result.push('@');
1738        }
1739
1740        result.push_str(&self.host.to_string());
1741        result
1742    }
1743
1744    pub fn additional_args_for_scp(&self) -> Vec<String> {
1745        self.args.iter().flatten().cloned().collect::<Vec<String>>()
1746    }
1747
1748    pub fn additional_args(&self) -> Vec<String> {
1749        let mut args = self.additional_args_for_scp();
1750
1751        if let Some(timeout) = self.connection_timeout {
1752            args.extend(["-o".to_string(), format!("ConnectTimeout={}", timeout)]);
1753        }
1754
1755        if let Some(port) = self.port {
1756            args.push("-p".to_string());
1757            args.push(port.to_string());
1758        }
1759
1760        if let Some(forwards) = &self.port_forwards {
1761            args.extend(forwards.iter().map(|pf| {
1762                let local_host = match &pf.local_host {
1763                    Some(host) => host,
1764                    None => "localhost",
1765                };
1766                let remote_host = match &pf.remote_host {
1767                    Some(host) => host,
1768                    None => "localhost",
1769                };
1770
1771                format!(
1772                    "-L{}:{}:{}:{}",
1773                    bracket_ipv6(local_host),
1774                    pf.local_port,
1775                    bracket_ipv6(remote_host),
1776                    pf.remote_port
1777                )
1778            }));
1779        }
1780
1781        args
1782    }
1783
1784    fn scp_destination(&self) -> String {
1785        if let Some(username) = &self.username {
1786            format!("{}@{}", username, self.host.to_bracketed_string())
1787        } else {
1788            self.host.to_string()
1789        }
1790    }
1791
1792    pub fn connection_string(&self) -> String {
1793        let host = if let Some(port) = &self.port {
1794            format!("{}:{}", self.host.to_bracketed_string(), port)
1795        } else {
1796            self.host.to_string()
1797        };
1798
1799        if let Some(username) = &self.username {
1800            format!("{}@{}", username, host)
1801        } else {
1802            host
1803        }
1804    }
1805}
1806
1807fn build_command_posix(
1808    input_program: Option<String>,
1809    input_args: &[String],
1810    input_env: &HashMap<String, String>,
1811    working_dir: Option<String>,
1812    port_forward: Option<(u16, String, u16)>,
1813    ssh_env: HashMap<String, String>,
1814    ssh_path_style: PathStyle,
1815    ssh_shell: &str,
1816    ssh_shell_kind: ShellKind,
1817    ssh_options: Vec<String>,
1818    ssh_destination: &str,
1819    interactive: Interactive,
1820) -> Result<CommandTemplate> {
1821    use std::fmt::Write as _;
1822
1823    let mut exec = String::new();
1824    if let Some(working_dir) = working_dir {
1825        let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1826
1827        // For paths starting with ~/, we need $HOME to expand, but the remainder
1828        // must be properly quoted to prevent command injection.
1829        // Pattern: cd "$HOME"/'quoted/remainder' - $HOME expands, rest is single-quoted
1830        const TILDE_PREFIX: &str = "~/";
1831        if working_dir.starts_with(TILDE_PREFIX) {
1832            let remainder = working_dir.trim_start_matches(TILDE_PREFIX);
1833            if remainder.is_empty() {
1834                write!(
1835                    exec,
1836                    "cd \"$HOME\" {} ",
1837                    ssh_shell_kind.sequential_and_commands_separator()
1838                )?;
1839            } else {
1840                let quoted_remainder = ssh_shell_kind
1841                    .try_quote(remainder)
1842                    .context("shell quoting")?;
1843                write!(
1844                    exec,
1845                    "cd \"$HOME\"/{quoted_remainder} {} ",
1846                    ssh_shell_kind.sequential_and_commands_separator()
1847                )?;
1848            }
1849        } else {
1850            let quoted_dir = ssh_shell_kind
1851                .try_quote(&working_dir)
1852                .context("shell quoting")?;
1853            write!(
1854                exec,
1855                "cd {quoted_dir} {} ",
1856                ssh_shell_kind.sequential_and_commands_separator()
1857            )?;
1858        }
1859    } else {
1860        write!(
1861            exec,
1862            "cd {} ",
1863            ssh_shell_kind.sequential_and_commands_separator()
1864        )?;
1865    };
1866    write!(exec, "exec env ")?;
1867
1868    for (k, v) in input_env.iter() {
1869        let assignment = format!("{k}={v}");
1870        let assignment = ssh_shell_kind
1871            .try_quote(&assignment)
1872            .context("shell quoting")?;
1873        write!(exec, "{assignment} ")?;
1874    }
1875
1876    if let Some(input_program) = input_program {
1877        write!(
1878            exec,
1879            "{}",
1880            ssh_shell_kind
1881                .try_quote_prefix_aware(&input_program)
1882                .context("shell quoting")?
1883        )?;
1884        for arg in input_args {
1885            let arg = ssh_shell_kind.try_quote(&arg).context("shell quoting")?;
1886            write!(exec, " {}", &arg)?;
1887        }
1888    } else {
1889        write!(exec, "{ssh_shell} -l")?;
1890    };
1891
1892    let mut args = Vec::new();
1893    args.extend(ssh_options);
1894
1895    if let Some((local_port, host, remote_port)) = port_forward {
1896        args.push("-L".into());
1897        args.push(format!(
1898            "{}:{}:{}",
1899            local_port,
1900            bracket_ipv6(&host),
1901            remote_port
1902        ));
1903    }
1904
1905    // LogLevel=ERROR suppresses the "Connection to ... closed." message while
1906    // preserving SSH errors.
1907    args.extend(["-o".into(), "LogLevel=ERROR".into()]);
1908    match interactive {
1909        // -t forces pseudo-TTY allocation (for interactive use)
1910        Interactive::Yes => args.push("-t".into()),
1911        // -T disables pseudo-TTY allocation (for non-interactive piped stdio)
1912        Interactive::No => args.push("-T".into()),
1913    }
1914    // The destination must come after all options but before the command
1915    args.push(ssh_destination.into());
1916    args.push(exec);
1917
1918    Ok(CommandTemplate {
1919        program: "ssh".into(),
1920        args,
1921        env: ssh_env,
1922    })
1923}
1924
1925fn build_command_windows(
1926    input_program: Option<String>,
1927    input_args: &[String],
1928    _input_env: &HashMap<String, String>,
1929    working_dir: Option<String>,
1930    port_forward: Option<(u16, String, u16)>,
1931    ssh_env: HashMap<String, String>,
1932    ssh_path_style: PathStyle,
1933    ssh_shell: &str,
1934    _ssh_shell_kind: ShellKind,
1935    ssh_options: Vec<String>,
1936    ssh_destination: &str,
1937    interactive: Interactive,
1938) -> Result<CommandTemplate> {
1939    use base64::Engine as _;
1940    use std::fmt::Write as _;
1941
1942    let mut exec = String::new();
1943    let shell_kind = ShellKind::PowerShell;
1944
1945    if let Some(working_dir) = working_dir {
1946        let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
1947
1948        write!(
1949            exec,
1950            "Set-Location -Path {} {} ",
1951            shell_kind
1952                .try_quote(&working_dir)
1953                .context("shell quoting")?,
1954            shell_kind.sequential_and_commands_separator()
1955        )?;
1956    }
1957
1958    // Windows OpenSSH has an 8K character limit for command lines. Sending a lot of environment variables easily puts us over the limit.
1959    // Until we have a better solution for this, we just won't set environment variables for now.
1960    // for (k, v) in input_env.iter() {
1961    //     write!(
1962    //         exec,
1963    //         "$env:{}={} {} ",
1964    //         k,
1965    //         shell_kind.try_quote(v).context("shell quoting")?,
1966    //         shell_kind.sequential_and_commands_separator()
1967    //     )?;
1968    // }
1969
1970    if let Some(input_program) = input_program {
1971        write!(
1972            exec,
1973            "{}",
1974            shell_kind
1975                .try_quote_prefix_aware(&shell_kind.prepend_command_prefix(&input_program))
1976                .context("shell quoting")?
1977        )?;
1978        for arg in input_args {
1979            let arg = shell_kind.try_quote(arg).context("shell quoting")?;
1980            write!(exec, " {}", &arg)?;
1981        }
1982    } else {
1983        // Launch an interactive shell session
1984        write!(exec, "{ssh_shell}")?;
1985    };
1986
1987    let mut args = Vec::new();
1988    args.extend(ssh_options);
1989
1990    if let Some((local_port, host, remote_port)) = port_forward {
1991        args.push("-L".into());
1992        args.push(format!(
1993            "{}:{}:{}",
1994            local_port,
1995            bracket_ipv6(&host),
1996            remote_port
1997        ));
1998    }
1999
2000    // LogLevel=ERROR suppresses the "Connection to ... closed." message while
2001    // preserving SSH errors.
2002    args.extend(["-o".into(), "LogLevel=ERROR".into()]);
2003    match interactive {
2004        // -t forces pseudo-TTY allocation (for interactive use)
2005        Interactive::Yes => args.push("-t".into()),
2006        // -T disables pseudo-TTY allocation (for non-interactive piped stdio)
2007        Interactive::No => args.push("-T".into()),
2008    }
2009
2010    // The destination must come after all options but before the command
2011    args.push(ssh_destination.into());
2012
2013    // Windows OpenSSH server incorrectly escapes the command string when the PTY is used.
2014    // The simplest way to work around this is to use a base64 encoded command, which doesn't require escaping.
2015    let utf16_bytes: Vec<u16> = exec.encode_utf16().collect();
2016    let byte_slice: Vec<u8> = utf16_bytes.iter().flat_map(|&u| u.to_le_bytes()).collect();
2017    let base64_encoded = base64::engine::general_purpose::STANDARD.encode(&byte_slice);
2018
2019    args.push(format!("powershell.exe -E {}", base64_encoded));
2020
2021    Ok(CommandTemplate {
2022        program: "ssh".into(),
2023        args,
2024        env: ssh_env,
2025    })
2026}
2027
2028#[cfg(test)]
2029mod tests {
2030    use super::*;
2031
2032    #[test]
2033    fn test_build_command() -> Result<()> {
2034        let mut input_env = HashMap::default();
2035        input_env.insert("INPUT_VA".to_string(), "val".to_string());
2036        let mut env = HashMap::default();
2037        env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
2038
2039        // Test non-interactive command (interactive=false should use -T)
2040        let command = build_command_posix(
2041            Some("remote_program".to_string()),
2042            &["arg1".to_string(), "arg2".to_string()],
2043            &input_env,
2044            Some("~/work".to_string()),
2045            None,
2046            env.clone(),
2047            PathStyle::Unix,
2048            "/bin/bash",
2049            ShellKind::Posix,
2050            vec!["-o".to_string(), "ControlMaster=auto".to_string()],
2051            "user@host",
2052            Interactive::No,
2053        )?;
2054        assert_eq!(command.program, "ssh");
2055        // Should contain -T for non-interactive
2056        assert!(command.args.iter().any(|arg| arg == "-T"));
2057        assert!(!command.args.iter().any(|arg| arg == "-t"));
2058
2059        // Test interactive command (interactive=true should use -t)
2060        let command = build_command_posix(
2061            Some("remote_program".to_string()),
2062            &["arg1".to_string(), "arg2".to_string()],
2063            &input_env,
2064            Some("~/work".to_string()),
2065            None,
2066            env.clone(),
2067            PathStyle::Unix,
2068            "/bin/fish",
2069            ShellKind::Fish,
2070            vec!["-p".to_string(), "2222".to_string()],
2071            "user@host",
2072            Interactive::Yes,
2073        )?;
2074
2075        assert_eq!(command.program, "ssh");
2076        assert_eq!(
2077            command.args.iter().map(String::as_str).collect::<Vec<_>>(),
2078            [
2079                "-p",
2080                "2222",
2081                "-o",
2082                "LogLevel=ERROR",
2083                "-t",
2084                "user@host",
2085                "cd \"$HOME\"/work && exec env 'INPUT_VA=val' remote_program arg1 arg2"
2086            ]
2087        );
2088        assert_eq!(command.env, env);
2089
2090        let mut input_env = HashMap::default();
2091        input_env.insert("INPUT_VA".to_string(), "val".to_string());
2092        let mut env = HashMap::default();
2093        env.insert("SSH_VAR".to_string(), "ssh-val".to_string());
2094
2095        let command = build_command_posix(
2096            None,
2097            &[],
2098            &input_env,
2099            None,
2100            Some((1, "foo".to_owned(), 2)),
2101            env.clone(),
2102            PathStyle::Unix,
2103            "/bin/fish",
2104            ShellKind::Fish,
2105            vec!["-p".to_string(), "2222".to_string()],
2106            "user@host",
2107            Interactive::Yes,
2108        )?;
2109
2110        assert_eq!(command.program, "ssh");
2111        assert_eq!(
2112            command.args.iter().map(String::as_str).collect::<Vec<_>>(),
2113            [
2114                "-p",
2115                "2222",
2116                "-L",
2117                "1:foo:2",
2118                "-o",
2119                "LogLevel=ERROR",
2120                "-t",
2121                "user@host",
2122                "cd && exec env 'INPUT_VA=val' /bin/fish -l"
2123            ]
2124        );
2125        assert_eq!(command.env, env);
2126
2127        Ok(())
2128    }
2129
2130    #[test]
2131    fn test_build_command_quotes_env_assignment() -> Result<()> {
2132        let mut input_env = HashMap::default();
2133        input_env.insert("ZED$(echo foo)".to_string(), "value".to_string());
2134
2135        let command = build_command_posix(
2136            Some("remote_program".to_string()),
2137            &[],
2138            &input_env,
2139            None,
2140            None,
2141            HashMap::default(),
2142            PathStyle::Unix,
2143            "/bin/bash",
2144            ShellKind::Posix,
2145            vec![],
2146            "user@host",
2147            Interactive::No,
2148        )?;
2149
2150        let remote_command = command
2151            .args
2152            .last()
2153            .context("missing remote command argument")?;
2154        assert!(
2155            remote_command.contains("exec env 'ZED$(echo foo)=value' remote_program"),
2156            "expected env assignment to be quoted, got: {remote_command}"
2157        );
2158
2159        Ok(())
2160    }
2161
2162    #[test]
2163    fn scp_args_exclude_port_forward_flags() {
2164        let options = SshConnectionOptions {
2165            host: "example.com".into(),
2166            args: Some(vec![
2167                "-p".to_string(),
2168                "2222".to_string(),
2169                "-o".to_string(),
2170                "StrictHostKeyChecking=no".to_string(),
2171            ]),
2172            port_forwards: Some(vec![SshPortForwardOption {
2173                local_host: Some("127.0.0.1".to_string()),
2174                local_port: 8080,
2175                remote_host: Some("127.0.0.1".to_string()),
2176                remote_port: 80,
2177            }]),
2178            ..Default::default()
2179        };
2180
2181        let ssh_args = options.additional_args();
2182        assert!(
2183            ssh_args.iter().any(|arg| arg.starts_with("-L")),
2184            "expected ssh args to include port-forward: {ssh_args:?}"
2185        );
2186
2187        let scp_args = options.additional_args_for_scp();
2188        assert_eq!(
2189            scp_args,
2190            vec![
2191                "-p".to_string(),
2192                "2222".to_string(),
2193                "-o".to_string(),
2194                "StrictHostKeyChecking=no".to_string(),
2195            ]
2196        );
2197    }
2198
2199    #[test]
2200    fn test_host_parsing() -> Result<()> {
2201        let opts = SshConnectionOptions::parse_command_line("user@2001:db8::1")?;
2202        assert_eq!(opts.host, "2001:db8::1".into());
2203        assert_eq!(opts.username, Some("user".to_string()));
2204        assert_eq!(opts.port, None);
2205
2206        let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]:2222")?;
2207        assert_eq!(opts.host, "2001:db8::1".into());
2208        assert_eq!(opts.username, Some("user".to_string()));
2209        assert_eq!(opts.port, Some(2222));
2210
2211        let opts = SshConnectionOptions::parse_command_line("user@[2001:db8::1]")?;
2212        assert_eq!(opts.host, "2001:db8::1".into());
2213        assert_eq!(opts.username, Some("user".to_string()));
2214        assert_eq!(opts.port, None);
2215
2216        let opts = SshConnectionOptions::parse_command_line("2001:db8::1")?;
2217        assert_eq!(opts.host, "2001:db8::1".into());
2218        assert_eq!(opts.username, None);
2219        assert_eq!(opts.port, None);
2220
2221        let opts = SshConnectionOptions::parse_command_line("[2001:db8::1]:2222")?;
2222        assert_eq!(opts.host, "2001:db8::1".into());
2223        assert_eq!(opts.username, None);
2224        assert_eq!(opts.port, Some(2222));
2225
2226        let opts = SshConnectionOptions::parse_command_line("user@example.com:2222")?;
2227        assert_eq!(opts.host, "example.com".into());
2228        assert_eq!(opts.username, Some("user".to_string()));
2229        assert_eq!(opts.port, Some(2222));
2230
2231        let opts = SshConnectionOptions::parse_command_line("user@192.168.1.1:2222")?;
2232        assert_eq!(opts.host, "192.168.1.1".into());
2233        assert_eq!(opts.username, Some("user".to_string()));
2234        assert_eq!(opts.port, Some(2222));
2235
2236        Ok(())
2237    }
2238
2239    #[test]
2240    fn test_parse_port_forward_spec_ipv6() -> Result<()> {
2241        let pf = parse_port_forward_spec("[::1]:8080:[::1]:80")?;
2242        assert_eq!(pf.local_host, Some("::1".to_string()));
2243        assert_eq!(pf.local_port, 8080);
2244        assert_eq!(pf.remote_host, Some("::1".to_string()));
2245        assert_eq!(pf.remote_port, 80);
2246
2247        let pf = parse_port_forward_spec("8080:[::1]:80")?;
2248        assert_eq!(pf.local_host, None);
2249        assert_eq!(pf.local_port, 8080);
2250        assert_eq!(pf.remote_host, Some("::1".to_string()));
2251        assert_eq!(pf.remote_port, 80);
2252
2253        let pf = parse_port_forward_spec("[2001:db8::1]:3000:[fe80::1]:4000")?;
2254        assert_eq!(pf.local_host, Some("2001:db8::1".to_string()));
2255        assert_eq!(pf.local_port, 3000);
2256        assert_eq!(pf.remote_host, Some("fe80::1".to_string()));
2257        assert_eq!(pf.remote_port, 4000);
2258
2259        let pf = parse_port_forward_spec("127.0.0.1:8080:localhost:80")?;
2260        assert_eq!(pf.local_host, Some("127.0.0.1".to_string()));
2261        assert_eq!(pf.local_port, 8080);
2262        assert_eq!(pf.remote_host, Some("localhost".to_string()));
2263        assert_eq!(pf.remote_port, 80);
2264
2265        Ok(())
2266    }
2267
2268    #[test]
2269    fn test_port_forward_ipv6_formatting() {
2270        let options = SshConnectionOptions {
2271            host: "example.com".into(),
2272            port_forwards: Some(vec![SshPortForwardOption {
2273                local_host: Some("::1".to_string()),
2274                local_port: 8080,
2275                remote_host: Some("::1".to_string()),
2276                remote_port: 80,
2277            }]),
2278            ..Default::default()
2279        };
2280
2281        let args = options.additional_args();
2282        assert!(
2283            args.iter().any(|arg| arg == "-L[::1]:8080:[::1]:80"),
2284            "expected bracketed IPv6 in -L flag: {args:?}"
2285        );
2286    }
2287
2288    #[test]
2289    fn test_build_command_with_ipv6_port_forward() -> Result<()> {
2290        let command = build_command_posix(
2291            None,
2292            &[],
2293            &HashMap::default(),
2294            None,
2295            Some((8080, "::1".to_owned(), 80)),
2296            HashMap::default(),
2297            PathStyle::Unix,
2298            "/bin/bash",
2299            ShellKind::Posix,
2300            vec![],
2301            "user@host",
2302            Interactive::No,
2303        )?;
2304
2305        assert!(
2306            command.args.iter().any(|arg| arg == "8080:[::1]:80"),
2307            "expected bracketed IPv6 in port forward arg: {:?}",
2308            command.args
2309        );
2310
2311        Ok(())
2312    }
2313}
2314
Served at tenant.openagents/omega Member data and write actions are omitted.