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