//! 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)); } }