diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 71a8e9e0..c2d83dc0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5088,6 +5088,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.10" @@ -6242,7 +6248,9 @@ dependencies = [ "notify", "once_cell", "openssl", + "plist", "reqwest", + "roxmltree", "russh", "russh-keys", "rust_decimal", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a139a01d..c86ba465 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -69,6 +69,10 @@ rustls-pemfile = "2" rustls-platform-verifier = "0.6" notify = "6" ulid = "1.2.1" +# Foreign-app connection import: plist (TablePlus/Sequel Ace), XML (DataGrip). +# AES/MD5 decryption (DBeaver, Beekeeper) reuses the existing `openssl` dep. +plist = "1.7" +roxmltree = "0.20" # GTK dependencies for Wayland window title workaround (Linux only) [target.'cfg(target_os = "linux")'.dependencies] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c032bcfe..a157cf4c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4309,6 +4309,16 @@ pub async fn export_connections_payload( pub async fn import_connections_payload( app: AppHandle, payload: ExportPayload, +) -> Result<(), String> { + apply_export_payload(app, payload).await +} + +/// Merge an `ExportPayload` into the user's stored connections, groups, SSH and +/// K8s records, moving any inline secrets into the keychain. Shared by the JSON +/// import command above and the foreign-app import flow. +pub async fn apply_export_payload( + app: AppHandle, + payload: ExportPayload, ) -> Result<(), String> { let conn_path = get_config_path(&app)?; let ssh_path = get_ssh_config_path(&app)?; diff --git a/src-tauri/src/connection_import/analyzer.rs b/src-tauri/src/connection_import/analyzer.rs new file mode 100644 index 00000000..d6628d3b --- /dev/null +++ b/src-tauri/src/connection_import/analyzer.rs @@ -0,0 +1,261 @@ +//! Annotates an [`ImportEnvelope`] against the user's existing connections and +//! the set of registered drivers, producing the serializable [`ImportPreview`] +//! the frontend renders. Ported from TablePro's `ConnectionImportAnalyzer`. + +use serde::Serialize; + +use super::types::ImportEnvelope; +use super::{driver_map, expand_home}; +use crate::models::SavedConnection; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportPreview { + pub source_name: String, + pub credentials_aborted: bool, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportItem { + /// Index into the envelope; the apply step resolves connections by this. + pub index: usize, + pub name: String, + pub driver_id: String, + pub driver_installed: bool, + pub host: String, + pub port: u16, + pub database: String, + pub username: String, + pub has_ssh: bool, + pub has_password: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_name: Option, + pub status: ImportItemStatus, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum ImportItemStatus { + Ready, + Duplicate { + existing_id: String, + existing_name: String, + }, + Warnings { + warnings: Vec, + }, +} + +/// Build the preview. `registered_ids` is the set of drivers Tabularis can use +/// right now (built-in + plugins); `file_exists` lets tests stub path checks. +pub fn analyze( + envelope: &ImportEnvelope, + existing: &[SavedConnection], + registered_ids: &[String], + file_exists: &dyn Fn(&str) -> bool, +) -> ImportPreview { + let existing_keys: Vec<(DupKey, String, String)> = existing + .iter() + .map(|c| { + ( + dup_key_existing(c), + c.id.clone(), + c.name.clone(), + ) + }) + .collect(); + + let items = envelope + .connections + .iter() + .enumerate() + .map(|(index, conn)| { + let (driver_id, driver_installed) = + driver_map::map_driver_label(&conn.driver_label, registered_ids); + let has_password = envelope + .credentials_by_index + .get(&index) + .map(|c| c.password.is_some()) + .unwrap_or(false); + + let key = dup_key(&conn.host, conn.port, &conn.database, &conn.username); + let status = if let Some((_, id, name)) = + existing_keys.iter().find(|(k, _, _)| k == &key) + { + ImportItemStatus::Duplicate { + existing_id: id.clone(), + existing_name: name.clone(), + } + } else { + let warnings = collect_warnings(conn, driver_installed, &driver_id, file_exists); + if warnings.is_empty() { + ImportItemStatus::Ready + } else { + ImportItemStatus::Warnings { warnings } + } + }; + + ImportItem { + index, + name: conn.name.clone(), + driver_id, + driver_installed, + host: conn.host.clone(), + port: conn.port, + database: conn.database.clone(), + username: conn.username.clone(), + has_ssh: conn.ssh.is_some(), + has_password, + group_name: conn.group_name.clone(), + status, + } + }) + .collect(); + + ImportPreview { + source_name: envelope.source_name.clone(), + credentials_aborted: envelope.credentials_aborted, + items, + } +} + +fn collect_warnings( + conn: &super::types::ImportedConnection, + driver_installed: bool, + driver_id: &str, + file_exists: &dyn Fn(&str) -> bool, +) -> Vec { + let mut warnings = Vec::new(); + + if let Some(ssh) = &conn.ssh { + if let Some(key) = ssh.private_key_path.as_deref().filter(|s| !s.is_empty()) { + if !file_exists(&expand_home(key)) { + warnings.push(format!("SSH private key not found: {}", key)); + } + } + } + if let Some(ssl) = &conn.ssl { + for (path, label) in [ + (&ssl.ca_certificate_path, "CA certificate"), + (&ssl.client_certificate_path, "Client certificate"), + (&ssl.client_key_path, "Client key"), + ] { + if let Some(p) = path.as_deref().filter(|s| !s.is_empty()) { + if !file_exists(&expand_home(p)) { + warnings.push(format!("{} not found: {}", label, p)); + } + } + } + } + if !driver_installed { + warnings.push(format!("Database driver \"{}\" is not installed", driver_id)); + } + warnings +} + +#[derive(PartialEq, Eq)] +struct DupKey(Vec); + +fn dup_key(host: &str, port: u16, database: &str, username: &str) -> DupKey { + DupKey(vec![ + norm(host), + port.to_string(), + norm(database), + norm(username), + ]) +} + +fn dup_key_existing(c: &SavedConnection) -> DupKey { + dup_key( + c.params.host.as_deref().unwrap_or(""), + c.params.port.unwrap_or(0), + c.params.database.primary(), + c.params.username.as_deref().unwrap_or(""), + ) +} + +fn norm(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connection_import::types::ImportedConnection; + use crate::models::{ConnectionParams, DatabaseSelection}; + + fn conn(name: &str, host: &str, port: u16, db: &str, user: &str, driver: &str) -> ImportedConnection { + ImportedConnection { + name: name.to_string(), + host: host.to_string(), + port, + database: db.to_string(), + username: user.to_string(), + driver_label: driver.to_string(), + ssh: None, + ssl: None, + group_name: None, + } + } + + fn saved(id: &str, name: &str, host: &str, port: u16, db: &str, user: &str) -> SavedConnection { + SavedConnection { + id: id.to_string(), + name: name.to_string(), + params: ConnectionParams { + driver: "postgres".to_string(), + host: Some(host.to_string()), + port: Some(port), + username: Some(user.to_string()), + database: DatabaseSelection::Single(db.to_string()), + ..Default::default() + }, + group_id: None, + sort_order: None, + detect_json_in_text_columns: None, + appearance: None, + } + } + + fn envelope(conns: Vec) -> ImportEnvelope { + ImportEnvelope { + source_name: "Test".to_string(), + connections: conns, + ..Default::default() + } + } + + #[test] + fn marks_duplicates() { + let env = envelope(vec![conn("A", "Host", 5432, "DB", "User", "PostgreSQL")]); + let existing = vec![saved("x", "Existing", "host", 5432, "db", "user")]; + let preview = analyze(&env, &existing, &["postgres".into()], &|_| true); + assert!(matches!( + preview.items[0].status, + ImportItemStatus::Duplicate { .. } + )); + } + + #[test] + fn warns_on_uninstalled_driver() { + let env = envelope(vec![conn("A", "h", 27017, "db", "u", "MongoDB")]); + let preview = analyze(&env, &[], &["postgres".into()], &|_| true); + match &preview.items[0].status { + ImportItemStatus::Warnings { warnings } => { + assert!(warnings.iter().any(|w| w.contains("not installed"))); + } + other => panic!("expected warnings, got {:?}", other), + } + assert_eq!(preview.items[0].driver_id, "mongodb"); + assert!(!preview.items[0].driver_installed); + } + + #[test] + fn ready_when_known_and_unique() { + let env = envelope(vec![conn("A", "h", 5432, "db", "u", "PostgreSQL")]); + let preview = analyze(&env, &[], &["postgres".into()], &|_| true); + assert!(matches!(preview.items[0].status, ImportItemStatus::Ready)); + } +} diff --git a/src-tauri/src/connection_import/beekeeper.rs b/src-tauri/src/connection_import/beekeeper.rs new file mode 100644 index 00000000..24c56537 --- /dev/null +++ b/src-tauri/src/connection_import/beekeeper.rs @@ -0,0 +1,345 @@ +//! Beekeeper Studio importer. Reads the local workspace from Beekeeper's +//! `app.db` (SQLite) and decrypts password columns with the per-install key +//! stored in `.key`. Ported from TablePro's `BeekeeperStudioImporter.swift`. +//! +//! Only the personal workspace (`workspaceId = -1`) is imported; cloud-synced +//! rows have a positive id and their own source of truth. + +use std::path::{Path, PathBuf}; + +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::Row; + +use super::types::{ + ImportEnvelope, ImportedConnection, ImportedCredentials, ImportedSsh, ImportedSsl, +}; +use super::{crypto, driver_map, resolve_key_path, ForeignAppImporter, ForeignImportError}; + +pub struct BeekeeperImporter { + data_dir: PathBuf, +} + +impl Default for BeekeeperImporter { + fn default() -> Self { + Self { + data_dir: default_data_dir(), + } + } +} + +/// Beekeeper stores `app.db` under the Electron userData dir: +/// macOS `~/Library/Application Support/beekeeper-studio`, Linux +/// `~/.config/beekeeper-studio`, Windows `%APPDATA%/beekeeper-studio`. +fn default_data_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + super::home_dir() + .map(|h| h.join("Library/Application Support/beekeeper-studio")) + .unwrap_or_default() + } + #[cfg(target_os = "linux")] + { + directories::BaseDirs::new() + .map(|d| d.config_dir().join("beekeeper-studio")) + .unwrap_or_default() + } + #[cfg(target_os = "windows")] + { + directories::BaseDirs::new() + .map(|d| d.data_dir().join("beekeeper-studio")) + .unwrap_or_default() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PathBuf::new() + } +} + +#[derive(sqlx::FromRow)] +struct SavedConnectionRow { + name: Option, + #[sqlx(rename = "connectionType")] + connection_type: Option, + host: Option, + port: Option, + username: Option, + #[sqlx(rename = "defaultDatabase")] + default_database: Option, + password: Option, + ssl: Option, + #[sqlx(rename = "sslCaFile")] + ssl_ca_file: Option, + #[sqlx(rename = "sslCertFile")] + ssl_cert_file: Option, + #[sqlx(rename = "sslKeyFile")] + ssl_key_file: Option, + #[sqlx(rename = "sslRejectUnauthorized")] + ssl_reject_unauthorized: Option, + #[sqlx(rename = "sshEnabled")] + ssh_enabled: Option, + #[sqlx(rename = "sshHost")] + ssh_host: Option, + #[sqlx(rename = "sshPort")] + ssh_port: Option, + #[sqlx(rename = "sshUsername")] + ssh_username: Option, + #[sqlx(rename = "sshMode")] + ssh_mode: Option, + #[sqlx(rename = "sshKeyfile")] + ssh_keyfile: Option, + #[sqlx(rename = "sshKeyfilePassword")] + ssh_keyfile_password: Option, + #[sqlx(rename = "sshPassword")] + ssh_password: Option, + #[sqlx(rename = "labelColor")] + _label_color: Option, + #[sqlx(rename = "connectionFolderId")] + connection_folder_id: Option, +} + +const SELECT_CONNECTIONS: &str = "SELECT id, name, connectionType, host, port, username, \ + defaultDatabase, password, ssl, sslCaFile, sslCertFile, sslKeyFile, sslRejectUnauthorized, \ + sshEnabled, sshHost, sshPort, sshUsername, sshMode, sshKeyfile, sshKeyfilePassword, \ + sshPassword, labelColor, connectionFolderId \ + FROM saved_connection WHERE workspaceId = -1 ORDER BY id"; + +#[async_trait::async_trait] +impl ForeignAppImporter for BeekeeperImporter { + fn id(&self) -> &'static str { + "beekeeperstudio" + } + fn display_name(&self) -> &'static str { + "Beekeeper Studio" + } + async fn is_available(&self) -> bool { + self.app_db_path().is_file() + } + async fn connection_count(&self) -> usize { + match self.read_rows().await { + Ok(rows) => rows + .iter() + .filter(|r| map_driver(r.connection_type.as_deref()).is_some()) + .count(), + Err(_) => 0, + } + } + + async fn import( + &self, + include_passwords: bool, + _file: Option<&Path>, + ) -> Result { + if !self.app_db_path().is_file() { + return Err(ForeignImportError::FileNotFound(self.display_name().to_string())); + } + let rows = self.read_rows().await?; + let folder_map = self.read_folders().await.unwrap_or_default(); + let user_key = if include_passwords { + self.load_user_encryption_key() + } else { + None + }; + + let mut envelope = ImportEnvelope { + source_name: self.display_name().to_string(), + ..Default::default() + }; + let mut group_names: Vec = Vec::new(); + + for row in &rows { + let driver_label = match map_driver(row.connection_type.as_deref()) { + Some(d) => d, + None => continue, // unsupported driver — skip, like TablePro + }; + let group_name = row + .connection_folder_id + .and_then(|id| folder_map.get(&id).cloned()); + if let Some(g) = &group_name { + if !group_names.contains(g) { + group_names.push(g.clone()); + } + } + + let driver_id = driver_map::canonical_id(&driver_label); + let port = row + .port + .and_then(|p| u16::try_from(p).ok()) + .unwrap_or_else(|| driver_map::default_port(&driver_id)); + + let conn = ImportedConnection { + name: non_empty(row.name.clone()).unwrap_or_else(|| "Untitled".to_string()), + host: non_empty(row.host.clone()).unwrap_or_else(|| "localhost".to_string()), + port, + database: row.default_database.clone().unwrap_or_default(), + username: row.username.clone().unwrap_or_default(), + driver_label, + ssh: build_ssh(row), + ssl: build_ssl(row), + group_name, + }; + let index = envelope.connections.len(); + envelope.connections.push(conn); + + if let Some(key) = &user_key { + let creds = extract_credentials(row, key); + if !creds.is_empty() { + envelope.credentials_by_index.insert(index, creds); + } + } + } + + if envelope.connections.is_empty() { + return Err(ForeignImportError::NoConnectionsFound); + } + group_names.sort(); + envelope.group_names = group_names; + Ok(envelope) + } +} + +impl BeekeeperImporter { + #[cfg(test)] + pub(crate) fn with_data_dir(data_dir: PathBuf) -> Self { + Self { data_dir } + } + + fn app_db_path(&self) -> PathBuf { + self.data_dir.join("app.db") + } + fn key_file_path(&self) -> PathBuf { + self.data_dir.join(".key") + } + + /// Open `app.db` read-only/immutable so we never write a journal into + /// another app's data directory. + async fn open(&self) -> Result { + let opts = SqliteConnectOptions::new() + .filename(self.app_db_path()) + .read_only(true) + .immutable(true); + SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .map_err(|e| ForeignImportError::ParseError(format!("Could not open app.db: {e}"))) + } + + async fn read_rows(&self) -> Result, ForeignImportError> { + let pool = self.open().await?; + let rows = sqlx::query_as::<_, SavedConnectionRow>(SELECT_CONNECTIONS) + .fetch_all(&pool) + .await + .map_err(|_| { + ForeignImportError::UnsupportedFormat("saved_connection schema mismatch".into()) + })?; + pool.close().await; + Ok(rows) + } + + async fn read_folders(&self) -> Result, ForeignImportError> { + let pool = self.open().await?; + let rows = sqlx::query("SELECT id, name FROM connection_folder") + .fetch_all(&pool) + .await + .unwrap_or_default(); + pool.close().await; + let mut map = std::collections::HashMap::new(); + for row in rows { + let id: i64 = row.try_get("id").unwrap_or_default(); + let name: Option = row.try_get("name").ok(); + if let Some(name) = name.filter(|n| !n.is_empty()) { + map.insert(id, name); + } + } + Ok(map) + } + + /// Decrypt `.key` with the bootstrap key to recover the per-install + /// encryption key used for password columns. + fn load_user_encryption_key(&self) -> Option { + let payload = std::fs::read_to_string(self.key_file_path()).ok()?; + crypto::decrypt_beekeeper_user_key(payload.trim()) + } +} + +fn build_ssh(row: &SavedConnectionRow) -> Option { + if row.ssh_enabled.unwrap_or(0) == 0 { + return None; + } + let auth_type = match row.ssh_mode.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("keyfile") => "ssh_key", + _ => "password", + } + .to_string(); + Some(ImportedSsh { + host: row.ssh_host.clone().unwrap_or_default(), + port: row.ssh_port.and_then(|p| u16::try_from(p).ok()), + username: row.ssh_username.clone().unwrap_or_default(), + private_key_path: row + .ssh_keyfile + .as_deref() + .filter(|s| !s.is_empty()) + .map(resolve_key_path), + auth_type, + }) +} + +fn build_ssl(row: &SavedConnectionRow) -> Option { + if row.ssl.unwrap_or(0) == 0 { + return None; + } + let mode = if row.ssl_reject_unauthorized.unwrap_or(0) != 0 { + "verify-full" + } else { + "require" + } + .to_string(); + Some(ImportedSsl { + mode, + ca_certificate_path: non_empty(row.ssl_ca_file.clone()), + client_certificate_path: non_empty(row.ssl_cert_file.clone()), + client_key_path: non_empty(row.ssl_key_file.clone()), + }) +} + +fn extract_credentials(row: &SavedConnectionRow, key: &str) -> ImportedCredentials { + let dec = |v: &Option| { + v.as_deref() + .filter(|s| !s.is_empty()) + .and_then(|s| crypto::decrypt_beekeeper_string(s, key)) + }; + ImportedCredentials { + password: dec(&row.password), + ssh_password: dec(&row.ssh_password), + ssh_key_passphrase: dec(&row.ssh_keyfile_password), + } +} + +fn non_empty(v: Option) -> Option { + v.filter(|s| !s.is_empty()) +} + +/// Maps Beekeeper's `ConnectionType` strings to the labels `driver_map` knows. +/// Unknown drivers return `None` and are skipped by the caller. +fn map_driver(raw: Option<&str>) -> Option { + let raw = raw?.to_ascii_lowercase(); + let label = match raw.as_str() { + "mysql" => "MySQL", + "mariadb" => "MariaDB", + "postgresql" | "postgres" => "PostgreSQL", + "redshift" => "Redshift", + "cockroachdb" => "CockroachDB", + "sqlite" => "SQLite", + "sqlserver" => "SQL Server", + "oracle" => "Oracle", + "mongodb" | "mongo" => "MongoDB", + "redis" => "Redis", + "cassandra" => "Cassandra", + "clickhouse" => "ClickHouse", + "bigquery" => "BigQuery", + "duckdb" => "DuckDB", + "libsql" => "libSQL", + _ => return None, + }; + Some(label.to_string()) +} diff --git a/src-tauri/src/connection_import/convert.rs b/src-tauri/src/connection_import/convert.rs new file mode 100644 index 00000000..7b97498d --- /dev/null +++ b/src-tauri/src/connection_import/convert.rs @@ -0,0 +1,281 @@ +//! Turns an analyzed [`ImportEnvelope`] plus the user's per-item resolutions +//! into a Tabularis `ExportPayload`, which is merged through the existing +//! import path (`apply_export_payload`). SSH details become separate +//! `SshConnection` records linked by `ssh_connection_id`; groups are matched to +//! existing groups by name or created fresh. + +use serde::Deserialize; + +use super::driver_map; +use super::types::{ImportEnvelope, ImportedConnection, ImportedCredentials}; +use crate::models::{ + ConnectionGroup, ConnectionParams, DatabaseSelection, ExportPayload, SavedConnection, + SshConnection, +}; + +/// One item's disposition, chosen by the user in the preview UI. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportResolution { + pub index: usize, + /// "import" (new), "replace" (overwrite an existing connection), or "skip". + pub action: String, + #[serde(default)] + pub replace_existing_id: Option, +} + +/// Build the payload from the envelope and resolutions. `existing_groups` lets +/// imported connections join a group with a matching name instead of duplicating +/// it. New ids (connections, groups, SSH records) are freshly generated. +pub fn build_payload( + envelope: &ImportEnvelope, + resolutions: &[ImportResolution], + registered_ids: &[String], + existing_groups: &[ConnectionGroup], +) -> ExportPayload { + let mut payload = ExportPayload { + version: 1, + groups: Vec::new(), + connections: Vec::new(), + ssh_connections: Vec::new(), + k8s_connections: Vec::new(), + }; + + // Resolve group name -> group id, reusing an existing group when the name + // matches (case-insensitive); otherwise mint a new group once. + let mut group_ids: std::collections::HashMap = std::collections::HashMap::new(); + + for res in resolutions { + if res.action == "skip" { + continue; + } + let conn = match envelope.connections.get(res.index) { + Some(c) => c, + None => continue, + }; + let creds = envelope.credentials_by_index.get(&res.index); + + let group_id = conn.group_name.as_ref().map(|name| { + resolve_group(name, existing_groups, &mut group_ids, &mut payload) + }); + + let conn_id = match res.action.as_str() { + "replace" => res + .replace_existing_id + .clone() + .unwrap_or_else(new_id), + _ => new_id(), + }; + + let (saved, ssh) = build_connection(conn, creds, registered_ids, &conn_id, group_id); + if let Some(ssh) = ssh { + payload.ssh_connections.push(ssh); + } + payload.connections.push(saved); + } + + payload +} + +fn resolve_group( + name: &str, + existing_groups: &[ConnectionGroup], + group_ids: &mut std::collections::HashMap, + payload: &mut ExportPayload, +) -> String { + let key = name.trim().to_ascii_lowercase(); + if let Some(id) = group_ids.get(&key) { + return id.clone(); + } + // Reuse an existing group with the same name. + if let Some(existing) = existing_groups + .iter() + .find(|g| g.name.trim().to_ascii_lowercase() == key) + { + group_ids.insert(key, existing.id.clone()); + return existing.id.clone(); + } + // Otherwise create a new group and include it in the payload. + let id = new_id(); + payload.groups.push(ConnectionGroup { + id: id.clone(), + name: name.to_string(), + collapsed: false, + sort_order: 0, + }); + group_ids.insert(key, id.clone()); + id +} + +fn build_connection( + conn: &ImportedConnection, + creds: Option<&ImportedCredentials>, + registered_ids: &[String], + conn_id: &str, + group_id: Option, +) -> (SavedConnection, Option) { + let (driver, _installed) = driver_map::map_driver_label(&conn.driver_label, registered_ids); + + let mut params = ConnectionParams { + driver, + host: (!conn.host.is_empty()).then(|| conn.host.clone()), + port: (conn.port != 0).then_some(conn.port), + username: (!conn.username.is_empty()).then(|| conn.username.clone()), + password: creds.and_then(|c| c.password.clone()), + database: DatabaseSelection::Single(conn.database.clone()), + ..Default::default() + }; + + if let Some(ssl) = &conn.ssl { + params.ssl_mode = Some(ssl.mode.clone()); + params.ssl_ca = ssl.ca_certificate_path.clone(); + params.ssl_cert = ssl.client_certificate_path.clone(); + params.ssl_key = ssl.client_key_path.clone(); + } + + let ssh_record = conn.ssh.as_ref().map(|ssh| { + let ssh_id = new_id(); + params.ssh_enabled = Some(true); + params.ssh_connection_id = Some(ssh_id.clone()); + params.ssh_password = creds.and_then(|c| c.ssh_password.clone()); + params.ssh_key_passphrase = creds.and_then(|c| c.ssh_key_passphrase.clone()); + + let has_ssh_secret = creds + .map(|c| c.ssh_password.is_some() || c.ssh_key_passphrase.is_some()) + .unwrap_or(false); + + SshConnection { + id: ssh_id, + name: ssh.host.clone(), + host: ssh.host.clone(), + port: ssh.port.unwrap_or(22), + user: ssh.username.clone(), + auth_type: Some(ssh.auth_type.clone()), + password: creds.and_then(|c| c.ssh_password.clone()), + key_file: ssh.private_key_path.clone(), + key_passphrase: creds.and_then(|c| c.ssh_key_passphrase.clone()), + allow_passphrase_prompt: None, + save_in_keychain: Some(has_ssh_secret), + } + }); + + // Persist any recovered secrets to the keychain on apply. + let has_secret = creds.map(|c| !c.is_empty()).unwrap_or(false); + params.save_in_keychain = Some(has_secret); + + let saved = SavedConnection { + id: conn_id.to_string(), + name: conn.name.clone(), + params, + group_id, + sort_order: None, + detect_json_in_text_columns: None, + appearance: None, + }; + (saved, ssh_record) +} + +fn new_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connection_import::types::{ImportedConnection, ImportedSsh}; + + fn base_conn() -> ImportedConnection { + ImportedConnection { + name: "Prod".to_string(), + host: "db.example.com".to_string(), + port: 5432, + database: "app".to_string(), + username: "postgres".to_string(), + driver_label: "PostgreSQL".to_string(), + ssh: None, + ssl: None, + group_name: Some("Work".to_string()), + } + } + + fn res(index: usize, action: &str) -> ImportResolution { + ImportResolution { + index, + action: action.to_string(), + replace_existing_id: None, + } + } + + #[test] + fn imports_connection_with_group() { + let env = ImportEnvelope { + source_name: "X".into(), + connections: vec![base_conn()], + ..Default::default() + }; + let payload = build_payload(&env, &[res(0, "import")], &["postgres".into()], &[]); + assert_eq!(payload.connections.len(), 1); + assert_eq!(payload.groups.len(), 1); + let c = &payload.connections[0]; + assert_eq!(c.params.driver, "postgres"); + assert_eq!(c.group_id.as_deref(), Some(payload.groups[0].id.as_str())); + assert_eq!(c.params.save_in_keychain, Some(false)); + } + + #[test] + fn skip_omits_connection() { + let env = ImportEnvelope { + source_name: "X".into(), + connections: vec![base_conn()], + ..Default::default() + }; + let payload = build_payload(&env, &[res(0, "skip")], &["postgres".into()], &[]); + assert!(payload.connections.is_empty()); + } + + #[test] + fn ssh_becomes_linked_record() { + let mut conn = base_conn(); + conn.ssh = Some(ImportedSsh { + host: "bastion".into(), + port: Some(2222), + username: "deploy".into(), + auth_type: "ssh_key".into(), + private_key_path: Some("~/.ssh/id_rsa".into()), + }); + let mut env = ImportEnvelope { + source_name: "X".into(), + connections: vec![conn], + ..Default::default() + }; + env.credentials_by_index.insert( + 0, + ImportedCredentials { + ssh_key_passphrase: Some("pp".into()), + ..Default::default() + }, + ); + let payload = build_payload(&env, &[res(0, "import")], &["postgres".into()], &[]); + assert_eq!(payload.ssh_connections.len(), 1); + let ssh = &payload.ssh_connections[0]; + let conn = &payload.connections[0]; + assert_eq!(conn.params.ssh_connection_id.as_deref(), Some(ssh.id.as_str())); + assert_eq!(conn.params.ssh_enabled, Some(true)); + assert_eq!(ssh.port, 2222); + assert_eq!(ssh.save_in_keychain, Some(true)); + assert_eq!(conn.params.save_in_keychain, Some(true)); + } + + #[test] + fn replace_reuses_existing_id() { + let env = ImportEnvelope { + source_name: "X".into(), + connections: vec![base_conn()], + ..Default::default() + }; + let mut r = res(0, "replace"); + r.replace_existing_id = Some("existing-123".into()); + let payload = build_payload(&env, &[r], &["postgres".into()], &[]); + assert_eq!(payload.connections[0].id, "existing-123"); + } +} diff --git a/src-tauri/src/connection_import/crypto.rs b/src-tauri/src/connection_import/crypto.rs new file mode 100644 index 00000000..c60cc9b6 --- /dev/null +++ b/src-tauri/src/connection_import/crypto.rs @@ -0,0 +1,127 @@ +//! Decryption helpers for foreign-app credential stores. +//! +//! - DBeaver encrypts `credentials-config.json` with a hardcoded AES-128-CBC +//! key; the 16-byte IV is prepended to the ciphertext. +//! - Beekeeper Studio uses the Node `simple-encryptor` format: +//! ``, AES-256-CBC, +//! key = `SHA-256(rawKeyString)`, plaintext = `JSON.stringify(value)`. +//! +//! Both reuse the already-vendored `openssl` crate. + +use openssl::symm::{decrypt, Cipher}; +use sha2::{Digest, Sha256}; + +/// DBeaver's hardcoded AES-128 key (see `DBeaverImporter.swift`). +const DBEAVER_KEY: [u8; 16] = [ + 0xBA, 0xBB, 0x4A, 0x9F, 0x77, 0x4A, 0xB8, 0x53, 0xC9, 0x6C, 0x2D, 0x65, 0x3D, 0xFE, 0x54, 0x4A, +]; + +/// Beekeeper's hardcoded bootstrap key, used only to unwrap the per-install +/// user key stored in the `.key` file. +pub const BEEKEEPER_BOOTSTRAP_KEY: &str = + "38782F413F442A472D4B6150645367566B59703373367639792442264529482B"; + +/// Decrypt DBeaver's `credentials-config.json` blob. The first 16 bytes are the +/// IV; the rest is AES-128-CBC ciphertext with PKCS#7 padding. +pub fn decrypt_dbeaver(data: &[u8]) -> Option> { + if data.len() <= 16 { + return None; + } + let (iv, ciphertext) = data.split_at(16); + decrypt(Cipher::aes_128_cbc(), &DBEAVER_KEY, Some(iv), ciphertext).ok() +} + +/// Decrypt one Beekeeper `simple-encryptor` payload and return the raw JSON +/// plaintext bytes (still JSON-encoded; callers parse/unquote as needed). +/// HMAC verification is skipped: the file is owned by the user and tampering +/// surfaces as a failed JSON decode downstream. +pub fn decrypt_beekeeper(payload: &str, key_string: &str) -> Option> { + if payload.len() <= 96 { + return None; + } + let iv_hex = &payload[64..96]; + let cipher_b64 = &payload[96..]; + + let iv = hex_decode(iv_hex)?; + if iv.len() != 16 { + return None; + } + let ciphertext = base64_decode(cipher_b64)?; + + let key = Sha256::digest(key_string.as_bytes()); + decrypt(Cipher::aes_256_cbc(), &key, Some(&iv), &ciphertext).ok() +} + +/// Decrypt a Beekeeper payload whose plaintext is a JSON string, returning the +/// unquoted string value (e.g. a password). +pub fn decrypt_beekeeper_string(payload: &str, key_string: &str) -> Option { + let bytes = decrypt_beekeeper(payload, key_string)?; + serde_json::from_slice::(&bytes).ok() +} + +/// Decrypt a Beekeeper payload whose plaintext is a JSON object (the `.key` +/// file: `{"encryptionKey":""}`), returning the `encryptionKey` value. +pub fn decrypt_beekeeper_user_key(payload: &str) -> Option { + let bytes = decrypt_beekeeper(payload, BEEKEEPER_BOOTSTRAP_KEY)?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + value + .get("encryptionKey") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn hex_decode(s: &str) -> Option> { + if s.len() % 2 != 0 { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} + +fn base64_decode(s: &str) -> Option> { + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode(s).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use openssl::symm::encrypt; + + #[test] + fn dbeaver_roundtrip() { + let plaintext = br##"{"conn":{"#connection":{"user":"bob","password":"secret"}}}"##; + let iv = [7u8; 16]; + let ct = encrypt(Cipher::aes_128_cbc(), &DBEAVER_KEY, Some(&iv), plaintext).unwrap(); + let mut blob = iv.to_vec(); + blob.extend_from_slice(&ct); + assert_eq!(decrypt_dbeaver(&blob).unwrap(), plaintext); + } + + #[test] + fn dbeaver_rejects_short_input() { + assert!(decrypt_dbeaver(&[0u8; 8]).is_none()); + } + + #[test] + fn beekeeper_string_roundtrip() { + // Build a simple-encryptor payload: 64 hex (fake hmac) + 32 hex iv + b64 ct. + use base64::Engine; + let key_string = "my-user-key"; + let key = Sha256::digest(key_string.as_bytes()); + let iv = [3u8; 16]; + let plaintext = serde_json::to_vec("hunter2").unwrap(); // JSON-encoded string + let ct = encrypt(Cipher::aes_256_cbc(), &key, Some(&iv), &plaintext).unwrap(); + + let mut payload = "0".repeat(64); // hmac placeholder + payload.push_str(&iv.iter().map(|b| format!("{:02x}", b)).collect::()); + payload.push_str(&base64::engine::general_purpose::STANDARD.encode(&ct)); + + assert_eq!( + decrypt_beekeeper_string(&payload, key_string).unwrap(), + "hunter2" + ); + } +} diff --git a/src-tauri/src/connection_import/datagrip.rs b/src-tauri/src/connection_import/datagrip.rs new file mode 100644 index 00000000..b85189dd --- /dev/null +++ b/src-tauri/src/connection_import/datagrip.rs @@ -0,0 +1,511 @@ +//! DataGrip importer. Reads `dataSources.xml` (+ `dataSources.local.xml`) and +//! `sshConfigs.xml` from the JetBrains config dirs, parsing the JDBC URL for +//! host/port/database. Ported from TablePro's `DataGripImporter.swift` and +//! `DataGripDataSourceParser.swift`. +//! +//! Cross-platform for connection metadata. Passwords are NOT imported: +//! DataGrip keeps them in a JetBrains credential store (Keychain or a KeePass +//! `c.kdbx`) whose format is out of scope for this version. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use roxmltree::{Document, Node}; + +use super::types::{ImportEnvelope, ImportedConnection, ImportedSsh, ImportedSsl}; +use super::{driver_map, resolve_key_path, ForeignAppImporter, ForeignImportError}; + +mod jdbc; + +pub struct DataGripImporter { + jetbrains_root: PathBuf, +} + +impl Default for DataGripImporter { + fn default() -> Self { + Self { + jetbrains_root: default_jetbrains_root(), + } + } +} + +fn default_jetbrains_root() -> PathBuf { + #[cfg(target_os = "macos")] + { + super::home_dir() + .map(|h| h.join("Library/Application Support/JetBrains")) + .unwrap_or_default() + } + #[cfg(target_os = "linux")] + { + directories::BaseDirs::new() + .map(|d| d.config_dir().join("JetBrains")) + .unwrap_or_default() + } + #[cfg(target_os = "windows")] + { + directories::BaseDirs::new() + .map(|d| d.data_dir().join("JetBrains")) + .unwrap_or_default() + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PathBuf::new() + } +} + +struct Location { + data_sources: PathBuf, + local: Option, + config_dir: PathBuf, +} + +#[async_trait::async_trait] +impl ForeignAppImporter for DataGripImporter { + fn id(&self) -> &'static str { + "datagrip" + } + fn display_name(&self) -> &'static str { + "DataGrip" + } + async fn is_available(&self) -> bool { + !self.locations().is_empty() + } + async fn connection_count(&self) -> usize { + let mut seen = std::collections::HashSet::new(); + for loc in self.locations() { + for ds in self.data_sources(&loc) { + seen.insert(ds.uuid.clone()); + } + } + seen.len() + } + + async fn import( + &self, + _include_passwords: bool, + _file: Option<&Path>, + ) -> Result { + let locations = self.locations(); + if locations.is_empty() { + return Err(ForeignImportError::FileNotFound(self.display_name().to_string())); + } + + let mut envelope = ImportEnvelope { + source_name: self.display_name().to_string(), + ..Default::default() + }; + let mut group_names: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut ssh_cache: HashMap> = HashMap::new(); + + for loc in locations { + let ssh_configs = ssh_cache + .entry(loc.config_dir.clone()) + .or_insert_with(|| load_ssh_configs(&loc.config_dir)) + .clone(); + + for ds in self.data_sources(&loc) { + if !seen.insert(ds.uuid.clone()) { + continue; + } + if let Some(conn) = make_connection(&ds, &ssh_configs) { + if let Some(g) = &conn.group_name { + if !group_names.contains(g) { + group_names.push(g.clone()); + } + } + envelope.connections.push(conn); + } + } + } + + if envelope.connections.is_empty() { + return Err(ForeignImportError::NoConnectionsFound); + } + envelope.group_names = group_names; + Ok(envelope) + } +} + +impl DataGripImporter { + #[cfg(test)] + pub(crate) fn with_root(jetbrains_root: PathBuf) -> Self { + Self { jetbrains_root } + } + + /// All `DataGrip*` config dirs, highest version first. + fn config_dirs(&self) -> Vec { + let mut dirs: Vec = std::fs::read_dir(&self.jetbrains_root) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.is_dir() + && p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("DataGrip")) + .unwrap_or(false) + }) + .collect(); + dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name())); + dirs + } + + fn locations(&self) -> Vec { + let mut result = Vec::new(); + for config_dir in self.config_dirs() { + push_location(&config_dir.join("options"), &config_dir, &mut result); + + let projects = config_dir.join("projects"); + if let Ok(entries) = std::fs::read_dir(&projects) { + for project in entries.flatten() { + push_location(&project.path().join(".idea"), &config_dir, &mut result); + } + } + for project_path in recent_project_paths(&config_dir) { + push_location( + &PathBuf::from(project_path).join(".idea"), + &config_dir, + &mut result, + ); + } + } + result + } + + /// Merge the shared and machine-local fragments for one location by uuid. + fn data_sources(&self, location: &Location) -> Vec { + let mut fragments: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + + for path in [Some(&location.data_sources), location.local.as_ref()] + .into_iter() + .flatten() + { + let Ok(text) = std::fs::read_to_string(path) else { continue }; + for frag in parse_fragments(&text) { + if let Some(existing) = fragments.get_mut(&frag.uuid) { + existing.merge(frag); + } else { + order.push(frag.uuid.clone()); + fragments.insert(frag.uuid.clone(), frag); + } + } + } + order + .iter() + .filter_map(|uuid| fragments.get(uuid).and_then(Fragment::resolved)) + .collect() + } +} + +fn push_location(dir: &Path, config_dir: &Path, out: &mut Vec) { + let data_sources = dir.join("dataSources.xml"); + if !data_sources.is_file() { + return; + } + let local = dir.join("dataSources.local.xml"); + out.push(Location { + data_sources, + local: local.is_file().then_some(local), + config_dir: config_dir.to_path_buf(), + }); +} + +fn recent_project_paths(config_dir: &Path) -> Vec { + let path = config_dir.join("options/recentProjects.xml"); + let Ok(text) = std::fs::read_to_string(&path) else { return Vec::new() }; + let Ok(doc) = Document::parse(&text) else { return Vec::new() }; + doc.descendants() + .filter(|n| n.has_tag_name("entry")) + .filter_map(|n| n.attribute("key")) + .map(expand_macros) + .collect() +} + +// MARK: - XML model + +#[derive(Default)] +struct Fragment { + uuid: String, + name: Option, + driver_ref: Option, + jdbc_url: Option, + username: Option, + group_name: Option, + ssh: Option, + ssl: Option, +} + +impl Fragment { + fn merge(&mut self, other: Fragment) { + self.name = other.name.or(self.name.take()); + self.driver_ref = other.driver_ref.or(self.driver_ref.take()); + self.jdbc_url = other.jdbc_url.or(self.jdbc_url.take()); + self.username = other.username.or(self.username.take()); + self.group_name = other.group_name.or(self.group_name.take()); + self.ssh = other.ssh.or(self.ssh.take()); + self.ssl = other.ssl.or(self.ssl.take()); + } + + fn resolved(&self) -> Option { + let driver_ref = self.driver_ref.clone().filter(|s| !s.is_empty())?; + let jdbc_url = self.jdbc_url.clone().filter(|s| !s.is_empty())?; + Some(DataSource { + uuid: self.uuid.clone(), + name: self.name.clone().unwrap_or_else(|| self.uuid.clone()), + driver_ref, + jdbc_url, + username: self.username.clone().unwrap_or_default(), + group_name: self.group_name.clone(), + ssh: self.ssh.clone(), + ssl: self.ssl.clone(), + }) + } +} + +struct DataSource { + uuid: String, + name: String, + driver_ref: String, + jdbc_url: String, + username: String, + group_name: Option, + ssh: Option, + ssl: Option, +} + +#[derive(Clone)] +struct SshReference { + config_id: Option, + inline_host: Option, + inline_port: Option, + inline_user: Option, +} + +#[derive(Clone)] +struct SshConfig { + host: String, + port: Option, + username: String, + auth_type: Option, + key_path: Option, +} + +fn parse_fragments(xml: &str) -> Vec { + let Ok(doc) = Document::parse(xml) else { return Vec::new() }; + doc.descendants() + .filter(|n| n.has_tag_name("data-source")) + .filter_map(parse_fragment) + .collect() +} + +fn parse_fragment(el: Node) -> Option { + let uuid = el.attribute("uuid")?.to_string(); + Some(Fragment { + uuid, + name: el.attribute("name").filter(|s| !s.is_empty()).map(String::from), + driver_ref: child_text(el, "driver-ref"), + jdbc_url: child_text(el, "jdbc-url"), + username: child_text(el, "user-name"), + group_name: el + .attribute("group-name") + .filter(|s| !s.is_empty()) + .map(String::from), + ssh: parse_ssh_reference(el), + ssl: parse_ssl_properties(el), + }) +} + +fn parse_ssh_reference(el: Node) -> Option { + let ssh = child_element(el, "ssh-properties")?; + let enabled = child_text(ssh, "enabled") + .or_else(|| ssh.attribute("enabled").map(String::from)) + .as_deref() + == Some("true"); + if !enabled { + return None; + } + let config_id = child_text(ssh, "ssh-config-id") + .or_else(|| ssh.attribute("ssh-config-id").map(String::from)) + .filter(|s| !s.is_empty()); + Some(SshReference { + config_id, + inline_host: ssh.attribute("host").map(String::from), + inline_port: ssh.attribute("port").and_then(|p| p.parse().ok()), + inline_user: ssh + .attribute("user") + .or_else(|| ssh.attribute("username")) + .map(String::from), + }) +} + +fn parse_ssl_properties(el: Node) -> Option { + let ssl = child_element(el, "ssl-config")?; + if child_text(ssl, "enabled").as_deref() != Some("true") { + return None; + } + let cert = |name: &str| child_text(ssl, name).filter(|s| !s.is_empty()).map(|s| expand_macros(&s)); + Some(ImportedSsl { + mode: child_text(ssl, "mode").unwrap_or_else(|| "prefer".to_string()), + ca_certificate_path: cert("ca-cert"), + client_certificate_path: cert("client-cert"), + client_key_path: cert("client-key"), + }) +} + +fn load_ssh_configs(config_dir: &Path) -> HashMap { + let path = config_dir.join("options/sshConfigs.xml"); + let Ok(text) = std::fs::read_to_string(&path) else { return HashMap::new() }; + let Ok(doc) = Document::parse(&text) else { return HashMap::new() }; + let mut result = HashMap::new(); + for node in doc.descendants().filter(|n| n.has_tag_name("sshConfig")) { + let Some(id) = node.attribute("id") else { continue }; + result.insert( + id.to_string(), + SshConfig { + host: node.attribute("host").unwrap_or("").to_string(), + port: node.attribute("port").and_then(|p| p.parse().ok()), + username: node.attribute("username").unwrap_or("").to_string(), + auth_type: node.attribute("authType").map(String::from), + key_path: node.attribute("keyPath").map(expand_macros), + }, + ); + } + result +} + +fn make_connection(ds: &DataSource, ssh_configs: &HashMap) -> Option { + let subprotocol = jdbc::subprotocol(&ds.jdbc_url); + let driver_label = map_driver_ref(&ds.driver_ref, &subprotocol); + let driver_id = driver_map::canonical_id(&driver_label); + let endpoint = jdbc::parse(&ds.jdbc_url, &subprotocol); + + let host = endpoint + .as_ref() + .map(|e| e.host.clone()) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "localhost".to_string()); + let database = endpoint.as_ref().map(|e| e.database.clone()).unwrap_or_default(); + let port = endpoint + .as_ref() + .and_then(|e| e.port) + .unwrap_or_else(|| driver_map::default_port(&driver_id)); + + Some(ImportedConnection { + name: ds.name.clone(), + host, + port, + database, + username: ds.username.clone(), + driver_label, + ssh: make_ssh(ds.ssh.as_ref(), ssh_configs), + ssl: ds.ssl.clone(), + group_name: ds.group_name.clone(), + }) +} + +fn make_ssh(reference: Option<&SshReference>, ssh_configs: &HashMap) -> Option { + let reference = reference?; + let config = reference.config_id.as_ref().and_then(|id| ssh_configs.get(id)); + let host = config + .map(|c| c.host.clone()) + .or_else(|| reference.inline_host.clone()) + .unwrap_or_default(); + if host.is_empty() { + return None; + } + let key_path = config.and_then(|c| c.key_path.clone()).unwrap_or_default(); + let auth_type = config.and_then(|c| c.auth_type.clone()); + let uses_key = uses_key_auth(auth_type.as_deref(), &key_path); + Some(ImportedSsh { + host, + port: config.and_then(|c| c.port).or(reference.inline_port), + username: config + .map(|c| c.username.clone()) + .or_else(|| reference.inline_user.clone()) + .unwrap_or_default(), + auth_type: if uses_key { "ssh_key" } else { "password" }.to_string(), + private_key_path: if uses_key && !key_path.is_empty() { + Some(resolve_key_path(&key_path)) + } else { + None + }, + }) +} + +/// DataGrip omits `authType` when relying on the OpenSSH config, so a present +/// key path is the reliable signal for key auth. +fn uses_key_auth(auth_type: Option<&str>, key_path: &str) -> bool { + match auth_type.unwrap_or("").to_ascii_uppercase().as_str() { + "KEY_PAIR" | "PUBLIC_KEY" | "OPEN_SSH" => true, + "PASSWORD" => false, + _ => !key_path.is_empty(), + } +} + +fn map_driver_ref(driver_ref: &str, subprotocol: &str) -> String { + let token = driver_ref + .to_ascii_lowercase() + .split('.') + .next() + .unwrap_or("") + .to_string(); + let label = match token.as_str() { + "mysql" => "MySQL", + "mariadb" => "MariaDB", + "postgresql" | "postgres" => "PostgreSQL", + "sqlite" => "SQLite", + "sqlserver" | "mssql" | "jtds" => "SQL Server", + "oracle" => "Oracle", + "mongo" | "mongodb" => "MongoDB", + "redis" => "Redis", + "clickhouse" => "ClickHouse", + "cassandra" => "Cassandra", + "duckdb" => "DuckDB", + "bigquery" => "BigQuery", + "cockroach" | "cockroachdb" => "CockroachDB", + "redshift" => "Redshift", + _ => return map_subprotocol(subprotocol, driver_ref), + }; + label.to_string() +} + +fn map_subprotocol(subprotocol: &str, fallback: &str) -> String { + let label = match subprotocol.to_ascii_lowercase().as_str() { + "mysql" => "MySQL", + "mariadb" => "MariaDB", + "postgresql" => "PostgreSQL", + "sqlite" => "SQLite", + "sqlserver" | "jtds" => "SQL Server", + "oracle" => "Oracle", + "mongodb" => "MongoDB", + "redis" => "Redis", + "clickhouse" => "ClickHouse", + "cassandra" => "Cassandra", + "duckdb" => "DuckDB", + "bigquery" => "BigQuery", + _ => return fallback.to_string(), + }; + label.to_string() +} + +fn expand_macros(path: &str) -> String { + if let Some(home) = super::home_dir() { + return path.replace("$USER_HOME$", &home.to_string_lossy()); + } + path.to_string() +} + +fn child_element<'a>(el: Node<'a, 'a>, name: &str) -> Option> { + el.children().find(|c| c.is_element() && c.has_tag_name(name)) +} + +fn child_text(el: Node, name: &str) -> Option { + child_element(el, name) + .and_then(|c| c.text()) + .map(|t| t.trim().to_string()) + .filter(|s| !s.is_empty()) +} diff --git a/src-tauri/src/connection_import/datagrip/jdbc.rs b/src-tauri/src/connection_import/datagrip/jdbc.rs new file mode 100644 index 00000000..7d183c89 --- /dev/null +++ b/src-tauri/src/connection_import/datagrip/jdbc.rs @@ -0,0 +1,243 @@ +//! Minimal JDBC connection-string parser, extracting host/port/database from +//! the URL forms DataGrip writes. Ported from TablePro's `JDBCConnectionString`. + +pub struct Endpoint { + pub host: String, + pub port: Option, + pub database: String, +} + +/// The subprotocol token in `jdbc::...` (lowercased by the caller +/// where needed). Empty when the URL isn't a JDBC URL. +pub fn subprotocol(url: &str) -> String { + let lower = url.to_ascii_lowercase(); + if !lower.starts_with("jdbc:") { + return String::new(); + } + url["jdbc:".len()..] + .chars() + .take_while(|c| *c != ':' && *c != '/') + .collect() +} + +pub fn parse(url: &str, subprotocol: &str) -> Option { + let trimmed = url.trim(); + if !trimmed.to_ascii_lowercase().starts_with("jdbc:") { + return None; + } + let body = &trimmed["jdbc:".len()..]; + match subprotocol.to_ascii_lowercase().as_str() { + "sqlserver" | "jtds" => parse_sql_server(body), + "oracle" => parse_oracle(body), + "sqlite" | "duckdb" | "h2" => parse_file(body, subprotocol), + _ => parse_authority(body), + } +} + +/// jdbc:://[user[:pass]@]host[:port][/database][?params] +fn parse_authority(body: &str) -> Option { + let idx = body.find("://")?; + let mut remainder = &body[idx + 3..]; + remainder = strip_query(remainder); + let (authority, database) = split_once(remainder, '/'); + let authority = strip_userinfo(authority); + let first_host = authority.split(',').next().unwrap_or(authority); + let (host, port) = parse_host_port(first_host); + if host.is_empty() { + return None; + } + Some(Endpoint { + host, + port, + database: database.unwrap_or_default().to_string(), + }) +} + +/// jdbc:sqlserver://host[\instance][:port][;prop=value;...] +/// jdbc:jtds:sqlserver://host:port/database +fn parse_sql_server(body: &str) -> Option { + let mut remainder = body; + if remainder.to_ascii_lowercase().starts_with("jtds:") { + remainder = &remainder["jtds:".len()..]; + } + if remainder.to_ascii_lowercase().starts_with("sqlserver:") { + remainder = &remainder["sqlserver:".len()..]; + } + let remainder = remainder.strip_prefix("//")?; + let (before_props, props_str) = split_once(remainder, ';'); + let props = parse_semicolon_props(props_str.unwrap_or("")); + let (mut authority, database) = split_once(before_props, '/'); + if let Some(bs) = authority.find('\\') { + authority = &authority[..bs]; + } + let (host, port) = parse_host_port(authority); + if host.is_empty() { + return None; + } + let database = database + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .or_else(|| props.get("databasename").or_else(|| props.get("database")).cloned()) + .unwrap_or_default(); + Some(Endpoint { host, port, database }) +} + +/// jdbc:oracle:thin:@host:port:SID or @//host:port/SERVICE_NAME +fn parse_oracle(body: &str) -> Option { + let at = body.find('@')?; + let mut descriptor = strip_query(&body[at + 1..]).to_string(); + + if let Some(rest) = descriptor.strip_prefix("//") { + let (authority, database) = split_once(rest, '/'); + let (host, port) = parse_host_port(authority); + if host.is_empty() { + return None; + } + return Some(Endpoint { + host, + port, + database: database.unwrap_or_default().to_string(), + }); + } + + let parts: Vec<&str> = descriptor.split(':').collect(); + if parts.len() < 2 || parts[0].is_empty() { + let (authority, database) = split_once(&descriptor, '/'); + let (host, port) = parse_host_port(authority); + if host.is_empty() { + return None; + } + return Some(Endpoint { + host, + port, + database: database.unwrap_or_default().to_string(), + }); + } + let host = parts[0].to_string(); + let (port_str, service) = split_once(parts[1], '/'); + let port = port_str.parse().ok(); + let database = service + .map(|s| s.to_string()) + .unwrap_or_else(|| parts.get(2).map(|s| s.to_string()).unwrap_or_default()); + descriptor.clear(); + Some(Endpoint { host, port, database }) +} + +/// jdbc:sqlite:/path/to/file.db +fn parse_file(body: &str, subprotocol: &str) -> Option { + let prefix = format!("{}:", subprotocol.to_ascii_lowercase()); + let path = if body.to_ascii_lowercase().starts_with(&prefix) { + &body[prefix.len()..] + } else { + body + }; + Some(Endpoint { + host: String::new(), + port: None, + database: strip_query(path).to_string(), + }) +} + +// MARK: - Helpers + +fn parse_host_port(authority: &str) -> (String, Option) { + if let Some(rest) = authority.strip_prefix('[') { + // IPv6 literal: [host]:port + if let Some(close) = rest.find(']') { + let host = rest[..close].to_string(); + let after = &rest[close + 1..]; + let port = after.strip_prefix(':').and_then(|p| p.parse().ok()); + return (host, port); + } + return (authority.to_string(), None); + } + match authority.rfind(':') { + Some(colon) => { + let host = authority[..colon].to_string(); + let port = authority[colon + 1..].parse().ok(); + (host, port) + } + None => (authority.to_string(), None), + } +} + +fn strip_userinfo(authority: &str) -> &str { + match authority.rfind('@') { + Some(at) => &authority[at + 1..], + None => authority, + } +} + +fn strip_query(value: &str) -> &str { + match value.find('?') { + Some(q) => &value[..q], + None => value, + } +} + +fn split_once(value: &str, sep: char) -> (&str, Option<&str>) { + match value.find(sep) { + Some(i) => (&value[..i], Some(&value[i + 1..])), + None => (value, None), + } +} + +fn parse_semicolon_props(value: &str) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + for pair in value.split(';').filter(|p| !p.is_empty()) { + if let (k, Some(v)) = split_once(pair, '=') { + map.insert(k.trim().to_ascii_lowercase(), v.to_string()); + } + } + map +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_postgres_authority() { + let e = parse("jdbc:postgresql://db.example.com:6543/mydb", "postgresql").unwrap(); + assert_eq!(e.host, "db.example.com"); + assert_eq!(e.port, Some(6543)); + assert_eq!(e.database, "mydb"); + } + + #[test] + fn parses_mysql_without_port() { + let e = parse("jdbc:mysql://localhost/app", "mysql").unwrap(); + assert_eq!(e.host, "localhost"); + assert_eq!(e.port, None); + assert_eq!(e.database, "app"); + } + + #[test] + fn strips_userinfo_and_query() { + let e = parse("jdbc:mysql://user:pw@host:3306/db?useSSL=true", "mysql").unwrap(); + assert_eq!(e.host, "host"); + assert_eq!(e.port, Some(3306)); + assert_eq!(e.database, "db"); + } + + #[test] + fn parses_sqlserver_with_props() { + let e = parse("jdbc:sqlserver://host:1433;databaseName=sales", "sqlserver").unwrap(); + assert_eq!(e.host, "host"); + assert_eq!(e.port, Some(1433)); + assert_eq!(e.database, "sales"); + } + + #[test] + fn parses_sqlite_file() { + let e = parse("jdbc:sqlite:/data/app.db", "sqlite").unwrap(); + assert_eq!(e.host, ""); + assert_eq!(e.database, "/data/app.db"); + } + + #[test] + fn subprotocol_extraction() { + assert_eq!(subprotocol("jdbc:postgresql://x"), "postgresql"); + assert_eq!(subprotocol("not-jdbc"), ""); + } +} diff --git a/src-tauri/src/connection_import/dbeaver.rs b/src-tauri/src/connection_import/dbeaver.rs new file mode 100644 index 00000000..e19a92b1 --- /dev/null +++ b/src-tauri/src/connection_import/dbeaver.rs @@ -0,0 +1,379 @@ +//! DBeaver importer. Reads `data-sources.json` from a DBeaver workspace and +//! decrypts `credentials-config.json` (AES-128-CBC, hardcoded key). DBeaver is +//! cross-platform; the workspace lives under a per-OS data root. Ported from +//! TablePro's `DBeaverImporter.swift`. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use super::types::{ + ImportEnvelope, ImportedConnection, ImportedCredentials, ImportedSsh, ImportedSsl, +}; +use super::{crypto, driver_map, resolve_key_path, ForeignAppImporter, ForeignImportError}; + +pub struct DBeaverImporter { + /// Directory containing `workspace*` folders. Injectable for tests. + data_root: PathBuf, +} + +impl Default for DBeaverImporter { + fn default() -> Self { + Self { + data_root: default_data_root(), + } + } +} + +/// Per-OS DBeaver data root. macOS uses `~/Library/DBeaverData`; Linux and +/// Windows use the platform data dir (`~/.local/share`, `%APPDATA%`). +fn default_data_root() -> PathBuf { + #[cfg(target_os = "macos")] + { + super::home_dir() + .map(|h| h.join("Library/DBeaverData")) + .unwrap_or_default() + } + #[cfg(not(target_os = "macos"))] + { + directories::BaseDirs::new() + .map(|d| d.data_dir().join("DBeaverData")) + .unwrap_or_default() + } +} + +#[async_trait::async_trait] +impl ForeignAppImporter for DBeaverImporter { + fn id(&self) -> &'static str { + "dbeaver" + } + fn display_name(&self) -> &'static str { + "DBeaver" + } + async fn is_available(&self) -> bool { + self.find_data_sources_file().is_some() + } + async fn connection_count(&self) -> usize { + self.find_data_sources_file() + .and_then(|p| load_json(&p)) + .and_then(|j| { + j.get("connections") + .and_then(Value::as_object) + .map(|m| m.len()) + }) + .unwrap_or(0) + } + + async fn import( + &self, + include_passwords: bool, + _file: Option<&Path>, + ) -> Result { + let ds_path = self + .find_data_sources_file() + .ok_or_else(|| ForeignImportError::FileNotFound(self.display_name().to_string()))?; + + let json = load_json(&ds_path) + .ok_or_else(|| ForeignImportError::ParseError("Could not parse data-sources.json".into()))?; + + let connections = json + .get("connections") + .and_then(Value::as_object) + .ok_or_else(|| { + ForeignImportError::UnsupportedFormat("Missing connections key".into()) + })?; + + let empty = serde_json::Map::new(); + let folders = json.get("folders").and_then(Value::as_object).unwrap_or(&empty); + + let creds_map = if include_passwords { + let creds_path = ds_path + .parent() + .map(|p| p.join("credentials-config.json")) + .unwrap_or_default(); + load_credentials(&creds_path) + } else { + serde_json::Map::new() + }; + + let mut envelope = ImportEnvelope { + source_name: self.display_name().to_string(), + ..Default::default() + }; + let mut group_names: Vec = Vec::new(); + + for (conn_id, conn_val) in connections { + let conn_dict = match conn_val.as_object() { + Some(d) => d, + None => continue, + }; + let credential_username = creds_map + .get(conn_id) + .and_then(|c| c.get("#connection")) + .and_then(|c| c.get("user")) + .and_then(Value::as_str); + + let conn = parse_connection(conn_id, conn_dict, folders, credential_username); + if let Some(g) = &conn.group_name { + if !group_names.contains(g) { + group_names.push(g.clone()); + } + } + let index = envelope.connections.len(); + envelope.connections.push(conn); + + if include_passwords { + if let Some(conn_creds) = creds_map.get(conn_id) { + let creds = extract_credentials(conn_creds); + if !creds.is_empty() { + envelope.credentials_by_index.insert(index, creds); + } + } + } + } + + if envelope.connections.is_empty() { + return Err(ForeignImportError::NoConnectionsFound); + } + envelope.group_names = group_names; + Ok(envelope) + } +} + +impl DBeaverImporter { + #[cfg(test)] + pub(crate) fn with_data_root(data_root: PathBuf) -> Self { + Self { data_root } + } + + /// Scan `/workspace*//.dbeaver/data-sources.json`, + /// preferring the highest workspace version. + fn find_data_sources_file(&self) -> Option { + let mut workspaces: Vec = std::fs::read_dir(&self.data_root) + .ok()? + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.is_dir() + && p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("workspace")) + .unwrap_or(false) + }) + .collect(); + workspaces.sort_by(|a, b| b.file_name().cmp(&a.file_name())); + + for workspace in workspaces { + let projects = match std::fs::read_dir(&workspace) { + Ok(p) => p, + Err(_) => continue, + }; + for project in projects.flatten() { + let candidate = project.path().join(".dbeaver/data-sources.json"); + if candidate.is_file() { + return Some(candidate); + } + } + } + None + } +} + +fn load_json(path: &Path) -> Option { + let data = std::fs::read(path).ok()?; + serde_json::from_slice(&data).ok() +} + +/// Decrypt and parse `credentials-config.json` into a map keyed by connection id. +fn load_credentials(path: &Path) -> serde_json::Map { + let data = match std::fs::read(path) { + Ok(d) => d, + Err(_) => return serde_json::Map::new(), + }; + let decrypted = match crypto::decrypt_dbeaver(&data) { + Some(d) => d, + None => return serde_json::Map::new(), + }; + serde_json::from_slice::(&decrypted) + .ok() + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() +} + +fn parse_connection( + conn_id: &str, + dict: &serde_json::Map, + folders: &serde_json::Map, + credential_username: Option<&str>, +) -> ImportedConnection { + let name = dict + .get("name") + .and_then(Value::as_str) + .unwrap_or(conn_id) + .to_string(); + let provider = dict.get("provider").and_then(Value::as_str).unwrap_or(""); + let driver_label = map_provider(provider); + + let config = dict.get("configuration").and_then(Value::as_object); + let host = config + .and_then(|c| c.get("host")) + .and_then(Value::as_str) + .unwrap_or("localhost") + .to_string(); + let port = config + .and_then(|c| c.get("port")) + .and_then(parse_port) + .unwrap_or_else(|| driver_map::default_port(&driver_map::canonical_id(&driver_label))); + let database = config + .and_then(|c| c.get("database").or_else(|| c.get("url"))) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let config_user = config + .and_then(|c| c.get("user")) + .and_then(Value::as_str) + .unwrap_or(""); + let username = credential_username + .filter(|s| !s.is_empty()) + .unwrap_or(config_user) + .to_string(); + + let group_name = dict + .get("folder") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(|path| { + folders + .get(path) + .and_then(|f| f.get("description")) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .unwrap_or_else(|| path.rsplit('/').next().unwrap_or(path).to_string()) + }); + + ImportedConnection { + name, + host, + port, + database, + username, + driver_label, + ssh: config.and_then(parse_ssh), + ssl: config.and_then(parse_ssl), + group_name, + } +} + +fn parse_ssh(config: &serde_json::Map) -> Option { + let tunnel = config + .get("handlers") + .and_then(|h| h.get("ssh_tunnel")) + .and_then(Value::as_object)?; + let properties = tunnel.get("properties").and_then(Value::as_object); + + let enabled = tunnel + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or_else(|| properties.map(|p| p.contains_key("host")).unwrap_or(false)); + if !enabled { + return None; + } + let props = properties?; + let host = props.get("host").and_then(Value::as_str).unwrap_or("").to_string(); + let port = props.get("port").and_then(parse_port); + let username = props + .get("username") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let auth_type = props.get("authType").and_then(Value::as_str).unwrap_or("PASSWORD"); + let key_path = props.get("keyPath").and_then(Value::as_str).unwrap_or(""); + + let (auth, private_key_path) = if auth_type == "PUBLIC_KEY" { + ("ssh_key".to_string(), Some(resolve_key_path(key_path))) + } else { + ("password".to_string(), None) + }; + + Some(ImportedSsh { + host, + port, + username, + auth_type: auth, + private_key_path, + }) +} + +fn parse_ssl(config: &serde_json::Map) -> Option { + let ssl = config + .get("handlers") + .and_then(|h| h.get("ssl")) + .and_then(Value::as_object)?; + if !ssl.get("enabled").and_then(Value::as_bool).unwrap_or(false) { + return None; + } + let props = ssl.get("properties").and_then(Value::as_object); + let mode = match props.and_then(|p| p.get("sslMode")).and_then(Value::as_str) { + Some("require") => "require", + Some("verify-ca") => "verify-ca", + Some("verify-full") => "verify-full", + _ => "prefer", + } + .to_string(); + let get = |k: &str| { + props + .and_then(|p| p.get(k)) + .and_then(Value::as_str) + .map(|s| s.to_string()) + }; + Some(ImportedSsl { + mode, + ca_certificate_path: get("caCertPath"), + client_certificate_path: get("clientCertPath"), + client_key_path: get("clientKeyPath"), + }) +} + +fn extract_credentials(conn_creds: &Value) -> ImportedCredentials { + let password = conn_creds + .get("#connection") + .and_then(|c| c.get("password")) + .and_then(Value::as_str) + .map(|s| s.to_string()); + let ssh_password = conn_creds + .get("ssh_tunnel") + .and_then(|c| c.get("password")) + .and_then(Value::as_str) + .map(|s| s.to_string()); + ImportedCredentials { + password, + ssh_password, + ssh_key_passphrase: None, + } +} + +/// DBeaver stores ports as either a number or a string. +fn parse_port(v: &Value) -> Option { + if let Some(n) = v.as_u64() { + return u16::try_from(n).ok(); + } + v.as_str().and_then(|s| s.parse().ok()) +} + +fn map_provider(provider: &str) -> String { + match provider.to_ascii_lowercase().as_str() { + "mysql" => "MySQL", + "postgresql" => "PostgreSQL", + "sqlite" => "SQLite", + "sqlserver" => "SQL Server", + "oracle" => "Oracle", + "mongo" | "mongodb" => "MongoDB", + "redis" => "Redis", + "clickhouse" => "ClickHouse", + "mariadb" => "MariaDB", + "cassandra" => "Cassandra", + other => return other.to_string(), + } + .to_string() +} diff --git a/src-tauri/src/connection_import/driver_map.rs b/src-tauri/src/connection_import/driver_map.rs new file mode 100644 index 00000000..8e64de59 --- /dev/null +++ b/src-tauri/src/connection_import/driver_map.rs @@ -0,0 +1,96 @@ +//! Maps a foreign app's database label to a Tabularis driver id. +//! +//! Built-in drivers are `postgres`, `mysql`, `sqlite` (see +//! `src/utils/connections.ts`). MariaDB rides the MySQL driver. Anything else +//! is only importable when a plugin driver with the same id is registered; the +//! analyzer flags the rest with a "driver not installed" warning but still lets +//! the user import the metadata. + +/// Canonical driver id for a foreign label, plus whether that driver is +/// currently registered (built-in or via plugin). +pub fn map_driver_label(label: &str, registered_ids: &[String]) -> (String, bool) { + let id = canonical_id(label); + let installed = registered_ids.iter().any(|r| r == &id); + (id, installed) +} + +/// Normalize a source-app driver label/provider/subprotocol to a Tabularis id. +/// Built-ins win; for everything else we lower-case the label as a best guess +/// at a plugin id (e.g. "MongoDB" -> "mongodb"). +pub fn canonical_id(label: &str) -> String { + match label.trim().to_ascii_lowercase().as_str() { + "postgresql" | "postgres" | "postgre" => "postgres".to_string(), + "mysql" | "mariadb" => "mysql".to_string(), + "sqlite" | "sqlite3" => "sqlite".to_string(), + "sql server" | "sqlserver" | "mssql" | "jtds" => "mssql".to_string(), + "mongodb" | "mongo" => "mongodb".to_string(), + "redis" => "redis".to_string(), + "oracle" => "oracle".to_string(), + "clickhouse" => "clickhouse".to_string(), + "cockroachdb" | "cockroach" => "cockroachdb".to_string(), + "redshift" => "redshift".to_string(), + "cassandra" => "cassandra".to_string(), + "bigquery" => "bigquery".to_string(), + "duckdb" => "duckdb".to_string(), + "libsql" => "libsql".to_string(), + other => other.replace(' ', ""), + } +} + +/// Default port for a Tabularis driver id, used when the source omits one. +/// 0 means "no port" (file-based drivers such as SQLite). +pub fn default_port(driver_id: &str) -> u16 { + match driver_id { + "postgres" | "redshift" => 5432, + "mysql" => 3306, + "cockroachdb" => 26257, + "mssql" => 1433, + "oracle" => 1521, + "mongodb" => 27017, + "redis" => 6379, + "clickhouse" => 8123, + "cassandra" => 9042, + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_builtin_labels() { + assert_eq!(canonical_id("PostgreSQL"), "postgres"); + assert_eq!(canonical_id("MySQL"), "mysql"); + assert_eq!(canonical_id("MariaDB"), "mysql"); + assert_eq!(canonical_id("SQLite"), "sqlite"); + } + + #[test] + fn maps_plugin_labels_lowercased() { + assert_eq!(canonical_id("MongoDB"), "mongodb"); + assert_eq!(canonical_id("SQL Server"), "mssql"); + assert_eq!(canonical_id("ClickHouse"), "clickhouse"); + } + + #[test] + fn installed_flag_reflects_registry() { + let registered = vec!["postgres".to_string(), "mysql".to_string()]; + assert_eq!( + map_driver_label("PostgreSQL", ®istered), + ("postgres".to_string(), true) + ); + assert_eq!( + map_driver_label("MongoDB", ®istered), + ("mongodb".to_string(), false) + ); + } + + #[test] + fn default_ports() { + assert_eq!(default_port("postgres"), 5432); + assert_eq!(default_port("mysql"), 3306); + assert_eq!(default_port("sqlite"), 0); + assert_eq!(default_port("mssql"), 1433); + } +} diff --git a/src-tauri/src/connection_import/importer_tests.rs b/src-tauri/src/connection_import/importer_tests.rs new file mode 100644 index 00000000..3cdd34c6 --- /dev/null +++ b/src-tauri/src/connection_import/importer_tests.rs @@ -0,0 +1,182 @@ +//! Fixture-based tests for the foreign-app importers. Each writes a synthetic +//! copy of the source app's config to a temp dir and asserts the parsed result. +//! Credential paths that need the OS Keychain / decryption keys are exercised +//! with `include_passwords = false` (metadata only). + +use std::fs; +use std::path::PathBuf; + +use super::beekeeper::BeekeeperImporter; +use super::datagrip::DataGripImporter; +use super::dbeaver::DBeaverImporter; +use super::tableplus::TablePlusImporter; +use super::ForeignAppImporter; + +fn write(path: &PathBuf, contents: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); +} + +#[tokio::test] +async fn dbeaver_parses_data_sources() { + let tmp = tempfile::tempdir().unwrap(); + let ds = tmp + .path() + .join("workspace6/General/.dbeaver/data-sources.json"); + write( + &ds, + r#"{ + "connections": { + "pg-1": { + "name": "Prod", + "provider": "postgresql", + "folder": "Work", + "configuration": { + "host": "db.example.com", + "port": 6543, + "database": "app", + "user": "postgres" + } + } + }, + "folders": { "Work": {} } + }"#, + ); + + let importer = DBeaverImporter::with_data_root(tmp.path().to_path_buf()); + assert!(importer.is_available().await); + assert_eq!(importer.connection_count().await, 1); + + let env = importer.import(false, None).await.unwrap(); + assert_eq!(env.connections.len(), 1); + let c = &env.connections[0]; + assert_eq!(c.name, "Prod"); + assert_eq!(c.host, "db.example.com"); + assert_eq!(c.port, 6543); + assert_eq!(c.database, "app"); + assert_eq!(c.username, "postgres"); + assert_eq!(c.driver_label, "PostgreSQL"); + assert_eq!(c.group_name.as_deref(), Some("Work")); +} + +#[tokio::test] +async fn tableplus_parses_plist() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("Data"); + write( + &data_dir.join("Connections.plist"), + r#" + + + + + ConnectionNameLocal PG + DriverPostgreSQL + DatabaseHostlocalhost + DatabasePort5433 + DatabaseUserme + DatabaseNamemydb + IDABC + + +"#, + ); + + let importer = TablePlusImporter::with_data_dir(data_dir); + assert_eq!(importer.connection_count().await, 1); + + let env = importer.import(false, None).await.unwrap(); + let c = &env.connections[0]; + assert_eq!(c.name, "Local PG"); + assert_eq!(c.driver_label, "PostgreSQL"); + assert_eq!(c.port, 5433); + assert_eq!(c.database, "mydb"); + assert_eq!(c.username, "me"); +} + +#[tokio::test] +async fn datagrip_parses_xml() { + let tmp = tempfile::tempdir().unwrap(); + let ds = tmp + .path() + .join("DataGrip2024.3/options/dataSources.xml"); + write( + &ds, + r#" + + + + postgresql + jdbc:postgresql://reports.db:5432/analytics + analyst + + +"#, + ); + + let importer = DataGripImporter::with_root(tmp.path().to_path_buf()); + assert!(importer.is_available().await); + + let env = importer.import(false, None).await.unwrap(); + assert_eq!(env.connections.len(), 1); + let c = &env.connections[0]; + assert_eq!(c.name, "Reporting"); + assert_eq!(c.host, "reports.db"); + assert_eq!(c.port, 5432); + assert_eq!(c.database, "analytics"); + assert_eq!(c.username, "analyst"); + assert_eq!(c.driver_label, "PostgreSQL"); +} + +#[tokio::test] +async fn beekeeper_reads_sqlite() { + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + + let tmp = tempfile::tempdir().unwrap(); + let db_path = tmp.path().join("app.db"); + + // Seed a minimal Beekeeper-shaped database. + let opts = SqliteConnectOptions::new() + .filename(&db_path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await + .unwrap(); + sqlx::query( + "CREATE TABLE saved_connection ( + id INTEGER PRIMARY KEY, name TEXT, connectionType TEXT, host TEXT, port INTEGER, + username TEXT, defaultDatabase TEXT, password TEXT, ssl INTEGER, sslCaFile TEXT, + sslCertFile TEXT, sslKeyFile TEXT, sslRejectUnauthorized INTEGER, + trustServerCertificate INTEGER, sshEnabled INTEGER, sshHost TEXT, sshPort INTEGER, + sshUsername TEXT, sshMode TEXT, sshKeyfile TEXT, sshKeyfilePassword TEXT, + sshPassword TEXT, sshBastionHost TEXT, sshBastionHostPort INTEGER, + sshBastionUsername TEXT, sshBastionMode TEXT, sshBastionKeyfile TEXT, + labelColor TEXT, connectionFolderId INTEGER, workspaceId INTEGER )", + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO saved_connection (name, connectionType, host, port, username, defaultDatabase, ssl, sshEnabled, workspaceId) + VALUES ('Staging', 'mysql', 'mysql.local', 3307, 'root', 'shop', 0, 0, -1)", + ) + .execute(&pool) + .await + .unwrap(); + pool.close().await; + + let importer = BeekeeperImporter::with_data_dir(tmp.path().to_path_buf()); + assert!(importer.is_available().await); + assert_eq!(importer.connection_count().await, 1); + + let env = importer.import(false, None).await.unwrap(); + let c = &env.connections[0]; + assert_eq!(c.name, "Staging"); + assert_eq!(c.driver_label, "MySQL"); + assert_eq!(c.host, "mysql.local"); + assert_eq!(c.port, 3307); + assert_eq!(c.database, "shop"); + assert_eq!(c.username, "root"); +} diff --git a/src-tauri/src/connection_import/keychain_read.rs b/src-tauri/src/connection_import/keychain_read.rs new file mode 100644 index 00000000..60a1589d --- /dev/null +++ b/src-tauri/src/connection_import/keychain_read.rs @@ -0,0 +1,39 @@ +//! Reads generic passwords from the OS credential store on macOS, used by +//! importers whose source app keeps secrets in the login Keychain (TablePlus, +//! Sequel Ace, DataGrip). +//! +//! On macOS a generic password is keyed by (service, account); the `keyring` +//! crate's `Entry::new(service, account)` maps to exactly that. Reading another +//! app's item triggers a per-item access prompt — expected behaviour, mirrored +//! from TablePro. On non-macOS platforms these source apps don't use a shared +//! keychain, so we report `NotFound` without touching the local store. + +/// Outcome of a single keychain lookup. `Cancelled` means the user denied the +/// access prompt; callers stop prompting for the rest of the import. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeychainReadResult { + Found(String), + NotFound, + Cancelled, +} + +#[cfg(target_os = "macos")] +pub fn read_generic_password(service: &str, account: &str) -> KeychainReadResult { + match keyring::Entry::new(service, account) { + Ok(entry) => match entry.get_password() { + Ok(value) => KeychainReadResult::Found(value), + Err(keyring::Error::NoEntry) => KeychainReadResult::NotFound, + Err(_) => { + // Access denied / user cancelled / item not decryptable: treat + // as cancellation so the caller stops issuing more prompts. + KeychainReadResult::Cancelled + } + }, + Err(_) => KeychainReadResult::NotFound, + } +} + +#[cfg(not(target_os = "macos"))] +pub fn read_generic_password(_service: &str, _account: &str) -> KeychainReadResult { + KeychainReadResult::NotFound +} diff --git a/src-tauri/src/connection_import/mod.rs b/src-tauri/src/connection_import/mod.rs new file mode 100644 index 00000000..4e3a15f4 --- /dev/null +++ b/src-tauri/src/connection_import/mod.rs @@ -0,0 +1,161 @@ +//! Import database connections from other installed SQL clients. +//! +//! Ported from TablePro's `ForeignApp` importers. Each [`ForeignAppImporter`] +//! reads a third-party client's on-disk config, decrypts any credentials and +//! returns a neutral [`ImportEnvelope`]. [`analyzer`] annotates the result +//! (duplicates / warnings) and [`convert`] turns it into Tabularis' +//! `ExportPayload`, which is merged through the existing import path. + +use std::path::{Path, PathBuf}; + +pub mod analyzer; +pub mod convert; +pub mod crypto; +pub mod driver_map; +pub mod keychain_read; +pub mod types; + +mod beekeeper; +mod dbeaver; +mod datagrip; +mod sequelace; +mod tableplus; + +#[cfg(test)] +mod importer_tests; + +pub use types::{ImportEnvelope, ImportedConnection, ImportedCredentials, ImportedSsh, ImportedSsl}; + +/// Errors surfaced while importing from a foreign app. +#[derive(Debug, Clone)] +pub enum ForeignImportError { + FileNotFound(String), + ParseError(String), + UnsupportedFormat(String), + NoConnectionsFound, +} + +impl std::fmt::Display for ForeignImportError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ForeignImportError::FileNotFound(app) => { + write!(f, "Could not find {} data files", app) + } + ForeignImportError::ParseError(detail) => { + write!(f, "Failed to parse connections: {}", detail) + } + ForeignImportError::UnsupportedFormat(detail) => { + write!(f, "Unsupported file format: {}", detail) + } + ForeignImportError::NoConnectionsFound => { + write!(f, "No connections found to import") + } + } + } +} + +impl std::error::Error for ForeignImportError {} + +/// One importable source app. Implementations are stateless: file locations are +/// computed from the user's home directory each call so tests can override the +/// home via [`home_dir`]-independent constructors where needed. +/// +/// Async because some sources (Beekeeper Studio) read an external SQLite file +/// through `sqlx`; the file-based sources simply don't await. +#[async_trait::async_trait] +pub trait ForeignAppImporter: Send + Sync { + /// Stable identifier used by the frontend (e.g. "dbeaver"). + fn id(&self) -> &'static str; + /// Human-readable name shown in the picker (e.g. "DBeaver"). + fn display_name(&self) -> &'static str; + /// True when reading passwords hits the macOS Keychain (per-item prompt). + fn reads_passwords_from_keychain(&self) -> bool { + false + } + /// File extensions to filter to when the importer reads a user-picked file + /// instead of auto-discovering installed data. `None` = auto-discover. + fn import_file_types(&self) -> Option> { + None + } + /// True when the source app's data is present on disk. + async fn is_available(&self) -> bool; + /// Best-effort count of discoverable connections (0 on any error). + async fn connection_count(&self) -> usize; + /// Run the import. `file` is only set for file-sourced importers. + async fn import( + &self, + include_passwords: bool, + file: Option<&Path>, + ) -> Result; +} + +/// All known importers, in the order shown to the user. +pub fn all_importers() -> Vec> { + vec![ + Box::new(dbeaver::DBeaverImporter::default()), + Box::new(beekeeper::BeekeeperImporter::default()), + Box::new(tableplus::TablePlusImporter::default()), + Box::new(datagrip::DataGripImporter::default()), + Box::new(sequelace::SequelAceImporter::default()), + ] +} + +/// Look up a single importer by id. +pub fn importer_by_id(id: &str) -> Option> { + all_importers().into_iter().find(|i| i.id() == id) +} + +// MARK: - Path helpers + +/// The current user's home directory (`$HOME` / `%USERPROFILE%`). +pub fn home_dir() -> Option { + directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) +} + +/// Expand a leading `~/` to the user's home directory. +pub fn expand_home(path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = home_dir() { + return home.join(rest).to_string_lossy().to_string(); + } + } + path.to_string() +} + +/// Resolve a private-key reference the way the foreign apps store them: an +/// absolute or `~/`-rooted path is kept as-is, a bare filename is assumed to +/// live under `~/.ssh/`. +pub fn resolve_key_path(path: &str) -> String { + let trimmed = path.trim(); + if trimmed.is_empty() { + return String::new(); + } + if trimmed.starts_with('/') || trimmed.starts_with("~/") { + return trimmed.to_string(); + } + format!("~/.ssh/{}", trimmed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_unique_ids() { + let importers = all_importers(); + let mut ids: Vec<&str> = importers.iter().map(|i| i.id()).collect(); + ids.sort_unstable(); + let len = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), len, "importer ids must be unique"); + assert!(importer_by_id("dbeaver").is_some()); + } + + #[test] + fn resolve_key_path_rules() { + assert_eq!(resolve_key_path("/abs/key"), "/abs/key"); + assert_eq!(resolve_key_path("~/keys/id"), "~/keys/id"); + assert_eq!(resolve_key_path("id_rsa"), "~/.ssh/id_rsa"); + assert_eq!(resolve_key_path(""), ""); + } +} diff --git a/src-tauri/src/connection_import/sequelace.rs b/src-tauri/src/connection_import/sequelace.rs new file mode 100644 index 00000000..8c1e2f7e --- /dev/null +++ b/src-tauri/src/connection_import/sequelace.rs @@ -0,0 +1,254 @@ +//! Sequel Ace importer (macOS). Reads `Favorites.plist` from the sandbox +//! container and pulls passwords from the login Keychain. Sequel Ace is +//! MySQL-only. Ported from TablePro's `SequelAceImporter.swift`. + +use std::path::{Path, PathBuf}; + +use plist::Value; + +use super::keychain_read::{read_generic_password, KeychainReadResult}; +use super::types::{ImportEnvelope, ImportedConnection, ImportedCredentials, ImportedSsh, ImportedSsl}; +use super::{resolve_key_path, ForeignAppImporter, ForeignImportError}; + +pub struct SequelAceImporter { + favorites_override: Option, +} + +impl Default for SequelAceImporter { + fn default() -> Self { + Self { + favorites_override: None, + } + } +} + +#[async_trait::async_trait] +impl ForeignAppImporter for SequelAceImporter { + fn id(&self) -> &'static str { + "sequelace" + } + fn display_name(&self) -> &'static str { + "Sequel Ace" + } + fn reads_passwords_from_keychain(&self) -> bool { + true + } + async fn is_available(&self) -> bool { + self.favorites_file().is_file() + } + async fn connection_count(&self) -> usize { + self.root_children().map(|c| count(&c)).unwrap_or(0) + } + + async fn import( + &self, + include_passwords: bool, + _file: Option<&Path>, + ) -> Result { + if !self.favorites_file().is_file() { + return Err(ForeignImportError::FileNotFound(self.display_name().to_string())); + } + let children = self.root_children().ok_or_else(|| { + ForeignImportError::UnsupportedFormat("Missing Favorites Root or Children".into()) + })?; + + let mut envelope = ImportEnvelope { + source_name: self.display_name().to_string(), + ..Default::default() + }; + let mut group_names: Vec = Vec::new(); + parse_children( + &children, + None, + include_passwords, + &mut envelope, + &mut group_names, + ); + + if envelope.connections.is_empty() { + return Err(ForeignImportError::NoConnectionsFound); + } + envelope.group_names = group_names; + Ok(envelope) + } +} + +impl SequelAceImporter { + #[cfg(test)] + pub(crate) fn with_favorites(path: PathBuf) -> Self { + Self { + favorites_override: Some(path), + } + } + + fn favorites_file(&self) -> PathBuf { + if let Some(p) = &self.favorites_override { + return p.clone(); + } + super::home_dir() + .unwrap_or_default() + .join("Library/Containers/com.sequel-ace.sequel-ace/Data/Library/Application Support/Sequel Ace/Data/Favorites.plist") + } + + fn root_children(&self) -> Option> { + let root = Value::from_file(self.favorites_file()).ok()?; + root.as_dictionary()? + .get("Favorites Root")? + .as_dictionary()? + .get("Children")? + .as_array() + .cloned() + } +} + +fn count(children: &[Value]) -> usize { + let mut n = 0; + for child in children { + let Some(dict) = child.as_dictionary() else { continue }; + if let Some(sub) = dict.get("Children").and_then(Value::as_array) { + n += count(sub); + } else if dict.contains_key("host") || dict.contains_key("id") { + n += 1; + } + } + n +} + +fn parse_children( + children: &[Value], + group_name: Option<&str>, + include_passwords: bool, + envelope: &mut ImportEnvelope, + group_names: &mut Vec, +) { + for child in children { + let Some(dict) = child.as_dictionary() else { continue }; + if let Some(sub) = dict.get("Children").and_then(Value::as_array) { + let name = dict + .get("Name") + .and_then(Value::as_string) + .unwrap_or("Untitled Group") + .to_string(); + if !group_names.contains(&name) { + group_names.push(name.clone()); + } + parse_children(sub, Some(&name), include_passwords, envelope, group_names); + } else { + let conn = parse_connection(dict, group_name); + let index = envelope.connections.len(); + envelope.connections.push(conn); + if include_passwords && !envelope.credentials_aborted { + let creds = read_credentials(dict, &mut envelope.credentials_aborted); + if !creds.is_empty() { + envelope.credentials_by_index.insert(index, creds); + } + } + } + } +} + +fn parse_connection(dict: &plist::Dictionary, group_name: Option<&str>) -> ImportedConnection { + let connection_type = dict.get("type").and_then(Value::as_signed_integer).unwrap_or(0); + ImportedConnection { + name: dict.get("name").and_then(Value::as_string).unwrap_or("Untitled").to_string(), + host: dict.get("host").and_then(Value::as_string).unwrap_or("localhost").to_string(), + port: dict.get("port").and_then(plist_port).unwrap_or(3306), + database: dict.get("database").and_then(Value::as_string).unwrap_or("").to_string(), + username: dict.get("user").and_then(Value::as_string).unwrap_or("").to_string(), + driver_label: "MySQL".to_string(), + ssh: parse_ssh(dict, connection_type), + ssl: parse_ssl(dict), + group_name: group_name.map(|s| s.to_string()), + } +} + +fn parse_ssh(dict: &plist::Dictionary, connection_type: i64) -> Option { + if connection_type != 2 { + return None; + } + let key_enabled = dict.get("sshKeyLocationEnabled").and_then(Value::as_signed_integer).unwrap_or(0) != 0; + let raw_key = dict.get("sshKeyLocation").and_then(Value::as_string).unwrap_or(""); + Some(ImportedSsh { + host: dict.get("sshHost").and_then(Value::as_string).unwrap_or("").to_string(), + port: dict.get("sshPort").and_then(plist_port), + username: dict.get("sshUser").and_then(Value::as_string).unwrap_or("").to_string(), + auth_type: if key_enabled { "ssh_key" } else { "password" }.to_string(), + private_key_path: if key_enabled { + Some(resolve_key_path(raw_key)) + } else { + None + }, + }) +} + +fn parse_ssl(dict: &plist::Dictionary) -> Option { + let use_ssl = match dict.get("useSSL") { + Some(v) => v.as_signed_integer().map(|n| n != 0).or_else(|| v.as_boolean()).unwrap_or(false), + None => false, + }; + if !use_ssl { + return None; + } + let get = |k: &str| dict.get(k).and_then(Value::as_string).map(|s| s.to_string()); + Some(ImportedSsl { + mode: "require".to_string(), + ca_certificate_path: get("sslCACertFileLocation"), + client_certificate_path: get("sslCertificateFileLocation"), + client_key_path: get("sslKeyFileLocation"), + }) +} + +fn read_credentials(dict: &plist::Dictionary, abort: &mut bool) -> ImportedCredentials { + let name = dict.get("name").and_then(Value::as_string).unwrap_or(""); + let conn_id = dict + .get("id") + .and_then(Value::as_signed_integer) + .map(|i| i.to_string()) + .unwrap_or_else(|| "0".to_string()); + let user = dict.get("user").and_then(Value::as_string).unwrap_or(""); + let host = dict.get("host").and_then(Value::as_string).unwrap_or(""); + let database = dict.get("database").and_then(Value::as_string).unwrap_or(""); + + let mut read = |service: String, account: String| -> Option { + if *abort { + return None; + } + match read_generic_password(&service, &account) { + KeychainReadResult::Found(v) => Some(v), + KeychainReadResult::NotFound => None, + KeychainReadResult::Cancelled => { + *abort = true; + None + } + } + }; + + let password = read( + format!("Sequel Ace : {name} ({conn_id})"), + format!("{user}@{host}/{database}"), + ); + + let ssh_password = if dict.get("type").and_then(Value::as_signed_integer) == Some(2) { + let ssh_user = dict.get("sshUser").and_then(Value::as_string).unwrap_or(""); + let ssh_host = dict.get("sshHost").and_then(Value::as_string).unwrap_or(""); + read( + format!("Sequel Ace SSHTunnel : {name} ({conn_id})"), + format!("{ssh_user}@{ssh_host}"), + ) + } else { + None + }; + + ImportedCredentials { + password, + ssh_password, + ssh_key_passphrase: None, + } +} + +fn plist_port(v: &Value) -> Option { + if let Some(s) = v.as_string() { + return s.parse().ok(); + } + v.as_signed_integer().and_then(|n| u16::try_from(n).ok()) +} diff --git a/src-tauri/src/connection_import/tableplus.rs b/src-tauri/src/connection_import/tableplus.rs new file mode 100644 index 00000000..4a6734fd --- /dev/null +++ b/src-tauri/src/connection_import/tableplus.rs @@ -0,0 +1,285 @@ +//! TablePlus importer (macOS). Reads `Connections.plist` / `ConnectionGroups.plist` +//! from TablePlus' Application Support data dir and pulls passwords from the +//! login Keychain. Ported from TablePro's `TablePlusImporter.swift`. + +use std::path::{Path, PathBuf}; + +use plist::Value; + +use super::keychain_read::{read_generic_password, KeychainReadResult}; +use super::types::{ImportEnvelope, ImportedConnection, ImportedCredentials, ImportedSsh, ImportedSsl}; +use super::{driver_map, resolve_key_path, ForeignAppImporter, ForeignImportError}; + +const KEYCHAIN_SERVICE: &str = "com.tableplus.TablePlus"; +const KNOWN_BUNDLE_IDS: &[&str] = &["com.tinyapp.TablePlus", "com.tinyapp.TablePlus-setapp"]; + +pub struct TablePlusImporter { + /// Override of the TablePlus `Data` directory; `None` = auto-discover. + data_dir_override: Option, +} + +impl Default for TablePlusImporter { + fn default() -> Self { + Self { + data_dir_override: None, + } + } +} + +#[async_trait::async_trait] +impl ForeignAppImporter for TablePlusImporter { + fn id(&self) -> &'static str { + "tableplus" + } + fn display_name(&self) -> &'static str { + "TablePlus" + } + fn reads_passwords_from_keychain(&self) -> bool { + true + } + async fn is_available(&self) -> bool { + self.connections_file().is_file() + } + async fn connection_count(&self) -> usize { + load_plist_array(&self.connections_file()) + .map(|a| a.len()) + .unwrap_or(0) + } + + async fn import( + &self, + include_passwords: bool, + _file: Option<&Path>, + ) -> Result { + let path = self.connections_file(); + if !path.is_file() { + return Err(ForeignImportError::FileNotFound(self.display_name().to_string())); + } + let entries = load_plist_array(&path).ok_or_else(|| { + ForeignImportError::UnsupportedFormat("Expected array in Connections.plist".into()) + })?; + + let group_map = self.load_groups(); + let mut envelope = ImportEnvelope { + source_name: self.display_name().to_string(), + ..Default::default() + }; + let mut group_names: Vec = Vec::new(); + + for entry in &entries { + let dict = match entry.as_dictionary() { + Some(d) => d, + None => continue, + }; + let name = match dict.get("ConnectionName").and_then(Value::as_string) { + Some(n) => n.to_string(), + None => continue, + }; + + let driver_label = map_driver(dict.get("Driver").and_then(Value::as_string).unwrap_or("")); + let driver_id = driver_map::canonical_id(&driver_label); + let host = dict + .get("DatabaseHost") + .and_then(Value::as_string) + .unwrap_or("localhost") + .to_string(); + let port = dict + .get("DatabasePort") + .and_then(plist_port) + .unwrap_or_else(|| driver_map::default_port(&driver_id)); + let username = dict + .get("DatabaseUser") + .and_then(Value::as_string) + .unwrap_or("") + .to_string(); + let database = if driver_id == "sqlite" { + dict.get("DatabasePath").and_then(Value::as_string) + } else { + dict.get("DatabaseName").and_then(Value::as_string) + } + .unwrap_or("") + .to_string(); + + let group_name = dict + .get("GroupID") + .and_then(Value::as_string) + .filter(|s| !s.is_empty()) + .and_then(|gid| group_map.get(gid).cloned()); + if let Some(g) = &group_name { + if !group_names.contains(g) { + group_names.push(g.clone()); + } + } + + let conn = ImportedConnection { + name, + host, + port, + database, + username, + driver_label, + ssh: parse_ssh(dict), + ssl: parse_ssl(dict), + group_name, + }; + let index = envelope.connections.len(); + envelope.connections.push(conn); + + if include_passwords && !envelope.credentials_aborted { + if let Some(conn_id) = dict.get("ID").and_then(Value::as_string) { + let creds = read_credentials(conn_id, &mut envelope.credentials_aborted); + if !creds.is_empty() { + envelope.credentials_by_index.insert(index, creds); + } + } + } + } + + if envelope.connections.is_empty() { + return Err(ForeignImportError::NoConnectionsFound); + } + envelope.group_names = group_names; + Ok(envelope) + } +} + +impl TablePlusImporter { + #[cfg(test)] + pub(crate) fn with_data_dir(dir: PathBuf) -> Self { + Self { + data_dir_override: Some(dir), + } + } + + fn data_dir(&self) -> PathBuf { + if let Some(dir) = &self.data_dir_override { + return dir.clone(); + } + let home = super::home_dir().unwrap_or_default(); + let bundle = KNOWN_BUNDLE_IDS + .iter() + .map(|b| home.join(format!("Library/Application Support/{}/Data", b))) + .find(|p| p.is_dir()); + bundle.unwrap_or_else(|| { + home.join(format!("Library/Application Support/{}/Data", KNOWN_BUNDLE_IDS[0])) + }) + } + + fn connections_file(&self) -> PathBuf { + self.data_dir().join("Connections.plist") + } + + fn load_groups(&self) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + if let Some(array) = load_plist_array(&self.data_dir().join("ConnectionGroups.plist")) { + for group in array { + if let Some(dict) = group.as_dictionary() { + if let (Some(id), Some(name)) = ( + dict.get("ID").and_then(Value::as_string), + dict.get("Name").and_then(Value::as_string), + ) { + map.insert(id.to_string(), name.to_string()); + } + } + } + } + map + } +} + +fn parse_ssh(dict: &plist::Dictionary) -> Option { + if dict.get("isOverSSH").and_then(Value::as_boolean) != Some(true) { + return None; + } + let use_key = dict.get("isUsePrivateKey").and_then(Value::as_boolean).unwrap_or(false); + let key_name = dict.get("ServerPrivateKeyName").and_then(Value::as_string).unwrap_or(""); + Some(ImportedSsh { + host: dict.get("ServerAddress").and_then(Value::as_string).unwrap_or("").to_string(), + port: dict.get("ServerPort").and_then(plist_port), + username: dict.get("ServerUser").and_then(Value::as_string).unwrap_or("").to_string(), + auth_type: if use_key { "ssh_key" } else { "password" }.to_string(), + private_key_path: if use_key && !key_name.is_empty() { + Some(resolve_key_path(key_name)) + } else { + None + }, + }) +} + +fn parse_ssl(dict: &plist::Dictionary) -> Option { + let tls_mode = dict.get("tLSMode")?.as_signed_integer()?; + let mode = match tls_mode { + 0 => "prefer", + 1 => "require", + 2 => "verify-ca", + 3 => "verify-full", + _ => return None, + } + .to_string(); + let paths: Vec = dict + .get("TlsKeyPaths") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .map(|v| v.as_string().unwrap_or("").to_string()) + .collect() + }) + .unwrap_or_default(); + let at = |i: usize| paths.get(i).filter(|s| !s.is_empty()).cloned(); + Some(ImportedSsl { + mode, + ca_certificate_path: at(0), + client_certificate_path: at(1), + client_key_path: at(2), + }) +} + +fn read_credentials(conn_id: &str, abort: &mut bool) -> ImportedCredentials { + let mut read = |account: String| -> Option { + if *abort { + return None; + } + match read_generic_password(KEYCHAIN_SERVICE, &account) { + KeychainReadResult::Found(v) => Some(v), + KeychainReadResult::NotFound => None, + KeychainReadResult::Cancelled => { + *abort = true; + None + } + } + }; + ImportedCredentials { + password: read(format!("{conn_id}_database")), + ssh_password: read(format!("{conn_id}_server")), + ssh_key_passphrase: read(format!("{conn_id}_server_key")), + } +} + +/// TablePlus stores ports as a string or integer plist value. +fn plist_port(v: &Value) -> Option { + if let Some(s) = v.as_string() { + return s.parse().ok(); + } + v.as_signed_integer().and_then(|n| u16::try_from(n).ok()) +} + +fn map_driver(driver: &str) -> String { + match driver { + "MySQL" => "MySQL", + "PostgreSQL" => "PostgreSQL", + "Mongo" => "MongoDB", + "SQLite" => "SQLite", + "Redis" => "Redis", + "MSSQL" => "SQL Server", + "Redshift" => "Redshift", + "MariaDB" => "MariaDB", + "CockroachDB" => "CockroachDB", + other => other, + } + .to_string() +} + +fn load_plist_array(path: &Path) -> Option> { + let value = Value::from_file(path).ok()?; + value.as_array().cloned() +} diff --git a/src-tauri/src/connection_import/types.rs b/src-tauri/src/connection_import/types.rs new file mode 100644 index 00000000..50444c01 --- /dev/null +++ b/src-tauri/src/connection_import/types.rs @@ -0,0 +1,81 @@ +//! Neutral intermediate representation shared by every foreign-app importer. +//! +//! Each importer reads a third-party client's on-disk config and produces an +//! [`ImportEnvelope`]. The analyzer then annotates each connection and the +//! converter turns the envelope into Tabularis' own `ExportPayload`. + +use serde::{Deserialize, Serialize}; + +/// A single connection extracted from a foreign app, in a driver-neutral shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportedConnection { + pub name: String, + pub host: String, + pub port: u16, + pub database: String, + pub username: String, + /// Human-readable database label as named by the source app + /// (e.g. "PostgreSQL", "MySQL", "MariaDB"). Mapped to a Tabularis driver id + /// by `driver_map`. + pub driver_label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportedSsh { + pub host: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + pub username: String, + /// "password" or "ssh_key" (mirrors `SshConnection::auth_type`). + pub auth_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub private_key_path: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportedSsl { + pub mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ca_certificate_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_certificate_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_key_path: Option, +} + +/// Secrets recovered for a connection (by its index in `connections`). +/// Kept entirely Rust-side; never serialized to the frontend. +#[derive(Debug, Clone, Default)] +pub struct ImportedCredentials { + pub password: Option, + pub ssh_password: Option, + pub ssh_key_passphrase: Option, +} + +impl ImportedCredentials { + pub fn is_empty(&self) -> bool { + self.password.is_none() + && self.ssh_password.is_none() + && self.ssh_key_passphrase.is_none() + } +} + +/// The full result of importing from one source app. +#[derive(Debug, Clone, Default)] +pub struct ImportEnvelope { + pub source_name: String, + pub connections: Vec, + /// Map of connection index -> recovered secrets. Sparse: only present when + /// credentials were requested and found. + pub credentials_by_index: std::collections::HashMap, + pub group_names: Vec, + /// True when the user denied a Keychain prompt mid-import, so some + /// passwords may be missing even though `include_passwords` was requested. + pub credentials_aborted: bool, +} diff --git a/src-tauri/src/connection_import_commands.rs b/src-tauri/src/connection_import_commands.rs new file mode 100644 index 00000000..7167466e --- /dev/null +++ b/src-tauri/src/connection_import_commands.rs @@ -0,0 +1,143 @@ +//! Tauri commands for importing connections from other installed SQL clients. +//! +//! Flow: `list_connection_import_sources` populates the picker; +//! `preview_connection_import` runs the chosen importer, caches the full +//! envelope (with secrets) in app state and returns a passwordless preview; +//! `apply_connection_import` converts the cached envelope per the user's +//! resolutions and merges it through `apply_export_payload`. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{AppHandle, Runtime}; + +use crate::commands::{apply_export_payload, get_config_path}; +use crate::connection_import::{ + all_importers, analyzer, convert, expand_home, importer_by_id, ImportEnvelope, +}; +use crate::persistence; + +/// Caches the most recent envelope per source so `apply` doesn't re-read the +/// keychain (and re-prompt) after `preview`. +#[derive(Default)] +pub struct ImportEnvelopeCache(pub Mutex>); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourceInfo { + pub id: String, + pub display_name: String, + pub available: bool, + pub connection_count: usize, + pub reads_passwords_from_keychain: bool, + /// True when the importer reads a user-picked export file (none today). + pub needs_file: bool, +} + +/// List every known source app with availability and connection counts. +#[tauri::command] +pub async fn list_connection_import_sources() -> Result, String> { + let mut sources = Vec::new(); + for importer in all_importers() { + let available = importer.is_available().await; + let connection_count = if available { + importer.connection_count().await + } else { + 0 + }; + sources.push(ImportSourceInfo { + id: importer.id().to_string(), + display_name: importer.display_name().to_string(), + available, + connection_count, + reads_passwords_from_keychain: importer.reads_passwords_from_keychain(), + needs_file: importer.import_file_types().is_some(), + }); + } + Ok(sources) +} + +/// Run the importer and return a preview annotated against existing connections +/// and installed drivers. The full envelope (with secrets) is cached in state. +#[tauri::command] +pub async fn preview_connection_import( + app: AppHandle, + cache: tauri::State<'_, ImportEnvelopeCache>, + source_id: String, + include_passwords: bool, + file_path: Option, +) -> Result { + let importer = importer_by_id(&source_id) + .ok_or_else(|| format!("Unknown import source: {source_id}"))?; + + let file = file_path.map(PathBuf::from); + let envelope = importer + .import(include_passwords, file.as_deref()) + .await + .map_err(|e| e.to_string())?; + + let existing = load_existing_connections(&app)?; + let registered_ids = registered_driver_ids().await; + let file_exists = |p: &str| PathBuf::from(expand_home(p)).exists(); + let preview = analyzer::analyze(&envelope, &existing, ®istered_ids, &file_exists); + + cache + .0 + .lock() + .map_err(|_| "Import cache poisoned".to_string())? + .insert(source_id, envelope); + + Ok(preview) +} + +/// Apply the cached envelope for `source_id` using the user's resolutions. +#[tauri::command] +pub async fn apply_connection_import( + app: AppHandle, + cache: tauri::State<'_, ImportEnvelopeCache>, + source_id: String, + resolutions: Vec, +) -> Result<(), String> { + let envelope = cache + .0 + .lock() + .map_err(|_| "Import cache poisoned".to_string())? + .remove(&source_id) + .ok_or_else(|| "No import preview found; run preview first".to_string())?; + + let registered_ids = registered_driver_ids().await; + let existing_groups = load_existing_groups(&app)?; + let payload = convert::build_payload(&envelope, &resolutions, ®istered_ids, &existing_groups); + + apply_export_payload(app, payload).await +} + +// MARK: - Helpers + +async fn registered_driver_ids() -> Vec { + crate::drivers::registry::list_drivers() + .await + .into_iter() + .map(|m| m.id) + .collect() +} + +fn load_existing_connections( + app: &AppHandle, +) -> Result, String> { + let path = get_config_path(app)?; + Ok(persistence::load_connections_file(&path) + .unwrap_or_default() + .connections) +} + +fn load_existing_groups( + app: &AppHandle, +) -> Result, String> { + let path = get_config_path(app)?; + Ok(persistence::load_connections_file(&path) + .unwrap_or_default() + .groups) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03cd2540..de5d04db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ pub mod cli; pub mod clipboard_import; pub mod commands; pub mod connection_appearance; +pub mod connection_import; +pub mod connection_import_commands; #[cfg(test)] pub mod connection_appearance_tests; pub mod config; @@ -182,6 +184,7 @@ pub fn run() { .manage(std::sync::Arc::new( connection_cache::ConnectionCache::default(), )) + .manage(connection_import_commands::ImportEnvelopeCache::default()) .manage(explain_import::PendingExplainFile::default()) .manage(json_viewer::JsonViewerStore::default()) .manage(results_window::ResultsWindowStore::default()) @@ -315,6 +318,9 @@ pub fn run() { commands::reorder_connections_in_group, commands::export_connections_payload, commands::import_connections_payload, + connection_import_commands::list_connection_import_sources, + connection_import_commands::preview_connection_import, + connection_import_commands::apply_connection_import, commands::get_schemas, commands::get_available_databases, commands::get_tables, diff --git a/src/components/modals/ImportFromAppModal.tsx b/src/components/modals/ImportFromAppModal.tsx new file mode 100644 index 00000000..59696b88 --- /dev/null +++ b/src/components/modals/ImportFromAppModal.tsx @@ -0,0 +1,472 @@ +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { + X, + Loader2, + Database, + ArrowLeft, + AlertTriangle, + CheckCircle2, + Copy, + KeyRound, + Lock, +} from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { readTextFile } from "@tauri-apps/plugin-fs"; +import clsx from "clsx"; +import { toErrorMessage } from "../../utils/errors"; +import type { + ImportSourceInfo, + ImportPreview, + ImportItem, + ImportResolution, + ImportAction, +} from "../../types/connectionImport"; + +interface ImportFromAppModalProps { + isOpen: boolean; + onClose: () => void; + /** Called after a successful import so the caller can reload connections. */ + onImported: () => void; +} + +type Step = "picker" | "preview"; + +/** Synthetic source: import from a Tabularis JSON export file (handled + * directly, without the foreign-app preview pipeline). */ +const TABULARIS_SOURCE_ID = "tabularis-json"; + +export const ImportFromAppModal = ({ + isOpen, + onClose, + onImported, +}: ImportFromAppModalProps) => { + const { t } = useTranslation(); + + const [step, setStep] = useState("picker"); + const [sources, setSources] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [includePasswords, setIncludePasswords] = useState(true); + const [preview, setPreview] = useState(null); + const [resolutions, setResolutions] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const reset = useCallback(() => { + setStep("picker"); + setSelectedId(null); + setIncludePasswords(true); + setPreview(null); + setResolutions({}); + setError(null); + setLoading(false); + }, []); + + // Load the source list whenever the modal opens. + useEffect(() => { + if (!isOpen) return; + reset(); + setLoading(true); + const tabularisSource: ImportSourceInfo = { + id: TABULARIS_SOURCE_ID, + displayName: "Tabularis", + available: true, + connectionCount: 0, + readsPasswordsFromKeychain: false, + needsFile: true, + }; + invoke("list_connection_import_sources") + .then((list) => { + const all = [tabularisSource, ...list]; + setSources(all); + const firstAvailable = all.find((s) => s.available); + setSelectedId(firstAvailable?.id ?? null); + }) + .catch((e) => setError(toErrorMessage(e))) + .finally(() => setLoading(false)); + }, [isOpen, reset]); + + const selectedSource = sources.find((s) => s.id === selectedId) ?? null; + + const handleContinue = async () => { + if (!selectedSource || !selectedSource.available) return; + setError(null); + setLoading(true); + try { + // Tabularis export file: reuse the existing lossless JSON import path. + if (selectedSource.id === TABULARIS_SOURCE_ID) { + const picked = await open({ + filters: [{ name: "JSON", extensions: ["json"] }], + multiple: false, + }); + if (!picked || Array.isArray(picked)) { + setLoading(false); + return; + } + const content = await readTextFile(picked); + const payload = JSON.parse(content); + await invoke("import_connections_payload", { payload }); + onImported(); + onClose(); + return; + } + + let filePath: string | null = null; + if (selectedSource.needsFile) { + const picked = await open({ multiple: false }); + if (!picked || Array.isArray(picked)) { + setLoading(false); + return; + } + filePath = picked; + } + const result = await invoke("preview_connection_import", { + sourceId: selectedSource.id, + includePasswords, + filePath, + }); + // Default: import everything, skip duplicates. + const defaults: Record = {}; + for (const item of result.items) { + defaults[item.index] = item.status.kind === "duplicate" ? "skip" : "import"; + } + setPreview(result); + setResolutions(defaults); + setStep("preview"); + } catch (e) { + setError(toErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + const handleApply = async () => { + if (!preview || !selectedSource) return; + setError(null); + setLoading(true); + try { + const payload: ImportResolution[] = preview.items.map((item) => { + const action = resolutions[item.index] ?? "skip"; + return { + index: item.index, + action, + replaceExistingId: + action === "replace" && item.status.kind === "duplicate" + ? item.status.existingId + : undefined, + }; + }); + await invoke("apply_connection_import", { + sourceId: selectedSource.id, + resolutions: payload, + }); + onImported(); + onClose(); + } catch (e) { + setError(toErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + const importCount = preview + ? preview.items.filter((i) => (resolutions[i.index] ?? "skip") !== "skip").length + : 0; + + return ( +
+
+ {/* Header */} +
+
+ {step === "preview" && ( + + )} +
+ +
+
+

