diff --git a/AGENTS.md b/AGENTS.md index 3075e83..f49a3e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,9 +193,9 @@ cargo test # Rust tests | DB Viewer: Redis browse | โŒ | Test connection works; browsing not wired | | Password storage in OS keychain | โœ… | macOS Keychain, Linux Secret Service, Windows Credential Manager | | SSH tunnel config UI | โœ… | Host, port, user, auth method, key path, passphrase fields | -| SSH tunnel runtime | ๐ŸŸก | UI exists; backend is a **placeholder** (TODO: ssh2 crate integration) | +| SSH tunnel runtime | โœ… | Real ssh2 tunnel (password + key auth), binds 127.0.0.1 only, secrets in OS keychain (`ssh_password:` / `ssh_passphrase:`), closed on pool eviction / app exit; TLS downgraded to `require` through the tunnel | | SSL/TLS config UI | โœ… | Mode (disable/require/verify-ca/verify-full), cert paths | -| SSL/TLS runtime | ๐ŸŸก | Config persisted; **not yet passed to sqlx/tokio-postgres** | +| SSL/TLS runtime | โœ… | PostgreSQL all modes via rustls (disable/require/verify-ca/verify-full; **v1: `verify-ca` behaves as `verify-full`** โ€” documented refinement), client certs PKCS#1/PKCS#8/EC, encrypted client keys rejected; MySQL test path maps modes (verify-full โ†’ VerifyIdentity) | ### Home Screen & Organization | Feature | Status | Details | @@ -237,7 +237,10 @@ cargo test # Rust tests | Row selection (checkboxes + select all) | โœ… | Bulk copy (JSON/CSV/SQL) and delete | | Export toolbar (JSON, CSV, SQL, Markdown) | โœ… | Client-side Blob download of visible rows | | Auto-refresh timer | โœ… | Configurable interval in settings | -| Changes queue (INSERT, UPDATE, DELETE) | โœ… | Queue changes โ†’ Commit All; cancel individual changes. Tab bar shows a **Changes** icon button with a pending-count badge that toggles the bottom panel (the queue dropdown was removed from the table toolbar โ€” one entry point only) | +| Changes queue (INSERT, UPDATE, DELETE, bulk_insert, empty_table, drop_table) | โœ… | Stage โ†’ **Commit All**. Tab bar **Changes** button (amber border + count badge when pending) toggles a **popover** anchored to it: header with **Visual/SQL** toggle (cards showing op badge + table + description + per-change **Revert**, or a generated-SQL preview via `buildChangeSql`), footer **Clear All** + **Commit All (N)** with **โŒ˜S/Ctrl+S** shortcut. Committed cards show a green โœ“ (failed โœ—); committing `drop_table` auto-closes open tabs of that table | +| Auto schema-tree refresh | โœ… | Tree auto-refreshes after a successful schema-modifying query run (`CREATE`/`DROP`/`ALTER`/`TRUNCATE` via `isSchemaModifyingQuery`) and after committing `drop_table` via the queue โ€” no manual refresh needed | +| Data import (CSV/JSON) | โœ… | Table overflow menu โ†’ ImportDialog: file pick, parse, preview (first 100 rows), headerโ†’column mapping, caps 100k rows / 100 MB; stages a bulk_insert change through the queue โ†’ Commit All | +| Table menu actions | โœ… | Copy table schema (DDL via pg_dump / sqlite_master), Empty Table (DELETE) / Delete Table (DROP) through the queue with confirm, export stubs wired (JSON/CSV/SQL/Markdown) | | Edit connection modal (from DB viewer) | โœ… | AnimatedModal with keychain password fetch on test | | Connection drop banner | โœ… | Auto-detects broken connections with reconnect prompt | | Inline cell editing | โŒ | Cells are read-only; changes via queue Insert button only | @@ -273,7 +276,7 @@ cargo test # Rust tests | Saved queries (named, organized) | โœ… | v6 `queries` table (nullable `connection_id` for global queries, `folder` field, `ON DELETE CASCADE`); `save_query`/`get_saved_queries`/`update_saved_query`/`delete_saved_query` commands with validation (name โ‰ค200, folder โ‰ค100, text โ‰ค1MB); **SaveQueryDialog** (name + folder, empty-name guard); managed in the Queries view Saved tab | | Query favorites / pinning | โœ… | Star toggle per history entry via `set_history_favorite`; favorites-only filter in the Queries view History tab | | Queries view | โœ… | Two-pane layout following Explorer: left sidebar (Explorer-styled header โ€” Queries title, History/Saved dropdown, favorites/clear/search icons, animated search) scoped to the **current connection**, right side reuses the shared tabbed query workspace (TabBar + toolbar + editor + results); clicking a history/saved row loads it into the editor | -| Editor settings (font, tab size, word wrap, minimap) | โŒ | Settings page has "Editor" tab with "coming soon" placeholder | +| Editor settings (font, tab size, word wrap, minimap) | โœ… | Font size (8โ€“24), font family (allowlist), word wrap, minimap, tab size (2โ€“8) โ€” applied live via Monaco `updateOptions`, no remount | ### Backup & Restore | Feature | Status | Details | @@ -302,7 +305,7 @@ cargo test # Rust tests | Confirm-before-delete toggle | โœ… | When off, folder/bulk deletes execute without a confirmation dialog | | Default ports per DB type | โœ… | New-connection forms prefill the port from `default_ports` per DB type (custom ports in pasted URLs still win) | | More keyboard shortcuts | โŒ | Only 2 configurable actions | -| Editor settings | โŒ | Placeholder tab | +| Editor settings | โœ… | Five options wired to the settings store + live Monaco `updateOptions` | | SSH key management | โŒ | Only path inputs, no key file reading | | Settings export/import | โŒ | | diff --git a/README.md b/README.md index 145d5da..0748135 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,17 @@ Most database GUI clients either lock essential productivity features behind pay | Saved connections | 2 | Unlimited | **Unlimited** | | Saved queries | 5 | Unlimited | **Unlimited** | | Data export (CSV, JSON, SQL) | โŒ (paid only) | Basic only | **JSON, CSV, SQL, Markdown** | -| Data import (CSV, JSON) | โŒ (paid only) | โœ… | ๐ŸŸก *Upcoming* | +| Data import (CSV, JSON) | โŒ (paid only) | โœ… | **โœ… CSV/JSON + column mapping** | | pg_dump / pg_restore GUI | โŒ | โŒ (paid only) | **First-class UI** | | DB-to-DB sync | โŒ | โŒ | **Built-in pipe sync** | | Object explorer depth | Tables, views | Tables, views | **Functions, Triggers, Enums, Sequences, Extensions** | | ER diagram / schema visualizer | โŒ (planned) | โŒ (paid only) | **โœ… Interactive React Flow** | | Inline cell editing | โœ… | โœ… | ๐ŸŸก *Upcoming* | -| SSH tunneling | ๐ŸŸก (likely paid) | โœ… | ๐ŸŸก *Config UI done* | +| SSH tunneling | ๐ŸŸก (likely paid) | โœ… | **โœ… Full tunnel (password + key auth, keychain)** | | OS credential vault | โœ… | โœ… | **Keychain / Secret Service** | | Workspace / folder hierarchy | โŒ | โŒ | **Multi-level tree + tags** | -| Changes queue (stage & commit) | โŒ | โŒ | **โœ… Queue โ†’ Commit All** (tab-bar **Changes** button with count badge toggles the commit panel) | -| Query history | โœ… (auto-saved) | โœ… | ๐ŸŸก *Backend done, UI pending* | +| Changes queue (stage & commit) | โŒ | โŒ | **โœ… Queue โ†’ Commit All** (tab-bar **Changes** button with count badge toggles a popover: Visual/SQL preview, per-change revert, Clear All, โŒ˜S commit) | +| Query history | โœ… (auto-saved) | โœ… | **โœ… Toolbar dropdown, favorites, Queries view** | | AI assistant | โœ… (BYO key) | โŒ (paid only) | ๐Ÿ”ฎ *Planned โ€” BYOK* | | Open source | โŒ | โœ… (GPLv3) | **โœ… (MIT)** | | Desktop shell | Native webview | Electron (~250MB) | **Tauri 2.0 (~40MB)** | @@ -103,7 +103,7 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **Query Tabs** โ€” dedicated query tabs alongside table tabs, results rendered in the same virtualized data grid, close with Cmd/Ctrl+W - **SQL Autocomplete** โ€” keyword + table suggestions from the active schema; typing `table.` suggests that table's columns (schema introspection, cached per schema) - **Multi-Tab Workspace** โ€” unlimited named tabs, session persistence across restarts -- **Changes Queue** โ€” queue INSERT/UPDATE/DELETE changes; preview before committing all. The tab bar's **Changes** button (checklist icon + pending-count badge) toggles the bottom Commit All panel โ€” the single entry point +- **Changes Queue** โ€” queue INSERT/UPDATE/DELETE/import/drop changes; preview before committing all. The tab bar's **Changes** button (checklist icon + amber pending border + count badge) toggles a popover with a **Visual/SQL** preview toggle, per-change revert, **Clear All** / **Commit All (N)** footer and a **โŒ˜S** shortcut โ€” the single entry point - **Smart Default Sort** โ€” auto-detects `updated_at`, `created_at`, `_id` columns for logical initial sorting - **Query History** โ€” recent queries per connection in a toolbar dropdown (load / run / favorite / clear), consecutive-identical dedup, retention pruned to 500 per connection - **Saved Queries** โ€” save the current query with a name + folder from the toolbar; manage them in the Queries view @@ -117,7 +117,7 @@ Full tree-view navigation of all native PostgreSQL schema objects: - **FK Preview** โ€” click a foreign key cell to preview the referenced row - **JSON/JSONB Viewer** โ€” popover with formatted/raw tabs and copy button - **Auto-Refresh** โ€” configurable interval timer -- *(Inline cell editing, visual filter builder, and data import โ€” upcoming)* +- *(Inline cell editing and visual filter builder โ€” upcoming)* ### PostgreSQL Administrative Tools - **Visual Backup** โ€” `pg_dump` wrapper with format selector (Plain SQL, Custom, Tar, Directory), file browser, schema filter, no-owner toggle, real-time progress bar @@ -239,12 +239,11 @@ gridline/ - **Query History & Saved Queries** โ€” toolbar history dropdown (load / run / favorite / clear), favorites, consecutive-identical dedup + 500-retention pruning, SaveQueryDialog, and a two-pane Queries view (History / Saved Queries sidebar scoped per connection + tabbed query workspace) - **Consolidated Navigation** โ€” merged Functions/Triggers/Sequences/Enums/Extensions into a single Objects view (object-type dropdown) and Backup/Restore/DB Sync into a single Tools view (operation dropdown) - **Settings (Redesigned & Fully Wired)** โ€” DB-viewer-styled settings screen (icon+text sidebar, tab-titled header, border-sharp no-card sections, Back returns to origin view); all settings functional: theme (light/dark/system, applied live + native macOS Overlay titlebar sync), font size, **accent color** (circle palette), default folder on startup, confirm-before-delete toggle, default ports prefill; drag-and-drop tag reorder +- **Editor Settings, SSH/SSL Runtime, Data Import** โ€” Monaco editor options (font size/family, word wrap, minimap, tab size) applied live; real SSH tunnel (`ssh2`, password + key auth, keychain secrets, full lifecycle) and TLS (`rustls`, all modes + client certs) for PostgreSQL/MySQL; CSV/JSON import with preview + column mapping through the changes queue; table-menu loose ends (Copy table schema DDL, Empty/Delete Table via queue, export stubs wired) ### ๐ŸŸก In Progress / Upcoming -- **Editor Settings** โ€” font, tab size, word wrap, minimap options -- **SSH/SSL Runtime** โ€” SSH tunnel via `ssh2` crate, SSL/TLS config passed to `sqlx`/`tokio-postgres` - **Inline Cell Editing** โ€” Edit cells directly in the data grid -- **Data Import** โ€” CSV, JSON import with column mapping +- **Visual Filter Builder** โ€” drag-and-drop filter construction ### ๐Ÿ”ฎ Future - **Multi-DB Support** โ€” MySQL browsing, Redis key browser, full MySQL/SQLite/Redis parity with PostgreSQL diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b8ba392..d022f5a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -112,7 +112,7 @@ checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" dependencies = [ "keyring-core", "log", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -329,6 +329,16 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bcder" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b593e5aeaf7992d388c08a9831c921cd703718064b3e50ba8e6d666d6cf86ca7" +dependencies = [ + "bytes", + "smallvec", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -704,6 +714,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -727,7 +747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -740,7 +760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1770,6 +1790,10 @@ dependencies = [ "indexmap 2.14.0", "redis", "rusqlite", + "rustls 0.23.43", + "rustls-native-certs", + "rustls-pemfile 2.2.0", + "rustls-pki-types", "serde", "serde_json", "sqlx", @@ -1782,6 +1806,7 @@ dependencies = [ "tauri-plugin-opener", "tokio", "tokio-postgres", + "tokio-postgres-rustls", "urlencoding", "uuid", ] @@ -3016,6 +3041,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-sys" version = "0.9.117" @@ -3115,6 +3146,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3748,10 +3789,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -3761,6 +3830,24 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -3771,6 +3858,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -3792,6 +3890,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3859,6 +3966,19 @@ dependencies = [ "untrusted", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3866,7 +3986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4310,8 +4430,8 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", - "rustls-pemfile", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", "serde", "serde_json", "sha2 0.10.9", @@ -4616,7 +4736,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -5156,6 +5276,30 @@ dependencies = [ "whoami 2.1.2", ] +[[package]] +name = "tokio-postgres-rustls" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04fb792ccd6bbcd4bba408eb8a292f70fc4a3589e5d793626f45190e6454b6ab" +dependencies = [ + "ring", + "rustls 0.23.43", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-certificate", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.43", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -6493,6 +6637,25 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x509-certificate" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66534846dec7a11d7c50a74b7cdb208b9a581cad890b7866430d438455847c85" +dependencies = [ + "bcder", + "bytes", + "chrono", + "der", + "hex", + "pem", + "ring", + "signature", + "spki", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8917729..d764a19 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,4 +38,10 @@ ssh2 = { version = "0.9" } deadpool-postgres = { version = "0.14" } indexmap = { version = "2", features = ["serde"] } tauri-plugin-keyring-store = { version = "0.2.0", default-features = false } +tokio-postgres-rustls = "0.12" +# rustls: ring-only crypto provider (no aws-lc-rs / cmake C build); tls12 needed for Postgres +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } +rustls-pemfile = "2" +rustls-pki-types = "1" +rustls-native-certs = "0.7" diff --git a/src-tauri/src/commands/connections.rs b/src-tauri/src/commands/connections.rs index bc05bd4..798c4cc 100644 --- a/src-tauri/src/commands/connections.rs +++ b/src-tauri/src/commands/connections.rs @@ -96,8 +96,21 @@ pub fn update_connection( } #[tauri::command] -pub fn delete_connection(state: tauri::State, id: String) -> Result<(), String> { - delete_connection_inner(&state.db_store, &id) +pub fn delete_connection( + state: tauri::State, + id: String, + app: tauri::AppHandle, +) -> Result<(), String> { + delete_connection_inner(&state.db_store, &id)?; + // Purge keychain secrets (missing entries are no-ops) and close any + // SSH tunnel associated with the deleted connection. + let _ = crate::commands::keychain::delete_connection_password_internal(&app, &id); + let _ = crate::commands::keychain::delete_connection_ssh_password_internal(&app, &id); + let _ = crate::commands::keychain::delete_connection_ssh_passphrase_internal(&app, &id); + if let Ok(mut mgr) = state.ssh_manager.lock() { + mgr.close_tunnel(&id); + } + Ok(()) } #[tauri::command] @@ -147,6 +160,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -177,6 +191,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -205,6 +220,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, diff --git a/src-tauri/src/commands/db_viewer.rs b/src-tauri/src/commands/db_viewer.rs index bba1ae5..5659bda 100644 --- a/src-tauri/src/commands/db_viewer.rs +++ b/src-tauri/src/commands/db_viewer.rs @@ -90,6 +90,75 @@ pub fn offset(page: i64, page_size: i64) -> i64 { (page - 1) * page_size } +// --------------------------------------------------------------------------- +// Table DDL helpers +// --------------------------------------------------------------------------- + +/// Fetch the stored `CREATE TABLE` statement for a SQLite table from +/// `sqlite_master`. Errors when the table does not exist. +pub fn get_sqlite_ddl(conn: &rusqlite::Connection, table: &str) -> Result { + conn.query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND name=?1", + rusqlite::params![table], + |row| row.get::<_, String>(0), + ) + .map_err(|e| format!("table DDL not found for {table}: {e}")) +} + +/// Build the `pg_dump` argument vector for schema-only DDL extraction of a +/// single table. The password is intentionally NOT part of these args โ€” it is +/// passed via the `PGPASSWORD` environment variable so it never appears on +/// the command line. +pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec { + vec![ + "--schema-only".into(), + "--no-owner".into(), + format!("--schema={schema}"), + format!("--table={table}"), + ] +} + +/// Check whether the system `pg_dump` binary is on PATH. +pub fn pg_dump_available() -> bool { + std::process::Command::new("pg_dump") + .arg("--version") + .output() + .is_ok() +} + +/// Extract a single table's DDL from a PostgreSQL database by shelling out to +/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the +/// `PGPASSWORD` environment variable only โ€” never as argv โ€” and are never +/// logged. Execution requires a reachable PostgreSQL server plus an installed +/// `pg_dump`; unit tests cover the argument construction instead. +pub fn get_pg_ddl_via_dump( + schema: &str, + table: &str, + host: &str, + port: u16, + user: &str, + db: &str, + password: &str, +) -> Result { + if !pg_dump_available() { + return Err("pg_dump not found. Install PostgreSQL client tools to copy table schema.".into()); + } + let mut cmd = std::process::Command::new("pg_dump"); + cmd.args([ + format!("--host={host}"), + format!("--port={port}"), + format!("--username={user}"), + format!("--dbname={db}"), + ]); + cmd.args(build_pg_dump_ddl_args(schema, table)); + cmd.env("PGPASSWORD", password); + let out = cmd.output().map_err(|e| format!("pg_dump spawn failed: {e}"))?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + // --------------------------------------------------------------------------- // Filter / Sort โ†’ SQL helpers // --------------------------------------------------------------------------- @@ -420,6 +489,112 @@ fn json_to_sqlite_value(v: &serde_json::Value) -> rusqlite::types::Value { } } +/// Build the SQL skeleton for a bulk INSERT into PostgreSQL. +/// +/// Emits `$N` placeholders; callers bind one row of values per execution so +/// the same statement can be reused for every row in the batch. +pub fn build_pg_bulk_insert_sql(schema: &str, table: &str, columns: &[String]) -> String { + let cols: Vec = columns.iter().map(|c| format!("\"{}\"", c)).collect(); + let placeholders: Vec = (1..=columns.len()).map(|i| format!("${i}")).collect(); + format!( + "INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})", + schema, + table, + cols.join(", "), + placeholders.join(", ") + ) +} + +/// Build a `DROP TABLE` statement (schema-qualified). SQLite accepts the same +/// qualified form against the `main` schema. +pub fn build_drop_table_sql(schema: &str, table: &str) -> String { + format!("DROP TABLE \"{}\".\"{}\"", schema, table) +} + +/// Build a `DELETE FROM` (empty-table) statement (schema-qualified). SQLite +/// accepts the same qualified form against the `main` schema. +pub fn build_empty_table_sql(schema: &str, table: &str) -> String { + format!("DELETE FROM \"{}\".\"{}\"", schema, table) +} + +/// Apply a batch of rows to a SQLite table inside a single transaction. +/// +/// Every row is inserted with its own parameterized statement; on the first +/// error the whole transaction is rolled back so no partial batch survives. +pub fn apply_bulk_insert_sqlite( + conn: &rusqlite::Connection, + table: &str, + columns: &[String], + rows: &[Vec], +) -> Result { + let sql = build_insert_sql("main", table, columns); + conn.execute_batch("BEGIN").map_err(|e| e.to_string())?; + let result = (|| { + let mut count = 0; + for (i, row) in rows.iter().enumerate() { + let params: Vec = + row.iter().map(json_to_sqlite_value).collect(); + conn.execute(&sql, rusqlite::params_from_iter(params)) + .map_err(|e| format!("row {}: {}", i + 1, e))?; + count += 1; + } + Ok::(count) + })(); + match result { + Ok(count) => { + conn.execute_batch("COMMIT").map_err(|e| e.to_string())?; + Ok(count) + } + Err(e) => { + // Best-effort rollback so a failed batch never persists partially. + let _ = conn.execute_batch("ROLLBACK"); + Err(e) + } + } +} + +/// Apply a batch of rows to a PostgreSQL table inside a single transaction. +/// +/// The same `$N`-placeholder statement is reused per row with natively bound +/// values; on the first error the transaction is rolled back. +pub async fn apply_bulk_insert_pg( + client: &tokio_postgres::Client, + schema: &str, + table: &str, + columns: &[String], + rows: &[Vec], +) -> Result { + let sql = build_pg_bulk_insert_sql(schema, table, columns); + client.batch_execute("BEGIN").await.map_err(|e| e.to_string())?; + let mut count = 0; + for (i, row) in rows.iter().enumerate() { + let boxed: Vec> = row.iter().map(pg_box_value).collect(); + let refs: Vec<&(dyn ToSql + Sync)> = boxed + .iter() + .map(|b| { + let r: &(dyn ToSql + Sync) = &**b; + r + }) + .collect(); + if let Err(e) = client.execute(&sql, &refs).await { + let _ = client.batch_execute("ROLLBACK").await; + return Err(format!("row {}: {}", i + 1, e)); + } + count += 1; + } + client + .batch_execute("COMMIT") + .await + .map_err(|e| e.to_string())?; + Ok(count) +} + +/// Sanitize a raw error string before it crosses the IPC boundary: redact +/// credential-like fragments (connection URLs, `password=...`) and cap length. +fn sanitize_error(e: &str) -> String { + truncate(&redact_secrets(e), 400) +} + /// Parse a JSON object string (e.g. `{"id": 1}`) into ordered (column, value) /// pairs. Insertion order of the JSON object is preserved by `serde_json`. fn parse_json_pairs(json: &str) -> Result, String> { @@ -538,6 +713,29 @@ pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_jso serde_json::Value::Null } +/// Establish a PostgreSQL connection with the given TLS connector and spawn +/// the background connection driver task. +/// +/// This helper keeps the two TLS branches of `db_connect` unified: without it +/// the `Connection` vs `Connection` +/// types would force duplicated spawn/register blocks. +pub(crate) async fn connect_pg_with( + pgconfig: &tokio_postgres::Config, + tls: T, +) -> Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>), tokio_postgres::Error> +where + T: tokio_postgres::tls::MakeTlsConnect, + T::Stream: Send + 'static, +{ + let (client, connection) = pgconfig.connect(tls).await?; + let handle = tokio::spawn(async move { + if let Err(e) = connection.await { + eprintln!("PostgreSQL connection error: {}", e); + } + }); + Ok((client, handle)) +} + #[tauri::command] pub async fn db_connect( connection_id: String, @@ -545,35 +743,87 @@ pub async fn db_connect( state: State<'_, crate::AppState>, ) -> Result<(), String> { if config.db_type == "postgresql" { - use tokio_postgres::NoTls; - - let host = &config.host; - let port = config.port.unwrap_or(5432) as u16; let user = config.username.as_deref().unwrap_or("postgres"); let dbname = config.database.as_deref().unwrap_or("postgres"); let password = config.password.as_deref().unwrap_or(""); + let default_port = config.port.unwrap_or(5432) as u16; - // Build a postgres URL connection string rather than the fragile - // libpq key=value format. tokio-postgres parses URLs reliably and - // urlencoding handles special characters in user/password/dbname. - use urlencoding::encode as enc; - let conn_str = format!( - "postgresql://{}:{}@{}:{}/{}?connect_timeout=10", - enc(user), - enc(password), - host, - port, - enc(dbname), + let ssh_cfg = config.ssh_config(); + let will_tunnel = ssh_cfg.is_some(); + + // TLS first: through a tunnel the peer is loopback, so + // verify-ca/verify-full degrade to encrypt-only `require`; direct + // connections honor the user's mode. Building this before opening the + // tunnel means a config error can't leak the tunnel. + let decision = crate::commands::ssh::effective_tls_decision( + crate::db::tls::tls_decision(config.ssl_mode.as_deref()), + will_tunnel, ); + let tls = crate::db::tls::build_tls_config( + decision, + config.ssl_ca_path.as_deref(), + config.ssl_cert_path.as_deref(), + config.ssl_key_path.as_deref(), + ) + .map_err(|e| sanitize_error(&e))?; - match tokio_postgres::connect(&conn_str, NoTls).await { - Ok((client, connection)) => { - let handle = tokio::spawn(async move { - if let Err(e) = connection.await { - eprintln!("PostgreSQL connection error: {}", e); - } - }); + // SSH tunnel: if configured, open a loopback tunnel to the remote DB + // and connect through it. The blocking ssh2 handshake runs in + // `spawn_blocking` so it never blocks the async runtime. + let (connect_host, connect_port, via_tunnel) = match ssh_cfg { + Some(ssh) => { + let key = connection_id.clone(); + let remote_host = config.host.clone(); + let remote_port = config.port.unwrap_or(5432) as u16; + let pw = config.ssh_password.clone(); + let pp = config.ssh_passphrase.clone(); + let backend = state.ssh_manager.lock().unwrap().backend_clone(); + let tunnel = tokio::task::spawn_blocking(move || { + backend.open( + &key, + &ssh, + &remote_host, + remote_port, + pw.as_deref(), + pp.as_deref(), + ) + }) + .await + .map_err(|e| format!("Connection failed: {e}"))? + .map_err(|e| sanitize_error(&e))?; + let lp = tunnel.local_port; + state + .ssh_manager + .lock() + .unwrap() + .insert_tunnel(connection_id.clone(), tunnel); + ("127.0.0.1".to_string(), lp, true) + } + None => (config.host.clone(), default_port, false), + }; + // Config builder: user/password/dbname are sent as-is (no URL + // percent-encoding needed), and the TLS connector is chosen explicitly. + let mut pgconfig = tokio_postgres::Config::new(); + pgconfig + .host(connect_host.clone()) + .port(connect_port) + .user(user) + .password(password) + .dbname(dbname) + .connect_timeout(std::time::Duration::from_secs(10)); + + let result = match tls { + None => connect_pg_with(&pgconfig, tokio_postgres::NoTls).await, + Some(cc) => { + let connector = + tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone()); + connect_pg_with(&pgconfig, connector).await + } + }; + + match result { + Ok((client, handle)) => { let mut pm = state.pool_manager.lock().await; pm.register( &connection_id, @@ -581,7 +831,16 @@ pub async fn db_connect( ); Ok(()) } - Err(e) => Err(format!("Connection failed: {}", pg_error_message(&e))), + Err(e) => { + if via_tunnel { + state + .ssh_manager + .lock() + .unwrap() + .close_tunnel(&connection_id); + } + Err(format!("Connection failed: {}", pg_error_message(&e))) + } } } else if config.db_type == "sqlite" { match rusqlite::Connection::open(&config.host) { @@ -1233,6 +1492,33 @@ pub async fn execute_change( client.execute(sql, &[]).await.map_err(|e| e.to_string())?; return Ok(()); } + Change::BulkInsert { + schema, + table, + columns, + rows, + .. + } => { + // Single transaction for the whole batch; rolls back on the + // first failed row so no partial batch persists. + return apply_bulk_insert_pg(client, schema, table, columns, rows) + .await + .map(|_| ()); + } + Change::DropTable { schema, table, .. } => { + client + .execute(&build_drop_table_sql(schema, table), &[]) + .await + .map_err(|e| e.to_string())?; + return Ok(()); + } + Change::EmptyTable { schema, table, .. } => { + client + .execute(&build_empty_table_sql(schema, table), &[]) + .await + .map_err(|e| e.to_string())?; + return Ok(()); + } }; // Box each value for trait-object binding (`$N` placeholders). The @@ -1302,6 +1588,26 @@ pub async fn execute_change( conn.execute(sql, []).map_err(|e| e.to_string())?; return Ok(()); } + Change::BulkInsert { + table, + columns, + rows, + .. + } => { + // Single transaction for the whole batch; rolls back on the + // first failed row so no partial batch persists. + return apply_bulk_insert_sqlite(conn, table, columns, rows).map(|_| ()); + } + Change::DropTable { schema, table, .. } => { + conn.execute(&build_drop_table_sql(schema, table), []) + .map_err(|e| e.to_string())?; + return Ok(()); + } + Change::EmptyTable { schema, table, .. } => { + conn.execute(&build_empty_table_sql(schema, table), []) + .map_err(|e| e.to_string())?; + return Ok(()); + } }; let sqlite_params: Vec = @@ -1500,6 +1806,68 @@ pub async fn get_extensions( } } +/// Fetch a table's `CREATE TABLE` DDL for display/copy. +/// +/// SQLite reads the stored statement from `sqlite_master` directly; PostgreSQL +/// shells out to the system `pg_dump --schema-only` so the output matches what +/// `pg_dump` would emit, scoped to the requested schema + table. +#[tauri::command] +pub async fn get_table_ddl( + connection_id: String, + schema: String, + table: String, + state: State<'_, crate::AppState>, + app: tauri::AppHandle, +) -> Result { + let mut pm = state.pool_manager.lock().await; + match pm.get(&connection_id) { + Some(DbHandle::Sqlite(conn)) => get_sqlite_ddl(conn, &table), + Some(DbHandle::Postgresql(_client, _)) => { + // Pull connection metadata so pg_dump reaches the same server the + // pool is connected to (host/port/user/dbname + keychain password). + let conn_row = state + .db_store + .lock() + .map_err(|e| e.to_string())? + .get_connections() + .map_err(|e| e.to_string())? + .into_iter() + .find(|c| c.id == connection_id) + .ok_or_else(|| format!("connection {connection_id} not found"))?; + let host = conn_row.host.clone(); + let port = conn_row.port.unwrap_or(5432) as u16; + let user = conn_row.username.unwrap_or_else(|| "postgres".into()); + let db = conn_row.database.unwrap_or_else(|| "postgres".into()); + let password = + crate::commands::keychain::get_connection_password_internal(&app, &connection_id)? + .unwrap_or_default(); + + // SSH-tunneled connections: pg_dump must reach the DB through the + // same local loopback listener the app uses, not the remote host. + let tunnel_port = state + .ssh_manager + .lock() + .map_err(|e| e.to_string())? + .get_local_port(&connection_id); + let (dump_host, dump_port) = match tunnel_port { + Some(lp) => ("127.0.0.1".to_string(), lp), + None => (host, port), + }; + + // pg_dump is blocking I/O; run it off the async runtime. Credentials + // travel via PGPASSWORD, never argv. + let ddl = tokio::task::spawn_blocking(move || { + get_pg_ddl_via_dump(&schema, &table, &dump_host, dump_port, &user, &db, &password) + }) + .await + .map_err(|e| format!("pg_dump task failed: {e}"))??; + Ok(sanitize_error(&ddl)) + } + None => Err("Connection not found".into()), + } +} + + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1620,4 +1988,132 @@ mod tests { sql ); } + + /// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement + /// from `sqlite_master`. + #[test] + fn sqlite_ddl_returns_create_table() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute("CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT)", []) + .unwrap(); + let ddl = get_sqlite_ddl(&conn, "foo").unwrap(); + assert!(ddl.contains("CREATE TABLE foo"), "got: {ddl}"); + } + + /// Verify that `get_sqlite_ddl` errors for a table that does not exist. + #[test] + fn sqlite_ddl_missing_table_errors() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + assert!(get_sqlite_ddl(&conn, "nope").is_err()); + } + + /// Verify that the pg_dump argument builder emits schema-only DDL flags + /// scoped to the requested schema and table. + #[test] + fn pg_dump_ddl_args_built() { + let args = build_pg_dump_ddl_args("public", "users"); + assert_eq!( + args, + vec![ + "--schema-only".to_string(), + "--no-owner".to_string(), + "--schema=public".to_string(), + "--table=users".to_string() + ] + ); + } + + /// Verify that a SQLite bulk insert applies every row in a single batch. + #[test] + fn apply_bulk_insert_sqlite_success() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT)", []) + .unwrap(); + let rows = vec![ + vec![serde_json::json!(1), serde_json::json!("y")], + vec![serde_json::json!(2), serde_json::json!("z")], + ]; + apply_bulk_insert_sqlite( + &conn, + "t", + &["a".to_string(), "b".to_string()], + &rows, + ) + .unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 2); + } + + /// Verify that a SQLite bulk insert rolls back the whole batch when any + /// row fails (a non-integer value bound to the INTEGER PRIMARY KEY column + /// raises a datatype mismatch). + #[test] + fn apply_bulk_insert_sqlite_inserts_and_rolls_back() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT)", []) + .unwrap(); + let rows = vec![ + vec![serde_json::json!(1), serde_json::json!("y")], + vec![serde_json::json!("bad"), serde_json::json!("z")], + ]; + let res = apply_bulk_insert_sqlite( + &conn, + "t", + &["a".to_string(), "b".to_string()], + &rows, + ); + assert!(res.is_err(), "non-integer PK value should fail"); + // Rollback: no rows persisted. + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0, "failed batch must roll back all rows"); + } + + /// Verify that a SQLite bulk insert reports the failing row index (1-based) + /// when a row cannot be inserted. + #[test] + fn apply_bulk_insert_sqlite_error_includes_row_index() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + // INTEGER PRIMARY KEY rejects non-integer values (datatype mismatch), + // guaranteeing row 2 fails. + conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY)", []).unwrap(); + let rows = vec![vec![serde_json::json!(1)], vec![serde_json::json!("x")]]; + let err = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string()], &rows) + .unwrap_err(); + assert!( + err.contains("row 2"), + "error should name the failing row index (1-based): {err}" + ); + } + + /// Verify that the PostgreSQL bulk-insert skeleton uses `$N` placeholders + /// and quotes schema, table, and columns. + #[test] + fn build_pg_bulk_insert_sql_shape() { + let sql = build_pg_bulk_insert_sql( + "public", + "users", + &["id".to_string(), "name".to_string()], + ); + assert_eq!( + sql, + r#"INSERT INTO "public"."users" ("id", "name") VALUES ($1, $2)"# + ); + } + + /// Verify that the DROP TABLE / DELETE-all SQL helpers quote schema + table. + #[test] + fn drop_and_empty_table_sql_shapes() { + assert_eq!( + build_drop_table_sql("public", "users"), + r#"DROP TABLE "public"."users""# + ); + assert_eq!( + build_empty_table_sql("public", "users"), + r#"DELETE FROM "public"."users""# + ); + } } \ No newline at end of file diff --git a/src-tauri/src/commands/demo.rs b/src-tauri/src/commands/demo.rs index 1cc2256..0e6b610 100644 --- a/src-tauri/src/commands/demo.rs +++ b/src-tauri/src/commands/demo.rs @@ -55,6 +55,7 @@ pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex) -> Re ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -108,6 +109,7 @@ fn ensure_demo_db_inner(store: &Mutex) -> Result<(), String> { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -143,6 +145,7 @@ fn ensure_demo_db_inner(store: &Mutex) -> Result<(), String> { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs index 1e28246..5a241a5 100644 --- a/src-tauri/src/commands/import_export.rs +++ b/src-tauri/src/commands/import_export.rs @@ -80,6 +80,7 @@ pub fn import_connections_inner(state: &Mutex, json: String) -> Result String { + format!("ssh_{kind}:{connection_id}") +} + +/// Store an SSH tunnel password in the OS keychain. +#[tauri::command] +pub fn save_connection_ssh_password( + app: tauri::AppHandle, + connection_id: String, + password: String, +) -> Result<(), String> { + app.keyring() + .store + .set_password(&ssh_account("password", &connection_id), &password) + .map_err(|e| e.to_string()) +} + +/// Retrieve an SSH tunnel password from the OS keychain. +/// Returns None if no SSH password was stored for this connection. +#[tauri::command] +pub fn get_connection_ssh_password( + app: tauri::AppHandle, + connection_id: String, +) -> Result, String> { + app.keyring() + .store + .get_password(&ssh_account("password", &connection_id)) + .map_err(|e| e.to_string()) +} + +/// Delete an SSH tunnel password from the OS keychain. +#[tauri::command] +pub fn delete_connection_ssh_password( + app: tauri::AppHandle, + connection_id: String, +) -> Result<(), String> { + app.keyring() + .store + .delete(&ssh_account("password", &connection_id)) + .map_err(|e| e.to_string()) +} + +/// Store an SSH private-key passphrase in the OS keychain. +#[tauri::command] +pub fn save_connection_ssh_passphrase( + app: tauri::AppHandle, + connection_id: String, + passphrase: String, +) -> Result<(), String> { + app.keyring() + .store + .set_password(&ssh_account("passphrase", &connection_id), &passphrase) + .map_err(|e| e.to_string()) +} + +/// Retrieve an SSH private-key passphrase from the OS keychain. +/// Returns None if no passphrase was stored for this connection. +#[tauri::command] +pub fn get_connection_ssh_passphrase( + app: tauri::AppHandle, + connection_id: String, +) -> Result, String> { + app.keyring() + .store + .get_password(&ssh_account("passphrase", &connection_id)) + .map_err(|e| e.to_string()) +} + +/// Delete an SSH private-key passphrase from the OS keychain. +#[tauri::command] +pub fn delete_connection_ssh_passphrase( + app: tauri::AppHandle, + connection_id: String, +) -> Result<(), String> { + app.keyring() + .store + .delete(&ssh_account("passphrase", &connection_id)) + .map_err(|e| e.to_string()) +} + +/// Retrieve an SSH tunnel password from the OS keychain (internal helper). +/// Returns None if no SSH password was stored for this connection. +pub fn get_connection_ssh_password_internal( + app: &tauri::AppHandle, + connection_id: &str, +) -> Result, String> { + app.keyring() + .store + .get_password(&ssh_account("password", connection_id)) + .map_err(|e| e.to_string()) +} + +/// Delete an SSH tunnel password from the OS keychain (internal helper). +/// Errors are ignored by callers (deleting an absent key is a no-op). +pub fn delete_connection_ssh_password_internal( + app: &tauri::AppHandle, + connection_id: &str, +) -> Result<(), String> { + app.keyring() + .store + .delete(&ssh_account("password", connection_id)) + .map_err(|e| e.to_string()) +} + +/// Delete an SSH private-key passphrase from the OS keychain (internal helper). +/// Errors are ignored by callers (deleting an absent key is a no-op). +pub fn delete_connection_ssh_passphrase_internal( + app: &tauri::AppHandle, + connection_id: &str, +) -> Result<(), String> { + app.keyring() + .store + .delete(&ssh_account("passphrase", connection_id)) + .map_err(|e| e.to_string()) +} + /// Delete a connection password from the OS keychain. #[tauri::command] pub fn delete_connection_password( @@ -49,4 +167,27 @@ pub fn delete_connection_password( .store .delete(&connection_id) .map_err(|e| e.to_string()) +} + +/// Delete a connection password from the OS keychain (internal helper). +/// Errors are ignored by callers (deleting an absent key is a no-op). +pub fn delete_connection_password_internal( + app: &tauri::AppHandle, + connection_id: &str, +) -> Result<(), String> { + app.keyring() + .store + .delete(connection_id) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ssh_account_namespaces_password() { + assert_eq!(ssh_account("password", "c1"), "ssh_password:c1"); + assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1"); + } } \ No newline at end of file diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs index 7fba407..157c13f 100644 --- a/src-tauri/src/commands/query.rs +++ b/src-tauri/src/commands/query.rs @@ -906,6 +906,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index 484ad9b..ef5ced8 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -1,110 +1,112 @@ -use serde::{Deserialize, Serialize}; +use crate::db::tls::TlsDecision; +use crate::models::SshConfig; use std::collections::HashMap; +use std::sync::Arc; -/// SSH tunnel configuration. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SshConfig { - pub host: String, - pub port: u16, - pub user: String, - /// "password" or "key" - pub auth_method: String, - pub password: Option, - pub private_key_path: Option, - pub passphrase: Option, +/// Through a tunnel the TLS peer is loopback (`127.0.0.1`), so certificate +/// verification is meaningless: `verify-ca`/`verify-full` degrade to +/// encrypt-only `require`. A direct (non-tunneled) connection honors the +/// user's mode unchanged. +pub fn effective_tls_decision(d: TlsDecision, via_tunnel: bool) -> TlsDecision { + if via_tunnel && matches!(d, TlsDecision::Verify) { + TlsDecision::Require + } else { + d + } } -impl SshConfig { - /// Create a new `SshConfig` with the required fields. - pub fn new( - host: String, - port: u16, - user: String, - auth_method: String, - ) -> Self { - SshConfig { - host, - port, - user, - auth_method, - password: None, - private_key_path: None, - passphrase: None, +/// A live tunnel handle. `closer` drops the listener + ssh session when called. +pub struct Tunnel { + pub local_port: u16, + closer: Option>, +} + +impl Tunnel { + /// Create a tunnel handle with no resources to clean up (test backend). + pub fn fake(port: u16) -> Self { + Tunnel { + local_port: port, + closer: None, } } - - /// Validate SSH configuration. - /// - /// Returns `true` if: - /// - `host` is not empty - /// - `port` is in range 1..=65535 (u16 guarantees <= 65535) - /// - `user` is not empty - pub fn is_valid(&self) -> bool { - !self.host.is_empty() && self.port >= 1 && !self.user.is_empty() - } } -/// Represents an active SSH tunnel connection. -#[derive(Debug)] -struct SshTunnel { - local_port: u16, - remote_host: String, - remote_port: u16, +/// Backend that actually establishes SSH tunnels. +/// +/// The manager only does bookkeeping; opening/closing the OS-level tunnel is +/// delegated here so it can be faked in tests. +pub trait TunnelBackend: Send + Sync { + /// Open a tunnel to `remote_host:remote_port` via `cfg` and return a + /// handle exposing the bound local port. + fn open( + &self, + key: &str, + cfg: &SshConfig, + remote_host: &str, + remote_port: u16, + password: Option<&str>, + passphrase: Option<&str>, + ) -> Result; } /// Manages SSH tunnels, mapping connection keys to active tunnels. /// -/// This is a placeholder implementation. Real SSH connectivity (via `ssh2` -/// or `async-ssh2`) will be added in a later task. Currently the manager -/// stores mock entries when validation passes. -#[derive(Debug)] +/// Bookkeeping only: validation, key->tunnel map, and lifecycle hooks. +/// The actual SSH connectivity is delegated to a `TunnelBackend` so the +/// manager's behavior is unit-testable with a fake backend. pub struct SshTunnelManager { - tunnels: HashMap, + tunnels: HashMap, + backend: Arc, } impl SshTunnelManager { - /// Create a new empty tunnel manager. - pub fn new() -> Self { + /// Create a new tunnel manager backed by `backend`. + pub fn new(backend: Arc) -> Self { SshTunnelManager { tunnels: HashMap::new(), + backend, } } /// Open an SSH tunnel for the given config. /// - /// Returns the local port on success. - /// - /// TODO: Replace placeholder with a real SSH connection via `ssh2` or - /// `async-ssh2`. Currently stores a mock entry (`local_port = 15432`) - /// when `config.is_valid()` passes. - pub fn open_tunnel(&mut self, key: &str, config: &SshConfig) -> Result { - if !config.is_valid() { + /// Returns the local port on success. Replaces any existing tunnel for + /// the same key (closing the old one). + pub fn open_tunnel( + &mut self, + key: &str, + cfg: &SshConfig, + remote_host: &str, + remote_port: u16, + password: Option<&str>, + passphrase: Option<&str>, + ) -> Result { + if !cfg.is_valid() { return Err("invalid SSH configuration".to_string()); } - // TODO: Replace with real SSH tunnel via ssh2::Session + port forwarding. - // For now, store a mock entry with local_port = 15432. - self.tunnels.insert( - key.to_string(), - SshTunnel { - local_port: 15432, - remote_host: config.host.clone(), - remote_port: config.port, - }, - ); - Ok(15432) + let tunnel = self + .backend + .open(key, cfg, remote_host, remote_port, password, passphrase)?; + let port = tunnel.local_port; + if let Some(old) = self.tunnels.insert(key.to_string(), tunnel) { + drop(old.closer); + } + Ok(port) } /// Close and remove the SSH tunnel for the given key. - /// - /// TODO: When real SSH is implemented, this should disconnect the - /// session and free the local port. pub fn close_tunnel(&mut self, key: &str) { - self.tunnels.remove(key); + if let Some(t) = self.tunnels.remove(key) { + drop(t.closer); + } } /// Close all active SSH tunnels. pub fn close_all(&mut self) { - self.tunnels.clear(); + let tunnels = std::mem::take(&mut self.tunnels); + for (_, t) in tunnels { + drop(t.closer); + } } /// Get the local port for an active tunnel, if any. @@ -112,12 +114,122 @@ impl SshTunnelManager { self.tunnels.get(key).map(|t| t.local_port) } + /// Clone of the active backend, for handing into `spawn_blocking` so the + /// blocking ssh2 work never blocks an async runtime thread. + pub fn backend_clone(&self) -> Arc { + self.backend.clone() + } + + /// Insert an already-opened tunnel under `key`, closing any previous one. + pub fn insert_tunnel(&mut self, key: String, tunnel: Tunnel) { + if let Some(old) = self.tunnels.insert(key, tunnel) { + drop(old.closer); + } + } + /// Return the number of active tunnels. pub fn active_count(&self) -> usize { self.tunnels.len() } } +/// Real ssh2 backend: binds a loopback listener, authenticates to the SSH +/// host over a blocking socket, and pumps data between the local client and +/// the remote DB over an SSH direct-tcpip channel. +/// +/// The whole `open` runs inside `tokio::task::spawn_blocking` at the call +/// sites because `ssh2::Session` is purely blocking. +pub struct Ssh2Backend; + +impl TunnelBackend for Ssh2Backend { + fn open( + &self, + _key: &str, + cfg: &SshConfig, + remote_host: &str, + remote_port: u16, + password: Option<&str>, + passphrase: Option<&str>, + ) -> Result { + use ssh2::Session; + + // Loopback-only listener with an ephemeral port. + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| format!("bind local tunnel port: {e}"))?; + let local_port = listener + .local_addr() + .map_err(|e| format!("local tunnel address: {e}"))? + .port(); + + let tcp = std::net::TcpStream::connect((cfg.host.as_str(), cfg.port)) + .map_err(|e| format!("connect ssh host: {e}"))?; + let mut session = Session::new().map_err(|e| format!("ssh session: {e}"))?; + session.set_tcp_stream(tcp); + session.handshake().map_err(|e| format!("ssh handshake: {e}"))?; + + match cfg.auth_method.as_str() { + "key" => { + let path = cfg.private_key_path.as_deref().ok_or_else(|| { + "private_key_path required for key auth".to_string() + })?; + session + .userauth_pubkey_file(&cfg.user, None, std::path::Path::new(path), passphrase) + .map_err(|e| format!("ssh key auth: {e}"))?; + } + _ => session + .userauth_password(&cfg.user, password.unwrap_or("")) + .map_err(|e| format!("ssh password auth: {e}"))?, + } + if !session.authenticated() { + return Err("SSH authentication failed".into()); + } + + let remote_host = remote_host.to_string(); + let session = Arc::new(std::sync::Mutex::new(session)); + let (closer_tx, closer_rx) = std::sync::mpsc::channel::<()>(); + std::thread::spawn(move || { + if let Ok((mut local, _)) = listener.accept() { + // Open the direct-tcpip channel to the remote DB. `Channel` is + // cloneable (Arc-shared inner), so one clone per direction + // lets two pump threads copy data in parallel. + let mut channel = match session.lock().unwrap().channel_direct_tcpip( + &remote_host, + remote_port as u16, + None, + ) { + Ok(c) => c, + Err(_) => return, + }; + // The accepted socket stays owned by this thread; when the + // tunnel is closed the closer wakes us, we drop `local` and + // the pumps end on EOF/broken pipe. + let mut upstream = channel.clone(); + let down = local.try_clone(); + let pump = match down { + Ok(down) => Some(std::thread::spawn(move || { + let mut down = down; + // client -> remote DB + let _ = std::io::copy(&mut down, &mut upstream); + })), + Err(_) => None, + }; + // remote DB -> client (this thread) + let _ = std::io::copy(&mut channel, &mut local); + if let Some(p) = pump { + let _ = p.join(); + } + } + let _ = closer_rx.recv(); + }); + Ok(Tunnel { + local_port, + closer: Some(Box::new(move || { + let _ = closer_tx.send(()); + })), + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -176,4 +288,136 @@ mod tests { ); assert!(config.is_valid(), "port 1 should be valid"); } + + // ------------------------------------------------------------------ + // Tunnel manager tests (fake backend) + // ------------------------------------------------------------------ + + #[derive(Debug, Default)] + struct FakeBackend { + opens: std::sync::Mutex>, + next_port: u16, + } + + impl Clone for FakeBackend { + fn clone(&self) -> Self { + Self { + opens: std::sync::Mutex::new(self.opens.lock().unwrap().clone()), + next_port: self.next_port, + } + } + } + + impl TunnelBackend for FakeBackend { + fn open( + &self, + key: &str, + _cfg: &crate::models::SshConfig, + _remote_host: &str, + _remote_port: u16, + _pw: Option<&str>, + _pp: Option<&str>, + ) -> Result { + self.opens.lock().unwrap().push(key.to_string()); + let p = self.next_port; + Ok(Tunnel::fake(p)) + } + } + + #[test] + fn manager_open_and_get_port() { + let backend = Arc::new(FakeBackend { + next_port: 22222, + ..Default::default() + }); + let mut mgr = SshTunnelManager::new(backend.clone()); + let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into()); + let port = mgr + .open_tunnel("c1", &cfg, "db.host", 5432, None, None) + .unwrap(); + assert_eq!(port, 22222); + assert_eq!(mgr.get_local_port("c1"), Some(22222)); + } + + #[test] + fn manager_invalid_config_errors() { + let backend = Arc::new(FakeBackend::default()); + let mut mgr = SshTunnelManager::new(backend); + let cfg = crate::models::SshConfig::new("".into(), 22, "u".into(), "password".into()); + assert!(mgr + .open_tunnel("c1", &cfg, "db.host", 5432, None, None) + .is_err()); + } + + #[test] + fn manager_close_removes_tunnel() { + let backend = Arc::new(FakeBackend { + next_port: 1, + ..Default::default() + }); + let mut mgr = SshTunnelManager::new(backend); + let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into()); + mgr.open_tunnel("c1", &cfg, "db.host", 5432, None, None) + .unwrap(); + mgr.close_tunnel("c1"); + assert_eq!(mgr.get_local_port("c1"), None); + assert_eq!(mgr.active_count(), 0); + } + + #[test] + fn tunneled_tls_is_downgraded_to_require() { + // verify-full through a tunnel degrades to encrypt-only `require` + assert_eq!( + effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), true), + crate::db::tls::TlsDecision::Require + ); + // direct (non-tunneled) connection keeps the user's mode + assert_eq!( + effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), false), + crate::db::tls::TlsDecision::Verify + ); + // disable stays disabled regardless of tunneling + assert_eq!( + effective_tls_decision(crate::db::tls::tls_decision(Some("disable")), true), + crate::db::tls::TlsDecision::Disable + ); + } + + #[test] + fn manager_backend_clone_returns_backend() { + let backend = Arc::new(FakeBackend { + next_port: 7, + ..Default::default() + }); + let mgr = SshTunnelManager::new(backend.clone()); + // The cloned Arc points at the same fake backend. + let cloned = mgr.backend_clone(); + let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into()); + let tunnel = cloned + .open("c1", &cfg, "db.host", 5432, None, None) + .unwrap(); + assert_eq!(tunnel.local_port, 7); + } + + #[test] + fn manager_insert_tunnel_replaces_and_closes_old() { + let mut mgr = SshTunnelManager::new(Arc::new(FakeBackend::default())); + mgr.insert_tunnel("c1".to_string(), Tunnel::fake(1111)); + assert_eq!(mgr.get_local_port("c1"), Some(1111)); + // Re-inserting under the same key replaces the old tunnel. + mgr.insert_tunnel("c1".to_string(), Tunnel::fake(2222)); + assert_eq!(mgr.get_local_port("c1"), Some(2222)); + assert_eq!(mgr.active_count(), 1); + } + + #[test] + fn manager_close_all() { + let backend = Arc::new(FakeBackend::default()); + let mut mgr = SshTunnelManager::new(backend); + let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into()); + mgr.open_tunnel("a", &cfg, "db", 5432, None, None).ok(); + mgr.open_tunnel("b", &cfg, "db", 5432, None, None).ok(); + mgr.close_all(); + assert_eq!(mgr.active_count(), 0); + } } \ No newline at end of file diff --git a/src-tauri/src/commands/test_connection.rs b/src-tauri/src/commands/test_connection.rs index 5894de5..71bd1ed 100644 --- a/src-tauri/src/commands/test_connection.rs +++ b/src-tauri/src/commands/test_connection.rs @@ -1,7 +1,12 @@ use serde::{Deserialize, Serialize}; +use tauri::State; +use crate::commands::ssh::SshTunnelManager; use crate::db::pool::DbConfig; +/// The SSH tunnel manager behind a mutex (as stored in `AppState`). +type SshManager = std::sync::Mutex; + /// Result of a test database connection attempt. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestConnectionResult { @@ -23,9 +28,8 @@ pub fn sanitize_error(msg: &str) -> String { let mut i = 0; while i < bytes.len() { let lower = msg[i..].to_lowercase(); - if lower.starts_with("postgres://") || lower.starts_with("postgresql://") { + if let Some(scheme_end) = url_scheme_end(msg, i) { out.push_str("[redacted-url://"); - let scheme_end = i + msg[i..].find("://").unwrap_or(0) + 3; let rest = &msg[scheme_end..]; let end = match rest.find(['/', '?']) { Some(pos) => scheme_end + pos, @@ -61,6 +65,32 @@ pub fn sanitize_error(msg: &str) -> String { } } +/// If `msg[i..]` begins a `scheme://authority` URL โ€” a 1-16 char scheme of +/// alphanumerics/`+`/`-`/`.` preceded by a non-word boundary โ€” return the byte +/// index just past the `://`. This redacts embedded credentials for any scheme +/// (`postgres://`, `redis://`, `mysql://`, ...) without matching non-URL text. +fn url_scheme_end(msg: &str, i: usize) -> Option { + let rest = &msg[i..]; + let colon = rest.find("://")?; + if colon == 0 || colon > 16 { + return None; + } + let scheme = &rest[..colon]; + if !scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) + { + return None; + } + // Require a boundary before the scheme so mid-word text is not a URL. + if let Some(c) = msg[..i].chars().next_back() { + if c.is_ascii_alphanumeric() || c == '_' { + return None; + } + } + Some(i + colon + 3) +} + /// Validate `DbConfig` before attempting a connection test. /// /// Returns `Some(error_message)` if the config is invalid, or `None` if valid. @@ -102,12 +132,81 @@ pub fn validate_test_input(config: &DbConfig) -> Option { None } +/// Effective connect target after optional SSH tunnel resolution. +struct ConnectTarget { + host: String, + port: u16, + via_tunnel: bool, + /// Key of the opened tunnel, if any โ€” must be closed after the probe. + tunnel_key: Option, +} + +/// Open an SSH tunnel if `config` has one configured, returning the loopback +/// target to connect through. The blocking ssh2 handshake runs in +/// `spawn_blocking` so it never blocks the async runtime. +/// +/// The caller must close the tunnel (via `tunnel_key`) after the probe, on +/// every path. +async fn resolve_connect_target( + config: &DbConfig, + ssh: &SshManager, + default_port: u16, +) -> Result { + match config.ssh_config() { + Some(ssh_cfg) => { + let key = format!("test-{}", uuid::Uuid::new_v4()); + let open_key = key.clone(); + let remote_host = config.host.clone(); + let remote_port = config.port.unwrap_or(default_port as i64) as u16; + let pw = config.ssh_password.clone(); + let pp = config.ssh_passphrase.clone(); + let backend = ssh.lock().unwrap().backend_clone(); + let tunnel = tokio::task::spawn_blocking(move || { + backend.open( + &open_key, + &ssh_cfg, + &remote_host, + remote_port, + pw.as_deref(), + pp.as_deref(), + ) + }) + .await + .map_err(|e| e.to_string())??; + let lp = tunnel.local_port; + ssh.lock().unwrap().insert_tunnel(key.clone(), tunnel); + Ok(ConnectTarget { + host: "127.0.0.1".to_string(), + port: lp, + via_tunnel: true, + tunnel_key: Some(key), + }) + } + None => Ok(ConnectTarget { + host: config.host.clone(), + port: config.port.unwrap_or(default_port as i64) as u16, + via_tunnel: false, + tunnel_key: None, + }), + } +} + +/// Close the probe tunnel if one was opened. +fn close_probe_tunnel(ssh: &SshManager, key: Option<&str>) { + if let Some(k) = key { + ssh.lock().unwrap().close_tunnel(k); + } +} + /// Test a database connection for the given configuration. /// /// Dispatches to the appropriate type-specific connection test based on /// `config.db_type`. Returns a `TestConnectionResult` indicating success /// or failure with a sanitized error message. -pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult { +pub async fn test_database_connection( + config: &DbConfig, + ssh: &SshManager, +) -> TestConnectionResult { // Validate input first if let Some(err) = validate_test_input(config) { return TestConnectionResult { @@ -117,10 +216,10 @@ pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult } let result = match config.db_type.to_lowercase().as_str() { - "postgresql" => test_pg_connection(config).await, - "mysql" => test_mysql_connection(config).await, + "postgresql" => test_pg_connection(config, ssh).await, + "mysql" => test_mysql_connection(config, ssh).await, "sqlite" => test_sqlite_connection(config), - "redis" => test_redis_connection(config).await, + "redis" => test_redis_connection(config, ssh).await, other => TestConnectionResult { ok: false, error: Some(format!("unsupported database type: {other}")), @@ -135,78 +234,141 @@ pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult /// Test a PostgreSQL connection using `tokio-postgres`. /// -/// Connects without TLS. The connection handler is spawned and immediately -/// dropped after confirming the connection is alive. -async fn test_pg_connection(config: &DbConfig) -> TestConnectionResult { - use tokio_postgres::NoTls; - - let host = &config.host; - let port = config.port.unwrap_or(5432) as u16; +/// Connects through an SSH tunnel when configured, with TLS selected from +/// `ssl_mode` (downgraded to encrypt-only through a tunnel). The connection +/// handler is spawned and immediately dropped after confirming the +/// connection is alive; any probe tunnel is closed in every path. +async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult { let user = config.username.as_deref().unwrap_or("postgres"); let dbname = config.database.as_deref().unwrap_or("postgres"); let password = config.password.as_deref().unwrap_or(""); - // Use a postgres URL rather than libpq key=value format: tokio-postgres - // parses URLs reliably and urlencoding handles special chars safely. - use urlencoding::encode as enc; - let conn_str = format!( - "postgresql://{}:{}@{}:{}/{}?connect_timeout=10", - enc(user), - enc(password), - host, - port, - enc(dbname), - ); + let target = match resolve_connect_target(config, ssh, 5432).await { + Ok(t) => t, + Err(e) => return TestConnectionResult { ok: false, error: Some(e) }, + }; - match tokio_postgres::connect(&conn_str, NoTls).await { - Ok((_client, connection)) => { - // Spawn the connection handler so it keeps running while we test - tokio::spawn(async move { - if let Err(e) = connection.await { - eprintln!("connection error: {}", e); - } - }); + // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full + // degrade to encrypt-only `require`. Direct connections honor the mode. + let decision = crate::commands::ssh::effective_tls_decision( + crate::db::tls::tls_decision(config.ssl_mode.as_deref()), + target.via_tunnel, + ); + let tls = match crate::db::tls::build_tls_config( + decision, + config.ssl_ca_path.as_deref(), + config.ssl_cert_path.as_deref(), + config.ssl_key_path.as_deref(), + ) { + Ok(t) => t, + Err(e) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + return TestConnectionResult { ok: false, error: Some(e) }; + } + }; + + // Config builder: user/password/dbname are sent as-is (no URL + // percent-encoding needed), and the TLS connector is chosen explicitly. + let mut pgconfig = tokio_postgres::Config::new(); + pgconfig + .host(target.host) + .port(target.port) + .user(user) + .password(password) + .dbname(dbname) + .connect_timeout(std::time::Duration::from_secs(10)); + + let result = match tls { + None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await, + Some(cc) => { + let connector = + tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone()); + crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await + } + }; + + match result { + Ok((_client, _handle)) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + // Spawn the connection handler so it keeps running while we test. + // (Already spawned inside `connect_pg_with`.) TestConnectionResult { ok: true, error: None } } - Err(e) => TestConnectionResult { - ok: false, - error: Some(e.to_string()), - }, + Err(e) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + TestConnectionResult { + ok: false, + error: Some(e.to_string()), + } + } } } /// Test a MySQL connection using `sqlx`. /// /// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second -/// `acquire_timeout`. -async fn test_mysql_connection(config: &DbConfig) -> TestConnectionResult { - use sqlx::mysql::MySqlPoolOptions; +/// `acquire_timeout`, connecting through an SSH tunnel when configured and +/// mapping `ssl_mode` onto `MySqlSslMode` (downgraded to encrypt-only +/// through a tunnel). Any probe tunnel is closed in every path. +async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult { + use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode}; - let host = &config.host; - let port = config.port.unwrap_or(3306); - let user = config.username.as_deref().unwrap_or("root"); - let password = config.password.as_deref().unwrap_or(""); - let dbname = config.database.as_deref().unwrap_or("mysql"); + let target = match resolve_connect_target(config, ssh, 3306).await { + Ok(t) => t, + Err(e) => return TestConnectionResult { ok: false, error: Some(e) }, + }; - let conn_str = format!( - "mysql://{}:{}@{}:{}/{}", - user, password, host, port, dbname + let mut opts = MySqlConnectOptions::new() + .host(&target.host) + .port(target.port) + .username(config.username.as_deref().unwrap_or("root")) + .password(config.password.as_deref().unwrap_or("")) + .database(config.database.as_deref().unwrap_or("mysql")); + + // TLS: through a tunnel the peer is loopback, so verify-ca/verify-full + // degrade to encrypt-only `require`. Direct connections honor the mode. + let decision = crate::commands::ssh::effective_tls_decision( + crate::db::tls::tls_decision(config.ssl_mode.as_deref()), + target.via_tunnel, ); + match decision { + crate::db::tls::TlsDecision::Disable => { + opts = opts.ssl_mode(MySqlSslMode::Disabled); + } + crate::db::tls::TlsDecision::Require => { + opts = opts.ssl_mode(MySqlSslMode::Required); + } + crate::db::tls::TlsDecision::Verify => { + // sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only), + // verify-full -> VerifyIdentity (chain + hostname). + match config.ssl_mode.as_deref() { + Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa), + _ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity), + } + if let Some(ca) = config.ssl_ca_path.as_deref() { + opts = opts.ssl_ca(ca); + } + } + } match MySqlPoolOptions::new() .max_connections(1) .acquire_timeout(std::time::Duration::from_secs(10)) - .connect(&conn_str) + .connect_with(opts) .await { Ok(pool) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); pool.close().await; TestConnectionResult { ok: true, error: None } } - Err(e) => TestConnectionResult { - ok: false, - error: Some(e.to_string()), - }, + Err(e) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + TestConnectionResult { + ok: false, + error: Some(e.to_string()), + } + } } } @@ -227,18 +389,21 @@ fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult { /// Test a Redis connection using the `redis` crate. /// /// Uses `redis::Client::open` followed by `get_async_connection` with a -/// 10-second timeout via `tokio::time::timeout`. -async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult { +/// 10-second timeout via `tokio::time::timeout`. Connects through an SSH +/// tunnel when configured; any probe tunnel is closed in every path. +async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult { use tokio::time::timeout; - let host = &config.host; - let port = config.port.unwrap_or(6379); + let target = match resolve_connect_target(config, ssh, 6379).await { + Ok(t) => t, + Err(e) => return TestConnectionResult { ok: false, error: Some(e) }, + }; let password = config.password.as_deref(); let conn_str = if let Some(pwd) = password { - format!("redis://:{}@{}:{}/", pwd, host, port) + format!("redis://:{}@{}:{}/", pwd, target.host, target.port) } else { - format!("redis://{}:{}/", host, port) + format!("redis://{}:{}/", target.host, target.port) }; match redis::Client::open(conn_str.as_str()) { @@ -249,30 +414,46 @@ async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult { ) .await { - Ok(Ok(_conn)) => TestConnectionResult { ok: true, error: None }, - Ok(Err(e)) => TestConnectionResult { - ok: false, - error: Some(e.to_string()), - }, - Err(_) => TestConnectionResult { - ok: false, - error: Some("connection timed out after 10 seconds".to_string()), - }, + Ok(Ok(_conn)) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + TestConnectionResult { ok: true, error: None } + } + Ok(Err(e)) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + TestConnectionResult { + ok: false, + error: Some(e.to_string()), + } + } + Err(_) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + TestConnectionResult { + ok: false, + error: Some("connection timed out after 10 seconds".to_string()), + } + } + } + } + Err(e) => { + close_probe_tunnel(ssh, target.tunnel_key.as_deref()); + TestConnectionResult { + ok: false, + error: Some(e.to_string()), } } - Err(e) => TestConnectionResult { - ok: false, - error: Some(e.to_string()), - }, } } /// Tauri command to test a database connection. /// -/// Calls `test_database_connection` and returns the result. +/// Calls `test_database_connection` and returns the result. `state` is +/// auto-injected; the frontend only passes `config`. #[tauri::command] -pub async fn test_connection(config: DbConfig) -> Result { - Ok(test_database_connection(&config).await) +pub async fn test_connection( + config: DbConfig, + state: State<'_, crate::AppState>, +) -> Result { + Ok(test_database_connection(&config, &state.ssh_manager).await) } #[cfg(test)] @@ -313,6 +494,22 @@ mod tests { assert!(!sanitized.contains("user="), "should remove user= pattern"); } + #[test] + fn sanitize_error_redacts_tunnel_style_urls() { + // Tunnel connect errors can carry a URL with embedded credentials, e.g. + // the redis:// string built for SSH-tunneled connections. + let msg = "SSH tunnel connect failed: redis://:hunter2@127.0.0.1:6379/"; + let sanitized = sanitize_error(msg); + assert!( + !sanitized.contains("hunter2"), + "must redact URL password: {sanitized}" + ); + assert!( + sanitized.contains("SSH tunnel connect failed"), + "must keep the diagnostic prefix: {sanitized}" + ); + } + // ------------------------------------------------------------------ // validate_test_input rejection // ------------------------------------------------------------------ @@ -324,13 +521,7 @@ mod tests { db_type: "mongodb".to_string(), host: "localhost".to_string(), port: Some(27017), - username: None, - password: None, - database: None, - ssl_mode: None, - ssl_ca_path: None, - ssl_cert_path: None, - ssl_key_path: None, + ..Default::default() }; assert!( validate_test_input(&config).is_some(), @@ -342,13 +533,7 @@ mod tests { db_type: "postgresql".to_string(), host: "".to_string(), port: Some(5432), - username: None, - password: None, - database: None, - ssl_mode: None, - ssl_ca_path: None, - ssl_cert_path: None, - ssl_key_path: None, + ..Default::default() }; assert!( validate_test_input(&config).is_some(), @@ -360,13 +545,7 @@ mod tests { db_type: "postgresql".to_string(), host: "localhost".to_string(), port: Some(0), - username: None, - password: None, - database: None, - ssl_mode: None, - ssl_ca_path: None, - ssl_cert_path: None, - ssl_key_path: None, + ..Default::default() }; assert!( validate_test_input(&config).is_some(), @@ -385,12 +564,8 @@ mod tests { host: "localhost".to_string(), port: Some(5432), username: Some("user".to_string()), - password: None, database: Some("mydb".to_string()), - ssl_mode: None, - ssl_ca_path: None, - ssl_cert_path: None, - ssl_key_path: None, + ..Default::default() }; assert!( validate_test_input(&config).is_none(), @@ -405,13 +580,7 @@ mod tests { db_type: "sqlite".to_string(), host: "/tmp/test.db".to_string(), port: None, - username: None, - password: None, - database: None, - ssl_mode: None, - ssl_ca_path: None, - ssl_cert_path: None, - ssl_key_path: None, + ..Default::default() }; assert!( validate_test_input(&config).is_none(), @@ -423,13 +592,7 @@ mod tests { db_type: "sqlite".to_string(), host: "/tmp/test.db".to_string(), port: Some(9999), - username: None, - password: None, - database: None, - ssl_mode: None, - ssl_ca_path: None, - ssl_cert_path: None, - ssl_key_path: None, + ..Default::default() }; assert!( validate_test_input(&config).is_none(), diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index d067c15..dc42bdd 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,5 +1,6 @@ pub mod pool; pub mod introspection; +pub mod tls; #[allow(unused_imports)] pub use pool::{ConnectionPoolManager, DbConfig, DbHandle}; \ No newline at end of file diff --git a/src-tauri/src/db/pool.rs b/src-tauri/src/db/pool.rs index 1bf535d..c23bc87 100644 --- a/src-tauri/src/db/pool.rs +++ b/src-tauri/src/db/pool.rs @@ -5,7 +5,7 @@ use std::time::Instant; /// /// Fields map to connection parameters. For SQLite, `host` stores the /// file path and `port` is always `None`. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct DbConfig { pub db_type: String, pub host: String, @@ -17,6 +17,20 @@ pub struct DbConfig { pub ssl_ca_path: Option, pub ssl_cert_path: Option, pub ssl_key_path: Option, + #[serde(default)] + pub ssh_host: Option, + #[serde(default)] + pub ssh_port: Option, + #[serde(default)] + pub ssh_user: Option, + #[serde(default)] + pub ssh_auth_method: Option, + #[serde(default)] + pub ssh_password: Option, + #[serde(default)] + pub ssh_private_key_path: Option, + #[serde(default)] + pub ssh_passphrase: Option, } impl DbConfig { @@ -35,8 +49,35 @@ impl DbConfig { ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None, + ssh_host: None, + ssh_port: None, + ssh_user: None, + ssh_auth_method: None, + ssh_password: None, + ssh_private_key_path: None, + ssh_passphrase: None, } } + + /// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set. + pub fn ssh_config(&self) -> Option { + let host = self.ssh_host.clone()?; + if host.is_empty() { + return None; + } + Some(crate::models::SshConfig { + host, + port: self.ssh_port.unwrap_or(22) as u16, + user: self.ssh_user.clone().unwrap_or_default(), + auth_method: self + .ssh_auth_method + .clone() + .unwrap_or_else(|| "password".to_string()), + password: self.ssh_password.clone(), + private_key_path: self.ssh_private_key_path.clone(), + passphrase: self.ssh_passphrase.clone(), + }) + } } /// A handle to an active database connection. @@ -74,6 +115,10 @@ pub(crate) struct DbPoolEntry { pub struct ConnectionPoolManager { pools: indexmap::IndexMap, max_pools: usize, + /// Invoked with the id of every pool that gets evicted (LRU overflow in + /// `register` or shrinkage in `set_max_pools`). Lets callers free + /// associated resources (e.g. SSH tunnels). + on_evict: Option>, } impl ConnectionPoolManager { @@ -82,9 +127,15 @@ impl ConnectionPoolManager { Self { pools: indexmap::IndexMap::new(), max_pools: 5, + on_evict: None, } } + /// Register a callback invoked with the id of every evicted pool. + pub fn set_on_evict(&mut self, cb: Box) { + self.on_evict = Some(cb); + } + /// Set the maximum number of pools before LRU eviction kicks in. /// /// If the current pool count exceeds the new maximum, the oldest @@ -92,7 +143,11 @@ impl ConnectionPoolManager { pub fn set_max_pools(&mut self, max: usize) { self.max_pools = max; while self.pools.len() > self.max_pools { - self.pools.shift_remove_index(0); + if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) { + if let Some(cb) = &self.on_evict { + cb(&evicted_id); + } + } } } @@ -114,7 +169,11 @@ impl ConnectionPoolManager { // LRU eviction: remove oldest (front) entries until within capacity while self.pools.len() > self.max_pools { - self.pools.shift_remove_index(0); + if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) { + if let Some(cb) = &self.on_evict { + cb(&evicted_id); + } + } } } @@ -166,10 +225,7 @@ mod tests { username: Some("admin".into()), password: Some("secret".into()), database: Some("mydb".into()), - ssl_mode: None, - ssl_ca_path: None, - ssl_cert_path: None, - ssl_key_path: None, + ..Default::default() }; assert_eq!(cfg.db_type, "PostgreSQL"); @@ -192,6 +248,33 @@ mod tests { assert!(cfg.database.is_none()); } + #[test] + fn db_config_ssh_config_is_none_when_no_host() { + let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "h".into(), port: Some(5432), + username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None, + ssl_cert_path: None, ssl_key_path: None, ssh_host: None, ssh_port: None, ssh_user: None, + ssh_auth_method: None, ssh_password: None, ssh_private_key_path: None, ssh_passphrase: None, + }; + assert!(cfg.ssh_config().is_none()); + } + + #[test] + fn db_config_ssh_config_builds_from_flat_fields() { + let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "db".into(), port: Some(5432), + username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None, + ssl_cert_path: None, ssl_key_path: None, + ssh_host: Some("jump".into()), ssh_port: Some(2222), ssh_user: Some("u".into()), + ssh_auth_method: Some("password".into()), ssh_password: Some("pw".into()), + ssh_private_key_path: None, ssh_passphrase: None, + }; + let s = cfg.ssh_config().expect("ssh config present"); + assert_eq!(s.host, "jump"); + assert_eq!(s.port, 2222); + assert_eq!(s.user, "u"); + assert_eq!(s.auth_method, "password"); + assert_eq!(s.password.as_deref(), Some("pw")); + } + // ------------------------------------------------------------------ // ConnectionPoolManager tests // ------------------------------------------------------------------ @@ -261,4 +344,45 @@ mod tests { assert!(manager.contains("c")); assert!(manager.contains("d")); } + + #[test] + fn pool_invokes_on_evict_with_evicted_id() { + let mut manager = ConnectionPoolManager::new(); + manager.set_max_pools(1); + let evicted: std::sync::Arc>> = std::sync::Arc::default(); + let evicted_cb = evicted.clone(); + manager.set_on_evict(Box::new(move |id: &str| { + evicted_cb.lock().unwrap().push(id.to_string()); + })); + manager.register( + "a", + DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()), + ); + manager.register( + "b", + DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()), + ); + assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]); + } + + #[test] + fn pool_invokes_on_evict_on_max_pools_shrink() { + let mut manager = ConnectionPoolManager::new(); + let evicted: std::sync::Arc>> = std::sync::Arc::default(); + let evicted_cb = evicted.clone(); + manager.set_on_evict(Box::new(move |id: &str| { + evicted_cb.lock().unwrap().push(id.to_string()); + })); + manager.register( + "a", + DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()), + ); + manager.register( + "b", + DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()), + ); + // Shrinking max_pools below the current count evicts oldest first. + manager.set_max_pools(1); + assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]); + } } \ No newline at end of file diff --git a/src-tauri/src/db/tls.rs b/src-tauri/src/db/tls.rs new file mode 100644 index 0000000..807c242 --- /dev/null +++ b/src-tauri/src/db/tls.rs @@ -0,0 +1,252 @@ +//! TLS connector factory for tokio-postgres. +//! +//! Maps the user-facing SSL modes to rustls `ClientConfig` values: +//! - `disable` -> no TLS (returns `None`) +//! - `require` -> encrypt without verifying the server certificate (custom `NoVerifier`) +//! - `verify-ca` / `verify-full` -> standard rustls webpki verification (chain AND +//! hostname; `verify-ca` is intentionally identical to `verify-full` in v1) +//! +//! Client certificates are supported via optional `cert_path` / `key_path` pair. + +use std::sync::Arc; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TlsDecision { + Disable, + Require, + Verify, +} + +pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision { + match ssl_mode { + Some("require") => TlsDecision::Require, + Some("verify-ca") | Some("verify-full") => TlsDecision::Verify, + _ => TlsDecision::Disable, + } +} + +/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable. +/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth. +pub fn build_tls_config( + decision: TlsDecision, + ca_path: Option<&str>, + cert_path: Option<&str>, + key_path: Option<&str>, +) -> Result>, String> { + if matches!(decision, TlsDecision::Disable) { + return Ok(None); + } + + let client_auth = match (cert_path, key_path) { + (Some(c), Some(k)) => Some(load_client_identity(c, k)?), + (Some(_), None) | (None, Some(_)) => { + return Err("both ssl_cert_path and ssl_key_path must be set for client auth".into()) + } + (None, None) => None, + }; + + let config = match decision { + TlsDecision::Require => { + // Encrypt without verifying the server certificate. + let builder = ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier)); + match client_auth { + Some((certs, key)) => builder + .with_client_auth_cert(certs, key) + .map_err(|e| format!("client cert: {e}"))?, + None => builder.with_no_client_auth(), + } + } + TlsDecision::Verify => { + let mut roots = RootCertStore::empty(); + if let Some(ca) = ca_path { + add_ca_file(&mut roots, ca)?; + } else { + return Err("ssl_ca_path is required for verify-ca / verify-full".into()); + } + for ta in rustls_native_certs::load_native_certs() + .map_err(|e| format!("native certs: {e}"))? + { + let _ = roots.add(ta); + } + let builder = ClientConfig::builder().with_root_certificates(roots); + match client_auth { + Some((certs, key)) => builder + .with_client_auth_cert(certs, key) + .map_err(|e| format!("client cert: {e}"))?, + None => builder.with_no_client_auth(), + } + } + TlsDecision::Disable => unreachable!(), + }; + Ok(Some(Arc::new(config))) +} + +fn add_ca_file(roots: &mut RootCertStore, path: &str) -> Result<(), String> { + let bytes = std::fs::read(path).map_err(|e| format!("failed to read CA file {path}: {e}"))?; + let mut reader = std::io::BufReader::new(bytes.as_slice()); + let parsed = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .map_err(|e| format!("parse CA: {e}"))?; + let added = parsed.into_iter().filter_map(|c| roots.add(c).ok()).count(); + if added == 0 { + return Err("no usable CA certificates found".into()); + } + Ok(()) +} + +fn load_client_identity( + cert_path: &str, + key_path: &str, +) -> Result<(Vec>, PrivateKeyDer<'static>), String> { + // rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so reject them up + // front with a clear message before touching the certificate file. + let kb = std::fs::read(key_path).map_err(|e| format!("read key: {e}"))?; + if String::from_utf8_lossy(&kb).contains("ENCRYPTED PRIVATE KEY") { + return Err( + "encrypted client keys are not supported in v1; use an unencrypted PEM key".into(), + ); + } + let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?; + let certs: Vec> = rustls_pemfile::certs(&mut std::io::BufReader::new( + cb.as_slice(), + )) + .collect::, _>>() + .map_err(|e| format!("parse cert: {e}"))? + .into_iter() + .map(|c| c.into_owned()) + .collect(); + if certs.is_empty() { + return Err("no client certificates parsed".into()); + } + let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(kb.as_slice())) + .map_err(|e| format!("parse key: {e}"))? + .ok_or_else(|| "no private key parsed".to_string())? + .clone_key(); + Ok((certs, key)) +} + +/// Accepts every certificate: TLS encryption without authentication (`require` mode). +#[derive(Debug)] +struct NoVerifier; + +impl ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _ee: &CertificateDer<'_>, + _ic: &[CertificateDer<'_>], + _n: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tls_decision_maps_modes() { + assert!(matches!(tls_decision(None), TlsDecision::Disable)); + assert!(matches!(tls_decision(Some("disable")), TlsDecision::Disable)); + assert!(matches!(tls_decision(Some("require")), TlsDecision::Require)); + assert!(matches!(tls_decision(Some("verify-ca")), TlsDecision::Verify)); + assert!(matches!(tls_decision(Some("verify-full")), TlsDecision::Verify)); + assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable)); + } + + #[test] + fn build_tls_disable_returns_none() { + assert!( + build_tls_config(TlsDecision::Disable, None, None, None) + .unwrap() + .is_none() + ); + } + + #[test] + fn build_tls_require_returns_some_without_files() { + assert!( + build_tls_config(TlsDecision::Require, None, None, None) + .unwrap() + .is_some() + ); + } + + #[test] + fn build_tls_verify_missing_ca_errors() { + let err = build_tls_config(TlsDecision::Verify, Some("/nonexistent/ca.pem"), None, None) + .unwrap_err(); + assert!(err.to_lowercase().contains("ca"), "got: {err}"); + } + + #[test] + fn build_tls_client_cert_missing_key_errors() { + // cert set without key + let err = build_tls_config(TlsDecision::Require, None, Some("/nonexistent/cert.pem"), None) + .unwrap_err(); + assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key")); + } + + #[test] + fn build_tls_rejects_encrypted_key_marker() { + // rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so an ENCRYPTED + // PRIVATE KEY header must be rejected with a clear error. The cert file + // is a dummy: the key check fires before the cert is read. + let dir = std::env::temp_dir(); + let cert_path = dir.join("gl_tls_cert_dummy.pem"); + let key_path = dir.join("gl_tls_enc_key.pem"); + std::fs::write( + &cert_path, + "-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n", + ) + .unwrap(); + std::fs::write( + &key_path, + "-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n", + ) + .unwrap(); + let r = build_tls_config( + TlsDecision::Require, + None, + Some(cert_path.to_str().unwrap()), + Some(key_path.to_str().unwrap()), + ); + assert!(r.is_err()); + assert!( + r.unwrap_err().to_lowercase().contains("encrypt"), + "must mention encryption" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2639714..6a2e278 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,10 +7,10 @@ mod models; mod store; mod commands; -use std::sync::Mutex as StdMutex; +use std::sync::{Arc, Mutex as StdMutex}; use tauri::Manager; use store::Store; -use commands::ssh::SshTunnelManager; +use commands::ssh::{Ssh2Backend, SshTunnelManager}; use db::pool::ConnectionPoolManager; pub struct AppState { @@ -29,6 +29,10 @@ fn greet(name: &str) -> String { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Install the ring crypto provider so rustls `ClientConfig::builder()` works (no-op if + // another provider is already installed). + let _ = rustls::crypto::ring::default_provider().install_default(); + let store = Store::open("gridline.db").expect("failed to open db"); let store_ref = StdMutex::new(store); @@ -40,7 +44,7 @@ pub fn run() { .manage(AppState { db_store: store_ref, pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()), - ssh_manager: StdMutex::new(SshTunnelManager::new()), + ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))), }) .setup(move |app| { let state = app.state::(); @@ -49,6 +53,22 @@ pub fn run() { eprintln!("Failed to set up demo DB: {e}"); }) .ok(); + + // Close the SSH tunnel for a connection when its pool is evicted + // (LRU overflow or max-pool shrink). The hook captures a clone of + // the app handle and resolves AppState through the manager. + let handle = app.handle().clone(); + state + .pool_manager + .blocking_lock() + .set_on_evict(Box::new(move |id: &str| { + if let Some(s) = handle.try_state::() { + if let Ok(mut mgr) = s.ssh_manager.lock() { + mgr.close_tunnel(id); + } + } + })); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -80,6 +100,7 @@ pub fn run() { db_viewer::get_table_data, db_viewer::get_fk_preview, db_viewer::execute_change, + db_viewer::get_table_ddl, db_viewer::refresh_connection, db_viewer::get_functions, db_viewer::get_triggers, @@ -89,6 +110,12 @@ pub fn run() { keychain::save_connection_password, keychain::get_connection_password, keychain::delete_connection_password, + keychain::save_connection_ssh_password, + keychain::get_connection_ssh_password, + keychain::delete_connection_ssh_password, + keychain::save_connection_ssh_passphrase, + keychain::get_connection_ssh_passphrase, + keychain::delete_connection_ssh_passphrase, demo::recreate_demo_db, backup::detect_pg_tools, backup::pg_dump, @@ -104,6 +131,18 @@ pub fn run() { query::update_saved_query, query::delete_saved_query, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|app_handle, event| { + // Close all SSH tunnels on exit: ExitRequested fires before the + // event loop ends, Exit fires after it has. + if matches!( + event, + tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit + ) { + if let Ok(mut mgr) = app_handle.state::().ssh_manager.lock() { + mgr.close_all(); + } + } + }); } diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index 79c0920..e5c1ca0 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -43,6 +43,7 @@ pub struct ConnectionInput { pub ssh_user: Option, pub ssh_auth_method: Option, pub ssh_private_key_path: Option, + pub ssh_password: Option, pub ssh_passphrase: Option, pub ssl_mode: Option, pub ssl_ca_path: Option, @@ -71,6 +72,7 @@ mod tests { ssh_user: Some("tunnel".to_string()), ssh_auth_method: Some("Key".to_string()), ssh_private_key_path: Some("/path/to/key".to_string()), + ssh_password: Some("ssh-pw".to_string()), ssh_passphrase: Some("passphrase".to_string()), ssl_mode: Some("require".to_string()), ssl_ca_path: Some("/path/to/ca".to_string()), @@ -96,6 +98,7 @@ mod tests { assert_eq!(deserialized.ssh_user, Some("tunnel".to_string())); assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string())); assert_eq!(deserialized.ssh_private_key_path, Some("/path/to/key".to_string())); + assert_eq!(deserialized.ssh_password, Some("ssh-pw".to_string())); assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string())); assert_eq!(deserialized.ssl_mode, Some("require".to_string())); assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string())); diff --git a/src-tauri/src/models/db_viewer.rs b/src-tauri/src/models/db_viewer.rs index a4e96d9..7085b47 100644 --- a/src-tauri/src/models/db_viewer.rs +++ b/src-tauri/src/models/db_viewer.rs @@ -135,6 +135,23 @@ pub enum Change { sql: String, rollback_sql: String, }, + BulkInsert { + id: String, + schema: String, + table: String, + columns: Vec, + rows: Vec>, + }, + DropTable { + id: String, + schema: String, + table: String, + }, + EmptyTable { + id: String, + schema: String, + table: String, + }, } impl Change { @@ -143,7 +160,10 @@ impl Change { Change::Update { id, .. } | Change::Insert { id, .. } | Change::Delete { id, .. } - | Change::AlterTable { id, .. } => id, + | Change::AlterTable { id, .. } + | Change::BulkInsert { id, .. } + | Change::DropTable { id, .. } + | Change::EmptyTable { id, .. } => id, } } } @@ -279,6 +299,35 @@ mod tests { ); } + #[test] + fn change_bulk_insert_roundtrip() { + let json = serde_json::json!({ + "type": "bulk_insert", "id": "x", "schema": "public", "table": "t", + "columns": ["a", "b"], + "rows": [[1, "y"], [2, "z"]] + }); + let c: Change = serde_json::from_value(json).unwrap(); + match c { + Change::BulkInsert { columns, rows, .. } => { + assert_eq!(columns, vec!["a".to_string(), "b".to_string()]); + assert_eq!(rows.len(), 2); + } + _ => panic!("expected BulkInsert"), + } + } + + #[test] + fn change_drop_and_empty_roundtrip() { + let drop: Change = serde_json::from_value(serde_json::json!({ + "type": "drop_table", "id": "d", "schema": "public", "table": "t" + })).unwrap(); + assert_eq!(drop.id(), "d"); + let empty: Change = serde_json::from_value(serde_json::json!({ + "type": "empty_table", "id": "e", "schema": "public", "table": "t" + })).unwrap(); + assert_eq!(empty.id(), "e"); + } + #[test] fn column_info_fk_ref() { let col = ColumnInfo { diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index fa6797a..360d063 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -2,6 +2,7 @@ pub mod backup; pub mod connection; pub mod db_viewer; pub mod folder; +pub mod ssh; pub mod tag; pub mod settings; @@ -10,4 +11,5 @@ pub use connection::{Connection, ConnectionInput}; pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo}; pub use folder::{Folder, FolderInput}; pub use settings::Settings; +pub use ssh::SshConfig; pub use tag::{Tag, TagInput}; \ No newline at end of file diff --git a/src-tauri/src/models/settings.rs b/src-tauri/src/models/settings.rs index 194abc7..e51cb1e 100644 --- a/src-tauri/src/models/settings.rs +++ b/src-tauri/src/models/settings.rs @@ -13,4 +13,10 @@ pub struct Settings { pub table_page_size: i64, pub shortcuts: HashMap, pub accent_color: String, + // Editor (Plan A) + pub editor_font_size: i64, + pub editor_font_family: String, + pub editor_word_wrap: String, + pub editor_minimap: bool, + pub editor_tab_size: i64, } \ No newline at end of file diff --git a/src-tauri/src/models/ssh.rs b/src-tauri/src/models/ssh.rs new file mode 100644 index 0000000..d181b90 --- /dev/null +++ b/src-tauri/src/models/ssh.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +/// SSH tunnel configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshConfig { + pub host: String, + pub port: u16, + pub user: String, + /// "password" or "key" + pub auth_method: String, + pub password: Option, + pub private_key_path: Option, + pub passphrase: Option, +} + +impl SshConfig { + /// Create a new `SshConfig` with the required fields. + pub fn new( + host: String, + port: u16, + user: String, + auth_method: String, + ) -> Self { + SshConfig { + host, + port, + user, + auth_method, + password: None, + private_key_path: None, + passphrase: None, + } + } + + /// Validate SSH configuration. + /// + /// Returns `true` if: + /// - `host` is not empty + /// - `port` is in range 1..=65535 (u16 guarantees <= 65535) + /// - `user` is not empty + pub fn is_valid(&self) -> bool { + !self.host.is_empty() && self.port >= 1 && !self.user.is_empty() + } +} \ No newline at end of file diff --git a/src-tauri/src/store/mod.rs b/src-tauri/src/store/mod.rs index 7aa66f2..2d025ec 100644 --- a/src-tauri/src/store/mod.rs +++ b/src-tauri/src/store/mod.rs @@ -27,6 +27,9 @@ const MAX_NAME_LEN: usize = 200; const MAX_FOLDER_LEN: usize = 100; const MAX_QUERY_TEXT_LEN: usize = 1_048_576; // 1 MB +const ALLOWED_EDITOR_FONTS: &[&str] = + &["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"]; + impl Store { pub fn from_connection(conn: SqliteConnection) -> Self { Self { @@ -432,6 +435,29 @@ impl Store { default_ports = parsed; } } + let editor_font_size = map + .get("editor_font_size") + .and_then(|v| v.parse::().ok()) + .map(|v| v.clamp(8, 24)) + .unwrap_or(13); + let editor_font_family = map + .get("editor_font_family") + .cloned() + .filter(|v| ALLOWED_EDITOR_FONTS.contains(&v.as_str())) + .unwrap_or_else(|| "Space Mono".to_string()); + let editor_word_wrap = match map.get("editor_word_wrap").map(|v| v.as_str()) { + Some("on") => "on".to_string(), + _ => "off".to_string(), + }; + let editor_minimap = map + .get("editor_minimap") + .map(|v| v == "true") + .unwrap_or(false); + let editor_tab_size = map + .get("editor_tab_size") + .and_then(|v| v.parse::().ok()) + .map(|v| v.clamp(2, 8)) + .unwrap_or(4); Ok(Settings { confirm_before_delete: confirm, default_folder_id, @@ -455,6 +481,11 @@ impl Store { .get("accent_color") .cloned() .unwrap_or_else(|| "#2563EB".to_string()), + editor_font_size, + editor_font_family, + editor_word_wrap, + editor_minimap, + editor_tab_size, }) } @@ -836,6 +867,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -883,6 +915,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -922,6 +955,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -960,6 +994,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -1022,6 +1057,7 @@ mod tests { ssh_user: Some("tunneluser".into()), ssh_auth_method: Some("Key".into()), ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()), + ssh_password: None, ssh_passphrase: None, ssl_mode: Some("verify-full".into()), ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()), @@ -1076,6 +1112,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -1127,6 +1164,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -1176,6 +1214,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -1211,6 +1250,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -1258,6 +1298,7 @@ mod tests { ssh_user: None, ssh_auth_method: None, ssh_private_key_path: None, + ssh_password: None, ssh_passphrase: None, ssl_mode: None, ssl_ca_path: None, @@ -1350,4 +1391,45 @@ mod tests { let result = store.save_query(None, "ok", &huge_text, ""); assert!(result.is_err(), "Over-size query text should be rejected"); } + + #[test] + fn editor_settings_defaults_when_unset() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::store::migrations::run_migrations(&conn).unwrap(); + let store = Store::from_connection(conn); + let s = store.get_settings().unwrap(); + assert_eq!(s.editor_font_size, 13); + assert_eq!(s.editor_font_family, "Space Mono"); + assert_eq!(s.editor_word_wrap, "off"); + assert!(!s.editor_minimap); + assert_eq!(s.editor_tab_size, 4); + } + + #[test] + fn editor_settings_clamp_out_of_range() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::store::migrations::run_migrations(&conn).unwrap(); + let store = Store::from_connection(conn); + store.update_setting("editor_font_size", "999").unwrap(); + store.update_setting("editor_tab_size", "1").unwrap(); + store.update_setting("editor_minimap", "true").unwrap(); + let s = store.get_settings().unwrap(); + assert_eq!(s.editor_font_size, 24, "font_size clamps to 24"); + assert_eq!(s.editor_tab_size, 2, "tab_size clamps to 2"); + assert!(s.editor_minimap); + } + + #[test] + fn editor_settings_garbage_and_disallowed_fall_back() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::store::migrations::run_migrations(&conn).unwrap(); + let store = Store::from_connection(conn); + store.update_setting("editor_font_size", "abc").unwrap(); + store.update_setting("editor_font_family", "Comic Sans").unwrap(); + store.update_setting("editor_word_wrap", "weird").unwrap(); + let s = store.get_settings().unwrap(); + assert_eq!(s.editor_font_size, 13, "garbage -> default"); + assert_eq!(s.editor_font_family, "Space Mono", "disallowed font -> default"); + assert_eq!(s.editor_word_wrap, "off", "invalid wrap -> default"); + } } \ No newline at end of file diff --git a/src/App.test.tsx b/src/App.test.tsx index 918aea6..ec04bc7 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -21,6 +21,11 @@ vi.mock("./lib/commands", () => ({ table_page_size: 50, shortcuts: {}, accent_color: "#2563EB", + editor_font_size: 13, + editor_font_family: "Space Mono", + editor_word_wrap: "off", + editor_minimap: false, + editor_tab_size: 4, } satisfies Settings), testConnection: vi.fn().mockResolvedValue({ ok: true }), })); @@ -112,6 +117,11 @@ describe("App", () => { table_page_size: 50, shortcuts: {}, accent_color: "#2563EB", + editor_font_size: 13, + editor_font_family: "Space Mono", + editor_word_wrap: "off", + editor_minimap: false, + editor_tab_size: 4, }); await waitFor(() => { expect(useUiStore.getState().activeFolderId).toBe("folder-1"); diff --git a/src/components/connections/DetailedConnectionForm.test.tsx b/src/components/connections/DetailedConnectionForm.test.tsx index 54ad892..ecad5cd 100644 --- a/src/components/connections/DetailedConnectionForm.test.tsx +++ b/src/components/connections/DetailedConnectionForm.test.tsx @@ -20,6 +20,7 @@ const BASE_FORM: ConnectionFormData = { password: null, database: null, use_keychain: false, + ssh_password: null, }; function StatefulForm( diff --git a/src/components/connections/GeneralTab.test.tsx b/src/components/connections/GeneralTab.test.tsx index 7aa3e8c..955968b 100644 --- a/src/components/connections/GeneralTab.test.tsx +++ b/src/components/connections/GeneralTab.test.tsx @@ -16,6 +16,7 @@ const BASE_FORM: ConnectionFormData = { password: "secret", database: "mydb", use_keychain: true, + ssh_password: null, }; describe("GeneralTab", () => { diff --git a/src/components/connections/NewConnectionScreen.test.tsx b/src/components/connections/NewConnectionScreen.test.tsx index 07a22e7..6d64750 100644 --- a/src/components/connections/NewConnectionScreen.test.tsx +++ b/src/components/connections/NewConnectionScreen.test.tsx @@ -46,6 +46,11 @@ describe("NewConnectionScreen", () => { table_page_size: 50, shortcuts: {}, accent_color: "#2563EB", + editor_font_size: 13, + editor_font_family: "Space Mono", + editor_word_wrap: "off", + editor_minimap: false, + editor_tab_size: 4, }, }); render(); diff --git a/src/components/connections/NewConnectionScreen.tsx b/src/components/connections/NewConnectionScreen.tsx index de49db4..8a08225 100644 --- a/src/components/connections/NewConnectionScreen.tsx +++ b/src/components/connections/NewConnectionScreen.tsx @@ -42,6 +42,7 @@ function createEmptyForm( password: null, database: null, use_keychain: false, + ssh_password: null, }; } @@ -112,6 +113,13 @@ export function NewConnectionScreen({ password: form.password, database: form.database, use_keychain: form.use_keychain, + ssh_host: form.ssh_host ?? null, + ssh_port: form.ssh_port ?? null, + ssh_user: form.ssh_user ?? null, + ssh_auth_method: form.ssh_auth_method ?? null, + ssh_private_key_path: form.ssh_private_key ?? null, + ssh_password: form.ssh_password ?? null, + ssh_passphrase: form.ssh_passphrase ?? null, }; }, [form]); diff --git a/src/components/connections/SimpleConnectionForm.test.tsx b/src/components/connections/SimpleConnectionForm.test.tsx index 591a707..7dca49c 100644 --- a/src/components/connections/SimpleConnectionForm.test.tsx +++ b/src/components/connections/SimpleConnectionForm.test.tsx @@ -19,6 +19,7 @@ const BASE_FORM: ConnectionFormData = { password: null, database: null, use_keychain: false, + ssh_password: null, }; function StatefulForm( diff --git a/src/components/connections/connectionFormData.ts b/src/components/connections/connectionFormData.ts index 79719d5..8d1fbc8 100644 --- a/src/components/connections/connectionFormData.ts +++ b/src/components/connections/connectionFormData.ts @@ -14,4 +14,14 @@ export interface ConnectionFormData { password: string | null; database: string | null; use_keychain: boolean; + // SSH tunnel fields (non-secret flat fields are optional; SshFields writes + // the key path under ssh_private_key, which submit handlers map to + // ssh_private_key_path on ConnectionInput) + ssh_host?: string | null; + ssh_port?: number | null; + ssh_user?: string | null; + ssh_auth_method?: "password" | "key" | null; + ssh_private_key?: string | null; + ssh_password: string | null; + ssh_passphrase?: string | null; } diff --git a/src/components/db-viewer/ChangesQueuePanel.test.tsx b/src/components/db-viewer/ChangesQueuePanel.test.tsx index e15ffa9..dad19ad 100644 --- a/src/components/db-viewer/ChangesQueuePanel.test.tsx +++ b/src/components/db-viewer/ChangesQueuePanel.test.tsx @@ -1,12 +1,16 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { ChangesQueuePanel } from "./ChangesQueuePanel"; import { useDbViewerStore } from "../../stores/dbViewerStore"; +import { useUiStore } from "../../stores/uiStore"; +import * as commands from "../../lib/commands"; describe("ChangesQueuePanel", () => { beforeEach(() => { - useDbViewerStore.setState({ changesQueue: [] }); + useDbViewerStore.getState().reset(); + useUiStore.setState({ activeConnectionId: "c1" }); + vi.resetAllMocks(); }); it("shows nothing when queue is empty", () => { @@ -14,7 +18,7 @@ describe("ChangesQueuePanel", () => { expect(container.textContent).toBe(""); }); - it("shows pending changes", () => { + it("shows the pending-changes header and a change card", () => { useDbViewerStore.getState().addChange({ type: "update", schema: "public", @@ -22,13 +26,15 @@ describe("ChangesQueuePanel", () => { primaryKey: { id: 1 }, oldData: { name: "Bob" }, newData: { name: "Alice" }, - }); + description: "Update row in users", + } as any); render(); - expect(screen.getByText(/1 pending change/i)).toBeInTheDocument(); - expect(screen.getByText(/users/i)).toBeInTheDocument(); + expect(screen.getByText(/pending changes/i)).toBeInTheDocument(); + expect(screen.getByText(/update/i)).toBeInTheDocument(); + expect(screen.getByText(/public.users/i)).toBeInTheDocument(); }); - it("toggle button flips the store expanded state", async () => { + it("revert removes the change from the queue", async () => { const user = userEvent.setup(); useDbViewerStore.getState().addChange({ type: "update", @@ -37,27 +43,138 @@ describe("ChangesQueuePanel", () => { primaryKey: { id: 1 }, oldData: { name: "Bob" }, newData: { name: "Alice" }, - }); + description: "Update row in users", + } as any); render(); - await user.click(screen.getByText(/1 pending change/i)); - expect(useDbViewerStore.getState().changesPanelExpanded).toBe(false); - await user.click(screen.getByText(/1 pending change/i)); - expect(useDbViewerStore.getState().changesPanelExpanded).toBe(true); + await user.click(screen.getByRole("button", { name: /revert/i })); + expect(useDbViewerStore.getState().changesQueue).toHaveLength(0); }); - it("cancel button changes status", async () => { - const user = userEvent.setup(); + it("labels bulk_insert / empty_table / drop_table cards", () => { useDbViewerStore.getState().addChange({ - type: "update", + type: "bulk_insert", schema: "public", - table: "users", - primaryKey: { id: 1 }, - oldData: { name: "Bob" }, - newData: { name: "Alice" }, + table: "t", + columns: ["a"], + rows: [[1]], + description: "Import 2 rows into public.t", + } as any); + useDbViewerStore.getState().addChange({ + type: "empty_table", + schema: "public", + table: "t", + description: "Empty Table: public.t", + } as any); + useDbViewerStore.getState().addChange({ + type: "drop_table", + schema: "public", + table: "t", + description: "Drop Table: public.t", + } as any); + render(); + expect(screen.getByText(/import 2 rows into public.t/i)).toBeInTheDocument(); + expect(screen.getByText(/empty table: public.t/i)).toBeInTheDocument(); + expect(screen.getByText(/drop table: public.t/i)).toBeInTheDocument(); + }); + + it("commit calls executeChange with buildChangePayload output for insert", async () => { + const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined); + useDbViewerStore.getState().addChange({ + type: "insert", + schema: "public", + table: "t", + newData: { id: 1, name: "Alice" }, + description: "Insert row into t", }); render(); - const cancelBtn = screen.getByRole("button", { name: /cancel/i }); - await user.click(cancelBtn); - expect(screen.getByText(/cancelled/i)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /commit all/i })); + await waitFor(() => expect(exec).toHaveBeenCalled()); + expect(exec.mock.calls[0][0]).toBe("c1"); + expect(exec.mock.calls[0][1]).toEqual(expect.objectContaining({ + type: "insert", + schema: "public", + table: "t", + data: expect.any(String), + })); + }); + + it("refreshes the schema tree after committing a drop_table change", async () => { + vi.spyOn(commands, "executeChange").mockResolvedValue(undefined); + const getSchemas = vi.spyOn(commands, "getSchemas").mockResolvedValue(["public"]); + vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]); + vi.spyOn(commands, "getTables").mockResolvedValue([] as any); + useDbViewerStore.getState().addChange({ + type: "drop_table", + schema: "public", + table: "t", + description: "Drop Table: public.t", + }); + render(); + fireEvent.click(screen.getByRole("button", { name: /commit all/i })); + await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1")); + }); + + it("SQL toggle shows the generated SQL", async () => { + const user = userEvent.setup(); + useDbViewerStore.getState().addChange({ + type: "insert", + schema: "public", + table: "users", + newData: { name: "Alice" }, + description: "Insert row into users", + } as any); + render(); + await user.click(screen.getByRole("button", { name: /sql/i })); + expect(screen.getByText(/insert into "public"."users"/i)).toBeInTheDocument(); + }); + + it("Cmd+S commits all pending changes", async () => { + const exec = vi.spyOn(commands, "executeChange").mockResolvedValue(undefined); + useDbViewerStore.getState().addChange({ + type: "insert", + schema: "public", + table: "t", + newData: { a: 1 }, + description: "Insert row into t", + } as any); + render(); + fireEvent.keyDown(document, { key: "s", metaKey: true }); + await waitFor(() => expect(exec).toHaveBeenCalled()); + }); + + it("Clear All empties the queue", async () => { + const user = userEvent.setup(); + useDbViewerStore.getState().addChange({ + type: "insert", + schema: "public", + table: "t", + newData: { a: 1 }, + description: "Insert row into t", + } as any); + render(); + await user.click(screen.getByRole("button", { name: /clear all/i })); + expect(useDbViewerStore.getState().changesQueue).toHaveLength(0); + }); + + it("shows a green check on committed changes after Commit All", async () => { + vi.spyOn(commands, "executeChange").mockResolvedValue(undefined); + useDbViewerStore.getState().addChange({ type: "insert", schema: "public", table: "t", newData: { a: 1 }, description: "Insert row into t" } as any); + render(); + fireEvent.click(screen.getByRole("button", { name: /commit all/i })); + await waitFor(() => expect(screen.getByTitle("Committed")).toBeInTheDocument()); + }); + + it("auto-closes tabs for a table dropped via Commit All", async () => { + vi.spyOn(commands, "executeChange").mockResolvedValue(undefined); + useDbViewerStore.getState().openTab("public", "users"); + useDbViewerStore.getState().openTab("public", "posts", true); + useDbViewerStore.getState().addChange({ type: "drop_table", schema: "public", table: "users", description: "Drop Table: public.users" } as any); + render(); + fireEvent.click(screen.getByRole("button", { name: /commit all/i })); + await waitFor(() => { + const tabs = useDbViewerStore.getState().tabs; + expect(tabs.some((t) => t.table === "users")).toBe(false); + expect(tabs.some((t) => t.table === "posts")).toBe(true); + }); }); }); \ No newline at end of file diff --git a/src/components/db-viewer/ChangesQueuePanel.tsx b/src/components/db-viewer/ChangesQueuePanel.tsx index 27af4c6..71a95e2 100644 --- a/src/components/db-viewer/ChangesQueuePanel.tsx +++ b/src/components/db-viewer/ChangesQueuePanel.tsx @@ -1,11 +1,11 @@ -import { useCallback } from "react"; -import { X, Check, ChevronUp, ChevronDown } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { Check, X, RotateCcw } from "lucide-react"; import { useDbViewerStore } from "../../stores/dbViewerStore"; import { useUiStore } from "../../stores/uiStore"; import { useNotificationStore } from "../../stores/notificationStore"; import * as cmd from "../../lib/commands"; +import { buildChangePayload, buildChangeSql } from "../../lib/changePayload"; import type { QueueItem, QueueStatus } from "../../stores/dbViewerStore"; -import type { ChangeItem } from "../../lib/types"; const statusBg: Record = { pending: "bg-accent/5", @@ -14,54 +14,40 @@ const statusBg: Record = { cancelled: "bg-surface-raised/50", }; +function formatChangeLabel(change: QueueItem): string { + const schema = change.schema ?? ""; + const table = change.table ?? ""; + const fullName = schema ? `${schema}.${table}` : table; + switch (change.type) { + case "bulk_insert": + return change.description ?? `Import ${change.rows?.length ?? 0} rows into ${fullName}`; + case "empty_table": + return `Empty Table: ${fullName}`; + case "drop_table": + return `Drop Table: ${fullName}`; + default: + return change.table ?? "-"; + } +} + function capitalizeType(type: string) { return type.charAt(0).toUpperCase() + type.slice(1); } -function StatusIndicator({ status }: { status: QueueStatus }) { - switch (status) { - case "pending": - return ( -
- - Pending -
- ); - case "committed": - return ( -
- - Committed -
- ); - case "failed": - return ( -
- - Failed -
- ); - case "cancelled": - return ( -
- Cancelled -
- ); - default: - return null; - } +function tableRef(change: QueueItem): string { + if (change.schema && change.table) return `${change.schema}.${change.table}`; + return change.table ?? "-"; } export function ChangesQueuePanel() { const changesQueue = useDbViewerStore((state) => state.changesQueue); - const cancelChange = useDbViewerStore((state) => state.cancelChange); + const removeChange = useDbViewerStore((state) => state.removeChange); + const clearChanges = useDbViewerStore((state) => state.clearChanges); const markChangeCommitted = useDbViewerStore((state) => state.markChangeCommitted); const markChangeFailed = useDbViewerStore((state) => state.markChangeFailed); const notify = useNotificationStore((state) => state.notify); - const expanded = useDbViewerStore((state) => state.changesPanelExpanded); - const toggleChangesPanel = useDbViewerStore( - (state) => state.toggleChangesPanel, - ); + + const [view, setView] = useState<"visual" | "sql">("visual"); const handleCommitAll = useCallback(async () => { const connectionId = useUiStore.getState().activeConnectionId; @@ -76,19 +62,19 @@ export function ChangesQueuePanel() { if (pending.length === 0) return; let committedCount = 0; + let treeDirty = false; for (const change of pending) { try { - const payload = { - id: change.id, - type: change.type, - sql: change.sql, - status: "pending" as const, - description: change.description ?? null, - } satisfies ChangeItem; + const payload = buildChangePayload(change); await cmd.executeChange(connectionId, payload); markChangeCommitted(change.id); committedCount++; + if (change.type === "drop_table") { + treeDirty = true; + const st = useDbViewerStore.getState(); + st.closeTabsForTable(change.schema ?? "", change.table ?? ""); + } } catch (e) { const msg = e instanceof Error ? e.message : String(e); markChangeFailed(change.id, msg); @@ -100,102 +86,134 @@ export function ChangesQueuePanel() { if (committedCount > 0) { notify(`${committedCount} change(s) committed`, "success"); } + + if (treeDirty) { + const st = useDbViewerStore.getState(); + void st.refreshTree(connectionId, st.currentSchema ?? undefined); + } }, [markChangeCommitted, markChangeFailed, notify]); + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + void handleCommitAll(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [handleCommitAll]); + if (changesQueue.length === 0) { return null; } const pendingCount = changesQueue.filter((c) => c.status === "pending").length; - const processedCount = changesQueue.filter( - (c) => c.status === "committed" || c.status === "failed", - ).length; - - const changeWord = pendingCount === 1 ? "change" : "changes"; return ( -
- +
+ + +
+ {view === "visual" ? ( + changesQueue.map((change) => ( +
+
+
+ + {capitalizeType(change.type)} + + + {tableRef(change)} + +
+ {change.status === "pending" ? ( + + ) : change.status === "committed" ? ( + + + + ) : change.status === "failed" ? ( + + + + ) : null} +
+
+ {formatChangeLabel(change)} +
+
+ )) + ) : ( + changesQueue.map((change) => ( +
+              {buildChangeSql(change)}
+            
+ )) + )} +
+ +
+ - - - {expanded && ( -
- {changesQueue.map((change) => ( - cancelChange(change.id)} - /> - ))} -
- )} -
- ); -} - -function ChangeRow({ - change, - onCancel, -}: { - change: QueueItem; - onCancel: () => void; -}) { - return ( -
-
- - {capitalizeType(change.type)} - - - {change.table ? change.table : "-"} - -
- -
- - {change.status === "pending" && ( - - )}
); diff --git a/src/components/db-viewer/DbViewerScreen.test.tsx b/src/components/db-viewer/DbViewerScreen.test.tsx index 85eb968..b2ae878 100644 --- a/src/components/db-viewer/DbViewerScreen.test.tsx +++ b/src/components/db-viewer/DbViewerScreen.test.tsx @@ -202,6 +202,62 @@ describe("DbViewerScreen", () => { ); }); + it("refreshes the schema tree after a DDL query runs", async () => { + vi.spyOn(commands, "executeQuery").mockResolvedValue( + mockQueryResult as any, + ); + const getSchemas = vi + .spyOn(commands, "getSchemas") + .mockResolvedValue(["public"]); + vi.spyOn(commands, "getDatabases").mockResolvedValue(["mydb"]); + vi.spyOn(commands, "getTables").mockResolvedValue([] as any); + render( + {}} + onSettings={() => {}} + />, + ); + fireEvent.click(screen.getByRole("button", { name: /new query/i })); + const textarea = await waitFor(() => + screen.getByTestId("monaco-textarea"), + ); + fireEvent.change(textarea, { + target: { value: "CREATE TABLE users_new (id INTEGER)" }, + }); + fireEvent.click(screen.getByRole("button", { name: /run query/i })); + // CREATE is destructive -> confirm dialog appears -> click Execute + await waitFor(() => + screen.getByRole("button", { name: /execute/i }), + ); + fireEvent.click(screen.getByRole("button", { name: /execute/i })); + await waitFor(() => expect(getSchemas).toHaveBeenCalledWith("c1")); + }); + + it("does not refresh the schema tree after a SELECT", async () => { + vi.spyOn(commands, "executeQuery").mockResolvedValue( + mockQueryResult as any, + ); + const getSchemas = vi + .spyOn(commands, "getSchemas") + .mockResolvedValue(["public"]); + render( + {}} + onSettings={() => {}} + />, + ); + fireEvent.click(screen.getByRole("button", { name: /new query/i })); + const textarea = await waitFor(() => + screen.getByTestId("monaco-textarea"), + ); + fireEvent.change(textarea, { target: { value: "SELECT 1" } }); + fireEvent.click(screen.getByRole("button", { name: /run query/i })); + await waitFor(() => expect(commands.executeQuery).toHaveBeenCalled()); + expect(getSchemas).not.toHaveBeenCalled(); + }); + it("formats the query SQL when Auto format is clicked", async () => { render( import("../editor/QueryEditor").then((m) => ({ default: m.QueryEditor }))); @@ -16,7 +16,6 @@ import { TableTree } from "./TableTree"; import { ObjectExplorerPage } from "./ObjectExplorerPage"; import { TabBar } from "./TabBar"; import { VirtualDataGrid } from "../grid/VirtualDataGrid"; -import { ChangesQueuePanel } from "./ChangesQueuePanel"; import { TableControls } from "./TableControls"; import { EditConnectionModal } from "./EditConnectionModal"; import { useDbConnection } from "../../hooks/useDbConnection"; @@ -130,6 +129,10 @@ export function DbViewerScreen({ const result = await executeQuery(connectionId, sql, tab.page, tab.pageSize); setTabData(tabId, result); useQueryStore.getState().invalidateHistory(connectionId); + if (isSchemaModifyingQuery(sql)) { + const st = useDbViewerStore.getState(); + void st.refreshTree(connectionId, st.currentSchema ?? undefined); + } } catch (e) { setTabError(tabId, e instanceof Error ? e.message : String(e)); useQueryStore.getState().invalidateHistory(connectionId); @@ -1056,7 +1059,6 @@ const onQueriesPanelResizeStart = useCallback( }} /> ) : null} - {(currentView === "db-viewer" || currentView === "queries") && } {currentConnection && ( {}); } + // Persist SSH secrets to the OS keychain (not SQLite) + if (form.ssh_host && (form.ssh_auth_method ?? "password") === "password" && form.ssh_password) { + await saveConnectionSshPassword(connection.id, form.ssh_password).catch(() => {}); + } + if (form.ssh_host && form.ssh_passphrase) { + await saveConnectionSshPassphrase(connection.id, form.ssh_passphrase).catch(() => {}); + } notify("Connection updated", "success"); onSaved(updated); onClose(); @@ -91,6 +114,7 @@ export function EditConnectionModal({ folder_id: form.folder_id, environment: form.environment, tag_ids: form.tag_ids, + ssh_password: form.ssh_password ?? null, }); if (result.ok) { notify("Connection successful", "success"); diff --git a/src/components/db-viewer/ImportDialog.test.tsx b/src/components/db-viewer/ImportDialog.test.tsx new file mode 100644 index 0000000..3270802 --- /dev/null +++ b/src/components/db-viewer/ImportDialog.test.tsx @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { readTextFile } from "@tauri-apps/plugin-fs"; +import { ImportDialog } from "./ImportDialog"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn().mockResolvedValue("/tmp/f.csv") })); +vi.mock("@tauri-apps/plugin-fs", () => ({ readTextFile: vi.fn().mockResolvedValue("a,b\n1,2\n3,4") })); + +describe("ImportDialog", () => { + it("parses CSV and stages a bulk_insert change", async () => { + const addChange = vi.fn(); + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + await waitFor(() => expect(screen.getByText(/preview/i)).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: /stage import/i })); + await waitFor(() => { + expect(addChange).toHaveBeenCalledWith(expect.objectContaining({ + type: "bulk_insert", schema: "public", table: "t", + columns: ["a", "b"], + })); + expect(addChange.mock.calls[0][0].rows.length).toBe(2); + }); + }); + + it("rejects files over the row cap", async () => { + vi.mocked(readTextFile).mockResolvedValue("a\n" + "1\n".repeat(100_001)); + const onStage = vi.fn(); + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + await waitFor(() => expect(screen.getByText(/limit/i)).toBeInTheDocument()); + expect(onStage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/db-viewer/ImportDialog.tsx b/src/components/db-viewer/ImportDialog.tsx new file mode 100644 index 0000000..7e7b228 --- /dev/null +++ b/src/components/db-viewer/ImportDialog.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import { readTextFile } from "@tauri-apps/plugin-fs"; +import { AnimatedModal } from "../ui/AnimatedModal"; +import { Button } from "../ui/Button"; +import { Select } from "../ui/Select"; +import { normalizeImport, coerceRow } from "../../lib/importNormalize"; + +const MAX_ROWS = 100_000; +const MAX_BYTES = 100 * 1024 * 1024; +const SKIP = ""; + +export interface ImportDialogProps { + open: boolean; + schema: string; + table: string; + columns: string[]; + onStage: (change: { + type: "bulk_insert"; + schema: string; + table: string; + columns: string[]; + rows: unknown[][]; + description: string; + }) => void; + onClose: () => void; +} + +export function ImportDialog({ + open: isOpen, + schema, + table, + columns, + onStage, + onClose, +}: ImportDialogProps) { + const [parsed, setParsed] = useState<{ headers: string[]; rows: string[][] } | null>(null); + const [mapping, setMapping] = useState>({}); + const [error, setError] = useState(null); + + const chooseFile = async () => { + try { + const path = await open({ + filters: [{ name: "Data", extensions: ["csv", "json"] }], + }); + if (!path || Array.isArray(path)) return; + + const text = await readTextFile(path as string); + if (text.length > MAX_BYTES) { + setError("File exceeds 100 MB limit"); + return; + } + + const { headers, rows } = normalizeImport(text); + + if (rows.length > MAX_ROWS) { + setError(`File has ${rows.length.toLocaleString()} rows; limit is ${MAX_ROWS.toLocaleString()}`); + return; + } + + setParsed({ headers, rows }); + setMapping( + Object.fromEntries( + headers.map((h, i) => [h, columns[i] ?? SKIP]), + ), + ); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + + const stage = () => { + if (!parsed) return; + + const selected = parsed.headers + .map((header) => ({ header, col: mapping[header] })) + .filter(({ col }) => col && col !== SKIP); + + const targetColumns = selected.map(({ col }) => col); + const dataRows = parsed.rows.map((row) => + selected.map(({ header }) => { + const idx = parsed.headers.indexOf(header); + return coerceRow(row[idx]); + }), + ); + + onStage({ + type: "bulk_insert", + schema, + table, + columns: targetColumns, + rows: dataRows, + description: `Import ${dataRows.length.toLocaleString()} rows into ${schema}.${table}`, + }); + onClose(); + }; + + const mappingOptions = [ + { value: SKIP, label: "" }, + ...columns.map((c) => ({ value: c, label: c })), + ]; + + const previewRows = parsed ? parsed.rows.slice(0, 100) : []; + + return ( + +
+

+ Import into {schema}.{table} +

+ +
+
+ + CSV or JSON, up to 100 MB / 100,000 rows +
+ + {error && ( +
+ {error} +
+ )} + + {parsed && ( +
+

+ Preview ({parsed.rows.length.toLocaleString()} rows ร— {parsed.headers.length} columns) +

+ +
+ {parsed.headers.map((header) => ( +
+ {header} + โ†’ + + + + + + + void updateSetting("editor_minimap", c ? "true" : "false")} /> + + +