Skip to main content

ssh_commander_pg/
config.rs

1//! PostgreSQL connection configuration.
2//!
3//! Mirrors the shape of `SshConfig` / `SftpConfig` so the macOS bridge can
4//! map a `ConnectionProfile` onto a `PgConfig` with the same conventions
5//! used for every other protocol.
6
7use serde::{Deserialize, Serialize};
8
9/// How to authenticate to the Postgres server.
10///
11/// `Keychain` defers credential lookup to the macOS keychain at connect time;
12/// this matches the SSH/SFTP pattern and keeps secrets out of memory until
13/// they are actually required.
14#[derive(Clone, Serialize, Deserialize)]
15pub enum PgAuthMethod {
16    /// Plaintext password supplied directly. Use only for ephemeral test
17    /// connections; production callers should prefer `Keychain`.
18    Password { password: String },
19    /// Resolve the password from the macOS keychain at connect time, using
20    /// the supplied `account` identifier (e.g. `"postgres:profile-id"`).
21    Keychain { account: String },
22}
23
24impl std::fmt::Debug for PgAuthMethod {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            PgAuthMethod::Password { .. } => f
28                .debug_struct("PgAuthMethod::Password")
29                .field("password", &"<redacted>")
30                .finish(),
31            PgAuthMethod::Keychain { account } => f
32                .debug_struct("PgAuthMethod::Keychain")
33                .field("account", account)
34                .finish(),
35        }
36    }
37}
38
39/// TLS posture for the connection.
40///
41/// Modeled after libpq's `sslmode`. The MVP supports the four most useful
42/// values; `allow` is omitted because it negotiates plaintext on failure
43/// and silently weakens security in a way no UI affordance can clarify.
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
45pub enum PgTlsMode {
46    /// Never use TLS.
47    Disable,
48    /// Try TLS, fall back to plaintext on negotiation failure.
49    #[default]
50    Prefer,
51    /// Require TLS, but do not verify the server certificate.
52    Require,
53    /// Require TLS and validate the server certificate against system roots.
54    VerifyFull,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PgConfig {
59    pub host: String,
60    pub port: u16,
61    pub database: String,
62    pub user: String,
63    pub auth: PgAuthMethod,
64    #[serde(default)]
65    pub tls: PgTlsMode,
66    /// Optional application_name reported to the server. Surfaces nicely in
67    /// `pg_stat_activity` so DBAs can identify connections from r-shell.
68    #[serde(default)]
69    pub application_name: Option<String>,
70    /// Connection timeout, seconds. `None` falls back to the driver default.
71    #[serde(default)]
72    pub connect_timeout_secs: Option<u64>,
73    /// Maximum number of connections this profile's pool may open.
74    /// `None` keeps the built-in default. Tighten on managed-DB
75    /// providers with strict `max_connections` quotas.
76    #[serde(default)]
77    pub max_pool_size: Option<u32>,
78    /// How long an idle connection lingers before the eviction loop
79    /// closes it, in seconds. `None` keeps the built-in default.
80    /// Lower values are politer to providers that bill on connection
81    /// hours (RDS, Neon).
82    #[serde(default)]
83    pub idle_timeout_secs: Option<u64>,
84    /// Minimum idle connections to keep alive even past
85    /// `idle_timeout_secs`. `Some(0)` lets a profile fully evacuate
86    /// during inactivity at the cost of a reconnect on next use.
87    #[serde(default)]
88    pub min_idle_connections: Option<u32>,
89}
90
91impl PgConfig {
92    /// Sensible local-development default — useful in tests and the bridge's
93    /// "new connection" flow.
94    pub fn local(database: impl Into<String>, user: impl Into<String>) -> Self {
95        Self {
96            host: "127.0.0.1".to_string(),
97            port: 5432,
98            database: database.into(),
99            user: user.into(),
100            auth: PgAuthMethod::Password {
101                password: String::new(),
102            },
103            tls: PgTlsMode::Disable,
104            application_name: Some("r-shell".to_string()),
105            connect_timeout_secs: Some(10),
106            max_pool_size: None,
107            idle_timeout_secs: None,
108            min_idle_connections: None,
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn local_defaults_disable_tls_and_set_app_name() {
119        let cfg = PgConfig::local("mydb", "alice");
120        assert_eq!(cfg.host, "127.0.0.1");
121        assert_eq!(cfg.port, 5432);
122        assert_eq!(cfg.database, "mydb");
123        assert_eq!(cfg.user, "alice");
124        assert_eq!(cfg.tls, PgTlsMode::Disable);
125        assert_eq!(cfg.application_name.as_deref(), Some("r-shell"));
126    }
127
128    #[test]
129    fn tls_mode_default_is_prefer() {
130        assert_eq!(PgTlsMode::default(), PgTlsMode::Prefer);
131    }
132
133    #[test]
134    fn config_round_trips_through_serde() {
135        let cfg = PgConfig {
136            host: "db.example.com".to_string(),
137            port: 5433,
138            database: "app".to_string(),
139            user: "svc".to_string(),
140            auth: PgAuthMethod::Keychain {
141                account: "postgres:profile-1".to_string(),
142            },
143            tls: PgTlsMode::VerifyFull,
144            application_name: Some("r-shell".to_string()),
145            connect_timeout_secs: Some(15),
146            max_pool_size: Some(10),
147            idle_timeout_secs: Some(120),
148            min_idle_connections: Some(0),
149        };
150        let json = serde_json::to_string(&cfg).expect("serialize");
151        let back: PgConfig = serde_json::from_str(&json).expect("deserialize");
152        assert_eq!(back.host, cfg.host);
153        assert_eq!(back.tls, cfg.tls);
154        assert_eq!(back.max_pool_size, Some(10));
155        assert_eq!(back.idle_timeout_secs, Some(120));
156        assert_eq!(back.min_idle_connections, Some(0));
157    }
158
159    #[test]
160    fn local_defaults_pool_settings_to_none() {
161        // None means "use built-in default". The pool reads these
162        // and substitutes its constants when absent.
163        let cfg = PgConfig::local("db", "u");
164        assert_eq!(cfg.max_pool_size, None);
165        assert_eq!(cfg.idle_timeout_secs, None);
166        assert_eq!(cfg.min_idle_connections, None);
167    }
168
169    #[test]
170    fn debug_redacts_direct_password() {
171        let cfg = PgConfig {
172            auth: PgAuthMethod::Password {
173                password: "super-secret-pg-password".to_string(),
174            },
175            ..PgConfig::local("db", "u")
176        };
177        let rendered = format!("{cfg:?}");
178        assert!(!rendered.contains("super-secret-pg-password"));
179        assert!(rendered.contains("<redacted>"));
180    }
181
182    #[test]
183    fn debug_preserves_keychain_account() {
184        let auth = PgAuthMethod::Keychain {
185            account: "postgres:profile-1".to_string(),
186        };
187        let rendered = format!("{auth:?}");
188        assert!(rendered.contains("postgres:profile-1"));
189    }
190}