Skip to main content

faucet_common_oracle/
config.rs

1//! Shared Oracle connection configuration and the pure logic that turns it into
2//! an ODPI-C connect string. No I/O lives here — pooling is in [`crate::pool`].
3
4use faucet_core::FaucetError;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7
8const DEFAULT_TCP_PORT: u16 = 1521;
9const DEFAULT_TCPS_PORT: u16 = 2484;
10
11/// Connection settings shared by the Oracle source, CDC source and sink.
12///
13/// Set **either** [`connect_string`](Self::connect_string) (an Easy Connect
14/// string, a TNS alias resolved through `tnsnames.ora`, or a full connect
15/// descriptor) **or** [`host`](Self::host) plus exactly one of
16/// [`service_name`](Self::service_name) / [`sid`](Self::sid). The host form is
17/// rendered into a connect descriptor, which is where the [`tls`](Self::tls)
18/// block takes effect.
19#[derive(Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
20pub struct OracleConnectionConfig {
21    /// Easy Connect (`host:1521/FREEPDB1`), TNS alias, or connect descriptor.
22    /// Mutually exclusive with `host`.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    #[schemars(extend("x-faucet-resource" = "url", "x-faucet-url-syntax" = "oracle"))]
25    pub connect_string: Option<String>,
26    /// Database host. Mutually exclusive with `connect_string`.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    #[schemars(extend("x-faucet-resource" = "url"))]
29    pub host: Option<String>,
30    /// Listener port. Defaults to 1521 (2484 when `tls.enabled`).
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub port: Option<u16>,
33    /// Service name (e.g. a PDB such as `FREEPDB1`). Mutually exclusive with `sid`.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub service_name: Option<String>,
36    /// Legacy SID. Mutually exclusive with `service_name`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub sid: Option<String>,
39    /// Database user. Required unless `external_auth` is set.
40    #[serde(default)]
41    pub username: String,
42    /// Password for `username`.
43    #[serde(default)]
44    pub password: String,
45    /// Authenticate with external credentials (OS authentication or a wallet
46    /// holding the credentials) instead of `username` / `password`.
47    #[serde(default)]
48    #[schemars(extend("x-faucet-host-credentials" = true))]
49    pub external_auth: bool,
50    /// Transport encryption (TCPS) for the `host` form.
51    #[serde(default)]
52    pub tls: OracleTls,
53}
54
55/// TCPS settings applied to a `host`-form connection.
56#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
57pub struct OracleTls {
58    /// Connect over TCPS. Defaults to `false`.
59    #[serde(default)]
60    pub enabled: bool,
61    /// Directory holding the Oracle wallet (`cwallet.sso` / `ewallet.p12`) with
62    /// the trusted CA and, for mutual TLS, the client certificate.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    #[schemars(extend("x-faucet-resource" = "path"))]
65    pub wallet_location: Option<String>,
66    /// Verify that the server certificate's DN matches the host. Defaults to `true`.
67    #[serde(default = "default_true")]
68    #[schemars(extend("x-faucet-unverified" = [false]))]
69    pub server_dn_match: bool,
70    /// Expected server certificate DN, when it differs from the host name.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub server_cert_dn: Option<String>,
73}
74
75fn default_true() -> bool {
76    true
77}
78
79impl Default for OracleTls {
80    fn default() -> Self {
81        Self {
82            enabled: false,
83            wallet_location: None,
84            server_dn_match: true,
85            server_cert_dn: None,
86        }
87    }
88}
89
90impl std::fmt::Debug for OracleConnectionConfig {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("OracleConnectionConfig")
93            .field("connect_string", &self.connect_string)
94            .field("host", &self.host)
95            .field("port", &self.port)
96            .field("service_name", &self.service_name)
97            .field("sid", &self.sid)
98            .field("username", &self.username)
99            .field("password", &"***")
100            .field("external_auth", &self.external_auth)
101            .field("tls", &self.tls)
102            .finish()
103    }
104}
105
106impl OracleConnectionConfig {
107    /// A `host`/`service_name` connection with username and password.
108    pub fn new(
109        host: impl Into<String>,
110        port: u16,
111        service_name: impl Into<String>,
112        username: impl Into<String>,
113        password: impl Into<String>,
114    ) -> Self {
115        Self {
116            host: Some(host.into()),
117            port: Some(port),
118            service_name: Some(service_name.into()),
119            username: username.into(),
120            password: password.into(),
121            ..Default::default()
122        }
123    }
124
125    /// Validate the fail-fast invariants.
126    pub fn validate(&self) -> Result<(), FaucetError> {
127        match (&self.connect_string, &self.host) {
128            (Some(_), Some(_)) => {
129                return Err(cfg_err(
130                    "sets both `connect_string` and `host`; set exactly one",
131                ));
132            }
133            (None, None) => {
134                return Err(cfg_err("requires either `connect_string` or `host`"));
135            }
136            (Some(cs), None) => {
137                if cs.trim().is_empty() {
138                    return Err(cfg_err("`connect_string` must not be empty"));
139                }
140                if self.service_name.is_some() || self.sid.is_some() || self.port.is_some() {
141                    return Err(cfg_err(
142                        "`port` / `service_name` / `sid` only apply to the `host` form; \
143                         put them in `connect_string` instead",
144                    ));
145                }
146                if self.tls != OracleTls::default() {
147                    return Err(cfg_err(
148                        "the `tls` block only applies to the `host` form; use a `tcps://` \
149                         connect string (or a descriptor with PROTOCOL=TCPS) instead",
150                    ));
151                }
152            }
153            (None, Some(host)) => {
154                check_descriptor_value("host", host)?;
155                match (&self.service_name, &self.sid) {
156                    (Some(_), Some(_)) => {
157                        return Err(cfg_err("sets both `service_name` and `sid`; set one"));
158                    }
159                    (None, None) => {
160                        return Err(cfg_err("the `host` form needs `service_name` or `sid`"));
161                    }
162                    (Some(s), None) => check_descriptor_value("service_name", s)?,
163                    (None, Some(s)) => check_descriptor_value("sid", s)?,
164                }
165                if !self.tls.enabled
166                    && (self.tls.wallet_location.is_some()
167                        || self.tls.server_cert_dn.is_some()
168                        || !self.tls.server_dn_match)
169                {
170                    return Err(cfg_err(
171                        "the `tls` block sets wallet / certificate options but not \
172                         `enabled: true`, so the connection would run over plain TCP; set \
173                         `tls.enabled: true` (or remove the options)",
174                    ));
175                }
176                if let Some(w) = &self.tls.wallet_location {
177                    check_descriptor_value("tls.wallet_location", w)?;
178                }
179                if let Some(dn) = &self.tls.server_cert_dn
180                    && dn.contains('"')
181                {
182                    return Err(cfg_err("`tls.server_cert_dn` must not contain '\"'"));
183                }
184            }
185        }
186        if self.external_auth {
187            if !self.username.is_empty() || !self.password.is_empty() {
188                return Err(cfg_err(
189                    "`external_auth: true` takes its credentials from the wallet / OS; \
190                     leave `username` and `password` unset",
191                ));
192            }
193        } else if self.username.trim().is_empty() {
194            return Err(cfg_err("requires a `username` (or `external_auth: true`)"));
195        }
196        Ok(())
197    }
198
199    /// The connect string handed to ODPI-C: `connect_string` verbatim, or a
200    /// connect descriptor rendered from the `host` form.
201    pub fn resolve_connect_string(&self) -> Result<String, FaucetError> {
202        self.validate()?;
203        if let Some(cs) = &self.connect_string {
204            return Ok(cs.trim().to_string());
205        }
206        let host = self.host.as_deref().unwrap_or_default();
207        let (protocol, default_port) = if self.tls.enabled {
208            ("TCPS", DEFAULT_TCPS_PORT)
209        } else {
210            ("TCP", DEFAULT_TCP_PORT)
211        };
212        let port = self.port.unwrap_or(default_port);
213        let connect_data = match (&self.service_name, &self.sid) {
214            (Some(s), _) => format!("(SERVICE_NAME={s})"),
215            (None, Some(s)) => format!("(SID={s})"),
216            (None, None) => unreachable!("validate() requires service_name or sid"),
217        };
218        let mut out = format!(
219            "(DESCRIPTION=(ADDRESS=(PROTOCOL={protocol})(HOST={host})(PORT={port}))\
220             (CONNECT_DATA={connect_data})"
221        );
222        if self.tls.enabled {
223            let mut security = format!(
224                "(SSL_SERVER_DN_MATCH={})",
225                if self.tls.server_dn_match {
226                    "YES"
227                } else {
228                    "NO"
229                }
230            );
231            if let Some(dn) = &self.tls.server_cert_dn {
232                security.push_str(&format!("(SSL_SERVER_CERT_DN=\"{dn}\")"));
233            }
234            if let Some(w) = &self.tls.wallet_location {
235                security.push_str(&format!("(MY_WALLET_DIRECTORY={w})"));
236            }
237            out.push_str(&format!("(SECURITY={security})"));
238        }
239        out.push(')');
240        Ok(out)
241    }
242
243    /// A credential-free URI naming the database, for lineage `dataset_uri`s and
244    /// state-key derivation.
245    pub fn display_uri(&self) -> String {
246        if let Some(cs) = &self.connect_string {
247            return format!(
248                "oracle://{}",
249                faucet_core::redact_uri_credentials(cs.trim())
250            );
251        }
252        let host = self.host.as_deref().unwrap_or_default();
253        let port = self.port.unwrap_or(if self.tls.enabled {
254            DEFAULT_TCPS_PORT
255        } else {
256            DEFAULT_TCP_PORT
257        });
258        let db = self
259            .service_name
260            .as_deref()
261            .or(self.sid.as_deref())
262            .unwrap_or_default();
263        format!("oracle://{host}:{port}/{db}")
264    }
265
266    /// A short, key-safe scope used when deriving state keys: the host form
267    /// names host, port and service / SID (two databases with the same
268    /// service name on different hosts get different keys); the
269    /// `connect_string` form is the connect string itself.
270    pub fn scope_label(&self) -> String {
271        let raw = match (&self.host, &self.connect_string) {
272            (Some(host), _) => {
273                let port = self.port.unwrap_or(if self.tls.enabled {
274                    DEFAULT_TCPS_PORT
275                } else {
276                    DEFAULT_TCP_PORT
277                });
278                let db = self
279                    .service_name
280                    .as_deref()
281                    .or(self.sid.as_deref())
282                    .unwrap_or_default();
283                format!("{}_{port}_{}", host.trim(), db.trim())
284            }
285            (None, Some(cs)) => cs.clone(),
286            (None, None) => "oracle".to_string(),
287        };
288        key_safe(&raw)
289    }
290
291    /// The scope [`scope_label`](Self::scope_label) rendered before it named
292    /// the host and port (service / SID / host / connect string, first set),
293    /// when that differs — for moving a bookmark stored under the old key.
294    pub fn legacy_scope_label(&self) -> Option<String> {
295        let raw = self
296            .service_name
297            .as_deref()
298            .or(self.sid.as_deref())
299            .or(self.host.as_deref())
300            .or(self.connect_string.as_deref())
301            .unwrap_or("oracle");
302        let old = key_safe(raw.trim());
303        (old != self.scope_label()).then_some(old)
304    }
305}
306
307/// `raw` with every character outside `[A-Za-z0-9_.-]` replaced by `_`, and
308/// leading / trailing `_` trimmed.
309fn key_safe(raw: &str) -> String {
310    let label: String = raw
311        .chars()
312        .map(|c| {
313            if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
314                c
315            } else {
316                '_'
317            }
318        })
319        .collect();
320    label.trim_matches('_').to_string()
321}
322
323fn cfg_err(msg: &str) -> FaucetError {
324    FaucetError::Config(format!("oracle connection {msg}"))
325}
326
327/// Reject characters that would break out of a connect-descriptor value.
328fn check_descriptor_value(field: &str, value: &str) -> Result<(), FaucetError> {
329    if value.trim().is_empty() {
330        return Err(cfg_err(&format!("`{field}` must not be empty")));
331    }
332    if value.chars().any(|c| matches!(c, '(' | ')' | '=' | '\0')) {
333        return Err(cfg_err(&format!(
334            "`{field}` must not contain '(', ')', '=' or NUL"
335        )));
336    }
337    Ok(())
338}
339
340#[cfg(test)]
341mod tests;