diff --git a/Cargo.lock b/Cargo.lock index 58e482ee39..302c1cc6ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -538,6 +538,8 @@ dependencies = [ "anyhow", "futures 0.3.31", "gpui", + "net", + "parking_lot", "smol", "tempfile", "util", @@ -10231,6 +10233,18 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "net" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-io", + "smol", + "tempfile", + "windows 0.61.1", + "workspace-hack", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -12535,6 +12549,7 @@ dependencies = [ "prost 0.9.0", "prost-build 0.9.0", "serde", + "typed-path", "workspace-hack", ] @@ -17036,6 +17051,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566" + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 8dd7892329..32f520c602 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,7 @@ members = [ "crates/migrator", "crates/mistral", "crates/multi_buffer", + "crates/net", "crates/node_runtime", "crates/notifications", "crates/ollama", @@ -311,6 +312,7 @@ menu = { path = "crates/menu" } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } +net = { path = "crates/net" } node_runtime = { path = "crates/node_runtime" } notifications = { path = "crates/notifications" } ollama = { path = "crates/ollama" } @@ -660,6 +662,7 @@ features = [ "Win32_Graphics_Gdi", "Win32_Graphics_Imaging", "Win32_Graphics_Imaging_D2D", + "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Credentials", "Win32_Storage_FileSystem", diff --git a/crates/askpass/Cargo.toml b/crates/askpass/Cargo.toml index d64ee9f7c3..0527399af8 100644 --- a/crates/askpass/Cargo.toml +++ b/crates/askpass/Cargo.toml @@ -15,6 +15,8 @@ path = "src/askpass.rs" anyhow.workspace = true futures.workspace = true gpui.workspace = true +net.workspace = true +parking_lot.workspace = true smol.workspace = true tempfile.workspace = true util.workspace = true diff --git a/crates/askpass/src/askpass.rs b/crates/askpass/src/askpass.rs index 519c08aa26..f085a2be72 100644 --- a/crates/askpass/src/askpass.rs +++ b/crates/askpass/src/askpass.rs @@ -1,21 +1,14 @@ -use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::{ffi::OsStr, time::Duration}; -#[cfg(unix)] -use anyhow::Context as _; +use anyhow::{Context as _, Result}; use futures::channel::{mpsc, oneshot}; -#[cfg(unix)] -use futures::{AsyncBufReadExt as _, io::BufReader}; -#[cfg(unix)] -use futures::{AsyncWriteExt as _, FutureExt as _, select_biased}; -use futures::{SinkExt, StreamExt}; +use futures::{ + AsyncBufReadExt as _, AsyncWriteExt as _, FutureExt as _, SinkExt, StreamExt, io::BufReader, + select_biased, +}; use gpui::{AsyncApp, BackgroundExecutor, Task}; -#[cfg(unix)] use smol::fs; -#[cfg(unix)] -use smol::net::unix::UnixListener; -#[cfg(unix)] -use util::{ResultExt as _, fs::make_file_executable, get_shell_safe_zed_path}; +use util::ResultExt as _; #[derive(PartialEq, Eq)] pub enum AskPassResult { @@ -42,41 +35,56 @@ impl AskPassDelegate { Self { tx, _task: task } } - pub async fn ask_password(&mut self, prompt: String) -> anyhow::Result { + pub async fn ask_password(&mut self, prompt: String) -> Result { let (tx, rx) = oneshot::channel(); self.tx.send((prompt, tx)).await?; Ok(rx.await?) } } -#[cfg(unix)] pub struct AskPassSession { - script_path: PathBuf, + #[cfg(not(target_os = "windows"))] + script_path: std::path::PathBuf, + #[cfg(target_os = "windows")] + askpass_helper: String, + #[cfg(target_os = "windows")] + secret: std::sync::Arc>, _askpass_task: Task<()>, askpass_opened_rx: Option>, askpass_kill_master_rx: Option>, } -#[cfg(unix)] +#[cfg(not(target_os = "windows"))] +const ASKPASS_SCRIPT_NAME: &str = "askpass.sh"; +#[cfg(target_os = "windows")] +const ASKPASS_SCRIPT_NAME: &str = "askpass.ps1"; + impl AskPassSession { /// This will create a new AskPassSession. /// You must retain this session until the master process exits. #[must_use] - pub async fn new( - executor: &BackgroundExecutor, - mut delegate: AskPassDelegate, - ) -> anyhow::Result { + pub async fn new(executor: &BackgroundExecutor, mut delegate: AskPassDelegate) -> Result { + use net::async_net::UnixListener; + use util::fs::make_file_executable; + + #[cfg(target_os = "windows")] + let secret = std::sync::Arc::new(parking_lot::Mutex::new(String::new())); let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?; let askpass_socket = temp_dir.path().join("askpass.sock"); - let askpass_script_path = temp_dir.path().join("askpass.sh"); + let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME); let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>(); - let listener = - UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?; - let zed_path = get_shell_safe_zed_path()?; + let listener = UnixListener::bind(&askpass_socket).context("creating askpass socket")?; + #[cfg(not(target_os = "windows"))] + let zed_path = util::get_shell_safe_zed_path()?; + #[cfg(target_os = "windows")] + let zed_path = std::env::current_exe() + .context("finding current executable path for use in askpass")?; let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>(); let mut kill_tx = Some(askpass_kill_master_tx); + #[cfg(target_os = "windows")] + let askpass_secret = secret.clone(); let askpass_task = executor.spawn(async move { let mut askpass_opened_tx = Some(askpass_opened_tx); @@ -93,10 +101,14 @@ impl AskPassSession { if let Some(password) = delegate .ask_password(prompt.to_string()) .await - .context("failed to get askpass password") + .context("getting askpass password") .log_err() { stream.write_all(password.as_bytes()).await.log_err(); + #[cfg(target_os = "windows")] + { + *askpass_secret.lock() = password; + } } else { if let Some(kill_tx) = kill_tx.take() { kill_tx.send(()).log_err(); @@ -112,34 +124,49 @@ impl AskPassSession { }); // Create an askpass script that communicates back to this process. - let askpass_script = format!( - "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n", - zed_exe = zed_path, - askpass_socket = askpass_socket.display(), - print_args = "printf '%s\\0' \"$@\"", - shebang = "#!/bin/sh", - ); - fs::write(&askpass_script_path, askpass_script).await?; + let askpass_script = generate_askpass_script(&zed_path, &askpass_socket); + fs::write(&askpass_script_path, askpass_script) + .await + .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?; make_file_executable(&askpass_script_path).await?; + #[cfg(target_os = "windows")] + let askpass_helper = format!( + "powershell.exe -ExecutionPolicy Bypass -File {}", + askpass_script_path.display() + ); Ok(Self { + #[cfg(not(target_os = "windows"))] script_path: askpass_script_path, + + #[cfg(target_os = "windows")] + secret, + #[cfg(target_os = "windows")] + askpass_helper, + _askpass_task: askpass_task, askpass_kill_master_rx: Some(askpass_kill_master_rx), askpass_opened_rx: Some(askpass_opened_rx), }) } - pub fn script_path(&self) -> &Path { + #[cfg(not(target_os = "windows"))] + pub fn script_path(&self) -> impl AsRef { &self.script_path } + #[cfg(target_os = "windows")] + pub fn script_path(&self) -> impl AsRef { + &self.askpass_helper + } + // This will run the askpass task forever, resolving as many authentication requests as needed. // The caller is responsible for examining the result of their own commands and cancelling this // future when this is no longer needed. Note that this can only be called once, but due to the // drop order this takes an &mut, so you can `drop()` it after you're done with the master process. pub async fn run(&mut self) -> AskPassResult { - let connection_timeout = Duration::from_secs(10); + // This is the default timeout setting used by VSCode. + let connection_timeout = Duration::from_secs(17); let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once"); let askpass_kill_master_rx = self .askpass_kill_master_rx @@ -158,14 +185,19 @@ impl AskPassSession { } } } + + /// This will return the password that was last set by the askpass script. + #[cfg(target_os = "windows")] + pub fn get_password(&self) -> String { + self.secret.lock().clone() + } } /// The main function for when Zed is running in netcat mode for use in askpass. /// Called from both the remote server binary and the zed binary in their respective main functions. -#[cfg(unix)] pub fn main(socket: &str) { + use net::UnixStream; use std::io::{self, Read, Write}; - use std::os::unix::net::UnixStream; use std::process::exit; let mut stream = match UnixStream::connect(socket) { @@ -182,6 +214,10 @@ pub fn main(socket: &str) { exit(1); } + #[cfg(target_os = "windows")] + while buffer.last().map_or(false, |&b| b == b'\n' || b == b'\r') { + buffer.pop(); + } if buffer.last() != Some(&b'\0') { buffer.push(b'\0'); } @@ -202,28 +238,28 @@ pub fn main(socket: &str) { exit(1); } } -#[cfg(not(unix))] -pub fn main(_socket: &str) {} -#[cfg(not(unix))] -pub struct AskPassSession { - path: PathBuf, +#[inline] +#[cfg(not(target_os = "windows"))] +fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) -> String { + format!( + "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n", + zed_exe = zed_path, + askpass_socket = askpass_socket.display(), + print_args = "printf '%s\\0' \"$@\"", + shebang = "#!/bin/sh", + ) } -#[cfg(not(unix))] -impl AskPassSession { - pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result { - Ok(Self { - path: PathBuf::new(), - }) - } - - pub fn script_path(&self) -> &Path { - &self.path - } - - pub async fn run(&mut self) -> AskPassResult { - futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))).await; - AskPassResult::Timedout - } +#[inline] +#[cfg(target_os = "windows")] +fn generate_askpass_script(zed_path: &std::path::Path, askpass_socket: &std::path::Path) -> String { + format!( + r#" + $ErrorActionPreference = 'Stop'; + ($args -join [char]0) | & "{zed_exe}" --askpass={askpass_socket} 2> $null + "#, + zed_exe = zed_path.display(), + askpass_socket = askpass_socket.display(), + ) } diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index 7c58fac1e0..dd753199a8 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -54,7 +54,7 @@ use std::{ time::{Duration, Instant}, }; use url::Url; -use util::ResultExt; +use util::{ResultExt, paths::RemotePathBuf}; use wasm_host::{ WasmExtension, WasmHost, wit::{is_supported_wasm_api_version, wasm_api_version_range}, @@ -1689,6 +1689,7 @@ impl ExtensionStore { .request(proto::SyncExtensions { extensions }) })? .await?; + let path_style = client.read_with(cx, |client, _| client.path_style())?; for missing_extension in response.missing_extensions.into_iter() { let tmp_dir = tempfile::tempdir()?; @@ -1701,7 +1702,10 @@ impl ExtensionStore { ) })? .await?; - let dest_dir = PathBuf::from(&response.tmp_dir).join(missing_extension.clone().id); + let dest_dir = RemotePathBuf::new( + PathBuf::from(&response.tmp_dir).join(missing_extension.clone().id), + path_style, + ); log::info!("Uploading extension {}", missing_extension.clone().id); client @@ -1718,7 +1722,7 @@ impl ExtensionStore { client .update(cx, |client, _cx| { client.proto_client().request(proto::InstallExtension { - tmp_dir: dest_dir.to_string_lossy().to_string(), + tmp_dir: dest_dir.to_proto(), extension: Some(missing_extension), }) })? diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 8feaec89de..1dfcf56e5d 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -1,7 +1,10 @@ use std::{path::PathBuf, sync::Arc}; use anyhow::{Context as _, Result}; -use client::{TypedEnvelope, proto}; +use client::{ + TypedEnvelope, + proto::{self, FromProto}, +}; use collections::{HashMap, HashSet}; use extension::{ Extension, ExtensionDebugAdapterProviderProxy, ExtensionHostProxy, ExtensionLanguageProxy, @@ -328,7 +331,7 @@ impl HeadlessExtensionStore { version: extension.version, dev: extension.dev, }, - PathBuf::from(envelope.payload.tmp_dir), + PathBuf::from_proto(envelope.payload.tmp_dir), cx, ) })? diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index d7428f0068..68ba7a78b5 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -15,16 +15,14 @@ use std::{ }; use ui::{Context, LabelLike, ListItem, Window}; use ui::{HighlightedLabel, ListItemSpacing, prelude::*}; -use util::{maybe, paths::compare_paths}; +use util::{ + maybe, + paths::{PathStyle, compare_paths}, +}; use workspace::Workspace; pub(crate) struct OpenPathPrompt; -#[cfg(target_os = "windows")] -const PROMPT_ROOT: &str = "C:\\"; -#[cfg(not(target_os = "windows"))] -const PROMPT_ROOT: &str = "/"; - #[derive(Debug)] pub struct OpenPathDelegate { tx: Option>>>, @@ -34,6 +32,8 @@ pub struct OpenPathDelegate { string_matches: Vec, cancel_flag: Arc, should_dismiss: bool, + prompt_root: String, + path_style: PathStyle, replace_prompt: Task<()>, } @@ -42,6 +42,7 @@ impl OpenPathDelegate { tx: oneshot::Sender>>, lister: DirectoryLister, creating_path: bool, + path_style: PathStyle, ) -> Self { Self { tx: Some(tx), @@ -53,6 +54,11 @@ impl OpenPathDelegate { string_matches: Vec::new(), cancel_flag: Arc::new(AtomicBool::new(false)), should_dismiss: true, + prompt_root: match path_style { + PathStyle::Posix => "/".to_string(), + PathStyle::Windows => "C:\\".to_string(), + }, + path_style, replace_prompt: Task::ready(()), } } @@ -185,7 +191,8 @@ impl OpenPathPrompt { cx: &mut Context, ) { workspace.toggle_modal(window, cx, |window, cx| { - let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path); + let delegate = + OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::current()); let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.)); let query = lister.default_query(cx); picker.set_query(query, window, cx); @@ -226,18 +233,7 @@ impl PickerDelegate for OpenPathDelegate { cx: &mut Context>, ) -> Task<()> { let lister = &self.lister; - let last_item = Path::new(&query) - .file_name() - .unwrap_or_default() - .to_string_lossy(); - let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) { - (dir.to_string(), last_item.into_owned()) - } else { - (query, String::new()) - }; - if dir == "" { - dir = PROMPT_ROOT.to_string(); - } + let (dir, suffix) = get_dir_and_suffix(query, self.path_style); let query = match &self.directory_state { DirectoryState::List { parent_path, .. } => { @@ -266,6 +262,7 @@ impl PickerDelegate for OpenPathDelegate { self.cancel_flag = Arc::new(AtomicBool::new(false)); let cancel_flag = self.cancel_flag.clone(); + let parent_path_is_root = self.prompt_root == dir; cx.spawn_in(window, async move |this, cx| { if let Some(query) = query { let paths = query.await; @@ -279,7 +276,7 @@ impl PickerDelegate for OpenPathDelegate { DirectoryState::None { create: false } | DirectoryState::List { .. } => match paths { Ok(paths) => DirectoryState::List { - entries: path_candidates(&dir, paths), + entries: path_candidates(parent_path_is_root, paths), parent_path: dir.clone(), error: None, }, @@ -292,7 +289,7 @@ impl PickerDelegate for OpenPathDelegate { DirectoryState::None { create: true } | DirectoryState::Create { .. } => match paths { Ok(paths) => { - let mut entries = path_candidates(&dir, paths); + let mut entries = path_candidates(parent_path_is_root, paths); let mut exists = false; let mut is_dir = false; let mut new_id = None; @@ -488,6 +485,7 @@ impl PickerDelegate for OpenPathDelegate { _: &mut Context>, ) -> Option { let candidate = self.get_entry(self.selected_index)?; + let path_style = self.path_style; Some( maybe!({ match &self.directory_state { @@ -496,7 +494,7 @@ impl PickerDelegate for OpenPathDelegate { parent_path, candidate.path.string, if candidate.is_dir { - MAIN_SEPARATOR_STR + path_style.separator() } else { "" } @@ -506,7 +504,7 @@ impl PickerDelegate for OpenPathDelegate { parent_path, candidate.path.string, if candidate.is_dir { - MAIN_SEPARATOR_STR + path_style.separator() } else { "" } @@ -527,8 +525,8 @@ impl PickerDelegate for OpenPathDelegate { DirectoryState::None { .. } => return, DirectoryState::List { parent_path, .. } => { let confirmed_path = - if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() { - PathBuf::from(PROMPT_ROOT) + if parent_path == &self.prompt_root && candidate.path.string.is_empty() { + PathBuf::from(&self.prompt_root) } else { Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref()) .join(&candidate.path.string) @@ -548,8 +546,8 @@ impl PickerDelegate for OpenPathDelegate { return; } let prompted_path = - if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() { - PathBuf::from(PROMPT_ROOT) + if parent_path == &self.prompt_root && user_input.file.string.is_empty() { + PathBuf::from(&self.prompt_root) } else { Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref()) .join(&user_input.file.string) @@ -652,8 +650,8 @@ impl PickerDelegate for OpenPathDelegate { .inset(true) .toggle_state(selected) .child(HighlightedLabel::new( - if parent_path == PROMPT_ROOT { - format!("{}{}", PROMPT_ROOT, candidate.path.string) + if parent_path == &self.prompt_root { + format!("{}{}", self.prompt_root, candidate.path.string) } else { candidate.path.string.clone() }, @@ -665,10 +663,10 @@ impl PickerDelegate for OpenPathDelegate { user_input, .. } => { - let (label, delta) = if parent_path == PROMPT_ROOT { + let (label, delta) = if parent_path == &self.prompt_root { ( - format!("{}{}", PROMPT_ROOT, candidate.path.string), - PROMPT_ROOT.len(), + format!("{}{}", self.prompt_root, candidate.path.string), + self.prompt_root.len(), ) } else { (candidate.path.string.clone(), 0) @@ -751,8 +749,11 @@ impl PickerDelegate for OpenPathDelegate { } } -fn path_candidates(parent_path: &String, mut children: Vec) -> Vec { - if *parent_path == PROMPT_ROOT { +fn path_candidates( + parent_path_is_root: bool, + mut children: Vec, +) -> Vec { + if parent_path_is_root { children.push(DirectoryItem { is_dir: true, path: PathBuf::default(), @@ -769,3 +770,128 @@ fn path_candidates(parent_path: &String, mut children: Vec) -> Ve }) .collect() } + +#[cfg(target_os = "windows")] +fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) { + let last_item = Path::new(&query) + .file_name() + .unwrap_or_default() + .to_string_lossy(); + let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) { + (dir.to_string(), last_item.into_owned()) + } else { + (query.to_string(), String::new()) + }; + match path_style { + PathStyle::Posix => { + if dir.is_empty() { + dir = "/".to_string(); + } + } + PathStyle::Windows => { + if dir.len() < 3 { + dir = "C:\\".to_string(); + } + } + } + (dir, suffix) +} + +#[cfg(not(target_os = "windows"))] +fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) { + match path_style { + PathStyle::Posix => { + let (mut dir, suffix) = if let Some(index) = query.rfind('/') { + (query[..index].to_string(), query[index + 1..].to_string()) + } else { + (query, String::new()) + }; + if !dir.ends_with('/') { + dir.push('/'); + } + (dir, suffix) + } + PathStyle::Windows => { + let (mut dir, suffix) = if let Some(index) = query.rfind('\\') { + (query[..index].to_string(), query[index + 1..].to_string()) + } else { + (query, String::new()) + }; + if dir.len() < 3 { + dir = "C:\\".to_string(); + } + if !dir.ends_with('\\') { + dir.push('\\'); + } + (dir, suffix) + } + } +} + +#[cfg(test)] +mod tests { + use util::paths::PathStyle; + + use crate::open_path_prompt::get_dir_and_suffix; + + #[test] + fn test_get_dir_and_suffix_with_windows_style() { + let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Windows); + assert_eq!(dir, "C:\\"); + assert_eq!(suffix, ""); + + let (dir, suffix) = get_dir_and_suffix("C:".into(), PathStyle::Windows); + assert_eq!(dir, "C:\\"); + assert_eq!(suffix, ""); + + let (dir, suffix) = get_dir_and_suffix("C:\\".into(), PathStyle::Windows); + assert_eq!(dir, "C:\\"); + assert_eq!(suffix, ""); + + let (dir, suffix) = get_dir_and_suffix("C:\\Use".into(), PathStyle::Windows); + assert_eq!(dir, "C:\\"); + assert_eq!(suffix, "Use"); + + let (dir, suffix) = + get_dir_and_suffix("C:\\Users\\Junkui\\Docum".into(), PathStyle::Windows); + assert_eq!(dir, "C:\\Users\\Junkui\\"); + assert_eq!(suffix, "Docum"); + + let (dir, suffix) = + get_dir_and_suffix("C:\\Users\\Junkui\\Documents".into(), PathStyle::Windows); + assert_eq!(dir, "C:\\Users\\Junkui\\"); + assert_eq!(suffix, "Documents"); + + let (dir, suffix) = + get_dir_and_suffix("C:\\Users\\Junkui\\Documents\\".into(), PathStyle::Windows); + assert_eq!(dir, "C:\\Users\\Junkui\\Documents\\"); + assert_eq!(suffix, ""); + } + + #[test] + fn test_get_dir_and_suffix_with_posix_style() { + let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Posix); + assert_eq!(dir, "/"); + assert_eq!(suffix, ""); + + let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Posix); + assert_eq!(dir, "/"); + assert_eq!(suffix, ""); + + let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Posix); + assert_eq!(dir, "/"); + assert_eq!(suffix, "Use"); + + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Posix); + assert_eq!(dir, "/Users/Junkui/"); + assert_eq!(suffix, "Docum"); + + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Posix); + assert_eq!(dir, "/Users/Junkui/"); + assert_eq!(suffix, "Documents"); + + let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Posix); + assert_eq!(dir, "/Users/Junkui/Documents/"); + assert_eq!(suffix, ""); + } +} diff --git a/crates/file_finder/src/open_path_prompt_tests.rs b/crates/file_finder/src/open_path_prompt_tests.rs index 0acf2a517d..a69ac6992d 100644 --- a/crates/file_finder/src/open_path_prompt_tests.rs +++ b/crates/file_finder/src/open_path_prompt_tests.rs @@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate}; use project::Project; use serde_json::json; use ui::rems; -use util::path; +use util::{path, paths::PathStyle}; use workspace::{AppState, Workspace}; use crate::OpenPathDelegate; @@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, cx); + let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx); let query = path!("/root"); insert_query(query, &picker, cx).await; @@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, cx); + let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx); // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash. let query = path!("/root"); @@ -186,7 +186,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) { } #[gpui::test] -#[cfg(target_os = "windows")] +#[cfg_attr(not(target_os = "windows"), ignore)] async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { let app_state = init_test(cx); app_state @@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, false, cx); + let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx); // Support both forward and backward slashes. let query = "C:/root/"; @@ -251,6 +251,47 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) { ); } +#[gpui::test] +#[cfg_attr(not(target_os = "windows"), ignore)] +async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "a": "A", + "dir1": {}, + "dir2": {} + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; + + let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx); + + let query = "/root/"; + insert_query(query, &picker, cx).await; + assert_eq!( + collect_match_candidates(&picker, cx), + vec!["a", "dir1", "dir2"] + ); + assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/a"); + + // Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash. + let query = "/root/d"; + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); + assert_eq!(confirm_completion(query, 1, &picker, cx), "/root/dir2/"); + + let query = "/root/d"; + insert_query(query, &picker, cx).await; + assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]); + assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/dir1/"); +} + #[gpui::test] async fn test_new_path_prompt(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -278,7 +319,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (picker, cx) = build_open_path_prompt(project, true, cx); + let (picker, cx) = build_open_path_prompt(project, true, PathStyle::current(), cx); insert_query(path!("/root"), &picker, cx).await; assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]); @@ -315,11 +356,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc { fn build_open_path_prompt( project: Entity, creating_path: bool, + path_style: PathStyle, cx: &mut TestAppContext, ) -> (Entity>, &mut VisualTestContext) { let (tx, _) = futures::channel::oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path); + let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style); let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); ( diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml new file mode 100644 index 0000000000..fc08bc89f5 --- /dev/null +++ b/crates/net/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "net" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/net.rs" +doctest = false + +[dependencies] +smol.workspace = true +workspace-hack.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +anyhow.workspace = true +async-io = "2.4" +windows.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/net/LICENSE-GPL b/crates/net/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/net/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/net/src/async_net.rs b/crates/net/src/async_net.rs new file mode 100644 index 0000000000..6a47902bd8 --- /dev/null +++ b/crates/net/src/async_net.rs @@ -0,0 +1,69 @@ +#[cfg(not(target_os = "windows"))] +pub use smol::net::unix::{UnixListener, UnixStream}; + +#[cfg(target_os = "windows")] +pub use windows::{UnixListener, UnixStream}; + +#[cfg(target_os = "windows")] +pub mod windows { + use std::{ + io::Result, + path::Path, + pin::Pin, + task::{Context, Poll}, + }; + + use smol::{ + Async, + io::{AsyncRead, AsyncWrite}, + }; + + pub struct UnixListener(Async); + + impl UnixListener { + pub fn bind>(path: P) -> Result { + Ok(UnixListener(Async::new(crate::UnixListener::bind(path)?)?)) + } + + pub async fn accept(&self) -> Result<(UnixStream, ())> { + let (sock, _) = self.0.read_with(|listener| listener.accept()).await?; + Ok((UnixStream(Async::new(sock)?), ())) + } + } + + pub struct UnixStream(Async); + + impl UnixStream { + pub async fn connect>(path: P) -> Result { + Ok(UnixStream(Async::new(crate::UnixStream::connect(path)?)?)) + } + } + + impl AsyncRead for UnixStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, buf) + } + } + + impl AsyncWrite for UnixStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_close(cx) + } + } +} diff --git a/crates/net/src/listener.rs b/crates/net/src/listener.rs new file mode 100644 index 0000000000..4774bb850b --- /dev/null +++ b/crates/net/src/listener.rs @@ -0,0 +1,45 @@ +use std::{ + io::Result, + os::windows::io::{AsSocket, BorrowedSocket}, + path::Path, +}; + +use windows::Win32::Networking::WinSock::{SOCKADDR_UN, SOMAXCONN, bind, listen}; + +use crate::{ + socket::UnixSocket, + stream::UnixStream, + util::{init, map_ret, sockaddr_un}, +}; + +pub struct UnixListener(UnixSocket); + +impl UnixListener { + pub fn bind>(path: P) -> Result { + init(); + let socket = UnixSocket::new()?; + let (addr, len) = sockaddr_un(path)?; + unsafe { + map_ret(bind( + socket.as_raw(), + &addr as *const _ as *const _, + len as i32, + ))?; + map_ret(listen(socket.as_raw(), SOMAXCONN as _))?; + } + Ok(Self(socket)) + } + + pub fn accept(&self) -> Result<(UnixStream, ())> { + let mut storage = SOCKADDR_UN::default(); + let mut len = std::mem::size_of_val(&storage) as i32; + let raw = self.0.accept(&mut storage as *mut _ as *mut _, &mut len)?; + Ok((UnixStream::new(raw), ())) + } +} + +impl AsSocket for UnixListener { + fn as_socket(&self) -> BorrowedSocket<'_> { + unsafe { BorrowedSocket::borrow_raw(self.0.as_raw().0 as _) } + } +} diff --git a/crates/net/src/net.rs b/crates/net/src/net.rs new file mode 100644 index 0000000000..4fa76ffcb8 --- /dev/null +++ b/crates/net/src/net.rs @@ -0,0 +1,107 @@ +pub mod async_net; +#[cfg(target_os = "windows")] +pub mod listener; +#[cfg(target_os = "windows")] +pub mod socket; +#[cfg(target_os = "windows")] +pub mod stream; +#[cfg(target_os = "windows")] +mod util; + +#[cfg(target_os = "windows")] +pub use listener::*; +#[cfg(target_os = "windows")] +pub use socket::*; +#[cfg(not(target_os = "windows"))] +pub use std::os::unix::net::{UnixListener, UnixStream}; +#[cfg(target_os = "windows")] +pub use stream::*; + +#[cfg(test)] +mod tests { + use std::io::{Read, Write}; + + use smol::io::{AsyncReadExt, AsyncWriteExt}; + + const SERVER_MESSAGE: &str = "Connection closed"; + const CLIENT_MESSAGE: &str = "Hello, server!"; + const BUFFER_SIZE: usize = 32; + + #[test] + fn test_windows_listener() -> std::io::Result<()> { + use crate::{UnixListener, UnixStream}; + + let temp = tempfile::tempdir()?; + let socket = temp.path().join("socket.sock"); + let listener = UnixListener::bind(&socket)?; + + // Server + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + + // Read data from the client + let mut buffer = [0; BUFFER_SIZE]; + let bytes_read = stream.read(&mut buffer).unwrap(); + let string = String::from_utf8_lossy(&buffer[..bytes_read]); + assert_eq!(string, CLIENT_MESSAGE); + + // Send a message back to the client + stream.write_all(SERVER_MESSAGE.as_bytes()).unwrap(); + }); + + // Client + let mut client = UnixStream::connect(&socket)?; + + // Send data to the server + client.write_all(CLIENT_MESSAGE.as_bytes())?; + let mut buffer = [0; BUFFER_SIZE]; + + // Read the response from the server + let bytes_read = client.read(&mut buffer)?; + let string = String::from_utf8_lossy(&buffer[..bytes_read]); + assert_eq!(string, SERVER_MESSAGE); + client.flush()?; + + server.join().unwrap(); + Ok(()) + } + + #[test] + fn test_unix_listener() -> std::io::Result<()> { + use crate::async_net::{UnixListener, UnixStream}; + + smol::block_on(async { + let temp = tempfile::tempdir()?; + let socket = temp.path().join("socket.sock"); + let listener = UnixListener::bind(&socket)?; + + // Server + let server = smol::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + + // Read data from the client + let mut buffer = [0; BUFFER_SIZE]; + let bytes_read = stream.read(&mut buffer).await.unwrap(); + let string = String::from_utf8_lossy(&buffer[..bytes_read]); + assert_eq!(string, CLIENT_MESSAGE); + + // Send a message back to the client + stream.write_all(SERVER_MESSAGE.as_bytes()).await.unwrap(); + }); + + // Client + let mut client = UnixStream::connect(&socket).await?; + client.write_all(CLIENT_MESSAGE.as_bytes()).await?; + + // Read the response from the server + let mut buffer = [0; BUFFER_SIZE]; + let bytes_read = client.read(&mut buffer).await?; + let string = String::from_utf8_lossy(&buffer[..bytes_read]); + assert_eq!(string, "Connection closed"); + client.flush().await?; + + server.await; + Ok(()) + }) + } +} diff --git a/crates/net/src/socket.rs b/crates/net/src/socket.rs new file mode 100644 index 0000000000..6a1fa3d4c4 --- /dev/null +++ b/crates/net/src/socket.rs @@ -0,0 +1,59 @@ +use std::io::{Error, ErrorKind, Result}; + +use windows::Win32::{ + Foundation::{HANDLE, HANDLE_FLAG_INHERIT, HANDLE_FLAGS, SetHandleInformation}, + Networking::WinSock::{ + AF_UNIX, SEND_RECV_FLAGS, SOCK_STREAM, SOCKADDR, SOCKET, WSA_FLAG_OVERLAPPED, + WSAEWOULDBLOCK, WSASocketW, accept, closesocket, recv, send, + }, +}; + +use crate::util::map_ret; + +pub struct UnixSocket(SOCKET); + +impl UnixSocket { + pub fn new() -> Result { + unsafe { + let raw = WSASocketW(AF_UNIX as _, SOCK_STREAM.0, 0, None, 0, WSA_FLAG_OVERLAPPED)?; + SetHandleInformation( + HANDLE(raw.0 as _), + HANDLE_FLAG_INHERIT.0, + HANDLE_FLAGS::default(), + )?; + Ok(Self(raw)) + } + } + + pub(crate) fn as_raw(&self) -> SOCKET { + self.0 + } + + pub fn accept(&self, storage: *mut SOCKADDR, len: &mut i32) -> Result { + match unsafe { accept(self.0, Some(storage), Some(len)) } { + Ok(sock) => Ok(Self(sock)), + Err(err) => { + let wsa_err = unsafe { windows::Win32::Networking::WinSock::WSAGetLastError().0 }; + if wsa_err == WSAEWOULDBLOCK.0 { + Err(Error::new(ErrorKind::WouldBlock, "accept would block")) + } else { + Err(err.into()) + } + } + } + } + + pub(crate) fn recv(&self, buf: &mut [u8]) -> Result { + map_ret(unsafe { recv(self.0, buf, SEND_RECV_FLAGS::default()) }) + } + + pub(crate) fn send(&self, buf: &[u8]) -> Result { + map_ret(unsafe { send(self.0, buf, SEND_RECV_FLAGS::default()) }) + } +} + +impl Drop for UnixSocket { + fn drop(&mut self) { + unsafe { closesocket(self.0) }; + } +} diff --git a/crates/net/src/stream.rs b/crates/net/src/stream.rs new file mode 100644 index 0000000000..d8b6852fcf --- /dev/null +++ b/crates/net/src/stream.rs @@ -0,0 +1,60 @@ +use std::{ + io::{Read, Result, Write}, + os::windows::io::{AsSocket, BorrowedSocket}, + path::Path, +}; + +use async_io::IoSafe; +use windows::Win32::Networking::WinSock::connect; + +use crate::{ + socket::UnixSocket, + util::{init, map_ret, sockaddr_un}, +}; + +pub struct UnixStream(UnixSocket); + +unsafe impl IoSafe for UnixStream {} + +impl UnixStream { + pub fn new(socket: UnixSocket) -> Self { + Self(socket) + } + + pub fn connect>(path: P) -> Result { + init(); + unsafe { + let inner = UnixSocket::new()?; + let (addr, len) = sockaddr_un(path)?; + + map_ret(connect( + inner.as_raw(), + &addr as *const _ as *const _, + len as i32, + ))?; + Ok(Self(inner)) + } + } +} + +impl Read for UnixStream { + fn read(&mut self, buf: &mut [u8]) -> Result { + self.0.recv(buf) + } +} + +impl Write for UnixStream { + fn write(&mut self, buf: &[u8]) -> Result { + self.0.send(buf) + } + + fn flush(&mut self) -> Result<()> { + Ok(()) + } +} + +impl AsSocket for UnixStream { + fn as_socket(&self) -> BorrowedSocket<'_> { + unsafe { BorrowedSocket::borrow_raw(self.0.as_raw().0 as _) } + } +} diff --git a/crates/net/src/util.rs b/crates/net/src/util.rs new file mode 100644 index 0000000000..f454c099c7 --- /dev/null +++ b/crates/net/src/util.rs @@ -0,0 +1,76 @@ +use std::{ + io::{Error, ErrorKind, Result}, + path::Path, + sync::Once, +}; + +use windows::Win32::Networking::WinSock::{ + ADDRESS_FAMILY, AF_UNIX, SOCKADDR_UN, SOCKET_ERROR, WSAGetLastError, WSAStartup, +}; + +pub(crate) fn init() { + static ONCE: Once = Once::new(); + + ONCE.call_once(|| unsafe { + let mut wsa_data = std::mem::zeroed(); + let result = WSAStartup(0x202, &mut wsa_data); + if result != 0 { + panic!("WSAStartup failed: {}", result); + } + }); +} + +// https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/ +pub(crate) fn sockaddr_un>(path: P) -> Result<(SOCKADDR_UN, usize)> { + let mut addr = SOCKADDR_UN::default(); + addr.sun_family = ADDRESS_FAMILY(AF_UNIX); + + let bytes = path + .as_ref() + .to_str() + .map(|s| s.as_bytes()) + .ok_or(ErrorKind::InvalidInput)?; + + if bytes.contains(&0) { + return Err(Error::new( + ErrorKind::InvalidInput, + "paths may not contain interior null bytes", + )); + } + if bytes.len() >= addr.sun_path.len() { + return Err(Error::new( + ErrorKind::InvalidInput, + "path must be shorter than SUN_LEN", + )); + } + + unsafe { + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + addr.sun_path.as_mut_ptr().cast(), + bytes.len(), + ); + } + + let mut len = sun_path_offset(&addr) + bytes.len(); + match bytes.first() { + Some(&0) | None => {} + Some(_) => len += 1, + } + Ok((addr, len)) +} + +pub(crate) fn map_ret(ret: i32) -> Result { + if ret == SOCKET_ERROR { + Err(Error::from_raw_os_error(unsafe { WSAGetLastError().0 })) + } else { + Ok(ret as usize) + } +} + +fn sun_path_offset(addr: &SOCKADDR_UN) -> usize { + // Work with an actual instance of the type since using a null pointer is UB + let base = addr as *const _ as usize; + let path = &addr.sun_path as *const _ as usize; + path - base +} diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index 29555d0179..19e64adb2d 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -33,7 +33,7 @@ use http_client::HttpClient; use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind}; use node_runtime::NodeRuntime; -use remote::SshRemoteClient; +use remote::{SshRemoteClient, ssh_session::SshArgs}; use rpc::{ AnyProtoClient, TypedEnvelope, proto::{self}, @@ -253,11 +253,16 @@ impl DapStore { cx.spawn(async move |_, cx| { let response = request.await?; let binary = DebugAdapterBinary::from_proto(response)?; - let mut ssh_command = ssh_client.read_with(cx, |ssh, _| { - anyhow::Ok(SshCommand { - arguments: ssh.ssh_args().context("SSH arguments not found")?, - }) - })??; + let (mut ssh_command, envs, path_style) = + ssh_client.read_with(cx, |ssh, _| { + let (SshArgs { arguments, envs }, path_style) = + ssh.ssh_info().context("SSH arguments not found")?; + anyhow::Ok(( + SshCommand { arguments }, + envs.unwrap_or_default(), + path_style, + )) + })??; let mut connection = None; if let Some(c) = binary.connection { @@ -282,12 +287,13 @@ impl DapStore { binary.cwd.as_deref(), binary.envs, None, + path_style, ); Ok(DebugAdapterBinary { command: Some(program), arguments: args, - envs: HashMap::default(), + envs, cwd: None, connection, request_args: binary.request_args, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index c7a1f05761..8e1026421e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -117,7 +117,7 @@ use text::{Anchor, BufferId, Point}; use toolchain_store::EmptyToolchainStore; use util::{ ResultExt as _, - paths::{SanitizedPath, compare_paths}, + paths::{PathStyle, RemotePathBuf, SanitizedPath, compare_paths}, }; use worktree::{CreatedEntry, Snapshot, Traversal}; pub use worktree::{ @@ -1159,9 +1159,11 @@ impl Project { let snippets = SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx); - let ssh_proto = ssh.read(cx).proto_client(); - let worktree_store = - cx.new(|_| WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID)); + let (ssh_proto, path_style) = + ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style())); + let worktree_store = cx.new(|_| { + WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style) + }); cx.subscribe(&worktree_store, Self::on_worktree_store_event) .detach(); @@ -1410,8 +1412,15 @@ impl Project { let remote_id = response.payload.project_id; let role = response.payload.role(); + // todo(zjk) + // Set the proper path style based on the remote let worktree_store = cx.new(|_| { - WorktreeStore::remote(true, client.clone().into(), response.payload.project_id) + WorktreeStore::remote( + true, + client.clone().into(), + response.payload.project_id, + PathStyle::Posix, + ) })?; let buffer_store = cx.new(|cx| { BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx) @@ -4039,7 +4048,8 @@ impl Project { }) }) } else if let Some(ssh_client) = self.ssh_client.as_ref() { - let request_path = Path::new(path); + let path_style = ssh_client.read(cx).path_style(); + let request_path = RemotePathBuf::from_str(path, path_style); let request = ssh_client .read(cx) .proto_client() diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index b067396881..385fdf9082 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -4,6 +4,7 @@ use collections::HashMap; use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, Task, WeakEntity}; use itertools::Itertools; use language::LanguageName; +use remote::ssh_session::SshArgs; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ @@ -17,7 +18,10 @@ use terminal::{ TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::{self, TerminalSettings, VenvSettings}, }; -use util::ResultExt; +use util::{ + ResultExt, + paths::{PathStyle, RemotePathBuf}, +}; pub struct Terminals { pub(crate) local_handles: Vec>, @@ -47,6 +51,13 @@ impl SshCommand { } } +pub struct SshDetails { + pub host: String, + pub ssh_command: SshCommand, + pub envs: Option>, + pub path_style: PathStyle, +} + impl Project { pub fn active_project_directory(&self, cx: &App) -> Option> { let worktree = self @@ -68,14 +79,16 @@ impl Project { } } - pub fn ssh_details(&self, cx: &App) -> Option<(String, SshCommand)> { + pub fn ssh_details(&self, cx: &App) -> Option { if let Some(ssh_client) = &self.ssh_client { let ssh_client = ssh_client.read(cx); - if let Some(args) = ssh_client.ssh_args() { - return Some(( - ssh_client.connection_options().host.clone(), - SshCommand { arguments: args }, - )); + if let Some((SshArgs { arguments, envs }, path_style)) = ssh_client.ssh_info() { + return Some(SshDetails { + host: ssh_client.connection_options().host.clone(), + ssh_command: SshCommand { arguments }, + envs, + path_style, + }); } } @@ -158,17 +171,26 @@ impl Project { .unwrap_or_default(); env.extend(settings.env.clone()); - match &self.ssh_details(cx) { - Some((_, ssh_command)) => { + match self.ssh_details(cx) { + Some(SshDetails { + ssh_command, + envs, + path_style, + .. + }) => { let (command, args) = wrap_for_ssh( - ssh_command, + &ssh_command, Some((&command, &args)), path.as_deref(), env, None, + path_style, ); let mut command = std::process::Command::new(command); command.args(args); + if let Some(envs) = envs { + command.envs(envs); + } command } None => { @@ -202,6 +224,7 @@ impl Project { } }; let ssh_details = this.ssh_details(cx); + let is_ssh_terminal = ssh_details.is_some(); let mut settings_location = None; if let Some(path) = path.as_ref() { @@ -226,11 +249,7 @@ impl Project { // precedence. env.extend(settings.env.clone()); - let local_path = if ssh_details.is_none() { - path.clone() - } else { - None - }; + let local_path = if is_ssh_terminal { None } else { path.clone() }; let mut python_venv_activate_command = None; @@ -241,8 +260,13 @@ impl Project { this.python_activate_command(python_venv_directory, &settings.detect_venv); } - match &ssh_details { - Some((host, ssh_command)) => { + match ssh_details { + Some(SshDetails { + host, + ssh_command, + envs, + path_style, + }) => { log::debug!("Connecting to a remote server: {ssh_command:?}"); // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed @@ -252,9 +276,18 @@ impl Project { env.entry("TERM".to_string()) .or_insert_with(|| "xterm-256color".to_string()); - let (program, args) = - wrap_for_ssh(&ssh_command, None, path.as_deref(), env, None); + let (program, args) = wrap_for_ssh( + &ssh_command, + None, + path.as_deref(), + env, + None, + path_style, + ); env = HashMap::default(); + if let Some(envs) = envs { + env.extend(envs); + } ( Option::::None, Shell::WithArguments { @@ -290,8 +323,13 @@ impl Project { ); } - match &ssh_details { - Some((host, ssh_command)) => { + match ssh_details { + Some(SshDetails { + host, + ssh_command, + envs, + path_style, + }) => { log::debug!("Connecting to a remote server: {ssh_command:?}"); env.entry("TERM".to_string()) .or_insert_with(|| "xterm-256color".to_string()); @@ -304,8 +342,12 @@ impl Project { path.as_deref(), env, python_venv_directory.as_deref(), + path_style, ); env = HashMap::default(); + if let Some(envs) = envs { + env.extend(envs); + } ( task_state, Shell::WithArguments { @@ -343,7 +385,7 @@ impl Project { settings.cursor_shape.unwrap_or_default(), settings.alternate_scroll, settings.max_scroll_history_lines, - ssh_details.is_some(), + is_ssh_terminal, window, completion_tx, cx, @@ -533,6 +575,7 @@ pub fn wrap_for_ssh( path: Option<&Path>, env: HashMap, venv_directory: Option<&Path>, + path_style: PathStyle, ) -> (String, Vec) { let to_run = if let Some((command, args)) = command { // DEFAULT_REMOTE_SHELL is '"${SHELL:-sh}"' so must not be escaped @@ -555,24 +598,25 @@ pub fn wrap_for_ssh( } if let Some(venv_directory) = venv_directory { if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) { - env_changes.push_str(&format!("PATH={}:$PATH ", str)); + let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string(); + env_changes.push_str(&format!("PATH={}:$PATH ", path)); } } let commands = if let Some(path) = path { - let path_string = path.to_string_lossy().to_string(); + let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string(); // shlex will wrap the command in single quotes (''), disabling ~ expansion, // replace ith with something that works let tilde_prefix = "~/"; if path.starts_with(tilde_prefix) { - let trimmed_path = path_string + let trimmed_path = path .trim_start_matches("/") .trim_start_matches("~") .trim_start_matches("/"); format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}") } else { - format!("cd {path:?}; {env_changes} {to_run}") + format!("cd {path}; {env_changes} {to_run}") } } else { format!("cd; {env_changes} {to_run}") diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 48ef3bda6f..16e42e90cb 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -25,7 +25,10 @@ use smol::{ stream::StreamExt, }; use text::ReplicaId; -use util::{ResultExt, paths::SanitizedPath}; +use util::{ + ResultExt, + paths::{PathStyle, RemotePathBuf, SanitizedPath}, +}; use worktree::{ Entry, ProjectEntryId, UpdatedEntriesSet, UpdatedGitRepositoriesSet, Worktree, WorktreeId, WorktreeSettings, @@ -46,6 +49,7 @@ enum WorktreeStoreState { Remote { upstream_client: AnyProtoClient, upstream_project_id: u64, + path_style: PathStyle, }, } @@ -100,6 +104,7 @@ impl WorktreeStore { retain_worktrees: bool, upstream_client: AnyProtoClient, upstream_project_id: u64, + path_style: PathStyle, ) -> Self { Self { next_entry_id: Default::default(), @@ -111,6 +116,7 @@ impl WorktreeStore { state: WorktreeStoreState::Remote { upstream_client, upstream_project_id, + path_style, }, } } @@ -214,17 +220,16 @@ impl WorktreeStore { if !self.loading_worktrees.contains_key(&abs_path) { let task = match &self.state { WorktreeStoreState::Remote { - upstream_client, .. + upstream_client, + path_style, + .. } => { if upstream_client.is_via_collab() { Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab")))) } else { - self.create_ssh_worktree( - upstream_client.clone(), - abs_path.clone(), - visible, - cx, - ) + let abs_path = + RemotePathBuf::new(abs_path.as_path().to_path_buf(), *path_style); + self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx) } } WorktreeStoreState::Local { fs } => { @@ -250,11 +255,12 @@ impl WorktreeStore { fn create_ssh_worktree( &mut self, client: AnyProtoClient, - abs_path: impl Into, + abs_path: RemotePathBuf, visible: bool, cx: &mut Context, ) -> Task, Arc>> { - let mut abs_path = Into::::into(abs_path).to_string(); + let path_style = abs_path.path_style(); + let mut abs_path = abs_path.to_string(); // If we start with `/~` that means the ssh path was something like `ssh://user@host/~/home-dir-folder/` // in which case want to strip the leading the `/`. // On the host-side, the `~` will get expanded. @@ -265,10 +271,11 @@ impl WorktreeStore { if abs_path.is_empty() { abs_path = "~/".to_string(); } + cx.spawn(async move |this, cx| { let this = this.upgrade().context("Dropped worktree store")?; - let path = Path::new(abs_path.as_str()); + let path = RemotePathBuf::new(abs_path.into(), path_style); let response = client .request(proto::AddWorktree { project_id: SSH_PROJECT_ID, diff --git a/crates/proto/Cargo.toml b/crates/proto/Cargo.toml index 5255463600..6cae4394bd 100644 --- a/crates/proto/Cargo.toml +++ b/crates/proto/Cargo.toml @@ -27,3 +27,4 @@ prost-build.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } +typed-path = "0.11" diff --git a/crates/proto/src/typed_envelope.rs b/crates/proto/src/typed_envelope.rs index a4d0a9bf85..381a6379dc 100644 --- a/crates/proto/src/typed_envelope.rs +++ b/crates/proto/src/typed_envelope.rs @@ -127,51 +127,46 @@ pub trait ToProto { fn to_proto(self) -> String; } -impl FromProto for PathBuf { +#[inline] +fn from_proto_path(proto: String) -> PathBuf { #[cfg(target_os = "windows")] - fn from_proto(proto: String) -> Self { - proto.split("/").collect() - } + let proto = proto.replace('/', "\\"); + + PathBuf::from(proto) +} + +#[inline] +fn to_proto_path(path: &Path) -> String { + #[cfg(target_os = "windows")] + let proto = path.to_string_lossy().replace('\\', "/"); #[cfg(not(target_os = "windows"))] + let proto = path.to_string_lossy().to_string(); + + proto +} + +impl FromProto for PathBuf { fn from_proto(proto: String) -> Self { - PathBuf::from(proto) + from_proto_path(proto) } } impl FromProto for Arc { fn from_proto(proto: String) -> Self { - PathBuf::from_proto(proto).into() + from_proto_path(proto).into() } } impl ToProto for PathBuf { - #[cfg(target_os = "windows")] fn to_proto(self) -> String { - self.components() - .map(|comp| comp.as_os_str().to_string_lossy().to_string()) - .collect::>() - .join("/") - } - - #[cfg(not(target_os = "windows"))] - fn to_proto(self) -> String { - self.to_string_lossy().to_string() + to_proto_path(&self) } } impl ToProto for &Path { - #[cfg(target_os = "windows")] fn to_proto(self) -> String { - self.components() - .map(|comp| comp.as_os_str().to_string_lossy().to_string()) - .collect::>() - .join("/") - } - - #[cfg(not(target_os = "windows"))] - fn to_proto(self) -> String { - self.to_string_lossy().to_string() + to_proto_path(self) } } @@ -214,3 +209,103 @@ impl TypedEnvelope { } } } + +#[cfg(test)] +mod tests { + use typed_path::{UnixPath, UnixPathBuf, WindowsPath, WindowsPathBuf}; + + fn windows_path_from_proto(proto: String) -> WindowsPathBuf { + let proto = proto.replace('/', "\\"); + WindowsPathBuf::from(proto) + } + + fn unix_path_from_proto(proto: String) -> UnixPathBuf { + UnixPathBuf::from(proto) + } + + fn windows_path_to_proto(path: &WindowsPath) -> String { + path.to_string_lossy().replace('\\', "/") + } + + fn unix_path_to_proto(path: &UnixPath) -> String { + path.to_string_lossy().to_string() + } + + #[test] + fn test_path_proto_interop() { + const WINDOWS_PATHS: &[&str] = &[ + "C:\\Users\\User\\Documents\\file.txt", + "C:/Program Files/App/app.exe", + "projects\\zed\\crates\\proto\\src\\typed_envelope.rs", + "projects/my project/src/main.rs", + ]; + const UNIX_PATHS: &[&str] = &[ + "/home/user/documents/file.txt", + "/usr/local/bin/my app/app", + "projects/zed/crates/proto/src/typed_envelope.rs", + "projects/my project/src/main.rs", + ]; + + // Windows path to proto and back + for &windows_path_str in WINDOWS_PATHS { + let windows_path = WindowsPathBuf::from(windows_path_str); + let proto = windows_path_to_proto(&windows_path); + let recovered_path = windows_path_from_proto(proto); + assert_eq!(windows_path, recovered_path); + assert_eq!( + recovered_path.to_string_lossy(), + windows_path_str.replace('/', "\\") + ); + } + // Unix path to proto and back + for &unix_path_str in UNIX_PATHS { + let unix_path = UnixPathBuf::from(unix_path_str); + let proto = unix_path_to_proto(&unix_path); + let recovered_path = unix_path_from_proto(proto); + assert_eq!(unix_path, recovered_path); + assert_eq!(recovered_path.to_string_lossy(), unix_path_str); + } + // Windows host, Unix client, host sends Windows path to client + for &windows_path_str in WINDOWS_PATHS { + let windows_host_path = WindowsPathBuf::from(windows_path_str); + let proto = windows_path_to_proto(&windows_host_path); + let unix_client_received_path = unix_path_from_proto(proto); + let proto = unix_path_to_proto(&unix_client_received_path); + let windows_host_recovered_path = windows_path_from_proto(proto); + assert_eq!(windows_host_path, windows_host_recovered_path); + assert_eq!( + windows_host_recovered_path.to_string_lossy(), + windows_path_str.replace('/', "\\") + ); + } + // Unix host, Windows client, host sends Unix path to client + for &unix_path_str in UNIX_PATHS { + let unix_host_path = UnixPathBuf::from(unix_path_str); + let proto = unix_path_to_proto(&unix_host_path); + let windows_client_received_path = windows_path_from_proto(proto); + let proto = windows_path_to_proto(&windows_client_received_path); + let unix_host_recovered_path = unix_path_from_proto(proto); + assert_eq!(unix_host_path, unix_host_recovered_path); + assert_eq!(unix_host_recovered_path.to_string_lossy(), unix_path_str); + } + } + + // todo(zjk) + #[test] + fn test_unsolved_case() { + // Unix host, Windows client + // The Windows client receives a Unix path with backslashes in it, then + // sends it back to the host. + // This currently fails. + let unix_path = UnixPathBuf::from("/home/user/projects/my\\project/src/main.rs"); + let proto = unix_path_to_proto(&unix_path); + let windows_client_received_path = windows_path_from_proto(proto); + let proto = windows_path_to_proto(&windows_client_received_path); + let unix_host_recovered_path = unix_path_from_proto(proto); + assert_ne!(unix_path, unix_host_recovered_path); + assert_eq!( + unix_host_recovered_path.to_string_lossy(), + "/home/user/projects/my/project/src/main.rs" + ); + } +} diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 134f728680..aa5103e62b 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -28,9 +28,8 @@ use paths::user_ssh_config_file; use picker::Picker; use project::Fs; use project::Project; -use remote::SshConnectionOptions; -use remote::SshRemoteClient; use remote::ssh_session::ConnectionIdentifier; +use remote::{SshConnectionOptions, SshRemoteClient}; use settings::Settings; use settings::SettingsStore; use settings::update_settings_file; @@ -42,7 +41,10 @@ use ui::{ IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Scrollbar, ScrollbarState, Section, Tooltip, prelude::*, }; -use util::ResultExt; +use util::{ + ResultExt, + paths::{PathStyle, RemotePathBuf}, +}; use workspace::OpenOptions; use workspace::Toast; use workspace::notifications::NotificationId; @@ -142,20 +144,21 @@ impl ProjectPicker { ix: usize, connection: SshConnectionOptions, project: Entity, - home_dir: PathBuf, + home_dir: RemotePathBuf, + path_style: PathStyle, workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Entity { let (tx, rx) = oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let delegate = file_finder::OpenPathDelegate::new(tx, lister, false); + let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .width(rems(34.)) .modal(false); - picker.set_query(home_dir.to_string_lossy().to_string(), window, cx); + picker.set_query(home_dir.to_string(), window, cx); picker }); let connection_string = connection.connection_string().into(); @@ -422,7 +425,8 @@ impl RemoteServerProjects { ix: usize, connection_options: remote::SshConnectionOptions, project: Entity, - home_dir: PathBuf, + home_dir: RemotePathBuf, + path_style: PathStyle, window: &mut Window, cx: &mut Context, workspace: WeakEntity, @@ -435,6 +439,7 @@ impl RemoteServerProjects { connection_options, project, home_dir, + path_style, workspace, window, cx, @@ -589,15 +594,18 @@ impl RemoteServerProjects { }); }; - let project = cx.update(|_, cx| { - project::Project::ssh( - session, - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - cx, + let (path_style, project) = cx.update(|_, cx| { + ( + session.read(cx).path_style(), + project::Project::ssh( + session, + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + cx, + ), ) })?; @@ -605,7 +613,13 @@ impl RemoteServerProjects { .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))? .await .and_then(|path| path.into_abs_path()) - .unwrap_or(PathBuf::from("/")); + .map(|path| RemotePathBuf::new(path, path_style)) + .unwrap_or_else(|| match path_style { + PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix), + PathStyle::Windows => { + RemotePathBuf::from_str("C:\\", PathStyle::Windows) + } + }); workspace .update_in(cx, |workspace, window, cx| { @@ -617,6 +631,7 @@ impl RemoteServerProjects { connection_options, project, home_dir, + path_style, window, cx, weak, diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index e01f4cfb04..2653a19bd9 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -49,7 +49,10 @@ use std::{ time::{Duration, Instant}, }; use tempfile::TempDir; -use util::ResultExt; +use util::{ + ResultExt, + paths::{PathStyle, RemotePathBuf}, +}; #[derive( Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize, @@ -59,7 +62,10 @@ pub struct SshProjectId(pub u64); #[derive(Clone)] pub struct SshSocket { connection_options: SshConnectionOptions, + #[cfg(not(target_os = "windows"))] socket_path: PathBuf, + #[cfg(target_os = "windows")] + envs: HashMap, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)] @@ -85,6 +91,11 @@ pub struct SshConnectionOptions { pub upload_binary_over_ssh: bool, } +pub struct SshArgs { + pub arguments: Vec, + pub envs: Option>, +} + #[macro_export] macro_rules! shell_script { ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{ @@ -338,6 +349,28 @@ pub trait SshClientDelegate: Send + Sync { } impl SshSocket { + #[cfg(not(target_os = "windows"))] + fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result { + Ok(Self { + connection_options: options, + socket_path, + }) + } + + #[cfg(target_os = "windows")] + fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result { + let askpass_script = temp_dir.path().join("askpass.bat"); + std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?; + let mut envs = HashMap::default(); + envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into()); + envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string()); + envs.insert("ZED_SSH_ASKPASS".into(), secret); + Ok(Self { + connection_options: options, + envs, + }) + } + // :WARNING: ssh unquotes arguments when executing on the remote :WARNING: // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l // and passes -l as an argument to sh, not to ls. @@ -375,6 +408,7 @@ impl SshSocket { Ok(String::from_utf8_lossy(&output.stdout).to_string()) } + #[cfg(not(target_os = "windows"))] fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { command .stdin(Stdio::piped()) @@ -384,14 +418,68 @@ impl SshSocket { .arg(format!("ControlPath={}", self.socket_path.display())) } - fn ssh_args(&self) -> Vec { - vec![ - "-o".to_string(), - "ControlMaster=no".to_string(), - "-o".to_string(), - format!("ControlPath={}", self.socket_path.display()), - self.connection_options.ssh_url(), - ] + #[cfg(target_os = "windows")] + fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command { + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .envs(self.envs.clone()) + } + + // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. + // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to + #[cfg(not(target_os = "windows"))] + fn ssh_args(&self) -> SshArgs { + SshArgs { + arguments: vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", self.socket_path.display()), + self.connection_options.ssh_url(), + ], + envs: None, + } + } + + #[cfg(target_os = "windows")] + fn ssh_args(&self) -> SshArgs { + SshArgs { + arguments: vec![self.connection_options.ssh_url()], + envs: Some(self.envs.clone()), + } + } + + async fn platform(&self) -> Result { + let uname = self.run_command("sh", &["-c", "uname -sm"]).await?; + let Some((os, arch)) = uname.split_once(" ") else { + anyhow::bail!("unknown uname: {uname:?}") + }; + + let os = match os.trim() { + "Darwin" => "macos", + "Linux" => "linux", + _ => anyhow::bail!( + "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" + ), + }; + // exclude armv5,6,7 as they are 32-bit. + let arch = if arch.starts_with("armv8") + || arch.starts_with("armv9") + || arch.starts_with("arm64") + || arch.starts_with("aarch64") + { + "aarch64" + } else if arch.starts_with("x86") { + "x86_64" + } else { + anyhow::bail!( + "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" + ) + }; + + Ok(SshPlatform { os, arch }) } } @@ -560,6 +648,7 @@ pub struct SshRemoteClient { client: Arc, unique_identifier: String, connection_options: SshConnectionOptions, + path_style: PathStyle, state: Arc>>, } @@ -620,22 +709,25 @@ impl SshRemoteClient { let client = cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?; - let this = cx.new(|_| Self { - client: client.clone(), - unique_identifier: unique_identifier.clone(), - connection_options: connection_options.clone(), - state: Arc::new(Mutex::new(Some(State::Connecting))), - })?; let ssh_connection = cx .update(|cx| { cx.update_default_global(|pool: &mut ConnectionPool, cx| { - pool.connect(connection_options, &delegate, cx) + pool.connect(connection_options.clone(), &delegate, cx) }) })? .await .map_err(|e| e.cloned())?; + let path_style = ssh_connection.path_style(); + let this = cx.new(|_| Self { + client: client.clone(), + unique_identifier: unique_identifier.clone(), + connection_options, + path_style, + state: Arc::new(Mutex::new(Some(State::Connecting))), + })?; + let io_task = ssh_connection.start_proxy( unique_identifier, false, @@ -1065,18 +1157,18 @@ impl SshRemoteClient { self.client.subscribe_to_entity(remote_id, entity); } - pub fn ssh_args(&self) -> Option> { + pub fn ssh_info(&self) -> Option<(SshArgs, PathStyle)> { self.state .lock() .as_ref() .and_then(|state| state.ssh_connection()) - .map(|ssh_connection| ssh_connection.ssh_args()) + .map(|ssh_connection| (ssh_connection.ssh_args(), ssh_connection.path_style())) } pub fn upload_directory( &self, src_path: PathBuf, - dest_path: PathBuf, + dest_path: RemotePathBuf, cx: &App, ) -> Task> { let state = self.state.lock(); @@ -1110,6 +1202,10 @@ impl SshRemoteClient { self.connection_state() == ConnectionState::Disconnected } + pub fn path_style(&self) -> PathStyle { + self.path_style + } + #[cfg(any(test, feature = "test-support"))] pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> { let opts = self.connection_options(); @@ -1288,12 +1384,19 @@ trait RemoteConnection: Send + Sync { delegate: Arc, cx: &mut AsyncApp, ) -> Task>; - fn upload_directory(&self, src_path: PathBuf, dest_path: PathBuf, cx: &App) - -> Task>; + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task>; async fn kill(&self) -> Result<()>; fn has_been_killed(&self) -> bool; - fn ssh_args(&self) -> Vec; + /// On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh. + /// On Linux, we use the `ControlPath` option to create a socket file that ssh can use to + fn ssh_args(&self) -> SshArgs; fn connection_options(&self) -> SshConnectionOptions; + fn path_style(&self) -> PathStyle; #[cfg(any(test, feature = "test-support"))] fn simulate_disconnect(&self, _: &AsyncApp) {} @@ -1302,7 +1405,9 @@ trait RemoteConnection: Send + Sync { struct SshRemoteConnection { socket: SshSocket, master_process: Mutex>, - remote_binary_path: Option, + remote_binary_path: Option, + ssh_platform: SshPlatform, + ssh_path_style: PathStyle, _temp_dir: TempDir, } @@ -1321,7 +1426,7 @@ impl RemoteConnection for SshRemoteConnection { self.master_process.lock().is_none() } - fn ssh_args(&self) -> Vec { + fn ssh_args(&self) -> SshArgs { self.socket.ssh_args() } @@ -1332,7 +1437,7 @@ impl RemoteConnection for SshRemoteConnection { fn upload_directory( &self, src_path: PathBuf, - dest_path: PathBuf, + dest_path: RemotePathBuf, cx: &App, ) -> Task> { let mut command = util::command::new_smol_command("scp"); @@ -1352,7 +1457,7 @@ impl RemoteConnection for SshRemoteConnection { .arg(format!( "{}:{}", self.socket.connection_options.scp_url(), - dest_path.display() + dest_path.to_string() )) .output(); @@ -1363,7 +1468,7 @@ impl RemoteConnection for SshRemoteConnection { output.status.success(), "failed to upload directory {} -> {}: {}", src_path.display(), - dest_path.display(), + dest_path.to_string(), String::from_utf8_lossy(&output.stderr) ); @@ -1389,7 +1494,7 @@ impl RemoteConnection for SshRemoteConnection { let mut start_proxy_command = shell_script!( "exec {binary_path} proxy --identifier {identifier}", - binary_path = &remote_binary_path.to_string_lossy(), + binary_path = &remote_binary_path.to_string(), identifier = &unique_identifier, ); @@ -1432,19 +1537,13 @@ impl RemoteConnection for SshRemoteConnection { &cx, ) } + + fn path_style(&self) -> PathStyle { + self.ssh_path_style + } } impl SshRemoteConnection { - #[cfg(not(unix))] - async fn new( - _connection_options: SshConnectionOptions, - _delegate: Arc, - _cx: &mut AsyncApp, - ) -> Result { - anyhow::bail!("ssh is not supported on this platform"); - } - - #[cfg(unix)] async fn new( connection_options: SshConnectionOptions, delegate: Arc, @@ -1470,27 +1569,38 @@ impl SshRemoteConnection { // Start the master SSH process, which does not do anything except for establish // the connection and keep it open, allowing other ssh commands to reuse it // via a control socket. + #[cfg(not(target_os = "windows"))] let socket_path = temp_dir.path().join("ssh.sock"); - let mut master_process = process::Command::new("ssh") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .env("SSH_ASKPASS_REQUIRE", "force") - .env("SSH_ASKPASS", &askpass.script_path()) - .args(connection_options.additional_args()) - .args([ + let mut master_process = { + #[cfg(not(target_os = "windows"))] + let args = [ "-N", "-o", "ControlPersist=no", "-o", "ControlMaster=yes", "-o", - ]) - .arg(format!("ControlPath={}", socket_path.display())) - .arg(&url) - .kill_on_drop(true) - .spawn()?; + ]; + // On Windows, `ControlMaster` and `ControlPath` are not supported: + // https://github.com/PowerShell/Win32-OpenSSH/issues/405 + // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope + #[cfg(target_os = "windows")] + let args = ["-N"]; + let mut master_process = process::Command::new("ssh"); + master_process + .kill_on_drop(true) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("SSH_ASKPASS_REQUIRE", "force") + .env("SSH_ASKPASS", askpass.script_path()) + .args(connection_options.additional_args()) + .args(args); + #[cfg(not(target_os = "windows"))] + master_process.arg(format!("ControlPath={}", socket_path.display())); + master_process.arg(&url).spawn()? + }; // Wait for this ssh process to close its stdout, indicating that authentication // has completed. let mut stdout = master_process.stdout.take().unwrap(); @@ -1529,11 +1639,16 @@ impl SshRemoteConnection { anyhow::bail!(error_message); } + #[cfg(not(target_os = "windows"))] + let socket = SshSocket::new(connection_options, socket_path)?; + #[cfg(target_os = "windows")] + let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?; drop(askpass); - let socket = SshSocket { - connection_options, - socket_path, + let ssh_platform = socket.platform().await?; + let ssh_path_style = match ssh_platform.os { + "windows" => PathStyle::Windows, + _ => PathStyle::Posix, }; let mut this = Self { @@ -1541,6 +1656,8 @@ impl SshRemoteConnection { master_process: Mutex::new(Some(master_process)), _temp_dir: temp_dir, remote_binary_path: None, + ssh_path_style, + ssh_platform, }; let (release_channel, version, commit) = cx.update(|cx| { @@ -1558,37 +1675,6 @@ impl SshRemoteConnection { Ok(this) } - async fn platform(&self) -> Result { - let uname = self.socket.run_command("sh", &["-c", "uname -sm"]).await?; - let Some((os, arch)) = uname.split_once(" ") else { - anyhow::bail!("unknown uname: {uname:?}") - }; - - let os = match os.trim() { - "Darwin" => "macos", - "Linux" => "linux", - _ => anyhow::bail!( - "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" - ), - }; - // exclude armv5,6,7 as they are 32-bit. - let arch = if arch.starts_with("armv8") - || arch.starts_with("armv9") - || arch.starts_with("arm64") - || arch.starts_with("aarch64") - { - "aarch64" - } else if arch.starts_with("x86") { - "x86_64" - } else { - anyhow::bail!( - "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" - ) - }; - - Ok(SshPlatform { os, arch }) - } - fn multiplex( mut ssh_proxy_process: Child, incoming_tx: UnboundedSender, @@ -1699,11 +1785,10 @@ impl SshRemoteConnection { version: SemanticVersion, commit: Option, cx: &mut AsyncApp, - ) -> Result { + ) -> Result { let version_str = match release_channel { ReleaseChannel::Nightly => { let commit = commit.map(|s| s.full()).unwrap_or_default(); - format!("{}-{}", version, commit) } ReleaseChannel::Dev => "build".to_string(), @@ -1714,19 +1799,23 @@ impl SshRemoteConnection { release_channel.dev_name(), version_str ); - let dst_path = paths::remote_server_dir_relative().join(binary_name); + let dst_path = RemotePathBuf::new( + paths::remote_server_dir_relative().join(binary_name), + self.ssh_path_style, + ); let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok(); #[cfg(debug_assertions)] if let Some(build_remote_server) = build_remote_server { - let src_path = self - .build_local(build_remote_server, self.platform().await?, delegate, cx) - .await?; - let tmp_path = paths::remote_server_dir_relative().join(format!( - "download-{}-{}", - std::process::id(), - src_path.file_name().unwrap().to_string_lossy() - )); + let src_path = self.build_local(build_remote_server, delegate, cx).await?; + let tmp_path = RemotePathBuf::new( + paths::remote_server_dir_relative().join(format!( + "download-{}-{}", + std::process::id(), + src_path.file_name().unwrap().to_string_lossy() + )), + self.ssh_path_style, + ); self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx) .await?; self.extract_server_binary(&dst_path, &tmp_path, delegate, cx) @@ -1736,7 +1825,7 @@ impl SshRemoteConnection { if self .socket - .run_command(&dst_path.to_string_lossy(), &["version"]) + .run_command(&dst_path.to_string(), &["version"]) .await .is_ok() { @@ -1754,16 +1843,17 @@ impl SshRemoteConnection { _ => Ok(Some(AppVersion::global(cx))), })??; - let platform = self.platform().await?; - - let tmp_path_gz = PathBuf::from(format!( - "{}-download-{}.gz", - dst_path.to_string_lossy(), - std::process::id() - )); + let tmp_path_gz = RemotePathBuf::new( + PathBuf::from(format!( + "{}-download-{}.gz", + dst_path.to_string(), + std::process::id() + )), + self.ssh_path_style, + ); if !self.socket.connection_options.upload_binary_over_ssh { if let Some((url, body)) = delegate - .get_download_params(platform, release_channel, wanted_version, cx) + .get_download_params(self.ssh_platform, release_channel, wanted_version, cx) .await? { match self @@ -1786,7 +1876,7 @@ impl SshRemoteConnection { } let src_path = delegate - .download_server_binary_locally(platform, release_channel, wanted_version, cx) + .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx) .await?; self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx) .await?; @@ -1799,7 +1889,7 @@ impl SshRemoteConnection { &self, url: &str, body: &str, - tmp_path_gz: &Path, + tmp_path_gz: &RemotePathBuf, delegate: &Arc, cx: &mut AsyncApp, ) -> Result<()> { @@ -1809,10 +1899,7 @@ impl SshRemoteConnection { "sh", &[ "-c", - &shell_script!( - "mkdir -p {parent}", - parent = parent.to_string_lossy().as_ref() - ), + &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), ], ) .await?; @@ -1835,7 +1922,7 @@ impl SshRemoteConnection { &body, &url, "-o", - &tmp_path_gz.to_string_lossy(), + &tmp_path_gz.to_string(), ], ) .await @@ -1857,7 +1944,7 @@ impl SshRemoteConnection { &body, &url, "-O", - &tmp_path_gz.to_string_lossy(), + &tmp_path_gz.to_string(), ], ) .await @@ -1880,7 +1967,7 @@ impl SshRemoteConnection { async fn upload_local_server_binary( &self, src_path: &Path, - tmp_path_gz: &Path, + tmp_path_gz: &RemotePathBuf, delegate: &Arc, cx: &mut AsyncApp, ) -> Result<()> { @@ -1890,10 +1977,7 @@ impl SshRemoteConnection { "sh", &[ "-c", - &shell_script!( - "mkdir -p {parent}", - parent = parent.to_string_lossy().as_ref() - ), + &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), ], ) .await?; @@ -1918,33 +2002,33 @@ impl SshRemoteConnection { async fn extract_server_binary( &self, - dst_path: &Path, - tmp_path: &Path, + dst_path: &RemotePathBuf, + tmp_path: &RemotePathBuf, delegate: &Arc, cx: &mut AsyncApp, ) -> Result<()> { delegate.set_status(Some("Extracting remote development server"), cx); let server_mode = 0o755; - let orig_tmp_path = tmp_path.to_string_lossy(); + let orig_tmp_path = tmp_path.to_string(); let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { shell_script!( "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", server_mode = &format!("{:o}", server_mode), - dst_path = &dst_path.to_string_lossy() + dst_path = &dst_path.to_string(), ) } else { shell_script!( "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}", server_mode = &format!("{:o}", server_mode), - dst_path = &dst_path.to_string_lossy() + dst_path = &dst_path.to_string() ) }; self.socket.run_command("sh", &["-c", &script]).await?; Ok(()) } - async fn upload_file(&self, src_path: &Path, dest_path: &Path) -> Result<()> { + async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> { log::debug!("uploading file {:?} to {:?}", src_path, dest_path); let mut command = util::command::new_smol_command("scp"); let output = self @@ -1961,7 +2045,7 @@ impl SshRemoteConnection { .arg(format!( "{}:{}", self.socket.connection_options.scp_url(), - dest_path.display() + dest_path.to_string() )) .output() .await?; @@ -1970,7 +2054,7 @@ impl SshRemoteConnection { output.status.success(), "failed to upload file {} -> {}: {}", src_path.display(), - dest_path.display(), + dest_path.to_string(), String::from_utf8_lossy(&output.stderr) ); Ok(()) @@ -1980,7 +2064,6 @@ impl SshRemoteConnection { async fn build_local( &self, build_remote_server: String, - platform: SshPlatform, delegate: &Arc, cx: &mut AsyncApp, ) -> Result { @@ -1999,7 +2082,9 @@ impl SshRemoteConnection { Ok(()) } - if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS { + if self.ssh_platform.arch == std::env::consts::ARCH + && self.ssh_platform.os == std::env::consts::OS + { delegate.set_status(Some("Building remote server binary from source"), cx); log::info!("building remote server binary from source"); run_cmd(Command::new("cargo").args([ @@ -2025,12 +2110,15 @@ impl SshRemoteConnection { let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz"); return Ok(path); } - let Some(triple) = platform.triple() else { - anyhow::bail!("can't cross compile for: {:?}", platform); + let Some(triple) = self.ssh_platform.triple() else { + anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform); }; smol::fs::create_dir_all("target/remote_server").await?; if build_remote_server.contains("cross") { + #[cfg(target_os = "windows")] + use util::paths::SanitizedPath; + delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx); log::info!("installing cross"); run_cmd(Command::new("cargo").args([ @@ -2049,6 +2137,13 @@ impl SshRemoteConnection { cx, ); log::info!("building remote server binary from source for {}", &triple); + + // On Windows, the binding needs to be set to the canonical path + #[cfg(target_os = "windows")] + let src = + SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string(); + #[cfg(not(target_os = "windows"))] + let src = "./target"; run_cmd( Command::new("cross") .args([ @@ -2064,7 +2159,7 @@ impl SshRemoteConnection { ]) .env( "CROSS_CONTAINER_OPTS", - "--mount type=bind,src=./target,dst=/app/target", + format!("--mount type=bind,src={src},dst=/app/target"), ), ) .await?; @@ -2074,9 +2169,18 @@ impl SshRemoteConnection { .await; if which.is_err() { - anyhow::bail!( - "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" - ) + #[cfg(not(target_os = "windows"))] + { + anyhow::bail!( + "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" + ) + } + #[cfg(target_os = "windows")] + { + anyhow::bail!( + "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross" + ) + } } delegate.set_status(Some("Adding rustup target for cross-compilation"), cx); @@ -2112,12 +2216,31 @@ impl SshRemoteConnection { if !build_remote_server.contains("nocompress") { delegate.set_status(Some("Compressing binary"), cx); - run_cmd(Command::new("gzip").args([ - "-9", - "-f", - &format!("target/remote_server/{}/debug/remote_server", triple), - ])) - .await?; + #[cfg(not(target_os = "windows"))] + { + run_cmd(Command::new("gzip").args([ + "-9", + "-f", + &format!("target/remote_server/{}/debug/remote_server", triple), + ])) + .await?; + } + #[cfg(target_os = "windows")] + { + // On Windows, we use 7z to compress the binary + let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?; + let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple); + if smol::fs::metadata(&gz_path).await.is_ok() { + smol::fs::remove_file(&gz_path).await?; + } + run_cmd(Command::new(seven_zip).args([ + "a", + "-tgzip", + &gz_path, + &format!("target/remote_server/{}/debug/remote_server", triple), + ])) + .await?; + } path = std::env::current_dir()?.join(format!( "target/remote_server/{triple}/debug/remote_server.gz" @@ -2450,9 +2573,11 @@ mod fake { use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext}; use release_channel::ReleaseChannel; use rpc::proto::Envelope; + use util::paths::{PathStyle, RemotePathBuf}; use super::{ - ChannelClient, RemoteConnection, SshClientDelegate, SshConnectionOptions, SshPlatform, + ChannelClient, RemoteConnection, SshArgs, SshClientDelegate, SshConnectionOptions, + SshPlatform, }; pub(super) struct FakeRemoteConnection { @@ -2488,13 +2613,17 @@ mod fake { false } - fn ssh_args(&self) -> Vec { - Vec::new() + fn ssh_args(&self) -> SshArgs { + SshArgs { + arguments: Vec::new(), + envs: None, + } } + fn upload_directory( &self, _src_path: PathBuf, - _dest_path: PathBuf, + _dest_path: RemotePathBuf, _cx: &App, ) -> Task> { unreachable!() @@ -2513,7 +2642,6 @@ mod fake { fn start_proxy( &self, - _unique_identifier: String, _reconnect: bool, mut client_incoming_tx: mpsc::UnboundedSender, @@ -2551,6 +2679,10 @@ mod fake { } }) } + + fn path_style(&self) -> PathStyle { + PathStyle::current() + } } pub(super) struct Delegate; diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 2e02f051d1..585f2b08aa 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -166,6 +166,98 @@ impl> From for SanitizedPath { } } +#[derive(Debug, Clone, Copy)] +pub enum PathStyle { + Posix, + Windows, +} + +impl PathStyle { + #[cfg(target_os = "windows")] + pub const fn current() -> Self { + PathStyle::Windows + } + + #[cfg(not(target_os = "windows"))] + pub const fn current() -> Self { + PathStyle::Posix + } + + #[inline] + pub fn separator(&self) -> &str { + match self { + PathStyle::Posix => "/", + PathStyle::Windows => "\\", + } + } +} + +#[derive(Debug, Clone)] +pub struct RemotePathBuf { + inner: PathBuf, + style: PathStyle, + string: String, // Cached string representation +} + +impl RemotePathBuf { + pub fn new(path: PathBuf, style: PathStyle) -> Self { + #[cfg(target_os = "windows")] + let string = match style { + PathStyle::Posix => path.to_string_lossy().replace('\\', "/"), + PathStyle::Windows => path.to_string_lossy().into(), + }; + #[cfg(not(target_os = "windows"))] + let string = match style { + PathStyle::Posix => path.to_string_lossy().to_string(), + PathStyle::Windows => path.to_string_lossy().replace('/', "\\"), + }; + Self { + inner: path, + style, + string, + } + } + + pub fn from_str(path: &str, style: PathStyle) -> Self { + let path_buf = PathBuf::from(path); + Self::new(path_buf, style) + } + + pub fn to_string(&self) -> String { + self.string.clone() + } + + #[cfg(target_os = "windows")] + pub fn to_proto(self) -> String { + match self.path_style() { + PathStyle::Posix => self.to_string(), + PathStyle::Windows => self.inner.to_string_lossy().replace('\\', "/"), + } + } + + #[cfg(not(target_os = "windows"))] + pub fn to_proto(self) -> String { + match self.path_style() { + PathStyle::Posix => self.inner.to_string_lossy().to_string(), + PathStyle::Windows => self.to_string(), + } + } + + pub fn as_path(&self) -> &Path { + &self.inner + } + + pub fn path_style(&self) -> PathStyle { + self.style + } + + pub fn parent(&self) -> Option { + self.inner + .parent() + .map(|p| RemotePathBuf::new(p.to_path_buf(), self.style)) + } +} + /// A delimiter to use in `path_query:row_number:column_number` strings parsing. pub const FILE_ROW_COLUMN_DELIMITER: char = ':'; diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 3c46c486a8..b5efea10e2 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -167,16 +167,6 @@ pub fn main() { #[cfg(unix)] util::prevent_root_execution(); - // Check if there is a pending installer - // If there is, run the installer and exit - // And we don't want to run the installer if we are not the first instance - #[cfg(target_os = "windows")] - let is_first_instance = crate::zed::windows_only_instance::is_first_instance(); - #[cfg(target_os = "windows")] - if is_first_instance && auto_update::check_pending_installation() { - return; - } - let args = Args::parse(); // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass @@ -191,6 +181,16 @@ pub fn main() { return; } + // Check if there is a pending installer + // If there is, run the installer and exit + // And we don't want to run the installer if we are not the first instance + #[cfg(target_os = "windows")] + let is_first_instance = crate::zed::windows_only_instance::is_first_instance(); + #[cfg(target_os = "windows")] + if is_first_instance && auto_update::check_pending_installation() { + return; + } + if args.dump_all_actions { dump_all_gpui_actions(); return;