Files
dbx/crates/dbx-core/src/runtime_config.rs
T

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