172 lines
8.0 KiB
Rust
172 lines
8.0 KiB
Rust
//! Lifecycle rules for runtime connection configs (`AppState::configs`).
|
|
//!
|
|
//! `save_connections` reclaims runtime configs by asking "is this id in the saved
|
|
//! list?", but two kinds of config are never in that list and must be judged
|
|
//! separately, or they get evicted by mistake:
|
|
//!
|
|
//! - **Transient probe ids**: `test_connection`, the visible-database and
|
|
//! visible-schema drafts, and other runtime ids generated by prefix convention.
|
|
//! Their creators are responsible for cleaning them up.
|
|
//! - **One-time connections (`one_time`)**: connections the user explicitly marked
|
|
//! as one-time, plus the dropped-file preview connections. They are never
|
|
//! persisted (the frontend `persistConnections` filters them out), so they can
|
|
//! never appear in the saved list.
|
|
//!
|
|
//! The desktop backend (`src-tauri`) and the web backend (`dbx-web`) each have
|
|
//! their own `sync_connection_configs` and `disconnect_db`; the rules must agree
|
|
//! across both, so they live here.
|
|
|
|
use crate::connection::AppState;
|
|
use crate::models::connection::ConnectionConfig;
|
|
|
|
/// Prefix for the throwaway probe connection created by `test_connection`.
|
|
pub const TEST_PROBE_ID_PREFIX: &str = "__test_";
|
|
/// Prefix for the connection dialog's "visible databases" picker draft.
|
|
pub const VISIBLE_DRAFT_ID_PREFIX: &str = "__visible_draft_";
|
|
/// Prefix for the connection dialog's "visible schemas" picker draft.
|
|
pub const VISIBLE_SCHEMA_DRAFT_ID_PREFIX: &str = "__visible_schema_draft_";
|
|
|
|
/// Runtime configs generated by the transient-id prefix convention.
|
|
///
|
|
/// These ids never appear in the saved connection list, so `save_connections`
|
|
/// must skip them; otherwise one concurrent save kills an in-flight probe.
|
|
pub fn is_transient_runtime_config_id(id: &str) -> bool {
|
|
id.starts_with(TEST_PROBE_ID_PREFIX)
|
|
|| id.starts_with(VISIBLE_DRAFT_ID_PREFIX)
|
|
|| id.starts_with(VISIBLE_SCHEMA_DRAFT_ID_PREFIX)
|
|
}
|
|
|
|
/// Whether `sync_connection_configs` must keep this runtime config. The caller
|
|
/// separately checks whether the id is in the saved list.
|
|
pub fn should_retain_runtime_config(id: &str, config: &ConnectionConfig) -> bool {
|
|
config.one_time || is_transient_runtime_config_id(id)
|
|
}
|
|
|
|
/// Whether `disconnect_db` should drop the runtime config along with the session.
|
|
///
|
|
/// The asymmetry with [`should_retain_runtime_config`] is deliberate:
|
|
/// `__test_*` probes are removed synchronously by `run_temporary_connection_test`
|
|
/// in its own cleanup and never reach `disconnect_db`, so that prefix is not
|
|
/// matched here.
|
|
pub fn should_release_runtime_config_on_disconnect(id: &str, config: Option<&ConnectionConfig>) -> bool {
|
|
id.starts_with(VISIBLE_DRAFT_ID_PREFIX)
|
|
|| id.starts_with(VISIBLE_SCHEMA_DRAFT_ID_PREFIX)
|
|
|| config.is_some_and(|config| config.one_time)
|
|
}
|
|
|
|
/// Reclaim a transient runtime config after its session is torn down.
|
|
///
|
|
/// These configs are not in the saved list, so the reclaim branch of
|
|
/// `sync_connection_configs` never fires for them; this is the only reclaim
|
|
/// point. Both the config itself and the session credential (a plaintext
|
|
/// password) must be released here, or they live until the process exits.
|
|
///
|
|
/// The check and the removal share one write lock so that a concurrent
|
|
/// `connect_db` cannot have a freshly inserted config deleted out from under it.
|
|
pub async fn release_runtime_config_on_disconnect(state: &AppState, connection_id: &str) {
|
|
let released = {
|
|
let mut configs = state.configs.write().await;
|
|
if should_release_runtime_config_on_disconnect(connection_id, configs.get(connection_id)) {
|
|
configs.remove(connection_id);
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
};
|
|
if released {
|
|
// Matches how `sync_connection_configs` treats a deleted connection: once
|
|
// the config is gone the credential has no owner, and leaving it behind
|
|
// keeps a plaintext password in memory.
|
|
state.session_credentials.clear_connection(connection_id);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn config_with_one_time(one_time: bool) -> ConnectionConfig {
|
|
serde_json::from_value(serde_json::json!({
|
|
"id": "cfg",
|
|
"name": "cfg",
|
|
"db_type": "mysql",
|
|
"host": "127.0.0.1",
|
|
"port": 3306,
|
|
"username": "root",
|
|
"password": "",
|
|
"database": null,
|
|
"one_time": one_time,
|
|
}))
|
|
.expect("connection config fixture")
|
|
}
|
|
|
|
#[test]
|
|
fn transient_ids_are_recognized_by_prefix() {
|
|
assert!(is_transient_runtime_config_id("__test_abc"));
|
|
assert!(is_transient_runtime_config_id("__visible_draft_abc"));
|
|
assert!(is_transient_runtime_config_id("__visible_schema_draft_abc"));
|
|
assert!(!is_transient_runtime_config_id("saved-connection"));
|
|
assert!(!is_transient_runtime_config_id("__production_database_draft_abc"));
|
|
}
|
|
|
|
#[test]
|
|
fn retain_covers_both_transient_ids_and_one_time_configs() {
|
|
assert!(should_retain_runtime_config("__test_abc", &config_with_one_time(false)));
|
|
assert!(should_retain_runtime_config("preview-duckdb", &config_with_one_time(true)));
|
|
// Production-database drafts have no dedicated prefix; only `one_time` keeps them alive.
|
|
assert!(should_retain_runtime_config("__production_database_draft_abc", &config_with_one_time(true)));
|
|
assert!(!should_retain_runtime_config("saved-connection", &config_with_one_time(false)));
|
|
}
|
|
|
|
#[test]
|
|
fn disconnect_releases_drafts_and_one_time_but_not_test_probes() {
|
|
assert!(should_release_runtime_config_on_disconnect("__visible_draft_abc", None));
|
|
assert!(should_release_runtime_config_on_disconnect("__visible_schema_draft_abc", None));
|
|
assert!(should_release_runtime_config_on_disconnect("preview-duckdb", Some(&config_with_one_time(true))));
|
|
// `__test_*` cleans up after itself in run_temporary_connection_test, not here.
|
|
assert!(!should_release_runtime_config_on_disconnect("__test_abc", Some(&config_with_one_time(false))));
|
|
assert!(!should_release_runtime_config_on_disconnect("saved-connection", Some(&config_with_one_time(false))));
|
|
assert!(!should_release_runtime_config_on_disconnect("saved-connection", None));
|
|
}
|
|
|
|
async fn app_state() -> (AppState, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let storage = crate::storage::Storage::open(&dir.path().join("storage.db")).await.unwrap();
|
|
(AppState::new(storage), dir)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disconnect_release_drops_one_time_config_and_its_session_credential() {
|
|
let (state, _dir) = app_state().await;
|
|
let mut config = config_with_one_time(true);
|
|
config.id = "preview-duckdb".to_string();
|
|
state.configs.write().await.insert(config.id.clone(), config.clone());
|
|
state.session_credentials.set("", &config.id, "secret").expect("session credential fixture");
|
|
|
|
release_runtime_config_on_disconnect(&state, &config.id).await;
|
|
|
|
assert!(!state.configs.read().await.contains_key(&config.id));
|
|
assert!(
|
|
!state.session_credentials.has("", &config.id),
|
|
"the plaintext session password must be cleared along with the config"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disconnect_release_keeps_saved_config_and_its_session_credential() {
|
|
let (state, _dir) = app_state().await;
|
|
let mut config = config_with_one_time(false);
|
|
config.id = "saved-connection".to_string();
|
|
state.configs.write().await.insert(config.id.clone(), config.clone());
|
|
state.session_credentials.set("", &config.id, "secret").expect("session credential fixture");
|
|
|
|
release_runtime_config_on_disconnect(&state, &config.id).await;
|
|
|
|
// Disconnecting a normal connection only closes the session: the config stays
|
|
// so it can reconnect, and the credential must survive too, or every
|
|
// save_password=false connection would re-prompt after each disconnect.
|
|
assert!(state.configs.read().await.contains_key(&config.id));
|
|
assert!(state.session_credentials.has("", &config.id));
|
|
}
|
|
}
|