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:
2026-08-02 20:38:23 +08:00
committed by GitHub
parent e32fe7967c
commit e0c0db8352
68 changed files with 3885 additions and 553 deletions
+18 -2
View File
@@ -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,
+519 -23
View File
@@ -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""#
);
}
}
+3
View File
@@ -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,
+2 -1
View File
@@ -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![],
+141
View File
@@ -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");
}
}
+1
View File
@@ -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
View File
@@ -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);
}
}
+277 -114
View File
@@ -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
View File
@@ -1,5 +1,6 @@
pub mod pool;
pub mod introspection;
pub mod tls;
#[allow(unused_imports)]
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
+131 -7
View File
@@ -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()]);
}
}
+252
View File
@@ -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
View File
@@ -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();
}
}
});
}
+3
View File
@@ -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()));
+50 -1
View File
@@ -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
View File
@@ -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};
+6
View File
@@ -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,
}
+44
View File
@@ -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()
}
}
+82
View File
@@ -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");
}
}