ssh.rs

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