use std::sync::Arc;
use std::time::Duration;
use asupersync::net::TcpStream;
use asupersync::tls::{TlsConnector, TlsError as AsupersyncTlsError, TlsStream};
use oracledb_protocol::net::EasyConnect;
use oracledb_protocol::tls::dn::{check_cert_dn, check_server_name, DnMatchError};
use oracledb_protocol::tls::sni::build_sni;
use oracledb_protocol::tls::wallet::{resolve_wallet_dir, WalletContents};
use rustls::client::{
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
ResolvesClientCert,
};
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, WebPkiSupportedAlgorithms};
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::sign::CertifiedKey;
use rustls::{ClientConfig, DigitallySignedStruct, Error as RustlsError, SignatureScheme};
use crate::Error;
#[derive(Clone, Debug, Default)]
pub struct TlsParams {
pub wallet: Option<WalletContents>,
pub dn_match: bool,
pub server_cert_dn: Option<String>,
pub expected_host: String,
pub use_sni: bool,
}
pub(crate) struct PreparedTlsHandshake {
config: ClientConfig,
server_name: String,
}
#[derive(Debug)]
pub(crate) struct TlsHandshakeError {
message: String,
terminal: bool,
}
impl TlsHandshakeError {
#[cfg(test)]
pub(crate) fn new(message: String) -> Self {
Self {
message,
terminal: false,
}
}
fn from_asupersync(error: AsupersyncTlsError) -> Self {
let terminal = tls_error_is_terminal(&error);
Self {
message: error.to_string(),
terminal,
}
}
pub(crate) fn is_terminal(&self) -> bool {
self.terminal
}
pub(crate) fn into_driver_error(self) -> Error {
Error::Tls(format!("TCPS handshake failed: {}", self.message))
}
}
impl std::fmt::Display for TlsHandshakeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "TLS/TCPS error: TCPS handshake failed: {}", self.message)
}
}
fn tls_error_is_terminal(error: &AsupersyncTlsError) -> bool {
match error {
AsupersyncTlsError::Certificate(_)
| AsupersyncTlsError::CertificateExpired { .. }
| AsupersyncTlsError::CertificateNotYetValid { .. }
| AsupersyncTlsError::ChainValidation(_)
| AsupersyncTlsError::PinMismatch { .. } => true,
AsupersyncTlsError::Rustls(error) => rustls_error_is_terminal(error),
AsupersyncTlsError::Handshake(message) => tls_error_message_is_terminal(message),
_ => false,
}
}
fn rustls_error_is_terminal(error: &RustlsError) -> bool {
matches!(error, RustlsError::InvalidCertificate(_))
|| tls_error_message_is_terminal(&error.to_string())
}
fn tls_error_message_is_terminal(message: &str) -> bool {
message.contains("certificate")
|| message.contains("distinguished name (DN)")
|| message.contains("server certificate")
|| message.contains("UnknownIssuer")
|| message.contains("unknown issuer")
}
#[derive(Debug)]
pub(crate) struct OracleServerCertVerifier {
trust_anchor_ders: Vec<Vec<u8>>,
supported_algs: WebPkiSupportedAlgorithms,
dn_match: bool,
server_cert_dn: Option<String>,
expected_host: String,
}
impl OracleServerCertVerifier {
fn run_dn_match(&self, end_entity: &CertificateDer<'_>) -> Result<(), RustlsError> {
if !self.dn_match {
return Ok(());
}
let (subject_dn, san_dns, common_names) = parse_cert_identity(end_entity)?;
let result = if let Some(expected_dn) = self.server_cert_dn.as_deref() {
check_cert_dn(expected_dn, &subject_dn)
} else {
check_server_name(&self.expected_host, &san_dns, &common_names)
};
result.map_err(dn_error_to_rustls)
}
}
impl ServerCertVerifier for OracleServerCertVerifier {
fn verify_server_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
_server_name: &ServerName<'_>,
_ocsp_response: &[u8],
now: UnixTime,
) -> Result<ServerCertVerified, RustlsError> {
let owned_certs: Vec<CertificateDer<'static>> = self
.trust_anchor_ders
.iter()
.map(|der| CertificateDer::from(der.clone()))
.collect();
let anchors: Vec<rustls_pki_types::TrustAnchor<'_>> = owned_certs
.iter()
.filter_map(|c| webpki::anchor_from_trusted_cert(c).ok())
.collect();
if anchors.is_empty() {
return Err(RustlsError::General(
"wallet contained no usable CA trust anchors".to_string(),
));
}
let ee = webpki::EndEntityCert::try_from(end_entity)
.map_err(|e| RustlsError::General(format!("invalid server certificate: {e}")))?;
ee.verify_for_usage(
self.supported_algs.all,
&anchors,
intermediates,
now,
webpki::KeyUsage::server_auth(),
None,
None,
)
.map_err(|e| {
RustlsError::General(format!("TCPS server certificate chain is not trusted: {e}"))
})?;
self.run_dn_match(end_entity)?;
Ok(ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
verify_tls12_signature(message, cert, dss, &self.supported_algs)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, RustlsError> {
verify_tls13_signature(message, cert, dss, &self.supported_algs)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.supported_algs.supported_schemes()
}
}
#[derive(Debug)]
struct StaticClientCert(Arc<CertifiedKey>);
impl ResolvesClientCert for StaticClientCert {
fn resolve(
&self,
_root_hint_subjects: &[&[u8]],
_sigschemes: &[SignatureScheme],
) -> Option<Arc<CertifiedKey>> {
Some(Arc::clone(&self.0))
}
fn has_certs(&self) -> bool {
true
}
}
fn dn_error_to_rustls(err: DnMatchError) -> RustlsError {
RustlsError::General(err.to_string())
}
fn parse_cert_identity(
cert: &CertificateDer<'_>,
) -> Result<(String, Vec<String>, Vec<String>), RustlsError> {
use x509_cert::der::Decode;
let parsed = x509_cert::Certificate::from_der(cert.as_ref())
.map_err(|e| RustlsError::General(format!("server certificate parse error: {e}")))?;
let subject_dn = parsed.tbs_certificate.subject.to_string();
let mut common_names = Vec::new();
for rdn in parsed.tbs_certificate.subject.0.iter() {
for atv in rdn.0.iter() {
if atv.oid.to_string() == "2.5.4.3" {
if let Ok(s) = std::str::from_utf8(atv.value.value()) {
common_names.push(s.to_string());
} else if let Ok(s) = atv.value.decode_as::<x509_cert::der::asn1::Utf8StringRef>() {
common_names.push(s.as_str().to_string());
}
}
}
}
let mut san_dns = Vec::new();
if let Some(extensions) = parsed.tbs_certificate.extensions.as_ref() {
for ext in extensions.iter() {
if ext.extn_id.to_string() == "2.5.29.17" {
if let Ok(san) =
x509_cert::ext::pkix::SubjectAltName::from_der(ext.extn_value.as_bytes())
{
for name in san.0.iter() {
if let x509_cert::ext::pkix::name::GeneralName::DnsName(dns) = name {
san_dns.push(dns.as_str().to_string());
}
}
}
}
}
}
Ok((subject_dn, san_dns, common_names))
}
pub(crate) fn build_client_config(params: &TlsParams) -> Result<ClientConfig, Error> {
use rustls::crypto::ring::default_provider;
let provider = Arc::new(default_provider());
let key_provider = provider.key_provider;
let supported_algs = provider.signature_verification_algorithms;
let trust_anchor_ders = union_trust_anchors(load_system_roots(), params.wallet.as_ref());
if trust_anchor_ders.is_empty() {
return Err(Error::Tls(
"no trust anchors available for TCPS: supply a wallet (ewallet.pem) \
or install system root certificates"
.to_string(),
));
}
let verifier = Arc::new(OracleServerCertVerifier {
trust_anchor_ders,
supported_algs,
dn_match: params.dn_match,
server_cert_dn: params.server_cert_dn.clone(),
expected_host: params.expected_host.clone(),
});
let builder = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|e| Error::Tls(format!("TLS protocol setup failed: {e}")))?
.dangerous()
.with_custom_certificate_verifier(verifier);
let mut config = if let Some(w) = ¶ms.wallet {
if w.has_client_identity() {
let chain: Vec<CertificateDer<'static>> = w
.client_cert_chain
.iter()
.map(|der| CertificateDer::from(der.clone()))
.collect();
let key = client_private_key(w)?;
let signing_key = key_provider
.load_private_key(key)
.map_err(|e| Error::Tls(format!("client private key rejected: {e}")))?;
let certified = Arc::new(CertifiedKey::new(chain, signing_key));
builder.with_client_cert_resolver(Arc::new(StaticClientCert(certified)))
} else {
builder.with_no_client_auth()
}
} else {
builder.with_no_client_auth()
};
config.enable_sni = true;
Ok(config)
}
fn client_private_key(
w: &WalletContents,
) -> Result<rustls::pki_types::PrivateKeyDer<'static>, Error> {
let der = w
.client_private_key
.as_ref()
.ok_or_else(|| Error::Tls("wallet has a client cert but no private key".to_string()))?;
rustls::pki_types::PrivateKeyDer::try_from(der.clone())
.map_err(|e| Error::Tls(format!("client private key parse failed: {e}")))
}
fn load_system_roots() -> Vec<Vec<u8>> {
let native = rustls_native_certs::load_native_certs();
let native_roots: Vec<Vec<u8>> = native
.certs
.into_iter()
.map(|certificate| certificate.as_ref().to_vec())
.collect();
if !native_roots.is_empty() || explicit_root_override_is_set() {
return native_roots;
}
const BUNDLES: &[&str] = &[
"/etc/ssl/certs/ca-certificates.crt",
"/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem",
"/etc/ssl/cert.pem",
];
for path in BUNDLES {
if let Ok(bytes) = std::fs::read(path) {
let mut reader = std::io::BufReader::new(&bytes[..]);
let certs: Vec<Vec<u8>> = rustls_pemfile_certs(&mut reader);
if !certs.is_empty() {
return certs;
}
}
}
Vec::new()
}
fn explicit_root_override_is_set() -> bool {
std::env::var_os("SSL_CERT_FILE").is_some() || std::env::var_os("SSL_CERT_DIR").is_some()
}
fn union_trust_anchors(
mut system_roots: Vec<Vec<u8>>,
wallet: Option<&WalletContents>,
) -> Vec<Vec<u8>> {
if let Some(wallet) = wallet {
system_roots.extend(wallet.ca_certificates.iter().cloned());
}
system_roots
}
fn rustls_pemfile_certs(reader: &mut dyn std::io::BufRead) -> Vec<Vec<u8>> {
oracledb_protocol::tls::wallet::parse_pem_certificates(reader)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn resolve_tls_params(
descriptor: &EasyConnect,
wallet_location: Option<&str>,
wallet_password: Option<&str>,
ssl_server_dn_match: bool,
ssl_server_cert_dn: Option<&str>,
use_sni: bool,
) -> Result<TlsParams, Error> {
let tns_admin = std::env::var("TNS_ADMIN").ok();
let wallet = match resolve_wallet_dir(wallet_location, tns_admin.as_deref()) {
Some(dir) => Some(load_wallet(&dir, wallet_password)?),
None => None,
};
if !ssl_server_dn_match {
obs_warn!(
server.host = %descriptor.host,
server.port = descriptor.port as u64,
pinned_cert_dn_configured = ssl_server_cert_dn.is_some(),
"TCPS SSL_SERVER_DN_MATCH=OFF: the Oracle server-certificate identity check \
(DN/SAN/CN match) is disabled for this connection; TLS chain-of-trust \
validation still applies, but the peer's identity is not verified. This must be \
a deliberate, explicit choice, never a default."
);
}
Ok(TlsParams {
wallet,
dn_match: ssl_server_dn_match,
server_cert_dn: ssl_server_cert_dn.map(str::to_string),
expected_host: descriptor.host.clone(),
use_sni,
})
}
fn load_wallet(dir: &std::path::Path, password: Option<&str>) -> Result<WalletContents, Error> {
resolve_wallet_inner(dir, password).map(|(_, contents)| contents)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WalletFile {
Pem,
P12,
Sso,
}
impl WalletFile {
#[must_use]
pub fn file_name(self) -> &'static str {
use oracledb_protocol::tls::wallet::{
P12_WALLET_FILE_NAME, PEM_WALLET_FILE_NAME, SSO_WALLET_FILE_NAME,
};
match self {
WalletFile::Pem => PEM_WALLET_FILE_NAME,
WalletFile::P12 => P12_WALLET_FILE_NAME,
WalletFile::Sso => SSO_WALLET_FILE_NAME,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WalletResolution {
pub chosen: WalletFile,
pub attempted_primary: Option<WalletFile>,
pub fell_through: bool,
pub fallthrough_eligible: bool,
}
pub fn resolve_wallet(
dir: &std::path::Path,
password: Option<&str>,
) -> Result<WalletResolution, Error> {
resolve_wallet_inner(dir, password).map(|(resolution, _)| resolution)
}
fn resolve_wallet_inner(
dir: &std::path::Path,
password: Option<&str>,
) -> Result<(WalletResolution, WalletContents), Error> {
use oracledb_protocol::tls::wallet::{
p12_wallet_path, pem_wallet_path, read_ewallet_p12, read_ewallet_pem, read_wallet_file,
sso_wallet_path, WalletError,
};
let read_sso = || -> Result<Option<WalletContents>, WalletError> {
let sso = sso_wallet_path(dir);
if !sso.exists() {
return Ok(None);
}
let bytes = read_wallet_file(&sso)?;
oracledb_protocol::tls::sso::parse_cwallet_sso(&bytes).map(Some)
};
let falls_through_to_autologin = |e: &WalletError| {
matches!(
e,
WalletError::KeyDecrypt(_)
| WalletError::Pkcs12(_)
| WalletError::PasswordRequired { .. }
| WalletError::UnsupportedFormat { .. }
)
};
let have_p12 = p12_wallet_path(dir).exists();
let primary: Option<(WalletFile, Result<WalletContents, WalletError>)> =
if pem_wallet_path(dir).exists() {
Some((WalletFile::Pem, read_ewallet_pem(dir, password)))
} else if have_p12 && password.is_some() {
Some((WalletFile::P12, read_ewallet_p12(dir, password)))
} else {
None
};
match primary {
Some((file, Ok(contents))) => Ok((
WalletResolution {
chosen: file,
attempted_primary: Some(file),
fell_through: false,
fallthrough_eligible: false,
},
contents,
)),
Some((file, Err(primary_err))) => {
if falls_through_to_autologin(&primary_err) {
if let Ok(Some(sso)) = read_sso() {
let name = file.file_name();
obs_warn!(
skipped_wallet = name,
"wallet {name} could not be used ({primary_err}); \
falling back to auto-login cwallet.sso"
);
let _ = name;
return Ok((
WalletResolution {
chosen: WalletFile::Sso,
attempted_primary: Some(file),
fell_through: true,
fallthrough_eligible: true,
},
sso,
));
}
}
Err(primary_err.into())
}
None => {
if let Some(sso) = read_sso()? {
return Ok((
WalletResolution {
chosen: WalletFile::Sso,
attempted_primary: None,
fell_through: false,
fallthrough_eligible: false,
},
sso,
));
}
if have_p12 {
return read_ewallet_p12(dir, password)
.map(|contents| {
(
WalletResolution {
chosen: WalletFile::P12,
attempted_primary: Some(WalletFile::P12),
fell_through: false,
fallthrough_eligible: false,
},
contents,
)
})
.map_err(Error::from);
}
Err(
WalletError::FileMissing("ewallet.pem, ewallet.p12, or cwallet.sso".to_string())
.into(),
)
}
}
}
const SNI_PLACEHOLDER: &str = "oracle.invalid";
fn sni_is_rustls_valid(sni: &str) -> bool {
matches!(
rustls::pki_types::ServerName::try_from(sni.to_string()),
Ok(ServerName::DnsName(_))
)
}
fn is_oci_adb_host(host: &str) -> bool {
let host = host.to_ascii_lowercase();
if host.is_empty() || host.contains(':') || host.starts_with('.') || host.ends_with('.') {
return false;
}
let Some(rest) = strip_oci_cloud_suffix(&host) else {
return false;
};
rest == "adb" || rest.starts_with("adb.") || rest.ends_with(".adb") || rest.contains(".adb.")
}
fn is_oci_adb_service(service_name: &str) -> bool {
let service_name = service_name.to_ascii_lowercase();
let Some(rest) = strip_oci_cloud_suffix(&service_name) else {
return false;
};
rest.contains(".adb.") || rest.ends_with(".adb")
}
fn strip_oci_cloud_suffix(name: &str) -> Option<&str> {
const SUFFIXES: &[&str] = &[
".oraclecloud.com.au",
".oraclegovcloud.com",
".oraclecloud.eu",
".oraclecloud.com",
];
for suffix in SUFFIXES {
if let Some(rest) = name.strip_suffix(suffix) {
if !rest.is_empty() {
return Some(rest);
}
}
}
None
}
fn is_oci_adb_endpoint(host: &str, service_name: &str) -> bool {
is_oci_adb_host(host) && is_oci_adb_service(service_name)
}
pub(crate) fn decide_sni(
use_sni: bool,
host: &str,
service_name: &str,
server_type: Option<&str>,
) -> Result<Option<String>, Error> {
if !use_sni {
return Ok(None);
}
let sni = build_sni(service_name, server_type);
if sni_is_rustls_valid(&sni) {
return Ok(Some(sni));
}
if is_oci_adb_endpoint(host, service_name) && sni_is_rustls_valid(host) {
return Ok(Some(host.to_string()));
}
Err(Error::UnsupportedSni(sni))
}
pub(crate) fn prepare_tls_handshake(
descriptor: &EasyConnect,
server_type: Option<&str>,
params: &TlsParams,
) -> Result<PreparedTlsHandshake, Error> {
let mut config = build_client_config(params)?;
let server_name = match decide_sni(
params.use_sni,
¶ms.expected_host,
&descriptor.service_name,
server_type,
)? {
Some(sni) => {
config.enable_sni = true;
sni
}
None => {
config.enable_sni = false;
SNI_PLACEHOLDER.to_string()
}
};
Ok(PreparedTlsHandshake {
config,
server_name,
})
}
pub(crate) async fn tls_handshake_prepared(
prepared: &PreparedTlsHandshake,
tcp: TcpStream,
handshake_timeout: Duration,
) -> Result<TlsStream<TcpStream>, TlsHandshakeError> {
let connector =
TlsConnector::new(prepared.config.clone()).with_handshake_timeout(handshake_timeout);
connector
.connect(&prepared.server_name, tcp)
.await
.map_err(TlsHandshakeError::from_asupersync)
}
pub(crate) const fn handshake_timeout_from_connect_timeout(connect_timeout: Duration) -> Duration {
connect_timeout
}
#[cfg(test)]
#[allow(dead_code)] pub async fn tls_handshake(
descriptor: &EasyConnect,
server_type: Option<&str>,
params: &TlsParams,
tcp: TcpStream,
handshake_timeout: Duration,
) -> Result<TlsStream<TcpStream>, Error> {
let prepared = prepare_tls_handshake(descriptor, server_type, params)?;
tls_handshake_prepared(&prepared, tcp, handshake_timeout)
.await
.map_err(TlsHandshakeError::into_driver_error)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_config_requires_trust_anchors() {
let params = TlsParams {
wallet: Some(WalletContents::default()),
dn_match: true,
server_cert_dn: None,
expected_host: "db.example.com".to_string(),
use_sni: false,
};
let _ = build_client_config(¶ms);
}
#[test]
fn platform_and_wallet_trust_anchors_are_unioned() {
let platform_root = vec![0x30, 0x01];
let wallet_root = vec![0x30, 0x02];
let wallet = WalletContents {
ca_certificates: vec![wallet_root.clone()],
..WalletContents::default()
};
assert_eq!(
union_trust_anchors(vec![platform_root.clone()], Some(&wallet)),
vec![platform_root, wallet_root]
);
}
#[test]
fn system_root_loader_honors_ssl_cert_file() {
assert_system_root_loader_honors_explicit_override(
"file",
"system_root_loader_honors_ssl_cert_file",
);
}
#[test]
fn system_root_loader_honors_ssl_cert_dir() {
assert_system_root_loader_honors_explicit_override(
"dir",
"system_root_loader_honors_ssl_cert_dir",
);
}
#[test]
fn tls_configuration_failure_is_typed_before_any_transport_attempt() {
let descriptor =
EasyConnect::parse("tcps://db.example.com:2484/FREEPDB1").expect("descriptor");
let params = TlsParams {
wallet: Some(WalletContents {
ca_certificates: vec![vec![0x30, 0x00]],
client_cert_chain: vec![vec![0x30, 0x00]],
client_private_key: Some(vec![0xff]),
}),
dn_match: true,
server_cert_dn: None,
expected_host: "db.example.com".to_string(),
use_sni: false,
};
let error = match prepare_tls_handshake(&descriptor, None, ¶ms) {
Ok(_) => panic!("an invalid client private key must fail TLS preparation"),
Err(error) => error,
};
assert!(
matches!(error, Error::Tls(ref detail) if detail.contains("private key")),
"configuration failure must remain the original typed TLS error, got {error:?}"
);
}
#[test]
fn oracle_service_form_sni_rejection_is_the_terminal_numeric_label() {
let service_form = "S11.myadb_high.V3.319";
let err = ServerName::try_from(service_form.to_string())
.expect_err("an Oracle service-form SNI currently is not a rustls ServerName");
assert_eq!(err.to_string(), "invalid dns name");
assert!(ServerName::try_from("myadb_high".to_string()).is_ok());
assert!(ServerName::try_from("S11.myadb_high.V3.name".to_string()).is_ok());
assert!(!sni_is_rustls_valid(service_form));
assert!(!sni_is_rustls_valid("192.0.2.1"));
assert!(sni_is_rustls_valid("db.example.com"));
}
#[test]
fn decide_sni_without_use_sni_sends_no_sni() {
let decided =
decide_sni(false, "db.example.com", "FREEPDB1", None).expect("no-SNI must be Ok");
assert!(decided.is_none(), "use_sni=false must not send an SNI");
}
#[test]
fn decide_sni_with_use_sni_fails_closed_not_silent() {
let err = decide_sni(true, "db.example.com", "FREEPDB1", None)
.expect_err("use_sni=true with an un-encodable Oracle SNI must fail closed");
match err {
Error::UnsupportedSni(sni) => {
assert!(
sni.starts_with('S') && sni.contains("FREEPDB1"),
"error must name the Oracle SNI string, got {sni:?}"
);
}
other => panic!("expected Error::UnsupportedSni, got {other:?}"),
}
}
#[test]
fn decide_sni_for_oci_adb_uses_the_valid_endpoint_host() {
let host = "adb.eu-frankfurt-1.oraclecloud.com";
let service_name = "g2bb4261a88e318_myadb_high.adb.oraclecloud.com";
assert_eq!(
decide_sni(true, host, service_name, None).expect("OCI host SNI is encodable"),
Some(host.to_string())
);
}
#[test]
fn only_the_oci_adb_descriptor_shape_gets_the_host_sni_fallback() {
assert!(is_oci_adb_endpoint(
"adb.eu-frankfurt-1.oraclecloud.com",
"g2bb4261a88e318_myadb_high.adb.oraclecloud.com"
));
assert!(is_oci_adb_endpoint(
"pe1.adb.us-ashburn-1.oraclecloud.com",
"g2bb4261a88e318_myadb_high.adb.oraclecloud.com"
));
assert!(is_oci_adb_endpoint(
"adb.eu-frankfurt-1.oraclecloud.eu",
"g2bb4261a88e318_myadb_high.adb.eu-frankfurt-1.oraclecloud.eu"
));
assert!(is_oci_adb_endpoint(
"ADB.US-ASHBURN-1.ORACLECLOUD.COM",
"G2BB4261A88E318_MYADB_HIGH.ADB.ORACLECLOUD.COM"
));
assert!(!is_oci_adb_endpoint(
"db.example.com",
"g2bb4261a88e318_myadb_high.adb.oraclecloud.com"
));
assert!(!is_oci_adb_endpoint(
"adb.eu-frankfurt-1.oraclecloud.com",
"FREEPDB1"
));
assert!(!is_oci_adb_endpoint(
"203.0.113.10",
"g2bb4261a88e318_myadb_high.adb.oraclecloud.com"
));
assert!(!is_oci_adb_endpoint(
"db.customer.example",
"g2bb4261a88e318_myadb_high.adb.oraclecloud.com"
));
}
#[test]
fn decide_sni_private_endpoint_adb_uses_host_fallback() {
let host = "abcd1234.adb.us-phoenix-1.oraclecloud.com";
let service = "g2bb4261a88e318_myadb_tp.adb.oraclecloud.com";
let service_form = build_sni(service, None);
assert!(
!sni_is_rustls_valid(&service_form),
"fixture must stay unencodable as rustls DNS: {service_form}"
);
assert_eq!(
decide_sni(true, host, service, None).expect("PE ADB host SNI"),
Some(host.to_string())
);
}
#[test]
fn decide_sni_sovereign_cloud_adb_uses_host_fallback() {
let host = "adb.uk-london-1.oraclegovcloud.com";
let service = "govdb_high.adb.uk-london-1.oraclegovcloud.com";
assert_eq!(
decide_sni(true, host, service, None).expect("gov ADB host SNI"),
Some(host.to_string())
);
}
fn fixture_tls_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("tls")
}
fn assert_system_root_loader_honors_explicit_override(mode: &str, test_name: &str) {
const CHILD_MODE: &str = "ORACLEDB_TEST_SYSTEM_ROOT_LOADER_MODE";
if let Some(child_mode) = std::env::var_os(CHILD_MODE) {
let (configured_path, expected_cert_file) = match child_mode.to_string_lossy().as_ref()
{
"file" => {
let cert = fixture_tls_dir().join("ca_wallet.pem");
(cert.clone(), cert)
}
"dir" => {
let dir = fixture_tls_dir().join("invalid_client_key");
(dir.clone(), dir.join("ewallet.pem"))
}
other => panic!("unexpected child root-loader mode {other:?}"),
};
let expected = std::fs::File::open(&expected_cert_file)
.map(std::io::BufReader::new)
.map(|mut reader| rustls_pemfile_certs(&mut reader))
.expect("read fixture certificate");
assert!(
!expected.is_empty(),
"fixture must provide an explicit trust anchor"
);
let actual = load_system_roots();
assert_eq!(
actual,
expected,
"load_system_roots must use the {child_mode:?} SSL_CERT_* override at {}",
configured_path.display()
);
return;
}
let mut child = std::process::Command::new(std::env::current_exe().expect("test binary"));
child
.arg("--exact")
.arg(format!("tls::tests::{test_name}"))
.arg("--nocapture")
.env(CHILD_MODE, mode)
.env_remove("SSL_CERT_FILE")
.env_remove("SSL_CERT_DIR");
match mode {
"file" => {
child.env("SSL_CERT_FILE", fixture_tls_dir().join("ca_wallet.pem"));
}
"dir" => {
child.env("SSL_CERT_DIR", fixture_tls_dir().join("invalid_client_key"));
}
other => panic!("unexpected root-loader mode {other:?}"),
}
let status = child.status().expect("run isolated root-loader test");
assert!(status.success(), "isolated {mode} root-loader test failed");
}
#[test]
fn ewallet_p12_only_wallet_without_password_is_password_required() {
let dir = fixture_tls_dir().join("p12_only");
let err = load_wallet(&dir, None).expect_err("p12-only wallet without password");
let wallet_err = if let Error::Wallet(wallet_err) = err {
wallet_err
} else {
panic!("expected wallet error, got {err:?}");
};
assert!(
matches!(
&wallet_err,
oracledb_protocol::tls::wallet::WalletError::PasswordRequired { format }
if *format == "ewallet.p12"
),
"expected PasswordRequired, got {wallet_err:?}"
);
let sensitive_path = dir.display().to_string();
assert!(!format!("{wallet_err}").contains(&sensitive_path));
assert!(!format!("{wallet_err:?}").contains(&sensitive_path));
}
#[test]
fn ewallet_p12_only_wallet_with_password_garbage_is_typed_pkcs12_error() {
let dir = fixture_tls_dir().join("p12_only");
let err = load_wallet(&dir, Some("any-password")).expect_err("dummy p12 must not parse");
let wallet_err = if let Error::Wallet(wallet_err) = err {
wallet_err
} else {
panic!("expected wallet error, got {err:?}");
};
assert!(
matches!(
&wallet_err,
oracledb_protocol::tls::wallet::WalletError::Pkcs12(_)
),
"expected Pkcs12, got {wallet_err:?}"
);
assert!(!format!("{wallet_err}").contains("any-password"));
assert!(!format!("{wallet_err:?}").contains("any-password"));
}
fn temp_wallet_dir(label: &str, files: &[&str]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"oracledb-wallet-test-{label}-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create temp wallet dir");
for name in files {
std::fs::copy(
fixture_tls_dir().join(name),
dir.join(wallet_file_name(name)),
)
.expect("copy fixture");
}
dir
}
fn wallet_file_name(fixture: &str) -> &'static str {
match fixture {
"ewallet_orapki.p12" => "ewallet.p12",
"cwallet_orapki.sso" => "cwallet.sso",
"ewallet.pem" => "ewallet.pem",
other => panic!("unmapped fixture {other}"),
}
}
#[test]
fn adb_style_wallet_dir_prefers_p12_with_password_and_sso_without() {
let dir = temp_wallet_dir("adb", &["ewallet_orapki.p12", "cwallet_orapki.sso"]);
let with_pw =
load_wallet(&dir, Some("WalletPass123")).expect("p12 path must load with password");
assert!(with_pw.has_client_identity());
let without_pw = load_wallet(&dir, None).expect("sso path must load without password");
assert!(without_pw.has_client_identity());
assert_eq!(with_pw.ca_certificates, without_pw.ca_certificates);
}
#[test]
fn wallet_dir_prefers_pem_over_p12_and_sso() {
let dir = temp_wallet_dir(
"pem-first",
&["ewallet.pem", "ewallet_orapki.p12", "cwallet_orapki.sso"],
);
let wallet = load_wallet(&dir, None).expect("pem path must load");
assert!(wallet.has_client_identity());
use oracledb_protocol::tls::wallet::parse_ewallet_pem;
let pem_bytes =
std::fs::read(fixture_tls_dir().join("ewallet.pem")).expect("read pem fixture");
let direct = parse_ewallet_pem(&pem_bytes, None).expect("parse pem fixture");
assert_eq!(wallet.ca_certificates, direct.ca_certificates);
}
#[test]
fn unusable_p12_falls_through_to_auto_login_sso() {
let dir = temp_wallet_dir("fallthrough", &["ewallet_orapki.p12", "cwallet_orapki.sso"]);
let fell_through = load_wallet(&dir, Some("not-the-password!"))
.expect("wrong p12 password must fall through to the auto-login cwallet.sso");
assert!(fell_through.has_client_identity());
let sso_only = temp_wallet_dir("fallthrough-sso", &["cwallet_orapki.sso"]);
let direct = load_wallet(&sso_only, None).expect("sso path must load");
assert_eq!(fell_through.ca_certificates, direct.ca_certificates);
assert_eq!(fell_through.client_private_key, direct.client_private_key);
}
#[test]
fn unusable_p12_without_sso_preserves_original_typed_error() {
let dir = temp_wallet_dir("no-sso", &["ewallet_orapki.p12"]);
let err = load_wallet(&dir, Some("not-the-password!"))
.expect_err("wrong p12 password with no sso must fail closed");
let wallet_err = if let Error::Wallet(wallet_err) = err {
wallet_err
} else {
panic!("expected wallet error, got {err:?}");
};
assert!(
matches!(
&wallet_err,
oracledb_protocol::tls::wallet::WalletError::Pkcs12(_)
),
"expected the original typed Pkcs12 error, got {wallet_err:?}"
);
for rendered in [format!("{wallet_err}"), format!("{wallet_err:?}")] {
assert!(!rendered.contains("not-the-password!"), "password leaked");
let lower = rendered.to_ascii_lowercase();
assert!(
!lower.contains("fall") && !lower.contains("auto-login") && !lower.contains("sso"),
"preserved error must not mention the fallthrough, got {rendered:?}"
);
}
}
#[test]
fn resolve_wallet_reports_pem_first_no_fallthrough() {
let dir = temp_wallet_dir(
"resolve-pem-first",
&["ewallet.pem", "ewallet_orapki.p12", "cwallet_orapki.sso"],
);
let res = resolve_wallet(&dir, None).expect("pem must resolve");
assert_eq!(res.chosen, WalletFile::Pem);
assert_eq!(res.attempted_primary, Some(WalletFile::Pem));
assert!(!res.fell_through);
assert!(!res.fallthrough_eligible);
assert_eq!(res.chosen.file_name(), "ewallet.pem");
}
#[test]
fn resolve_wallet_adb_dir_reports_p12_with_password_sso_without() {
let dir = temp_wallet_dir("resolve-adb", &["ewallet_orapki.p12", "cwallet_orapki.sso"]);
let with_pw =
resolve_wallet(&dir, Some("WalletPass123")).expect("p12 resolves with password");
assert_eq!(with_pw.chosen, WalletFile::P12);
assert_eq!(with_pw.attempted_primary, Some(WalletFile::P12));
assert!(!with_pw.fell_through);
assert!(!with_pw.fallthrough_eligible);
let without_pw = resolve_wallet(&dir, None).expect("sso resolves without password");
assert_eq!(without_pw.chosen, WalletFile::Sso);
assert_eq!(without_pw.attempted_primary, None);
assert!(!without_pw.fell_through);
assert!(!without_pw.fallthrough_eligible);
assert_eq!(without_pw.chosen.file_name(), "cwallet.sso");
}
#[test]
fn resolve_wallet_unusable_p12_reports_fallthrough_to_sso() {
let dir = temp_wallet_dir(
"resolve-fallthrough",
&["ewallet_orapki.p12", "cwallet_orapki.sso"],
);
let res = resolve_wallet(&dir, Some("not-the-password!"))
.expect("wrong p12 password falls through to cwallet.sso");
assert_eq!(res.chosen, WalletFile::Sso);
assert_eq!(res.attempted_primary, Some(WalletFile::P12));
assert!(res.fell_through);
assert!(res.fallthrough_eligible);
}
#[test]
fn resolve_wallet_does_not_drift_from_load_wallet() {
let dir = temp_wallet_dir(
"resolve-parity",
&["ewallet_orapki.p12", "cwallet_orapki.sso"],
);
let res = resolve_wallet(&dir, Some("WalletPass123")).expect("resolve with password");
assert_eq!(res.chosen, WalletFile::P12);
let loaded = load_wallet(&dir, Some("WalletPass123")).expect("load with password");
assert!(loaded.has_client_identity());
}
#[test]
fn resolve_wallet_empty_dir_is_typed_wallet_error() {
let dir = temp_wallet_dir("resolve-empty", &[]);
let err = resolve_wallet(&dir, None).expect_err("empty wallet dir must error");
assert!(
matches!(err, Error::Wallet(_)),
"expected wallet error, got {err:?}"
);
}
}