1use 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#[derive(Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)]
20pub struct OracleConnectionConfig {
21 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
28 #[schemars(extend("x-faucet-resource" = "url"))]
29 pub host: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub port: Option<u16>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub service_name: Option<String>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub sid: Option<String>,
39 #[serde(default)]
41 pub username: String,
42 #[serde(default)]
44 pub password: String,
45 #[serde(default)]
48 #[schemars(extend("x-faucet-host-credentials" = true))]
49 pub external_auth: bool,
50 #[serde(default)]
52 pub tls: OracleTls,
53}
54
55#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
57pub struct OracleTls {
58 #[serde(default)]
60 pub enabled: bool,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 #[schemars(extend("x-faucet-resource" = "path"))]
65 pub wallet_location: Option<String>,
66 #[serde(default = "default_true")]
68 #[schemars(extend("x-faucet-unverified" = [false]))]
69 pub server_dn_match: bool,
70 #[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 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 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 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 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 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 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
307fn 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
327fn 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;