From d14329ea1508f5a1a103cafc522c227ae7c2c256 Mon Sep 17 00:00:00 2001 From: SuLe Date: Sat, 2 May 2026 03:37:46 +0800 Subject: [PATCH] store connection secrets in system keyring --- src-tauri/Cargo.lock | 466 +++++++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/connection.rs | 17 +- src-tauri/src/commands/connection_secrets.rs | 419 ++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/models/connection.rs | 1 + src/App.vue | 14 +- .../connection/ConnectionDialog.vue | 4 +- src/components/sidebar/TreeItem.vue | 10 +- src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 2 + src/stores/connectionStore.ts | 34 +- 12 files changed, 935 insertions(+), 36 deletions(-) create mode 100644 src-tauri/src/commands/connection_secrets.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ec6d79550..000becf97 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -355,6 +355,59 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-native-tls" version = "0.4.0" @@ -367,6 +420,59 @@ dependencies = [ "url", ] +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -589,6 +695,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -1398,6 +1517,35 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding 0.3.3", + "cbc 0.1.2", + "dbus", + "fastrand", + "hkdf 0.12.4", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "dbx" version = "0.3.4" @@ -1406,6 +1554,7 @@ dependencies = [ "chrono", "duckdb", "futures", + "keyring", "log", "mongodb", "oracle-rs", @@ -1810,6 +1959,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1841,6 +1996,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" dependencies = [ "enumflags2_derive", + "serde", ] [[package]] @@ -1913,6 +2069,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2154,6 +2320,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2626,6 +2805,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -3329,6 +3514,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3443,6 +3645,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libduckdb-sys" version = "1.10502.0" @@ -3499,6 +3710,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3851,7 +4072,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -3892,6 +4113,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nix" version = "0.31.2" @@ -3910,6 +4144,20 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3972,6 +4220,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4248,6 +4507,16 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -4626,6 +4895,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -4741,6 +5021,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -5576,7 +5870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36140e8a20297bc2e8338807c3d9ca911f7fa49d7539cbcd6d48d3befd70efd8" dependencies = [ "log", - "nix", + "nix 0.31.2", "ssh-encoding", "windows-sys 0.61.2", ] @@ -5707,7 +6001,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -5890,6 +6184,38 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc 0.1.2", + "futures-util", + "generic-array 0.14.7", + "hkdf 0.12.4", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6646,6 +6972,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -7723,6 +8055,17 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -9050,6 +9393,16 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.2" @@ -9073,6 +9426,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1 0.10.6", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -9119,6 +9528,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -9190,3 +9613,40 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 00abe53f3..c77a92828 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,3 +47,4 @@ mongodb = "3.2.5" oracle-rs = "0.1" percent-encoding = "2" russh = "0.60" +keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] } diff --git a/src-tauri/src/commands/connection.rs b/src-tauri/src/commands/connection.rs index 416912734..8bc87690e 100644 --- a/src-tauri/src/commands/connection.rs +++ b/src-tauri/src/commands/connection.rs @@ -3,6 +3,9 @@ use std::sync::Arc; use tauri::{AppHandle, Manager, State}; use tokio::sync::Mutex; +use crate::commands::connection_secrets::{ + load_connections_from_file, save_connections_to_file, KeyringConnectionSecretStore, +}; use crate::commands::query_cancel::RunningQueries; use crate::db; use crate::db::ssh_tunnel::TunnelManager; @@ -230,21 +233,15 @@ pub async fn save_connections( configs: Vec, ) -> Result<(), String> { let path = connections_file(&app)?; - let json = serde_json::to_string_pretty(&configs).map_err(|e| e.to_string())?; - std::fs::write(path, json).map_err(|e| e.to_string())?; - Ok(()) + let store = KeyringConnectionSecretStore; + save_connections_to_file(&path, &configs, &store) } #[tauri::command] pub async fn load_connections(app: AppHandle) -> Result, String> { let path = connections_file(&app)?; - if !path.exists() { - return Ok(vec![]); - } - let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?; - let configs: Vec = - serde_json::from_str(&json).map_err(|e| e.to_string())?; - Ok(configs) + let store = KeyringConnectionSecretStore; + load_connections_from_file(&path, &store) } #[tauri::command] diff --git a/src-tauri/src/commands/connection_secrets.rs b/src-tauri/src/commands/connection_secrets.rs new file mode 100644 index 000000000..c720d261f --- /dev/null +++ b/src-tauri/src/commands/connection_secrets.rs @@ -0,0 +1,419 @@ +use crate::models::connection::ConnectionConfig; +use std::collections::HashSet; +use std::path::Path; + +pub(super) const MAIN_PASSWORD_KEY: &str = "password"; +pub(super) const SSH_PASSWORD_KEY: &str = "ssh_password"; +pub(super) const CONNECTION_STRING_KEY: &str = "connection_string"; + +const KEYRING_SERVICE: &str = "dev.dbx.connections"; + +pub(super) trait ConnectionSecretStore { + fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>; + fn get_secret(&self, connection_id: &str, key: &str) -> Result, String>; + fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String>; +} + +pub(super) struct KeyringConnectionSecretStore; + +impl ConnectionSecretStore for KeyringConnectionSecretStore { + fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String> { + let entry = keyring_entry(connection_id, key, "create")?; + entry + .set_password(secret) + .map_err(|err| keyring_error("store", connection_id, key, err)) + } + + fn get_secret(&self, connection_id: &str, key: &str) -> Result, String> { + let entry = keyring_entry(connection_id, key, "create")?; + match entry.get_password() { + Ok(secret) => Ok(Some(secret)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(err) => Err(keyring_error("read", connection_id, key, err)), + } + } + + fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String> { + let entry = keyring_entry(connection_id, key, "create")?; + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(err) => Err(keyring_error("delete", connection_id, key, err)), + } + } +} + +pub(super) fn save_connections_to_file( + path: &Path, + configs: &[ConnectionConfig], + store: &impl ConnectionSecretStore, +) -> Result<(), String> { + delete_removed_connection_secrets(path, configs, store)?; + for config in configs { + persist_secret(store, &config.id, MAIN_PASSWORD_KEY, &config.password)?; + persist_secret(store, &config.id, SSH_PASSWORD_KEY, &config.ssh_password)?; + persist_optional_secret( + store, + &config.id, + CONNECTION_STRING_KEY, + config.connection_string.as_deref(), + )?; + } + + write_sanitized_connections(path, configs) +} + +pub(super) fn load_connections_from_file( + path: &Path, + store: &impl ConnectionSecretStore, +) -> Result, String> { + if !path.exists() { + return Ok(vec![]); + } + + let mut configs = read_connections(path)?; + let mut needs_rewrite = false; + for config in &mut configs { + if config.password.is_empty() { + if let Some(secret) = store.get_secret(&config.id, MAIN_PASSWORD_KEY)? { + config.password = secret; + } + } else { + store.set_secret(&config.id, MAIN_PASSWORD_KEY, &config.password)?; + needs_rewrite = true; + } + + if config.ssh_password.is_empty() { + if let Some(secret) = store.get_secret(&config.id, SSH_PASSWORD_KEY)? { + config.ssh_password = secret; + } + } else { + store.set_secret(&config.id, SSH_PASSWORD_KEY, &config.ssh_password)?; + needs_rewrite = true; + } + + match config + .connection_string + .as_deref() + .filter(|secret| !secret.is_empty()) + { + Some(secret) => { + store.set_secret(&config.id, CONNECTION_STRING_KEY, secret)?; + needs_rewrite = true; + } + None => { + if let Some(secret) = store.get_secret(&config.id, CONNECTION_STRING_KEY)? { + config.connection_string = Some(secret); + } + } + } + } + + if needs_rewrite { + write_sanitized_connections(path, &configs)?; + } + + Ok(configs) +} + +fn delete_removed_connection_secrets( + path: &Path, + configs: &[ConnectionConfig], + store: &impl ConnectionSecretStore, +) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + + let previous = match read_connections(path) { + Ok(configs) => configs, + Err(_) => return Ok(()), + }; + let current_ids: HashSet<&str> = configs.iter().map(|config| config.id.as_str()).collect(); + for config in previous { + if current_ids.contains(config.id.as_str()) { + continue; + } + store.delete_secret(&config.id, MAIN_PASSWORD_KEY)?; + store.delete_secret(&config.id, SSH_PASSWORD_KEY)?; + store.delete_secret(&config.id, CONNECTION_STRING_KEY)?; + } + Ok(()) +} + +fn persist_secret( + store: &impl ConnectionSecretStore, + connection_id: &str, + key: &str, + secret: &str, +) -> Result<(), String> { + if secret.is_empty() { + store.delete_secret(connection_id, key) + } else { + store.set_secret(connection_id, key, secret) + } +} + +fn persist_optional_secret( + store: &impl ConnectionSecretStore, + connection_id: &str, + key: &str, + secret: Option<&str>, +) -> Result<(), String> { + match secret.filter(|secret| !secret.is_empty()) { + Some(secret) => store.set_secret(connection_id, key, secret), + None => store.delete_secret(connection_id, key), + } +} + +fn read_connections(path: &Path) -> Result, String> { + let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?; + serde_json::from_str(&json).map_err(|e| e.to_string()) +} + +fn write_sanitized_connections(path: &Path, configs: &[ConnectionConfig]) -> Result<(), String> { + let sanitized = sanitize_connections(configs); + let json = serde_json::to_string_pretty(&sanitized).map_err(|e| e.to_string())?; + std::fs::write(path, json).map_err(|e| e.to_string()) +} + +fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec { + configs + .iter() + .cloned() + .map(|mut config| { + config.password.clear(); + config.ssh_password.clear(); + config.connection_string = None; + config + }) + .collect() +} + +fn keyring_entry(connection_id: &str, key: &str, action: &str) -> Result { + keyring::Entry::new(KEYRING_SERVICE, &secret_account(connection_id, key)) + .map_err(|err| keyring_error(action, connection_id, key, err)) +} + +fn secret_account(connection_id: &str, key: &str) -> String { + format!("connection:{connection_id}:{key}") +} + +fn keyring_error(action: &str, connection_id: &str, key: &str, err: keyring::Error) -> String { + format!("Failed to {action} saved {key} for connection {connection_id}: {err}") +} + +#[cfg(test)] +mod tests { + use super::{ + load_connections_from_file, save_connections_to_file, ConnectionSecretStore, + CONNECTION_STRING_KEY, MAIN_PASSWORD_KEY, SSH_PASSWORD_KEY, + }; + use crate::models::connection::{ConnectionConfig, DatabaseType}; + use std::cell::RefCell; + use std::collections::HashMap; + use std::path::Path; + + #[derive(Default)] + struct MemorySecretStore { + values: RefCell>, + deleted: RefCell>, + } + + impl MemorySecretStore { + fn set_existing(&self, connection_id: &str, key: &str, value: &str) { + self.values + .borrow_mut() + .insert(secret_key(connection_id, key), value.to_string()); + } + + fn get_existing(&self, connection_id: &str, key: &str) -> Option { + self.values + .borrow() + .get(&secret_key(connection_id, key)) + .cloned() + } + + fn was_deleted(&self, connection_id: &str, key: &str) -> bool { + self.deleted + .borrow() + .contains(&secret_key(connection_id, key)) + } + } + + impl ConnectionSecretStore for MemorySecretStore { + fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String> { + self.values + .borrow_mut() + .insert(secret_key(connection_id, key), secret.to_string()); + Ok(()) + } + + fn get_secret(&self, connection_id: &str, key: &str) -> Result, String> { + Ok(self + .values + .borrow() + .get(&secret_key(connection_id, key)) + .cloned()) + } + + fn delete_secret(&self, connection_id: &str, key: &str) -> Result<(), String> { + self.values + .borrow_mut() + .remove(&secret_key(connection_id, key)); + self.deleted + .borrow_mut() + .push(secret_key(connection_id, key)); + Ok(()) + } + } + + fn secret_key(connection_id: &str, key: &str) -> String { + format!("{connection_id}:{key}") + } + + fn temp_connections_file(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "dbx-connection-secrets-test-{}-{name}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("connections.json") + } + + fn connection(id: &str, password: &str, ssh_password: &str) -> ConnectionConfig { + ConnectionConfig { + id: id.to_string(), + name: format!("{id} connection"), + db_type: DatabaseType::Postgres, + driver_profile: None, + driver_label: None, + url_params: None, + host: "localhost".to_string(), + port: 5432, + username: "postgres".to_string(), + password: password.to_string(), + database: Some("postgres".to_string()), + color: None, + ssh_enabled: !ssh_password.is_empty(), + ssh_host: String::new(), + ssh_port: 22, + ssh_user: String::new(), + ssh_password: ssh_password.to_string(), + ssh_key_path: String::new(), + ssh_expose_lan: false, + ssl: false, + connection_string: None, + } + } + + fn read_configs(path: &Path) -> Vec { + let json = std::fs::read_to_string(path).unwrap(); + serde_json::from_str(&json).unwrap() + } + + #[test] + fn save_connections_moves_passwords_to_secret_store_and_redacts_file() { + let path = temp_connections_file("save-redacts"); + let store = MemorySecretStore::default(); + let configs = vec![connection("main", "db-secret", "ssh-secret")]; + + save_connections_to_file(&path, &configs, &store).unwrap(); + + assert_eq!( + store.get_existing("main", MAIN_PASSWORD_KEY).as_deref(), + Some("db-secret") + ); + assert_eq!( + store.get_existing("main", SSH_PASSWORD_KEY).as_deref(), + Some("ssh-secret") + ); + let persisted = read_configs(&path); + assert_eq!(persisted[0].password, ""); + assert_eq!(persisted[0].ssh_password, ""); + } + + #[test] + fn load_connections_restores_passwords_from_secret_store() { + let path = temp_connections_file("load-restores"); + let store = MemorySecretStore::default(); + store.set_existing("main", MAIN_PASSWORD_KEY, "db-secret"); + store.set_existing("main", SSH_PASSWORD_KEY, "ssh-secret"); + let sanitized = vec![connection("main", "", "")]; + std::fs::write(&path, serde_json::to_string_pretty(&sanitized).unwrap()).unwrap(); + + let loaded = load_connections_from_file(&path, &store).unwrap(); + + assert_eq!(loaded[0].password, "db-secret"); + assert_eq!(loaded[0].ssh_password, "ssh-secret"); + } + + #[test] + fn load_connections_migrates_plaintext_passwords_and_rewrites_sanitized_file() { + let path = temp_connections_file("migrates-plaintext"); + let store = MemorySecretStore::default(); + let legacy = vec![connection("legacy", "plain-db", "plain-ssh")]; + std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap(); + + let loaded = load_connections_from_file(&path, &store).unwrap(); + + assert_eq!(loaded[0].password, "plain-db"); + assert_eq!(loaded[0].ssh_password, "plain-ssh"); + assert_eq!( + store.get_existing("legacy", MAIN_PASSWORD_KEY).as_deref(), + Some("plain-db") + ); + assert_eq!( + store.get_existing("legacy", SSH_PASSWORD_KEY).as_deref(), + Some("plain-ssh") + ); + let persisted = read_configs(&path); + assert_eq!(persisted[0].password, ""); + assert_eq!(persisted[0].ssh_password, ""); + } + + #[test] + fn save_connections_deletes_secrets_for_removed_connections() { + let path = temp_connections_file("deletes-removed"); + let store = MemorySecretStore::default(); + let previous = vec![connection("old", "", ""), connection("kept", "", "")]; + std::fs::write(&path, serde_json::to_string_pretty(&previous).unwrap()).unwrap(); + store.set_existing("old", MAIN_PASSWORD_KEY, "old-db"); + store.set_existing("old", SSH_PASSWORD_KEY, "old-ssh"); + store.set_existing("kept", MAIN_PASSWORD_KEY, "kept-db"); + + save_connections_to_file(&path, &[connection("kept", "new-db", "")], &store).unwrap(); + + assert!(store.was_deleted("old", MAIN_PASSWORD_KEY)); + assert!(store.was_deleted("old", SSH_PASSWORD_KEY)); + assert_eq!( + store.get_existing("kept", MAIN_PASSWORD_KEY).as_deref(), + Some("new-db") + ); + } + + #[test] + fn save_connections_moves_connection_string_to_secret_store_and_restores_it() { + let path = temp_connections_file("connection-string"); + let store = MemorySecretStore::default(); + let mut config = connection("mongo", "", ""); + config.db_type = DatabaseType::MongoDb; + config.connection_string = Some("mongodb://user:secret@localhost/app".to_string()); + + save_connections_to_file(&path, &[config], &store).unwrap(); + + assert_eq!( + store + .get_existing("mongo", CONNECTION_STRING_KEY) + .as_deref(), + Some("mongodb://user:secret@localhost/app") + ); + let persisted = read_configs(&path); + assert_eq!(persisted[0].connection_string, None); + + let loaded = load_connections_from_file(&path, &store).unwrap(); + assert_eq!( + loaded[0].connection_string.as_deref(), + Some("mongodb://user:secret@localhost/app") + ); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f0bc01bf5..97f1e27df 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod ai; pub mod connection; +mod connection_secrets; pub mod history; pub mod mongo_cmd; pub mod query; diff --git a/src-tauri/src/models/connection.rs b/src-tauri/src/models/connection.rs index eace8b326..e59a8ca0e 100644 --- a/src-tauri/src/models/connection.rs +++ b/src-tauri/src/models/connection.rs @@ -249,6 +249,7 @@ mod tests { ssh_user: String::new(), ssh_password: String::new(), ssh_key_path: String::new(), + ssh_expose_lan: false, ssl: false, connection_string: None, } diff --git a/src/App.vue b/src/App.vue index 67c18346a..bc70f82cd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -596,7 +596,9 @@ function handleKeydown(e: KeyboardEvent) { onMounted(() => { applyTheme(); - connectionStore.initFromDisk(); + connectionStore.initFromDisk().catch((e: any) => { + toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); + }); settingsStore.initAiConfig(); window.addEventListener("keydown", handleKeydown, true); setupFileDrop(); @@ -671,9 +673,13 @@ async function setupFileDrop() { username: "", password: "", }; - connectionStore.addConnection(config); - connectionStore.connect(config); - toast(t("welcome.fileOpened", { name })); + try { + await connectionStore.addConnection(config); + void connectionStore.connect(config); + toast(t("welcome.fileOpened", { name })); + } catch (e: any) { + toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); + } } }); } diff --git a/src/components/connection/ConnectionDialog.vue b/src/components/connection/ConnectionDialog.vue index 7f39683a3..4ddae6cd2 100644 --- a/src/components/connection/ConnectionDialog.vue +++ b/src/components/connection/ConnectionDialog.vue @@ -269,11 +269,11 @@ async function save() { try { if (editingId.value) { const updated: ConnectionConfig = { ...form.value, id: editingId.value }; - store.updateConnection(updated); + await store.updateConnection(updated); store.stopEditing(); } else { const config: ConnectionConfig = { ...form.value, id: crypto.randomUUID() }; - store.addConnection(config); + await store.addConnection(config); open.value = false; await nextTick(); emit("connectStarted", config.name); diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index 2b40954bd..2d91ab648 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -220,11 +220,15 @@ function deleteConnection() { showDeleteConfirm.value = true; } -function confirmDelete() { +async function confirmDelete() { const node = props.node; if (node.connectionId) { - connectionStore.disconnect(node.connectionId); - connectionStore.removeConnection(node.connectionId); + try { + await connectionStore.disconnect(node.connectionId); + await connectionStore.removeConnection(node.connectionId); + } catch (e: any) { + toast(t("connection.saveFailed", { message: e?.message || String(e) }), 5000); + } } } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 679b4ff65..1fd950286 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -51,6 +51,8 @@ export default { connecting: "Connecting to {name}...", connectSuccess: "Connected to {name}", connectFailed: "Connection failed: {message}", + saveFailed: "Failed to save connection: {message}", + loadFailed: "Failed to load saved connections: {message}", sshTunnel: "SSH Tunnel", sshEnable: "Connect via SSH tunnel", sshHost: "SSH Host", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c37248247..1e1d5cedc 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -53,6 +53,8 @@ export default { connecting: "正在连接 {name}...", connectSuccess: "已连接 {name}", connectFailed: "连接失败:{message}", + saveFailed: "保存连接失败:{message}", + loadFailed: "加载已保存连接失败:{message}", sshTunnel: "SSH 隧道", sshEnable: "通过 SSH 隧道连接", sshHost: "SSH 主机", diff --git a/src/stores/connectionStore.ts b/src/stores/connectionStore.ts index 9764d5e56..585df4b64 100644 --- a/src/stores/connectionStore.ts +++ b/src/stores/connectionStore.ts @@ -130,16 +130,18 @@ export const useConnectionStore = defineStore("connection", () => { } } - function addConnection(config: ConnectionConfig) { + async function addConnection(config: ConnectionConfig) { const normalized = normalizeConnection(config); const existing = connections.value.findIndex((c) => c.id === normalized.id); + const nextConnections = [...connections.value]; if (existing >= 0) { - connections.value[existing] = normalized; + nextConnections[existing] = normalized; } else { - connections.value.push(normalized); + nextConnections.push(normalized); } + await persistConnections(nextConnections); + connections.value = nextConnections; upsertConnectionNode(normalized); - persistConnections(); } function invalidateCompletionCache(connectionId: string) { @@ -152,20 +154,25 @@ export const useConnectionStore = defineStore("connection", () => { ); } - function removeConnection(id: string) { - connections.value = connections.value.filter((c) => c.id !== id); + async function removeConnection(id: string) { + const nextConnections = connections.value.filter((c) => c.id !== id); + await persistConnections(nextConnections); + connections.value = nextConnections; treeNodes.value = treeNodes.value.filter((n) => n.id !== id); if (activeConnectionId.value === id) { activeConnectionId.value = null; } invalidateCompletionCache(id); - persistConnections(); } - function updateConnection(config: ConnectionConfig) { + async function updateConnection(config: ConnectionConfig) { config = normalizeConnection(config); const idx = connections.value.findIndex((c) => c.id === config.id); - if (idx >= 0) connections.value[idx] = config; + if (idx < 0) return; + const nextConnections = [...connections.value]; + nextConnections[idx] = config; + await persistConnections(nextConnections); + connections.value = nextConnections; const node = findNode(treeNodes.value, config.id); if (node) { node.label = config.name; @@ -174,7 +181,6 @@ export const useConnectionStore = defineStore("connection", () => { } connectedIds.value.delete(config.id); invalidateCompletionCache(config.id); - persistConnections(); } async function connect(config: ConnectionConfig) { @@ -561,8 +567,8 @@ export const useConnectionStore = defineStore("connection", () => { return null; } - function persistConnections() { - api.saveConnections(connections.value).catch(() => {}); + async function persistConnections(nextConnections: ConnectionConfig[] = connections.value) { + await api.saveConnections(nextConnections); } async function exportConnectionsToFile() { @@ -570,7 +576,7 @@ export const useConnectionStore = defineStore("connection", () => { const { writeTextFile } = await import("@tauri-apps/plugin-fs"); const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" }); if (!path) return; - const data = connections.value.map((c) => ({ ...c, password: "", ssh_password: "" })); + const data = connections.value.map((c) => ({ ...c, password: "", ssh_password: "", connection_string: undefined })); await writeTextFile(path, JSON.stringify(data, null, 2)); } @@ -585,7 +591,7 @@ export const useConnectionStore = defineStore("connection", () => { if (!connections.value.find((c) => c.id === config.id)) { config.id = crypto.randomUUID(); const normalized = normalizeConnection(config); - addConnection(normalized); + await addConnection(normalized); } } }