Skip to main content

ferrule_core/
resolver.rs

1//! Connection resolution — URL, credentials, proxy, SSH config.
2//!
3//! This module lives in `ferrule-core` so that non-CLI consumers
4//! (daemons, REPLs, library embedders) can resolve connections the
5//! same way the CLI does, without depending on `clap` or interactive
6//! prompts.
7
8use ferrule_sql::{resolve_proxy_from_env, DatabaseUrl, ProxyConfig, SqlError, SshConfig};
9use secrecy::ExposeSecret;
10
11/// Bundled output of connection resolution.
12///
13/// `url` has the resolved password injected (if any) so it remains a
14/// complete connection string — the daemon path serializes this URL
15/// over its socket, and several drivers parse the whole raw URL.
16///
17/// `secret` is the same resolved credential surfaced as a standalone
18/// [`secrecy::SecretString`], so the in-process connect path can hand it to
19/// `ferrule_sql::ConnectOptions::password` rather than relying on the
20/// URL. Credential resolution itself (env var, OS keyring,
21/// interactive prompt) stays here in the CLI/config layer; `ferrule-sql`
22/// only ever receives the already-resolved secret. `secret` is `None`
23/// when no password was resolved.
24///
25/// SSH config and proxy config are plain data — the caller (CLI) still
26/// needs to resolve the actual SSH key source (file vs agent,
27/// passphrase prompt) and set up the tunnel.
28#[derive(Debug, Clone)]
29pub struct ResolvedConnection {
30    pub url: DatabaseUrl,
31    pub secret: Option<secrecy::SecretString>,
32    pub ssh_config: Option<SshConfig>,
33    pub proxy: Option<ProxyConfig>,
34}
35
36/// Resolve a connection string into a [`ResolvedConnection`].
37///
38/// `password` is an explicit password (e.g. from `--password`).
39/// `ssh_config` is already-merged SSH tunnel configuration (host,
40/// port, user, key_path hint).  `proxy_url` is the optional
41/// `--proxy-url` CLI flag.
42pub fn resolve_connection(
43    connection: &str,
44    password: Option<String>,
45    ssh_config: Option<SshConfig>,
46    proxy_url: Option<&str>,
47    global_config: &ferrule_config::profile::GlobalConfig,
48) -> Result<ResolvedConnection, SqlError> {
49    let url = resolve_url(connection, password, global_config)?;
50    let proxy = resolve_proxy_config(connection, proxy_url, global_config, &url)?;
51    // Surface the resolved credential as a standalone secret. It is
52    // exactly the URL's password component after the credential stack
53    // ran, so the in-process connect path (which threads it through
54    // `ConnectOptions::password`) and the daemon path (which only sees
55    // the serialized URL) authenticate identically.
56    let secret = url.password();
57    Ok(ResolvedConnection {
58        url,
59        secret,
60        ssh_config,
61        proxy,
62    })
63}
64
65/// Resolve just the URL (and credential stack) without touching SSH
66/// or proxy.
67fn resolve_url(
68    connection: &str,
69    password: Option<String>,
70    global_config: &ferrule_config::profile::GlobalConfig,
71) -> Result<DatabaseUrl, SqlError> {
72    match DatabaseUrl::parse(connection) {
73        Ok(mut url) => {
74            if let Some(pwd) = password {
75                url.set_password(Some(&pwd));
76            }
77            Ok(url)
78        }
79        Err(_) => {
80            // 1. Try profile (from .ferrule.toml)
81            if let Some(profile) = global_config.connection.get(connection) {
82                let mut url = DatabaseUrl::parse(&profile.url).map_err(|e| {
83                    SqlError::InvalidUrl(format!(
84                        "Invalid URL in profile for '{}': {}",
85                        connection, e
86                    ))
87                })?;
88                let resolved = ferrule_config::credentials::resolve_password_stack(
89                    connection,
90                    password.map(|p| secrecy::SecretString::new(p.into())),
91                    profile.password_url.as_deref(),
92                )
93                .map_err(|e| SqlError::RegistryError(e.to_string()))?;
94                if let Some(pwd) = resolved {
95                    url.set_password(Some(pwd.expose_secret()));
96                }
97                return Ok(url);
98            }
99
100            // 2. Fall back to registry (connections.toml)
101            let registry = ferrule_config::registry::ConnectionRegistry::load_default()
102                .map_err(|e| SqlError::RegistryError(e.to_string()))?;
103            let entry = registry.get(connection).ok_or_else(|| {
104                SqlError::InvalidUrl(format!(
105                    "Connection '{}' is not a valid URL and not found in registry or profile.",
106                    connection
107                ))
108            })?;
109            let mut url = DatabaseUrl::parse(&entry.url).map_err(|e| {
110                SqlError::InvalidUrl(format!(
111                    "Invalid URL in registry for '{}': {}",
112                    connection, e
113                ))
114            })?;
115
116            let resolved = ferrule_config::credentials::resolve_password_stack(
117                connection,
118                password.map(|p| secrecy::SecretString::new(p.into())),
119                None,
120            )
121            .map_err(|e| SqlError::RegistryError(e.to_string()))?;
122            if let Some(pwd) = resolved {
123                url.set_password(Some(pwd.expose_secret()));
124            }
125            Ok(url)
126        }
127    }
128}
129
130/// Resolve proxy configuration from explicit flag, profile, or env.
131fn resolve_proxy_config(
132    connection_name: &str,
133    proxy_url: Option<&str>,
134    global_config: &ferrule_config::profile::GlobalConfig,
135    url: &DatabaseUrl,
136) -> Result<Option<ProxyConfig>, SqlError> {
137    // 1. CLI flag
138    if let Some(raw) = proxy_url {
139        return ProxyConfig::parse(raw)
140            .map(Some)
141            .map_err(|e| SqlError::InvalidUrl(format!("Invalid --proxy-url: {e}")));
142    }
143
144    // 2. Profile
145    if let Some(profile) = global_config.connection.get(connection_name) {
146        if let Some(raw) = &profile.proxy_url {
147            return ProxyConfig::parse(raw).map(Some).map_err(|e| {
148                SqlError::InvalidUrl(format!(
149                    "Invalid proxy_url in profile for '{connection_name}': {e}"
150                ))
151            });
152        }
153    }
154
155    // 3. FERRULE_<NAME>_PROXY_URL env var
156    let env_name = format!(
157        "FERRULE_{}_PROXY_URL",
158        connection_name.to_ascii_uppercase().replace('-', "_")
159    );
160    if let Ok(raw) = std::env::var(&env_name) {
161        if !raw.is_empty() {
162            return ProxyConfig::parse(&raw)
163                .map(Some)
164                .map_err(|e| SqlError::InvalidUrl(format!("{env_name} is set but invalid: {e}")));
165        }
166    }
167
168    // 4. ALL_PROXY / HTTP_PROXY / HTTPS_PROXY env vars
169    let target_scheme = url.scheme();
170    if let Some(cfg) = resolve_proxy_from_env(target_scheme) {
171        if let Some(host) = url.host() {
172            if ferrule_sql::is_no_proxy(host) {
173                return Ok(None);
174            }
175        }
176        return Ok(Some(cfg));
177    }
178
179    Ok(None)
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use secrecy::ExposeSecret;
186
187    /// A password embedded in a parseable URL is surfaced both on the
188    /// URL (for the daemon-serialization path) and as a standalone
189    /// `secret` (for the in-process `ConnectOptions::password` path),
190    /// and the two agree. This is the consistency contract the rest of
191    /// the credential plumbing relies on.
192    #[test]
193    fn url_password_is_surfaced_as_secret() {
194        let cfg = ferrule_config::profile::GlobalConfig::default();
195        let resolved = resolve_connection(
196            "postgres://user:url_pw@localhost/db",
197            None,
198            None,
199            None,
200            &cfg,
201        )
202        .expect("resolve a plain URL");
203
204        let secret = resolved.secret.expect("a surfaced secret");
205        assert_eq!(secret.expose_secret(), "url_pw");
206        assert_eq!(
207            resolved
208                .url
209                .password()
210                .map(|p| p.expose_secret().to_string()),
211            Some("url_pw".to_string()),
212        );
213    }
214
215    /// An explicit `--password` overrides the URL component, and that
216    /// override is what gets surfaced as `secret`.
217    #[test]
218    fn explicit_password_overrides_url_and_is_surfaced() {
219        let cfg = ferrule_config::profile::GlobalConfig::default();
220        let resolved = resolve_connection(
221            "postgres://user:url_pw@localhost/db",
222            Some("flag_pw".to_string()),
223            None,
224            None,
225            &cfg,
226        )
227        .expect("resolve with an explicit password");
228
229        assert_eq!(
230            resolved.secret.map(|s| s.expose_secret().to_string()),
231            Some("flag_pw".to_string()),
232        );
233    }
234
235    /// A passwordless URL yields no surfaced secret, so the SQL core
236    /// connects without a password (trust/peer auth, local socket).
237    #[test]
238    fn passwordless_url_surfaces_no_secret() {
239        let cfg = ferrule_config::profile::GlobalConfig::default();
240        let resolved = resolve_connection("postgres://user@localhost/db", None, None, None, &cfg)
241            .expect("resolve a passwordless URL");
242        assert!(resolved.secret.is_none());
243    }
244}