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:
@@ -1,5 +1,6 @@
|
||||
pub mod pool;
|
||||
pub mod introspection;
|
||||
pub mod tls;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use pool::{ConnectionPoolManager, DbConfig, DbHandle};
|
||||
+131
-7
@@ -5,7 +5,7 @@ use std::time::Instant;
|
||||
///
|
||||
/// Fields map to connection parameters. For SQLite, `host` stores the
|
||||
/// file path and `port` is always `None`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct DbConfig {
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
@@ -17,6 +17,20 @@ pub struct DbConfig {
|
||||
pub ssl_ca_path: Option<String>,
|
||||
pub ssl_cert_path: Option<String>,
|
||||
pub ssl_key_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_host: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_port: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub ssh_user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_auth_method: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_private_key_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssh_passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl DbConfig {
|
||||
@@ -35,8 +49,35 @@ impl DbConfig {
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_password: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an `SshConfig` from the flat SSH fields, or `None` if no SSH host is set.
|
||||
pub fn ssh_config(&self) -> Option<crate::models::SshConfig> {
|
||||
let host = self.ssh_host.clone()?;
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::models::SshConfig {
|
||||
host,
|
||||
port: self.ssh_port.unwrap_or(22) as u16,
|
||||
user: self.ssh_user.clone().unwrap_or_default(),
|
||||
auth_method: self
|
||||
.ssh_auth_method
|
||||
.clone()
|
||||
.unwrap_or_else(|| "password".to_string()),
|
||||
password: self.ssh_password.clone(),
|
||||
private_key_path: self.ssh_private_key_path.clone(),
|
||||
passphrase: self.ssh_passphrase.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to an active database connection.
|
||||
@@ -74,6 +115,10 @@ pub(crate) struct DbPoolEntry {
|
||||
pub struct ConnectionPoolManager {
|
||||
pools: indexmap::IndexMap<String, DbPoolEntry>,
|
||||
max_pools: usize,
|
||||
/// Invoked with the id of every pool that gets evicted (LRU overflow in
|
||||
/// `register` or shrinkage in `set_max_pools`). Lets callers free
|
||||
/// associated resources (e.g. SSH tunnels).
|
||||
on_evict: Option<Box<dyn Fn(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ConnectionPoolManager {
|
||||
@@ -82,9 +127,15 @@ impl ConnectionPoolManager {
|
||||
Self {
|
||||
pools: indexmap::IndexMap::new(),
|
||||
max_pools: 5,
|
||||
on_evict: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback invoked with the id of every evicted pool.
|
||||
pub fn set_on_evict(&mut self, cb: Box<dyn Fn(&str) + Send + Sync>) {
|
||||
self.on_evict = Some(cb);
|
||||
}
|
||||
|
||||
/// Set the maximum number of pools before LRU eviction kicks in.
|
||||
///
|
||||
/// If the current pool count exceeds the new maximum, the oldest
|
||||
@@ -92,7 +143,11 @@ impl ConnectionPoolManager {
|
||||
pub fn set_max_pools(&mut self, max: usize) {
|
||||
self.max_pools = max;
|
||||
while self.pools.len() > self.max_pools {
|
||||
self.pools.shift_remove_index(0);
|
||||
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||
if let Some(cb) = &self.on_evict {
|
||||
cb(&evicted_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +169,11 @@ impl ConnectionPoolManager {
|
||||
|
||||
// LRU eviction: remove oldest (front) entries until within capacity
|
||||
while self.pools.len() > self.max_pools {
|
||||
self.pools.shift_remove_index(0);
|
||||
if let Some((evicted_id, _)) = self.pools.shift_remove_index(0) {
|
||||
if let Some(cb) = &self.on_evict {
|
||||
cb(&evicted_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +225,7 @@ mod tests {
|
||||
username: Some("admin".into()),
|
||||
password: Some("secret".into()),
|
||||
database: Some("mydb".into()),
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(cfg.db_type, "PostgreSQL");
|
||||
@@ -192,6 +248,33 @@ mod tests {
|
||||
assert!(cfg.database.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_ssh_config_is_none_when_no_host() {
|
||||
let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "h".into(), port: Some(5432),
|
||||
username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None,
|
||||
ssl_cert_path: None, ssl_key_path: None, ssh_host: None, ssh_port: None, ssh_user: None,
|
||||
ssh_auth_method: None, ssh_password: None, ssh_private_key_path: None, ssh_passphrase: None,
|
||||
};
|
||||
assert!(cfg.ssh_config().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_config_ssh_config_builds_from_flat_fields() {
|
||||
let cfg = DbConfig { db_type: "PostgreSQL".into(), host: "db".into(), port: Some(5432),
|
||||
username: None, password: None, database: None, ssl_mode: None, ssl_ca_path: None,
|
||||
ssl_cert_path: None, ssl_key_path: None,
|
||||
ssh_host: Some("jump".into()), ssh_port: Some(2222), ssh_user: Some("u".into()),
|
||||
ssh_auth_method: Some("password".into()), ssh_password: Some("pw".into()),
|
||||
ssh_private_key_path: None, ssh_passphrase: None,
|
||||
};
|
||||
let s = cfg.ssh_config().expect("ssh config present");
|
||||
assert_eq!(s.host, "jump");
|
||||
assert_eq!(s.port, 2222);
|
||||
assert_eq!(s.user, "u");
|
||||
assert_eq!(s.auth_method, "password");
|
||||
assert_eq!(s.password.as_deref(), Some("pw"));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ConnectionPoolManager tests
|
||||
// ------------------------------------------------------------------
|
||||
@@ -261,4 +344,45 @@ mod tests {
|
||||
assert!(manager.contains("c"));
|
||||
assert!(manager.contains("d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_invokes_on_evict_with_evicted_id() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
manager.set_max_pools(1);
|
||||
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||
let evicted_cb = evicted.clone();
|
||||
manager.set_on_evict(Box::new(move |id: &str| {
|
||||
evicted_cb.lock().unwrap().push(id.to_string());
|
||||
}));
|
||||
manager.register(
|
||||
"a",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
manager.register(
|
||||
"b",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_invokes_on_evict_on_max_pools_shrink() {
|
||||
let mut manager = ConnectionPoolManager::new();
|
||||
let evicted: std::sync::Arc<std::sync::Mutex<Vec<String>>> = std::sync::Arc::default();
|
||||
let evicted_cb = evicted.clone();
|
||||
manager.set_on_evict(Box::new(move |id: &str| {
|
||||
evicted_cb.lock().unwrap().push(id.to_string());
|
||||
}));
|
||||
manager.register(
|
||||
"a",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
manager.register(
|
||||
"b",
|
||||
DbHandle::Sqlite(rusqlite::Connection::open_in_memory().unwrap()),
|
||||
);
|
||||
// Shrinking max_pools below the current count evicts oldest first.
|
||||
manager.set_max_pools(1);
|
||||
assert_eq!(evicted.lock().unwrap().as_slice(), ["a".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! TLS connector factory for tokio-postgres.
|
||||
//!
|
||||
//! Maps the user-facing SSL modes to rustls `ClientConfig` values:
|
||||
//! - `disable` -> no TLS (returns `None`)
|
||||
//! - `require` -> encrypt without verifying the server certificate (custom `NoVerifier`)
|
||||
//! - `verify-ca` / `verify-full` -> standard rustls webpki verification (chain AND
|
||||
//! hostname; `verify-ca` is intentionally identical to `verify-full` in v1)
|
||||
//!
|
||||
//! Client certificates are supported via optional `cert_path` / `key_path` pair.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};
|
||||
use rustls::{ClientConfig, DigitallySignedStruct, RootCertStore};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TlsDecision {
|
||||
Disable,
|
||||
Require,
|
||||
Verify,
|
||||
}
|
||||
|
||||
pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
|
||||
match ssl_mode {
|
||||
Some("require") => TlsDecision::Require,
|
||||
Some("verify-ca") | Some("verify-full") => TlsDecision::Verify,
|
||||
_ => TlsDecision::Disable,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
|
||||
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
|
||||
pub fn build_tls_config(
|
||||
decision: TlsDecision,
|
||||
ca_path: Option<&str>,
|
||||
cert_path: Option<&str>,
|
||||
key_path: Option<&str>,
|
||||
) -> Result<Option<Arc<ClientConfig>>, String> {
|
||||
if matches!(decision, TlsDecision::Disable) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let client_auth = match (cert_path, key_path) {
|
||||
(Some(c), Some(k)) => Some(load_client_identity(c, k)?),
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
return Err("both ssl_cert_path and ssl_key_path must be set for client auth".into())
|
||||
}
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
let config = match decision {
|
||||
TlsDecision::Require => {
|
||||
// Encrypt without verifying the server certificate.
|
||||
let builder = ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(NoVerifier));
|
||||
match client_auth {
|
||||
Some((certs, key)) => builder
|
||||
.with_client_auth_cert(certs, key)
|
||||
.map_err(|e| format!("client cert: {e}"))?,
|
||||
None => builder.with_no_client_auth(),
|
||||
}
|
||||
}
|
||||
TlsDecision::Verify => {
|
||||
let mut roots = RootCertStore::empty();
|
||||
if let Some(ca) = ca_path {
|
||||
add_ca_file(&mut roots, ca)?;
|
||||
} else {
|
||||
return Err("ssl_ca_path is required for verify-ca / verify-full".into());
|
||||
}
|
||||
for ta in rustls_native_certs::load_native_certs()
|
||||
.map_err(|e| format!("native certs: {e}"))?
|
||||
{
|
||||
let _ = roots.add(ta);
|
||||
}
|
||||
let builder = ClientConfig::builder().with_root_certificates(roots);
|
||||
match client_auth {
|
||||
Some((certs, key)) => builder
|
||||
.with_client_auth_cert(certs, key)
|
||||
.map_err(|e| format!("client cert: {e}"))?,
|
||||
None => builder.with_no_client_auth(),
|
||||
}
|
||||
}
|
||||
TlsDecision::Disable => unreachable!(),
|
||||
};
|
||||
Ok(Some(Arc::new(config)))
|
||||
}
|
||||
|
||||
fn add_ca_file(roots: &mut RootCertStore, path: &str) -> Result<(), String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("failed to read CA file {path}: {e}"))?;
|
||||
let mut reader = std::io::BufReader::new(bytes.as_slice());
|
||||
let parsed = rustls_pemfile::certs(&mut reader)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("parse CA: {e}"))?;
|
||||
let added = parsed.into_iter().filter_map(|c| roots.add(c).ok()).count();
|
||||
if added == 0 {
|
||||
return Err("no usable CA certificates found".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_client_identity(
|
||||
cert_path: &str,
|
||||
key_path: &str,
|
||||
) -> Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>), String> {
|
||||
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so reject them up
|
||||
// front with a clear message before touching the certificate file.
|
||||
let kb = std::fs::read(key_path).map_err(|e| format!("read key: {e}"))?;
|
||||
if String::from_utf8_lossy(&kb).contains("ENCRYPTED PRIVATE KEY") {
|
||||
return Err(
|
||||
"encrypted client keys are not supported in v1; use an unencrypted PEM key".into(),
|
||||
);
|
||||
}
|
||||
let cb = std::fs::read(cert_path).map_err(|e| format!("read cert: {e}"))?;
|
||||
let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut std::io::BufReader::new(
|
||||
cb.as_slice(),
|
||||
))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| format!("parse cert: {e}"))?
|
||||
.into_iter()
|
||||
.map(|c| c.into_owned())
|
||||
.collect();
|
||||
if certs.is_empty() {
|
||||
return Err("no client certificates parsed".into());
|
||||
}
|
||||
let key = rustls_pemfile::private_key(&mut std::io::BufReader::new(kb.as_slice()))
|
||||
.map_err(|e| format!("parse key: {e}"))?
|
||||
.ok_or_else(|| "no private key parsed".to_string())?
|
||||
.clone_key();
|
||||
Ok((certs, key))
|
||||
}
|
||||
|
||||
/// Accepts every certificate: TLS encryption without authentication (`require` mode).
|
||||
#[derive(Debug)]
|
||||
struct NoVerifier;
|
||||
|
||||
impl ServerCertVerifier for NoVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_ee: &CertificateDer<'_>,
|
||||
_ic: &[CertificateDer<'_>],
|
||||
_n: &ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: UnixTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn verify_tls13_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &CertificateDer<'_>,
|
||||
_dss: &DigitallySignedStruct,
|
||||
) -> Result<HandshakeSignatureValid, rustls::Error> {
|
||||
Ok(HandshakeSignatureValid::assertion())
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
rustls::crypto::ring::default_provider()
|
||||
.signature_verification_algorithms
|
||||
.supported_schemes()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tls_decision_maps_modes() {
|
||||
assert!(matches!(tls_decision(None), TlsDecision::Disable));
|
||||
assert!(matches!(tls_decision(Some("disable")), TlsDecision::Disable));
|
||||
assert!(matches!(tls_decision(Some("require")), TlsDecision::Require));
|
||||
assert!(matches!(tls_decision(Some("verify-ca")), TlsDecision::Verify));
|
||||
assert!(matches!(tls_decision(Some("verify-full")), TlsDecision::Verify));
|
||||
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_disable_returns_none() {
|
||||
assert!(
|
||||
build_tls_config(TlsDecision::Disable, None, None, None)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_require_returns_some_without_files() {
|
||||
assert!(
|
||||
build_tls_config(TlsDecision::Require, None, None, None)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_verify_missing_ca_errors() {
|
||||
let err = build_tls_config(TlsDecision::Verify, Some("/nonexistent/ca.pem"), None, None)
|
||||
.unwrap_err();
|
||||
assert!(err.to_lowercase().contains("ca"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_client_cert_missing_key_errors() {
|
||||
// cert set without key
|
||||
let err = build_tls_config(TlsDecision::Require, None, Some("/nonexistent/cert.pem"), None)
|
||||
.unwrap_err();
|
||||
assert!(err.to_lowercase().contains("cert") || err.to_lowercase().contains("key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tls_rejects_encrypted_key_marker() {
|
||||
// rustls-pemfile cannot decrypt PKCS#8-encrypted keys, so an ENCRYPTED
|
||||
// PRIVATE KEY header must be rejected with a clear error. The cert file
|
||||
// is a dummy: the key check fires before the cert is read.
|
||||
let dir = std::env::temp_dir();
|
||||
let cert_path = dir.join("gl_tls_cert_dummy.pem");
|
||||
let key_path = dir.join("gl_tls_enc_key.pem");
|
||||
std::fs::write(
|
||||
&cert_path,
|
||||
"-----BEGIN CERTIFICATE-----\nZmFrZQ==\n-----END CERTIFICATE-----\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
&key_path,
|
||||
"-----BEGIN ENCRYPTED PRIVATE KEY-----\nabc\n-----END ENCRYPTED PRIVATE KEY-----\n",
|
||||
)
|
||||
.unwrap();
|
||||
let r = build_tls_config(
|
||||
TlsDecision::Require,
|
||||
None,
|
||||
Some(cert_path.to_str().unwrap()),
|
||||
Some(key_path.to_str().unwrap()),
|
||||
);
|
||||
assert!(r.is_err());
|
||||
assert!(
|
||||
r.unwrap_err().to_lowercase().contains("encrypt"),
|
||||
"must mention encryption"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user