From cbac155012373a71dcc272a4ae8cec6066747687 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sat, 27 Jun 2026 12:37:45 +0200 Subject: [PATCH] feat(connections): import connections from external SQL clients Add an import flow that reads saved connections from DBeaver, Beekeeper Studio, TablePlus, DataGrip and Sequel Ace, plus Tabularis JSON exports. Each source is parsed into a neutral envelope, credentials are decrypted or read from the keychain when requested, and a preview lets you resolve duplicates before merging. Entry point is a dropup next to Add Connection. --- src-tauri/Cargo.lock | 8 + src-tauri/Cargo.toml | 4 + src-tauri/src/commands.rs | 10 + src-tauri/src/connection_import/analyzer.rs | 261 +++++++++ src-tauri/src/connection_import/beekeeper.rs | 345 ++++++++++++ src-tauri/src/connection_import/convert.rs | 281 ++++++++++ src-tauri/src/connection_import/crypto.rs | 127 +++++ src-tauri/src/connection_import/datagrip.rs | 511 ++++++++++++++++++ .../src/connection_import/datagrip/jdbc.rs | 243 +++++++++ src-tauri/src/connection_import/dbeaver.rs | 379 +++++++++++++ src-tauri/src/connection_import/driver_map.rs | 96 ++++ .../src/connection_import/importer_tests.rs | 182 +++++++ .../src/connection_import/keychain_read.rs | 39 ++ src-tauri/src/connection_import/mod.rs | 161 ++++++ src-tauri/src/connection_import/sequelace.rs | 254 +++++++++ src-tauri/src/connection_import/tableplus.rs | 285 ++++++++++ src-tauri/src/connection_import/types.rs | 81 +++ src-tauri/src/connection_import_commands.rs | 143 +++++ src-tauri/src/lib.rs | 6 + src/components/modals/ImportFromAppModal.tsx | 472 ++++++++++++++++ src/i18n/locales/de.json | 29 +- src/i18n/locales/en.json | 29 +- src/i18n/locales/es.json | 29 +- src/i18n/locales/fr.json | 29 +- src/i18n/locales/it.json | 29 +- src/i18n/locales/ja.json | 29 +- src/i18n/locales/ru.json | 29 +- src/i18n/locales/zh.json | 29 +- src/pages/Connections.tsx | 124 +++-- src/types/connectionImport.ts | 49 ++ 30 files changed, 4235 insertions(+), 58 deletions(-) create mode 100644 src-tauri/src/connection_import/analyzer.rs create mode 100644 src-tauri/src/connection_import/beekeeper.rs create mode 100644 src-tauri/src/connection_import/convert.rs create mode 100644 src-tauri/src/connection_import/crypto.rs create mode 100644 src-tauri/src/connection_import/datagrip.rs create mode 100644 src-tauri/src/connection_import/datagrip/jdbc.rs create mode 100644 src-tauri/src/connection_import/dbeaver.rs create mode 100644 src-tauri/src/connection_import/driver_map.rs create mode 100644 src-tauri/src/connection_import/importer_tests.rs create mode 100644 src-tauri/src/connection_import/keychain_read.rs create mode 100644 src-tauri/src/connection_import/mod.rs create mode 100644 src-tauri/src/connection_import/sequelace.rs create mode 100644 src-tauri/src/connection_import/tableplus.rs create mode 100644 src-tauri/src/connection_import/types.rs create mode 100644 src-tauri/src/connection_import_commands.rs create mode 100644 src/components/modals/ImportFromAppModal.tsx create mode 100644 src/types/connectionImport.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ed50079e..6034e633 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 745d5131..c0a41a9f 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 fbb034c9..b58b0b77 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4318,6 +4318,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 ff2cc441..cee91b40 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; @@ -181,6 +183,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(query_history::QueryHistoryState::default()) @@ -313,6 +316,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 80ab27af..2e60fe7d 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", @@ -248,7 +250,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 892b7fbf..7ba1432b 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", @@ -261,7 +263,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 77496f23..0ef2f70e 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", @@ -248,7 +250,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 af708770..aebf9ac6 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", @@ -248,7 +250,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 3690db6c..996296dc 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", @@ -248,7 +250,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 e3d33562..c3f1deae 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": "接続", @@ -261,7 +263,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 3c806daf..2ef0728f 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": "Подключения", @@ -264,7 +266,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 5421c18c..d687c2d0 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": "连接", @@ -247,7 +249,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) */}
-