fix(theme): Define the chart series colour in the high-contrast dark theme

This commit is contained in:
l0ng-ai committed 2026-10-27 17:43:37 +08:00
1 parent 575749f2ac
commit 0ab50e0dd4
51 files changed
+4398 -537

No files matched your search

Generated
+80 -2
View File
@@ -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"
+2
View File
@@ -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"] }
+22
View File
@@ -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)]
+1
View File
@@ -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;
+150
View File
@@ -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<SshConfiguration>,
}
/// 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<u16>,
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<DeploymentServer>,
/// 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<String>,
}
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<DeploymentMapping>,
/// Remote paths (relative to the root) never touched.
pub excluded_paths: Vec<String>,
}
#[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,
}
+10
View File
@@ -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<RunTarget>,
}
#[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(),
}
}
}
+1 -2
View File
@@ -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"] }
+1 -1
View File
@@ -192,7 +192,7 @@ fn looks_like_query(sql: &str) -> bool {
fn file_setting(path: &str) -> Option<PathBuf> {
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 {
+62 -59
View File
@@ -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<HashMap<String, Arc<Tunnel>>>,
/// 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<HashMap<String, UnknownHostKey>>,
known_hosts: PathBuf,
ssh_config: Option<PathBuf>,
/// App-wide SSH connections and host-key trust.
ssh: Arc<SshManager>,
saved_passwords: SecretStore,
history: Arc<HistoryStore>,
/// 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<PathBuf>,
) -> Self {
pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc<SshManager>) -> 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<String>, 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.
+4 -11
View File
@@ -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<u16>,
+2 -1
View File
@@ -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(),
-226
View File
@@ -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<std::net::SocketAddr>) -> Sshd {
self.clone()
}
}
impl server::Handler for Sshd {
type Error = russh::Error;
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
}
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
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<Msg>,
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, OpenError> {
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<Tunnel, OpenError> {
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<Tunnel, OpenError>) -> 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);
}
+23
View File
@@ -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
+347
View File
@@ -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<TransferError>,
}
#[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<String>, 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<u32>,
}
#[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<FileStamp>,
pub remote: Option<FileStamp>,
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<FileStamp>, remote: Option<FileStamp>) -> 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<PathBuf> {
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<ide_ssh::RemoteEntry>) -> 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<Vec<SyncEntry>> {
let remote_root =
self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?;
let mut by_rel: BTreeMap<String, SyncEntry> = 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<TransferReport> {
let steps: Vec<SyncStep> = 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<FileStamp> {
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);
}
}
+12
View File
@@ -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};
+197
View File
@@ -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::<Vec<_>>()
.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<String> {
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<PathBuf> {
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<String> {
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::<Vec<_>>().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<String>,
}
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"));
}
}
+312
View File
@@ -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<String>,
}
pub struct RemoteService {
ssh: Arc<SshManager>,
/// Local copies of remote files: `<cache>/<ssh config>/<remote path>`.
cache: PathBuf,
edits: Mutex<HashMap<PathBuf, RemoteEdit>>,
pub runs: RemoteRuns,
}
/// The deployment server `id`, or the default one.
pub fn server(settings: &Settings, id: Option<&str>) -> IdeResult<DeploymentServer> {
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<RunTarget> {
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<SshManager>, cache: PathBuf) -> Self {
Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() }
}
async fn fs(&self, ssh_config: &str) -> IdeResult<RemoteFs> {
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<T>(
&self,
settings: &Settings,
root: &Path,
server: &DeploymentServer,
cancel: CancellationToken,
f: impl AsyncFnOnce(&Deployment<'_>) -> T,
) -> IdeResult<T> {
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<TransferReport> {
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<TransferReport> {
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<TransferReport> {
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<Vec<SyncEntry>> {
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<TransferReport> {
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<Option<DeployedPath>> {
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<String> {
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<Vec<RemoteEntry>> {
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<PathBuf> {
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<IdeResult<TransferReport>> {
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<IdeResult<TransferReport>> {
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<dyn Fn(OutputChunk) + Send + Sync>,
) -> IdeResult<RunInfo> {
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<SyncFn> = 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()
}
+295
View File
@@ -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<dyn Fn(OutputChunk) + Send + Sync>;
/// Uploads local changes before the run; reports progress lines.
pub type SyncFn = Box<dyn FnOnce(OutputFn) -> Pin<Box<dyn Future<Output = IdeResult<()>> + 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<String>,
pub cwd: String,
pub env: BTreeMap<String, String>,
/// A whole shell command line (configurations with a command and no arguments).
pub shell_line: Option<String>,
}
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::<Vec<_>>()
.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<RunInfo>,
process: Mutex<Option<Arc<RemoteProcess>>>,
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<Mutex<BTreeMap<RunId, Arc<RemoteRun>>>>,
}
pub struct RemoteStart {
pub session: Arc<Session>,
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<SyncFn>,
}
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<RunInfo> {
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<dyn Fn(OutputChunk) + Send + Sync>) -> 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<RemoteProcess>, 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<Option<u32>> {
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");
}
}
+173
View File
@@ -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<Session>,
}
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<Vec<IdeEvent>>);
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<Mutex<String>> = 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);
}
+5
View File
@@ -111,6 +111,7 @@ struct FileConfig {
emulate_terminal: bool,
#[serde(alias = "stopCommand")]
stop_command: Option<String>,
target: Option<String>,
}
fn slug(s: &str) -> String {
@@ -173,6 +174,7 @@ fn convert(configs: Vec<FileConfig>, local: bool) -> Vec<RunConfig> {
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
}
+5
View File
@@ -125,6 +125,10 @@ pub struct RunExtras {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[specta(optional)]
pub stop_command: Option<String>,
/// 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<String>,
}
impl Default for RunExtras {
@@ -145,6 +149,7 @@ impl Default for RunExtras {
focus_tool_window: false,
emulate_terminal: false,
stop_command: None,
target: None,
}
}
}
+27
View File
@@ -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
+208
View File
@@ -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<String>,
}
type Pending = Arc<Mutex<HashMap<u64, mpsc::Sender<Option<String>>>>>;
/// 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<dyn Fn(AskpassPrompt) + Send + Sync>) -> IdeResult<Self> {
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<String>) {
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::<Request>(&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<i32> {
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<Option<String>> {
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::<Response>(&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("[email protected]'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<Mutex<Option<AskpassPrompt>>> = 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::<Response>(&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"));
}
}
+21
View File
@@ -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;
+181
View File
@@ -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<HashMap<String, String>>,
configs: RwLock<Vec<SshConfiguration>>,
sessions: Mutex<HashMap<String, Arc<Session>>>,
/// 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<HashMap<String, UnknownHostKey>>,
}
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<SshConfiguration>) {
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<SshConfiguration> {
self.configs.read().clone()
}
pub fn config(&self, id: &str) -> IdeResult<SshConfiguration> {
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<String>, 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<String> {
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<Arc<Session>> {
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<Arc<Session>> {
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<Session> {
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<String>) -> IdeResult<String> {
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<String> {
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()
}
}
+87
View File
@@ -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<String> {
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<String, String> {
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<String, String>) -> 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);
}
}
}
@@ -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<UnknownHostKey>),
Other(IdeError),
}
impl From<IdeError> for OpenError {
impl From<IdeError> 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<ConnectError> 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<Handle<Client>>,
/// 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<u32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
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<String> {
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<Client>,
/// Sessions of the `ProxyJump` hosts, kept alive under `handle`.
_jumps: Vec<Handle<Client>>,
accept: JoinHandle<()>,
label: String,
}
impl Tunnel {
/// Connects and starts listening on a free local port.
pub async fn open(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
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<Session, ConnectError> {
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<Tunnel, OpenError> {
let hops = resolve_hops(spec.ssh, spec.ssh_config)?;
async fn connect_inner(target: &SshTarget, secret: Option<&str>, files: &SshFiles) -> Result<Session, ConnectError> {
let hops = resolve_hops(target, files.config.as_deref())?;
let mut sessions: Vec<Handle<Client>> = 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::<tokio::net::TcpStream>(hop, spec.known_hosts, None).await?,
None => connect::<tokio::net::TcpStream>(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<ChannelStream<Msg>, 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<Handle<Client>>, 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<ExecOutput> {
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<dyn FnMut(Output<'_>) + Send>,
) -> IdeResult<RemoteProcess> {
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<russh_sftp::client::SftpSession> {
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<ChannelWriteHalf<Msg>>,
exit: Mutex<Option<oneshot::Receiver<Option<u32>>>>,
}
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<u32> {
let rx = self.exit.lock().take()?;
rx.await.ok().flatten()
}
}
fn io(message: impl Into<String>) -> IdeError {
IdeError::new(ErrorKind::Io, message)
}
// --- hosts ---------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
struct Hop {
host: String,
port: u16,
user: String,
identity_files: Vec<PathBuf>,
}
impl Hop {
fn label(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
}
fn home() -> Option<PathBuf> {
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<String>) {
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::<u16>().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<Vec<Hop>> {
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<Hop> = 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<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, OpenError>
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, 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<Client>, user: &str) -> bool {
async fn authenticate(
handle: &mut Handle<Client>,
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<keys::PrivateKey
Err(e) => 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 [email protected]: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<String> = hops.iter().map(Hop::label).collect();
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]: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(), "[email protected]: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::<Vec<_>>(), ["u@db-bastion:22"]);
assert!(resolve_hops(&DbSsh::default(), None).is_err());
}
}
+236
View File
@@ -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<u32>,
/// `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<String> {
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<String> {
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<Option<RemoteEntry>> {
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<Vec<RemoteEntry>> {
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<Vec<u8>> {
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<u32>) -> 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<u32> {
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");
}
}
+68
View File
@@ -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 <cwd> && exec env K=V … <program> <args…>`. A program starting with `~/`
/// is left to the shell's tilde expansion.
pub fn command_line(cwd: Option<&str>, env: &BTreeMap<String, String>, 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(&quote(&format!("{k}={v}")));
}
s.push(' ');
}
s.push_str(&tilde_quote(program));
for a in args {
s.push(' ');
s.push_str(&quote(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");
}
}
+181
View File
@@ -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<u16>,
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<PathBuf>,
}
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<PathBuf>,
}
impl Hop {
pub fn label(&self) -> String {
format!("{}@{}:{}", self.user, self.host, self.port)
}
}
pub(crate) fn home() -> Option<PathBuf> {
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<String>) {
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::<u16>().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<Vec<Hop>> {
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<Hop> = 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<String> {
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 [email protected]: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<String> = hops.iter().map(Hop::label).collect();
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]: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(), "[email protected]: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::<Vec<_>>(), ["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"]);
}
}
+248
View File
@@ -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 `<root>/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<dyn Fn(&str) -> (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<std::net::SocketAddr>) -> Conn {
Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default() }
}
}
struct Conn {
root: PathBuf,
exec: ExecFn,
channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
}
impl server::Handler for Conn {
type Error = russh::Error;
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
}
async fn channel_open_session(
&mut self,
channel: Channel<Msg>,
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<String, std::fs::File>,
/// Open directories: entries not yet sent.
dirs: HashMap<String, Option<Vec<File>>>,
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<FileAttributes, StatusCode> {
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<String, String>) -> Result<Version, Self::Error> {
Ok(Version::new())
}
async fn realpath(&mut self, id: u32, path: String) -> Result<Name, Self::Error> {
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<Handle, Self::Error> {
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<Status, Self::Error> {
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<Data, Self::Error> {
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<u8>) -> Result<Status, Self::Error> {
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<Attrs, Self::Error> {
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<Attrs, Self::Error> {
Ok(Attrs { id, attrs: Self::attrs(&self.local(&path))? })
}
async fn fstat(&mut self, id: u32, handle: String) -> Result<Attrs, Self::Error> {
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<Status, Self::Error> {
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<Handle, Self::Error> {
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<Name, Self::Error> {
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<Status, Self::Error> {
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<Status, Self::Error> {
std::fs::create_dir(self.local(&path)).map_err(code)?;
Ok(ok(id))
}
async fn rmdir(&mut self, id: u32, path: String) -> Result<Status, Self::Error> {
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<Status, Self::Error> {
std::fs::rename(self.local(&oldpath), self.local(&newpath)).map_err(code)?;
Ok(ok(id))
}
}
+80
View File
@@ -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<Session>,
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<Session>, host: &str, port: u16) -> IdeResult<Tunnel> {
// 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<Session>, host: &str, port: u16) -> IdeResult<Tunnel> {
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<Session>, 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}"))
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
+256
View File
@@ -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<std::net::SocketAddr>) -> Sshd {
self.clone()
}
}
impl server::Handler for Sshd {
type Error = russh::Error;
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
}
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
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<Msg>,
reply: ChannelOpenHandle,
_session: &mut ServerSession,
) -> Result<(), Self::Error> {
reply.accept().await;
Ok(())
}
/// `ran: <command>` 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<Msg>,
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, ConnectError> {
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<Session, ConnectError> {
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<Session, ConnectError>) -> 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<Mutex<(String, String)>> = 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()));
}
+11
View File
@@ -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<Vec<(String, String)>> = 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<Command> {
.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)
}
+1 -1
View File
@@ -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;
+2
View File
@@ -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" }
+4
View File
@@ -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(),
+185
View File
@@ -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<IpcPath>) -> Vec<PathBuf> {
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<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<IpcPath>,
server: Option<String>,
) -> IdeResult<TransferReport> {
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<String>,
request: RequestId,
) -> IdeResult<Vec<SyncEntry>> {
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<SyncStep>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
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<String>,
) -> IdeResult<Option<DeployedPath>> {
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<String> {
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<Vec<RemoteEntry>> {
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<IpcPath> {
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
}
+23
View File
@@ -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<tauri::Wry> {
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,
+32 -2
View File
@@ -50,7 +50,7 @@ pub fn run_system_env() -> std::collections::BTreeMap<String, String> {
#[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<RunInfo> {
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)
}
+92
View File
@@ -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<String>) -> IdeResult<String> {
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<String>, 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<bool> {
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<String> {
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<String>) {
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<String>,
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<SshTerminalCommand> {
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 })
}
+4
View File
@@ -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()
}
+91 -2
View File
@@ -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<ide_plugin::PluginHost>,
/// 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<ide_ssh::SshManager>,
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
pub askpass: Option<ide_ssh::askpass::AskpassServer>,
/// SFTP deployment, remote file editing and runs on run targets.
pub remote: Arc<ide_remote::RemoteService>,
/// 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<ide_remote::RemoteService>,
settings: Arc<SettingsStore>,
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<ide_remote::RemoteService>,
settings: Arc<SettingsStore>,
root: PathBuf,
removed: Vec<PathBuf>,
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<ide_remote::TransferReport>) {
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::<Vec<_>>().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<ide_ssh::askpass::AskpassServer> {
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
}
}
}
+232 -9
View File
@@ -755,6 +755,51 @@ export const commands = {
* "Unknown SSH host key" (records it in `~/.ssh/known_hosts`).
*/
dbTrustSshHost: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("db_trust_ssh_host", { id })),
/** Test Connection: connects with `config` as edited (not necessarily saved). */
sshTest: (config: SshConfiguration, secret: string | null) => typedError<string, IdeError>(__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<void>("ssh_set_secret", { id, secret, save }),
/** Whether connecting with configuration `id` needs a password first. */
sshNeedsSecret: (id: string) => typedError<boolean, IdeError>(__TAURI_INVOKE("ssh_needs_secret", { id })),
/** Trusts the host key that failed the last connection of configuration `id`. */
sshTrustHost: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("ssh_trust_host", { id })),
/** Ids of the configurations with an open connection. */
sshConnected: () => __TAURI_INVOKE<string[]>("ssh_connected"),
sshDisconnect: (id: string) => __TAURI_INVOKE<void>("ssh_disconnect", { id }),
/** Answers an `IdeEvent::Askpass` prompt (`None`: cancelled); every window drops it. */
sshAskpassAnswer: (id: number, answer: string | null) => __TAURI_INVOKE<void>("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<SshTerminalCommand, IdeError>(__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<TransferReport, IdeError>(__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<TransferReport, IdeError>(__TAURI_INVOKE("deploy_download", { paths, server, request })),
/** Deletes the remote counterparts of files and folders. */
deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError<TransferReport, IdeError>(__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<SyncEntry[], IdeError>(__TAURI_INVOKE("deploy_compare", { path, server, request })),
/** Applies the Sync with Deployed choices. */
deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("deploy_server_root", { server })),
/** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */
remoteList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__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<IpcPath, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("remote_delete", { ssh, path })),
remoteMkdir: (ssh: string, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { ssh, path })),
remoteRename: (ssh: string, from: string, to: string) => typedError<null, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("db_test", { source, password, sshSecret })),
dbConnected: () => typedError<string[], IdeError>(__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`),
+2 -2
View File
@@ -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<SelectOption<DbTxMode>> = [
{ value: "manual", label: "Manual" },
];
const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
const SSH_AUTH: Array<SelectOption<SshAuth>> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
+210
View File
@@ -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<string, unknown>;
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 = <T extends string>(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 `<prefix><n>`). */
function withIds<T extends { id: string }>(raw: unknown, prefix: string, item: (o: Obj) => T | null): T[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
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<SshConfigValid, "name" | "host" | "port" | "user">): 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}`;
+4 -1
View File
@@ -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<string, unknown>;
@@ -423,6 +425,7 @@ export function validateWorkflow(raw: unknown): WorkflowSettings {
terminal: validateTerminal(root.terminal),
actionsOnSave: validateActionsOnSave(root.actionsOnSave),
externalTools: validateExternalTools(root.externalTools),
...validateRemote(root),
};
}
+2
View File
@@ -645,6 +645,8 @@ html {
--link: #a7c5ff;
--link-hover: #d0dffe;
--chart-series: #f29598;
--danger: #ff9b9f;
--warning: #f2c55c;
--success: #8ec39d;