From 0ab50e0dd403f3dcf4f4e60c7396b0d51a1a3897 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:43:37 +0800 Subject: [PATCH] fix(theme): Define the chart series colour in the high-contrast dark theme --- Cargo.lock | 82 +++- Cargo.toml | 2 + crates/ide-core/src/event.rs | 22 + crates/ide-core/src/lib.rs | 1 + crates/ide-core/src/remote_settings.rs | 150 +++++++ crates/ide-core/src/settings.rs | 10 + crates/ide-db/Cargo.toml | 3 +- crates/ide-db/src/conn.rs | 2 +- crates/ide-db/src/lib.rs | 121 ++--- crates/ide-db/src/source.rs | 15 +- crates/ide-db/tests/sqlite.rs | 3 +- crates/ide-db/tests/ssh_tunnel.rs | 226 ---------- crates/ide-remote/Cargo.toml | 23 + crates/ide-remote/src/deploy.rs | 347 ++++++++++++++ crates/ide-remote/src/lib.rs | 12 + crates/ide-remote/src/mapping.rs | 197 ++++++++ crates/ide-remote/src/service.rs | 312 +++++++++++++ crates/ide-remote/src/target.rs | 295 ++++++++++++ crates/ide-remote/tests/remote.rs | 173 +++++++ crates/ide-run/src/config.rs | 5 + crates/ide-run/src/lib.rs | 5 + crates/ide-ssh/Cargo.toml | 27 ++ crates/ide-ssh/src/askpass.rs | 208 +++++++++ crates/ide-ssh/src/lib.rs | 21 + crates/ide-ssh/src/manager.rs | 181 ++++++++ crates/ide-ssh/src/secrets.rs | 87 ++++ .../src/tunnel.rs => ide-ssh/src/session.rs} | 424 +++++++++--------- crates/ide-ssh/src/sftp.rs | 236 ++++++++++ crates/ide-ssh/src/shell.rs | 68 +++ crates/ide-ssh/src/target.rs | 181 ++++++++ crates/ide-ssh/src/testing.rs | 248 ++++++++++ crates/ide-ssh/src/tunnel.rs | 80 ++++ .../tests/fixtures/ssh/client | 0 .../tests/fixtures/ssh/host | 0 .../tests/fixtures/ssh/locked | 0 crates/ide-ssh/tests/session.rs | 256 +++++++++++ crates/ide-vcs/src/cli.rs | 11 + crates/ide-vcs/src/lib.rs | 2 +- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/app.rs | 4 + src-tauri/src/commands/deploy.rs | 185 ++++++++ src-tauri/src/commands/mod.rs | 23 + src-tauri/src/commands/run.rs | 34 +- src-tauri/src/commands/ssh.rs | 92 ++++ src-tauri/src/main.rs | 4 + src-tauri/src/state.rs | 93 +++- src/bindings.ts | 241 +++++++++- src/features/database/SourceDialog.tsx | 4 +- src/platform/remoteSettings.ts | 210 +++++++++ src/platform/workflowSettings.ts | 5 +- src/styles/tokens.css | 2 + 51 files changed, 4398 insertions(+), 537 deletions(-) create mode 100644 crates/ide-core/src/remote_settings.rs delete mode 100644 crates/ide-db/tests/ssh_tunnel.rs create mode 100644 crates/ide-remote/Cargo.toml create mode 100644 crates/ide-remote/src/deploy.rs create mode 100644 crates/ide-remote/src/lib.rs create mode 100644 crates/ide-remote/src/mapping.rs create mode 100644 crates/ide-remote/src/service.rs create mode 100644 crates/ide-remote/src/target.rs create mode 100644 crates/ide-remote/tests/remote.rs create mode 100644 crates/ide-ssh/Cargo.toml create mode 100644 crates/ide-ssh/src/askpass.rs create mode 100644 crates/ide-ssh/src/lib.rs create mode 100644 crates/ide-ssh/src/manager.rs create mode 100644 crates/ide-ssh/src/secrets.rs rename crates/{ide-db/src/tunnel.rs => ide-ssh/src/session.rs} (50%) create mode 100644 crates/ide-ssh/src/sftp.rs create mode 100644 crates/ide-ssh/src/shell.rs create mode 100644 crates/ide-ssh/src/target.rs create mode 100644 crates/ide-ssh/src/testing.rs create mode 100644 crates/ide-ssh/src/tunnel.rs rename crates/{ide-db => ide-ssh}/tests/fixtures/ssh/client (100%) rename crates/{ide-db => ide-ssh}/tests/fixtures/ssh/host (100%) rename crates/{ide-db => ide-ssh}/tests/fixtures/ssh/locked (100%) create mode 100644 crates/ide-ssh/tests/session.rs create mode 100644 src-tauri/src/commands/deploy.rs create mode 100644 src-tauri/src/commands/ssh.rs create mode 100644 src/platform/remoteSettings.ts diff --git a/Cargo.lock b/Cargo.lock index c614838ff..8c80426d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3461,6 +3461,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -4043,9 +4055,8 @@ dependencies = [ "dirs 6.0.0", "futures", "ide-core", + "ide-ssh", "parking_lot", - "russh", - "russh-config", "serde", "serde_json", "specta", @@ -4245,6 +4256,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-remote" +version = "0.1.15" +dependencies = [ + "globset", + "ide-core", + "ide-run", + "ide-ssh", + "ignore", + "parking_lot", + "serde", + "specta", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "ide-run" version = "0.1.15" @@ -4317,6 +4345,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-ssh" +version = "0.1.15" +dependencies = [ + "dirs 6.0.0", + "ide-core", + "parking_lot", + "russh", + "russh-config", + "russh-sftp", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "ide-syntax" version = "0.1.15" @@ -5877,10 +5923,12 @@ dependencies = [ "ide-lsp", "ide-plugin", "ide-profiler", + "ide-remote", "ide-run", "ide-sdk", "ide-search", "ide-spell", + "ide-ssh", "ide-syntax", "ide-terminal", "ide-text", @@ -6972,6 +7020,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "russh-sftp" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "chrono", + "dashmap", + "gloo-timers", + "log", + "serde", + "serde_bytes", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "wasm-bindgen-futures", +] + [[package]] name = "russh-util" version = "0.52.0" @@ -7305,6 +7373,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" diff --git a/Cargo.toml b/Cargo.toml index 30f1ad27b..81a659f65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ ide-db = { path = "crates/ide-db" } ide-web = { path = "crates/ide-web" } ide-spell = { path = "crates/ide-spell" } ide-clipboard = { path = "crates/ide-clipboard" } +ide-ssh = { path = "crates/ide-ssh" } +ide-remote = { path = "crates/ide-remote" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index b9859ddca..9db7dfb0a 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -100,6 +100,28 @@ pub enum IdeEvent { ProjectRootsChanged { root: crate::IpcPath, }, + /// A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`. + Askpass { + id: u64, + prompt: String, + kind: AskpassKind, + }, + /// Prompt `id` was answered (in any window) or gave up. + AskpassDone { + id: u64, + }, +} + +/// What an askpass prompt asks for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AskpassKind { + /// Password or passphrase: hidden input. + Secret, + /// User name: visible input. + Text, + /// Yes / no (an unknown host key); answered with `yes` or `no`. + Confirm, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index ea2e3721d..e4fae4c61 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod inspection_settings; pub mod ipc_path; pub mod process; pub mod project_dir; +pub mod remote_settings; pub mod settings; pub mod text; pub mod workflow_settings; diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs new file mode 100644 index 000000000..771c30735 --- /dev/null +++ b/crates/ide-core/src/remote_settings.rs @@ -0,0 +1,150 @@ +//! Settings of remote work: SSH configurations, SFTP deployment and SSH run +//! targets. The fields hang off `Settings` in `settings.rs`. + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// How an SSH connection authenticates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SshAuth { + Password, + /// A private key file, optionally protected by a passphrase. + KeyPair, + /// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. + #[default] + OpenSsh, +} + +/// Tools › SSH Configurations. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct SshSettings { + pub configs: Vec, +} + +/// A named SSH host, shared by terminals, deployment, run targets and data sources. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct SshConfiguration { + pub id: String, + pub name: String, + /// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`]. + pub host: String, + pub port: Option, + pub user: String, + pub auth: SshAuth, + /// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory. + pub key_file: String, + /// Keep the password / key passphrase in the saved passwords. + pub save_secret: bool, +} + +impl SshConfiguration { + /// `user@host:port` with what is known (the name when the host is empty). + pub fn label(&self) -> String { + let host = self.host.trim(); + if host.is_empty() { + return self.name.clone(); + } + let mut s = String::new(); + if !self.user.trim().is_empty() { + s.push_str(self.user.trim()); + s.push('@'); + } + s.push_str(host); + if let Some(p) = self.port { + s.push_str(&format!(":{p}")); + } + s + } +} + +/// When local changes are uploaded to the default deployment server. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AutoUpload { + #[default] + Never, + /// Files saved with Save / Save All (⌘S). + OnExplicitSave, + /// Every save, including autosave and external changes. + Always, +} + +/// Build, Execution, Deployment › Deployment (and its Options page). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct DeploymentSettings { + pub servers: Vec, + /// Server used by Upload / Download / Sync and automatic upload; empty = none. + pub default_server: String, + pub auto_upload: AutoUpload, + /// Delete remote files when their local file is deleted (automatic upload). + pub delete_remote: bool, + /// Keep local modification times on uploaded files. + pub preserve_timestamps: bool, + /// File names never uploaded or downloaded (`*` and `?` wildcards; `;`-separated in the UI). + pub exclude: Vec, +} + +impl Default for DeploymentSettings { + fn default() -> Self { + Self { + servers: Vec::new(), + default_server: String::new(), + auto_upload: AutoUpload::Never, + delete_remote: false, + preserve_timestamps: true, + exclude: [".git", ".svn", ".hg", ".idea", ".openidea", ".DS_Store", "*.pyc", "__pycache__"] + .map(String::from) + .to_vec(), + } + } +} + +/// An SFTP server and how project folders map onto it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct DeploymentServer { + pub id: String, + pub name: String, + /// [`SshConfiguration::id`]. + pub ssh_config: String, + /// Absolute remote folder the mappings are relative to; empty = the login directory. + pub root_path: String, + /// Web server URL of the root (Open in Browser on the deployed file). + pub web_url: String, + pub mappings: Vec, + /// Remote paths (relative to the root) never touched. + pub excluded_paths: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct DeploymentMapping { + /// Project-relative folder; empty = the project root. + pub local: String, + /// Remote folder, relative to the server root (a leading `/` is the root too). + pub deployment: String, + /// Web path, relative to the server's URL. + pub web: String, +} + +/// Build, Execution, Deployment › Run Targets: run configurations on another machine. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct RunTarget { + pub id: String, + pub name: String, + /// [`SshConfiguration::id`]. + pub ssh_config: String, + /// Remote folder the project is synchronised to and run from. + pub project_root: String, + /// Upload changed project files before each run. + pub sync_before_run: bool, + /// Interpreters on the target; empty = the one on the remote `PATH`. + pub python: String, + pub node: String, + pub java: String, +} diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 8732351d6..435763832 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; pub use crate::editor_settings::*; pub use crate::inspection_settings::*; +pub use crate::remote_settings::*; pub use crate::workflow_settings::*; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] @@ -45,6 +46,12 @@ pub struct Settings { pub inspections: InspectionSettings, /// Editor › Natural Languages › Spelling. pub spelling: SpellingSettings, + /// Tools › SSH Configurations. + pub ssh: SshSettings, + /// Build, Execution, Deployment › Deployment. + pub deployment: DeploymentSettings, + /// Build, Execution, Deployment › Run Targets. + pub run_targets: Vec, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] @@ -84,6 +91,9 @@ impl Default for Settings { tools: BTreeMap::new(), inspections: InspectionSettings::default(), spelling: SpellingSettings::default(), + ssh: SshSettings::default(), + deployment: DeploymentSettings::default(), + run_targets: Vec::new(), } } } diff --git a/crates/ide-db/Cargo.toml b/crates/ide-db/Cargo.toml index 4e062df7d..4d384b809 100644 --- a/crates/ide-db/Cargo.toml +++ b/crates/ide-db/Cargo.toml @@ -17,8 +17,7 @@ parking_lot.workspace = true futures.workspace = true sqlx.workspace = true dirs.workspace = true -russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] } -russh-config = "0.58.0" +ide-ssh.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/ide-db/src/conn.rs b/crates/ide-db/src/conn.rs index 83f32315d..85eb6806b 100644 --- a/crates/ide-db/src/conn.rs +++ b/crates/ide-db/src/conn.rs @@ -192,7 +192,7 @@ fn looks_like_query(sql: &str) -> bool { fn file_setting(path: &str) -> Option { let path = path.trim(); - (!path.is_empty()).then(|| crate::tunnel::expand(path)) + (!path.is_empty()).then(|| ide_ssh::expand(path)) } fn is_identifier(name: &str) -> bool { diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index 1a7f120aa..8304329c8 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -9,7 +9,6 @@ pub mod secrets; pub mod source; pub mod split; pub mod sql; -pub mod tunnel; pub use conn::{Held, Pool, QueryResult, ResultColumn}; pub use history::HistoryEntry; @@ -29,7 +28,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey}; +use ide_ssh::{SshManager, SshTarget, Tunnel}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -210,10 +209,8 @@ pub struct DbManager { tunnels: Mutex>>, /// Serialises opening tunnels so concurrent connects share one. tunnel_open: tokio::sync::Mutex<()>, - /// Host keys awaiting the user's trust, by data source id. - unknown_host_keys: Mutex>, - known_hosts: PathBuf, - ssh_config: Option, + /// App-wide SSH connections and host-key trust. + ssh: Arc, saved_passwords: SecretStore, history: Arc, /// Passwords entered this session (not saved, or not yet), by credential key. @@ -281,13 +278,10 @@ pub fn split_target(target: &str) -> (&str, Option<&str>) { } /// Says which leg failed when the database is unreachable from the SSH host. -fn through_tunnel(e: IdeError, source: &DataSource, tunneled: bool) -> IdeError { - let Some(ssh) = source.ssh.as_ref().filter(|_| tunneled && e.kind == ErrorKind::Io) else { return e }; +fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeError { + let Some(via) = via.filter(|_| e.kind == ErrorKind::Io) else { return e }; let port = source.port.or(source.driver.default_port()).unwrap_or_default(); - IdeError::new( - ErrorKind::Io, - format!("{} through the SSH tunnel ({}:{port} as seen from {})", e.message, source.host, ssh.host), - ) + IdeError::new(ErrorKind::Io, format!("{} through the SSH tunnel ({}:{port} as seen from {via})", e.message, source.host)) } /// Options › Run keep-alive query: pings the pool until it is closed or dropped @@ -321,19 +315,7 @@ async fn end_session(session: &Session) { impl DbManager { /// `passwords` / `history` are the app-wide files of saved passwords /// ([`SecretStore`]) and query history ([`HistoryStore`]). - pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf) -> Self { - let ssh_dir = dirs::home_dir().unwrap_or_default().join(".ssh"); - Self::with_ssh_files(root, passwords, history, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config"))) - } - - /// [`DbManager::new`] with other `known_hosts` / `~/.ssh/config` files (tests). - pub fn with_ssh_files( - root: PathBuf, - passwords: PathBuf, - history: PathBuf, - known_hosts: PathBuf, - ssh_config: Option, - ) -> Self { + pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc) -> Self { Self { saved_passwords: SecretStore::new(passwords, &root), history: Arc::new(HistoryStore::new(history, &root)), @@ -341,9 +323,7 @@ impl DbManager { pools: Mutex::default(), tunnels: Mutex::default(), tunnel_open: tokio::sync::Mutex::new(()), - unknown_host_keys: Mutex::default(), - known_hosts, - ssh_config, + ssh, session_passwords: Mutex::default(), sessions: Mutex::default(), } @@ -397,7 +377,14 @@ impl DbManager { } /// [`DbManager::set_password`] for the SSH password / key passphrase. + /// A data source using an SSH configuration sets that configuration's secret. pub async fn set_ssh_secret(&self, id: &str, secret: Option, save: bool) -> IdeResult<()> { + let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config); + if let Some(c) = config.filter(|c| !c.trim().is_empty()) { + let save = self.ssh.config(&c).map(|c| c.save_secret).unwrap_or(save); + self.ssh.set_secret(&c, secret, save); + return Ok(()); + } self.remember(secrets::ssh_key(id), secret, save).await } @@ -443,10 +430,27 @@ impl DbManager { /// Trusts the host key that failed the last connection of `id` (adds it to `known_hosts`). pub async fn trust_ssh_host(&self, id: &str) -> IdeResult<()> { - let key = - self.unknown_host_keys.lock().remove(id).ok_or_else(|| IdeError::not_found("no host key to trust"))?; - let path = self.known_hosts.clone(); - tokio::task::spawn_blocking(move || key.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))? + if self.ssh.trust_host(&self.host_key(id)).await.is_ok() { + return Ok(()); + } + let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config); + match config.filter(|c| !c.trim().is_empty()) { + Some(c) => self.ssh.trust_host(&c).await, + None => Err(IdeError::not_found("no host key to trust")), + } + } + + /// Key of the unknown host keys of data source `id`'s own SSH settings. + fn host_key(&self, id: &str) -> String { + format!("db:{}:{id}", self.root.display()) + } + + /// How the SSH host of `ssh` is shown in errors. + fn ssh_label(&self, ssh: &DbSsh) -> String { + match self.ssh.config(ssh.config.trim()) { + Ok(c) if !ssh.config.trim().is_empty() => c.label(), + _ => ssh.host.clone(), + } } /// Opens the SSH tunnel of `source` when it has one, returning the settings to @@ -470,35 +474,34 @@ impl DbManager { return Ok((local(&t), Some(t))); } } - let secret = match ssh_secret { - Some(s) => Some(s), - None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await, + let config = ssh.config.trim(); + let session = if config.is_empty() { + let secret = match ssh_secret { + Some(s) => Some(s), + None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await, + }; + let target = SshTarget { + host: ssh.host.clone(), + port: ssh.port, + user: ssh.user.clone(), + auth: ssh.auth, + key_file: ssh.key_file.clone(), + }; + Arc::new(self.ssh.connect(&self.host_key(&source.id), &target, secret.as_deref()).await?) + } else { + if let Some(s) = ssh_secret { + let save = self.ssh.config(config)?.save_secret; + self.ssh.set_secret(config, Some(s), save); + } + self.ssh.session(config).await? }; let target_port = source.port.or(source.driver.default_port()).unwrap_or_default(); let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }; - let spec = TunnelSpec { - ssh, - secret: secret.as_deref(), - target_host, - target_port, - known_hosts: &self.known_hosts, - ssh_config: self.ssh_config.as_deref(), - }; - match Tunnel::open(spec).await { - Ok(t) => { - let t = Arc::new(t); - if shared { - self.tunnels.lock().insert(source.id.clone(), t.clone()); - } - Ok((local(&t), Some(t))) - } - Err(OpenError::Other(e)) => Err(e), - Err(OpenError::UnknownHostKey(k)) => { - let message = format!("Unknown SSH host key for {}:{} ({})", k.host, k.port, k.fingerprint()); - self.unknown_host_keys.lock().insert(source.id.clone(), *k); - Err(IdeError::new(ErrorKind::Protocol, message)) - } + let t = Arc::new(Tunnel::open(session, target_host, target_port).await?); + if shared { + self.tunnels.lock().insert(source.id.clone(), t.clone()); } + Ok((local(&t), Some(t))) } /// Connects with `source` as given (not necessarily saved): Test Connection. @@ -516,7 +519,7 @@ impl DbManager { let (target, tunnel) = self.endpoint(source, ssh_secret, false).await?; let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)) .await - .map_err(|e| through_tunnel(e, source, tunnel.is_some()))?; + .map_err(|e| through_tunnel(e, source, tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref()))?; let version = match source.driver { DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()", DbDriver::Postgres => "SELECT version()", @@ -553,7 +556,7 @@ impl DbManager { Ok(p) => Arc::new(p), Err(e) => { self.drop_unused_tunnel(source_id); - return Err(through_tunnel(e, &source, tunnel.is_some())); + return Err(through_tunnel(e, &source, tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref())); } }; // Another caller may have connected meanwhile; keep theirs. diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index a4e56300c..c580c596c 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -122,23 +122,16 @@ pub struct DbProperty { pub value: String, } -/// How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type"). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub enum DbSshAuth { - Password, - /// A private key file, optionally protected by a passphrase. - KeyPair, - /// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. - #[default] - OpenSsh, -} +/// How the SSH tunnel authenticates (shared with Settings › SSH Configurations). +pub use ide_core::remote_settings::SshAuth as DbSshAuth; /// Connect through an SSH tunnel: the database host and port are reached from `host`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase", default)] pub struct DbSsh { pub enabled: bool, + /// A Settings › SSH Configurations id to connect with; empty = the fields below. + pub config: String, /// Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`]. pub host: String, pub port: Option, diff --git a/crates/ide-db/tests/sqlite.rs b/crates/ide-db/tests/sqlite.rs index 315ecff54..f340ee536 100644 --- a/crates/ide-db/tests/sqlite.rs +++ b/crates/ide-db/tests/sqlite.rs @@ -7,7 +7,8 @@ async fn setup() -> (tempfile::TempDir, DbManager) { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); std::fs::File::create(root.join("app.db")).unwrap(); - let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json")); + let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json"))); + let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh); db.save_sources(vec![DataSource { id: "s1".into(), name: "app".into(), diff --git a/crates/ide-db/tests/ssh_tunnel.rs b/crates/ide-db/tests/ssh_tunnel.rs deleted file mode 100644 index ccfa5189b..000000000 --- a/crates/ide-db/tests/ssh_tunnel.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! SSH tunnels against an in-process russh server that forwards `direct-tcpip` -//! channels to a local echo server. - -use ide_core::error::ErrorKind; -use ide_db::tunnel::{OpenError, Tunnel, TunnelSpec}; -use ide_db::{DbSsh, DbSshAuth}; -use russh::Channel; -use russh::keys::{PrivateKey, PublicKey, decode_secret_key}; -use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; - -fn fixture(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name) -} - -fn key(name: &str, passphrase: Option<&str>) -> PrivateKey { - decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap() -} - -#[derive(Clone)] -struct Sshd { - client_key: PublicKey, -} - -impl server::Server for Sshd { - type Handler = Sshd; - fn new_client(&mut self, _: Option) -> Sshd { - self.clone() - } -} - -impl server::Handler for Sshd { - type Error = russh::Error; - - async fn auth_password(&mut self, user: &str, password: &str) -> Result { - Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) - } - - async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result { - Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() }) - } - - async fn channel_open_direct_tcpip( - &mut self, - channel: Channel, - host: &str, - port: u32, - _originator_address: &str, - _originator_port: u32, - reply: ChannelOpenHandle, - _session: &mut Session, - ) -> Result<(), Self::Error> { - let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else { - return Ok(()); - }; - reply.accept().await; - tokio::spawn(async move { - let mut stream = channel.into_stream(); - let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await; - }); - Ok(()) - } -} - -/// Echo server standing in for the database. -async fn echo() -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tokio::spawn(async move { - while let Ok((mut s, _)) = listener.accept().await { - tokio::spawn(async move { - let (mut r, mut w) = s.split(); - let _ = tokio::io::copy(&mut r, &mut w).await; - }); - } - }); - port -} - -async fn sshd() -> u16 { - let config = Arc::new(server::Config { - keys: vec![key("host", None)], - auth_rejection_time: std::time::Duration::from_millis(10), - auth_rejection_time_initial: Some(std::time::Duration::ZERO), - ..Default::default() - }); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let mut server = Sshd { client_key: key("client", None).public_key().clone() }; - tokio::spawn(async move { - let _ = server.run_on_socket(config, &listener).await; - }); - port -} - -struct Env { - _tmp: tempfile::TempDir, - known_hosts: PathBuf, - ssh_port: u16, - db_port: u16, -} - -async fn env() -> Env { - let tmp = tempfile::tempdir().unwrap(); - let known_hosts = tmp.path().join("known_hosts"); - Env { known_hosts, _tmp: tmp, ssh_port: sshd().await, db_port: echo().await } -} - -impl Env { - fn ssh(&self, auth: DbSshAuth) -> DbSsh { - DbSsh { - enabled: true, - host: "127.0.0.1".into(), - port: Some(self.ssh_port), - user: "dev".into(), - auth, - ..Default::default() - } - } - - async fn open(&self, ssh: &DbSsh, secret: Option<&str>) -> Result { - Tunnel::open(TunnelSpec { - ssh, - secret, - target_host: "127.0.0.1", - target_port: self.db_port, - known_hosts: &self.known_hosts, - ssh_config: None, - }) - .await - } - - /// Trusts the server's key on the first connection, as the UI does. - async fn open_trusted(&self, ssh: &DbSsh, secret: Option<&str>) -> Result { - match self.open(ssh, secret).await { - Err(OpenError::UnknownHostKey(k)) => { - k.trust(&self.known_hosts).unwrap(); - self.open(ssh, secret).await - } - other => other, - } - } -} - -async fn round_trip(t: &Tunnel) { - let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap(); - s.write_all(b"SELECT 1").await.unwrap(); - let mut buf = [0u8; 8]; - s.read_exact(&mut buf).await.unwrap(); - assert_eq!(&buf, b"SELECT 1"); -} - -fn denied(r: Result) -> String { - match r { - Err(OpenError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message, - Err(OpenError::Other(e)) => panic!("expected permission denied, got {e:?}"), - Err(OpenError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()), - Ok(_) => panic!("expected permission denied, connected"), - } -} - -#[tokio::test] -async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() { - let env = env().await; - let ssh = env.ssh(DbSshAuth::Password); - let Err(OpenError::UnknownHostKey(k)) = env.open(&ssh, Some("secret")).await else { - panic!("expected an unknown host key"); - }; - assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint()); - k.trust(&env.known_hosts).unwrap(); - round_trip(&env.open(&ssh, Some("secret")).await.ok().unwrap()).await; - - // Another key recorded for the same host: refused, never offered for trust. - let other = key("client", None).public_key().to_openssh().unwrap(); - std::fs::write(&env.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap(); - let msg = denied(env.open(&ssh, Some("secret")).await); - assert!(msg.contains("has changed"), "{msg}"); -} - -#[tokio::test] -async fn password_authentication() { - let env = env().await; - let ssh = env.ssh(DbSshAuth::Password); - let tunnel = env.open_trusted(&ssh, Some("secret")).await.ok().unwrap(); - round_trip(&tunnel).await; - round_trip(&tunnel).await; - assert!(!tunnel.is_closed()); - - assert!(denied(env.open(&ssh, None).await).starts_with("SSH password required")); - assert!(denied(env.open(&ssh, Some("wrong")).await).starts_with("SSH authentication failed")); -} - -#[tokio::test] -async fn key_pair_authentication_with_and_without_passphrase() { - let env = env().await; - let ssh = DbSsh { key_file: fixture("client").to_string_lossy().into(), ..env.ssh(DbSshAuth::KeyPair) }; - round_trip(&env.open_trusted(&ssh, None).await.ok().unwrap()).await; - - // An encrypted key the server does not accept: passphrase first, then the key is refused. - let locked = DbSsh { key_file: fixture("locked").to_string_lossy().into(), ..ssh.clone() }; - assert!(denied(env.open(&locked, None).await).starts_with("SSH key passphrase required")); - assert!(denied(env.open(&locked, Some("nope")).await).contains("wrong passphrase")); - assert!(denied(env.open(&locked, Some("pw")).await).starts_with("SSH authentication failed")); -} - -#[tokio::test] -async fn unreachable_database_fails_fast_with_the_reason() { - let env = env().await; - let ssh = env.ssh(DbSshAuth::Password); - drop(env.open_trusted(&ssh, Some("secret")).await.ok().unwrap()); - let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port(); - let r = Tunnel::open(TunnelSpec { - ssh: &ssh, - secret: Some("secret"), - target_host: "127.0.0.1", - target_port: closed, - known_hosts: &env.known_hosts, - ssh_config: None, - }) - .await; - let Err(OpenError::Other(e)) = r else { panic!("expected an error") }; - assert!(e.message.contains(&format!("cannot reach the database at 127.0.0.1:{closed}")), "{}", e.message); -} diff --git a/crates/ide-remote/Cargo.toml b/crates/ide-remote/Cargo.toml new file mode 100644 index 000000000..8442d6fe7 --- /dev/null +++ b/crates/ide-remote/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ide-remote" +description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-ssh.workspace = true +ide-run.workspace = true +serde.workspace = true +specta.workspace = true +tokio.workspace = true +tracing.workspace = true +parking_lot.workspace = true +globset.workspace = true +ignore.workspace = true + +[dev-dependencies] +ide-ssh = { workspace = true, features = ["testing"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tempfile.workspace = true diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs new file mode 100644 index 000000000..41b2732d7 --- /dev/null +++ b/crates/ide-remote/src/deploy.rs @@ -0,0 +1,347 @@ +//! Upload, download, delete and synchronise project files with a deployment +//! server over SFTP. + +use crate::mapping::{Excludes, Mappings, posix}; +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_ssh::RemoteFs; +use ide_ssh::sftp::mtime_of; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Modification times closer than this count as equal (FAT, rounding by servers). +const MTIME_SLACK: u32 = 2; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TransferReport { + pub files: u32, + /// Files left alone: excluded, or outside every mapping. + pub skipped: u32, + pub errors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TransferError { + pub path: String, + pub message: String, +} + +impl TransferReport { + fn fail(&mut self, path: impl Into, e: IdeError) { + self.errors.push(TransferError { path: path.into(), message: e.message }); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileStamp { + pub size: u64, + pub mtime: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SyncStatus { + LocalOnly, + RemoteOnly, + LocalNewer, + RemoteNewer, + /// Same time, different size. + Different, + Same, +} + +/// One file of Sync with Deployed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncEntry { + /// Project-relative, `/`-separated. + pub path: String, + pub remote_path: String, + pub local: Option, + pub remote: Option, + pub status: SyncStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum SyncAction { + Upload, + Download, + DeleteRemote, + DeleteLocal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SyncStep { + /// Project-relative, `/`-separated. + pub path: String, + pub action: SyncAction, +} + +pub fn status(local: Option, remote: Option) -> SyncStatus { + match (local, remote) { + (Some(_), None) => SyncStatus::LocalOnly, + (None, Some(_)) | (None, None) => SyncStatus::RemoteOnly, + (Some(l), Some(r)) => match (l.mtime, r.mtime) { + (Some(lt), Some(rt)) if lt > rt + MTIME_SLACK => SyncStatus::LocalNewer, + (Some(lt), Some(rt)) if rt > lt + MTIME_SLACK => SyncStatus::RemoteNewer, + _ if l.size != r.size => SyncStatus::Different, + _ => SyncStatus::Same, + }, + } +} + +/// Operations on one server through one SFTP session. +pub struct Deployment<'a> { + pub fs: &'a RemoteFs, + pub map: &'a Mappings, + pub exclude: &'a Excludes, + pub preserve_time: bool, + pub cancel: CancellationToken, +} + +impl Deployment<'_> { + fn rel(&self, local: &Path) -> String { + posix(local.strip_prefix(self.map.project_root()).unwrap_or(local)) + } + + fn excluded_local(&self, local: &Path) -> bool { + self.exclude.local(local.strip_prefix(self.map.project_root()).unwrap_or(local)) + } + + /// Local files under `paths` (folders recursively), excluded names left out. + fn local_files(&self, paths: &[PathBuf], report: &mut TransferReport) -> Vec { + let mut out = Vec::new(); + for p in paths { + if self.excluded_local(p) { + report.skipped += 1; + continue; + } + if p.is_dir() { + let walk = ignore::WalkBuilder::new(p).standard_filters(false).follow_links(false).build(); + for entry in walk.flatten() { + let path = entry.path(); + if entry.file_type().is_some_and(|t| t.is_file()) { + if self.excluded_local(path) { + report.skipped += 1; + } else { + out.push(path.to_path_buf()); + } + } + } + } else if p.is_file() { + out.push(p.clone()); + } + } + out + } + + /// Remote files under `dir` (recursively), excluded ones left out. + async fn remote_files(&self, dir: &str, out: &mut Vec) -> IdeResult<()> { + for e in self.fs.list(dir).await? { + if self.cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if self.exclude.remote(&e.path) { + continue; + } + if e.is_dir && !e.is_symlink { + Box::pin(self.remote_files(&e.path, out)).await?; + } else if !e.is_dir { + out.push(e); + } + } + Ok(()) + } + + pub async fn upload(&self, paths: &[PathBuf], progress: &mut (dyn FnMut(&str, usize, usize) + Send)) -> TransferReport { + let mut report = TransferReport::default(); + let files = self.local_files(paths, &mut report); + for (i, local) in files.iter().enumerate() { + if self.cancel.is_cancelled() { + break; + } + let rel = self.rel(local); + let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else { + report.skipped += 1; + continue; + }; + progress(&rel, i, files.len()); + match self.fs.upload(local, &remote, self.preserve_time).await { + Ok(()) => report.files += 1, + Err(e) => report.fail(rel, e), + } + } + report + } + + /// Downloads the remote counterparts of `paths` (folders recursively). + pub async fn download(&self, paths: &[PathBuf], progress: &mut (dyn FnMut(&str, usize, usize) + Send)) -> TransferReport { + let mut report = TransferReport::default(); + let mut jobs: Vec<(String, PathBuf)> = Vec::new(); + for p in paths { + let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else { + report.skipped += 1; + continue; + }; + match self.fs.stat(&remote).await { + Ok(Some(e)) if e.is_dir => { + let mut files = Vec::new(); + if let Err(e) = self.remote_files(&remote, &mut files).await { + report.fail(self.rel(p), e); + } + for f in files { + if let Some(local) = self.map.local(&f.path) { + jobs.push((f.path, local)); + } + } + } + Ok(Some(_)) => jobs.push((remote, p.clone())), + Ok(None) => report.fail(self.rel(p), IdeError::not_found(format!("{remote} does not exist on the server"))), + Err(e) => report.fail(self.rel(p), e), + } + } + for (i, (remote, local)) in jobs.iter().enumerate() { + if self.cancel.is_cancelled() { + break; + } + if self.excluded_local(local) { + report.skipped += 1; + continue; + } + let rel = self.rel(local); + progress(&rel, i, jobs.len()); + match self.fs.download(remote, local).await { + Ok(()) => report.files += 1, + Err(e) => report.fail(rel, e), + } + } + report + } + + /// Deletes the remote counterparts of `paths`. + pub async fn delete_remote(&self, paths: &[PathBuf]) -> TransferReport { + let mut report = TransferReport::default(); + for p in paths { + let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else { + report.skipped += 1; + continue; + }; + match self.fs.stat(&remote).await { + Ok(None) => {} + Ok(Some(_)) => match self.fs.remove(&remote).await { + Ok(()) => report.files += 1, + Err(e) => report.fail(self.rel(p), e), + }, + Err(e) => report.fail(self.rel(p), e), + } + } + report + } + + /// Sync with Deployed: local and remote files under `local` side by side. + pub async fn compare(&self, local: &Path) -> IdeResult> { + let remote_root = + self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?; + let mut by_rel: BTreeMap = BTreeMap::new(); + let mut ignored = TransferReport::default(); + for f in self.local_files(&[local.to_path_buf()], &mut ignored) { + let Some(remote_path) = self.map.remote(&f) else { continue }; + let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0); + let stamp = FileStamp { size, mtime: mtime_of(&f) }; + by_rel.insert( + self.rel(&f), + SyncEntry { path: self.rel(&f), remote_path, local: Some(stamp), remote: None, status: SyncStatus::LocalOnly }, + ); + } + let mut remote = Vec::new(); + match self.fs.stat(&remote_root).await? { + Some(e) if e.is_dir => self.remote_files(&remote_root, &mut remote).await?, + Some(e) => remote.push(e), + None => {} + } + for e in remote { + let Some(l) = self.map.local(&e.path) else { continue }; + if self.excluded_local(&l) { + continue; + } + let rel = self.rel(&l); + let stamp = FileStamp { size: e.size, mtime: e.mtime }; + by_rel + .entry(rel.clone()) + .and_modify(|x| x.remote = Some(stamp)) + .or_insert(SyncEntry { path: rel, remote_path: e.path, local: None, remote: Some(stamp), status: SyncStatus::RemoteOnly }); + } + Ok(by_rel + .into_values() + .map(|mut e| { + e.status = status(e.local, e.remote); + e + }) + .collect()) + } + + /// Applies the chosen Sync with Deployed actions. + pub async fn apply(&self, steps: &[SyncStep], progress: &mut (dyn FnMut(&str, usize, usize) + Send)) -> TransferReport { + let mut report = TransferReport::default(); + let root = self.map.project_root().to_path_buf(); + for (i, step) in steps.iter().enumerate() { + if self.cancel.is_cancelled() { + break; + } + let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s)); + let Some(remote) = self.map.remote(&local) else { + report.skipped += 1; + continue; + }; + progress(&step.path, i, steps.len()); + let r = match step.action { + SyncAction::Upload => self.fs.upload(&local, &remote, self.preserve_time).await, + SyncAction::Download => self.fs.download(&remote, &local).await, + SyncAction::DeleteRemote => self.fs.remove(&remote).await, + SyncAction::DeleteLocal => std::fs::remove_file(&local).map_err(IdeError::from), + }; + match r { + Ok(()) => report.files += 1, + Err(e) => report.fail(step.path.clone(), e), + } + } + report + } + + /// Uploads what changed locally under `local` (new, newer or resized files): sync before a run. + pub async fn push_changes(&self, local: &Path, progress: &mut (dyn FnMut(&str, usize, usize) + Send)) -> IdeResult { + let steps: Vec = self + .compare(local) + .await? + .into_iter() + .filter(|e| matches!(e.status, SyncStatus::LocalOnly | SyncStatus::LocalNewer | SyncStatus::Different)) + .map(|e| SyncStep { path: e.path, action: SyncAction::Upload }) + .collect(); + Ok(self.apply(&steps, progress).await) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stamp(size: u64, mtime: u32) -> Option { + Some(FileStamp { size, mtime: Some(mtime) }) + } + + #[test] + fn compares_by_time_then_size() { + assert_eq!(status(stamp(1, 100), None), SyncStatus::LocalOnly); + assert_eq!(status(None, stamp(1, 100)), SyncStatus::RemoteOnly); + assert_eq!(status(stamp(1, 200), stamp(1, 100)), SyncStatus::LocalNewer); + assert_eq!(status(stamp(1, 100), stamp(1, 200)), SyncStatus::RemoteNewer); + assert_eq!(status(stamp(1, 101), stamp(1, 100)), SyncStatus::Same); + assert_eq!(status(stamp(2, 100), stamp(1, 100)), SyncStatus::Different); + } +} diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs new file mode 100644 index 000000000..09af9f62e --- /dev/null +++ b/crates/ide-remote/src/lib.rs @@ -0,0 +1,12 @@ +//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run +//! configurations on run targets ([`target`]). + +pub mod deploy; +pub mod mapping; +pub mod service; +pub mod target; + +pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport}; +pub use mapping::{Excludes, Mappings}; +pub use service::{DeployedPath, RemoteService}; +pub use target::{RemoteRuns, RemoteStart}; diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs new file mode 100644 index 000000000..3dfe426a7 --- /dev/null +++ b/crates/ide-remote/src/mapping.rs @@ -0,0 +1,197 @@ +//! Local ↔ remote paths of a deployment server's mappings, and excluded names. + +use globset::{Glob, GlobSet, GlobSetBuilder}; +use ide_core::remote_settings::{DeploymentServer, DeploymentSettings}; +use ide_ssh::sftp::join; +use std::path::{Component, Path, PathBuf}; + +/// A server's mappings resolved against the project and the server root. +#[derive(Debug, Clone)] +pub struct Mappings { + root: PathBuf, + /// (absolute local folder, absolute remote folder, web path), longest local first. + entries: Vec<(PathBuf, String, String)>, + web_url: String, +} + +/// `a/b` (POSIX) of a relative path. +pub fn posix(rel: &Path) -> String { + rel.components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/") +} + +impl Mappings { + /// `remote_root` is the server root resolved on the host (its login + /// directory when the setting is empty). A server without mappings maps the + /// project root onto the server root. + pub fn new(server: &DeploymentServer, project_root: &Path, remote_root: &str) -> Self { + let mut entries: Vec<(PathBuf, String, String)> = server + .mappings + .iter() + .map(|m| { + let local = m.local.trim().trim_matches('/'); + let local = if local.is_empty() { project_root.to_path_buf() } else { project_root.join(local) }; + // Relative to the server root, with or without a leading `/`. + let deployment = m.deployment.trim().trim_start_matches("./").trim_matches('/'); + let remote = + if deployment.is_empty() { remote_root.to_owned() } else { join(remote_root, deployment) }; + (local, remote.trim_end_matches('/').to_owned(), m.web.trim().trim_matches('/').to_owned()) + }) + .collect(); + if entries.is_empty() { + entries.push((project_root.to_path_buf(), remote_root.trim_end_matches('/').to_owned(), String::new())); + } + entries.sort_by_key(|(l, _, _)| std::cmp::Reverse(l.components().count())); + Self { root: project_root.to_path_buf(), entries, web_url: server.web_url.trim().trim_end_matches('/').to_owned() } + } + + pub fn project_root(&self) -> &Path { + &self.root + } + + /// The remote path of a local file or folder (`None`: not under any mapping). + pub fn remote(&self, local: &Path) -> Option { + self.entries.iter().find_map(|(l, r, _)| { + let rel = local.strip_prefix(l).ok()?; + let rel = posix(rel); + Some(if rel.is_empty() { r.clone() } else { join(r, &rel) }) + }) + } + + /// The local path of a remote file or folder (`None`: not under any mapping). + pub fn local(&self, remote: &str) -> Option { + let remote = remote.trim_end_matches('/'); + let mut best: Option<(&PathBuf, &str, usize)> = None; + for (l, r, _) in &self.entries { + let rest = if remote == r { + "" + } else if let Some(rest) = remote.strip_prefix(r.as_str()).and_then(|x| x.strip_prefix('/')) { + rest + } else if r.is_empty() || r == "/" { + remote.trim_start_matches('/') + } else { + continue; + }; + if best.is_none_or(|(_, _, len)| r.len() > len) { + best = Some((l, rest, r.len())); + } + } + let (l, rest, _) = best?; + Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s))) + } + + /// The web URL of a local file (`None` without a server URL or mapping). + pub fn web_url(&self, local: &Path) -> Option { + if self.web_url.is_empty() { + return None; + } + self.entries.iter().find_map(|(l, _, web)| { + let rel = posix(local.strip_prefix(l).ok()?); + let path = [web.as_str(), rel.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join("/"); + Some(if path.is_empty() { format!("{}/", self.web_url) } else { format!("{}/{path}", self.web_url) }) + }) + } +} + +/// Settings › Deployment › Options "Exclude items by name". +#[derive(Debug, Clone)] +pub struct Excludes { + names: GlobSet, + /// Remote paths (absolute) never touched. + remote: Vec, +} + +impl Excludes { + pub fn new(settings: &DeploymentSettings, server: &DeploymentServer, remote_root: &str) -> Self { + let mut b = GlobSetBuilder::new(); + for pattern in settings.exclude.iter().flat_map(|p| p.split(';')).map(str::trim).filter(|p| !p.is_empty()) { + match Glob::new(pattern) { + Ok(g) => { + b.add(g); + } + Err(e) => tracing::warn!("deployment exclude {pattern:?}: {e}"), + } + } + let remote = server + .excluded_paths + .iter() + .map(|p| p.trim()) + .filter(|p| !p.is_empty()) + .map(|p| join(remote_root, p).trim_end_matches('/').to_owned()) + .collect(); + Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), remote } + } + + pub fn name(&self, name: &str) -> bool { + self.names.is_match(name) + } + + /// Any component of `rel` (relative to the project) is excluded by name. + pub fn local(&self, rel: &Path) -> bool { + rel.components().any(|c| matches!(c, Component::Normal(s) if self.name(&s.to_string_lossy()))) + } + + pub fn remote(&self, path: &str) -> bool { + let path = path.trim_end_matches('/'); + path.rsplit('/').next().is_some_and(|n| self.name(n)) + || self.remote.iter().any(|r| path == r || path.strip_prefix(r.as_str()).is_some_and(|x| x.starts_with('/'))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ide_core::remote_settings::DeploymentMapping; + + fn server(mappings: Vec<(&str, &str, &str)>) -> DeploymentServer { + DeploymentServer { + web_url: "https://example.com/app/".into(), + mappings: mappings + .into_iter() + .map(|(l, d, w)| DeploymentMapping { local: l.into(), deployment: d.into(), web: w.into() }) + .collect(), + excluded_paths: vec!["logs".into()], + ..Default::default() + } + } + + #[test] + fn maps_paths_both_ways_by_the_longest_mapping() { + let root = Path::new("/p"); + let m = Mappings::new(&server(vec![("", "www", ""), ("static", "/cdn/assets/", "s")]), root, "/home/me"); + assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/home/me/www/src/a.py")); + assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/home/me/cdn/assets/x.css")); + assert_eq!(m.remote(Path::new("/p")).as_deref(), Some("/home/me/www")); + assert_eq!(m.remote(Path::new("/elsewhere/a")), None); + assert_eq!(m.local("/home/me/www/src/a.py"), Some(PathBuf::from("/p/src/a.py"))); + assert_eq!(m.local("/home/me/cdn/assets/x.css"), Some(PathBuf::from("/p/static/x.css"))); + assert_eq!(m.local("/var/other"), None); + assert_eq!(m.web_url(Path::new("/p/static/x.css")).as_deref(), Some("https://example.com/app/s/x.css")); + assert_eq!(m.web_url(Path::new("/p/index.html")).as_deref(), Some("https://example.com/app/index.html")); + } + + #[test] + fn without_mappings_the_project_root_maps_onto_the_server_root() { + let m = Mappings::new(&server(vec![]), Path::new("/p"), "/srv/app"); + assert_eq!(m.remote(Path::new("/p/a/b.txt")).as_deref(), Some("/srv/app/a/b.txt")); + assert_eq!(m.local("/srv/app"), Some(PathBuf::from("/p"))); + } + + #[test] + fn excludes_names_and_remote_paths() { + let settings = DeploymentSettings { exclude: vec![".git;*.pyc".into(), "node_modules".into()], ..Default::default() }; + let x = Excludes::new(&settings, &server(vec![]), "/srv"); + assert!(x.local(Path::new("src/.git/config"))); + assert!(x.local(Path::new("a/b.pyc"))); + assert!(x.local(Path::new("node_modules/x"))); + assert!(!x.local(Path::new("src/main.py"))); + assert!(x.remote("/srv/logs/today.log")); + assert!(x.remote("/srv/logs")); + assert!(!x.remote("/srv/logsx")); + } +} diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs new file mode 100644 index 000000000..f19f0bf93 --- /dev/null +++ b/crates/ide-remote/src/service.rs @@ -0,0 +1,312 @@ +//! The deployment and run-target operations of one app, over the shared SSH +//! connections: what the IPC commands and the save hook call. + +use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport}; +use crate::mapping::{Excludes, Mappings}; +use crate::target::{RemoteRuns, RemoteStart, SyncFn}; +use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget}; +use ide_core::settings::Settings; +use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink}; +use ide_run::{OutputChunk, RunConfig, RunInfo}; +use ide_ssh::{RemoteEntry, RemoteFs, SshManager}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Progress of a transfer: (project-relative path, index, total). +pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send); + +/// A remote file opened for editing (a local copy uploaded back on save). +#[derive(Debug, Clone)] +struct RemoteEdit { + ssh_config: String, + remote: String, +} + +/// Where a local file is deployed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct DeployedPath { + pub server: String, + pub remote_path: String, + pub web_url: Option, +} + +pub struct RemoteService { + ssh: Arc, + /// Local copies of remote files: `//`. + cache: PathBuf, + edits: Mutex>, + pub runs: RemoteRuns, +} + +/// The deployment server `id`, or the default one. +pub fn server(settings: &Settings, id: Option<&str>) -> IdeResult { + let d = &settings.deployment; + let id = id.filter(|s| !s.is_empty()).unwrap_or(&d.default_server); + if id.is_empty() { + return Err(IdeError::invalid("no default deployment server (Settings › Deployment)")); + } + d.servers + .iter() + .find(|s| s.id == id) + .cloned() + .ok_or_else(|| IdeError::not_found(format!("no deployment server {id}"))) +} + +pub fn run_target(settings: &Settings, id: &str) -> IdeResult { + settings + .run_targets + .iter() + .find(|t| t.id == id) + .cloned() + .ok_or_else(|| IdeError::not_found(format!("no run target {id} (Settings › Run Targets)"))) +} + +impl RemoteService { + pub fn new(ssh: Arc, cache: PathBuf) -> Self { + Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() } + } + + async fn fs(&self, ssh_config: &str) -> IdeResult { + if ssh_config.is_empty() { + return Err(IdeError::invalid("the deployment server has no SSH configuration")); + } + let session = self.ssh.session(ssh_config).await?; + Ok(RemoteFs::new(session.sftp().await?)) + } + + /// Runs `f` with a [`Deployment`] on `server`. + async fn with_deployment( + &self, + settings: &Settings, + root: &Path, + server: &DeploymentServer, + cancel: CancellationToken, + f: impl AsyncFnOnce(&Deployment<'_>) -> T, + ) -> IdeResult { + let fs = self.fs(&server.ssh_config).await?; + let remote_root = match fs.resolve(&server.root_path).await { + Ok(r) => r, + Err(e) => { + fs.close().await; + return Err(e); + } + }; + let map = Mappings::new(server, root, &remote_root); + let exclude = Excludes::new(&settings.deployment, server, &remote_root); + let d = Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: settings.deployment.preserve_timestamps, cancel }; + let out = f(&d).await; + fs.close().await; + Ok(out) + } + + pub async fn upload( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + paths: &[PathBuf], + cancel: CancellationToken, + progress: Progress<'_>, + ) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.upload(paths, progress).await).await + } + + pub async fn download( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + paths: &[PathBuf], + cancel: CancellationToken, + progress: Progress<'_>, + ) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.download(paths, progress).await).await + } + + pub async fn delete_remote(&self, settings: &Settings, root: &Path, server_id: Option<&str>, paths: &[PathBuf]) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| d.delete_remote(paths).await).await + } + + pub async fn compare(&self, settings: &Settings, root: &Path, server_id: Option<&str>, path: &Path, cancel: CancellationToken) -> IdeResult> { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.compare(path).await).await? + } + + pub async fn sync( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + steps: &[SyncStep], + cancel: CancellationToken, + progress: Progress<'_>, + ) -> IdeResult { + let server = server(settings, server_id)?; + self.with_deployment(settings, root, &server, cancel, async |d| d.apply(steps, progress).await).await + } + + /// Where `path` is deployed on `server_id` (or the default server). + pub async fn deployed(&self, settings: &Settings, root: &Path, server_id: Option<&str>, path: &Path) -> IdeResult> { + let server = server(settings, server_id)?; + let name = server.name.clone(); + self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| { + d.map.remote(path).map(|remote_path| DeployedPath { server: name, remote_path, web_url: d.map.web_url(path) }) + }) + .await + } + + // --- Remote Host browser ------------------------------------------------------- + + /// The server root of `server_id`, resolved on the host. + pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult { + let server = server(settings, Some(server_id))?; + let fs = self.fs(&server.ssh_config).await?; + let r = fs.resolve(&server.root_path).await; + fs.close().await; + r + } + + pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult> { + let fs = self.fs(ssh_config).await?; + let r = async { fs.list(&fs.resolve(path).await?).await }.await; + fs.close().await; + r + } + + /// Downloads a remote file for editing; saving the copy uploads it back. + pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult { + let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s)); + let local = self.cache.join(sanitize(ssh_config)).join(rel); + let fs = self.fs(ssh_config).await?; + let r = fs.download(remote, &local).await; + fs.close().await; + r?; + self.edits.lock().insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() }); + Ok(local) + } + + /// The remote file a local copy stands for. + pub fn remote_of(&self, local: &Path) -> Option<(String, String)> { + self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone())) + } + + pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { + let fs = self.fs(ssh_config).await?; + let r = fs.remove(remote).await; + fs.close().await; + r + } + + pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> { + let fs = self.fs(ssh_config).await?; + let r = fs.mkdir_all(remote).await; + fs.close().await; + r + } + + pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> { + let fs = self.fs(ssh_config).await?; + let r = fs.rename(from, to).await; + fs.close().await; + r + } + + // --- save hook ----------------------------------------------------------------- + + /// After a document save: uploads a remote file's copy, or (automatic upload) + /// the file to the default server. `None` when nothing applies. + pub async fn on_saved(&self, settings: &Settings, root: &Path, path: &Path) -> Option> { + if let Some((config, remote)) = self.remote_of(path) { + let r = async { + let fs = self.fs(&config).await?; + let r = fs.upload(path, &remote, false).await; + fs.close().await; + r.map(|()| TransferReport { files: 1, ..Default::default() }) + } + .await; + return Some(r); + } + if settings.deployment.auto_upload == AutoUpload::Never || settings.deployment.default_server.is_empty() || !path.starts_with(root) { + return None; + } + Some(self.upload(settings, root, None, &[path.to_path_buf()], CancellationToken::new(), &mut |_, _, _| {}).await) + } + + /// After files were deleted locally: their remote copies too (automatic upload + Delete remote files). + pub async fn on_deleted(&self, settings: &Settings, root: &Path, paths: &[PathBuf]) -> Option> { + let d = &settings.deployment; + if d.auto_upload == AutoUpload::Never || !d.delete_remote || d.default_server.is_empty() { + return None; + } + Some(self.delete_remote(settings, root, None, paths).await) + } + + // --- run targets --------------------------------------------------------------- + + /// Starts `config` on its run target. + pub async fn run( + &self, + settings: &Settings, + root: &Path, + config: &RunConfig, + sink: SharedSink, + on_output: Box, + ) -> IdeResult { + let target_id = config.extras.target.as_deref().unwrap_or_default(); + let target = run_target(settings, target_id)?; + let session = self.ssh.session(&target.ssh_config).await?; + let fs = RemoteFs::new(session.sftp().await?); + let folder = if target.project_root.trim().is_empty() { + format!("~/{}", root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "project".into())) + } else { + target.project_root.clone() + }; + let remote_root = fs.resolve(&folder).await; + let remote_root = match remote_root { + Ok(r) => r, + Err(e) => { + fs.close().await; + return Err(e); + } + }; + let sync: Option = if target.sync_before_run { + let deployment = settings.deployment.clone(); + let root = root.to_path_buf(); + let remote_root = remote_root.clone(); + Some(Box::new(move |out| { + Box::pin(async move { + let server = DeploymentServer { root_path: remote_root.clone(), ..Default::default() }; + let map = Mappings::new(&server, &root, &remote_root); + let exclude = Excludes::new(&deployment, &server, &remote_root); + let d = Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: true, cancel: CancellationToken::new() }; + let mut progress = |_: &str, _: usize, _: usize| {}; + let report = d.push_changes(&root, &mut progress).await; + fs.close().await; + let report = report?; + out(OutputChunk::System { text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files) }); + match report.errors.first() { + Some(e) => Err(IdeError::new(ide_core::error::ErrorKind::Io, format!("upload of {} failed: {}", e.path, e.message))), + None => Ok(()), + } + }) + })) + } else { + fs.close().await; + None + }; + let start = RemoteStart { session, target, root: root.to_path_buf(), remote_root, sync }; + Ok(self.runs.start(start, config, sink, on_output)) + } +} + +fn sanitize(s: &str) -> String { + s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect() +} diff --git a/crates/ide-remote/src/target.rs b/crates/ide-remote/src/target.rs new file mode 100644 index 000000000..7c67aea6d --- /dev/null +++ b/crates/ide-remote/src/target.rs @@ -0,0 +1,295 @@ +//! Run configurations on a run target: the project is synchronised to the +//! target's folder, then the command runs there over SSH with local paths and +//! interpreters swapped for their remote counterparts. + +use ide_core::remote_settings::RunTarget; +use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, RunId, SharedSink}; +use ide_run::{OutputChunk, RunConfig, RunInfo}; +use ide_ssh::sftp::join; +use ide_ssh::{Output, RemoteProcess, Session, Sig}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::future::Future; +use std::path::Path; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +type OutputFn = Arc; +/// Uploads local changes before the run; reports progress lines. +pub type SyncFn = Box Pin> + Send>> + Send>; + +/// The remote program, arguments, folder and environment of a configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteCommand { + pub program: String, + pub args: Vec, + pub cwd: String, + pub env: BTreeMap, + /// A whole shell command line (configurations with a command and no arguments). + pub shell_line: Option, +} + +impl RemoteCommand { + pub fn line(&self) -> String { + match &self.shell_line { + Some(line) => format!("cd {} && {line}", ide_ssh::shell::quote(&self.cwd)), + None => ide_ssh::shell::command_line(Some(&self.cwd), &self.env, &self.program, &self.args), + } + } + + /// The echoed command line. + pub fn display(&self) -> String { + match &self.shell_line { + Some(line) => line.clone(), + None => std::iter::once(self.program.as_str()) + .chain(self.args.iter().map(String::as_str)) + .map(ide_ssh::shell::quote) + .collect::>() + .join(" "), + } + } +} + +fn file_name(program: &str) -> &str { + program.rsplit(['/', '\\']).next().unwrap_or(program) +} + +/// The target's interpreter for a local interpreter executable, if it is one. +fn interpreter<'a>(program: &str, target: &'a RunTarget) -> Option<&'a str> { + let name = file_name(program).trim_end_matches(".exe").to_ascii_lowercase(); + let pick = |configured: &'a str, default: &'a str| Some(if configured.trim().is_empty() { default } else { configured.trim() }); + if name == "python" || name.starts_with("python3") || name == "python2" { + pick(&target.python, "python3") + } else if name == "node" { + pick(&target.node, "node") + } else if name == "java" { + pick(&target.java, "java") + } else { + None + } +} + +/// Builds the remote command of `config`; `root` (the project) maps onto `remote_root`. +pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_root: &str) -> RemoteCommand { + let root_str = root.to_string_lossy().into_owned(); + let map = |s: &str| -> String { + if root_str.is_empty() { + return s.to_owned(); + } + s.replace(&root_str, remote_root.trim_end_matches('/')).replace('\\', "/") + }; + let map_text = |s: &str| -> String { if root_str.is_empty() { s.to_owned() } else { s.replace(&root_str, remote_root.trim_end_matches('/')) } }; + let cwd = match config.cwd.as_ref().map(|p| p.to_path_buf()) { + Some(dir) if dir.is_absolute() => match dir.strip_prefix(root) { + Ok(rel) => join(remote_root, &crate::mapping::posix(rel)), + Err(_) => remote_root.to_owned(), + }, + Some(dir) if !dir.as_os_str().is_empty() => join(remote_root, &crate::mapping::posix(&dir)), + _ => remote_root.to_owned(), + }; + let env = config.env.iter().map(|(k, v)| (k.clone(), map_text(v))).collect(); + let command = config.command.trim(); + if config.args.is_empty() && command.contains(char::is_whitespace) { + return RemoteCommand { program: String::new(), args: Vec::new(), cwd, env, shell_line: Some(map_text(command)) }; + } + let program = match interpreter(command, target) { + Some(remote) => remote.to_owned(), + None if Path::new(command).is_absolute() && Path::new(command).starts_with(root) => map(command), + None if Path::new(command).is_absolute() => file_name(command).to_owned(), + None => command.to_owned(), + }; + let args = config.args.iter().map(|a| map_text(a)).collect(); + RemoteCommand { program, args, cwd, env, shell_line: None } +} + +struct RemoteRun { + info: Mutex, + process: Mutex>>, + cancel: CancellationToken, + stopping: AtomicBool, + /// The runtime it started on: stdin / stop requests may come from threads without one. + runtime: tokio::runtime::Handle, +} + +/// Runs on run targets, alongside the local `ide_run::ProcessManager`. +#[derive(Default)] +pub struct RemoteRuns { + runs: Arc>>>, +} + +pub struct RemoteStart { + pub session: Arc, + pub target: RunTarget, + pub root: std::path::PathBuf, + /// The target's project folder, resolved on the host. + pub remote_root: String, + /// Uploads local changes first (`sync_before_run`). + pub sync: Option, +} + +impl RemoteRuns { + pub fn new() -> Self { + Self::default() + } + + pub fn contains(&self, id: RunId) -> bool { + self.runs.lock().contains_key(&id) + } + + pub fn list(&self) -> Vec { + self.runs.lock().values().map(|r| r.info.lock().clone()).collect() + } + + /// Starts `config` on the target; returns at once (synchronisation and the + /// remote process follow in the background, reported as output). + pub fn start(&self, start: RemoteStart, config: &RunConfig, sink: SharedSink, on_output: Box) -> RunInfo { + let id = RunId::next(); + let pty = config.extras.emulate_terminal; + let info = RunInfo { id, config_id: config.id.clone(), name: config.name.clone(), pid: None, running: true, pty }; + let run = Arc::new(RemoteRun { + info: Mutex::new(info.clone()), + process: Mutex::new(None), + cancel: CancellationToken::new(), + stopping: AtomicBool::new(false), + runtime: tokio::runtime::Handle::current(), + }); + self.runs.lock().insert(id, run.clone()); + let on_output: OutputFn = Arc::from(on_output); + let command = remote_command(config, &start.target, &start.root, &start.remote_root); + tokio::spawn(async move { + let code = run_remote(&run, start, command, pty, on_output.clone()).await; + let message = match &code { + Ok(Some(c)) => format!("Process finished with exit code {c}"), + Ok(None) if run.stopping.load(Ordering::Acquire) => "Process finished (stopped)".to_owned(), + Ok(None) => "Process finished".to_owned(), + Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => "Process finished (stopped)".to_owned(), + Err(e) => format!("Cannot run on the target: {}", e.message), + }; + on_output(OutputChunk::System { text: format!("\n{message}\n") }); + run.info.lock().running = false; + run.process.lock().take(); + let code = code.ok().flatten().and_then(|c| i32::try_from(c).ok()); + sink.emit(IdeEvent::RunExited { run: id, code }); + }); + info + } + + pub fn write_stdin(&self, id: RunId, data: &str) -> IdeResult<()> { + let (process, rt) = self.process(id)?; + let data = data.as_bytes().to_vec(); + rt.spawn(async move { process.write(&data).await }); + Ok(()) + } + + pub fn close_stdin(&self, id: RunId) -> IdeResult<()> { + let (process, rt) = self.process(id)?; + rt.spawn(async move { process.eof().await }); + Ok(()) + } + + pub fn resize(&self, id: RunId, cols: u16, rows: u16) -> IdeResult<()> { + let (process, rt) = self.process(id)?; + rt.spawn(async move { process.resize(cols, rows).await }); + Ok(()) + } + + /// `INT`, then `KILL` (closing the channel) after `grace`. + pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> { + let run = self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?; + run.stopping.store(true, Ordering::Release); + run.cancel.cancel(); + let Some(process) = run.process.lock().clone() else { return Ok(()) }; + let rt = run.runtime.clone(); + rt.spawn(async move { + let _ = process.signal(Sig::INT).await; + tokio::time::sleep(grace).await; + if run.info.lock().running { + let _ = process.signal(Sig::KILL).await; + } + }); + Ok(()) + } + + fn process(&self, id: RunId) -> IdeResult<(Arc, tokio::runtime::Handle)> { + let run = self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?; + let process = run.process.lock().clone(); + let process = process.ok_or_else(|| IdeError::unavailable("the remote process has not started yet"))?; + Ok((process, run.runtime.clone())) + } +} + +async fn run_remote(run: &RemoteRun, start: RemoteStart, command: RemoteCommand, pty: bool, on_output: OutputFn) -> IdeResult> { + on_output(OutputChunk::System { text: format!("[{}] {}\n", start.target.name, start.session.label()) }); + if let Some(sync) = start.sync { + tokio::select! { + r = sync(on_output.clone()) => r?, + _ = run.cancel.cancelled() => return Err(IdeError::cancelled()), + } + } + if run.cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + on_output(OutputChunk::System { text: format!("{}\n", command.display()) }); + let out = on_output.clone(); + let process = start + .session + .spawn( + &command.line(), + pty.then_some((120, 30)), + Box::new(move |chunk| match chunk { + Output::Stdout(b) => out(OutputChunk::Stdout { text: String::from_utf8_lossy(b).into_owned() }), + Output::Stderr(b) => out(OutputChunk::Stderr { text: String::from_utf8_lossy(b).into_owned() }), + }), + ) + .await?; + let process = Arc::new(process); + *run.process.lock() = Some(process.clone()); + Ok(process.wait().await) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(command: &str, args: &[&str], cwd: Option<&str>) -> RunConfig { + RunConfig { + id: "c".into(), + name: "c".into(), + kind: "python".into(), + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + cwd: cwd.map(|c| ide_core::IpcPath::from(std::path::PathBuf::from(c))), + env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + } + } + + #[test] + fn maps_interpreters_paths_and_folders() { + let target = RunTarget { python: "/opt/py/bin/python".into(), ..Default::default() }; + let root = Path::new("/work/proj"); + let c = remote_command( + &config("/work/proj/.venv/bin/python", &["-u", "/work/proj/src/main.py"], Some("/work/proj/src")), + &target, + root, + "/home/me/proj", + ); + assert_eq!(c.program, "/opt/py/bin/python"); + assert_eq!(c.args, ["-u", "/home/me/proj/src/main.py"]); + assert_eq!(c.cwd, "/home/me/proj/src"); + assert_eq!(c.env["DATA"], "/home/me/proj/data"); + assert_eq!(c.line(), "cd /home/me/proj/src && exec env DATA=/home/me/proj/data /opt/py/bin/python -u /home/me/proj/src/main.py"); + + let c = remote_command(&config("npm run dev", &[], None), &target, root, "/home/me/proj"); + assert_eq!(c.line(), "cd /home/me/proj && npm run dev"); + let c = remote_command(&config("/usr/local/bin/node", &["app.js"], None), &RunTarget::default(), root, "/srv"); + assert_eq!((c.program.as_str(), c.cwd.as_str()), ("node", "/srv")); + let c = remote_command(&config("/usr/bin/make", &["test"], None), &RunTarget::default(), root, "/srv"); + assert_eq!(c.program, "make"); + } +} diff --git a/crates/ide-remote/tests/remote.rs b/crates/ide-remote/tests/remote.rs new file mode 100644 index 000000000..4c1c1b983 --- /dev/null +++ b/crates/ide-remote/tests/remote.rs @@ -0,0 +1,173 @@ +//! Deployment and run targets against the in-process SSH / SFTP server. + +use ide_core::remote_settings::{DeploymentMapping, DeploymentServer, DeploymentSettings, RunTarget, SshAuth}; +use ide_core::{CancellationToken, IdeEvent, SharedSink}; +use ide_remote::{Deployment, Excludes, Mappings, RemoteRuns, RemoteStart, SyncAction, SyncStatus, SyncStep}; +use ide_run::{OutputChunk, RunConfig}; +use ide_ssh::{RemoteFs, Session, SshFiles, SshTarget}; +use parking_lot::Mutex; +use std::sync::Arc; + +struct Env { + _tmp: tempfile::TempDir, + project: std::path::PathBuf, + server_root: std::path::PathBuf, + session: Arc, +} + +async fn env() -> Env { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let server_root = tmp.path().join("server"); + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::create_dir_all(project.join(".git")).unwrap(); + std::fs::create_dir_all(&server_root).unwrap(); + std::fs::write(project.join("src/main.py"), "print('hi')\n").unwrap(); + std::fs::write(project.join("README.md"), "# demo\n").unwrap(); + std::fs::write(project.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap(); + let port = ide_ssh::testing::sshd(server_root.clone(), Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await; + let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }; + let target = SshTarget { host: "127.0.0.1".into(), port: Some(port), user: "dev".into(), auth: SshAuth::Password, key_file: String::new() }; + let session = match Session::connect(&target, Some("secret"), &files).await { + Err(ide_ssh::ConnectError::UnknownHostKey(k)) => { + k.trust(&files.known_hosts).unwrap(); + Session::connect(&target, Some("secret"), &files).await.ok().unwrap() + } + other => other.ok().unwrap(), + }; + Env { _tmp: tmp, project, server_root, session: Arc::new(session) } +} + +fn server() -> DeploymentServer { + DeploymentServer { + id: "s".into(), + name: "staging".into(), + root_path: "/srv".into(), + mappings: vec![DeploymentMapping { local: String::new(), deployment: "app".into(), web: String::new() }], + ..Default::default() + } +} + +fn no_progress() -> impl FnMut(&str, usize, usize) + Send { + |_, _, _| {} +} + +#[tokio::test] +async fn uploads_compares_syncs_and_downloads() { + let env = env().await; + let fs = RemoteFs::new(env.session.sftp().await.unwrap()); + let server = server(); + let settings = DeploymentSettings::default(); + let map = Mappings::new(&server, &env.project, "/srv"); + let exclude = Excludes::new(&settings, &server, "/srv"); + let d = Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: true, cancel: CancellationToken::new() }; + + let report = d.upload(&[env.project.clone()], &mut no_progress()).await; + assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}"); + assert_eq!(std::fs::read_to_string(env.server_root.join("srv/app/src/main.py")).unwrap(), "print('hi')\n"); + assert!(!env.server_root.join("srv/app/.git").exists(), ".git is excluded by name"); + + let same = d.compare(&env.project).await.unwrap(); + assert!(same.iter().all(|e| e.status == SyncStatus::Same), "{same:?}"); + + // A new local file, a file only on the server, a remote change. + std::fs::write(env.project.join("src/new.py"), "x = 1\n").unwrap(); + std::fs::write(env.server_root.join("srv/app/remote.txt"), "server side\n").unwrap(); + std::fs::write(env.server_root.join("srv/app/README.md"), "# changed on the server, longer\n").unwrap(); + let file = std::fs::File::options().write(true).open(env.server_root.join("srv/app/README.md")).unwrap(); + file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)).unwrap(); + let entries = d.compare(&env.project).await.unwrap(); + let status = |p: &str| entries.iter().find(|e| e.path == p).map(|e| e.status); + assert_eq!(status("src/new.py"), Some(SyncStatus::LocalOnly)); + assert_eq!(status("remote.txt"), Some(SyncStatus::RemoteOnly)); + assert_eq!(status("README.md"), Some(SyncStatus::RemoteNewer)); + assert_eq!(status("src/main.py"), Some(SyncStatus::Same)); + + let steps = vec![ + SyncStep { path: "src/new.py".into(), action: SyncAction::Upload }, + SyncStep { path: "README.md".into(), action: SyncAction::Download }, + SyncStep { path: "remote.txt".into(), action: SyncAction::DeleteRemote }, + ]; + let report = d.apply(&steps, &mut no_progress()).await; + assert_eq!((report.files, report.errors.len()), (3, 0), "{report:?}"); + assert!(env.server_root.join("srv/app/src/new.py").is_file()); + assert!(!env.server_root.join("srv/app/remote.txt").exists()); + assert_eq!(std::fs::read_to_string(env.project.join("README.md")).unwrap(), "# changed on the server, longer\n"); + + // Download a folder, then delete it remotely. + std::fs::remove_dir_all(env.project.join("src")).unwrap(); + let report = d.download(&[env.project.join("src")], &mut no_progress()).await; + assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}"); + assert!(env.project.join("src/main.py").is_file()); + let report = d.delete_remote(&[env.project.join("src")]).await; + assert_eq!(report.files, 1); + assert!(!env.server_root.join("srv/app/src").exists()); + + // Sync before a run uploads only what changed. + let report = d.push_changes(&env.project, &mut no_progress()).await.unwrap(); + assert_eq!(report.files, 2, "{report:?}"); + assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 0); +} + +struct Sink(Mutex>); + +impl ide_core::EventSink for Sink { + fn emit(&self, event: IdeEvent) { + self.0.lock().push(event); + } +} + +#[tokio::test] +async fn runs_a_configuration_on_a_target() { + let env = env().await; + let runs = RemoteRuns::new(); + let target = RunTarget { id: "t".into(), name: "box".into(), python: "/usr/bin/python3".into(), ..Default::default() }; + let config = RunConfig { + id: "main".into(), + name: "main".into(), + kind: "python".into(), + command: "python3".into(), + args: vec![env.project.join("src/main.py").to_string_lossy().into_owned()], + cwd: None, + env: Default::default(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let output: Arc> = Arc::default(); + let out = output.clone(); + let sink = Arc::new(Sink(Mutex::default())); + let start = RemoteStart { + session: env.session.clone(), + target, + root: env.project.clone(), + remote_root: "/home/dev/demo".into(), + sync: None, + }; + let info = runs.start( + start, + &config, + sink.clone() as SharedSink, + Box::new(move |chunk| match chunk { + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => out.lock().push_str(&text), + }), + ); + assert!(runs.contains(info.id)); + for _ in 0..200 { + if !sink.0.lock().is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let events = sink.0.lock().clone(); + assert!(matches!(events.as_slice(), [IdeEvent::RunExited { code: Some(0), .. }]), "{events:?}"); + let text = output.lock().clone(); + assert!( + text.contains("ran: cd /home/dev/demo && exec /usr/bin/python3 /home/dev/demo/src/main.py"), + "{text}" + ); + assert!(text.contains("Process finished with exit code 0"), "{text}"); + assert!(!runs.list()[0].running); +} diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index e2f067088..9125a4c35 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -111,6 +111,7 @@ struct FileConfig { emulate_terminal: bool, #[serde(alias = "stopCommand")] stop_command: Option, + target: Option, } fn slug(s: &str) -> String { @@ -173,6 +174,7 @@ fn convert(configs: Vec, local: bool) -> Vec { focus_tool_window: c.focus_tool_window, emulate_terminal: c.emulate_terminal, stop_command: non_empty(c.stop_command), + target: non_empty(c.target), }, }); } @@ -275,6 +277,9 @@ fn to_table(c: &RunConfig) -> Table { if let Some(stop) = &x.stop_command { t.insert("stop-command", value(stop)); } + if let Some(target) = &x.target { + t.insert("target", value(target)); + } t } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 58e6b12cc..f4642380d 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -125,6 +125,10 @@ pub struct RunExtras { #[serde(default, skip_serializing_if = "Option::is_none")] #[specta(optional)] pub stop_command: Option, + /// Settings › Run Targets id the process runs on (over SSH); `None` = this machine. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub target: Option, } impl Default for RunExtras { @@ -145,6 +149,7 @@ impl Default for RunExtras { focus_tool_window: false, emulate_terminal: false, stop_command: None, + target: None, } } } diff --git a/crates/ide-ssh/Cargo.toml b/crates/ide-ssh/Cargo.toml new file mode 100644 index 000000000..8dae8423f --- /dev/null +++ b/crates/ide-ssh/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "ide-ssh" +description = "SSH connections: configurations, host keys, authentication, remote commands, SFTP, port forwarding and askpass" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tokio.workspace = true +tracing.workspace = true +parking_lot.workspace = true +dirs.workspace = true +russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] } +russh-config = "0.58.0" +russh-sftp = "2.4" + +[features] +# `testing`: an in-process SSH / SFTP server for tests of crates built on this one. +testing = [] + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tempfile.workspace = true diff --git a/crates/ide-ssh/src/askpass.rs b/crates/ide-ssh/src/askpass.rs new file mode 100644 index 000000000..af22f5456 --- /dev/null +++ b/crates/ide-ssh/src/askpass.rs @@ -0,0 +1,208 @@ +//! Credential prompts of child processes (`ssh`, `git`) answered by the UI. +//! +//! The app's own executable doubles as the `SSH_ASKPASS` / `GIT_ASKPASS` +//! program: started with [`PORT_ENV`] set, it sends the prompt to the +//! [`AskpassServer`] on 127.0.0.1 and prints the answer ([`run_helper`]). + +use ide_core::{IdeError, IdeResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::time::Duration; + +pub const PORT_ENV: &str = "OPENIDEA_ASKPASS_PORT"; +pub const TOKEN_ENV: &str = "OPENIDEA_ASKPASS_TOKEN"; +/// An unanswered prompt fails after this long. +const ANSWER_TIMEOUT: Duration = Duration::from_secs(300); + +pub use ide_core::event::AskpassKind; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AskpassPrompt { + pub id: u64, + pub prompt: String, + pub kind: AskpassKind, +} + +pub fn classify(prompt: &str) -> AskpassKind { + let p = prompt.to_ascii_lowercase(); + if p.contains("(yes/no") || p.contains("continue connecting") { + AskpassKind::Confirm + } else if p.starts_with("username") { + AskpassKind::Text + } else { + AskpassKind::Secret + } +} + +#[derive(Serialize, Deserialize)] +struct Request { + token: String, + prompt: String, +} + +#[derive(Serialize, Deserialize)] +struct Response { + answer: Option, +} + +type Pending = Arc>>>>; + +/// Serves prompts to `on_prompt`; answers come back through [`AskpassServer::answer`]. +pub struct AskpassServer { + port: u16, + token: String, + exe: std::path::PathBuf, + pending: Pending, +} + +impl AskpassServer { + /// Listens on a free local port. `exe` is the helper program (the app itself). + pub fn start(exe: std::path::PathBuf, on_prompt: Arc) -> IdeResult { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + let token = random_token(); + let pending: Pending = Arc::default(); + let (t, p) = (token.clone(), pending.clone()); + std::thread::Builder::new() + .name("askpass".into()) + .spawn(move || { + let next = AtomicU64::new(1); + for stream in listener.incoming().flatten() { + let id = next.fetch_add(1, Ordering::Relaxed); + let (t, p, on_prompt) = (t.clone(), p.clone(), on_prompt.clone()); + let _ = std::thread::Builder::new() + .name("askpass-prompt".into()) + .spawn(move || serve(stream, id, &t, &p, on_prompt.as_ref())); + } + }) + .map_err(|e| IdeError::internal(format!("askpass: {e}")))?; + Ok(Self { port, token, exe, pending }) + } + + /// Answers prompt `id` (`None`: cancelled). + pub fn answer(&self, id: u64, answer: Option) { + if let Some(tx) = self.pending.lock().remove(&id) { + let _ = tx.send(answer); + } + } + + /// Environment that routes `ssh` / `git` prompts here. + pub fn env(&self) -> Vec<(String, String)> { + let exe = self.exe.to_string_lossy().into_owned(); + vec![ + (PORT_ENV.into(), self.port.to_string()), + (TOKEN_ENV.into(), self.token.clone()), + ("SSH_ASKPASS".into(), exe.clone()), + ("SSH_ASKPASS_REQUIRE".into(), "force".into()), + ("GIT_ASKPASS".into(), exe), + ] + } +} + +fn serve(stream: TcpStream, id: u64, token: &str, pending: &Pending, on_prompt: &(dyn Fn(AskpassPrompt) + Send + Sync)) { + let mut line = String::new(); + let Ok(mut reader) = stream.try_clone().map(BufReader::new) else { return }; + if reader.read_line(&mut line).is_err() { + return; + } + let Ok(req) = serde_json::from_str::(&line) else { return }; + let answer = if req.token != token { + None + } else { + let (tx, rx) = mpsc::channel(); + pending.lock().insert(id, tx); + let prompt = req.prompt.trim().to_owned(); + on_prompt(AskpassPrompt { id, kind: classify(&prompt), prompt }); + let answer = rx.recv_timeout(ANSWER_TIMEOUT).ok().flatten(); + pending.lock().remove(&id); + answer + }; + let mut out = stream; + let _ = writeln!(out, "{}", serde_json::to_string(&Response { answer }).unwrap_or_default()); +} + +fn random_token() -> String { + use std::collections::hash_map::RandomState; + use std::hash::{BuildHasher, Hasher}; + // Two independently seeded SipHash keys: unpredictable to other local users. + (0..2) + .map(|_| { + let mut h = RandomState::new().build_hasher(); + h.write_u128(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()); + format!("{:016x}", h.finish()) + }) + .collect() +} + +/// When started as the askpass helper (by `ssh` / `git`), forwards the prompt +/// (the first argument) and returns the exit code; `None` otherwise. +pub fn run_helper() -> Option { + let port: u16 = std::env::var(PORT_ENV).ok()?.parse().ok()?; + let token = std::env::var(TOKEN_ENV).unwrap_or_default(); + let prompt = std::env::args().nth(1).unwrap_or_else(|| "Password:".into()); + let ask = || -> std::io::Result> { + let mut stream = TcpStream::connect(("127.0.0.1", port))?; + writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt }).unwrap_or_default())?; + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + Ok(serde_json::from_str::(&line).ok().and_then(|r| r.answer)) + }; + match ask() { + Ok(Some(answer)) => { + println!("{answer}"); + Some(0) + } + _ => Some(1), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classifies_prompts() { + assert_eq!(classify("Enter passphrase for key '/u/.ssh/id_ed25519': "), AskpassKind::Secret); + assert_eq!(classify("git@github.com's password: "), AskpassKind::Secret); + assert_eq!(classify("Username for 'https://github.com': "), AskpassKind::Text); + assert_eq!( + classify("Are you sure you want to continue connecting (yes/no/[fingerprint])? "), + AskpassKind::Confirm + ); + } + + #[test] + fn round_trips_a_prompt() { + let seen: Arc>> = Arc::default(); + let s = seen.clone(); + let server = Arc::new( + AskpassServer::start("helper".into(), Arc::new(move |p| *s.lock() = Some(p))).unwrap(), + ); + let port = server.port; + let token = server.token.clone(); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt: "Password: ".into() }).unwrap()) + .unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).unwrap(); + serde_json::from_str::(&line).unwrap().answer + }); + let id = loop { + if let Some(p) = seen.lock().clone() { + assert_eq!((p.prompt.as_str(), p.kind), ("Password:", AskpassKind::Secret)); + break p.id; + } + std::thread::sleep(Duration::from_millis(5)); + }; + server.answer(id, Some("hunter2".into())); + assert_eq!(client.join().unwrap().as_deref(), Some("hunter2")); + assert!(server.env().iter().any(|(k, v)| k == "SSH_ASKPASS" && v == "helper")); + } +} diff --git a/crates/ide-ssh/src/lib.rs b/crates/ide-ssh/src/lib.rs new file mode 100644 index 000000000..ec8627011 --- /dev/null +++ b/crates/ide-ssh/src/lib.rs @@ -0,0 +1,21 @@ +//! SSH for every remote feature: configurations and `~/.ssh/config` hosts, +//! host-key trust, authentication (password, key, agent), remote commands and +//! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`. + +pub mod askpass; +pub mod manager; +pub mod secrets; +pub mod session; +pub mod sftp; +pub mod shell; +pub mod target; +#[cfg(feature = "testing")] +pub mod testing; +pub mod tunnel; + +pub use manager::SshManager; +pub use russh::Sig; +pub use session::{ConnectError, ExecOutput, Output, RemoteProcess, Session, UnknownHostKey}; +pub use sftp::{RemoteEntry, RemoteFs}; +pub use target::{SshFiles, SshTarget, expand, openssh_args}; +pub use tunnel::Tunnel; diff --git a/crates/ide-ssh/src/manager.rs b/crates/ide-ssh/src/manager.rs new file mode 100644 index 000000000..d112d1c12 --- /dev/null +++ b/crates/ide-ssh/src/manager.rs @@ -0,0 +1,181 @@ +//! App-wide SSH connections, one per SSH configuration, shared by every +//! feature that works on that host (deployment, run targets, data sources). + +use crate::secrets::SecretFile; +use crate::session::{ConnectError, Session, UnknownHostKey}; +use crate::target::{SshFiles, SshTarget}; +use ide_core::remote_settings::{SshAuth, SshConfiguration}; +use ide_core::{IdeError, IdeResult}; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +pub struct SshManager { + files: SshFiles, + saved: SecretFile, + /// Secrets entered this session (not saved, or not yet), by configuration id. + entered: Mutex>, + configs: RwLock>, + sessions: Mutex>>, + /// Serialises connecting so concurrent users share one session. + opening: tokio::sync::Mutex<()>, + /// Host keys awaiting the user's trust, by key (configuration id or a caller's own). + unknown: Mutex>, +} + +impl SshManager { + /// `secrets` is the file of saved SSH passwords. + pub fn new(secrets: PathBuf) -> Self { + Self::with_files(secrets, SshFiles::default()) + } + + pub fn with_files(secrets: PathBuf, files: SshFiles) -> Self { + Self { + files, + saved: SecretFile::new(secrets), + entered: Mutex::default(), + configs: RwLock::default(), + sessions: Mutex::default(), + opening: tokio::sync::Mutex::new(()), + unknown: Mutex::default(), + } + } + + pub fn files(&self) -> &SshFiles { + &self.files + } + + /// Replaces the configurations (Settings › SSH Configurations); sessions of + /// changed or removed ones are closed, secrets of removed ones forgotten. + pub fn set_configs(&self, configs: Vec) { + let old = std::mem::replace(&mut *self.configs.write(), configs.clone()); + for o in old { + match configs.iter().find(|c| c.id == o.id) { + Some(n) if n.host == o.host && n.port == o.port && n.user == o.user && n.auth == o.auth && n.key_file == o.key_file => {} + Some(_) => { + self.sessions.lock().remove(&o.id); + } + None => { + self.sessions.lock().remove(&o.id); + self.entered.lock().remove(&o.id); + self.saved.delete(&o.id); + } + } + } + } + + pub fn configs(&self) -> Vec { + self.configs.read().clone() + } + + pub fn config(&self, id: &str) -> IdeResult { + self.configs + .read() + .iter() + .find(|c| c.id == id) + .cloned() + .ok_or_else(|| IdeError::not_found(format!("no SSH configuration {id}"))) + } + + /// Remembers the password / passphrase of configuration `id` for the session, + /// and in the saved passwords with `save` (`None` forgets it). + pub fn set_secret(&self, id: &str, secret: Option, save: bool) { + match &secret { + Some(s) => { + self.entered.lock().insert(id.to_owned(), s.clone()); + } + None => { + self.entered.lock().remove(id); + } + } + match (secret, save) { + (Some(s), true) => { + if let Err(e) = self.saved.set(id, &s) { + tracing::warn!("could not save the SSH password: {}", e.message); + } + } + _ => self.saved.delete(id), + } + } + + /// The known password / passphrase of configuration `id`. + pub fn secret(&self, config: &SshConfiguration) -> Option { + if let Some(s) = self.entered.lock().get(&config.id) { + return Some(s.clone()); + } + config.save_secret.then(|| self.saved.get(&config.id)).flatten() + } + + /// The shared session of configuration `id`, connecting first if needed. + pub async fn session(&self, id: &str) -> IdeResult> { + if let Some(s) = self.live(id) { + return Ok(s); + } + let _opening = self.opening.lock().await; + if let Some(s) = self.live(id) { + return Ok(s); + } + let config = self.config(id)?; + let secret = self.secret(&config); + let session = Arc::new(self.connect(id, &SshTarget::from(&config), secret.as_deref()).await?); + self.sessions.lock().insert(id.to_owned(), session.clone()); + Ok(session) + } + + fn live(&self, id: &str) -> Option> { + let mut sessions = self.sessions.lock(); + match sessions.get(id) { + Some(s) if !s.is_closed() => Some(s.clone()), + Some(_) => { + sessions.remove(id); + None + } + None => None, + } + } + + /// A private session to `target`; an unknown host key is kept under `key` + /// for [`SshManager::trust_host`]. + pub async fn connect(&self, key: &str, target: &SshTarget, secret: Option<&str>) -> IdeResult { + match Session::connect(target, secret, &self.files).await { + Ok(s) => Ok(s), + Err(ConnectError::Other(e)) => Err(e), + Err(ConnectError::UnknownHostKey(k)) => { + let e = k.error(); + self.unknown.lock().insert(key.to_owned(), *k); + Err(e) + } + } + } + + /// Connects with `config` as given (not necessarily saved) and describes the host. + pub async fn test(&self, config: &SshConfiguration, secret: Option) -> IdeResult { + let secret = secret.or_else(|| self.secret(config)); + let session = self.connect(&config.id, &SshTarget::from(config), secret.as_deref()).await?; + let out = session.exec("uname -srm 2>/dev/null || ver", None).await?; + let os = out.stdout_str().trim().to_owned(); + Ok(if os.is_empty() { format!("Connected to {}", session.label()) } else { format!("Connected to {}: {os}", session.label()) }) + } + + /// Trusts the host key that failed the last connection under `key` (adds it to `known_hosts`). + pub async fn trust_host(&self, key: &str) -> IdeResult<()> { + let k = self.unknown.lock().remove(key).ok_or_else(|| IdeError::not_found("no host key to trust"))?; + let path = self.files.known_hosts.clone(); + tokio::task::spawn_blocking(move || k.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))? + } + + pub fn disconnect(&self, id: &str) { + self.sessions.lock().remove(id); + } + + /// Ids of the configurations with an open session. + pub fn connected(&self) -> Vec { + self.sessions.lock().iter().filter(|(_, s)| !s.is_closed()).map(|(k, _)| k.clone()).collect() + } + + /// Whether connecting to `config` will need a secret the user has not given. + pub fn needs_secret(&self, config: &SshConfiguration) -> bool { + config.auth == SshAuth::Password && self.secret(config).is_none() + } +} diff --git a/crates/ide-ssh/src/secrets.rs b/crates/ide-ssh/src/secrets.rs new file mode 100644 index 000000000..5612ec9e8 --- /dev/null +++ b/crates/ide-ssh/src/secrets.rs @@ -0,0 +1,87 @@ +//! Saved SSH passwords / key passphrases of SSH configurations, in the app's +//! data directory rather than the OS credential store (no system prompts). +//! One JSON object, configuration id → secret; owner-only on Unix. + +use ide_core::{IdeError, IdeResult}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::PathBuf; + +pub struct SecretFile { + path: PathBuf, + lock: Mutex<()>, +} + +impl SecretFile { + pub fn new(path: PathBuf) -> Self { + Self { path, lock: Mutex::new(()) } + } + + pub fn get(&self, key: &str) -> Option { + let _g = self.lock.lock(); + self.read().remove(key) + } + + pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> { + let _g = self.lock.lock(); + let mut all = self.read(); + all.insert(key.to_owned(), secret.to_owned()); + self.write(&all) + } + + pub fn delete(&self, key: &str) { + let _g = self.lock.lock(); + let mut all = self.read(); + if all.remove(key).is_some() + && let Err(e) = self.write(&all) + { + tracing::warn!("saved SSH passwords: {}", e.message); + } + } + + fn read(&self) -> BTreeMap { + match std::fs::read(&self.path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + tracing::warn!("saved SSH passwords: {}: {e}", self.path.display()); + BTreeMap::new() + }), + Err(_) => BTreeMap::new(), + } + } + + fn write(&self, all: &BTreeMap) -> IdeResult<()> { + let err = |e: std::io::Error| IdeError::unavailable(format!("saved SSH passwords: {e}")); + if let Some(dir) = self.path.parent() { + std::fs::create_dir_all(dir).map_err(err)?; + } + let tmp = self.path.with_extension("json.tmp"); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?; + std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?; + std::fs::rename(&tmp, &self.path).map_err(err) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stores_and_forgets_secrets() { + let tmp = tempfile::tempdir().unwrap(); + let file = SecretFile::new(tmp.path().join("ssh-passwords.json")); + file.set("a", "pw").unwrap(); + assert_eq!(file.get("a").as_deref(), Some("pw")); + file.delete("a"); + assert_eq!(file.get("a"), None); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(tmp.path().join("ssh-passwords.json")).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } +} diff --git a/crates/ide-db/src/tunnel.rs b/crates/ide-ssh/src/session.rs similarity index 50% rename from crates/ide-db/src/tunnel.rs rename to crates/ide-ssh/src/session.rs index 8a6f60a66..104ea980d 100644 --- a/crates/ide-db/src/tunnel.rs +++ b/crates/ide-ssh/src/session.rs @@ -1,21 +1,21 @@ -//! SSH tunnels for data sources: a listener on 127.0.0.1 whose connections are -//! forwarded to the database through `direct-tcpip` channels of an SSH session -//! (russh), optionally reached through `ProxyJump` hosts. +//! An authenticated SSH connection (russh), optionally through `ProxyJump` +//! hosts, and what runs over it: commands, processes, SFTP and TCP channels. //! //! Host keys are checked against `known_hosts`; an unknown key fails the -//! connection with [`OpenError::UnknownHostKey`] so the UI can ask the user, +//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user, //! and a changed key is always refused. -use crate::source::{DbSsh, DbSshAuth}; +use crate::target::{Hop, SshFiles, SshTarget, expand, home, resolve_hops}; +use ide_core::remote_settings::SshAuth; use ide_core::{IdeError, IdeResult, error::ErrorKind}; use parking_lot::Mutex; -use russh::client::{self, Handle}; +use russh::client::{self, Handle, Msg}; use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts}; +use russh::{ChannelMsg, ChannelStream, ChannelWriteHalf, Sig}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; -use tokio::net::TcpListener; -use tokio::task::JoinHandle; +use tokio::sync::oneshot; /// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip. const CONNECT_TIMEOUT: Duration = Duration::from_secs(45); @@ -37,235 +37,254 @@ impl UnknownHostKey { /// Records the key in `known_hosts` (blocking). pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> { + if let Some(dir) = known_hosts.parent() { + std::fs::create_dir_all(dir)?; + } known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts) .map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display()))) } + + /// The error shown for it; the UI recognises the `Unknown SSH host key` prefix. + pub fn error(&self) -> IdeError { + IdeError::new( + ErrorKind::Protocol, + format!("Unknown SSH host key for {}:{} ({})", self.host, self.port, self.fingerprint()), + ) + } } #[derive(Debug)] -pub enum OpenError { +pub enum ConnectError { UnknownHostKey(Box), Other(IdeError), } -impl From for OpenError { +impl From for ConnectError { fn from(e: IdeError) -> Self { - OpenError::Other(e) + ConnectError::Other(e) } } -/// Where and how to connect. -pub struct TunnelSpec<'a> { - pub ssh: &'a DbSsh, - /// SSH password or key passphrase. - pub secret: Option<&'a str>, - /// The database, as seen from the SSH host. - pub target_host: &'a str, - pub target_port: u16, - pub known_hosts: &'a Path, - /// `~/.ssh/config` (used with [`DbSshAuth::OpenSsh`]). - pub ssh_config: Option<&'a Path>, +impl From for IdeError { + fn from(e: ConnectError) -> Self { + match e { + ConnectError::UnknownHostKey(k) => k.error(), + ConnectError::Other(e) => e, + } + } } -/// An open tunnel; dropping it stops forwarding and closes the SSH sessions. -pub struct Tunnel { - port: u16, - session: Arc>, - /// Sessions of the `ProxyJump` hosts, kept alive under `session`. +/// Output of a finished remote command. +#[derive(Debug, Clone, Default)] +pub struct ExecOutput { + /// `None` when the server reported no status (killed by a signal). + pub code: Option, + pub stdout: Vec, + pub stderr: Vec, +} + +impl ExecOutput { + pub fn success(&self) -> bool { + self.code == Some(0) + } + + pub fn stdout_str(&self) -> String { + String::from_utf8_lossy(&self.stdout).into_owned() + } + + /// stdout on success, else an error with stderr. + pub fn into_stdout(self, what: &str) -> IdeResult { + if self.success() { + return Ok(self.stdout_str()); + } + let err = String::from_utf8_lossy(&self.stderr).trim().to_owned(); + let code = self.code.map_or_else(|| "killed".to_owned(), |c| format!("exit code {c}")); + Err(io(if err.is_empty() { format!("{what} failed ({code})") } else { format!("{what} failed: {err}") })) + } +} + +/// An open SSH connection; dropping it closes the sessions. +pub struct Session { + handle: Handle, + /// Sessions of the `ProxyJump` hosts, kept alive under `handle`. _jumps: Vec>, - accept: JoinHandle<()>, + label: String, } -impl Tunnel { - /// Connects and starts listening on a free local port. - pub async fn open(spec: TunnelSpec<'_>) -> Result { - let host = format!("{}:{}", spec.ssh.host, spec.ssh.port.unwrap_or(22)); - match tokio::time::timeout(CONNECT_TIMEOUT, Self::open_inner(spec)).await { +impl Session { + /// Connects and authenticates; `secret` is the password or key passphrase. + pub async fn connect(target: &SshTarget, secret: Option<&str>, files: &SshFiles) -> Result { + let host = format!("{}:{}", target.host, target.port.unwrap_or(22)); + match tokio::time::timeout(CONNECT_TIMEOUT, Self::connect_inner(target, secret, files)).await { Ok(r) => r, - Err(_) => Err(IdeError::new( - ErrorKind::Io, - format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs()), - ) + Err(_) => Err(io(format!( + "timed out connecting to the SSH host {host} ({} s)", + CONNECT_TIMEOUT.as_secs() + )) .into()), } } - async fn open_inner(spec: TunnelSpec<'_>) -> Result { - let hops = resolve_hops(spec.ssh, spec.ssh_config)?; + async fn connect_inner(target: &SshTarget, secret: Option<&str>, files: &SshFiles) -> Result { + let hops = resolve_hops(target, files.config.as_deref())?; let mut sessions: Vec> = Vec::new(); for (i, hop) in hops.iter().enumerate() { - let target = i == hops.len() - 1; + let last = i == hops.len() - 1; let mut handle = match sessions.last() { - None => connect::(hop, spec.known_hosts, None).await?, + None => connect::(hop, &files.known_hosts, None).await?, Some(prev) => { let channel = prev .channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0) .await .map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?; - connect(hop, spec.known_hosts, Some(channel.into_stream())).await? + connect(hop, &files.known_hosts, Some(channel.into_stream())).await? } }; // Jump hosts always use the agent / identity files; the secret belongs to the target. - let mode = if target { spec.ssh.auth } else { DbSshAuth::OpenSsh }; - authenticate(&mut handle, hop, mode, &spec.ssh.key_file, spec.secret).await?; + let mode = if last { target.auth } else { SshAuth::OpenSsh }; + authenticate(&mut handle, hop, mode, &target.key_file, secret).await?; sessions.push(handle); } - let session = Arc::new(sessions.pop().expect("at least one hop")); - // Fail fast with the real reason when the SSH host cannot reach the database - // (a refused forward otherwise surfaces as the driver's connect timeout). - let via = hops.last().expect("at least one hop").label(); - session - .channel_open_direct_tcpip(spec.target_host, u32::from(spec.target_port), "127.0.0.1", 0) - .await - .map_err(|e| unreachable_target(&via, spec.target_host, spec.target_port, &e))?; - let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|e| io(e.to_string()))?; - let port = listener.local_addr().map_err(|e| io(e.to_string()))?.port(); - let accept = tokio::spawn(forward(listener, session.clone(), spec.target_host.to_owned(), spec.target_port)); - Ok(Tunnel { port, session, _jumps: sessions, accept }) + let handle = sessions.pop().expect("at least one hop"); + let label = hops.last().expect("at least one hop").label(); + Ok(Session { handle, _jumps: sessions, label }) } - /// The local port to connect to instead of the database. - pub fn port(&self) -> u16 { - self.port + /// `user@host:port` of the target. + pub fn label(&self) -> &str { + &self.label } - /// The SSH session ended (network loss, server restart). + /// The connection ended (network loss, server restart). pub fn is_closed(&self) -> bool { - self.session.is_closed() + self.handle.is_closed() } -} -impl Drop for Tunnel { - fn drop(&mut self) { - self.accept.abort(); + /// A TCP connection to `host:port` as seen from the SSH host. + pub async fn direct_tcpip(&self, host: &str, port: u16) -> Result, russh::Error> { + let channel = self.handle.channel_open_direct_tcpip(host, u32::from(port), "127.0.0.1", 0).await?; + Ok(channel.into_stream()) } -} -async fn forward(listener: TcpListener, session: Arc>, host: String, port: u16) { - loop { - let Ok((mut socket, peer)) = listener.accept().await else { return }; - let session = session.clone(); - let host = host.clone(); - tokio::spawn(async move { - match session - .channel_open_direct_tcpip(host.as_str(), u32::from(port), "127.0.0.1", u32::from(peer.port())) - .await - { - Ok(channel) => { - let mut stream = channel.into_stream(); - let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await; - } - Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"), + /// Runs `command` through the remote shell and collects its output. + pub async fn exec(&self, command: &str, stdin: Option<&[u8]>) -> IdeResult { + let mut channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; + channel.exec(true, command).await.map_err(|e| self.failed(e))?; + if let Some(input) = stdin { + channel.data(input).await.map_err(|e| self.failed(e))?; + } + channel.eof().await.map_err(|e| self.failed(e))?; + let mut out = ExecOutput::default(); + while let Some(msg) = channel.wait().await { + match msg { + ChannelMsg::Data { data } => out.stdout.extend_from_slice(&data), + ChannelMsg::ExtendedData { data, ext: 1 } => out.stderr.extend_from_slice(&data), + ChannelMsg::ExitStatus { exit_status } => out.code = Some(exit_status), + _ => {} } + } + Ok(out) + } + + /// Starts `command` (through the remote shell), streaming its output. + /// With `pty` (columns, rows) stdout and stderr arrive merged, as in a terminal. + pub async fn spawn( + &self, + command: &str, + pty: Option<(u16, u16)>, + on_output: Box) + Send>, + ) -> IdeResult { + let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; + if let Some((cols, rows)) = pty { + channel + .request_pty(true, "xterm-256color", u32::from(cols), u32::from(rows), 0, 0, &[]) + .await + .map_err(|e| self.failed(e))?; + } + if command.is_empty() { + channel.request_shell(true).await.map_err(|e| self.failed(e))?; + } else { + channel.exec(true, command).await.map_err(|e| self.failed(e))?; + } + let (mut read, write) = channel.split(); + let (tx, rx) = oneshot::channel(); + let mut on_output = on_output; + tokio::spawn(async move { + let mut code = None; + while let Some(msg) = read.wait().await { + match msg { + ChannelMsg::Data { data } => on_output(Output::Stdout(&data)), + ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)), + ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status), + _ => {} + } + } + let _ = tx.send(code); }); + Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)) }) + } + + /// An SFTP session on its own channel. + pub async fn sftp(&self) -> IdeResult { + let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; + channel.request_subsystem(true, "sftp").await.map_err(|e| self.failed(e))?; + russh_sftp::client::SftpSession::new(channel.into_stream()) + .await + .map_err(|e| io(format!("SFTP is not available on {}: {e}", self.label))) + } + + fn failed(&self, e: russh::Error) -> IdeError { + io(format!("SSH {}: {e}", self.label)) } } -fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError { - let why = match e { - russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => { - "connection refused or host unreachable" +/// A chunk of a remote process's output. +pub enum Output<'a> { + Stdout(&'a [u8]), + Stderr(&'a [u8]), +} + +/// A running remote command. +pub struct RemoteProcess { + write: Arc>, + exit: Mutex>>>, +} + +impl RemoteProcess { + pub async fn write(&self, data: &[u8]) -> IdeResult<()> { + self.write.data(data).await.map_err(|e| io(e.to_string())) + } + + /// Closes stdin. + pub async fn eof(&self) -> IdeResult<()> { + self.write.eof().await.map_err(|e| io(e.to_string())) + } + + pub async fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> { + self.write.window_change(u32::from(cols), u32::from(rows), 0, 0).await.map_err(|e| io(e.to_string())) + } + + /// Sends `INT` / `TERM` / `KILL`; servers that ignore signals get the channel closed. + pub async fn signal(&self, signal: Sig) -> IdeResult<()> { + let kill = matches!(signal, Sig::KILL); + let r = self.write.signal(signal).await; + if kill || r.is_err() { + let _ = self.write.close().await; } - russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => { - "port forwarding is not allowed by the SSH server (AllowTcpForwarding)" - } - _ => "", - }; - let why = if why.is_empty() { e.to_string() } else { why.to_owned() }; - io(format!("SSH host {via} cannot reach the database at {host}:{port}: {why}")) + Ok(()) + } + + /// Waits for the exit status (`None`: killed, or no status reported). Once. + pub async fn wait(&self) -> Option { + let rx = self.exit.lock().take()?; + rx.await.ok().flatten() + } } fn io(message: impl Into) -> IdeError { IdeError::new(ErrorKind::Io, message) } -// --- hosts --------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Eq)] -struct Hop { - host: String, - port: u16, - user: String, - identity_files: Vec, -} - -impl Hop { - fn label(&self) -> String { - format!("{}@{}:{}", self.user, self.host, self.port) - } -} - -fn home() -> Option { - dirs::home_dir() -} - -/// `~/x` → `$HOME/x`. -pub(crate) fn expand(path: &str) -> PathBuf { - match (path.strip_prefix("~/"), home()) { - (Some(rest), Some(h)) => h.join(rest), - _ => PathBuf::from(path), - } -} - -fn local_user() -> String { - std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default() -} - -/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults). -fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option) { - let c = config - .and_then(|p| russh_config::parse_path(p, alias).ok()) - .unwrap_or_else(|| russh_config::Config::default(alias)); - let identity_files = c.host_config.identity_file.clone().unwrap_or_default(); - let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files }; - (hop, c.host_config.proxy_jump.clone()) -} - -/// `[user@]host[:port]` of a `ProxyJump` entry. -fn jump_hop(config: Option<&Path>, spec: &str) -> Hop { - let (user, rest) = match spec.rsplit_once('@') { - Some((u, r)) => (Some(u), r), - None => (None, spec), - }; - let (host, port) = match rest.rsplit_once(':') { - Some((h, p)) if p.parse::().is_ok() => (h, p.parse().ok()), - _ => (rest, None), - }; - let (mut hop, _) = config_hop(config, host); - if let Some(u) = user { - hop.user = u.to_owned(); - } - if let Some(p) = port { - hop.port = p; - } - hop -} - -/// The SSH hosts to go through, the tunnel's host last. -fn resolve_hops(ssh: &DbSsh, config: Option<&Path>) -> IdeResult> { - let host = ssh.host.trim(); - if host.is_empty() { - return Err(IdeError::invalid("SSH host is empty")); - } - let (mut hop, proxy_jump) = if ssh.auth == DbSshAuth::OpenSsh { - config_hop(config, host) - } else { - (Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None) - }; - if let Some(p) = ssh.port { - hop.port = p; - } - if !ssh.user.trim().is_empty() { - hop.user = ssh.user.trim().to_owned(); - } - let mut hops: Vec = proxy_jump - .filter(|j| !j.eq_ignore_ascii_case("none")) - .map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect()) - .unwrap_or_default(); - hops.push(hop); - Ok(hops) -} - // --- connection ---------------------------------------------------------------- #[derive(Debug)] @@ -299,7 +318,7 @@ impl client::Handler for Client { } } -async fn connect(hop: &Hop, known_hosts: &Path, stream: Option) -> Result, OpenError> +async fn connect(hop: &Hop, known_hosts: &Path, stream: Option) -> Result, ConnectError> where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, { @@ -319,16 +338,16 @@ where }; result.map_err(|e| match issue.lock().take() { Some(HostKeyIssue::Unknown(key)) => { - OpenError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key })) + ConnectError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key })) } - Some(HostKeyIssue::Changed(line)) => OpenError::Other(IdeError::new( + Some(HostKeyIssue::Changed(line)) => ConnectError::Other(IdeError::new( ErrorKind::PermissionDenied, format!( "SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack", hop.host, hop.port ), )), - None => OpenError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))), + None => ConnectError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))), }) } @@ -376,21 +395,21 @@ async fn with_agent(handle: &mut Handle, user: &str) -> bool { async fn authenticate( handle: &mut Handle, hop: &Hop, - mode: DbSshAuth, + mode: SshAuth, key_file: &str, secret: Option<&str>, ) -> IdeResult<()> { let user = hop.user.as_str(); let failed = || denied(format!("SSH authentication failed for {}", hop.label())); match mode { - DbSshAuth::Password => { + SshAuth::Password => { let Some(password) = secret else { return Err(denied(format!("SSH password required for {}", hop.label()))); }; let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?; if r.success() { Ok(()) } else { Err(failed()) } } - DbSshAuth::KeyPair => { + SshAuth::KeyPair => { if key_file.trim().is_empty() { return Err(IdeError::invalid("SSH private key file is not set")); } @@ -398,7 +417,7 @@ async fn authenticate( let key = load_key(&path, secret)?; if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) } } - DbSshAuth::OpenSsh => { + SshAuth::OpenSsh => { if with_agent(handle, user).await { return Ok(()); } @@ -441,34 +460,3 @@ fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))), } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolves_aliases_and_proxy_jumps_from_ssh_config() { - let tmp = tempfile::tempdir().unwrap(); - let config = tmp.path().join("config"); - std::fs::write( - &config, - "Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump jump@gw.example.com:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n", - ) - .unwrap(); - let ssh = DbSsh { enabled: true, host: "db-bastion".into(), auth: DbSshAuth::OpenSsh, ..Default::default() }; - let hops = resolve_hops(&ssh, Some(&config)).unwrap(); - let labels: Vec = hops.iter().map(Hop::label).collect(); - assert_eq!(labels, ["jump@gw.example.com:2200", "edgeuser@edge.example.com:22", "ops@10.0.0.5:2222"]); - assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]); - - // Explicit fields win over the config. - let ssh = DbSsh { user: "me".into(), port: Some(22), ..ssh }; - assert_eq!(resolve_hops(&ssh, Some(&config)).unwrap()[2].label(), "me@10.0.0.5:22"); - - // Without OpenSSH mode the host is used as is. - let ssh = DbSsh { auth: DbSshAuth::Password, user: "u".into(), port: None, ..ssh }; - let hops = resolve_hops(&ssh, Some(&config)).unwrap(); - assert_eq!(hops.iter().map(Hop::label).collect::>(), ["u@db-bastion:22"]); - assert!(resolve_hops(&DbSsh::default(), None).is_err()); - } -} diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs new file mode 100644 index 000000000..4defd6189 --- /dev/null +++ b/crates/ide-ssh/src/sftp.rs @@ -0,0 +1,236 @@ +//! Remote files over SFTP. Remote paths are POSIX strings. + +use ide_core::{IdeError, IdeResult, error::ErrorKind}; +use russh_sftp::client::SftpSession; +use russh_sftp::client::error::Error as SftpError; +use russh_sftp::protocol::{FileAttributes, StatusCode}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::Path; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +/// A remote directory entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteEntry { + pub name: String, + /// Absolute remote path. + pub path: String, + pub is_dir: bool, + pub is_symlink: bool, + pub size: u64, + /// Seconds since the Unix epoch. + pub mtime: Option, + /// `rwxr-xr-x`. + pub permissions: String, +} + +/// `dir/name` without doubling the separator. +pub fn join(dir: &str, name: &str) -> String { + if name.starts_with('/') { + return name.to_owned(); + } + let dir = dir.trim_end_matches('/'); + if dir.is_empty() && !name.is_empty() { format!("/{name}") } else { format!("{dir}/{name}") } +} + +/// The parent of a remote path (`/` for top-level entries). +pub fn parent(path: &str) -> Option<&str> { + let trimmed = path.trim_end_matches('/'); + let i = trimmed.rfind('/')?; + Some(if i == 0 { "/" } else { &trimmed[..i] }) +} + +fn mode_string(mode: u32) -> String { + let bits = [(0o400, 'r'), (0o200, 'w'), (0o100, 'x'), (0o40, 'r'), (0o20, 'w'), (0o10, 'x'), (0o4, 'r'), (0o2, 'w'), (0o1, 'x')]; + bits.iter().map(|&(b, c)| if mode & b != 0 { c } else { '-' }).collect() +} + +fn entry(path: String, name: String, attrs: &FileAttributes, target_is_dir: bool) -> RemoteEntry { + RemoteEntry { + name, + path, + is_dir: attrs.is_dir() || target_is_dir, + is_symlink: attrs.is_symlink(), + size: attrs.size.unwrap_or(0), + mtime: attrs.mtime, + permissions: attrs.permissions.map(mode_string).unwrap_or_default(), + } +} + +fn err(path: &str, e: SftpError) -> IdeError { + let kind = match &e { + SftpError::Status(s) if s.status_code == StatusCode::NoSuchFile => ErrorKind::NotFound, + SftpError::Status(s) if s.status_code == StatusCode::PermissionDenied => ErrorKind::PermissionDenied, + _ => ErrorKind::Io, + }; + IdeError::new(kind, format!("{path}: {e}")) +} + +/// Higher-level operations on an [`SftpSession`]. +pub struct RemoteFs { + sftp: SftpSession, +} + +impl RemoteFs { + pub fn new(sftp: SftpSession) -> Self { + Self { sftp } + } + + /// The login directory. + pub async fn home(&self) -> IdeResult { + self.sftp.canonicalize(".").await.map_err(|e| err(".", e)) + } + + /// Absolute form of `path`; `~` and `~/…` are relative to the login directory. + pub async fn resolve(&self, path: &str) -> IdeResult { + let path = path.trim(); + if path.is_empty() || path == "~" { + return self.home().await; + } + if let Some(rest) = path.strip_prefix("~/") { + return Ok(join(&self.home().await?, rest)); + } + if path.starts_with('/') { + return Ok(path.to_owned()); + } + Ok(join(&self.home().await?, path)) + } + + pub async fn stat(&self, path: &str) -> IdeResult> { + match self.sftp.metadata(path).await { + Ok(a) => { + let name = path.rsplit('/').next().unwrap_or(path).to_owned(); + Ok(Some(entry(path.to_owned(), name, &a, false))) + } + Err(e) if err(path, e.clone()).kind == ErrorKind::NotFound => Ok(None), + Err(e) => Err(err(path, e)), + } + } + + /// Entries of `dir`, folders first, then by name; symlinks to folders count as folders. + pub async fn list(&self, dir: &str) -> IdeResult> { + let read = self.sftp.read_dir(dir).await.map_err(|e| err(dir, e))?; + let mut out = Vec::new(); + for e in read { + let name = e.file_name(); + if name == "." || name == ".." { + continue; + } + let path = join(dir, &name); + let attrs = e.metadata(); + let target_is_dir = + attrs.is_symlink() && self.sftp.metadata(path.as_str()).await.map(|m| m.is_dir()).unwrap_or(false); + out.push(entry(path, name, &attrs, target_is_dir)); + } + out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + Ok(out) + } + + pub async fn read(&self, path: &str) -> IdeResult> { + let mut file = self.sftp.open(path).await.map_err(|e| err(path, e))?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf).await?; + Ok(buf) + } + + /// Writes `data`, creating missing parent folders. + pub async fn write(&self, path: &str, data: &[u8], mtime: Option) -> IdeResult<()> { + if let Some(dir) = parent(path) { + self.mkdir_all(dir).await?; + } + let mut file = self.sftp.create(path).await.map_err(|e| err(path, e))?; + file.write_all(data).await?; + file.shutdown().await?; + if let Some(t) = mtime { + let attrs = FileAttributes { atime: Some(t), mtime: Some(t), ..FileAttributes::empty() }; + if let Err(e) = self.sftp.set_metadata(path, attrs).await { + tracing::debug!("SFTP: cannot set the time of {path}: {e}"); + } + } + Ok(()) + } + + pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> { + let data = tokio::fs::read(local).await?; + let mtime = if preserve_time { mtime_of(local) } else { None }; + self.write(remote, &data, mtime).await + } + + pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> { + let data = self.read(remote).await?; + if let Some(dir) = local.parent() { + tokio::fs::create_dir_all(dir).await?; + } + tokio::fs::write(local, data).await?; + Ok(()) + } + + /// `mkdir -p`. + pub async fn mkdir_all(&self, dir: &str) -> IdeResult<()> { + if dir.is_empty() || dir == "/" { + return Ok(()); + } + match self.sftp.metadata(dir).await { + Ok(a) if a.is_dir() => return Ok(()), + Ok(_) => return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{dir} is not a folder"))), + Err(_) => {} + } + if let Some(p) = parent(dir) { + Box::pin(self.mkdir_all(p)).await?; + } + match self.sftp.create_dir(dir).await { + Ok(()) => Ok(()), + // Created meanwhile by a concurrent upload. + Err(_) if self.sftp.metadata(dir).await.is_ok_and(|a| a.is_dir()) => Ok(()), + Err(e) => Err(err(dir, e)), + } + } + + /// Deletes a file, or a folder with its contents. + pub async fn remove(&self, path: &str) -> IdeResult<()> { + let attrs = self.sftp.symlink_metadata(path).await.map_err(|e| err(path, e))?; + if attrs.is_dir() { + for e in self.list(path).await? { + if e.is_dir && !e.is_symlink { + Box::pin(self.remove(&e.path)).await?; + } else { + self.sftp.remove_file(e.path.as_str()).await.map_err(|x| err(&e.path, x))?; + } + } + self.sftp.remove_dir(path).await.map_err(|e| err(path, e)) + } else { + self.sftp.remove_file(path).await.map_err(|e| err(path, e)) + } + } + + pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> { + self.sftp.rename(from, to).await.map_err(|e| err(from, e)) + } + + pub async fn close(&self) { + let _ = self.sftp.close().await; + } +} + +/// Seconds since the epoch of a local file's modification. +pub fn mtime_of(path: &Path) -> Option { + let t = std::fs::metadata(path).ok()?.modified().ok()?; + u32::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn joins_and_splits_paths() { + assert_eq!(join("/srv/app/", "a.txt"), "/srv/app/a.txt"); + assert_eq!(join("/", "etc"), "/etc"); + assert_eq!(join("/x", "/abs"), "/abs"); + assert_eq!(parent("/srv/app/a.txt"), Some("/srv/app")); + assert_eq!(parent("/etc"), Some("/")); + assert_eq!(parent("rel"), None); + assert_eq!(mode_string(0o755), "rwxr-xr-x"); + } +} diff --git a/crates/ide-ssh/src/shell.rs b/crates/ide-ssh/src/shell.rs new file mode 100644 index 000000000..de11468d1 --- /dev/null +++ b/crates/ide-ssh/src/shell.rs @@ -0,0 +1,68 @@ +//! POSIX shell command lines for the remote side. + +use std::collections::BTreeMap; + +/// Quotes `s` for a POSIX shell (`'…'`, with `'\''` for quotes inside). +pub fn quote(s: &str) -> String { + if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_./=:,+@%".contains(&b)) { + return s.to_owned(); + } + format!("'{}'", s.replace('\'', r"'\''")) +} + +/// `cd && exec env K=V … `. A program starting with `~/` +/// is left to the shell's tilde expansion. +pub fn command_line(cwd: Option<&str>, env: &BTreeMap, program: &str, args: &[String]) -> String { + let mut s = String::new(); + if let Some(dir) = cwd.filter(|d| !d.is_empty()) { + s.push_str("cd "); + s.push_str(&tilde_quote(dir)); + s.push_str(" && "); + } + s.push_str("exec "); + if !env.is_empty() { + s.push_str("env"); + for (k, v) in env { + s.push(' '); + s.push_str("e(&format!("{k}={v}"))); + } + s.push(' '); + } + s.push_str(&tilde_quote(program)); + for a in args { + s.push(' '); + s.push_str("e(a)); + } + s +} + +fn tilde_quote(path: &str) -> String { + match path.strip_prefix("~/") { + Some(rest) => format!("~/{}", quote(rest)), + None if path == "~" => "~".into(), + None => quote(path), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_only_when_needed() { + assert_eq!(quote("plain/path-1.txt"), "plain/path-1.txt"); + assert_eq!(quote("a b"), "'a b'"); + assert_eq!(quote("it's"), r"'it'\''s'"); + assert_eq!(quote(""), "''"); + } + + #[test] + fn builds_a_command_line() { + let env = BTreeMap::from([("A".to_owned(), "1 2".to_owned())]); + assert_eq!( + command_line(Some("~/proj dir"), &env, "python3", &["-u".into(), "main.py".into()]), + "cd ~/'proj dir' && exec env 'A=1 2' python3 -u main.py" + ); + assert_eq!(command_line(None, &BTreeMap::new(), "ls", &[]), "exec ls"); + } +} diff --git a/crates/ide-ssh/src/target.rs b/crates/ide-ssh/src/target.rs new file mode 100644 index 000000000..0fb75de75 --- /dev/null +++ b/crates/ide-ssh/src/target.rs @@ -0,0 +1,181 @@ +//! Where to connect: an [`SshTarget`] and the hosts to go through to reach it +//! (`~/.ssh/config` aliases and `ProxyJump` chains). + +use ide_core::remote_settings::{SshAuth, SshConfiguration}; +use ide_core::{IdeError, IdeResult}; +use std::path::{Path, PathBuf}; + +/// An SSH host and how to authenticate to it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SshTarget { + /// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`]. + pub host: String, + pub port: Option, + pub user: String, + pub auth: SshAuth, + /// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory. + pub key_file: String, +} + +impl From<&SshConfiguration> for SshTarget { + fn from(c: &SshConfiguration) -> Self { + Self { host: c.host.clone(), port: c.port, user: c.user.clone(), auth: c.auth, key_file: c.key_file.clone() } + } +} + +/// The user's OpenSSH files. +#[derive(Debug, Clone)] +pub struct SshFiles { + pub known_hosts: PathBuf, + /// `~/.ssh/config` (used with [`SshAuth::OpenSsh`]). + pub config: Option, +} + +impl Default for SshFiles { + fn default() -> Self { + let dir = home().unwrap_or_default().join(".ssh"); + Self { known_hosts: dir.join("known_hosts"), config: Some(dir.join("config")) } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Hop { + pub host: String, + pub port: u16, + pub user: String, + pub identity_files: Vec, +} + +impl Hop { + pub fn label(&self) -> String { + format!("{}@{}:{}", self.user, self.host, self.port) + } +} + +pub(crate) fn home() -> Option { + dirs::home_dir() +} + +/// `~/x` → `$HOME/x`. +pub fn expand(path: &str) -> PathBuf { + match (path.strip_prefix("~/"), home()) { + (Some(rest), Some(h)) => h.join(rest), + _ => PathBuf::from(path), + } +} + +fn local_user() -> String { + std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default() +} + +/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults). +fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option) { + let c = config + .and_then(|p| russh_config::parse_path(p, alias).ok()) + .unwrap_or_else(|| russh_config::Config::default(alias)); + let identity_files = c.host_config.identity_file.clone().unwrap_or_default(); + let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files }; + (hop, c.host_config.proxy_jump.clone()) +} + +/// `[user@]host[:port]` of a `ProxyJump` entry. +fn jump_hop(config: Option<&Path>, spec: &str) -> Hop { + let (user, rest) = match spec.rsplit_once('@') { + Some((u, r)) => (Some(u), r), + None => (None, spec), + }; + let (host, port) = match rest.rsplit_once(':') { + Some((h, p)) if p.parse::().is_ok() => (h, p.parse().ok()), + _ => (rest, None), + }; + let (mut hop, _) = config_hop(config, host); + if let Some(u) = user { + hop.user = u.to_owned(); + } + if let Some(p) = port { + hop.port = p; + } + hop +} + +/// The SSH hosts to go through, the target last. +pub(crate) fn resolve_hops(target: &SshTarget, config: Option<&Path>) -> IdeResult> { + let host = target.host.trim(); + if host.is_empty() { + return Err(IdeError::invalid("SSH host is empty")); + } + let (mut hop, proxy_jump) = if target.auth == SshAuth::OpenSsh { + config_hop(config, host) + } else { + (Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None) + }; + if let Some(p) = target.port { + hop.port = p; + } + if !target.user.trim().is_empty() { + hop.user = target.user.trim().to_owned(); + } + let mut hops: Vec = proxy_jump + .filter(|j| !j.eq_ignore_ascii_case("none")) + .map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect()) + .unwrap_or_default(); + hops.push(hop); + Ok(hops) +} + +/// `ssh` arguments that reach `target` (the system client: terminals, git). +pub fn openssh_args(target: &SshTarget) -> Vec { + let mut args = Vec::new(); + if let Some(p) = target.port { + args.extend(["-p".into(), p.to_string()]); + } + if target.auth == SshAuth::KeyPair && !target.key_file.trim().is_empty() { + args.extend(["-i".into(), expand(target.key_file.trim()).to_string_lossy().into_owned()]); + args.extend(["-o".into(), "IdentitiesOnly=yes".into()]); + } + if target.auth == SshAuth::Password { + args.extend(["-o".into(), "PreferredAuthentications=password,keyboard-interactive".into()]); + } + let host = target.host.trim(); + args.push(if target.user.trim().is_empty() { host.to_owned() } else { format!("{}@{host}", target.user.trim()) }); + args +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_aliases_and_proxy_jumps_from_ssh_config() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + std::fs::write( + &config, + "Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump jump@gw.example.com:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n", + ) + .unwrap(); + let target = SshTarget { host: "db-bastion".into(), auth: SshAuth::OpenSsh, ..Default::default() }; + let hops = resolve_hops(&target, Some(&config)).unwrap(); + let labels: Vec = hops.iter().map(Hop::label).collect(); + assert_eq!(labels, ["jump@gw.example.com:2200", "edgeuser@edge.example.com:22", "ops@10.0.0.5:2222"]); + assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]); + + // Explicit fields win over the config. + let target = SshTarget { user: "me".into(), port: Some(22), ..target }; + assert_eq!(resolve_hops(&target, Some(&config)).unwrap()[2].label(), "me@10.0.0.5:22"); + + // Without OpenSSH mode the host is used as is. + let target = SshTarget { auth: SshAuth::Password, user: "u".into(), port: None, ..target }; + let hops = resolve_hops(&target, Some(&config)).unwrap(); + assert_eq!(hops.iter().map(Hop::label).collect::>(), ["u@db-bastion:22"]); + assert!(resolve_hops(&SshTarget::default(), None).is_err()); + } + + #[test] + fn builds_openssh_arguments() { + let t = SshTarget { host: "box".into(), user: "me".into(), port: Some(2200), ..Default::default() }; + assert_eq!(openssh_args(&t), ["-p", "2200", "me@box"]); + let t = SshTarget { host: "box".into(), auth: SshAuth::KeyPair, key_file: "/k/id".into(), ..Default::default() }; + assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "box"]); + } +} diff --git a/crates/ide-ssh/src/testing.rs b/crates/ide-ssh/src/testing.rs new file mode 100644 index 000000000..8e3f10f80 --- /dev/null +++ b/crates/ide-ssh/src/testing.rs @@ -0,0 +1,248 @@ +//! An in-process SSH server for tests (feature `testing`): password `dev` / +//! `secret`, commands answered by a closure, and SFTP served from a local +//! folder (remote `/x` is `/x`). + +use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session}; +use russh::{Channel, ChannelId}; +use russh_sftp::protocol::{ + Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version, +}; +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +/// Reply of a command: (stdout, exit status). +pub type ExecFn = Arc (String, u32) + Send + Sync>; + +/// Starts the server; returns its port. `root` backs SFTP. +pub async fn sshd(root: PathBuf, exec: ExecFn) -> u16 { + let config = Arc::new(server::Config { + keys: vec![russh::keys::decode_secret_key(include_str!("../tests/fixtures/ssh/host"), None).expect("host key")], + auth_rejection_time: std::time::Duration::from_millis(10), + auth_rejection_time_initial: Some(std::time::Duration::ZERO), + ..Default::default() + }); + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let mut server = Sshd { root, exec }; + tokio::spawn(async move { + let _ = server.run_on_socket(config, &listener).await; + }); + port +} + +#[derive(Clone)] +struct Sshd { + root: PathBuf, + exec: ExecFn, +} + +impl server::Server for Sshd { + type Handler = Conn; + fn new_client(&mut self, _: Option) -> Conn { + Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default() } + } +} + +struct Conn { + root: PathBuf, + exec: ExecFn, + channels: Arc>>>, +} + +impl server::Handler for Conn { + type Error = russh::Error; + + async fn auth_password(&mut self, user: &str, password: &str) -> Result { + Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) + } + + async fn channel_open_session( + &mut self, + channel: Channel, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + self.channels.lock().await.insert(channel.id(), channel); + reply.accept().await; + Ok(()) + } + + async fn exec_request(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> { + let (out, code) = (self.exec)(&String::from_utf8_lossy(data)); + session.channel_success(channel)?; + session.data(channel, out.into_bytes())?; + session.exit_status_request(channel, code)?; + session.eof(channel)?; + session.close(channel)?; + Ok(()) + } + + async fn subsystem_request(&mut self, channel: ChannelId, name: &str, session: &mut Session) -> Result<(), Self::Error> { + let Some(ch) = self.channels.lock().await.remove(&channel).filter(|_| name == "sftp") else { + session.channel_failure(channel)?; + return Ok(()); + }; + session.channel_success(channel)?; + let sftp = Sftp { root: self.root.clone(), files: HashMap::new(), dirs: HashMap::new(), next: 0 }; + tokio::spawn(russh_sftp::server::run(ch.into_stream(), sftp)); + Ok(()) + } +} + +struct Sftp { + root: PathBuf, + files: HashMap, + /// Open directories: entries not yet sent. + dirs: HashMap>>, + next: u64, +} + +fn ok(id: u32) -> Status { + Status { id, status_code: StatusCode::Ok, error_message: "Ok".into(), language_tag: "en-US".into() } +} + +fn code(e: std::io::Error) -> StatusCode { + match e.kind() { + std::io::ErrorKind::NotFound => StatusCode::NoSuchFile, + std::io::ErrorKind::PermissionDenied => StatusCode::PermissionDenied, + _ => StatusCode::Failure, + } +} + +impl Sftp { + fn local(&self, path: &str) -> PathBuf { + path.split('/').filter(|s| !s.is_empty() && *s != "." && *s != "..").fold(self.root.clone(), |p, s| p.join(s)) + } + + fn handle(&mut self) -> String { + self.next += 1; + format!("h{}", self.next) + } + + fn attrs(path: &Path) -> Result { + std::fs::symlink_metadata(path).map(|m| FileAttributes::from(&m)).map_err(code) + } +} + +impl russh_sftp::server::Handler for Sftp { + type Error = StatusCode; + + fn unimplemented(&self) -> Self::Error { + StatusCode::OpUnsupported + } + + async fn init(&mut self, _version: u32, _ext: HashMap) -> Result { + Ok(Version::new()) + } + + async fn realpath(&mut self, id: u32, path: String) -> Result { + let p = if path == "." || path.is_empty() { "/home/dev".to_owned() } else { path }; + Ok(Name { id, files: vec![File::dummy(p)] }) + } + + async fn open(&mut self, id: u32, filename: String, pflags: OpenFlags, _attrs: FileAttributes) -> Result { + let path = self.local(&filename); + let file = std::fs::OpenOptions::new() + .read(pflags.contains(OpenFlags::READ)) + .write(pflags.contains(OpenFlags::WRITE)) + .create(pflags.contains(OpenFlags::CREATE)) + .truncate(pflags.contains(OpenFlags::TRUNCATE)) + .open(path) + .map_err(code)?; + let handle = self.handle(); + self.files.insert(handle.clone(), file); + Ok(Handle { id, handle }) + } + + async fn close(&mut self, id: u32, handle: String) -> Result { + self.files.remove(&handle); + self.dirs.remove(&handle); + Ok(ok(id)) + } + + async fn read(&mut self, id: u32, handle: String, offset: u64, len: u32) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(SeekFrom::Start(offset)).map_err(code)?; + let mut buf = vec![0; len as usize]; + let n = file.read(&mut buf).map_err(code)?; + if n == 0 { + return Err(StatusCode::Eof); + } + buf.truncate(n); + Ok(Data { id, data: buf }) + } + + async fn write(&mut self, id: u32, handle: String, offset: u64, data: Vec) -> Result { + let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?; + file.seek(SeekFrom::Start(offset)).map_err(code)?; + file.write_all(&data).map_err(code)?; + Ok(ok(id)) + } + + async fn stat(&mut self, id: u32, path: String) -> Result { + let p = self.local(&path); + let attrs = std::fs::metadata(&p).map(|m| FileAttributes::from(&m)).map_err(code)?; + Ok(Attrs { id, attrs }) + } + + async fn lstat(&mut self, id: u32, path: String) -> Result { + Ok(Attrs { id, attrs: Self::attrs(&self.local(&path))? }) + } + + async fn fstat(&mut self, id: u32, handle: String) -> Result { + let file = self.files.get(&handle).ok_or(StatusCode::Failure)?; + Ok(Attrs { id, attrs: FileAttributes::from(&file.metadata().map_err(code)?) }) + } + + async fn setstat(&mut self, id: u32, path: String, attrs: FileAttributes) -> Result { + if let Some(t) = attrs.mtime { + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.into()); + let f = std::fs::File::options().write(true).open(self.local(&path)).map_err(code)?; + f.set_modified(time).map_err(code)?; + } + Ok(ok(id)) + } + + async fn opendir(&mut self, id: u32, path: String) -> Result { + let dir = self.local(&path); + let mut files = Vec::new(); + for e in std::fs::read_dir(&dir).map_err(code)?.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + files.push(File::new(name, Self::attrs(&e.path())?)); + } + let handle = self.handle(); + self.dirs.insert(handle.clone(), Some(files)); + Ok(Handle { id, handle }) + } + + async fn readdir(&mut self, id: u32, handle: String) -> Result { + match self.dirs.get_mut(&handle).and_then(Option::take) { + Some(files) if !files.is_empty() => Ok(Name { id, files }), + _ => Err(StatusCode::Eof), + } + } + + async fn remove(&mut self, id: u32, filename: String) -> Result { + std::fs::remove_file(self.local(&filename)).map_err(code)?; + Ok(ok(id)) + } + + async fn mkdir(&mut self, id: u32, path: String, _attrs: FileAttributes) -> Result { + std::fs::create_dir(self.local(&path)).map_err(code)?; + Ok(ok(id)) + } + + async fn rmdir(&mut self, id: u32, path: String) -> Result { + std::fs::remove_dir(self.local(&path)).map_err(code)?; + Ok(ok(id)) + } + + async fn rename(&mut self, id: u32, oldpath: String, newpath: String) -> Result { + std::fs::rename(self.local(&oldpath), self.local(&newpath)).map_err(code)?; + Ok(ok(id)) + } +} diff --git a/crates/ide-ssh/src/tunnel.rs b/crates/ide-ssh/src/tunnel.rs new file mode 100644 index 000000000..c076ee2a3 --- /dev/null +++ b/crates/ide-ssh/src/tunnel.rs @@ -0,0 +1,80 @@ +//! Local port forwarding: a listener on 127.0.0.1 whose connections are +//! forwarded through `direct-tcpip` channels of a [`Session`]. + +use crate::session::Session; +use ide_core::{IdeError, IdeResult, error::ErrorKind}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +/// An open tunnel; dropping it stops forwarding (the session stays open while +/// other users hold it). +pub struct Tunnel { + port: u16, + session: Arc, + accept: JoinHandle<()>, +} + +impl Tunnel { + /// Checks that the SSH host reaches `host:port`, then listens on a free local port. + pub async fn open(session: Arc, host: &str, port: u16) -> IdeResult { + // Fail fast with the real reason when the SSH host cannot reach the target + // (a refused forward otherwise surfaces as the client's connect timeout). + session.direct_tcpip(host, port).await.map_err(|e| unreachable_target(session.label(), host, port, &e))?; + Self::listen(session, host, port).await + } + + /// [`Tunnel::open`] without the reachability check (the target may start later). + pub async fn listen(session: Arc, host: &str, port: u16) -> IdeResult { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local = listener.local_addr()?.port(); + let accept = tokio::spawn(forward(listener, session.clone(), host.to_owned(), port)); + Ok(Tunnel { port: local, session, accept }) + } + + /// The local port to connect to instead of the target. + pub fn port(&self) -> u16 { + self.port + } + + pub fn is_closed(&self) -> bool { + self.session.is_closed() + } +} + +impl Drop for Tunnel { + fn drop(&mut self) { + self.accept.abort(); + } +} + +async fn forward(listener: TcpListener, session: Arc, host: String, port: u16) { + loop { + let Ok((mut socket, _)) = listener.accept().await else { return }; + let session = session.clone(); + let host = host.clone(); + tokio::spawn(async move { + match session.direct_tcpip(&host, port).await { + Ok(mut stream) => { + let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await; + } + Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"), + } + }); + } +} + +/// Why the SSH host `via` cannot open a channel to `host:port`. +pub fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError { + let why = match e { + russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => { + "connection refused or host unreachable" + } + russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => { + "port forwarding is not allowed by the SSH server (AllowTcpForwarding)" + } + _ => "", + }; + let why = if why.is_empty() { e.to_string() } else { why.to_owned() }; + IdeError::new(ErrorKind::Io, format!("SSH host {via} cannot reach {host}:{port}: {why}")) +} diff --git a/crates/ide-db/tests/fixtures/ssh/client b/crates/ide-ssh/tests/fixtures/ssh/client similarity index 100% rename from crates/ide-db/tests/fixtures/ssh/client rename to crates/ide-ssh/tests/fixtures/ssh/client diff --git a/crates/ide-db/tests/fixtures/ssh/host b/crates/ide-ssh/tests/fixtures/ssh/host similarity index 100% rename from crates/ide-db/tests/fixtures/ssh/host rename to crates/ide-ssh/tests/fixtures/ssh/host diff --git a/crates/ide-db/tests/fixtures/ssh/locked b/crates/ide-ssh/tests/fixtures/ssh/locked similarity index 100% rename from crates/ide-db/tests/fixtures/ssh/locked rename to crates/ide-ssh/tests/fixtures/ssh/locked diff --git a/crates/ide-ssh/tests/session.rs b/crates/ide-ssh/tests/session.rs new file mode 100644 index 000000000..819037d1a --- /dev/null +++ b/crates/ide-ssh/tests/session.rs @@ -0,0 +1,256 @@ +//! Sessions against an in-process russh server that runs commands (a canned +//! reply) and forwards `direct-tcpip` channels to a local echo server. + +use ide_core::error::ErrorKind; +use ide_core::remote_settings::SshAuth; +use ide_ssh::{ConnectError, Output, Session, SshFiles, SshTarget, Tunnel}; +use parking_lot::Mutex; +use russh::keys::{PrivateKey, PublicKey, decode_secret_key}; +use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session as ServerSession}; +use russh::{Channel, ChannelId}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name) +} + +fn key(name: &str, passphrase: Option<&str>) -> PrivateKey { + decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap() +} + +#[derive(Clone)] +struct Sshd { + client_key: PublicKey, +} + +impl server::Server for Sshd { + type Handler = Sshd; + fn new_client(&mut self, _: Option) -> Sshd { + self.clone() + } +} + +impl server::Handler for Sshd { + type Error = russh::Error; + + async fn auth_password(&mut self, user: &str, password: &str) -> Result { + Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() }) + } + + async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result { + Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() }) + } + + async fn channel_open_session( + &mut self, + _channel: Channel, + reply: ChannelOpenHandle, + _session: &mut ServerSession, + ) -> Result<(), Self::Error> { + reply.accept().await; + Ok(()) + } + + /// `ran: ` on stdout, `oops` on stderr, exit status 3. + async fn exec_request( + &mut self, + channel: ChannelId, + data: &[u8], + session: &mut ServerSession, + ) -> Result<(), Self::Error> { + session.channel_success(channel)?; + session.data(channel, format!("ran: {}", String::from_utf8_lossy(data)).into_bytes())?; + session.extended_data(channel, 1, b"oops".to_vec())?; + session.exit_status_request(channel, 3)?; + session.eof(channel)?; + session.close(channel)?; + Ok(()) + } + + async fn channel_open_direct_tcpip( + &mut self, + channel: Channel, + host: &str, + port: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut ServerSession, + ) -> Result<(), Self::Error> { + let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else { + return Ok(()); + }; + reply.accept().await; + tokio::spawn(async move { + let mut stream = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await; + }); + Ok(()) + } +} + +/// Echo server standing in for a remote service. +async fn echo() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((mut s, _)) = listener.accept().await { + tokio::spawn(async move { + let (mut r, mut w) = s.split(); + let _ = tokio::io::copy(&mut r, &mut w).await; + }); + } + }); + port +} + +async fn sshd() -> u16 { + let config = Arc::new(server::Config { + keys: vec![key("host", None)], + auth_rejection_time: std::time::Duration::from_millis(10), + auth_rejection_time_initial: Some(std::time::Duration::ZERO), + ..Default::default() + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut server = Sshd { client_key: key("client", None).public_key().clone() }; + tokio::spawn(async move { + let _ = server.run_on_socket(config, &listener).await; + }); + port +} + +struct Env { + _tmp: tempfile::TempDir, + files: SshFiles, + ssh_port: u16, + echo_port: u16, +} + +async fn env() -> Env { + let tmp = tempfile::tempdir().unwrap(); + let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None }; + Env { files, _tmp: tmp, ssh_port: sshd().await, echo_port: echo().await } +} + +impl Env { + fn target(&self, auth: SshAuth) -> SshTarget { + SshTarget { + host: "127.0.0.1".into(), + port: Some(self.ssh_port), + user: "dev".into(), + auth, + ..Default::default() + } + } + + async fn connect(&self, target: &SshTarget, secret: Option<&str>) -> Result { + Session::connect(target, secret, &self.files).await + } + + /// Trusts the server's key on the first connection, as the UI does. + async fn connect_trusted(&self, target: &SshTarget, secret: Option<&str>) -> Result { + match self.connect(target, secret).await { + Err(ConnectError::UnknownHostKey(k)) => { + k.trust(&self.files.known_hosts).unwrap(); + self.connect(target, secret).await + } + other => other, + } + } +} + +async fn round_trip(t: &Tunnel) { + let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap(); + s.write_all(b"SELECT 1").await.unwrap(); + let mut buf = [0u8; 8]; + s.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"SELECT 1"); +} + +fn denied(r: Result) -> String { + match r { + Err(ConnectError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message, + Err(ConnectError::Other(e)) => panic!("expected permission denied, got {e:?}"), + Err(ConnectError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()), + Ok(_) => panic!("expected permission denied, connected"), + } +} + +#[tokio::test] +async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() { + let env = env().await; + let target = env.target(SshAuth::Password); + let Err(ConnectError::UnknownHostKey(k)) = env.connect(&target, Some("secret")).await else { + panic!("expected an unknown host key"); + }; + assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint()); + assert!(k.error().message.starts_with("Unknown SSH host key for 127.0.0.1:")); + k.trust(&env.files.known_hosts).unwrap(); + assert!(env.connect(&target, Some("secret")).await.is_ok()); + + // Another key recorded for the same host: refused, never offered for trust. + let other = key("client", None).public_key().to_openssh().unwrap(); + std::fs::write(&env.files.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap(); + let msg = denied(env.connect(&target, Some("secret")).await); + assert!(msg.contains("has changed"), "{msg}"); +} + +#[tokio::test] +async fn password_authentication_and_forwarding() { + let env = env().await; + let target = env.target(SshAuth::Password); + let session = Arc::new(env.connect_trusted(&target, Some("secret")).await.ok().unwrap()); + let tunnel = Tunnel::open(session.clone(), "127.0.0.1", env.echo_port).await.unwrap(); + round_trip(&tunnel).await; + round_trip(&tunnel).await; + assert!(!tunnel.is_closed()); + + assert!(denied(env.connect(&target, None).await).starts_with("SSH password required")); + assert!(denied(env.connect(&target, Some("wrong")).await).starts_with("SSH authentication failed")); + + let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port(); + let Err(e) = Tunnel::open(session, "127.0.0.1", closed).await else { panic!("expected an error") }; + assert!(e.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", e.message); +} + +#[tokio::test] +async fn key_pair_authentication_with_and_without_passphrase() { + let env = env().await; + let target = SshTarget { key_file: fixture("client").to_string_lossy().into(), ..env.target(SshAuth::KeyPair) }; + assert!(env.connect_trusted(&target, None).await.is_ok()); + + // An encrypted key the server does not accept: passphrase first, then the key is refused. + let locked = SshTarget { key_file: fixture("locked").to_string_lossy().into(), ..target.clone() }; + assert!(denied(env.connect(&locked, None).await).starts_with("SSH key passphrase required")); + assert!(denied(env.connect(&locked, Some("nope")).await).contains("wrong passphrase")); + assert!(denied(env.connect(&locked, Some("pw")).await).starts_with("SSH authentication failed")); +} + +#[tokio::test] +async fn runs_commands_and_streams_processes() { + let env = env().await; + let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap(); + let out = session.exec("uname -a", None).await.unwrap(); + assert_eq!((out.code, out.stdout_str().as_str(), out.stderr.as_slice()), (Some(3), "ran: uname -a", &b"oops"[..])); + assert!(out.into_stdout("uname").unwrap_err().message.contains("oops")); + + let seen: Arc> = Arc::default(); + let s = seen.clone(); + let process = session + .spawn( + "python3 main.py", + None, + Box::new(move |o| match o { + Output::Stdout(b) => s.lock().0.push_str(&String::from_utf8_lossy(b)), + Output::Stderr(b) => s.lock().1.push_str(&String::from_utf8_lossy(b)), + }), + ) + .await + .unwrap(); + assert_eq!(process.wait().await, Some(3)); + assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned())); +} diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index 97f687c63..b80ef4c0a 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -19,6 +19,14 @@ pub fn set_git_executable(path: Option<&Path>) { } } +/// Environment that routes credential prompts (`ssh` passphrases and host keys, +/// HTTPS user names and passwords) to the UI; empty = prompts fail. +static ASKPASS: std::sync::RwLock> = std::sync::RwLock::new(Vec::new()); + +pub fn set_askpass_env(env: Vec<(String, String)>) { + *ASKPASS.write().unwrap_or_else(|e| e.into_inner()) = env; +} + /// Locates the auto-detected git executable once per process. /// /// GUI apps on macOS start with a minimal `PATH`, so a few well-known install @@ -95,6 +103,9 @@ pub(crate) fn command(root: &Path) -> IdeResult { .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE"); + for (k, v) in ASKPASS.read().unwrap_or_else(|e| e.into_inner()).iter() { + cmd.env(k, v); + } ide_core::process::no_window(&mut cmd); Ok(cmd) } diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index cb3774434..77af90a75 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -452,7 +452,7 @@ pub mod shelf; mod status; mod write; -pub use cli::{detected_git_executable, git_version, set_git_executable}; +pub use cli::{detected_git_executable, git_version, set_askpass_env, set_git_executable}; pub use diff::build_partial_patch; pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree}; pub use remotes::Remote; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e9f3d7f99..daeb60306 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -56,6 +56,8 @@ ide-http.workspace = true ide-db.workspace = true ide-web.workspace = true ide-spell.workspace = true +ide-ssh.workspace = true +ide-remote.workspace = true ide-tools = { path = "../crates/ide-tools" } ide-plugin = { path = "../crates/ide-plugin" } ide-docker = { path = "../crates/ide-docker" } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 4a48827e0..2885097ec 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -184,6 +184,9 @@ pub fn settings_set( } }); } + if key == "ssh" || key.starts_with("ssh.") { + crate::commands::ssh::apply_ssh_settings(&state); + } // A new interpreter / toolchain / server executable restarts the language servers it affects. if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) { let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() }; @@ -254,6 +257,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P let lsp = project.lsp.clone(); tauri::async_runtime::spawn(async move { lsp.reconfigure().await }); } + crate::commands::ssh::apply_ssh_settings(&state); state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() }); let settings = match state.project(window.label()) { Ok(p) => p.settings.effective(), diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs new file mode 100644 index 000000000..00a6ce185 --- /dev/null +++ b/src-tauri/src/commands/deploy.rs @@ -0,0 +1,185 @@ +use crate::state::AppState; +use ide_core::{IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId}; +use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport}; +use ide_ssh::RemoteEntry; +use std::path::PathBuf; +use tauri::{State, Window}; + +/// Reports a transfer as a background task (status bar progress). +fn progress(sink: SharedSink, title: &str) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) { + let id = TaskId::next(); + let title = title.to_owned(); + let (s, t) = (sink.clone(), title.clone()); + let report = move |path: &str, i: usize, n: usize| { + let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) }; + s.emit(IdeEvent::Task { id, title: t.clone(), message: Some(path.to_owned()), fraction, done: false }); + }; + let done = move || sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true }); + (report, done) +} + +fn local(paths: Vec) -> Vec { + paths.into_iter().map(|p| p.to_path_buf()).collect() +} + +/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). +#[tauri::command] +#[specta::specta] +pub async fn deploy_upload( + window: Window, + state: State<'_, AppState>, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Uploading"); + let r = state + .remote + .upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Download from…: the remote counterparts of files and folders. +#[tauri::command] +#[specta::specta] +pub async fn deploy_download( + window: Window, + state: State<'_, AppState>, + paths: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Downloading"); + let r = state + .remote + .download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Deletes the remote counterparts of files and folders. +#[tauri::command] +#[specta::specta] +pub async fn deploy_delete_remote( + window: Window, + state: State<'_, AppState>, + paths: Vec, + server: Option, +) -> IdeResult { + let project = state.project(window.label())?; + state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await +} + +/// Sync with Deployed: local and remote files under `path`, side by side. +#[tauri::command] +#[specta::specta] +pub async fn deploy_compare( + window: Window, + state: State<'_, AppState>, + path: IpcPath, + server: Option, + request: RequestId, +) -> IdeResult> { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let r = state + .remote + .compare(&project.settings.effective(), &project.root, server.as_deref(), &path.to_path_buf(), token) + .await; + state.end_request(request); + r +} + +/// Applies the Sync with Deployed choices. +#[tauri::command] +#[specta::specta] +pub async fn deploy_sync( + window: Window, + state: State<'_, AppState>, + steps: Vec, + server: Option, + request: RequestId, +) -> IdeResult { + let project = state.project(window.label())?; + let token = state.begin_request(request); + let (mut report, done) = progress(project.sink.clone(), "Synchronizing"); + let r = state + .remote + .sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report) + .await; + done(); + state.end_request(request); + r +} + +/// Where `path` is deployed (remote path, web URL); `None` when not mapped. +#[tauri::command] +#[specta::specta] +pub async fn deploy_info( + window: Window, + state: State<'_, AppState>, + path: IpcPath, + server: Option, +) -> IdeResult> { + let project = state.project(window.label())?; + state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path.to_path_buf()).await +} + +/// Remote Host tool window: the server root of `server`, resolved on the host. +#[tauri::command] +#[specta::specta] +pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult { + let settings = match state.project(window.label()) { + Ok(p) => p.settings.effective(), + Err(_) => state.global_settings.effective(), + }; + state.remote.server_root(&settings, &server).await +} + +/// Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). +#[tauri::command] +#[specta::specta] +pub async fn remote_list(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult> { + state.remote.list(&ssh, &path).await +} + +/// Opens a remote file for editing: a local copy, uploaded back on save. +#[tauri::command] +#[specta::specta] +pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult { + Ok(state.remote.open_remote(&ssh, &path).await?.into()) +} + +/// The remote file a local copy stands for: (SSH configuration id, remote path). +#[tauri::command] +#[specta::specta] +pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> { + state.remote.remote_of(&path.to_path_buf()) +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_delete(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { + state.remote.remote_delete(&ssh, &path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_mkdir(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> { + state.remote.remote_mkdir(&ssh, &path).await +} + +#[tauri::command] +#[specta::specta] +pub async fn remote_rename(state: State<'_, AppState>, ssh: String, from: String, to: String) -> IdeResult<()> { + state.remote.remote_rename(&ssh, &from, &to).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8a9f04df1..bb937ab56 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod clipboard; pub mod coverage; pub mod db; pub mod debug; +pub mod deploy; pub mod doc; pub mod docker; pub mod fs; @@ -32,6 +33,7 @@ pub mod run; pub mod sdk; pub mod search; pub mod spell; +pub mod ssh; pub mod syntax; pub mod term; pub mod tools; @@ -356,6 +358,27 @@ pub fn builder() -> tauri_specta::Builder { db::db_set_password, db::db_set_ssh_secret, db::db_trust_ssh_host, + ssh::ssh_test, + ssh::ssh_set_secret, + ssh::ssh_needs_secret, + ssh::ssh_trust_host, + ssh::ssh_connected, + ssh::ssh_disconnect, + ssh::ssh_askpass_answer, + ssh::ssh_terminal_command, + deploy::deploy_upload, + deploy::deploy_download, + deploy::deploy_delete_remote, + deploy::deploy_compare, + deploy::deploy_sync, + deploy::deploy_info, + deploy::deploy_server_root, + deploy::remote_list, + deploy::remote_open, + deploy::remote_of, + deploy::remote_delete, + deploy::remote_mkdir, + deploy::remote_rename, db::db_test, db::db_connected, db::db_connect, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index a95d00ed1..99ce8f03d 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -50,7 +50,7 @@ pub fn run_system_env() -> std::collections::BTreeMap { #[tauri::command] #[specta::specta] -pub fn run_start( +pub async fn run_start( window: Window, state: State<'_, AppState>, config: RunConfig, @@ -61,6 +61,22 @@ pub fn run_start( if config.command.is_empty() { return Err(IdeError::invalid("run configuration has no command")); } + // Settings › Run Targets: over SSH. + if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { + let remote = state.remote.clone(); + let settings = project.settings.effective(); + return remote + .run( + &settings, + &root, + &config, + project.sink.clone(), + Box::new(move |chunk| { + let _ = on_output.send(chunk); + }), + ) + .await; + } let encoding = project.settings.effective().console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) }; state.processes.start( @@ -82,12 +98,18 @@ pub fn run_stop(window: Window, state: State<'_, AppState>, id: RunId) -> IdeRes Err(_) => state.global_settings.effective(), }; let grace = std::time::Duration::from_millis(u64::from(settings.run.termination_timeout_ms)); + if state.remote.runs.contains(id) { + return state.remote.runs.stop(id, grace); + } state.processes.stop_with_grace(id, grace) } #[tauri::command] #[specta::specta] pub fn run_stdin(state: State<'_, AppState>, id: RunId, data: String) -> IdeResult<()> { + if state.remote.runs.contains(id) { + return state.remote.runs.write_stdin(id, &data); + } state.processes.write_stdin(id, &data) } @@ -95,18 +117,26 @@ pub fn run_stdin(state: State<'_, AppState>, id: RunId, data: String) -> IdeResu #[tauri::command] #[specta::specta] pub fn run_resize(state: State<'_, AppState>, id: RunId, cols: u16, rows: u16) -> IdeResult<()> { + if state.remote.runs.contains(id) { + return state.remote.runs.resize(id, cols, rows); + } state.processes.resize(id, cols, rows) } #[tauri::command] #[specta::specta] pub fn run_list(state: State<'_, AppState>) -> Vec { - state.processes.list() + let mut runs = state.processes.list(); + runs.extend(state.remote.runs.list()); + runs } #[tauri::command] #[specta::specta] pub fn run_close_stdin(state: State<'_, AppState>, id: RunId) -> IdeResult<()> { + if state.remote.runs.contains(id) { + return state.remote.runs.close_stdin(id); + } state.processes.close_stdin(id) } diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs new file mode 100644 index 000000000..a5f3314cb --- /dev/null +++ b/src-tauri/src/commands/ssh.rs @@ -0,0 +1,92 @@ +use crate::state::AppState; +use ide_core::remote_settings::SshConfiguration; +use ide_core::{IdeError, IdeEvent, IdeResult}; +use ide_ssh::SshTarget; +use serde::{Deserialize, Serialize}; +use specta::Type; +use tauri::State; + +/// Settings › SSH Configurations: the configurations in effect. +pub(crate) fn apply_ssh_settings(state: &AppState) { + state.ssh.set_configs(state.global_settings.effective().ssh.configs); +} + +/// Test Connection: connects with `config` as edited (not necessarily saved). +#[tauri::command] +#[specta::specta] +pub async fn ssh_test(state: State<'_, AppState>, config: SshConfiguration, secret: Option) -> IdeResult { + let ssh = state.ssh.clone(); + ssh.test(&config, secret).await +} + +/// Remembers the password / passphrase of configuration `id` (`None` forgets it). +#[tauri::command] +#[specta::specta] +pub fn ssh_set_secret(state: State<'_, AppState>, id: String, secret: Option, save: bool) { + state.ssh.set_secret(&id, secret, save); +} + +/// Whether connecting with configuration `id` needs a password first. +#[tauri::command] +#[specta::specta] +pub fn ssh_needs_secret(state: State<'_, AppState>, id: String) -> IdeResult { + Ok(state.ssh.needs_secret(&state.ssh.config(&id)?)) +} + +/// Trusts the host key that failed the last connection of configuration `id`. +#[tauri::command] +#[specta::specta] +pub async fn ssh_trust_host(state: State<'_, AppState>, id: String) -> IdeResult<()> { + let ssh = state.ssh.clone(); + ssh.trust_host(&id).await +} + +/// Ids of the configurations with an open connection. +#[tauri::command] +#[specta::specta] +pub fn ssh_connected(state: State<'_, AppState>) -> Vec { + state.ssh.connected() +} + +#[tauri::command] +#[specta::specta] +pub fn ssh_disconnect(state: State<'_, AppState>, id: String) { + state.ssh.disconnect(&id); +} + +/// Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it. +#[tauri::command] +#[specta::specta] +pub fn ssh_askpass_answer(state: State<'_, AppState>, id: u64, answer: Option) { + if let Some(server) = &state.askpass { + server.answer(id, answer); + } + state.sink.emit(IdeEvent::AskpassDone { id }); +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct SshTerminalCommand { + pub shell: String, + pub args: Vec, + pub title: String, +} + +/// Tools › Start SSH Session: the system `ssh` command line for configuration `id` +/// (run in a terminal tab, so prompts and host-key questions are answered there). +#[tauri::command] +#[specta::specta] +pub fn ssh_terminal_command(state: State<'_, AppState>, id: String) -> IdeResult { + let config = state.ssh.config(&id)?; + if config.host.trim().is_empty() { + return Err(IdeError::invalid(format!("SSH configuration {} has no host", config.name))); + } + let path = std::env::var_os("PATH").unwrap_or_default(); + let shell = ide_run::which::find_executable("ssh", &path) + .map(|p| p.to_string_lossy().into_owned()) + .ok_or_else(|| IdeError::unavailable("ssh is not installed (OpenSSH client not found on PATH)"))?; + let mut args = vec!["-t".to_owned()]; + args.extend(ide_ssh::openssh_args(&SshTarget::from(&config))); + let title = if config.name.trim().is_empty() { config.label() } else { config.name.clone() }; + Ok(SshTerminalCommand { shell, args, title }) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 79b345178..661b614e7 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,9 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Started by `ssh` / `git` as their askpass program: answer and exit, no UI. + if let Some(code) = ide_ssh::askpass::run_helper() { + std::process::exit(code); + } openidea_lib::run() } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f8dd64db4..48885b29c 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,5 +1,5 @@ use crate::events::TauriSink; -use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, RequestId, SharedSink}; +use ide_core::{CancellationToken, EventSink, IdeError, IdeEvent, IdeResult, RequestId, SharedSink}; use ide_dap::DebugManager; use ide_index::ProjectIndex; use ide_language::{FileTypes, LanguageRegistry, Syntax, SyntaxCache}; @@ -39,6 +39,12 @@ pub struct AppState { pub plugins: Arc, /// The built-in web server (Open In › Browser, the editor's browser bar). pub web: ide_web::WebServer, + /// SSH connections of Settings › SSH Configurations, shared by all windows. + pub ssh: Arc, + /// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start). + pub askpass: Option, + /// SFTP deployment, remote file editing and runs on run targets. + pub remote: Arc, /// Broadcasts to every window. pub sink: SharedSink, /// One project per window, keyed by window label. @@ -118,9 +124,15 @@ impl AppState { } let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None }); let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?); + let global_settings = SettingsStore::load(&config_dir, None); + let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); + ssh.set_configs(global_settings.effective().ssh.configs); Ok(Self { recent: RecentProjects::load(&config_dir), - global_settings: SettingsStore::load(&config_dir, None), + global_settings, + remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), + ssh, + askpass: start_askpass(&app), // Sessions, runs and terminals report to the window that started them (`window_sink`). terminals: TerminalManager::new(), processes: ProcessManager::new(), @@ -302,6 +314,12 @@ impl AppState { documents.add_observer(Arc::new(SyntaxObserver(syntax.clone()))); documents.add_observer(Arc::new(LspObserver(lsp.clone()))); documents.add_observer(Arc::new(HistoryObserver(history.clone()))); + documents.add_observer(Arc::new(DeployObserver { + remote: self.remote.clone(), + settings: settings.clone(), + root: root.clone(), + sink: sink.clone(), + })); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); let watcher = { @@ -313,6 +331,7 @@ impl AppState { let root = root.clone(); let detected_roots = detected_roots.clone(); let history = history.clone(); + let remote = self.remote.clone(); Watcher::start( &root.clone(), excludes.clone(), @@ -323,6 +342,9 @@ impl AppState { // Atomic saves by external tools surface as `created`. documents.on_disk_changed(&changed); record_external_changes(&history, &changed, &batch.removed); + if !batch.removed.is_empty() { + delete_deployed(remote.clone(), settings.clone(), root.clone(), batch.removed.clone(), sink.clone()); + } // Build files / conventional folders changed: re-detect roots. let touched = changed.iter().chain(&batch.removed).filter_map(|p| p.strip_prefix(&root).ok()); if touched.into_iter().any(ide_workspace::roots::affects_roots) { @@ -401,6 +423,7 @@ impl AppState { root.clone(), self.data_dir.join("db-passwords.json"), self.data_dir.join("db-history.json"), + self.ssh.clone(), )); let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key))); let project = Arc::new(Project { @@ -814,3 +837,69 @@ impl DocumentObserver for HistoryObserver { } } } + +/// Uploads saved files: remote files' local copies, and automatic upload to the default server. +struct DeployObserver { + remote: Arc, + settings: Arc, + root: PathBuf, + sink: SharedSink, +} + +impl DocumentObserver for DeployObserver { + fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + let (remote, settings, root, sink, path) = + (self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf()); + tauri::async_runtime::spawn(async move { + if let Some(result) = remote.on_saved(&settings.effective(), &root, &path).await { + notify_transfer(&sink, "Upload", &path, result); + } + }); + } +} + +fn delete_deployed( + remote: Arc, + settings: Arc, + root: PathBuf, + removed: Vec, + sink: SharedSink, +) { + tauri::async_runtime::spawn(async move { + if let Some(result) = remote.on_deleted(&settings.effective(), &root, &removed).await { + let first = removed.first().cloned().unwrap_or_default(); + notify_transfer(&sink, "Delete on server", &first, result); + } + }); +} + +/// Reports a failed automatic transfer (successes stay quiet). +fn notify_transfer(sink: &SharedSink, what: &str, path: &Path, result: IdeResult) { + let body = match result { + Ok(r) if r.errors.is_empty() => return, + Ok(r) => r.errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect::>().join("\n"), + Err(e) => e.message, + }; + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + sink.emit(IdeEvent::Notify { level: ide_core::event::NotifyLevel::Error, title: format!("{what} failed: {name}"), body: Some(body) }); +} + +/// Serves askpass prompts to the focused window and routes `git`'s prompts there. +fn start_askpass(app: &AppHandle) -> Option { + let exe = std::env::current_exe().ok()?; + let handle = app.clone(); + let on_prompt = Arc::new(move |p: ide_ssh::askpass::AskpassPrompt| { + let window = crate::windows::target_window(&handle).map(|w| w.label().to_owned()); + TauriSink { app: handle.clone(), window }.emit(IdeEvent::Askpass { id: p.id, prompt: p.prompt, kind: p.kind }); + }); + match ide_ssh::askpass::AskpassServer::start(exe, on_prompt) { + Ok(server) => { + ide_vcs::set_askpass_env(server.env()); + Some(server) + } + Err(e) => { + tracing::warn!("askpass unavailable: {}", e.message); + None + } + } +} diff --git a/src/bindings.ts b/src/bindings.ts index 765fcc442..7723000fb 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -755,6 +755,51 @@ export const commands = { * "Unknown SSH host key" (records it in `~/.ssh/known_hosts`). */ dbTrustSshHost: (id: string) => typedError(__TAURI_INVOKE("db_trust_ssh_host", { id })), + /** Test Connection: connects with `config` as edited (not necessarily saved). */ + sshTest: (config: SshConfiguration, secret: string | null) => typedError(__TAURI_INVOKE("ssh_test", { config, secret })), + /** Remembers the password / passphrase of configuration `id` (`None` forgets it). */ + sshSetSecret: (id: string, secret: string | null, save: boolean) => __TAURI_INVOKE("ssh_set_secret", { id, secret, save }), + /** Whether connecting with configuration `id` needs a password first. */ + sshNeedsSecret: (id: string) => typedError(__TAURI_INVOKE("ssh_needs_secret", { id })), + /** Trusts the host key that failed the last connection of configuration `id`. */ + sshTrustHost: (id: string) => typedError(__TAURI_INVOKE("ssh_trust_host", { id })), + /** Ids of the configurations with an open connection. */ + sshConnected: () => __TAURI_INVOKE("ssh_connected"), + sshDisconnect: (id: string) => __TAURI_INVOKE("ssh_disconnect", { id }), + /** Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it. */ + sshAskpassAnswer: (id: number, answer: string | null) => __TAURI_INVOKE("ssh_askpass_answer", { id, answer }), + /** + * Tools › Start SSH Session: the system `ssh` command line for configuration `id` + * (run in a terminal tab, so prompts and host-key questions are answered there). + */ + sshTerminalCommand: (id: string) => typedError(__TAURI_INVOKE("ssh_terminal_command", { id })), + /** Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). */ + deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_upload", { paths, server, request })), + /** Download from…: the remote counterparts of files and folders. */ + deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_download", { paths, server, request })), + /** Deletes the remote counterparts of files and folders. */ + deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError(__TAURI_INVOKE("deploy_delete_remote", { paths, server })), + /** Sync with Deployed: local and remote files under `path`, side by side. */ + deployCompare: (path: IpcPath, server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_compare", { path, server, request })), + /** Applies the Sync with Deployed choices. */ + deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError(__TAURI_INVOKE("deploy_sync", { steps, server, request })), + /** Where `path` is deployed (remote path, web URL); `None` when not mapped. */ + deployInfo: (path: IpcPath, server: string | null) => typedError<{ + server: string, + remotePath: string, + webUrl: string | null, +} | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })), + /** Remote Host tool window: the server root of `server`, resolved on the host. */ + deployServerRoot: (server: string) => typedError(__TAURI_INVOKE("deploy_server_root", { server })), + /** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */ + remoteList: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_list", { ssh, path })), + /** Opens a remote file for editing: a local copy, uploaded back on save. */ + remoteOpen: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_open", { ssh, path })), + /** The remote file a local copy stands for: (SSH configuration id, remote path). */ + remoteOf: (path: IpcPath) => __TAURI_INVOKE<[string, string] | null>("remote_of", { path }), + remoteDelete: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_delete", { ssh, path })), + remoteMkdir: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { ssh, path })), + remoteRename: (ssh: string, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { ssh, from, to })), /** Test Connection: connects with unsaved settings and returns the server version. */ dbTest: (source: DataSource, password: string | null, sshSecret: string | null) => typedError(__TAURI_INVOKE("db_test", { source, password, sshSecret })), dbConnected: () => typedError(__TAURI_INVOKE("db_connected")), @@ -897,6 +942,15 @@ export type ApplyOutcome = { conflicts: string[], }; +/** What an askpass prompt asks for. */ +export type AskpassKind = +/** Password or passphrase: hidden input. */ +"secret" | +/** User name: visible input. */ +"text" | +/** Yes / no (an unknown host key); answered with `yes` or `no`. */ +"confirm"; + export type AttachKind = "java" | "node" | "python" | "go" | "native"; export type AttachableProcess = { @@ -918,6 +972,13 @@ export type AutoImportSettings = { optimizeOnTheFly?: boolean, }; +/** When local changes are uploaded to the default deployment server. */ +export type AutoUpload = "never" | +/** Files saved with Save / Save All (⌘S). */ +"onExplicitSave" | +/** Every save, including autosave and external changes. */ +"always"; + /** A step run before a configuration launches; a failing step aborts the launch. */ export type BeforeLaunchTask = { /** @@ -1682,24 +1743,19 @@ export type DbSortKey = { /** Connect through an SSH tunnel: the database host and port are reached from `host`. */ export type DbSsh = { enabled?: boolean, + /** A Settings › SSH Configurations id to connect with; empty = the fields below. */ + config?: string, /** Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`]. */ host?: string, port?: number | null, user?: string, - auth?: DbSshAuth, + auth?: SshAuth, /** Private key for [`DbSshAuth::KeyPair`]; `~/` is the home directory. */ keyFile?: string, /** Keep the SSH password / key passphrase in the saved passwords. */ saveSecret?: boolean, }; -/** How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type"). */ -export type DbSshAuth = "password" | -/** A private key file, optionally protected by a passphrase. */ -"keyPair" | -/** `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. */ -"openSsh"; - export type DbSslMode = "disable" | "prefer" | "require" | /** Require, and check the server certificate against the CA file. */ "verifyCa" | @@ -1798,6 +1854,51 @@ export type DeletedBranch = { unmerged: boolean, }; +/** Where a local file is deployed. */ +export type DeployedPath = { + server: string, + remotePath: string, + webUrl: string | null, +}; + +export type DeploymentMapping = { + /** Project-relative folder; empty = the project root. */ + local?: string, + /** Remote folder, relative to the server root (a leading `/` is the root too). */ + deployment?: string, + /** Web path, relative to the server's URL. */ + web?: string, +}; + +/** An SFTP server and how project folders map onto it. */ +export type DeploymentServer = { + id?: string, + name?: string, + /** [`SshConfiguration::id`]. */ + sshConfig?: string, + /** Absolute remote folder the mappings are relative to; empty = the login directory. */ + rootPath?: string, + /** Web server URL of the root (Open in Browser on the deployed file). */ + webUrl?: string, + mappings?: DeploymentMapping[], + /** Remote paths (relative to the root) never touched. */ + excludedPaths?: string[], +}; + +/** Build, Execution, Deployment › Deployment (and its Options page). */ +export type DeploymentSettings = { + servers?: DeploymentServer[], + /** Server used by Upload / Download / Sync and automatic upload; empty = none. */ + defaultServer?: string, + autoUpload?: AutoUpload, + /** Delete remote files when their local file is deleted (automatic upload). */ + deleteRemote?: boolean, + /** Keep local modification times on uploaded files. */ + preserveTimestamps?: boolean, + /** File names never uploaded or downloaded (`*` and `?` wildcards; `;`-separated in the UI). */ + exclude?: string[], +}; + export type Diagnostic = { range: Range, severity: Severity, @@ -2386,6 +2487,11 @@ export type FileSettings = { fileTypeOverrides?: { [key in string]: string }, }; +export type FileStamp = { + size: number, + mtime: number | null, +}; + export type FileStatus = { path: IpcPath, relPath: string, @@ -2731,7 +2837,11 @@ export type IdeEvent = * The project's source / test / resource / excluded roots changed (build files * changed or folders were marked); the UI re-reads them with `project_roots`. */ -{ type: "projectRootsChanged"; root: IpcPath }; +{ type: "projectRootsChanged"; root: IpcPath } | +/** A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`. */ +{ type: "askpass"; id: number; prompt: string; kind: AskpassKind } | +/** Prompt `id` was answered (in any window) or gave up. */ +{ type: "askpassDone"; id: number }; /** An identifier-like token whose text equals the searched name. */ export type IdentifierOccurrence = { @@ -3975,6 +4085,20 @@ export type RemoteEdit = { op: "add"; name: string; url: string } | { op: "remov /** Renames `name` to `new_name` (when different) and sets its URL. */ { op: "edit"; name: string; new_name: string; url: string }; +/** A remote directory entry. */ +export type RemoteEntry = { + name: string, + /** Absolute remote path. */ + path: string, + isDir: boolean, + isSymlink: boolean, + size: number, + /** Seconds since the Unix epoch. */ + mtime: number | null, + /** `rwxr-xr-x`. */ + permissions: string, +}; + export type RemoteOp = { op: "fetch" } | { op: "pull"; rebase: boolean } | { op: "push"; force_with_lease: boolean; set_upstream: boolean; /** Push dialog › Push tags. */ tags?: PushTags | null } | @@ -4121,6 +4245,8 @@ export type RunExtras_Deserialize = { * with the run's working directory and environment. */ stopCommand?: string | null, + /** Settings › Run Targets id the process runs on (over SSH); `None` = this machine. */ + target?: string | null, }; export type RunExtras_Serialize = { @@ -4156,6 +4282,8 @@ export type RunExtras_Serialize = { * with the run's working directory and environment. */ stopCommand?: string | null, + /** Settings › Run Targets id the process runs on (over SSH); `None` = this machine. */ + target?: string | null, }; /** A process started from a run configuration. */ @@ -4181,6 +4309,22 @@ export type RunSettings = { terminationTimeoutMs?: number, }; +/** Build, Execution, Deployment › Run Targets: run configurations on another machine. */ +export type RunTarget = { + id?: string, + name?: string, + /** [`SshConfiguration::id`]. */ + sshConfig?: string, + /** Remote folder the project is synchronised to and run from. */ + projectRoot?: string, + /** Upload changed project files before each run. */ + syncBeforeRun?: boolean, + /** Interpreters on the target; empty = the one on the remote `PATH`. */ + python?: string, + node?: string, + java?: string, +}; + export type RustSettings = { /** Directory holding `cargo` / `rustc`; empty = `PATH` / rustup default. */ toolchain?: string, @@ -4380,6 +4524,12 @@ export type Settings_Deserialize = { inspections?: InspectionSettings, /** Editor › Natural Languages › Spelling. */ spelling?: SpellingSettings, + /** Tools › SSH Configurations. */ + ssh?: SshSettings, + /** Build, Execution, Deployment › Deployment. */ + deployment?: DeploymentSettings, + /** Build, Execution, Deployment › Run Targets. */ + runTargets?: RunTarget[], }; export type Settings_Serialize = { @@ -4422,6 +4572,12 @@ export type Settings_Serialize = { inspections: InspectionSettings, /** Editor › Natural Languages › Spelling. */ spelling: SpellingSettings, + /** Tools › SSH Configurations. */ + ssh: SshSettings, + /** Build, Execution, Deployment › Deployment. */ + deployment: DeploymentSettings, + /** Build, Execution, Deployment › Run Targets. */ + runTargets: RunTarget[], }; export type Severity = "error" | "warning" | "info" | "hint"; @@ -4506,6 +4662,39 @@ export type SqlStatement = { text: string, }; +/** How an SSH connection authenticates. */ +export type SshAuth = "password" | +/** A private key file, optionally protected by a passphrase. */ +"keyPair" | +/** `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent. */ +"openSsh"; + +/** A named SSH host, shared by terminals, deployment, run targets and data sources. */ +export type SshConfiguration = { + id?: string, + name?: string, + /** Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`]. */ + host?: string, + port?: number | null, + user?: string, + auth?: SshAuth, + /** Private key for [`SshAuth::KeyPair`]; `~/` is the home directory. */ + keyFile?: string, + /** Keep the password / key passphrase in the saved passwords. */ + saveSecret?: boolean, +}; + +/** Tools › SSH Configurations. */ +export type SshSettings = { + configs?: SshConfiguration[], +}; + +export type SshTerminalCommand = { + shell: string, + args: string[], + title: string, +}; + export type StackFrame = { id: number, name: string, @@ -4632,12 +4821,34 @@ export type SymbolMatch = { indices: number[], }; +export type SyncAction = "upload" | "download" | "deleteRemote" | "deleteLocal"; + +/** One file of Sync with Deployed. */ +export type SyncEntry = { + /** Project-relative, `/`-separated. */ + path: string, + remotePath: string, + local: FileStamp | null, + remote: FileStamp | null, + status: SyncStatus, +}; + export type SyncProgress = { message: string, /** 0..1, or `None` when indeterminate. */ fraction: number | null, }; +export type SyncStatus = "localOnly" | "remoteOnly" | "localNewer" | "remoteNewer" | +/** Same time, different size. */ +"different" | "same"; + +export type SyncStep = { + /** Project-relative, `/`-separated. */ + path: string, + action: SyncAction, +}; + /** Counters for diagnostics (`app_metrics`). */ export type SyntaxCacheStats = { /** Documents tracked (open and observed). */ @@ -4875,6 +5086,18 @@ export type ToolStatus = /** The executable chosen in settings. */ "custom" | "missing"; +export type TransferError = { + path: string, + message: string, +}; + +export type TransferReport = { + files: number, + /** Files left alone: excluded, or outside every mapping. */ + skipped: number, + errors: TransferError[], +}; + export type TypeScriptSettings = { /** * `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`), diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx index 2929f361b..db874f9c8 100644 --- a/src/features/database/SourceDialog.tsx +++ b/src/features/database/SourceDialog.tsx @@ -13,7 +13,7 @@ import { type DbOptions, type DbProperty, type DbSsh, - type DbSshAuth, + type SshAuth, type DbSslMode, type DbTxMode, } from "@/platform/ipc"; @@ -51,7 +51,7 @@ const TX: Array> = [ { value: "manual", label: "Manual" }, ]; -const SSH_AUTH: Array> = [ +const SSH_AUTH: Array> = [ { value: "openSsh", label: "OpenSSH config and authentication agent" }, { value: "password", label: "Password" }, { value: "keyPair", label: "Key pair" }, diff --git a/src/platform/remoteSettings.ts b/src/platform/remoteSettings.ts new file mode 100644 index 000000000..55cc28b16 --- /dev/null +++ b/src/platform/remoteSettings.ts @@ -0,0 +1,210 @@ +/** + * SSH configurations, deployment servers and run targets as features consume + * them (mirrors `ide-core::remote_settings` defaults). Normalisation never throws. + */ +export type SshAuthValid = "password" | "keyPair" | "openSsh"; +export type AutoUploadValid = "never" | "onExplicitSave" | "always"; + +export interface SshConfigValid { + id: string; + name: string; + host: string; + port: number | null; + user: string; + auth: SshAuthValid; + keyFile: string; + saveSecret: boolean; +} + +export interface DeploymentMappingValid { + local: string; + deployment: string; + web: string; +} + +export interface DeploymentServerValid { + id: string; + name: string; + sshConfig: string; + rootPath: string; + webUrl: string; + mappings: DeploymentMappingValid[]; + excludedPaths: string[]; +} + +export interface DeploymentSettingsValid { + servers: DeploymentServerValid[]; + defaultServer: string; + autoUpload: AutoUploadValid; + deleteRemote: boolean; + preserveTimestamps: boolean; + exclude: string[]; +} + +export interface RunTargetValid { + id: string; + name: string; + sshConfig: string; + projectRoot: string; + syncBeforeRun: boolean; + python: string; + node: string; + java: string; +} + +export interface RemoteSettings { + ssh: { configs: SshConfigValid[] }; + deployment: DeploymentSettingsValid; + runTargets: RunTargetValid[]; +} + +export const DEFAULT_SSH_CONFIG: SshConfigValid = { + id: "", + name: "", + host: "", + port: null, + user: "", + auth: "openSsh", + keyFile: "", + saveSecret: false, +}; + +export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = { + id: "", + name: "", + sshConfig: "", + rootPath: "", + webUrl: "", + mappings: [{ local: "", deployment: "", web: "" }], + excludedPaths: [], +}; + +export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = { + servers: [], + defaultServer: "", + autoUpload: "never", + deleteRemote: false, + preserveTimestamps: true, + exclude: [".git", ".svn", ".hg", ".idea", ".openidea", ".DS_Store", "*.pyc", "__pycache__"], +}; + +export const DEFAULT_RUN_TARGET: RunTargetValid = { + id: "", + name: "", + sshConfig: "", + projectRoot: "", + syncBeforeRun: true, + python: "", + node: "", + java: "", +}; + +export const DEFAULT_REMOTE: RemoteSettings = { + ssh: { configs: [] }, + deployment: DEFAULT_DEPLOYMENT, + runTargets: [], +}; + +type Obj = Record; +const obj = (v: unknown): Obj => (v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {}); +const text = (v: unknown, d: string) => (typeof v === "string" ? v : d); +const bool = (v: unknown, d: boolean) => (typeof v === "boolean" ? v : d); +const strings = (v: unknown, d: string[]) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : d); +const oneOf = (v: unknown, values: readonly T[], d: T): T => + values.includes(v as T) ? (v as T) : d; +const port = (v: unknown): number | null => + typeof v === "number" && Number.isInteger(v) && v > 0 && v <= 65535 ? v : null; + +/** Items with a unique, non-empty id (duplicates get `_` appended, missing ones ``). */ +function withIds(raw: unknown, prefix: string, item: (o: Obj) => T | null): T[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const out: T[] = []; + raw.forEach((x, i) => { + const v = item(obj(x)); + if (!v) return; + let id = v.id.trim() || `${prefix}${i + 1}`; + while (seen.has(id)) id += "_"; + seen.add(id); + out.push({ ...v, id }); + }); + return out; +} + +export function validateSshConfigs(raw: unknown): SshConfigValid[] { + const d = DEFAULT_SSH_CONFIG; + return withIds(raw, "ssh", (o) => ({ + id: text(o.id, ""), + name: text(o.name, ""), + host: text(o.host, ""), + port: port(o.port), + user: text(o.user, ""), + auth: oneOf(o.auth, ["password", "keyPair", "openSsh"] as const, d.auth), + keyFile: text(o.keyFile, ""), + saveSecret: bool(o.saveSecret, d.saveSecret), + })); +} + +function validateMappings(raw: unknown): DeploymentMappingValid[] { + if (!Array.isArray(raw)) return []; + return raw.map((m) => { + const o = obj(m); + return { local: text(o.local, ""), deployment: text(o.deployment, ""), web: text(o.web, "") }; + }); +} + +export function validateDeployment(raw: unknown): DeploymentSettingsValid { + const o = obj(raw); + const d = DEFAULT_DEPLOYMENT; + const servers = withIds(o.servers, "server", (s) => ({ + id: text(s.id, ""), + name: text(s.name, ""), + sshConfig: text(s.sshConfig, ""), + rootPath: text(s.rootPath, ""), + webUrl: text(s.webUrl, ""), + mappings: validateMappings(s.mappings), + excludedPaths: strings(s.excludedPaths, []), + })); + return { + servers, + defaultServer: text(o.defaultServer, d.defaultServer), + autoUpload: oneOf(o.autoUpload, ["never", "onExplicitSave", "always"] as const, d.autoUpload), + deleteRemote: bool(o.deleteRemote, d.deleteRemote), + preserveTimestamps: bool(o.preserveTimestamps, d.preserveTimestamps), + exclude: strings(o.exclude, d.exclude), + }; +} + +export function validateRunTargets(raw: unknown): RunTargetValid[] { + const d = DEFAULT_RUN_TARGET; + return withIds(raw, "target", (o) => ({ + id: text(o.id, ""), + name: text(o.name, ""), + sshConfig: text(o.sshConfig, ""), + projectRoot: text(o.projectRoot, ""), + syncBeforeRun: bool(o.syncBeforeRun, d.syncBeforeRun), + python: text(o.python, ""), + node: text(o.node, ""), + java: text(o.java, ""), + })); +} + +export function validateRemote(root: unknown): RemoteSettings { + const o = obj(root); + return { + ssh: { configs: validateSshConfigs(obj(o.ssh).configs) }, + deployment: validateDeployment(o.deployment), + runTargets: validateRunTargets(o.runTargets), + }; +} + +/** `user@host:port` with what is known (the name when the host is empty). */ +export function sshLabel(c: Pick): string { + const host = c.host.trim(); + if (!host) return c.name; + return `${c.user.trim() ? `${c.user.trim()}@` : ""}${host}${c.port ? `:${c.port}` : ""}`; +} + +let seq = 0; +/** A fresh id for a new SSH configuration, server or target. */ +export const newRemoteId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${++seq}`; diff --git a/src/platform/workflowSettings.ts b/src/platform/workflowSettings.ts index e606396c3..b506ce077 100644 --- a/src/platform/workflowSettings.ts +++ b/src/platform/workflowSettings.ts @@ -4,6 +4,7 @@ * `ide-core::workflow_settings` defaults). Normalisation never throws. */ import { useProject } from "./project"; +import { DEFAULT_REMOTE, validateRemote, type RemoteSettings } from "./remoteSettings"; export type UpdateMethod = "merge" | "rebase"; export type IncomingCheck = "auto" | "always" | "never"; @@ -121,7 +122,7 @@ export interface ExternalToolValid { openConsole: boolean; } -export interface WorkflowSettings { +export interface WorkflowSettings extends RemoteSettings { vcs: VcsSettingsValid; console: ConsoleSettingsValid; debugger: DebuggerSettingsValid; @@ -239,6 +240,7 @@ export const DEFAULT_WORKFLOW: WorkflowSettings = { }, actionsOnSave: { optimizeImports: false, cleanup: false }, externalTools: [], + ...DEFAULT_REMOTE, }; type Obj = Record; @@ -423,6 +425,7 @@ export function validateWorkflow(raw: unknown): WorkflowSettings { terminal: validateTerminal(root.terminal), actionsOnSave: validateActionsOnSave(root.actionsOnSave), externalTools: validateExternalTools(root.externalTools), + ...validateRemote(root), }; } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index cd7444f57..57c4956fb 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -645,6 +645,8 @@ html { --link: #a7c5ff; --link-hover: #d0dffe; + --chart-series: #f29598; + --danger: #ff9b9f; --warning: #f2c55c; --success: #8ec39d;