Editor settings, SSH/SSL runtime, data import + table-menu loose ends (#7)
* feat: editor settings model + typed clamped defaults (Task 1) * feat: carry SSH config + ssh_password to backend DbConfig (Task 2) * feat: Change enum bulk/drop/empty + type-specific change payload builder (Task 3) * feat: csv parser + shared export util (Task 4) * feat: rustls TLS connector factory with modes + client auth (Task 5) * feat: real SSH tunnel manager (testable backend) + pool eviction hook (Task 6) * feat: table DDL fetch (sqlite + pg_dump arg builder) (Task 7) * feat: keychain SSH secrets + connection delete purge + tunnel lifecycle (Task 8) * feat: real SSH tunnel + TLS connect path for postgres/mysql (Task 9) * feat: execute_change bulk/drop/empty + get_table_ddl command (Task 10) * feat: fetch SSH secrets into dbConnect + save on connection form (Task 11) * feat: Editor settings tab UI (Task 12) * feat: QueryEditor applies editor settings live (Task 13) * feat: ImportDialog with CSV/JSON preview + column mapping (Task 14) * feat: table-menu export/empty/delete/import + queue labels + payload builder (Task 15) * fix: error sanitization, encrypted-key guard, row-indexed import errors, caps (Task 16) * docs: mark Editor Settings, SSH/SSL runtime, Data Import, table-menu loose ends shipped (Task 17) * feat: auto-refresh schema tree after schema-modifying SQL (query + queue drop) * fix: use theme-consistent red classes for danger menu items (text-error was undefined) * feat: changes queue as tab-bar popover + amber pending border * feat: redesign changes popover (visual/SQL toggle, cards, footer actions, Cmd+S) * refactor: drop per-card status label from changes popover cards * feat: green completion indicator on committed cards + auto-close tabs of dropped tables * docs: changes queue popover UX + auto schema refresh statuses
This commit is contained in:
@@ -96,8 +96,21 @@ pub fn update_connection(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_connection(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_connection_inner(&state.db_store, &id)
|
||||
pub fn delete_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
delete_connection_inner(&state.db_store, &id)?;
|
||||
// Purge keychain secrets (missing entries are no-ops) and close any
|
||||
// SSH tunnel associated with the deleted connection.
|
||||
let _ = crate::commands::keychain::delete_connection_password_internal(&app, &id);
|
||||
let _ = crate::commands::keychain::delete_connection_ssh_password_internal(&app, &id);
|
||||
let _ = crate::commands::keychain::delete_connection_ssh_passphrase_internal(&app, &id);
|
||||
if let Ok(mut mgr) = state.ssh_manager.lock() {
|
||||
mgr.close_tunnel(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -147,6 +160,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -177,6 +191,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -205,6 +220,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
|
||||
@@ -90,6 +90,75 @@ pub fn offset(page: i64, page_size: i64) -> i64 {
|
||||
(page - 1) * page_size
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table DDL helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch the stored `CREATE TABLE` statement for a SQLite table from
|
||||
/// `sqlite_master`. Errors when the table does not exist.
|
||||
pub fn get_sqlite_ddl(conn: &rusqlite::Connection, table: &str) -> Result<String, String> {
|
||||
conn.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?1",
|
||||
rusqlite::params![table],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.map_err(|e| format!("table DDL not found for {table}: {e}"))
|
||||
}
|
||||
|
||||
/// Build the `pg_dump` argument vector for schema-only DDL extraction of a
|
||||
/// single table. The password is intentionally NOT part of these args — it is
|
||||
/// passed via the `PGPASSWORD` environment variable so it never appears on
|
||||
/// the command line.
|
||||
pub fn build_pg_dump_ddl_args(schema: &str, table: &str) -> Vec<String> {
|
||||
vec![
|
||||
"--schema-only".into(),
|
||||
"--no-owner".into(),
|
||||
format!("--schema={schema}"),
|
||||
format!("--table={table}"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Check whether the system `pg_dump` binary is on PATH.
|
||||
pub fn pg_dump_available() -> bool {
|
||||
std::process::Command::new("pg_dump")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Extract a single table's DDL from a PostgreSQL database by shelling out to
|
||||
/// the system `pg_dump` with `--schema-only`. Credentials are supplied via the
|
||||
/// `PGPASSWORD` environment variable only — never as argv — and are never
|
||||
/// logged. Execution requires a reachable PostgreSQL server plus an installed
|
||||
/// `pg_dump`; unit tests cover the argument construction instead.
|
||||
pub fn get_pg_ddl_via_dump(
|
||||
schema: &str,
|
||||
table: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
user: &str,
|
||||
db: &str,
|
||||
password: &str,
|
||||
) -> Result<String, String> {
|
||||
if !pg_dump_available() {
|
||||
return Err("pg_dump not found. Install PostgreSQL client tools to copy table schema.".into());
|
||||
}
|
||||
let mut cmd = std::process::Command::new("pg_dump");
|
||||
cmd.args([
|
||||
format!("--host={host}"),
|
||||
format!("--port={port}"),
|
||||
format!("--username={user}"),
|
||||
format!("--dbname={db}"),
|
||||
]);
|
||||
cmd.args(build_pg_dump_ddl_args(schema, table));
|
||||
cmd.env("PGPASSWORD", password);
|
||||
let out = cmd.output().map_err(|e| format!("pg_dump spawn failed: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(String::from_utf8_lossy(&out.stderr).to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter / Sort → SQL helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -420,6 +489,112 @@ fn json_to_sqlite_value(v: &serde_json::Value) -> rusqlite::types::Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the SQL skeleton for a bulk INSERT into PostgreSQL.
|
||||
///
|
||||
/// Emits `$N` placeholders; callers bind one row of values per execution so
|
||||
/// the same statement can be reused for every row in the batch.
|
||||
pub fn build_pg_bulk_insert_sql(schema: &str, table: &str, columns: &[String]) -> String {
|
||||
let cols: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c)).collect();
|
||||
let placeholders: Vec<String> = (1..=columns.len()).map(|i| format!("${i}")).collect();
|
||||
format!(
|
||||
"INSERT INTO \"{}\".\"{}\" ({}) VALUES ({})",
|
||||
schema,
|
||||
table,
|
||||
cols.join(", "),
|
||||
placeholders.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a `DROP TABLE` statement (schema-qualified). SQLite accepts the same
|
||||
/// qualified form against the `main` schema.
|
||||
pub fn build_drop_table_sql(schema: &str, table: &str) -> String {
|
||||
format!("DROP TABLE \"{}\".\"{}\"", schema, table)
|
||||
}
|
||||
|
||||
/// Build a `DELETE FROM` (empty-table) statement (schema-qualified). SQLite
|
||||
/// accepts the same qualified form against the `main` schema.
|
||||
pub fn build_empty_table_sql(schema: &str, table: &str) -> String {
|
||||
format!("DELETE FROM \"{}\".\"{}\"", schema, table)
|
||||
}
|
||||
|
||||
/// Apply a batch of rows to a SQLite table inside a single transaction.
|
||||
///
|
||||
/// Every row is inserted with its own parameterized statement; on the first
|
||||
/// error the whole transaction is rolled back so no partial batch survives.
|
||||
pub fn apply_bulk_insert_sqlite(
|
||||
conn: &rusqlite::Connection,
|
||||
table: &str,
|
||||
columns: &[String],
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
) -> Result<usize, String> {
|
||||
let sql = build_insert_sql("main", table, columns);
|
||||
conn.execute_batch("BEGIN").map_err(|e| e.to_string())?;
|
||||
let result = (|| {
|
||||
let mut count = 0;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let params: Vec<rusqlite::types::Value> =
|
||||
row.iter().map(json_to_sqlite_value).collect();
|
||||
conn.execute(&sql, rusqlite::params_from_iter(params))
|
||||
.map_err(|e| format!("row {}: {}", i + 1, e))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok::<usize, String>(count)
|
||||
})();
|
||||
match result {
|
||||
Ok(count) => {
|
||||
conn.execute_batch("COMMIT").map_err(|e| e.to_string())?;
|
||||
Ok(count)
|
||||
}
|
||||
Err(e) => {
|
||||
// Best-effort rollback so a failed batch never persists partially.
|
||||
let _ = conn.execute_batch("ROLLBACK");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a batch of rows to a PostgreSQL table inside a single transaction.
|
||||
///
|
||||
/// The same `$N`-placeholder statement is reused per row with natively bound
|
||||
/// values; on the first error the transaction is rolled back.
|
||||
pub async fn apply_bulk_insert_pg(
|
||||
client: &tokio_postgres::Client,
|
||||
schema: &str,
|
||||
table: &str,
|
||||
columns: &[String],
|
||||
rows: &[Vec<serde_json::Value>],
|
||||
) -> Result<usize, String> {
|
||||
let sql = build_pg_bulk_insert_sql(schema, table, columns);
|
||||
client.batch_execute("BEGIN").await.map_err(|e| e.to_string())?;
|
||||
let mut count = 0;
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let boxed: Vec<Box<dyn ToSql + Send + Sync>> = row.iter().map(pg_box_value).collect();
|
||||
let refs: Vec<&(dyn ToSql + Sync)> = boxed
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let r: &(dyn ToSql + Sync) = &**b;
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = client.execute(&sql, &refs).await {
|
||||
let _ = client.batch_execute("ROLLBACK").await;
|
||||
return Err(format!("row {}: {}", i + 1, e));
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
client
|
||||
.batch_execute("COMMIT")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Sanitize a raw error string before it crosses the IPC boundary: redact
|
||||
/// credential-like fragments (connection URLs, `password=...`) and cap length.
|
||||
fn sanitize_error(e: &str) -> String {
|
||||
truncate(&redact_secrets(e), 400)
|
||||
}
|
||||
|
||||
/// Parse a JSON object string (e.g. `{"id": 1}`) into ordered (column, value)
|
||||
/// pairs. Insertion order of the JSON object is preserved by `serde_json`.
|
||||
fn parse_json_pairs(json: &str) -> Result<Vec<(String, serde_json::Value)>, String> {
|
||||
@@ -538,6 +713,29 @@ pub(crate) fn pg_value_to_json(row: &tokio_postgres::Row, i: usize) -> serde_jso
|
||||
serde_json::Value::Null
|
||||
}
|
||||
|
||||
/// Establish a PostgreSQL connection with the given TLS connector and spawn
|
||||
/// the background connection driver task.
|
||||
///
|
||||
/// This helper keeps the two TLS branches of `db_connect` unified: without it
|
||||
/// the `Connection<Socket, NoTlsStream>` vs `Connection<Socket, TlsStream>`
|
||||
/// types would force duplicated spawn/register blocks.
|
||||
pub(crate) async fn connect_pg_with<T>(
|
||||
pgconfig: &tokio_postgres::Config,
|
||||
tls: T,
|
||||
) -> Result<(tokio_postgres::Client, tokio::task::JoinHandle<()>), tokio_postgres::Error>
|
||||
where
|
||||
T: tokio_postgres::tls::MakeTlsConnect<tokio_postgres::Socket>,
|
||||
T::Stream: Send + 'static,
|
||||
{
|
||||
let (client, connection) = pgconfig.connect(tls).await?;
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
eprintln!("PostgreSQL connection error: {}", e);
|
||||
}
|
||||
});
|
||||
Ok((client, handle))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn db_connect(
|
||||
connection_id: String,
|
||||
@@ -545,35 +743,87 @@ pub async fn db_connect(
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if config.db_type == "postgresql" {
|
||||
use tokio_postgres::NoTls;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(5432) as u16;
|
||||
let user = config.username.as_deref().unwrap_or("postgres");
|
||||
let dbname = config.database.as_deref().unwrap_or("postgres");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
let default_port = config.port.unwrap_or(5432) as u16;
|
||||
|
||||
// Build a postgres URL connection string rather than the fragile
|
||||
// libpq key=value format. tokio-postgres parses URLs reliably and
|
||||
// urlencoding handles special characters in user/password/dbname.
|
||||
use urlencoding::encode as enc;
|
||||
let conn_str = format!(
|
||||
"postgresql://{}:{}@{}:{}/{}?connect_timeout=10",
|
||||
enc(user),
|
||||
enc(password),
|
||||
host,
|
||||
port,
|
||||
enc(dbname),
|
||||
let ssh_cfg = config.ssh_config();
|
||||
let will_tunnel = ssh_cfg.is_some();
|
||||
|
||||
// TLS first: through a tunnel the peer is loopback, so
|
||||
// verify-ca/verify-full degrade to encrypt-only `require`; direct
|
||||
// connections honor the user's mode. Building this before opening the
|
||||
// tunnel means a config error can't leak the tunnel.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
will_tunnel,
|
||||
);
|
||||
let tls = crate::db::tls::build_tls_config(
|
||||
decision,
|
||||
config.ssl_ca_path.as_deref(),
|
||||
config.ssl_cert_path.as_deref(),
|
||||
config.ssl_key_path.as_deref(),
|
||||
)
|
||||
.map_err(|e| sanitize_error(&e))?;
|
||||
|
||||
match tokio_postgres::connect(&conn_str, NoTls).await {
|
||||
Ok((client, connection)) => {
|
||||
let handle = tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
eprintln!("PostgreSQL connection error: {}", e);
|
||||
}
|
||||
});
|
||||
// SSH tunnel: if configured, open a loopback tunnel to the remote DB
|
||||
// and connect through it. The blocking ssh2 handshake runs in
|
||||
// `spawn_blocking` so it never blocks the async runtime.
|
||||
let (connect_host, connect_port, via_tunnel) = match ssh_cfg {
|
||||
Some(ssh) => {
|
||||
let key = connection_id.clone();
|
||||
let remote_host = config.host.clone();
|
||||
let remote_port = config.port.unwrap_or(5432) as u16;
|
||||
let pw = config.ssh_password.clone();
|
||||
let pp = config.ssh_passphrase.clone();
|
||||
let backend = state.ssh_manager.lock().unwrap().backend_clone();
|
||||
let tunnel = tokio::task::spawn_blocking(move || {
|
||||
backend.open(
|
||||
&key,
|
||||
&ssh,
|
||||
&remote_host,
|
||||
remote_port,
|
||||
pw.as_deref(),
|
||||
pp.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Connection failed: {e}"))?
|
||||
.map_err(|e| sanitize_error(&e))?;
|
||||
let lp = tunnel.local_port;
|
||||
state
|
||||
.ssh_manager
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert_tunnel(connection_id.clone(), tunnel);
|
||||
("127.0.0.1".to_string(), lp, true)
|
||||
}
|
||||
None => (config.host.clone(), default_port, false),
|
||||
};
|
||||
|
||||
// Config builder: user/password/dbname are sent as-is (no URL
|
||||
// percent-encoding needed), and the TLS connector is chosen explicitly.
|
||||
let mut pgconfig = tokio_postgres::Config::new();
|
||||
pgconfig
|
||||
.host(connect_host.clone())
|
||||
.port(connect_port)
|
||||
.user(user)
|
||||
.password(password)
|
||||
.dbname(dbname)
|
||||
.connect_timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
let result = match tls {
|
||||
None => connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
|
||||
Some(cc) => {
|
||||
let connector =
|
||||
tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
|
||||
connect_pg_with(&pgconfig, connector).await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((client, handle)) => {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
pm.register(
|
||||
&connection_id,
|
||||
@@ -581,7 +831,16 @@ pub async fn db_connect(
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!("Connection failed: {}", pg_error_message(&e))),
|
||||
Err(e) => {
|
||||
if via_tunnel {
|
||||
state
|
||||
.ssh_manager
|
||||
.lock()
|
||||
.unwrap()
|
||||
.close_tunnel(&connection_id);
|
||||
}
|
||||
Err(format!("Connection failed: {}", pg_error_message(&e)))
|
||||
}
|
||||
}
|
||||
} else if config.db_type == "sqlite" {
|
||||
match rusqlite::Connection::open(&config.host) {
|
||||
@@ -1233,6 +1492,33 @@ pub async fn execute_change(
|
||||
client.execute(sql, &[]).await.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
schema,
|
||||
table,
|
||||
columns,
|
||||
rows,
|
||||
..
|
||||
} => {
|
||||
// Single transaction for the whole batch; rolls back on the
|
||||
// first failed row so no partial batch persists.
|
||||
return apply_bulk_insert_pg(client, schema, table, columns, rows)
|
||||
.await
|
||||
.map(|_| ());
|
||||
}
|
||||
Change::DropTable { schema, table, .. } => {
|
||||
client
|
||||
.execute(&build_drop_table_sql(schema, table), &[])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::EmptyTable { schema, table, .. } => {
|
||||
client
|
||||
.execute(&build_empty_table_sql(schema, table), &[])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Box each value for trait-object binding (`$N` placeholders). The
|
||||
@@ -1302,6 +1588,26 @@ pub async fn execute_change(
|
||||
conn.execute(sql, []).map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::BulkInsert {
|
||||
table,
|
||||
columns,
|
||||
rows,
|
||||
..
|
||||
} => {
|
||||
// Single transaction for the whole batch; rolls back on the
|
||||
// first failed row so no partial batch persists.
|
||||
return apply_bulk_insert_sqlite(conn, table, columns, rows).map(|_| ());
|
||||
}
|
||||
Change::DropTable { schema, table, .. } => {
|
||||
conn.execute(&build_drop_table_sql(schema, table), [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
Change::EmptyTable { schema, table, .. } => {
|
||||
conn.execute(&build_empty_table_sql(schema, table), [])
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let sqlite_params: Vec<rusqlite::types::Value> =
|
||||
@@ -1500,6 +1806,68 @@ pub async fn get_extensions(
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a table's `CREATE TABLE` DDL for display/copy.
|
||||
///
|
||||
/// SQLite reads the stored statement from `sqlite_master` directly; PostgreSQL
|
||||
/// shells out to the system `pg_dump --schema-only` so the output matches what
|
||||
/// `pg_dump` would emit, scoped to the requested schema + table.
|
||||
#[tauri::command]
|
||||
pub async fn get_table_ddl(
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
state: State<'_, crate::AppState>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<String, String> {
|
||||
let mut pm = state.pool_manager.lock().await;
|
||||
match pm.get(&connection_id) {
|
||||
Some(DbHandle::Sqlite(conn)) => get_sqlite_ddl(conn, &table),
|
||||
Some(DbHandle::Postgresql(_client, _)) => {
|
||||
// Pull connection metadata so pg_dump reaches the same server the
|
||||
// pool is connected to (host/port/user/dbname + keychain password).
|
||||
let conn_row = state
|
||||
.db_store
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.get_connections()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|c| c.id == connection_id)
|
||||
.ok_or_else(|| format!("connection {connection_id} not found"))?;
|
||||
let host = conn_row.host.clone();
|
||||
let port = conn_row.port.unwrap_or(5432) as u16;
|
||||
let user = conn_row.username.unwrap_or_else(|| "postgres".into());
|
||||
let db = conn_row.database.unwrap_or_else(|| "postgres".into());
|
||||
let password =
|
||||
crate::commands::keychain::get_connection_password_internal(&app, &connection_id)?
|
||||
.unwrap_or_default();
|
||||
|
||||
// SSH-tunneled connections: pg_dump must reach the DB through the
|
||||
// same local loopback listener the app uses, not the remote host.
|
||||
let tunnel_port = state
|
||||
.ssh_manager
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.get_local_port(&connection_id);
|
||||
let (dump_host, dump_port) = match tunnel_port {
|
||||
Some(lp) => ("127.0.0.1".to_string(), lp),
|
||||
None => (host, port),
|
||||
};
|
||||
|
||||
// pg_dump is blocking I/O; run it off the async runtime. Credentials
|
||||
// travel via PGPASSWORD, never argv.
|
||||
let ddl = tokio::task::spawn_blocking(move || {
|
||||
get_pg_ddl_via_dump(&schema, &table, &dump_host, dump_port, &user, &db, &password)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("pg_dump task failed: {e}"))??;
|
||||
Ok(sanitize_error(&ddl))
|
||||
}
|
||||
None => Err("Connection not found".into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1620,4 +1988,132 @@ mod tests {
|
||||
sql
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that `get_sqlite_ddl` returns the stored CREATE TABLE statement
|
||||
/// from `sqlite_master`.
|
||||
#[test]
|
||||
fn sqlite_ddl_returns_create_table() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute("CREATE TABLE foo (id INTEGER PRIMARY KEY, name TEXT)", [])
|
||||
.unwrap();
|
||||
let ddl = get_sqlite_ddl(&conn, "foo").unwrap();
|
||||
assert!(ddl.contains("CREATE TABLE foo"), "got: {ddl}");
|
||||
}
|
||||
|
||||
/// Verify that `get_sqlite_ddl` errors for a table that does not exist.
|
||||
#[test]
|
||||
fn sqlite_ddl_missing_table_errors() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
assert!(get_sqlite_ddl(&conn, "nope").is_err());
|
||||
}
|
||||
|
||||
/// Verify that the pg_dump argument builder emits schema-only DDL flags
|
||||
/// scoped to the requested schema and table.
|
||||
#[test]
|
||||
fn pg_dump_ddl_args_built() {
|
||||
let args = build_pg_dump_ddl_args("public", "users");
|
||||
assert_eq!(
|
||||
args,
|
||||
vec![
|
||||
"--schema-only".to_string(),
|
||||
"--no-owner".to_string(),
|
||||
"--schema=public".to_string(),
|
||||
"--table=users".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that a SQLite bulk insert applies every row in a single batch.
|
||||
#[test]
|
||||
fn apply_bulk_insert_sqlite_success() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT)", [])
|
||||
.unwrap();
|
||||
let rows = vec![
|
||||
vec![serde_json::json!(1), serde_json::json!("y")],
|
||||
vec![serde_json::json!(2), serde_json::json!("z")],
|
||||
];
|
||||
apply_bulk_insert_sqlite(
|
||||
&conn,
|
||||
"t",
|
||||
&["a".to_string(), "b".to_string()],
|
||||
&rows,
|
||||
)
|
||||
.unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
}
|
||||
|
||||
/// Verify that a SQLite bulk insert rolls back the whole batch when any
|
||||
/// row fails (a non-integer value bound to the INTEGER PRIMARY KEY column
|
||||
/// raises a datatype mismatch).
|
||||
#[test]
|
||||
fn apply_bulk_insert_sqlite_inserts_and_rolls_back() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY, b TEXT)", [])
|
||||
.unwrap();
|
||||
let rows = vec![
|
||||
vec![serde_json::json!(1), serde_json::json!("y")],
|
||||
vec![serde_json::json!("bad"), serde_json::json!("z")],
|
||||
];
|
||||
let res = apply_bulk_insert_sqlite(
|
||||
&conn,
|
||||
"t",
|
||||
&["a".to_string(), "b".to_string()],
|
||||
&rows,
|
||||
);
|
||||
assert!(res.is_err(), "non-integer PK value should fail");
|
||||
// Rollback: no rows persisted.
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM t", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0, "failed batch must roll back all rows");
|
||||
}
|
||||
|
||||
/// Verify that a SQLite bulk insert reports the failing row index (1-based)
|
||||
/// when a row cannot be inserted.
|
||||
#[test]
|
||||
fn apply_bulk_insert_sqlite_error_includes_row_index() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
// INTEGER PRIMARY KEY rejects non-integer values (datatype mismatch),
|
||||
// guaranteeing row 2 fails.
|
||||
conn.execute("CREATE TABLE t (a INTEGER PRIMARY KEY)", []).unwrap();
|
||||
let rows = vec![vec![serde_json::json!(1)], vec![serde_json::json!("x")]];
|
||||
let err = apply_bulk_insert_sqlite(&conn, "t", &["a".to_string()], &rows)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.contains("row 2"),
|
||||
"error should name the failing row index (1-based): {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that the PostgreSQL bulk-insert skeleton uses `$N` placeholders
|
||||
/// and quotes schema, table, and columns.
|
||||
#[test]
|
||||
fn build_pg_bulk_insert_sql_shape() {
|
||||
let sql = build_pg_bulk_insert_sql(
|
||||
"public",
|
||||
"users",
|
||||
&["id".to_string(), "name".to_string()],
|
||||
);
|
||||
assert_eq!(
|
||||
sql,
|
||||
r#"INSERT INTO "public"."users" ("id", "name") VALUES ($1, $2)"#
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that the DROP TABLE / DELETE-all SQL helpers quote schema + table.
|
||||
#[test]
|
||||
fn drop_and_empty_table_sql_shapes() {
|
||||
assert_eq!(
|
||||
build_drop_table_sql("public", "users"),
|
||||
r#"DROP TABLE "public"."users""#
|
||||
);
|
||||
assert_eq!(
|
||||
build_empty_table_sql("public", "users"),
|
||||
r#"DELETE FROM "public"."users""#
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Re
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -108,6 +109,7 @@ fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -143,6 +145,7 @@ fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
|
||||
@@ -80,6 +80,7 @@ pub fn import_connections_inner(state: &Mutex<Store>, json: String) -> Result<Im
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -172,7 +173,7 @@ mod tests {
|
||||
port: Some(5432), username: None, folder_id: None,
|
||||
password: None, database: None,
|
||||
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
|
||||
ssh_private_key_path: None, ssh_passphrase: None,
|
||||
ssh_private_key_path: None, ssh_password: None, ssh_passphrase: None,
|
||||
ssl_mode: None, ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
|
||||
@@ -39,6 +39,124 @@ pub fn get_connection_password_internal(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Build the keyring account name for an SSH secret (password or passphrase).
|
||||
/// The `connection_id` namespaces each secret per connection.
|
||||
pub fn ssh_account(kind: &str, connection_id: &str) -> String {
|
||||
format!("ssh_{kind}:{connection_id}")
|
||||
}
|
||||
|
||||
/// Store an SSH tunnel password in the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn save_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("password", &connection_id), &password)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH tunnel password from the OS keychain.
|
||||
/// Returns None if no SSH password was stored for this connection.
|
||||
#[tauri::command]
|
||||
pub fn get_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH tunnel password from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_ssh_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("password", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Store an SSH private-key passphrase in the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn save_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
passphrase: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&ssh_account("passphrase", &connection_id), &passphrase)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH private-key passphrase from the OS keychain.
|
||||
/// Returns None if no passphrase was stored for this connection.
|
||||
#[tauri::command]
|
||||
pub fn get_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("passphrase", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH private-key passphrase from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_ssh_passphrase(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("passphrase", &connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve an SSH tunnel password from the OS keychain (internal helper).
|
||||
/// Returns None if no SSH password was stored for this connection.
|
||||
pub fn get_connection_ssh_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&ssh_account("password", connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH tunnel password from the OS keychain (internal helper).
|
||||
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||
pub fn delete_connection_ssh_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("password", connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete an SSH private-key passphrase from the OS keychain (internal helper).
|
||||
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||
pub fn delete_connection_ssh_passphrase_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&ssh_account("passphrase", connection_id))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete a connection password from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_password(
|
||||
@@ -49,4 +167,27 @@ pub fn delete_connection_password(
|
||||
.store
|
||||
.delete(&connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete a connection password from the OS keychain (internal helper).
|
||||
/// Errors are ignored by callers (deleting an absent key is a no-op).
|
||||
pub fn delete_connection_password_internal(
|
||||
app: &tauri::AppHandle,
|
||||
connection_id: &str,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ssh_account_namespaces_password() {
|
||||
assert_eq!(ssh_account("password", "c1"), "ssh_password:c1");
|
||||
assert_eq!(ssh_account("passphrase", "c1"), "ssh_passphrase:c1");
|
||||
}
|
||||
}
|
||||
@@ -906,6 +906,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
|
||||
+318
-74
@@ -1,110 +1,112 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::db::tls::TlsDecision;
|
||||
use crate::models::SshConfig;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// SSH tunnel configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
/// "password" or "key"
|
||||
pub auth_method: String,
|
||||
pub password: Option<String>,
|
||||
pub private_key_path: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
/// Through a tunnel the TLS peer is loopback (`127.0.0.1`), so certificate
|
||||
/// verification is meaningless: `verify-ca`/`verify-full` degrade to
|
||||
/// encrypt-only `require`. A direct (non-tunneled) connection honors the
|
||||
/// user's mode unchanged.
|
||||
pub fn effective_tls_decision(d: TlsDecision, via_tunnel: bool) -> TlsDecision {
|
||||
if via_tunnel && matches!(d, TlsDecision::Verify) {
|
||||
TlsDecision::Require
|
||||
} else {
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
impl SshConfig {
|
||||
/// Create a new `SshConfig` with the required fields.
|
||||
pub fn new(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
auth_method: String,
|
||||
) -> Self {
|
||||
SshConfig {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth_method,
|
||||
password: None,
|
||||
private_key_path: None,
|
||||
passphrase: None,
|
||||
/// A live tunnel handle. `closer` drops the listener + ssh session when called.
|
||||
pub struct Tunnel {
|
||||
pub local_port: u16,
|
||||
closer: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Create a tunnel handle with no resources to clean up (test backend).
|
||||
pub fn fake(port: u16) -> Self {
|
||||
Tunnel {
|
||||
local_port: port,
|
||||
closer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate SSH configuration.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - `host` is not empty
|
||||
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
||||
/// - `user` is not empty
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an active SSH tunnel connection.
|
||||
#[derive(Debug)]
|
||||
struct SshTunnel {
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
/// Backend that actually establishes SSH tunnels.
|
||||
///
|
||||
/// The manager only does bookkeeping; opening/closing the OS-level tunnel is
|
||||
/// delegated here so it can be faked in tests.
|
||||
pub trait TunnelBackend: Send + Sync {
|
||||
/// Open a tunnel to `remote_host:remote_port` via `cfg` and return a
|
||||
/// handle exposing the bound local port.
|
||||
fn open(
|
||||
&self,
|
||||
key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<Tunnel, String>;
|
||||
}
|
||||
|
||||
/// Manages SSH tunnels, mapping connection keys to active tunnels.
|
||||
///
|
||||
/// This is a placeholder implementation. Real SSH connectivity (via `ssh2`
|
||||
/// or `async-ssh2`) will be added in a later task. Currently the manager
|
||||
/// stores mock entries when validation passes.
|
||||
#[derive(Debug)]
|
||||
/// Bookkeeping only: validation, key->tunnel map, and lifecycle hooks.
|
||||
/// The actual SSH connectivity is delegated to a `TunnelBackend` so the
|
||||
/// manager's behavior is unit-testable with a fake backend.
|
||||
pub struct SshTunnelManager {
|
||||
tunnels: HashMap<String, SshTunnel>,
|
||||
tunnels: HashMap<String, Tunnel>,
|
||||
backend: Arc<dyn TunnelBackend>,
|
||||
}
|
||||
|
||||
impl SshTunnelManager {
|
||||
/// Create a new empty tunnel manager.
|
||||
pub fn new() -> Self {
|
||||
/// Create a new tunnel manager backed by `backend`.
|
||||
pub fn new(backend: Arc<dyn TunnelBackend>) -> Self {
|
||||
SshTunnelManager {
|
||||
tunnels: HashMap::new(),
|
||||
backend,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an SSH tunnel for the given config.
|
||||
///
|
||||
/// Returns the local port on success.
|
||||
///
|
||||
/// TODO: Replace placeholder with a real SSH connection via `ssh2` or
|
||||
/// `async-ssh2`. Currently stores a mock entry (`local_port = 15432`)
|
||||
/// when `config.is_valid()` passes.
|
||||
pub fn open_tunnel(&mut self, key: &str, config: &SshConfig) -> Result<u16, String> {
|
||||
if !config.is_valid() {
|
||||
/// Returns the local port on success. Replaces any existing tunnel for
|
||||
/// the same key (closing the old one).
|
||||
pub fn open_tunnel(
|
||||
&mut self,
|
||||
key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<u16, String> {
|
||||
if !cfg.is_valid() {
|
||||
return Err("invalid SSH configuration".to_string());
|
||||
}
|
||||
// TODO: Replace with real SSH tunnel via ssh2::Session + port forwarding.
|
||||
// For now, store a mock entry with local_port = 15432.
|
||||
self.tunnels.insert(
|
||||
key.to_string(),
|
||||
SshTunnel {
|
||||
local_port: 15432,
|
||||
remote_host: config.host.clone(),
|
||||
remote_port: config.port,
|
||||
},
|
||||
);
|
||||
Ok(15432)
|
||||
let tunnel = self
|
||||
.backend
|
||||
.open(key, cfg, remote_host, remote_port, password, passphrase)?;
|
||||
let port = tunnel.local_port;
|
||||
if let Some(old) = self.tunnels.insert(key.to_string(), tunnel) {
|
||||
drop(old.closer);
|
||||
}
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Close and remove the SSH tunnel for the given key.
|
||||
///
|
||||
/// TODO: When real SSH is implemented, this should disconnect the
|
||||
/// session and free the local port.
|
||||
pub fn close_tunnel(&mut self, key: &str) {
|
||||
self.tunnels.remove(key);
|
||||
if let Some(t) = self.tunnels.remove(key) {
|
||||
drop(t.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close all active SSH tunnels.
|
||||
pub fn close_all(&mut self) {
|
||||
self.tunnels.clear();
|
||||
let tunnels = std::mem::take(&mut self.tunnels);
|
||||
for (_, t) in tunnels {
|
||||
drop(t.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the local port for an active tunnel, if any.
|
||||
@@ -112,12 +114,122 @@ impl SshTunnelManager {
|
||||
self.tunnels.get(key).map(|t| t.local_port)
|
||||
}
|
||||
|
||||
/// Clone of the active backend, for handing into `spawn_blocking` so the
|
||||
/// blocking ssh2 work never blocks an async runtime thread.
|
||||
pub fn backend_clone(&self) -> Arc<dyn TunnelBackend> {
|
||||
self.backend.clone()
|
||||
}
|
||||
|
||||
/// Insert an already-opened tunnel under `key`, closing any previous one.
|
||||
pub fn insert_tunnel(&mut self, key: String, tunnel: Tunnel) {
|
||||
if let Some(old) = self.tunnels.insert(key, tunnel) {
|
||||
drop(old.closer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of active tunnels.
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.tunnels.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Real ssh2 backend: binds a loopback listener, authenticates to the SSH
|
||||
/// host over a blocking socket, and pumps data between the local client and
|
||||
/// the remote DB over an SSH direct-tcpip channel.
|
||||
///
|
||||
/// The whole `open` runs inside `tokio::task::spawn_blocking` at the call
|
||||
/// sites because `ssh2::Session` is purely blocking.
|
||||
pub struct Ssh2Backend;
|
||||
|
||||
impl TunnelBackend for Ssh2Backend {
|
||||
fn open(
|
||||
&self,
|
||||
_key: &str,
|
||||
cfg: &SshConfig,
|
||||
remote_host: &str,
|
||||
remote_port: u16,
|
||||
password: Option<&str>,
|
||||
passphrase: Option<&str>,
|
||||
) -> Result<Tunnel, String> {
|
||||
use ssh2::Session;
|
||||
|
||||
// Loopback-only listener with an ephemeral port.
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| format!("bind local tunnel port: {e}"))?;
|
||||
let local_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("local tunnel address: {e}"))?
|
||||
.port();
|
||||
|
||||
let tcp = std::net::TcpStream::connect((cfg.host.as_str(), cfg.port))
|
||||
.map_err(|e| format!("connect ssh host: {e}"))?;
|
||||
let mut session = Session::new().map_err(|e| format!("ssh session: {e}"))?;
|
||||
session.set_tcp_stream(tcp);
|
||||
session.handshake().map_err(|e| format!("ssh handshake: {e}"))?;
|
||||
|
||||
match cfg.auth_method.as_str() {
|
||||
"key" => {
|
||||
let path = cfg.private_key_path.as_deref().ok_or_else(|| {
|
||||
"private_key_path required for key auth".to_string()
|
||||
})?;
|
||||
session
|
||||
.userauth_pubkey_file(&cfg.user, None, std::path::Path::new(path), passphrase)
|
||||
.map_err(|e| format!("ssh key auth: {e}"))?;
|
||||
}
|
||||
_ => session
|
||||
.userauth_password(&cfg.user, password.unwrap_or(""))
|
||||
.map_err(|e| format!("ssh password auth: {e}"))?,
|
||||
}
|
||||
if !session.authenticated() {
|
||||
return Err("SSH authentication failed".into());
|
||||
}
|
||||
|
||||
let remote_host = remote_host.to_string();
|
||||
let session = Arc::new(std::sync::Mutex::new(session));
|
||||
let (closer_tx, closer_rx) = std::sync::mpsc::channel::<()>();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok((mut local, _)) = listener.accept() {
|
||||
// Open the direct-tcpip channel to the remote DB. `Channel` is
|
||||
// cloneable (Arc-shared inner), so one clone per direction
|
||||
// lets two pump threads copy data in parallel.
|
||||
let mut channel = match session.lock().unwrap().channel_direct_tcpip(
|
||||
&remote_host,
|
||||
remote_port as u16,
|
||||
None,
|
||||
) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
// The accepted socket stays owned by this thread; when the
|
||||
// tunnel is closed the closer wakes us, we drop `local` and
|
||||
// the pumps end on EOF/broken pipe.
|
||||
let mut upstream = channel.clone();
|
||||
let down = local.try_clone();
|
||||
let pump = match down {
|
||||
Ok(down) => Some(std::thread::spawn(move || {
|
||||
let mut down = down;
|
||||
// client -> remote DB
|
||||
let _ = std::io::copy(&mut down, &mut upstream);
|
||||
})),
|
||||
Err(_) => None,
|
||||
};
|
||||
// remote DB -> client (this thread)
|
||||
let _ = std::io::copy(&mut channel, &mut local);
|
||||
if let Some(p) = pump {
|
||||
let _ = p.join();
|
||||
}
|
||||
}
|
||||
let _ = closer_rx.recv();
|
||||
});
|
||||
Ok(Tunnel {
|
||||
local_port,
|
||||
closer: Some(Box::new(move || {
|
||||
let _ = closer_tx.send(());
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -176,4 +288,136 @@ mod tests {
|
||||
);
|
||||
assert!(config.is_valid(), "port 1 should be valid");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Tunnel manager tests (fake backend)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct FakeBackend {
|
||||
opens: std::sync::Mutex<Vec<String>>,
|
||||
next_port: u16,
|
||||
}
|
||||
|
||||
impl Clone for FakeBackend {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
opens: std::sync::Mutex::new(self.opens.lock().unwrap().clone()),
|
||||
next_port: self.next_port,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TunnelBackend for FakeBackend {
|
||||
fn open(
|
||||
&self,
|
||||
key: &str,
|
||||
_cfg: &crate::models::SshConfig,
|
||||
_remote_host: &str,
|
||||
_remote_port: u16,
|
||||
_pw: Option<&str>,
|
||||
_pp: Option<&str>,
|
||||
) -> Result<Tunnel, String> {
|
||||
self.opens.lock().unwrap().push(key.to_string());
|
||||
let p = self.next_port;
|
||||
Ok(Tunnel::fake(p))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_open_and_get_port() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 22222,
|
||||
..Default::default()
|
||||
});
|
||||
let mut mgr = SshTunnelManager::new(backend.clone());
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
let port = mgr
|
||||
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(port, 22222);
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(22222));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_invalid_config_errors() {
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("".into(), 22, "u".into(), "password".into());
|
||||
assert!(mgr
|
||||
.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_close_removes_tunnel() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
mgr.open_tunnel("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
mgr.close_tunnel("c1");
|
||||
assert_eq!(mgr.get_local_port("c1"), None);
|
||||
assert_eq!(mgr.active_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tunneled_tls_is_downgraded_to_require() {
|
||||
// verify-full through a tunnel degrades to encrypt-only `require`
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), true),
|
||||
crate::db::tls::TlsDecision::Require
|
||||
);
|
||||
// direct (non-tunneled) connection keeps the user's mode
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("verify-full")), false),
|
||||
crate::db::tls::TlsDecision::Verify
|
||||
);
|
||||
// disable stays disabled regardless of tunneling
|
||||
assert_eq!(
|
||||
effective_tls_decision(crate::db::tls::tls_decision(Some("disable")), true),
|
||||
crate::db::tls::TlsDecision::Disable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_backend_clone_returns_backend() {
|
||||
let backend = Arc::new(FakeBackend {
|
||||
next_port: 7,
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = SshTunnelManager::new(backend.clone());
|
||||
// The cloned Arc points at the same fake backend.
|
||||
let cloned = mgr.backend_clone();
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
let tunnel = cloned
|
||||
.open("c1", &cfg, "db.host", 5432, None, None)
|
||||
.unwrap();
|
||||
assert_eq!(tunnel.local_port, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_insert_tunnel_replaces_and_closes_old() {
|
||||
let mut mgr = SshTunnelManager::new(Arc::new(FakeBackend::default()));
|
||||
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(1111));
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(1111));
|
||||
// Re-inserting under the same key replaces the old tunnel.
|
||||
mgr.insert_tunnel("c1".to_string(), Tunnel::fake(2222));
|
||||
assert_eq!(mgr.get_local_port("c1"), Some(2222));
|
||||
assert_eq!(mgr.active_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_close_all() {
|
||||
let backend = Arc::new(FakeBackend::default());
|
||||
let mut mgr = SshTunnelManager::new(backend);
|
||||
let cfg = crate::models::SshConfig::new("h".into(), 22, "u".into(), "password".into());
|
||||
mgr.open_tunnel("a", &cfg, "db", 5432, None, None).ok();
|
||||
mgr.open_tunnel("b", &cfg, "db", 5432, None, None).ok();
|
||||
mgr.close_all();
|
||||
assert_eq!(mgr.active_count(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::ssh::SshTunnelManager;
|
||||
use crate::db::pool::DbConfig;
|
||||
|
||||
/// The SSH tunnel manager behind a mutex (as stored in `AppState`).
|
||||
type SshManager = std::sync::Mutex<SshTunnelManager>;
|
||||
|
||||
/// Result of a test database connection attempt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestConnectionResult {
|
||||
@@ -23,9 +28,8 @@ pub fn sanitize_error(msg: &str) -> String {
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let lower = msg[i..].to_lowercase();
|
||||
if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
|
||||
if let Some(scheme_end) = url_scheme_end(msg, i) {
|
||||
out.push_str("[redacted-url://");
|
||||
let scheme_end = i + msg[i..].find("://").unwrap_or(0) + 3;
|
||||
let rest = &msg[scheme_end..];
|
||||
let end = match rest.find(['/', '?']) {
|
||||
Some(pos) => scheme_end + pos,
|
||||
@@ -61,6 +65,32 @@ pub fn sanitize_error(msg: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// If `msg[i..]` begins a `scheme://authority` URL — a 1-16 char scheme of
|
||||
/// alphanumerics/`+`/`-`/`.` preceded by a non-word boundary — return the byte
|
||||
/// index just past the `://`. This redacts embedded credentials for any scheme
|
||||
/// (`postgres://`, `redis://`, `mysql://`, ...) without matching non-URL text.
|
||||
fn url_scheme_end(msg: &str, i: usize) -> Option<usize> {
|
||||
let rest = &msg[i..];
|
||||
let colon = rest.find("://")?;
|
||||
if colon == 0 || colon > 16 {
|
||||
return None;
|
||||
}
|
||||
let scheme = &rest[..colon];
|
||||
if !scheme
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Require a boundary before the scheme so mid-word text is not a URL.
|
||||
if let Some(c) = msg[..i].chars().next_back() {
|
||||
if c.is_ascii_alphanumeric() || c == '_' {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(i + colon + 3)
|
||||
}
|
||||
|
||||
/// Validate `DbConfig` before attempting a connection test.
|
||||
///
|
||||
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
|
||||
@@ -102,12 +132,81 @@ pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Effective connect target after optional SSH tunnel resolution.
|
||||
struct ConnectTarget {
|
||||
host: String,
|
||||
port: u16,
|
||||
via_tunnel: bool,
|
||||
/// Key of the opened tunnel, if any — must be closed after the probe.
|
||||
tunnel_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Open an SSH tunnel if `config` has one configured, returning the loopback
|
||||
/// target to connect through. The blocking ssh2 handshake runs in
|
||||
/// `spawn_blocking` so it never blocks the async runtime.
|
||||
///
|
||||
/// The caller must close the tunnel (via `tunnel_key`) after the probe, on
|
||||
/// every path.
|
||||
async fn resolve_connect_target(
|
||||
config: &DbConfig,
|
||||
ssh: &SshManager,
|
||||
default_port: u16,
|
||||
) -> Result<ConnectTarget, String> {
|
||||
match config.ssh_config() {
|
||||
Some(ssh_cfg) => {
|
||||
let key = format!("test-{}", uuid::Uuid::new_v4());
|
||||
let open_key = key.clone();
|
||||
let remote_host = config.host.clone();
|
||||
let remote_port = config.port.unwrap_or(default_port as i64) as u16;
|
||||
let pw = config.ssh_password.clone();
|
||||
let pp = config.ssh_passphrase.clone();
|
||||
let backend = ssh.lock().unwrap().backend_clone();
|
||||
let tunnel = tokio::task::spawn_blocking(move || {
|
||||
backend.open(
|
||||
&open_key,
|
||||
&ssh_cfg,
|
||||
&remote_host,
|
||||
remote_port,
|
||||
pw.as_deref(),
|
||||
pp.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
let lp = tunnel.local_port;
|
||||
ssh.lock().unwrap().insert_tunnel(key.clone(), tunnel);
|
||||
Ok(ConnectTarget {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: lp,
|
||||
via_tunnel: true,
|
||||
tunnel_key: Some(key),
|
||||
})
|
||||
}
|
||||
None => Ok(ConnectTarget {
|
||||
host: config.host.clone(),
|
||||
port: config.port.unwrap_or(default_port as i64) as u16,
|
||||
via_tunnel: false,
|
||||
tunnel_key: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the probe tunnel if one was opened.
|
||||
fn close_probe_tunnel(ssh: &SshManager, key: Option<&str>) {
|
||||
if let Some(k) = key {
|
||||
ssh.lock().unwrap().close_tunnel(k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a database connection for the given configuration.
|
||||
///
|
||||
/// Dispatches to the appropriate type-specific connection test based on
|
||||
/// `config.db_type`. Returns a `TestConnectionResult` indicating success
|
||||
/// or failure with a sanitized error message.
|
||||
pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
pub async fn test_database_connection(
|
||||
config: &DbConfig,
|
||||
ssh: &SshManager,
|
||||
) -> TestConnectionResult {
|
||||
// Validate input first
|
||||
if let Some(err) = validate_test_input(config) {
|
||||
return TestConnectionResult {
|
||||
@@ -117,10 +216,10 @@ pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult
|
||||
}
|
||||
|
||||
let result = match config.db_type.to_lowercase().as_str() {
|
||||
"postgresql" => test_pg_connection(config).await,
|
||||
"mysql" => test_mysql_connection(config).await,
|
||||
"postgresql" => test_pg_connection(config, ssh).await,
|
||||
"mysql" => test_mysql_connection(config, ssh).await,
|
||||
"sqlite" => test_sqlite_connection(config),
|
||||
"redis" => test_redis_connection(config).await,
|
||||
"redis" => test_redis_connection(config, ssh).await,
|
||||
other => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(format!("unsupported database type: {other}")),
|
||||
@@ -135,78 +234,141 @@ pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult
|
||||
|
||||
/// Test a PostgreSQL connection using `tokio-postgres`.
|
||||
///
|
||||
/// Connects without TLS. The connection handler is spawned and immediately
|
||||
/// dropped after confirming the connection is alive.
|
||||
async fn test_pg_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use tokio_postgres::NoTls;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(5432) as u16;
|
||||
/// Connects through an SSH tunnel when configured, with TLS selected from
|
||||
/// `ssl_mode` (downgraded to encrypt-only through a tunnel). The connection
|
||||
/// handler is spawned and immediately dropped after confirming the
|
||||
/// connection is alive; any probe tunnel is closed in every path.
|
||||
async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||
let user = config.username.as_deref().unwrap_or("postgres");
|
||||
let dbname = config.database.as_deref().unwrap_or("postgres");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
|
||||
// Use a postgres URL rather than libpq key=value format: tokio-postgres
|
||||
// parses URLs reliably and urlencoding handles special chars safely.
|
||||
use urlencoding::encode as enc;
|
||||
let conn_str = format!(
|
||||
"postgresql://{}:{}@{}:{}/{}?connect_timeout=10",
|
||||
enc(user),
|
||||
enc(password),
|
||||
host,
|
||||
port,
|
||||
enc(dbname),
|
||||
);
|
||||
let target = match resolve_connect_target(config, ssh, 5432).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
||||
};
|
||||
|
||||
match tokio_postgres::connect(&conn_str, NoTls).await {
|
||||
Ok((_client, connection)) => {
|
||||
// Spawn the connection handler so it keeps running while we test
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
eprintln!("connection error: {}", e);
|
||||
}
|
||||
});
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
let tls = match crate::db::tls::build_tls_config(
|
||||
decision,
|
||||
config.ssl_ca_path.as_deref(),
|
||||
config.ssl_cert_path.as_deref(),
|
||||
config.ssl_key_path.as_deref(),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
return TestConnectionResult { ok: false, error: Some(e) };
|
||||
}
|
||||
};
|
||||
|
||||
// Config builder: user/password/dbname are sent as-is (no URL
|
||||
// percent-encoding needed), and the TLS connector is chosen explicitly.
|
||||
let mut pgconfig = tokio_postgres::Config::new();
|
||||
pgconfig
|
||||
.host(target.host)
|
||||
.port(target.port)
|
||||
.user(user)
|
||||
.password(password)
|
||||
.dbname(dbname)
|
||||
.connect_timeout(std::time::Duration::from_secs(10));
|
||||
|
||||
let result = match tls {
|
||||
None => crate::commands::db_viewer::connect_pg_with(&pgconfig, tokio_postgres::NoTls).await,
|
||||
Some(cc) => {
|
||||
let connector =
|
||||
tokio_postgres_rustls::MakeRustlsConnect::new((*cc).clone());
|
||||
crate::commands::db_viewer::connect_pg_with(&pgconfig, connector).await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok((_client, _handle)) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
// Spawn the connection handler so it keeps running while we test.
|
||||
// (Already spawned inside `connect_pg_with`.)
|
||||
TestConnectionResult { ok: true, error: None }
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a MySQL connection using `sqlx`.
|
||||
///
|
||||
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
|
||||
/// `acquire_timeout`.
|
||||
async fn test_mysql_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
/// `acquire_timeout`, connecting through an SSH tunnel when configured and
|
||||
/// mapping `ssl_mode` onto `MySqlSslMode` (downgraded to encrypt-only
|
||||
/// through a tunnel). Any probe tunnel is closed in every path.
|
||||
async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(3306);
|
||||
let user = config.username.as_deref().unwrap_or("root");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
let dbname = config.database.as_deref().unwrap_or("mysql");
|
||||
let target = match resolve_connect_target(config, ssh, 3306).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
||||
};
|
||||
|
||||
let conn_str = format!(
|
||||
"mysql://{}:{}@{}:{}/{}",
|
||||
user, password, host, port, dbname
|
||||
let mut opts = MySqlConnectOptions::new()
|
||||
.host(&target.host)
|
||||
.port(target.port)
|
||||
.username(config.username.as_deref().unwrap_or("root"))
|
||||
.password(config.password.as_deref().unwrap_or(""))
|
||||
.database(config.database.as_deref().unwrap_or("mysql"));
|
||||
|
||||
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
|
||||
// degrade to encrypt-only `require`. Direct connections honor the mode.
|
||||
let decision = crate::commands::ssh::effective_tls_decision(
|
||||
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
|
||||
target.via_tunnel,
|
||||
);
|
||||
match decision {
|
||||
crate::db::tls::TlsDecision::Disable => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Disabled);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Require => {
|
||||
opts = opts.ssl_mode(MySqlSslMode::Required);
|
||||
}
|
||||
crate::db::tls::TlsDecision::Verify => {
|
||||
// sqlx 0.8 has no VerifyFull: verify-ca -> VerifyCa (chain only),
|
||||
// verify-full -> VerifyIdentity (chain + hostname).
|
||||
match config.ssl_mode.as_deref() {
|
||||
Some("verify-ca") => opts = opts.ssl_mode(MySqlSslMode::VerifyCa),
|
||||
_ => opts = opts.ssl_mode(MySqlSslMode::VerifyIdentity),
|
||||
}
|
||||
if let Some(ca) = config.ssl_ca_path.as_deref() {
|
||||
opts = opts.ssl_ca(ca);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match MySqlPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect(&conn_str)
|
||||
.connect_with(opts)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
pool.close().await;
|
||||
TestConnectionResult { ok: true, error: None }
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,18 +389,21 @@ fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
/// Test a Redis connection using the `redis` crate.
|
||||
///
|
||||
/// Uses `redis::Client::open` followed by `get_async_connection` with a
|
||||
/// 10-second timeout via `tokio::time::timeout`.
|
||||
async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
/// 10-second timeout via `tokio::time::timeout`. Connects through an SSH
|
||||
/// tunnel when configured; any probe tunnel is closed in every path.
|
||||
async fn test_redis_connection(config: &DbConfig, ssh: &SshManager) -> TestConnectionResult {
|
||||
use tokio::time::timeout;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(6379);
|
||||
let target = match resolve_connect_target(config, ssh, 6379).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => return TestConnectionResult { ok: false, error: Some(e) },
|
||||
};
|
||||
let password = config.password.as_deref();
|
||||
|
||||
let conn_str = if let Some(pwd) = password {
|
||||
format!("redis://:{}@{}:{}/", pwd, host, port)
|
||||
format!("redis://:{}@{}:{}/", pwd, target.host, target.port)
|
||||
} else {
|
||||
format!("redis://{}:{}/", host, port)
|
||||
format!("redis://{}:{}/", target.host, target.port)
|
||||
};
|
||||
|
||||
match redis::Client::open(conn_str.as_str()) {
|
||||
@@ -249,30 +414,46 @@ async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_conn)) => TestConnectionResult { ok: true, error: None },
|
||||
Ok(Err(e)) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
Err(_) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection timed out after 10 seconds".to_string()),
|
||||
},
|
||||
Ok(Ok(_conn)) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult { ok: true, error: None }
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection timed out after 10 seconds".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
close_probe_tunnel(ssh, target.tunnel_key.as_deref());
|
||||
TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to test a database connection.
|
||||
///
|
||||
/// Calls `test_database_connection` and returns the result.
|
||||
/// Calls `test_database_connection` and returns the result. `state` is
|
||||
/// auto-injected; the frontend only passes `config`.
|
||||
#[tauri::command]
|
||||
pub async fn test_connection(config: DbConfig) -> Result<TestConnectionResult, String> {
|
||||
Ok(test_database_connection(&config).await)
|
||||
pub async fn test_connection(
|
||||
config: DbConfig,
|
||||
state: State<'_, crate::AppState>,
|
||||
) -> Result<TestConnectionResult, String> {
|
||||
Ok(test_database_connection(&config, &state.ssh_manager).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -313,6 +494,22 @@ mod tests {
|
||||
assert!(!sanitized.contains("user="), "should remove user= pattern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_error_redacts_tunnel_style_urls() {
|
||||
// Tunnel connect errors can carry a URL with embedded credentials, e.g.
|
||||
// the redis:// string built for SSH-tunneled connections.
|
||||
let msg = "SSH tunnel connect failed: redis://:hunter2@127.0.0.1:6379/";
|
||||
let sanitized = sanitize_error(msg);
|
||||
assert!(
|
||||
!sanitized.contains("hunter2"),
|
||||
"must redact URL password: {sanitized}"
|
||||
);
|
||||
assert!(
|
||||
sanitized.contains("SSH tunnel connect failed"),
|
||||
"must keep the diagnostic prefix: {sanitized}"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// validate_test_input rejection
|
||||
// ------------------------------------------------------------------
|
||||
@@ -324,13 +521,7 @@ mod tests {
|
||||
db_type: "mongodb".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(27017),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
@@ -342,13 +533,7 @@ mod tests {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "".to_string(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
@@ -360,13 +545,7 @@ mod tests {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(0),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
@@ -385,12 +564,8 @@ mod tests {
|
||||
host: "localhost".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("user".to_string()),
|
||||
password: None,
|
||||
database: Some("mydb".to_string()),
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
@@ -405,13 +580,7 @@ mod tests {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
@@ -423,13 +592,7 @@ mod tests {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: Some(9999),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod pool;
|
||||
pub mod introspection;
|
||||
pub mod tls;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
+131
-7
@@ -5,7 +5,7 @@ use std::time::Instant;
|
||||
///
|
||||
/// Fields map to connection parameters. For SQLite, `host` stores the
|
||||
/// file path and `port` is always `None`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DbConfig {
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
@@ -17,6 +17,20 @@ pub struct DbConfig {
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_host: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_port: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub ssh_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_auth_method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl DbConfig {
|
||||
@@ -35,8 +49,35 @@ impl DbConfig {
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_password: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set.
|
||||
pub fn ssh_config(&self) -> Option<crate::models::SshConfig> {
|
||||
let host = self.ssh_host.clone()?;
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::models::SshConfig {
|
||||
host,
|
||||
port: self.ssh_port.unwrap_or(22) as u16,
|
||||
user: self.ssh_user.clone().unwrap_or_default(),
|
||||
auth_method: self
|
||||
.ssh_auth_method
|
||||
.clone()
|
||||
.unwrap_or_else(|| "password".to_string()),
|
||||
password: self.ssh_password.clone(),
|
||||
private_key_path: self.ssh_private_key_path.clone(),
|
||||
passphrase: self.ssh_passphrase.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to an active database connection.
|
||||
@@ -74,6 +115,10 @@ pub(crate) struct DbPoolEntry {
|
||||
pub struct ConnectionPoolManager {
|
||||
pools: indexmap::IndexMap<String, DbPoolEntry>,
|
||||
max_pools: usize,
|
||||
/// Invoked with the id of every pool that gets evicted (LRU overflow in
|
||||
/// `register` or shrinkage in `set_max_pools`). Lets callers free
|
||||
/// associated resources (e.g. SSH tunnels).
|
||||
on_evict: Option<Box<dyn Fn(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ConnectionPoolManager {
|
||||
@@ -82,9 +127,15 @@ impl ConnectionPoolManager {
|
||||
Self {
|
||||
pools: indexmap::IndexMap::new(),
|
||||
max_pools: 5,
|
||||
on_evict: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback invoked with the id of every evicted pool.
|
||||
pub fn set_on_evict(&mut self, cb: Box<dyn Fn(&str) + Send + Sync>) {
|
||||
self.on_evict = Some(cb);
|
||||
}
|
||||
|
||||
/// Set the maximum number of pools before LRU eviction kicks in.
|
||||
///
|
||||
/// If the current pool count exceeds the new maximum, the oldest
|
||||
@@ -92,7 +143,11 @@ impl ConnectionPoolManager {
|
||||
pub fn set_max_pools(&mut self, max: usize) {
|
||||
self.max_pools = max;
|
||||
while self.pools.len() > self.max_pools {
|
||||
self.pools.shift_remove_index(0);
|
||||
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||
if let Some(cb) = &self.on_evict {
|
||||
cb(&evicted_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +169,11 @@ impl ConnectionPoolManager {
|
||||
|
||||
// LRU eviction: remove oldest (front) entries until within capacity
|
||||
while self.pools.len() > self.max_pools {
|
||||
self.pools.shift_remove_index(0);
|
||||
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||
if let Some(cb) = &self.on_evict {
|
||||
cb(&evicted_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +225,7 @@ mod tests {
|
||||
username: Some("admin".into()),
|
||||
password: Some("secret".into()),
|
||||
database: Some("mydb".into()),
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(cfg.db_type, "PostgreSQL");
|
||||
@@ -192,6 +248,33 @@ mod tests {
|
||||
assert!(cfg.database.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_ssh_config_is_none_when_no_host() {
|
||||
let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "h".into(), port: Some(5432),
|
||||
username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None,
|
||||
ssl_cert_path: None, ssl_key_path: None, ssh_host: None, ssh_port: None, ssh_user: None,
|
||||
ssh_auth_method: None, ssh_password: None, ssh_private_key_path: None, ssh_passphrase: None,
|
||||
};
|
||||
assert!(cfg.ssh_config().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_ssh_config_builds_from_flat_fields() {
|
||||
let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "db".into(), port: Some(5432),
|
||||
username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None,
|
||||
ssl_cert_path: None, ssl_key_path: None,
|
||||
ssh_host: Some("jump".into()), ssh_port: Some(2222), ssh_user: Some("u".into()),
|
||||
ssh_auth_method: Some("password".into()), ssh_password: Some("pw".into()),
|
||||
ssh_private_key_path: None, ssh_passphrase: None,
|
||||
};
|
||||
let s = cfg.ssh_config().expect("ssh config present");
|
||||
assert_eq!(s.host, "jump");
|
||||
assert_eq!(s.port, 2222);
|
||||
assert_eq!(s.user, "u");
|
||||
assert_eq!(s.auth_method, "password");
|
||||
assert_eq!(s.password.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ConnectionPoolManager tests
|
||||
// ------------------------------------------------------------------
|
||||
@@ -261,4 +344,45 @@ mod tests {
|
||||
assert!(manager.contains("c"));
|
||||
assert!(manager.contains("d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_invokes_on_evict_with_evicted_id() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(1);
|
||||
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||
let evicted_cb = evicted.clone();
|
||||
manager.set_on_evict(Box::new(move |id: &str| {
|
||||
evicted_cb.lock().unwrap().push(id.to_string());
|
||||
}));
|
||||
manager.register(
|
||||
"a",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
manager.register(
|
||||
"b",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_invokes_on_evict_on_max_pools_shrink() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||
let evicted_cb = evicted.clone();
|
||||
manager.set_on_evict(Box::new(move |id: &str| {
|
||||
evicted_cb.lock().unwrap().push(id.to_string());
|
||||
}));
|
||||
manager.register(
|
||||
"a",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
manager.register(
|
||||
"b",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
// Shrinking max_pools below the current count evicts oldest first.
|
||||
manager.set_max_pools(1);
|
||||
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! TLS connector factory for tokio-postgres.
|
||||
//!
|
||||
//! Maps the user-facing SSL modes to rustls `ClientConfig` values:
|
||||
//! - `disable` -> no TLS (returns `None`)
|
||||
//! - `require` -> encrypt without verifying the server certificate (custom `NoVerifier`)
|
||||
//! - `verify-ca` / `verify-full` -> standard rustls webpki verification (chain AND
|
||||
//! hostname; `verify-ca` is intentionally identical to `verify-full` in v1)
|
||||
//!
|
||||
//! Client certificates are supported via optional `cert_path` / `key_path` pair.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TlsDecision {
|
||||
Disable,
|
||||
Require,
|
||||
Verify,
|
||||
}
|
||||
|
||||
pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
|
||||
match ssl_mode {
|
||||
Some("require") => TlsDecision::Require,
|
||||
Some("verify-ca") | Some("verify-full") => TlsDecision::Verify,
|
||||
_ => TlsDecision::Disable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
|
||||
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||
pub fn build_tls_config(
|
||||
decision: TlsDecision,
|
||||
ca_path: Option<&str>,
|
||||
cert_path: Option<&str>,
|
||||
key_path: Option<&str>,
|
||||
) -> Result<Option<Arc<ClientConfig>>, String> {
|
||||
if matches!(decision, TlsDecision::Disable) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let client_auth = match (cert_path, key_path) {
|
||||
(Some(c), Some(k)) => Some(load_client_identity(c, k)?),
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
return Err("both ssl_cert_path and ssl_key_path must be set for client auth".into())
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
let config = match decision {
|
||||
TlsDecision::Require => {
|
||||
// Encrypt without verifying the server certificate.
|
||||
let builder = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoVerifier));
|
||||
match client_auth {
|
||||
Some((certs, key)) => builder
|
||||
.with_client_auth_cert(certs, key)
|
||||
.map_err(|e| format!("client cert: {e}"))?,
|
||||
None => builder.with_no_client_auth(),
|
||||
}
|
||||
}
|
||||
TlsDecision::Verify => {
|
||||
let mut roots = RootCertStore::empty();
|
||||
if let Some(ca) = ca_path {
|
||||
add_ca_file(&mut roots, ca)?;
|
||||
} else {
|
||||
return Err("ssl_ca_path is required for verify-ca / verify-full".into());
|
||||
}
|
||||
for ta in rustls_native_certs::load_native_certs()
|
||||
.map_err(|e| format!("native certs: {e}"))?
|
||||
{
|
||||
let _ = roots.add(ta);
|
||||
}
|
||||
let builder = ClientConfig::builder().with_root_certificates(roots);
|
||||
match client_auth {
|
||||
Some((certs, key)) => builder
|
||||
.with_client_auth_cert(certs, key)
|
||||
.map_err(|e| format!("client cert: {e}"))?,
|
||||
None => builder.with_no_client_auth(),
|
||||
}
|
||||
}
|
||||
TlsDecision::Disable => unreachable!(),
|
||||
};
|
||||
Ok(Some(Arc::new(config)))
|
||||
}
|
||||
|
||||
fn add_ca_file(roots: &mut RootCertStore, path: &str) -> Result<(), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("failed to read CA file {path}: {e}"))?;
|
||||
let mut reader = std::io::BufReader::new(bytes.as_slice());
|
||||
let parsed = rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("parse CA: {e}"))?;
|
||||
let added = parsed.into_iter().filter_map(|c| roots.add(c).ok()).count();
|
||||
if added == 0 {
|
||||
return Err("no usable CA certificates found".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_client_identity(
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), String> {
|
||||
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so reject them up
|
||||
// front with a clear message before touching the certificate file.
|
||||
let kb = std::fs::read(key_path).map_err(|e| format!("read key: {e}"))?;
|
||||
if String::from_utf8_lossy(&kb).contains("ENCRYPTED PRIVATE KEY") {
|
||||
return Err(
|
||||
"encrypted client keys are not supported in v1; use an unencrypted PEM key".into(),
|
||||
);
|
||||
}
|
||||
let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?;
|
||||
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(
|
||||
cb.as_slice(),
|
||||
))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("parse cert: {e}"))?
|
||||
.into_iter()
|
||||
.map(|c| c.into_owned())
|
||||
.collect();
|
||||
if certs.is_empty() {
|
||||
return Err("no client certificates parsed".into());
|
||||
}
|
||||
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(kb.as_slice()))
|
||||
.map_err(|e| format!("parse key: {e}"))?
|
||||
.ok_or_else(|| "no private key parsed".to_string())?
|
||||
.clone_key();
|
||||
Ok((certs, key))
|
||||
}
|
||||
|
||||
/// Accepts every certificate: TLS encryption without authentication (`require` mode).
|
||||
#[derive(Debug)]
|
||||
struct NoVerifier;
|
||||
|
||||
impl ServerCertVerifier for NoVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_ee: &CertificateDer<'_>,
|
||||
_ic: &[CertificateDer<'_>],
|
||||
_n: &ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tls_decision_maps_modes() {
|
||||
assert!(matches!(tls_decision(None), TlsDecision::Disable));
|
||||
assert!(matches!(tls_decision(Some("disable")), TlsDecision::Disable));
|
||||
assert!(matches!(tls_decision(Some("require")), TlsDecision::Require));
|
||||
assert!(matches!(tls_decision(Some("verify-ca")), TlsDecision::Verify));
|
||||
assert!(matches!(tls_decision(Some("verify-full")), TlsDecision::Verify));
|
||||
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_disable_returns_none() {
|
||||
assert!(
|
||||
build_tls_config(TlsDecision::Disable, None, None, None)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_require_returns_some_without_files() {
|
||||
assert!(
|
||||
build_tls_config(TlsDecision::Require, None, None, None)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_verify_missing_ca_errors() {
|
||||
let err = build_tls_config(TlsDecision::Verify, Some("/nonexistent/ca.pem"), None, None)
|
||||
.unwrap_err();
|
||||
assert!(err.to_lowercase().contains("ca"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_client_cert_missing_key_errors() {
|
||||
// cert set without key
|
||||
let err = build_tls_config(TlsDecision::Require, None, Some("/nonexistent/cert.pem"), None)
|
||||
.unwrap_err();
|
||||
assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_rejects_encrypted_key_marker() {
|
||||
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so an ENCRYPTED
|
||||
// PRIVATE KEY header must be rejected with a clear error. The cert file
|
||||
// is a dummy: the key check fires before the cert is read.
|
||||
let dir = std::env::temp_dir();
|
||||
let cert_path = dir.join("gl_tls_cert_dummy.pem");
|
||||
let key_path = dir.join("gl_tls_enc_key.pem");
|
||||
std::fs::write(
|
||||
&cert_path,
|
||||
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&key_path,
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n",
|
||||
)
|
||||
.unwrap();
|
||||
let r = build_tls_config(
|
||||
TlsDecision::Require,
|
||||
None,
|
||||
Some(cert_path.to_str().unwrap()),
|
||||
Some(key_path.to_str().unwrap()),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert!(
|
||||
r.unwrap_err().to_lowercase().contains("encrypt"),
|
||||
"must mention encryption"
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
-5
@@ -7,10 +7,10 @@ mod models;
|
||||
mod store;
|
||||
mod commands;
|
||||
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use tauri::Manager;
|
||||
use store::Store;
|
||||
use commands::ssh::SshTunnelManager;
|
||||
use commands::ssh::{Ssh2Backend, SshTunnelManager};
|
||||
use db::pool::ConnectionPoolManager;
|
||||
|
||||
pub struct AppState {
|
||||
@@ -29,6 +29,10 @@ fn greet(name: &str) -> String {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Install the ring crypto provider so rustls `ClientConfig::builder()` works (no-op if
|
||||
// another provider is already installed).
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let store = Store::open("gridline.db").expect("failed to open db");
|
||||
let store_ref = StdMutex::new(store);
|
||||
|
||||
@@ -40,7 +44,7 @@ pub fn run() {
|
||||
.manage(AppState {
|
||||
db_store: store_ref,
|
||||
pool_manager: tokio::sync::Mutex::new(ConnectionPoolManager::new()),
|
||||
ssh_manager: StdMutex::new(SshTunnelManager::new()),
|
||||
ssh_manager: StdMutex::new(SshTunnelManager::new(Arc::new(Ssh2Backend))),
|
||||
})
|
||||
.setup(move |app| {
|
||||
let state = app.state::<AppState>();
|
||||
@@ -49,6 +53,22 @@ pub fn run() {
|
||||
eprintln!("Failed to set up demo DB: {e}");
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Close the SSH tunnel for a connection when its pool is evicted
|
||||
// (LRU overflow or max-pool shrink). The hook captures a clone of
|
||||
// the app handle and resolves AppState through the manager.
|
||||
let handle = app.handle().clone();
|
||||
state
|
||||
.pool_manager
|
||||
.blocking_lock()
|
||||
.set_on_evict(Box::new(move |id: &str| {
|
||||
if let Some(s) = handle.try_state::<AppState>() {
|
||||
if let Ok(mut mgr) = s.ssh_manager.lock() {
|
||||
mgr.close_tunnel(id);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -80,6 +100,7 @@ pub fn run() {
|
||||
db_viewer::get_table_data,
|
||||
db_viewer::get_fk_preview,
|
||||
db_viewer::execute_change,
|
||||
db_viewer::get_table_ddl,
|
||||
db_viewer::refresh_connection,
|
||||
db_viewer::get_functions,
|
||||
db_viewer::get_triggers,
|
||||
@@ -89,6 +110,12 @@ pub fn run() {
|
||||
keychain::save_connection_password,
|
||||
keychain::get_connection_password,
|
||||
keychain::delete_connection_password,
|
||||
keychain::save_connection_ssh_password,
|
||||
keychain::get_connection_ssh_password,
|
||||
keychain::delete_connection_ssh_password,
|
||||
keychain::save_connection_ssh_passphrase,
|
||||
keychain::get_connection_ssh_passphrase,
|
||||
keychain::delete_connection_ssh_passphrase,
|
||||
demo::recreate_demo_db,
|
||||
backup::detect_pg_tools,
|
||||
backup::pg_dump,
|
||||
@@ -104,6 +131,18 @@ pub fn run() {
|
||||
query::update_saved_query,
|
||||
query::delete_saved_query,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
// Close all SSH tunnels on exit: ExitRequested fires before the
|
||||
// event loop ends, Exit fires after it has.
|
||||
if matches!(
|
||||
event,
|
||||
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
|
||||
) {
|
||||
if let Ok(mut mgr) = app_handle.state::<AppState>().ssh_manager.lock() {
|
||||
mgr.close_all();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ pub struct ConnectionInput {
|
||||
pub ssh_user: Option<String>,
|
||||
pub ssh_auth_method: Option<String>,
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
pub ssh_password: Option<String>,
|
||||
pub ssh_passphrase: Option<String>,
|
||||
pub ssl_mode: Option<String>,
|
||||
pub ssl_ca_path: Option<String>,
|
||||
@@ -71,6 +72,7 @@ mod tests {
|
||||
ssh_user: Some("tunnel".to_string()),
|
||||
ssh_auth_method: Some("Key".to_string()),
|
||||
ssh_private_key_path: Some("/path/to/key".to_string()),
|
||||
ssh_password: Some("ssh-pw".to_string()),
|
||||
ssh_passphrase: Some("passphrase".to_string()),
|
||||
ssl_mode: Some("require".to_string()),
|
||||
ssl_ca_path: Some("/path/to/ca".to_string()),
|
||||
@@ -96,6 +98,7 @@ mod tests {
|
||||
assert_eq!(deserialized.ssh_user, Some("tunnel".to_string()));
|
||||
assert_eq!(deserialized.ssh_auth_method, Some("Key".to_string()));
|
||||
assert_eq!(deserialized.ssh_private_key_path, Some("/path/to/key".to_string()));
|
||||
assert_eq!(deserialized.ssh_password, Some("ssh-pw".to_string()));
|
||||
assert_eq!(deserialized.ssh_passphrase, Some("passphrase".to_string()));
|
||||
assert_eq!(deserialized.ssl_mode, Some("require".to_string()));
|
||||
assert_eq!(deserialized.ssl_ca_path, Some("/path/to/ca".to_string()));
|
||||
|
||||
@@ -135,6 +135,23 @@ pub enum Change {
|
||||
sql: String,
|
||||
rollback_sql: String,
|
||||
},
|
||||
BulkInsert {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
columns: Vec<String>,
|
||||
rows: Vec<Vec<serde_json::Value>>,
|
||||
},
|
||||
DropTable {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
},
|
||||
EmptyTable {
|
||||
id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Change {
|
||||
@@ -143,7 +160,10 @@ impl Change {
|
||||
Change::Update { id, .. }
|
||||
| Change::Insert { id, .. }
|
||||
| Change::Delete { id, .. }
|
||||
| Change::AlterTable { id, .. } => id,
|
||||
| Change::AlterTable { id, .. }
|
||||
| Change::BulkInsert { id, .. }
|
||||
| Change::DropTable { id, .. }
|
||||
| Change::EmptyTable { id, .. } => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +299,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_bulk_insert_roundtrip() {
|
||||
let json = serde_json::json!({
|
||||
"type": "bulk_insert", "id": "x", "schema": "public", "table": "t",
|
||||
"columns": ["a", "b"],
|
||||
"rows": [[1, "y"], [2, "z"]]
|
||||
});
|
||||
let c: Change = serde_json::from_value(json).unwrap();
|
||||
match c {
|
||||
Change::BulkInsert { columns, rows, .. } => {
|
||||
assert_eq!(columns, vec!["a".to_string(), "b".to_string()]);
|
||||
assert_eq!(rows.len(), 2);
|
||||
}
|
||||
_ => panic!("expected BulkInsert"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_drop_and_empty_roundtrip() {
|
||||
let drop: Change = serde_json::from_value(serde_json::json!({
|
||||
"type": "drop_table", "id": "d", "schema": "public", "table": "t"
|
||||
})).unwrap();
|
||||
assert_eq!(drop.id(), "d");
|
||||
let empty: Change = serde_json::from_value(serde_json::json!({
|
||||
"type": "empty_table", "id": "e", "schema": "public", "table": "t"
|
||||
})).unwrap();
|
||||
assert_eq!(empty.id(), "e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_info_fk_ref() {
|
||||
let col = ColumnInfo {
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod backup;
|
||||
pub mod connection;
|
||||
pub mod db_viewer;
|
||||
pub mod folder;
|
||||
pub mod ssh;
|
||||
pub mod tag;
|
||||
pub mod settings;
|
||||
|
||||
@@ -10,4 +11,5 @@ pub use connection::{Connection, ConnectionInput};
|
||||
pub use db_viewer::{Change, ColumnInfo, FilterRule, Pagination, QueryResult, SortRule, TableInfo};
|
||||
pub use folder::{Folder, FolderInput};
|
||||
pub use settings::Settings;
|
||||
pub use ssh::SshConfig;
|
||||
pub use tag::{Tag, TagInput};
|
||||
@@ -13,4 +13,10 @@ pub struct Settings {
|
||||
pub table_page_size: i64,
|
||||
pub shortcuts: HashMap<String, String>,
|
||||
pub accent_color: String,
|
||||
// Editor (Plan A)
|
||||
pub editor_font_size: i64,
|
||||
pub editor_font_family: String,
|
||||
pub editor_word_wrap: String,
|
||||
pub editor_minimap: bool,
|
||||
pub editor_tab_size: i64,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SSH tunnel configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
/// "password" or "key"
|
||||
pub auth_method: String,
|
||||
pub password: Option<String>,
|
||||
pub private_key_path: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl SshConfig {
|
||||
/// Create a new `SshConfig` with the required fields.
|
||||
pub fn new(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
auth_method: String,
|
||||
) -> Self {
|
||||
SshConfig {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth_method,
|
||||
password: None,
|
||||
private_key_path: None,
|
||||
passphrase: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate SSH configuration.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - `host` is not empty
|
||||
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
||||
/// - `user` is not empty
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ const MAX_NAME_LEN: usize = 200;
|
||||
const MAX_FOLDER_LEN: usize = 100;
|
||||
const MAX_QUERY_TEXT_LEN: usize = 1_048_576; // 1 MB
|
||||
|
||||
const ALLOWED_EDITOR_FONTS: &[&str] =
|
||||
&["Space Mono", "Fira Code", "Menlo", "Monaco", "Consolas", "JetBrains Mono", "monospace"];
|
||||
|
||||
impl Store {
|
||||
pub fn from_connection(conn: SqliteConnection) -> Self {
|
||||
Self {
|
||||
@@ -432,6 +435,29 @@ impl Store {
|
||||
default_ports = parsed;
|
||||
}
|
||||
}
|
||||
let editor_font_size = map
|
||||
.get("editor_font_size")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.map(|v| v.clamp(8, 24))
|
||||
.unwrap_or(13);
|
||||
let editor_font_family = map
|
||||
.get("editor_font_family")
|
||||
.cloned()
|
||||
.filter(|v| ALLOWED_EDITOR_FONTS.contains(&v.as_str()))
|
||||
.unwrap_or_else(|| "Space Mono".to_string());
|
||||
let editor_word_wrap = match map.get("editor_word_wrap").map(|v| v.as_str()) {
|
||||
Some("on") => "on".to_string(),
|
||||
_ => "off".to_string(),
|
||||
};
|
||||
let editor_minimap = map
|
||||
.get("editor_minimap")
|
||||
.map(|v| v == "true")
|
||||
.unwrap_or(false);
|
||||
let editor_tab_size = map
|
||||
.get("editor_tab_size")
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.map(|v| v.clamp(2, 8))
|
||||
.unwrap_or(4);
|
||||
Ok(Settings {
|
||||
confirm_before_delete: confirm,
|
||||
default_folder_id,
|
||||
@@ -455,6 +481,11 @@ impl Store {
|
||||
.get("accent_color")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "#2563EB".to_string()),
|
||||
editor_font_size,
|
||||
editor_font_family,
|
||||
editor_word_wrap,
|
||||
editor_minimap,
|
||||
editor_tab_size,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -836,6 +867,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -883,6 +915,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -922,6 +955,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -960,6 +994,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -1022,6 +1057,7 @@ mod tests {
|
||||
ssh_user: Some("tunneluser".into()),
|
||||
ssh_auth_method: Some("Key".into()),
|
||||
ssh_private_key_path: Some("/home/user/.ssh/id_rsa".into()),
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: Some("verify-full".into()),
|
||||
ssl_ca_path: Some("/etc/ssl/certs/ca.pem".into()),
|
||||
@@ -1076,6 +1112,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -1127,6 +1164,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -1176,6 +1214,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -1211,6 +1250,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -1258,6 +1298,7 @@ mod tests {
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_password: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
@@ -1350,4 +1391,45 @@ mod tests {
|
||||
let result = store.save_query(None, "ok", &huge_text, "");
|
||||
assert!(result.is_err(), "Over-size query text should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_settings_defaults_when_unset() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
let store = Store::from_connection(conn);
|
||||
let s = store.get_settings().unwrap();
|
||||
assert_eq!(s.editor_font_size, 13);
|
||||
assert_eq!(s.editor_font_family, "Space Mono");
|
||||
assert_eq!(s.editor_word_wrap, "off");
|
||||
assert!(!s.editor_minimap);
|
||||
assert_eq!(s.editor_tab_size, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_settings_clamp_out_of_range() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
let store = Store::from_connection(conn);
|
||||
store.update_setting("editor_font_size", "999").unwrap();
|
||||
store.update_setting("editor_tab_size", "1").unwrap();
|
||||
store.update_setting("editor_minimap", "true").unwrap();
|
||||
let s = store.get_settings().unwrap();
|
||||
assert_eq!(s.editor_font_size, 24, "font_size clamps to 24");
|
||||
assert_eq!(s.editor_tab_size, 2, "tab_size clamps to 2");
|
||||
assert!(s.editor_minimap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_settings_garbage_and_disallowed_fall_back() {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
let store = Store::from_connection(conn);
|
||||
store.update_setting("editor_font_size", "abc").unwrap();
|
||||
store.update_setting("editor_font_family", "Comic Sans").unwrap();
|
||||
store.update_setting("editor_word_wrap", "weird").unwrap();
|
||||
let s = store.get_settings().unwrap();
|
||||
assert_eq!(s.editor_font_size, 13, "garbage -> default");
|
||||
assert_eq!(s.editor_font_family, "Space Mono", "disallowed font -> default");
|
||||
assert_eq!(s.editor_word_wrap, "off", "invalid wrap -> default");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user