store connection secrets in system keyring

This commit is contained in:
SuLe 2026-05-02 03:37:46 +08:00
parent 3f71cd6dfa
commit d14329ea15
12 changed files with 935 additions and 36 deletions

466
src-tauri/Cargo.lock generated
View File

@ -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",
]

View File

@ -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"] }

View File

@ -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<ConnectionConfig>,
) -> 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<Vec<ConnectionConfig>, 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<ConnectionConfig> =
serde_json::from_str(&json).map_err(|e| e.to_string())?;
Ok(configs)
let store = KeyringConnectionSecretStore;
load_connections_from_file(&path, &store)
}
#[tauri::command]

View File

@ -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<Option<String>, 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<Option<String>, 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<Vec<ConnectionConfig>, 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<Vec<ConnectionConfig>, 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<ConnectionConfig> {
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, String> {
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<HashMap<String, String>>,
deleted: RefCell<Vec<String>>,
}
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<String> {
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<Option<String>, 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<ConnectionConfig> {
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")
);
}
}

View File

@ -1,5 +1,6 @@
pub mod ai;
pub mod connection;
mod connection_secrets;
pub mod history;
pub mod mongo_cmd;
pub mod query;

View File

@ -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,
}

View File

@ -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);
}
}
});
}

View File

@ -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);

View File

@ -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);
}
}
}

View File

@ -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",

View File

@ -53,6 +53,8 @@ export default {
connecting: "正在连接 {name}...",
connectSuccess: "已连接 {name}",
connectFailed: "连接失败:{message}",
saveFailed: "保存连接失败:{message}",
loadFailed: "加载已保存连接失败:{message}",
sshTunnel: "SSH 隧道",
sshEnable: "通过 SSH 隧道连接",
sshHost: "SSH 主机",

View File

@ -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);
}
}
}