+ {t("connections.importFromApp.title")} +

+

+ {step === "picker" + ? t("connections.importFromApp.subtitle") + : t("connections.importFromApp.previewSubtitle", { + source: preview?.sourceName ?? "", + })} +

+
+
+ +
+ + {/* Content */} +
+ {error && ( +
+ + {error} +
+ )} + + {loading && ( +
+ +
+ )} + + {!loading && step === "picker" && ( + + )} + + {!loading && step === "preview" && preview && ( + + setResolutions((prev) => ({ ...prev, [index]: action })) + } + /> + )} +
+ + {/* Footer */} +
+ {step === "preview" && preview?.credentialsAborted ? ( + + + {t("connections.importFromApp.credentialsAborted")} + + ) : ( + + )} +
+ + {step === "picker" ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; + +// ── Source picker step ───────────────────────────────────────────────────── + +interface SourcePickerProps { + sources: ImportSourceInfo[]; + selectedId: string | null; + onSelect: (id: string) => void; + includePasswords: boolean; + onTogglePasswords: (value: boolean) => void; +} + +const SourcePicker = ({ + sources, + selectedId, + onSelect, + includePasswords, + onTogglePasswords, +}: SourcePickerProps) => { + const { t } = useTranslation(); + + const subtitle = (s: ImportSourceInfo) => { + if (s.id === TABULARIS_SOURCE_ID) return t("connections.importFromApp.tabularisJsonHint"); + if (!s.available) return t("connections.importFromApp.notInstalled"); + if (s.needsFile) return t("connections.importFromApp.chooseFile"); + return t("connections.importFromApp.connectionsFound", { count: s.connectionCount }); + }; + + const showPasswordToggle = selectedId !== TABULARIS_SOURCE_ID; + + return ( +
+
+ {sources.map((s) => { + const selected = s.id === selectedId; + return ( + + ); + })} +
+ + {/* Include passwords toggle */} + {showPasswordToggle && ( + + )} +
+ ); +}; + +// ── Preview step ─────────────────────────────────────────────────────────── + +interface PreviewListProps { + preview: ImportPreview; + resolutions: Record; + onChange: (index: number, action: ImportAction) => void; +} + +const PreviewList = ({ preview, resolutions, onChange }: PreviewListProps) => { + const { t } = useTranslation(); + + return ( +
+ {preview.items.map((item) => ( + onChange(item.index, action)} + /> + ))} + {preview.items.length === 0 && ( +

+ {t("connections.importFromApp.noConnections")} +

+ )} +
+ ); +}; + +interface PreviewRowProps { + item: ImportItem; + action: ImportAction; + onChange: (action: ImportAction) => void; +} + +const PreviewRow = ({ item, action, onChange }: PreviewRowProps) => { + const { t } = useTranslation(); + const isDuplicate = item.status.kind === "duplicate"; + + return ( +
+ +
+

+ {item.name} + {item.hasPassword && ( + + )} +

+

+ {item.driverId} + {" · "} + {item.port ? `${item.host}:${item.port}` : item.host} + {item.database ? ` / ${item.database}` : ""} + {item.groupName ? ` · ${item.groupName}` : ""} +

+ {item.status.kind === "warnings" && ( +

+ {item.status.warnings.join(" · ")} +

+ )} + {isDuplicate && item.status.kind === "duplicate" && ( +

+ {t("connections.importFromApp.duplicateOf", { + name: item.status.existingName, + })} +

+ )} +
+ + {/* Per-item action selector */} + +
+ ); +}; + +const StatusBadge = ({ item }: { item: ImportItem }) => { + if (item.status.kind === "duplicate") { + return ; + } + if (item.status.kind === "warnings") { + return ; + } + return ; +}; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 2c71b48c..f14cd7f4 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -37,7 +37,9 @@ "noResults": "Keine Ergebnisse gefunden", "error": "Fehler", "success": "Erfolg", - "ok": "OK" + "ok": "OK", + "continue": "Weiter", + "back": "Zurück" }, "sidebar": { "connections": "Verbindungen", @@ -250,7 +252,30 @@ "export": "Verbindungen exportieren", "import": "Verbindungen importieren", "exportTitle": "Verbindungen exportieren", - "exportWarning": "Die exportierte Datei enthält deine Datenbank- und SSH-Passwörter im Klartext. Bewahre sie sicher auf." + "exportWarning": "Die exportierte Datei enthält deine Datenbank- und SSH-Passwörter im Klartext. Bewahre sie sicher auf.", + "importFromApp": { + "title": "Import from App", + "subtitle": "Import connections from another installed SQL client", + "previewSubtitle": "Review connections from {{source}}", + "notInstalled": "Not installed", + "chooseFile": "Choose an export file to import", + "connectionsFound_one": "1 connection found", + "connectionsFound_other": "{{count}} connections found", + "includePasswords": "Include passwords", + "includePasswordsHint": "Saved passwords are read and decrypted during import (may require Keychain permission)", + "credentialsAborted": "Some passwords could not be read", + "importCount_one": "Import 1 connection", + "importCount_other": "Import {{count}} connections", + "noConnections": "No connections found to import", + "duplicateOf": "Duplicate of \"{{name}}\"", + "action": { + "import": "Import", + "skip": "Skip", + "replace": "Replace" + }, + "tabularisJsonHint": "Tabularis-Exportdatei (.json)", + "menuLabel": "Verbindungen importieren…" + } }, "connectionAppearance": { "section": "Darstellung", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 85fb24fc..ab149e0e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -37,7 +37,9 @@ "noResults": "No results found", "error": "Error", "success": "Success", - "ok": "OK" + "ok": "OK", + "continue": "Continue", + "back": "Back" }, "sidebar": { "connections": "Connections", @@ -263,7 +265,30 @@ "export": "Export Connections", "import": "Import Connections", "exportTitle": "Export Connections", - "exportWarning": "The exported file will contain your database and SSH passwords in plaintext. Please store it securely." + "exportWarning": "The exported file will contain your database and SSH passwords in plaintext. Please store it securely.", + "importFromApp": { + "title": "Import from App", + "subtitle": "Import connections from another installed SQL client", + "previewSubtitle": "Review connections from {{source}}", + "notInstalled": "Not installed", + "chooseFile": "Choose an export file to import", + "connectionsFound_one": "1 connection found", + "connectionsFound_other": "{{count}} connections found", + "includePasswords": "Include passwords", + "includePasswordsHint": "Saved passwords are read and decrypted during import (may require Keychain permission)", + "credentialsAborted": "Some passwords could not be read", + "importCount_one": "Import 1 connection", + "importCount_other": "Import {{count}} connections", + "noConnections": "No connections found to import", + "duplicateOf": "Duplicate of \"{{name}}\"", + "action": { + "import": "Import", + "skip": "Skip", + "replace": "Replace" + }, + "tabularisJsonHint": "Tabularis export file (.json)", + "menuLabel": "Import connections…" + } }, "connectionAppearance": { "section": "Appearance", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ac679ce7..260597df 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -37,7 +37,9 @@ "noResults": "No se encontraron resultados", "error": "Error", "success": "Éxito", - "ok": "OK" + "ok": "OK", + "continue": "Continuar", + "back": "Atrás" }, "sidebar": { "connections": "Conexiones", @@ -250,7 +252,30 @@ "export": "Exportar conexiones", "import": "Importar conexiones", "exportTitle": "Exportar conexiones", - "exportWarning": "El archivo exportado contendrá tus contraseñas de base de datos y SSH en texto plano. Guárdalo de forma segura." + "exportWarning": "El archivo exportado contendrá tus contraseñas de base de datos y SSH en texto plano. Guárdalo de forma segura.", + "importFromApp": { + "title": "Import from App", + "subtitle": "Import connections from another installed SQL client", + "previewSubtitle": "Review connections from {{source}}", + "notInstalled": "Not installed", + "chooseFile": "Choose an export file to import", + "connectionsFound_one": "1 connection found", + "connectionsFound_other": "{{count}} connections found", + "includePasswords": "Include passwords", + "includePasswordsHint": "Saved passwords are read and decrypted during import (may require Keychain permission)", + "credentialsAborted": "Some passwords could not be read", + "importCount_one": "Import 1 connection", + "importCount_other": "Import {{count}} connections", + "noConnections": "No connections found to import", + "duplicateOf": "Duplicate of \"{{name}}\"", + "action": { + "import": "Import", + "skip": "Skip", + "replace": "Replace" + }, + "tabularisJsonHint": "Archivo de exportación de Tabularis (.json)", + "menuLabel": "Importar conexiones…" + } }, "connectionAppearance": { "section": "Apariencia", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2420cb4f..cb26fdd6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -37,7 +37,9 @@ "noResults": "Aucun résultat trouvé", "error": "Erreur", "success": "Succès", - "ok": "OK" + "ok": "OK", + "continue": "Continuer", + "back": "Retour" }, "sidebar": { "connections": "Connexions", @@ -250,7 +252,30 @@ "export": "Exporter les connexions", "import": "Importer les connexions", "exportTitle": "Exporter les connexions", - "exportWarning": "Le fichier exporté contiendra vos mots de passe de base de données et SSH en clair. Conservez-le en lieu sûr." + "exportWarning": "Le fichier exporté contiendra vos mots de passe de base de données et SSH en clair. Conservez-le en lieu sûr.", + "importFromApp": { + "title": "Import from App", + "subtitle": "Import connections from another installed SQL client", + "previewSubtitle": "Review connections from {{source}}", + "notInstalled": "Not installed", + "chooseFile": "Choose an export file to import", + "connectionsFound_one": "1 connection found", + "connectionsFound_other": "{{count}} connections found", + "includePasswords": "Include passwords", + "includePasswordsHint": "Saved passwords are read and decrypted during import (may require Keychain permission)", + "credentialsAborted": "Some passwords could not be read", + "importCount_one": "Import 1 connection", + "importCount_other": "Import {{count}} connections", + "noConnections": "No connections found to import", + "duplicateOf": "Duplicate of \"{{name}}\"", + "action": { + "import": "Import", + "skip": "Skip", + "replace": "Replace" + }, + "tabularisJsonHint": "Fichier d'export Tabularis (.json)", + "menuLabel": "Importer des connexions…" + } }, "connectionAppearance": { "section": "Apparence", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 5b7df5ff..0791d142 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -37,7 +37,9 @@ "noResults": "Nessun risultato trovato", "error": "Errore", "success": "Successo", - "ok": "OK" + "ok": "OK", + "continue": "Continua", + "back": "Indietro" }, "sidebar": { "connections": "Connessioni", @@ -250,7 +252,30 @@ "export": "Esporta connessioni", "import": "Importa connessioni", "exportTitle": "Esporta connessioni", - "exportWarning": "Il file esportato conterrà le password del database e SSH in chiaro. Conservalo in modo sicuro." + "exportWarning": "Il file esportato conterrà le password del database e SSH in chiaro. Conservalo in modo sicuro.", + "importFromApp": { + "title": "Importa da app", + "subtitle": "Importa connessioni da un altro client SQL installato", + "previewSubtitle": "Rivedi le connessioni da {{source}}", + "notInstalled": "Non installato", + "chooseFile": "Scegli un file di esportazione da importare", + "connectionsFound_one": "1 connessione trovata", + "connectionsFound_other": "{{count}} connessioni trovate", + "includePasswords": "Includi password", + "includePasswordsHint": "Le password salvate vengono lette e decifrate durante l'import (potrebbe richiedere l'accesso al Portachiavi)", + "credentialsAborted": "Alcune password non sono state lette", + "importCount_one": "Importa 1 connessione", + "importCount_other": "Importa {{count}} connessioni", + "noConnections": "Nessuna connessione da importare", + "duplicateOf": "Duplicato di \"{{name}}\"", + "action": { + "import": "Importa", + "skip": "Salta", + "replace": "Sostituisci" + }, + "tabularisJsonHint": "File di esportazione Tabularis (.json)", + "menuLabel": "Importa connessioni…" + } }, "connectionAppearance": { "section": "Aspetto", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9914178f..edc6399a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -37,7 +37,9 @@ "noResults": "結果が見つかりません", "error": "エラー", "success": "成功", - "ok": "OK" + "ok": "OK", + "continue": "続行", + "back": "戻る" }, "sidebar": { "connections": "接続", @@ -263,7 +265,30 @@ "export": "接続をエクスポート", "import": "接続をインポート", "exportTitle": "接続をエクスポート", - "exportWarning": "エクスポートされたファイルには、データベースおよび SSH のパスワードが平文で含まれます。安全な場所に保管してください。" + "exportWarning": "エクスポートされたファイルには、データベースおよび SSH のパスワードが平文で含まれます。安全な場所に保管してください。", + "importFromApp": { + "title": "Import from App", + "subtitle": "Import connections from another installed SQL client", + "previewSubtitle": "Review connections from {{source}}", + "notInstalled": "Not installed", + "chooseFile": "Choose an export file to import", + "connectionsFound_one": "1 connection found", + "connectionsFound_other": "{{count}} connections found", + "includePasswords": "Include passwords", + "includePasswordsHint": "Saved passwords are read and decrypted during import (may require Keychain permission)", + "credentialsAborted": "Some passwords could not be read", + "importCount_one": "Import 1 connection", + "importCount_other": "Import {{count}} connections", + "noConnections": "No connections found to import", + "duplicateOf": "Duplicate of \"{{name}}\"", + "action": { + "import": "Import", + "skip": "Skip", + "replace": "Replace" + }, + "tabularisJsonHint": "Tabularis エクスポートファイル (.json)", + "menuLabel": "接続をインポート…" + } }, "connectionAppearance": { "section": "外観", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 54cc92ab..100ad890 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -37,7 +37,9 @@ "noResults": "Ничего не найдено", "error": "Ошибка", "success": "Готово", - "ok": "ОК" + "ok": "ОК", + "continue": "Продолжить", + "back": "Назад" }, "sidebar": { "connections": "Подключения", @@ -266,7 +268,30 @@ "export": "Экспортировать подключения", "import": "Импортировать подключения", "exportTitle": "Экспорт подключений", - "exportWarning": "Экспортируемый файл будет содержать пароли к базам данных и SSH в открытом виде. Храните его в надёжном месте!" + "exportWarning": "Экспортируемый файл будет содержать пароли к базам данных и SSH в открытом виде. Храните его в надёжном месте!", + "importFromApp": { + "title": "Import from App", + "subtitle": "Import connections from another installed SQL client", + "previewSubtitle": "Review connections from {{source}}", + "notInstalled": "Not installed", + "chooseFile": "Choose an export file to import", + "connectionsFound_one": "1 connection found", + "connectionsFound_other": "{{count}} connections found", + "includePasswords": "Include passwords", + "includePasswordsHint": "Saved passwords are read and decrypted during import (may require Keychain permission)", + "credentialsAborted": "Some passwords could not be read", + "importCount_one": "Import 1 connection", + "importCount_other": "Import {{count}} connections", + "noConnections": "No connections found to import", + "duplicateOf": "Duplicate of \"{{name}}\"", + "action": { + "import": "Import", + "skip": "Skip", + "replace": "Replace" + }, + "tabularisJsonHint": "Файл экспорта Tabularis (.json)", + "menuLabel": "Импорт подключений…" + } }, "settings": { "title": "Настройки", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 059d7dfb..457bdd41 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -36,7 +36,9 @@ "search": "搜索...", "error": "错误", "success": "成功", - "ok": "OK" + "ok": "OK", + "continue": "继续", + "back": "返回" }, "sidebar": { "connections": "连接", @@ -249,7 +251,30 @@ "export": "导出连接", "import": "导入连接", "exportTitle": "导出连接", - "exportWarning": "导出的文件将包含您的数据库和 SSH 明文密码。请妥善保管该文件。" + "exportWarning": "导出的文件将包含您的数据库和 SSH 明文密码。请妥善保管该文件。", + "importFromApp": { + "title": "Import from App", + "subtitle": "Import connections from another installed SQL client", + "previewSubtitle": "Review connections from {{source}}", + "notInstalled": "Not installed", + "chooseFile": "Choose an export file to import", + "connectionsFound_one": "1 connection found", + "connectionsFound_other": "{{count}} connections found", + "includePasswords": "Include passwords", + "includePasswordsHint": "Saved passwords are read and decrypted during import (may require Keychain permission)", + "credentialsAborted": "Some passwords could not be read", + "importCount_one": "Import 1 connection", + "importCount_other": "Import {{count}} connections", + "noConnections": "No connections found to import", + "duplicateOf": "Duplicate of \"{{name}}\"", + "action": { + "import": "Import", + "skip": "Skip", + "replace": "Replace" + }, + "tabularisJsonHint": "Tabularis 导出文件 (.json)", + "menuLabel": "导入连接…" + } }, "connectionAppearance": { "section": "外观", diff --git a/src/pages/Connections.tsx b/src/pages/Connections.tsx index 368bfb79..372287d3 100644 --- a/src/pages/Connections.tsx +++ b/src/pages/Connections.tsx @@ -1,11 +1,13 @@ import { useState, useEffect, useMemo, useRef } from "react"; +import { createPortal } from "react-dom"; import { useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { NewConnectionModal } from "../components/modals/NewConnectionModal"; import { ConfirmModal } from "../components/modals/ConfirmModal"; +import { ImportFromAppModal } from "../components/modals/ImportFromAppModal"; import { invoke } from "@tauri-apps/api/core"; -import { save, open } from "@tauri-apps/plugin-dialog"; -import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; import { Database, Plus, @@ -19,7 +21,8 @@ import { FolderPlus, Folder, Download, - Upload, + FolderInput, + ChevronDown, } from "lucide-react"; import { useDatabase } from "../hooks/useDatabase"; import { useDrivers } from "../hooks/useDrivers"; @@ -57,6 +60,23 @@ export const Connections = () => { } = useDatabase(); const { drivers, allDrivers } = useDrivers(); const [isModalOpen, setIsModalOpen] = useState(false); + const [isImportAppModalOpen, setIsImportAppModalOpen] = useState(false); + const [isImportMenuOpen, setIsImportMenuOpen] = useState(false); + const importMenuBtnRef = useRef(null); + const [importMenuPos, setImportMenuPos] = useState({ top: 0, right: 0 }); + + // The header clips its overflow, so the dropup menu is portaled to the body + // and positioned just under the trigger button. + const toggleImportMenu = () => { + if (!isImportMenuOpen && importMenuBtnRef.current) { + const rect = importMenuBtnRef.current.getBoundingClientRect(); + setImportMenuPos({ + top: rect.bottom + 8, + right: window.innerWidth - rect.right, + }); + } + setIsImportMenuOpen((v) => !v); + }; const [editingConnection, setEditingConnection] = useState(null); const connections = contextConnections as SavedConnection[]; @@ -237,24 +257,6 @@ export const Connections = () => { }); }; - const handleImport = async () => { - try { - const selected = await open({ - filters: [{ name: "JSON", extensions: ["json"] }], - multiple: false, - }); - if (selected && !Array.isArray(selected)) { - const content = await readTextFile(selected); - const payload = JSON.parse(content); - await invoke("import_connections_payload", { payload }); - await loadConnections(); - } - } catch (e) { - console.error("Import failed:", e); - setError(toErrorMessage(e)); - } - }; - const handleRenameGroup = async (groupId: string) => { if (!editGroupName.trim()) return; try { @@ -556,16 +558,56 @@ export const Connections = () => { - +
+ + + {isImportMenuOpen && + createPortal( + <> +
setIsImportMenuOpen(false)} + /> +
+ +
+ , + document.body, + )} +
{/* ── Error banner ──────────────────────────────────────────────────── */} @@ -615,11 +657,11 @@ export const Connections = () => { {t("connections.createFirst")} @@ -697,15 +739,8 @@ export const Connections = () => { )} - {/* Export/Import buttons */} + {/* Export button (import lives in the New Connection dropup) */}
-