mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:02:17 +00:00
fix(theme): Define the chart series colour in the high-contrast dark theme
This commit is contained in:
1 parent
575749f2ac
commit
0ab50e0dd4
51 files changed
+4398
-537
No files matched your search
Generated
+80
-2
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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("e(&format!("{k}={v}")));
|
||||
}
|
||||
s.push(' ');
|
||||
}
|
||||
s.push_str(&tilde_quote(program));
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str("e(a));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn tilde_quote(path: &str) -> String {
|
||||
match path.strip_prefix("~/") {
|
||||
Some(rest) => format!("~/{}", quote(rest)),
|
||||
None if path == "~" => "~".into(),
|
||||
None => quote(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quotes_only_when_needed() {
|
||||
assert_eq!(quote("plain/path-1.txt"), "plain/path-1.txt");
|
||||
assert_eq!(quote("a b"), "'a b'");
|
||||
assert_eq!(quote("it's"), r"'it'\''s'");
|
||||
assert_eq!(quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_command_line() {
|
||||
let env = BTreeMap::from([("A".to_owned(), "1 2".to_owned())]);
|
||||
assert_eq!(
|
||||
command_line(Some("~/proj dir"), &env, "python3", &["-u".into(), "main.py".into()]),
|
||||
"cd ~/'proj dir' && exec env 'A=1 2' python3 -u main.py"
|
||||
);
|
||||
assert_eq!(command_line(None, &BTreeMap::new(), "ls", &[]), "exec ls");
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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()));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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`),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -645,6 +645,8 @@ html {
|
||||
--link: #a7c5ff;
|
||||
--link-hover: #d0dffe;
|
||||
|
||||
--chart-series: #f29598;
|
||||
|
||||
--danger: #ff9b9f;
|
||||
--warning: #f2c55c;
|
||||
--success: #8ec39d;
|
||||
|
||||
Reference in new issue
Block a user