Skip to main content

oracledb_protocol/net/
connectstring.rs

1#![forbid(unsafe_code)]
2//! Real, full-fidelity Oracle connect-string parsing.
3//!
4//! This module parses the three connect-string forms understood by
5//! python-oracledb thin mode, matching the reference parser
6//! (`impl/base/parsers.pyx` / `connect_params.pyx`) semantics:
7//!
8//!   1. **TNS connect descriptors** —
9//!      `(DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(HOST=..)(PORT=..)))
10//!      (CONNECT_DATA=(SERVICE_NAME=..)))`, including `DESCRIPTION_LIST`,
11//!      multiple `ADDRESS_LIST`/`ADDRESS`, `LOAD_BALANCE`/`FAILOVER`/
12//!      `SOURCE_ROUTE`, `RETRY_COUNT`/`RETRY_DELAY`, `EXPIRE_TIME`,
13//!      `TRANSPORT_CONNECT_TIMEOUT`, `SDU`, `SECURITY` (wallet / cert DN), and
14//!      arbitrary pass-through keys. Case-insensitive keywords, nested parens,
15//!      quoted values, and whitespace tolerance.
16//!
17//!   2. **EZConnect / EZConnect-Plus** —
18//!      `[proto://]host[,host2][:port][/service][:server][/instance][?k=v&..]`,
19//!      including multiple hosts, multiple address lists (`;`), IPv6 `[::1]`,
20//!      and the extended `?key=value` parameters.
21//!
22//!   3. **tnsnames.ora** — alias -> descriptor maps with comments (`#`),
23//!      multi-line entries, comma-separated alias lists, and `IFILE` includes
24//!      (with cycle detection), resolved relative to `TNS_ADMIN` / a config dir.
25//!
26//! Beyond parity, the parser produces **rich diagnostics**: every error points
27//! at the offending byte offset with surrounding context, and [`Descriptor`]
28//! offers a [`Descriptor::describe`] troubleshooting dump of the resolved
29//! address list and connect data.
30
31use crate::{ProtocolError, Result};
32
33/// Default listener port when none is given (reference `DEFAULT_PORT`).
34pub const DEFAULT_PORT: u16 = 1521;
35/// Default TCPS listener port.
36pub const DEFAULT_TCPS_PORT: u16 = 2484;
37/// Default SDU in bytes (reference `DEFAULT_SDU`).
38pub const DEFAULT_SDU: u32 = 8192;
39/// Minimum SDU after sanitisation.
40pub const MIN_SDU: u32 = 512;
41/// Maximum SDU after sanitisation.
42pub const MAX_SDU: u32 = 2_097_152;
43/// Default retry delay (reference `DEFAULT_RETRY_DELAY`).
44pub const DEFAULT_RETRY_DELAY: u32 = 1;
45/// Default transport connect timeout in seconds.
46pub const DEFAULT_TCP_CONNECT_TIMEOUT: f64 = 20.0;
47
48/// Transport protocol parsed from an `ADDRESS` `PROTOCOL=` or an EZConnect
49/// `proto://` prefix.
50#[derive(Clone, Copy, Debug, Eq, PartialEq, Default)]
51pub enum Protocol {
52    /// Plain TCP (default); default port 1521.
53    #[default]
54    Tcp,
55    /// TLS-encrypted TCP; default port 2484.
56    Tcps,
57}
58
59impl Protocol {
60    /// Default listener port for this protocol.
61    #[must_use]
62    pub fn default_port(self) -> u16 {
63        match self {
64            Self::Tcp => DEFAULT_PORT,
65            Self::Tcps => DEFAULT_TCPS_PORT,
66        }
67    }
68
69    /// Returns whether this protocol requires a TLS handshake.
70    #[must_use]
71    pub fn is_tls(self) -> bool {
72        matches!(self, Self::Tcps)
73    }
74
75    /// Lower-case keyword as it appears in a connect string.
76    #[must_use]
77    pub fn as_str(self) -> &'static str {
78        match self {
79            Self::Tcp => "tcp",
80            Self::Tcps => "tcps",
81        }
82    }
83
84    fn from_keyword(value: &str) -> Result<Self> {
85        match value.to_ascii_lowercase().as_str() {
86            "tcp" => Ok(Self::Tcp),
87            "tcps" => Ok(Self::Tcps),
88            other => Err(ProtocolError::InvalidConnectDescriptor(format!(
89                "invalid protocol \"{other}\""
90            ))),
91        }
92    }
93}
94
95/// Database server connection mode (`(SERVER=..)` / `:server` in EZConnect).
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum ServerType {
98    /// A dedicated server process.
99    Dedicated,
100    /// A shared (multi-threaded) server.
101    Shared,
102    /// A DRCP pooled server.
103    Pooled,
104}
105
106impl ServerType {
107    /// Lower-case keyword as it appears in a connect string.
108    #[must_use]
109    pub fn as_str(self) -> &'static str {
110        match self {
111            Self::Dedicated => "dedicated",
112            Self::Shared => "shared",
113            Self::Pooled => "pooled",
114        }
115    }
116
117    fn from_keyword(value: &str) -> Result<Self> {
118        match value.to_ascii_lowercase().as_str() {
119            "dedicated" => Ok(Self::Dedicated),
120            "shared" => Ok(Self::Shared),
121            "pooled" => Ok(Self::Pooled),
122            other => Err(ProtocolError::InvalidConnectDescriptor(format!(
123                "invalid server_type: {other}"
124            ))),
125        }
126    }
127}
128
129/// DRCP connection-pool purity (`(POOL_PURITY=..)`).
130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
131pub enum Purity {
132    /// Reuse a session from the pool as-is.
133    Self_,
134    /// Force a brand-new session.
135    New,
136}
137
138impl Purity {
139    fn from_keyword(value: &str) -> Result<Self> {
140        match value.to_ascii_uppercase().as_str() {
141            "SELF" => Ok(Self::Self_),
142            "NEW" => Ok(Self::New),
143            other => Err(ProtocolError::InvalidConnectDescriptor(format!(
144                "invalid value for enum Purity: {other}"
145            ))),
146        }
147    }
148}
149
150/// A single resolved network endpoint (one `ADDRESS` node).
151#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct Address {
153    /// Host name or IP literal.
154    pub host: Option<String>,
155    /// Listener port.
156    pub port: u16,
157    /// Transport protocol.
158    pub protocol: Protocol,
159    /// Optional forward proxy host.
160    pub https_proxy: Option<String>,
161    /// Optional forward proxy port (0 = unset).
162    pub https_proxy_port: u16,
163}
164
165impl Default for Address {
166    fn default() -> Self {
167        Self {
168            host: None,
169            port: DEFAULT_PORT,
170            protocol: Protocol::Tcp,
171            https_proxy: None,
172            https_proxy_port: 0,
173        }
174    }
175}
176
177/// A group of [`Address`]es (one `ADDRESS_LIST` node) plus its navigation flags.
178#[derive(Clone, Debug, Default, Eq, PartialEq)]
179pub struct AddressList {
180    /// Member addresses.
181    pub addresses: Vec<Address>,
182    /// `LOAD_BALANCE=ON` randomises address order.
183    pub load_balance: bool,
184    /// `FAILOVER=OFF` disables trying alternate addresses.
185    pub failover: bool,
186    /// `SOURCE_ROUTE=ON` chains through the addresses in order.
187    pub source_route: bool,
188}
189
190/// Resolved `CONNECT_DATA` settings.
191#[derive(Clone, Debug, Default, Eq, PartialEq)]
192pub struct ConnectData {
193    /// `SERVICE_NAME=`.
194    pub service_name: Option<String>,
195    /// `SID=`.
196    pub sid: Option<String>,
197    /// `INSTANCE_NAME=`.
198    pub instance_name: Option<String>,
199    /// `SERVER=` (dedicated / shared / pooled).
200    pub server_type: Option<ServerType>,
201    /// `POOL_CONNECTION_CLASS=`.
202    pub cclass: Option<String>,
203    /// `POOL_PURITY=`.
204    pub purity: Option<Purity>,
205    /// `POOL_BOUNDARY=`.
206    pub pool_boundary: Option<String>,
207    /// `POOL_NAME=`.
208    pub pool_name: Option<String>,
209    /// `CONNECTION_ID_PREFIX=`.
210    pub connection_id_prefix: Option<String>,
211    /// `USE_TCP_FAST_OPEN=ON`.
212    pub use_tcp_fast_open: bool,
213    /// Unrecognised CONNECT_DATA keys, passed through to the listener verbatim
214    /// (uppercased key -> reconstructed value).
215    pub extra: Vec<(String, String)>,
216}
217
218/// Resolved `SECURITY` settings (only meaningful for TCPS addresses).
219#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct Security {
221    /// `SSL_SERVER_DN_MATCH` (defaults to true).
222    pub ssl_server_dn_match: bool,
223    /// `SSL_SERVER_CERT_DN=`.
224    pub ssl_server_cert_dn: Option<String>,
225    /// `MY_WALLET_DIRECTORY=` / `WALLET_LOCATION=`.
226    pub wallet_location: Option<String>,
227    /// Unrecognised SECURITY keys, passed through verbatim.
228    pub extra: Vec<(String, String)>,
229}
230
231impl Default for Security {
232    fn default() -> Self {
233        Self {
234            ssl_server_dn_match: true,
235            ssl_server_cert_dn: None,
236            wallet_location: None,
237            extra: Vec::new(),
238        }
239    }
240}
241
242/// A single resolved `DESCRIPTION` node.
243#[derive(Clone, Debug, PartialEq)]
244pub struct Description {
245    /// Address lists belonging to this description.
246    pub address_lists: Vec<AddressList>,
247    /// `CONNECT_DATA` settings.
248    pub connect_data: ConnectData,
249    /// `SECURITY` settings.
250    pub security: Security,
251    /// `RETRY_COUNT=`.
252    pub retry_count: u32,
253    /// `RETRY_DELAY=` (seconds).
254    pub retry_delay: u32,
255    /// `EXPIRE_TIME=` (minutes; TCP keepalive).
256    pub expire_time: u32,
257    /// `TRANSPORT_CONNECT_TIMEOUT` / `CONNECT_TIMEOUT` (seconds).
258    pub tcp_connect_timeout: f64,
259    /// `SDU=` (sanitised into [`MIN_SDU`]..=[`MAX_SDU`]).
260    pub sdu: u32,
261    /// `LOAD_BALANCE=ON`.
262    pub load_balance: bool,
263    /// `FAILOVER=OFF`.
264    pub failover: bool,
265    /// `SOURCE_ROUTE=ON`.
266    pub source_route: bool,
267    /// `USE_SNI=ON`.
268    pub use_sni: bool,
269    /// Unrecognised DESCRIPTION keys, passed through verbatim.
270    pub extra: Vec<(String, String)>,
271}
272
273impl Default for Description {
274    fn default() -> Self {
275        Self {
276            address_lists: Vec::new(),
277            connect_data: ConnectData::default(),
278            security: Security::default(),
279            retry_count: 0,
280            retry_delay: DEFAULT_RETRY_DELAY,
281            expire_time: 0,
282            tcp_connect_timeout: DEFAULT_TCP_CONNECT_TIMEOUT,
283            sdu: DEFAULT_SDU,
284            load_balance: false,
285            failover: true,
286            source_route: false,
287            use_sni: false,
288            extra: Vec::new(),
289        }
290    }
291}
292
293impl Description {
294    /// Iterator over every [`Address`] across all address lists, in order.
295    pub fn addresses(&self) -> impl Iterator<Item = &Address> {
296        self.address_lists
297            .iter()
298            .flat_map(|list| list.addresses.iter())
299    }
300}
301
302/// A fully parsed connect string: one or more [`Description`]s.
303#[derive(Clone, Debug, PartialEq)]
304pub struct Descriptor {
305    /// Member descriptions (one for a plain `DESCRIPTION`, several for a
306    /// `DESCRIPTION_LIST` or multi-address-list EZConnect).
307    pub descriptions: Vec<Description>,
308    /// `DESCRIPTION_LIST` `LOAD_BALANCE=ON`.
309    pub load_balance: bool,
310    /// `DESCRIPTION_LIST` `FAILOVER=OFF`.
311    pub failover: bool,
312    /// `DESCRIPTION_LIST` `SOURCE_ROUTE=ON`.
313    pub source_route: bool,
314}
315
316impl Descriptor {
317    /// The first description (always present for a successfully parsed string).
318    #[must_use]
319    pub fn first_description(&self) -> &Description {
320        &self.descriptions[0]
321    }
322
323    /// Iterator over every [`Address`] across all descriptions, in order.
324    pub fn addresses(&self) -> impl Iterator<Item = &Address> {
325        self.descriptions.iter().flat_map(Description::addresses)
326    }
327
328    /// The first address that has a host, if any.
329    #[must_use]
330    pub fn first_address(&self) -> Option<&Address> {
331        self.addresses().find(|addr| addr.host.is_some())
332    }
333
334    /// Human-readable troubleshooting dump of the resolved address list and
335    /// connect data — the differentiator over python-oracledb's terse errors.
336    #[must_use]
337    pub fn describe(&self) -> String {
338        let mut out = String::new();
339        out.push_str("Descriptor {\n");
340        if self.descriptions.len() > 1 || self.load_balance || self.source_route || !self.failover {
341            out.push_str(&format!(
342                "  description_list: load_balance={}, failover={}, source_route={}\n",
343                self.load_balance, self.failover, self.source_route
344            ));
345        }
346        for (di, desc) in self.descriptions.iter().enumerate() {
347            out.push_str(&format!("  description[{di}]:\n"));
348            for (li, list) in desc.address_lists.iter().enumerate() {
349                out.push_str(&format!(
350                    "    address_list[{li}]: load_balance={}, failover={}, source_route={}\n",
351                    list.load_balance, list.failover, list.source_route
352                ));
353                for addr in &list.addresses {
354                    out.push_str(&format!(
355                        "      {}://{}:{}\n",
356                        addr.protocol.as_str(),
357                        addr.host.as_deref().unwrap_or("<none>"),
358                        addr.port
359                    ));
360                }
361            }
362            let cd = &desc.connect_data;
363            out.push_str("    connect_data:");
364            if let Some(s) = &cd.service_name {
365                out.push_str(&format!(" service_name={s}"));
366            }
367            if let Some(s) = &cd.sid {
368                out.push_str(&format!(" sid={s}"));
369            }
370            if let Some(s) = &cd.instance_name {
371                out.push_str(&format!(" instance_name={s}"));
372            }
373            if let Some(s) = cd.server_type {
374                out.push_str(&format!(" server={}", s.as_str()));
375            }
376            out.push('\n');
377            if desc.retry_count != 0 {
378                out.push_str(&format!(
379                    "    retry_count={}, retry_delay={}\n",
380                    desc.retry_count, desc.retry_delay
381                ));
382            }
383        }
384        out.push('}');
385        out
386    }
387}
388
389/// Parses a connect string into a [`Descriptor`].
390///
391/// Accepts a TNS connect descriptor (when the first non-space character is `(`)
392/// or an EZConnect / EZConnect-Plus string otherwise. Returns
393/// [`ProtocolError::InvalidConnectDescriptor`] with offset/context diagnostics
394/// on malformed input, or `Ok(None)` when the string is neither (i.e. it is a
395/// tnsnames.ora alias to be resolved separately).
396pub fn parse(connect_string: &str) -> Result<Option<Descriptor>> {
397    let trimmed = connect_string.trim();
398    if trimmed.is_empty() {
399        return Err(err_descriptor(
400            connect_string,
401            0,
402            "connect string must not be empty",
403        ));
404    }
405    let chars: Vec<char> = trimmed.chars().collect();
406    if chars[0] == '(' {
407        let mut parser = DescriptorParser::new(&chars, connect_string);
408        parser.pos = 1;
409        parser.temp_pos = 1;
410        let args = parser.parse_descriptor()?;
411        let descriptor = build_descriptor(connect_string, &args)?;
412        // The whole input must be consumed; mirror the reference's trailing
413        // check (it raises ERR_CANNOT_PARSE_CONNECT_STRING).
414        if parser.pos != chars.len() {
415            return Err(err_cannot_parse(connect_string));
416        }
417        Ok(Some(descriptor))
418    } else {
419        easy_connect::parse(&chars, connect_string)
420    }
421}
422
423/// EZConnect / EZConnect-Plus parsing.
424///
425/// Mirrors the reference `_parse_easy_connect*` methods: it parses an optional
426/// `proto://` prefix, one or more comma/semicolon-separated hosts (with IPv6
427/// brackets), an optional `:port`, an optional `/service[:server]`, an optional
428/// `/instance`, and an optional `?key=value&...` extended-parameter section.
429mod easy_connect {
430    use super::*;
431
432    /// Private sentinel keys used to stash `https_proxy` host/port until the
433    /// address lists are assembled (these never reach the public `extra` list).
434    const PROXY_HOST_KEY: &str = "\0https_proxy_host";
435    const PROXY_PORT_KEY: &str = "\0https_proxy_port";
436
437    /// Common EZConnect-Plus parameters recognised by all drivers (reference
438    /// `COMMON_PARAM_NAMES`); the value is the canonical name.
439    fn is_common_param(name: &str) -> bool {
440        matches!(
441            name,
442            "expire_time"
443                | "failover"
444                | "https_proxy"
445                | "https_proxy_port"
446                | "load_balance"
447                | "pool_boundary"
448                | "pool_name"
449                | "pool_connection_class"
450                | "pool_purity"
451                | "retry_count"
452                | "retry_delay"
453                | "sdu"
454                | "source_route"
455                | "ssl_server_cert_dn"
456                | "ssl_server_dn_match"
457                | "transport_connect_timeout"
458                | "use_sni"
459                | "wallet_location"
460        )
461    }
462
463    /// Extra DESCRIPTION params passed through when seen in an easy connect
464    /// string (reference `EXTRA_DESCRIPTION_PARAM_NAMES`).
465    fn is_extra_description_param(name: &str) -> bool {
466        matches!(name, "enable" | "recv_buf_size" | "send_buf_size")
467    }
468
469    fn is_host_or_service_char(ch: char) -> bool {
470        ch.is_alphanumeric() || matches!(ch, '-' | '_' | '.')
471    }
472
473    /// Parser state for an EZConnect string.
474    struct Ez<'a> {
475        chars: &'a [char],
476        pos: usize,
477        temp_pos: usize,
478    }
479
480    impl<'a> Ez<'a> {
481        fn current(&self) -> char {
482            self.chars[self.temp_pos]
483        }
484
485        fn skip_spaces(&mut self) {
486            while self.temp_pos < self.chars.len() && self.chars[self.temp_pos].is_whitespace() {
487                self.temp_pos += 1;
488            }
489        }
490
491        fn parse_keyword(&mut self) {
492            while self.temp_pos < self.chars.len() {
493                let ch = self.current();
494                if !ch.is_alphanumeric() && ch != '_' && ch != '.' {
495                    break;
496                }
497                self.temp_pos += 1;
498            }
499        }
500
501        /// Parses an optional `proto://` prefix. Returns the protocol keyword
502        /// (lower-cased) if one was found, advancing `pos` past the `//`.
503        /// Mirrors `_parse_easy_connect_protocol`.
504        fn parse_protocol(&mut self) -> Option<String> {
505            let mut start_sep_pos = self.pos;
506            let mut num_sep_chars = 0i32;
507            let mut protocol: Option<String> = None;
508            self.temp_pos = self.pos;
509            while self.temp_pos < self.chars.len() {
510                let ch = self.current();
511                if ch == ':' {
512                    protocol = Some(
513                        self.chars[self.pos..self.temp_pos]
514                            .iter()
515                            .collect::<String>()
516                            .to_ascii_lowercase(),
517                    );
518                    start_sep_pos = self.temp_pos + 1;
519                } else if ch == '/' && (self.temp_pos - start_sep_pos) as i32 == num_sep_chars {
520                    num_sep_chars += 1;
521                    if num_sep_chars == 2 {
522                        self.temp_pos += 1;
523                        self.pos = self.temp_pos;
524                        break;
525                    }
526                } else if !ch.is_alphabetic() && ch != '-' && ch != '_' {
527                    break;
528                }
529                self.temp_pos += 1;
530            }
531            if protocol.is_some() && num_sep_chars == 2 {
532                protocol
533            } else {
534                None
535            }
536        }
537
538        /// Parses one host (optionally bracketed IPv6). Mirrors
539        /// `_parse_easy_connect_host`.
540        fn parse_host(&mut self, address: &mut Address) {
541            let mut found_bracket = false;
542            let mut found_host = false;
543            let mut start_pos = self.temp_pos;
544            while self.temp_pos < self.chars.len() {
545                let ch = self.current();
546                if !found_bracket && !found_host && ch == '[' {
547                    found_bracket = true;
548                    start_pos = self.temp_pos + 1;
549                } else if found_bracket && ch == ']' {
550                    address.host = Some(self.chars[start_pos..self.temp_pos].iter().collect());
551                    self.temp_pos += 1;
552                    self.pos = self.temp_pos;
553                    break;
554                } else if found_bracket || is_host_or_service_char(ch) {
555                    self.temp_pos += 1;
556                    found_host = true;
557                } else {
558                    if found_host {
559                        address.host = Some(self.chars[start_pos..self.temp_pos].iter().collect());
560                        self.pos = self.temp_pos;
561                    }
562                    break;
563                }
564            }
565            // Handle a host that runs to end-of-string.
566            if found_host && self.temp_pos == self.chars.len() && address.host.is_none() {
567                address.host = Some(self.chars[start_pos..self.temp_pos].iter().collect());
568                self.pos = self.temp_pos;
569            }
570        }
571
572        /// Parses a port number. Mirrors `_parse_easy_connect_port`.
573        fn parse_port(&mut self, address: &mut Address) {
574            let start = self.temp_pos;
575            let mut found = false;
576            while self.temp_pos < self.chars.len() && self.current().is_ascii_digit() {
577                found = true;
578                self.temp_pos += 1;
579            }
580            if found {
581                let digits: String = self.chars[start..self.temp_pos].iter().collect();
582                if let Ok(port) = digits.parse::<u16>() {
583                    address.port = port;
584                }
585            }
586        }
587    }
588
589    /// Builds the host/address-list portion of an EZConnect string into a list
590    /// of address lists, plus the description that owns them. Mirrors
591    /// `_parse_easy_connect_hosts`.
592    #[allow(clippy::too_many_lines)]
593    pub(super) fn parse(chars: &[char], connect_string: &str) -> Result<Option<Descriptor>> {
594        let mut ez = Ez {
595            chars,
596            pos: 0,
597            temp_pos: 0,
598        };
599
600        // protocol prefix
601        let template_protocol = match ez.parse_protocol() {
602            Some(protocol) => Protocol::from_keyword(&protocol)?,
603            None => Protocol::Tcp,
604        };
605
606        // Hosts: a series of host names separated by commas (same list) or
607        // semicolons (new list).
608        let mut address_lists: Vec<Vec<Address>> = Vec::new();
609        let mut current_list: Vec<Address> = Vec::new();
610        ez.temp_pos = ez.pos;
611        let mut port_index = 0usize;
612        loop {
613            let mut address = Address {
614                protocol: template_protocol,
615                port: template_protocol.default_port(),
616                ..Address::default()
617            };
618            ez.parse_host(&mut address);
619            // No host consumed and not at end: stop (no more hosts).
620            if ez.temp_pos != ez.pos || ez.pos >= chars.len() {
621                // If a host was parsed and we're at end, it was committed by
622                // parse_host setting pos == temp_pos == len.
623                if ez.pos >= chars.len() && address.host.is_some() {
624                    current_list.push(address);
625                }
626                break;
627            }
628            ez.pos = ez.temp_pos;
629            current_list.push(address);
630            if ez.temp_pos >= chars.len() {
631                break;
632            }
633            let mut ch = ez.current();
634            if ch == ':' {
635                ez.temp_pos += 1;
636                if let Some(last) = current_list.last_mut() {
637                    ez.parse_port(last);
638                    let port = last.port;
639                    ez.pos = ez.temp_pos;
640                    if ez.pos >= chars.len() {
641                        break;
642                    }
643                    // Back-fill the port onto earlier hosts in this list that
644                    // had no explicit port (reference port_index loop).
645                    let upper = current_list.len() - 1;
646                    for addr in current_list.iter_mut().take(upper).skip(port_index) {
647                        addr.port = port;
648                    }
649                    port_index = current_list.len();
650                }
651                ch = ez.current();
652            }
653            if ch == ';' {
654                address_lists.push(std::mem::take(&mut current_list));
655                port_index = 0;
656            } else if ch != ',' {
657                break;
658            }
659            ez.temp_pos += 1;
660        }
661        address_lists.push(current_list);
662
663        // service name / server type, then instance name, then parameters.
664        let mut description = Description::default();
665        let mut found_service_section = false;
666        parse_service_name(&mut ez, chars, &mut description, &mut found_service_section);
667        if found_service_section {
668            parse_instance_name(&mut ez, chars, &mut description);
669        }
670
671        // If no `/` was ever seen, this is not a valid EZConnect string — it is
672        // a tnsnames.ora alias to resolve separately (reference returns None).
673        if !found_service_section {
674            return Ok(None);
675        }
676
677        parse_parameters(&mut ez, chars, connect_string, &mut description)?;
678
679        // Trailing data after a successful parse is an error.
680        if ez.pos != chars.len() {
681            if ez.pos > 0 {
682                return Err(err_cannot_parse(connect_string));
683            }
684            return Ok(None);
685        }
686
687        // Assemble the descriptor: each non-empty host group becomes an address
688        // list; a lone single list collapses into the description directly.
689        let mut lists: Vec<AddressList> = Vec::new();
690        for hosts in address_lists {
691            if hosts.is_empty() {
692                continue;
693            }
694            lists.push(AddressList {
695                addresses: hosts,
696                failover: true,
697                ..AddressList::default()
698            });
699        }
700        if lists.is_empty() {
701            return Ok(None);
702        }
703        description.address_lists = lists;
704
705        // Apply any stashed https_proxy host/port onto every address, then drop
706        // the sentinel entries from `extra`.
707        let proxy_host = description
708            .extra
709            .iter()
710            .find(|(k, _)| k == PROXY_HOST_KEY)
711            .map(|(_, v)| v.clone());
712        let proxy_port = description
713            .extra
714            .iter()
715            .find(|(k, _)| k == PROXY_PORT_KEY)
716            .and_then(|(_, v)| v.parse::<u16>().ok());
717        description
718            .extra
719            .retain(|(k, _)| k != PROXY_HOST_KEY && k != PROXY_PORT_KEY);
720        if proxy_host.is_some() || proxy_port.is_some() {
721            for list in &mut description.address_lists {
722                for addr in &mut list.addresses {
723                    if let Some(host) = &proxy_host {
724                        addr.https_proxy = Some(host.clone());
725                    }
726                    if let Some(port) = proxy_port {
727                        addr.https_proxy_port = port;
728                    }
729                }
730            }
731        }
732
733        Ok(Some(Descriptor {
734            descriptions: vec![description],
735            load_balance: false,
736            failover: true,
737            source_route: false,
738        }))
739    }
740
741    /// Mirrors `_parse_easy_connect_service_name`.
742    fn parse_service_name(
743        ez: &mut Ez,
744        chars: &[char],
745        description: &mut Description,
746        found_slash_out: &mut bool,
747    ) {
748        let mut found_service_name = false;
749        let mut found_server_type = false;
750        let mut found_slash = false;
751        let mut found_colon = false;
752        let mut service_name_end_pos = 0usize;
753        ez.temp_pos = ez.pos;
754        while ez.temp_pos < chars.len() {
755            let ch = ez.current();
756            if !found_slash && ch == '/' {
757                found_slash = true;
758            } else if found_service_name && !found_colon && ch == ':' {
759                found_colon = true;
760            } else if found_slash && !found_colon && is_host_or_service_char(ch) {
761                found_service_name = true;
762                service_name_end_pos = ez.temp_pos + 1;
763            } else if found_colon && ch.is_alphabetic() {
764                found_server_type = true;
765            } else {
766                break;
767            }
768            ez.temp_pos += 1;
769        }
770        if found_service_name {
771            description.connect_data.service_name =
772                Some(chars[ez.pos + 1..service_name_end_pos].iter().collect());
773        }
774        if found_slash {
775            ez.pos = ez.temp_pos;
776            *found_slash_out = true;
777        }
778        if found_server_type {
779            let value: String = chars[service_name_end_pos + 1..ez.temp_pos]
780                .iter()
781                .collect();
782            if let Ok(server_type) = ServerType::from_keyword(&value) {
783                description.connect_data.server_type = Some(server_type);
784            }
785        }
786    }
787
788    /// Mirrors `_parse_easy_connect_instance_name`.
789    fn parse_instance_name(ez: &mut Ez, chars: &[char], description: &mut Description) {
790        let mut found_instance_name = false;
791        let mut found_slash = false;
792        let mut instance_name_end_pos = 0usize;
793        ez.temp_pos = ez.pos;
794        while ez.temp_pos < chars.len() {
795            let ch = ez.current();
796            if !found_slash && ch == '/' {
797                found_slash = true;
798            } else if found_slash && is_host_or_service_char(ch) {
799                found_instance_name = true;
800                instance_name_end_pos = ez.temp_pos + 1;
801            } else {
802                break;
803            }
804            ez.temp_pos += 1;
805        }
806        if found_instance_name {
807            description.connect_data.instance_name =
808                Some(chars[ez.pos + 1..instance_name_end_pos].iter().collect());
809            ez.pos = ez.temp_pos;
810        }
811    }
812
813    /// Mirrors `_parse_easy_connect_parameters` + `_parse_easy_connect_parameter`.
814    fn parse_parameters(
815        ez: &mut Ez,
816        chars: &[char],
817        connect_string: &str,
818        description: &mut Description,
819    ) -> Result<()> {
820        let mut expected_sep = '?';
821        ez.temp_pos = ez.pos;
822        while ez.temp_pos < chars.len() {
823            let ch = ez.current();
824            if ch != expected_sep {
825                break;
826            }
827            expected_sep = '&';
828            ez.temp_pos += 1;
829            parse_one_parameter(ez, chars, connect_string, description)?;
830        }
831        Ok(())
832    }
833
834    fn parse_one_parameter(
835        ez: &mut Ez,
836        chars: &[char],
837        connect_string: &str,
838        description: &mut Description,
839    ) -> Result<()> {
840        // parameter name
841        ez.skip_spaces();
842        let start = ez.temp_pos;
843        ez.parse_keyword();
844        if ez.temp_pos == start || ez.temp_pos >= chars.len() {
845            return Ok(());
846        }
847        let raw_name: String = chars[start..ez.temp_pos]
848            .iter()
849            .collect::<String>()
850            .to_ascii_lowercase();
851        let (name, keep) = if let Some(stripped) = raw_name.strip_prefix("pyo.") {
852            (stripped.to_string(), true)
853        } else {
854            let keep = is_common_param(&raw_name) || is_extra_description_param(&raw_name);
855            (canonical_param_name(&raw_name).to_string(), keep)
856        };
857
858        // equals sign
859        ez.skip_spaces();
860        if ez.temp_pos >= chars.len() {
861            return Ok(());
862        }
863        if ez.current() != '=' {
864            return Ok(());
865        }
866        ez.temp_pos += 1;
867
868        // value
869        ez.skip_spaces();
870        let mut start_pos = ez.temp_pos;
871        let mut end_pos = ez.temp_pos;
872        while ez.temp_pos < chars.len() {
873            let ch = ez.current();
874            if ch == '"' {
875                if ez.temp_pos > start_pos {
876                    return Ok(());
877                }
878                ez.temp_pos += 1;
879                start_pos = ez.temp_pos;
880                // parse quoted string
881                let mut closed = false;
882                while ez.temp_pos < chars.len() {
883                    let qc = ez.current();
884                    ez.temp_pos += 1;
885                    if qc == '"' {
886                        closed = true;
887                        break;
888                    }
889                }
890                if !closed {
891                    return Err(err_descriptor(
892                        connect_string,
893                        ez.temp_pos,
894                        "missing ending quote (\")",
895                    ));
896                }
897                end_pos = ez.temp_pos - 1;
898                break;
899            } else if ch == '&' {
900                end_pos = ez.temp_pos;
901                break;
902            }
903            ez.temp_pos += 1;
904            end_pos = ez.temp_pos;
905        }
906        if end_pos > start_pos && keep {
907            let value: String = chars[start_pos..end_pos].iter().collect();
908            apply_easy_param(connect_string, description, &name, &value)?;
909        }
910        ez.skip_spaces();
911        ez.pos = ez.temp_pos;
912        Ok(())
913    }
914
915    /// Applies a recognised EZConnect-Plus parameter onto the description.
916    fn apply_easy_param(
917        connect_string: &str,
918        description: &mut Description,
919        name: &str,
920        value: &str,
921    ) -> Result<()> {
922        match name {
923            "expire_time" => {
924                description.expire_time = parse_uint(connect_string, "EXPIRE_TIME", value)?
925            }
926            "retry_count" => {
927                description.retry_count = parse_uint(connect_string, "RETRY_COUNT", value)?
928            }
929            "retry_delay" => {
930                description.retry_delay = parse_uint(connect_string, "RETRY_DELAY", value)?
931            }
932            "sdu" => {
933                description.sdu = parse_uint(connect_string, "SDU", value)?.clamp(MIN_SDU, MAX_SDU);
934            }
935            "tcp_connect_timeout" => {
936                description.tcp_connect_timeout =
937                    parse_duration(connect_string, "TRANSPORT_CONNECT_TIMEOUT", value)?;
938            }
939            "failover" => description.failover = parse_bool(value),
940            "load_balance" => description.load_balance = parse_bool(value),
941            "source_route" => description.source_route = parse_bool(value),
942            "use_sni" => description.use_sni = parse_bool(value),
943            "ssl_server_dn_match" => description.security.ssl_server_dn_match = parse_bool(value),
944            "ssl_server_cert_dn" => {
945                description.security.ssl_server_cert_dn = Some(value.to_string());
946            }
947            "wallet_location" => description.security.wallet_location = Some(value.to_string()),
948            // https_proxy / https_proxy_port are applied to every address after
949            // the address lists are assembled; they are stashed in `extra` under
950            // a private sentinel key and consumed in `parse`.
951            "https_proxy" => description
952                .extra
953                .push((PROXY_HOST_KEY.to_string(), value.to_string())),
954            "https_proxy_port" => description
955                .extra
956                .push((PROXY_PORT_KEY.to_string(), value.to_string())),
957            "pool_boundary" => description.connect_data.pool_boundary = Some(value.to_string()),
958            "pool_name" => description.connect_data.pool_name = Some(value.to_string()),
959            "cclass" => {
960                if !value.is_empty() {
961                    description.connect_data.cclass = Some(value.to_string());
962                }
963            }
964            "purity" => {
965                description.connect_data.purity = Some(Purity::from_keyword(value)?);
966            }
967            "enable" | "recv_buf_size" | "send_buf_size" => {
968                description
969                    .extra
970                    .push((name.to_ascii_uppercase(), value.to_string()));
971            }
972            // Extended (`pyo.`) params not affecting the descriptor topology are
973            // accepted but not modelled here (e.g. stmtcachesize, edition).
974            _ => {}
975        }
976        Ok(())
977    }
978}
979
980// ---------------------------------------------------------------------------
981// Diagnostics helpers
982// ---------------------------------------------------------------------------
983
984/// The raw connect string is included so the message is self-describing; a
985/// caret-context snippet is appended pointing at `offset` (a char index into
986/// the trimmed string) so the operator can see exactly where parsing failed.
987fn err_descriptor(connect_string: &str, char_offset: usize, reason: &str) -> ProtocolError {
988    let trimmed = connect_string.trim();
989    let snippet = context_snippet(trimmed, char_offset);
990    ProtocolError::InvalidConnectDescriptor(format!(
991        "invalid connect descriptor \"{connect_string}\": {reason} at offset {char_offset}\n{snippet}"
992    ))
993}
994
995fn err_cannot_parse(connect_string: &str) -> ProtocolError {
996    ProtocolError::InvalidConnectDescriptor(format!(
997        "cannot parse connect string \"{connect_string}\""
998    ))
999}
1000
1001/// Builds a two-line snippet: a window of the input around `char_offset` and a
1002/// caret `^` underneath the offending character.
1003fn context_snippet(trimmed: &str, char_offset: usize) -> String {
1004    let chars: Vec<char> = trimmed.chars().collect();
1005    let start = char_offset.saturating_sub(20);
1006    let end = (char_offset + 20).min(chars.len());
1007    let window: String = chars[start..end].iter().collect();
1008    let caret_pos = char_offset - start;
1009    let mut caret = String::new();
1010    for _ in 0..caret_pos {
1011        caret.push(' ');
1012    }
1013    caret.push('^');
1014    format!("  {window}\n  {caret}")
1015}
1016
1017// ---------------------------------------------------------------------------
1018// Descriptor argument tree
1019// ---------------------------------------------------------------------------
1020
1021/// A parsed value in the descriptor argument tree: either a simple string or a
1022/// nested key/value map (a parenthesised sub-node).
1023#[derive(Clone, Debug)]
1024enum ArgValue {
1025    Simple(String),
1026    Node(ArgMap),
1027}
1028
1029/// A descriptor node: maps lower-cased keys to one or more values. The reference
1030/// stores repeated keys as a Python list; we model that as a `Vec` per key.
1031#[derive(Clone, Debug, Default)]
1032struct ArgMap {
1033    entries: Vec<(String, Vec<ArgValue>)>,
1034}
1035
1036impl ArgMap {
1037    fn get(&self, key: &str) -> Option<&Vec<ArgValue>> {
1038        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
1039    }
1040
1041    fn take(&mut self, key: &str) -> Option<Vec<ArgValue>> {
1042        if let Some(idx) = self.entries.iter().position(|(k, _)| k == key) {
1043            Some(self.entries.remove(idx).1)
1044        } else {
1045            None
1046        }
1047    }
1048
1049    fn push(&mut self, key: String, value: ArgValue) {
1050        if let Some((_, values)) = self.entries.iter_mut().find(|(k, _)| *k == key) {
1051            values.push(value);
1052        } else {
1053            self.entries.push((key, vec![value]));
1054        }
1055    }
1056}
1057
1058/// Alternative parameter names accepted inside descriptors (reference
1059/// `ALTERNATIVE_PARAM_NAMES`): the listener keyword maps to the canonical key.
1060fn canonical_param_name(name: &str) -> &str {
1061    match name {
1062        "pool_connection_class" => "cclass",
1063        "pool_purity" => "purity",
1064        "server" => "server_type",
1065        "transport_connect_timeout" => "tcp_connect_timeout",
1066        "my_wallet_directory" => "wallet_location",
1067        other => other,
1068    }
1069}
1070
1071/// Container keywords that may not take a simple (non-parenthesised) value
1072/// (reference `CONTAINER_PARAM_NAMES`).
1073fn is_container_param(name: &str) -> bool {
1074    matches!(
1075        name,
1076        "address"
1077            | "address_list"
1078            | "connect_data"
1079            | "description"
1080            | "description_list"
1081            | "security"
1082    )
1083}
1084
1085// ---------------------------------------------------------------------------
1086// Descriptor tokenizer / recursive-descent parser
1087// ---------------------------------------------------------------------------
1088
1089/// Recursive-descent parser for TNS connect descriptors. Mirrors the reference
1090/// `ConnectStringParser` (`_parse_descriptor_key_value_pair`): it tokenises
1091/// keywords, simple values, and quoted strings while tracking nested parens.
1092/// Maximum nesting depth for a TNS connect descriptor. Real topologies
1093/// (DESCRIPTION_LIST > DESCRIPTION > ADDRESS_LIST > ADDRESS / CONNECT_DATA >
1094/// SECURITY ...) are well under 10 deep; 128 is far beyond any legitimate
1095/// descriptor. The cap converts an attacker/garbage deeply-nested input into a
1096/// clean `Result::Err` instead of unbounded recursion that overflows the stack
1097/// and ABORTS the process (an uncatchable crash, not a recoverable panic) —
1098/// bead rust-oracledb-uf8.
1099const MAX_DESCRIPTOR_DEPTH: usize = 128;
1100
1101struct DescriptorParser<'a> {
1102    chars: &'a [char],
1103    raw: &'a str,
1104    /// Confirmed cursor (chars consumed).
1105    pos: usize,
1106    /// Lookahead cursor.
1107    temp_pos: usize,
1108    /// Current parenthesis nesting depth (guards against stack overflow).
1109    depth: usize,
1110}
1111
1112impl<'a> DescriptorParser<'a> {
1113    fn new(chars: &'a [char], raw: &'a str) -> Self {
1114        Self {
1115            chars,
1116            raw,
1117            pos: 0,
1118            temp_pos: 0,
1119            depth: 0,
1120        }
1121    }
1122
1123    fn current(&self) -> char {
1124        self.chars[self.temp_pos]
1125    }
1126
1127    fn skip_spaces(&mut self) {
1128        while self.temp_pos < self.chars.len() && self.chars[self.temp_pos].is_whitespace() {
1129            self.temp_pos += 1;
1130        }
1131    }
1132
1133    /// Parses a keyword: alphanumeric plus `_` and `.` (reference
1134    /// `parse_keyword`).
1135    fn parse_keyword(&mut self) {
1136        while self.temp_pos < self.chars.len() {
1137            let ch = self.current();
1138            if !ch.is_alphanumeric() && ch != '_' && ch != '.' {
1139                break;
1140            }
1141            self.temp_pos += 1;
1142        }
1143    }
1144
1145    /// Parses a quoted string body, consuming the closing quote (reference
1146    /// `parse_quoted_string`). On entry `temp_pos` is just past the opening
1147    /// quote.
1148    fn parse_quoted_string(&mut self, quote: char) -> Result<()> {
1149        while self.temp_pos < self.chars.len() {
1150            let ch = self.current();
1151            self.temp_pos += 1;
1152            if ch == quote {
1153                self.pos = self.temp_pos;
1154                return Ok(());
1155            }
1156        }
1157        let reason = if quote == '\'' {
1158            "missing ending quote (')"
1159        } else {
1160            "missing ending quote (\")"
1161        };
1162        Err(err_descriptor(self.raw, self.temp_pos, reason))
1163    }
1164
1165    /// Parses a top-level descriptor node. On entry the opening `(` has already
1166    /// been consumed (reference `_parse_descriptor` calls
1167    /// `_parse_descriptor_key_value_pair` once on the implicit root).
1168    fn parse_descriptor(&mut self) -> Result<ArgMap> {
1169        let mut args = ArgMap::default();
1170        self.parse_key_value_pair(&mut args)?;
1171        Ok(args)
1172    }
1173
1174    /// Parses one `(KEY=VALUE)` pair into `args`. Assumes the opening `(` for
1175    /// this pair was already consumed. Directly mirrors the reference
1176    /// `_parse_descriptor_key_value_pair`.
1177    fn parse_key_value_pair(&mut self, args: &mut ArgMap) -> Result<()> {
1178        let mut is_simple_value = false;
1179        let mut simple_start = 0usize;
1180        let mut value: Option<ArgValue> = None;
1181
1182        // parse keyword
1183        self.skip_spaces();
1184        let start_pos = self.temp_pos;
1185        self.parse_keyword();
1186        if self.temp_pos == start_pos {
1187            return Err(err_descriptor(
1188                self.raw,
1189                self.temp_pos,
1190                "expected a keyword",
1191            ));
1192        }
1193        let raw_name: String = self.chars[start_pos..self.temp_pos]
1194            .iter()
1195            .collect::<String>()
1196            .to_ascii_lowercase();
1197        let name = canonical_param_name(&raw_name).to_string();
1198
1199        // look for equals sign
1200        self.skip_spaces();
1201        let mut ch = '\0';
1202        if self.temp_pos < self.chars.len() {
1203            ch = self.current();
1204        }
1205        if ch != '=' {
1206            return Err(err_descriptor(
1207                self.raw,
1208                self.temp_pos,
1209                "expected '=' after keyword",
1210            ));
1211        }
1212        self.temp_pos += 1;
1213        self.skip_spaces();
1214
1215        // parse value
1216        while self.temp_pos < self.chars.len() {
1217            ch = self.current();
1218            if ch == '"' {
1219                if is_simple_value {
1220                    return Err(err_descriptor(
1221                        self.raw,
1222                        self.temp_pos,
1223                        "unexpected quote inside a simple value",
1224                    ));
1225                }
1226                self.temp_pos += 1;
1227                let q_start = self.temp_pos;
1228                self.parse_quoted_string('"')?;
1229                if self.temp_pos > q_start + 1 {
1230                    let v: String = self.chars[q_start..self.temp_pos - 1].iter().collect();
1231                    value = Some(ArgValue::Simple(v));
1232                }
1233                break;
1234            } else if ch == '(' {
1235                if is_simple_value {
1236                    return Err(err_descriptor(
1237                        self.raw,
1238                        self.temp_pos,
1239                        "unexpected '(' inside a simple value",
1240                    ));
1241                }
1242                self.temp_pos += 1;
1243                let mut node = match value.take() {
1244                    Some(ArgValue::Node(n)) => n,
1245                    _ => ArgMap::default(),
1246                };
1247                self.depth += 1;
1248                if self.depth > MAX_DESCRIPTOR_DEPTH {
1249                    return Err(err_descriptor(
1250                        self.raw,
1251                        self.temp_pos,
1252                        "connect descriptor nesting too deep",
1253                    ));
1254                }
1255                let result = self.parse_key_value_pair(&mut node);
1256                self.depth -= 1;
1257                result?;
1258                value = Some(ArgValue::Node(node));
1259                continue;
1260            } else if ch == ')' {
1261                break;
1262            } else if !is_simple_value && !ch.is_whitespace() {
1263                if value.is_some() || is_container_param(&name) {
1264                    return Err(err_descriptor(
1265                        self.raw,
1266                        self.temp_pos,
1267                        "unexpected simple value for a container keyword",
1268                    ));
1269                }
1270                simple_start = self.temp_pos;
1271                is_simple_value = true;
1272            }
1273            self.temp_pos += 1;
1274        }
1275        if is_simple_value {
1276            let v: String = self.chars[simple_start..self.temp_pos]
1277                .iter()
1278                .collect::<String>()
1279                .trim()
1280                .to_string();
1281            value = Some(ArgValue::Simple(v));
1282        }
1283        self.skip_spaces();
1284        if self.temp_pos < self.chars.len() {
1285            ch = self.current();
1286            if ch != ')' {
1287                return Err(err_descriptor(
1288                    self.raw,
1289                    self.temp_pos,
1290                    "expected ')' to close the keyword",
1291                ));
1292            }
1293            self.temp_pos += 1;
1294        } else {
1295            return Err(err_descriptor(
1296                self.raw,
1297                self.temp_pos,
1298                "unbalanced parenthesis: expected ')'",
1299            ));
1300        }
1301        self.skip_spaces();
1302        self.pos = self.temp_pos;
1303
1304        if let Some(value) = value {
1305            self.set_descriptor_arg(args, name, value);
1306        }
1307        Ok(())
1308    }
1309
1310    /// Stores a value in `args`, mirroring the reference `_set_descriptor_arg`
1311    /// special handling for `address` vs `address_list` interleaving.
1312    fn set_descriptor_arg(&self, args: &mut ArgMap, name: String, value: ArgValue) {
1313        if args.get(&name).is_none() {
1314            if name == "address" && args.get("address_list").is_some() {
1315                let mut wrapper = ArgMap::default();
1316                wrapper.push("address".to_string(), value);
1317                self.set_descriptor_arg(args, "address_list".to_string(), ArgValue::Node(wrapper));
1318                return;
1319            } else if name == "address_list" && args.get("address").is_some() {
1320                let addresses = args.take("address").unwrap_or_default();
1321                // existing addresses become their own address_list nodes,
1322                // preserving order before the new list.
1323                for addr in addresses {
1324                    let mut wrapper = ArgMap::default();
1325                    wrapper.push("address".to_string(), addr);
1326                    args.push("address_list".to_string(), ArgValue::Node(wrapper));
1327                }
1328                args.push(name, value);
1329                return;
1330            }
1331            args.push(name, value);
1332        } else {
1333            args.push(name, value);
1334        }
1335    }
1336}
1337
1338// ---------------------------------------------------------------------------
1339// tnsnames.ora parsing
1340// ---------------------------------------------------------------------------
1341
1342/// Parses `tnsnames.ora` files into an alias -> connect-descriptor map.
1343///
1344/// Mirrors the reference `TnsnamesFileParser` / `TnsnamesFileReader`:
1345/// comment (`#`) handling, multi-line paren-balanced values, comma-separated
1346/// alias lists, and `IFILE` includes (resolved relative to the including file's
1347/// directory) with cycle detection. Aliases are upper-cased; the last
1348/// definition of a duplicate alias wins.
1349pub mod tnsnames {
1350    use crate::{ProtocolError, Result};
1351    use std::collections::HashSet;
1352    use std::path::{Path, PathBuf};
1353
1354    /// A fully resolved set of tnsnames.ora entries.
1355    #[derive(Debug, Default)]
1356    pub struct TnsnamesReader {
1357        /// Alias (upper-cased) -> connect descriptor/easy-connect string, in
1358        /// first-seen order.
1359        entries: Vec<(String, String)>,
1360        /// The path of the primary tnsnames.ora file (for diagnostics).
1361        file_name: PathBuf,
1362    }
1363
1364    impl TnsnamesReader {
1365        /// Reads `tnsnames.ora` from `config_dir`, following `IFILE` includes.
1366        pub fn read(config_dir: &Path) -> Result<Self> {
1367            let primary = config_dir.join("tnsnames.ora");
1368            let mut reader = TnsnamesReader {
1369                entries: Vec::new(),
1370                file_name: primary.clone(),
1371            };
1372            let mut in_progress: Vec<PathBuf> = Vec::new();
1373            let mut seen: HashSet<PathBuf> = HashSet::new();
1374            reader.read_file(&primary, &mut in_progress, &mut seen)?;
1375            Ok(reader)
1376        }
1377
1378        /// Looks up an alias (case-insensitive). Returns the connect string.
1379        #[must_use]
1380        pub fn get(&self, alias: &str) -> Option<&str> {
1381            let upper = alias.to_ascii_uppercase();
1382            self.entries
1383                .iter()
1384                .find(|(name, _)| *name == upper)
1385                .map(|(_, value)| value.as_str())
1386        }
1387
1388        /// All known network service names (upper-cased), in first-seen order.
1389        #[must_use]
1390        pub fn service_names(&self) -> Vec<String> {
1391            self.entries.iter().map(|(name, _)| name.clone()).collect()
1392        }
1393
1394        /// The path of the primary tnsnames.ora file.
1395        #[must_use]
1396        pub fn file_name(&self) -> &Path {
1397            &self.file_name
1398        }
1399
1400        fn set_entry(&mut self, name: String, value: String) {
1401            // Last definition wins, but keep first-seen ordering: if the alias
1402            // already exists, overwrite its value in place.
1403            if let Some(slot) = self.entries.iter_mut().find(|(n, _)| *n == name) {
1404                slot.1 = value;
1405            } else {
1406                self.entries.push((name, value));
1407            }
1408        }
1409
1410        fn read_file(
1411            &mut self,
1412            path: &Path,
1413            in_progress: &mut Vec<PathBuf>,
1414            seen: &mut HashSet<PathBuf>,
1415        ) -> Result<()> {
1416            let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
1417            if in_progress.contains(&canonical) {
1418                let including = in_progress
1419                    .last()
1420                    .map(|p| p.display().to_string())
1421                    .unwrap_or_default();
1422                return Err(ProtocolError::InvalidConnectDescriptor(format!(
1423                    "file '{including}' includes file '{}', which forms a cycle",
1424                    path.display()
1425                )));
1426            }
1427            let contents = std::fs::read_to_string(path).map_err(|_| {
1428                ProtocolError::InvalidConnectDescriptor(format!(
1429                    "file '{}' is missing or unreadable",
1430                    path.display()
1431                ))
1432            })?;
1433            in_progress.push(canonical.clone());
1434            seen.insert(canonical);
1435
1436            let dir = path.parent().unwrap_or_else(|| Path::new("."));
1437            // Collect entries first to avoid borrow conflicts during IFILE
1438            // recursion.
1439            let parsed = parse_file(&contents);
1440            for (key, value) in parsed {
1441                if key.eq_ignore_ascii_case("ifile") {
1442                    let mut inc = value.trim().to_string();
1443                    if inc.starts_with('"') && inc.ends_with('"') && inc.len() >= 2 {
1444                        inc = inc[1..inc.len() - 1].to_string();
1445                    }
1446                    let inc_path = if Path::new(&inc).is_absolute() {
1447                        PathBuf::from(&inc)
1448                    } else {
1449                        dir.join(&inc)
1450                    };
1451                    self.read_file(&inc_path, in_progress, seen)?;
1452                } else {
1453                    // The key may be a comma-separated alias list spanning
1454                    // multiple lines; split, take the last line of each, upper.
1455                    for raw_alias in key.split(',') {
1456                        let alias = raw_alias.trim().lines().last().unwrap_or("").trim();
1457                        if alias.is_empty() {
1458                            continue;
1459                        }
1460                        self.set_entry(alias.to_ascii_uppercase(), value.clone());
1461                    }
1462                }
1463            }
1464            in_progress.pop();
1465            Ok(())
1466        }
1467    }
1468
1469    /// Parses a tnsnames.ora file into a list of `(key, value)` pairs, where the
1470    /// key may be a (possibly multi-line) comma-separated alias list or `IFILE`,
1471    /// and the value is the descriptor / easy-connect / include path. Mirrors
1472    /// the reference `TnsnamesFileParser.parse`.
1473    fn parse_file(contents: &str) -> Vec<(String, String)> {
1474        let chars: Vec<char> = contents.chars().collect();
1475        let mut parser = FileParser {
1476            chars: &chars,
1477            temp_pos: 0,
1478            pos: 0,
1479        };
1480        let mut out = Vec::new();
1481        while parser.temp_pos < parser.chars.len() {
1482            let key = parser.parse_key();
1483            let value = parser.parse_value();
1484            if let (Some(key), Some(value)) = (key, value) {
1485                if !key.is_empty() && !value.is_empty() {
1486                    out.push((key, value.trim().to_string()));
1487                }
1488            }
1489        }
1490        out
1491    }
1492
1493    /// Fuzz-only accessor for the in-memory tnsnames.ora lexer (`parse_file`).
1494    ///
1495    /// Compiled **only** under `--cfg fuzzing` (set by `cargo-fuzz`); it never
1496    /// widens the normal public API. It feeds arbitrary bytes through the
1497    /// comment / multi-line / quote / paren-balancing tokenizer that the
1498    /// `TnsnamesReader` runs on untrusted config files, so the connect-string
1499    /// fuzz target can reach the tnsnames parser without touching the
1500    /// filesystem (the `IFILE` recursion itself is I/O-bound and is covered by
1501    /// `ifile_cycle_detected` / `ifile_same_directory`). Must never panic: the
1502    /// lexer only returns a possibly-empty `(key, value)` list.
1503    #[cfg(fuzzing)]
1504    pub fn fuzz_parse_file(contents: &str) -> Vec<(String, String)> {
1505        parse_file(contents)
1506    }
1507
1508    struct FileParser<'a> {
1509        chars: &'a [char],
1510        temp_pos: usize,
1511        pos: usize,
1512    }
1513
1514    impl FileParser<'_> {
1515        fn current(&self) -> char {
1516            self.chars[self.temp_pos]
1517        }
1518
1519        fn skip_spaces(&mut self) {
1520            while self.temp_pos < self.chars.len() && self.chars[self.temp_pos].is_whitespace() {
1521                self.temp_pos += 1;
1522            }
1523        }
1524
1525        fn skip_to_end_of_line(&mut self) {
1526            while self.temp_pos < self.chars.len() {
1527                let ch = self.current();
1528                self.temp_pos += 1;
1529                if ch == '\n' || ch == '\r' {
1530                    break;
1531                }
1532            }
1533            self.pos = self.temp_pos;
1534            self.skip_spaces();
1535        }
1536
1537        /// Mirrors `_parse_key`: reads non-whitespace chars until `=`. Lines with
1538        /// stray parens / comments before `=` are discarded.
1539        fn parse_key(&mut self) -> Option<String> {
1540            let mut found_key = false;
1541            let mut start_pos = 0usize;
1542            self.skip_spaces();
1543            while self.temp_pos < self.chars.len() {
1544                let ch = self.current();
1545                if ch == '(' || ch == ')' || ch == '#' {
1546                    self.skip_to_end_of_line();
1547                    found_key = false;
1548                    continue;
1549                } else if ch == '=' {
1550                    if !found_key {
1551                        self.skip_to_end_of_line();
1552                        continue;
1553                    }
1554                    self.temp_pos += 1;
1555                    self.pos = self.temp_pos;
1556                    let key: String = self.chars[start_pos..self.temp_pos - 1].iter().collect();
1557                    return Some(key.trim().to_string());
1558                } else if !found_key {
1559                    found_key = true;
1560                    start_pos = self.temp_pos;
1561                }
1562                self.temp_pos += 1;
1563            }
1564            None
1565        }
1566
1567        /// Mirrors `_parse_value`: accumulates value parts until parens balance.
1568        fn parse_value(&mut self) -> Option<String> {
1569            let mut num_parens: isize = 0;
1570            let mut parts: Vec<String> = Vec::new();
1571            while self.temp_pos < self.chars.len() {
1572                if let Some(part) = self.parse_value_part(&mut num_parens) {
1573                    parts.push(part);
1574                }
1575                if num_parens == 0 {
1576                    break;
1577                }
1578            }
1579            if parts.is_empty() {
1580                None
1581            } else {
1582                Some(parts.join("\n"))
1583            }
1584        }
1585
1586        /// Mirrors `_parse_value_part`.
1587        fn parse_value_part(&mut self, num_parens: &mut isize) -> Option<String> {
1588            let mut start_pos = 0usize;
1589            let mut end_pos = 0usize;
1590            let mut found_part = false;
1591            self.skip_spaces();
1592            while self.temp_pos < self.chars.len() {
1593                let ch = self.current();
1594                if ch == '#' {
1595                    end_pos = self.temp_pos;
1596                    self.skip_to_end_of_line();
1597                    if found_part {
1598                        break;
1599                    }
1600                    continue;
1601                }
1602                if found_part && *num_parens == 0 {
1603                    if ch == '\n' || ch == '\r' {
1604                        end_pos = self.temp_pos;
1605                        break;
1606                    }
1607                } else if ch == '(' {
1608                    *num_parens += 1;
1609                } else if ch == ')' && *num_parens > 0 {
1610                    *num_parens -= 1;
1611                }
1612                if !found_part {
1613                    found_part = true;
1614                    start_pos = self.temp_pos;
1615                }
1616                self.temp_pos += 1;
1617                end_pos = self.temp_pos;
1618            }
1619            if found_part {
1620                let part: String = self.chars[start_pos..end_pos].iter().collect();
1621                Some(part.trim().to_string())
1622            } else {
1623                None
1624            }
1625        }
1626    }
1627}
1628
1629// ---------------------------------------------------------------------------
1630// Argument-tree -> Descriptor builder
1631// ---------------------------------------------------------------------------
1632
1633/// Returns the first simple value for `key`, if present and simple.
1634fn simple(map: &ArgMap, key: &str) -> Option<String> {
1635    match map.get(key)?.first()? {
1636        ArgValue::Simple(s) => Some(s.clone()),
1637        ArgValue::Node(_) => None,
1638    }
1639}
1640
1641/// Parses a connect-string boolean (reference `_set_bool_param`): the strings
1642/// `on` / `yes` / `true` (case-insensitive) are true; everything else is false.
1643fn parse_bool(value: &str) -> bool {
1644    matches!(
1645        value.trim().to_ascii_lowercase().as_str(),
1646        "on" | "yes" | "true"
1647    )
1648}
1649
1650/// Parses a connect-string unsigned int (reference `_set_uint_param`). The
1651/// reference uses Python `int()`, which rejects non-numeric strings; we mirror
1652/// that by surfacing a diagnostic.
1653fn parse_uint(connect_string: &str, key: &str, value: &str) -> Result<u32> {
1654    value.trim().parse::<u32>().map_err(|_| {
1655        ProtocolError::InvalidConnectDescriptor(format!(
1656            "invalid connect descriptor \"{connect_string}\": {key} value \"{value}\" is not a \
1657             non-negative integer"
1658        ))
1659    })
1660}
1661
1662/// Parses a duration (reference `_set_duration_param`): a float with an
1663/// optional `ms` / `sec` / `min` unit suffix, normalised to seconds.
1664fn parse_duration(connect_string: &str, key: &str, value: &str) -> Result<f64> {
1665    let v = value.trim().to_ascii_lowercase();
1666    let (num, scale) = if let Some(stripped) = v.strip_suffix("sec") {
1667        (stripped.trim(), 1.0)
1668    } else if let Some(stripped) = v.strip_suffix("ms") {
1669        (stripped.trim(), 0.001)
1670    } else if let Some(stripped) = v.strip_suffix("min") {
1671        (stripped.trim(), 60.0)
1672    } else {
1673        (v.as_str(), 1.0)
1674    };
1675    num.parse::<f64>().map(|n| n * scale).map_err(|_| {
1676        ProtocolError::InvalidConnectDescriptor(format!(
1677            "invalid connect descriptor \"{connect_string}\": {key} value \"{value}\" is not a \
1678             valid duration"
1679        ))
1680    })
1681}
1682
1683/// Reconstructs the listener-form string for a pass-through (extra) value,
1684/// mirroring the reference `_value_repr`: simple values are kept verbatim;
1685/// nested nodes become `(KEY=value)` chains with upper-cased keys.
1686fn value_repr(value: &ArgValue) -> String {
1687    match value {
1688        ArgValue::Simple(s) => s.clone(),
1689        ArgValue::Node(node) => {
1690            let mut out = String::new();
1691            for (key, values) in &node.entries {
1692                for v in values {
1693                    out.push('(');
1694                    out.push_str(&key.to_ascii_uppercase());
1695                    out.push('=');
1696                    out.push_str(&value_repr(v));
1697                    out.push(')');
1698                }
1699            }
1700            out
1701        }
1702    }
1703}
1704
1705/// Iterates `(key, value)` pairs not in `allowed`, collecting them as
1706/// reconstructed pass-through strings (reference `_process_args_with_extras`).
1707fn collect_extras(map: &ArgMap, allowed: &[&str]) -> Vec<(String, String)> {
1708    let mut extras = Vec::new();
1709    for (key, values) in &map.entries {
1710        if allowed.contains(&key.as_str()) {
1711            continue;
1712        }
1713        for v in values {
1714            extras.push((key.to_ascii_uppercase(), value_repr(v)));
1715        }
1716    }
1717    extras
1718}
1719
1720/// Builds a [`Descriptor`] from the parsed argument tree, mirroring the
1721/// reference `_parse_descriptor`.
1722fn build_descriptor(connect_string: &str, args: &ArgMap) -> Result<Descriptor> {
1723    let mut descriptor = Descriptor {
1724        descriptions: Vec::new(),
1725        load_balance: false,
1726        failover: true,
1727        source_route: false,
1728    };
1729
1730    // DESCRIPTION_LIST flags, if present.
1731    let list_node = args.get("description_list").and_then(|v| match v.first() {
1732        Some(ArgValue::Node(n)) => Some(n),
1733        _ => None,
1734    });
1735    let description_container = if let Some(list_node) = list_node {
1736        descriptor.load_balance = list_node.get("load_balance").is_some()
1737            && simple(list_node, "load_balance").is_some_and(|v| parse_bool(&v));
1738        if let Some(v) = simple(list_node, "failover") {
1739            descriptor.failover = parse_bool(&v);
1740        }
1741        descriptor.source_route = simple(list_node, "source_route").is_some_and(|v| parse_bool(&v));
1742        list_node
1743    } else {
1744        args
1745    };
1746
1747    // Descriptions: the reference takes list_args.get("description", list_args)
1748    // — i.e. if there's no explicit "description" key, the container itself is
1749    // treated as a single description.
1750    let descriptions: Vec<&ArgMap> = match description_container.get("description") {
1751        Some(values) => {
1752            let mut out = Vec::new();
1753            for v in values {
1754                if let ArgValue::Node(n) = v {
1755                    out.push(n);
1756                }
1757            }
1758            out
1759        }
1760        None => vec![description_container],
1761    };
1762
1763    for desc_args in descriptions {
1764        let description = build_description(connect_string, desc_args)?;
1765        descriptor.descriptions.push(description);
1766    }
1767
1768    if descriptor.addresses().next().is_none() {
1769        return Err(ProtocolError::InvalidConnectDescriptor(format!(
1770            "no addresses are defined in connect descriptor: {connect_string}"
1771        )));
1772    }
1773    Ok(descriptor)
1774}
1775
1776const DESCRIPTION_PARAM_NAMES: &[&str] = &[
1777    "address",
1778    "address_list",
1779    "connect_data",
1780    "expire_time",
1781    "failover",
1782    "load_balance",
1783    "source_route",
1784    "retry_count",
1785    "retry_delay",
1786    "sdu",
1787    "tcp_connect_timeout",
1788    "use_sni",
1789    "security",
1790];
1791
1792const CONNECT_DATA_PARAM_NAMES: &[&str] = &[
1793    "cclass",
1794    "connection_id_prefix",
1795    "instance_name",
1796    "pool_boundary",
1797    "pool_name",
1798    "purity",
1799    "server_type",
1800    "service_name",
1801    "sid",
1802    "use_tcp_fast_open",
1803];
1804
1805const SECURITY_PARAM_NAMES: &[&str] = &[
1806    "ssl_server_cert_dn",
1807    "ssl_server_dn_match",
1808    "ssl_version",
1809    "wallet_location",
1810];
1811
1812fn build_description(connect_string: &str, desc_args: &ArgMap) -> Result<Description> {
1813    let mut description = Description::default();
1814
1815    // DESCRIPTION-level args.
1816    if let Some(v) = simple(desc_args, "expire_time") {
1817        description.expire_time = parse_uint(connect_string, "EXPIRE_TIME", &v)?;
1818    }
1819    if let Some(v) = simple(desc_args, "failover") {
1820        description.failover = parse_bool(&v);
1821    }
1822    if let Some(v) = simple(desc_args, "load_balance") {
1823        description.load_balance = parse_bool(&v);
1824    }
1825    if let Some(v) = simple(desc_args, "source_route") {
1826        description.source_route = parse_bool(&v);
1827    }
1828    if let Some(v) = simple(desc_args, "retry_count") {
1829        description.retry_count = parse_uint(connect_string, "RETRY_COUNT", &v)?;
1830    }
1831    if let Some(v) = simple(desc_args, "retry_delay") {
1832        description.retry_delay = parse_uint(connect_string, "RETRY_DELAY", &v)?;
1833    }
1834    if let Some(v) = simple(desc_args, "use_sni") {
1835        description.use_sni = parse_bool(&v);
1836    }
1837    if let Some(v) = simple(desc_args, "sdu") {
1838        description.sdu = parse_uint(connect_string, "SDU", &v)?.clamp(MIN_SDU, MAX_SDU);
1839    }
1840    if let Some(v) = simple(desc_args, "tcp_connect_timeout") {
1841        description.tcp_connect_timeout =
1842            parse_duration(connect_string, "TRANSPORT_CONNECT_TIMEOUT", &v)?;
1843    }
1844    description.extra = collect_extras(desc_args, DESCRIPTION_PARAM_NAMES);
1845
1846    // CONNECT_DATA.
1847    if let Some(ArgValue::Node(cd)) = desc_args.get("connect_data").and_then(|v| v.first()) {
1848        description.connect_data = build_connect_data(connect_string, cd)?;
1849    }
1850
1851    // SECURITY.
1852    if let Some(ArgValue::Node(sec)) = desc_args.get("security").and_then(|v| v.first()) {
1853        description.security = build_security(sec);
1854    }
1855
1856    // Address lists. The reference takes desc_args.get("address_list", desc_args)
1857    // and if that is not a list, sets source_route=False and wraps it.
1858    let address_list_nodes: Vec<&ArgMap> = match desc_args.get("address_list") {
1859        Some(values) => values
1860            .iter()
1861            .filter_map(|v| match v {
1862                ArgValue::Node(n) => Some(n),
1863                ArgValue::Simple(_) => None,
1864            })
1865            .collect(),
1866        None => {
1867            description.source_route = false;
1868            vec![desc_args]
1869        }
1870    };
1871
1872    for list_args in address_list_nodes {
1873        let mut address_list = AddressList {
1874            failover: true,
1875            ..AddressList::default()
1876        };
1877        if let Some(v) = simple(list_args, "failover") {
1878            address_list.failover = parse_bool(&v);
1879        }
1880        if let Some(v) = simple(list_args, "load_balance") {
1881            address_list.load_balance = parse_bool(&v);
1882        }
1883        if let Some(v) = simple(list_args, "source_route") {
1884            address_list.source_route = parse_bool(&v);
1885        }
1886        if let Some(addresses) = list_args.get("address") {
1887            for addr in addresses {
1888                if let ArgValue::Node(addr_node) = addr {
1889                    address_list.addresses.push(build_address(addr_node)?);
1890                }
1891            }
1892        }
1893        description.address_lists.push(address_list);
1894    }
1895
1896    Ok(description)
1897}
1898
1899fn build_address(addr: &ArgMap) -> Result<Address> {
1900    let mut address = Address::default();
1901    if let Some(host) = simple(addr, "host") {
1902        address.host = Some(host);
1903    }
1904    if let Some(port) = simple(addr, "port") {
1905        address.port = port.trim().parse::<u16>().map_err(|_| {
1906            ProtocolError::InvalidConnectDescriptor(format!("invalid port: {port}"))
1907        })?;
1908    }
1909    if let Some(protocol) = simple(addr, "protocol") {
1910        address.protocol = Protocol::from_keyword(&protocol)?;
1911    }
1912    if let Some(proxy) = simple(addr, "https_proxy") {
1913        address.https_proxy = Some(proxy);
1914    }
1915    if let Some(proxy_port) = simple(addr, "https_proxy_port") {
1916        address.https_proxy_port = proxy_port.trim().parse::<u16>().unwrap_or(0);
1917    }
1918    Ok(address)
1919}
1920
1921fn build_connect_data(connect_string: &str, cd: &ArgMap) -> Result<ConnectData> {
1922    let mut data = ConnectData {
1923        service_name: simple(cd, "service_name"),
1924        instance_name: simple(cd, "instance_name"),
1925        sid: simple(cd, "sid"),
1926        ..ConnectData::default()
1927    };
1928    if let Some(server) = simple(cd, "server_type") {
1929        data.server_type = Some(ServerType::from_keyword(&server)?);
1930    }
1931    if let Some(cclass) = simple(cd, "cclass") {
1932        if !cclass.is_empty() {
1933            data.cclass = Some(cclass);
1934        }
1935    }
1936    if let Some(purity) = simple(cd, "purity") {
1937        data.purity = Some(Purity::from_keyword(&purity).map_err(|_| {
1938            ProtocolError::InvalidConnectDescriptor(format!(
1939                "invalid connect descriptor \"{connect_string}\": invalid POOL_PURITY \"{purity}\""
1940            ))
1941        })?);
1942    }
1943    data.pool_boundary = simple(cd, "pool_boundary");
1944    data.pool_name = simple(cd, "pool_name");
1945    data.connection_id_prefix = simple(cd, "connection_id_prefix");
1946    if let Some(v) = simple(cd, "use_tcp_fast_open") {
1947        data.use_tcp_fast_open = parse_bool(&v);
1948    }
1949    data.extra = collect_extras(cd, CONNECT_DATA_PARAM_NAMES);
1950    Ok(data)
1951}
1952
1953fn build_security(sec: &ArgMap) -> Security {
1954    let mut security = Security::default();
1955    if let Some(v) = simple(sec, "ssl_server_dn_match") {
1956        security.ssl_server_dn_match = parse_bool(&v);
1957    }
1958    security.ssl_server_cert_dn = simple(sec, "ssl_server_cert_dn");
1959    security.wallet_location = simple(sec, "wallet_location");
1960    security.extra = collect_extras(sec, SECURITY_PARAM_NAMES);
1961    security
1962}
1963
1964#[cfg(test)]
1965mod tests {
1966    use super::*;
1967
1968    fn parse_ok(input: &str) -> Descriptor {
1969        parse(input)
1970            .unwrap_or_else(|e| panic!("parse({input:?}) should succeed but failed: {e}"))
1971            .unwrap_or_else(|| panic!("parse({input:?}) should be a descriptor, not a tns alias"))
1972    }
1973
1974    /// Flattened host list across all descriptions/lists (host order),
1975    /// mirroring python-oracledb's `params.host` for the multi-address case.
1976    fn hosts(d: &Descriptor) -> Vec<String> {
1977        d.addresses().filter_map(|a| a.host.clone()).collect()
1978    }
1979
1980    fn ports(d: &Descriptor) -> Vec<u16> {
1981        d.addresses().map(|a| a.port).collect()
1982    }
1983
1984    fn protocols(d: &Descriptor) -> Vec<Protocol> {
1985        d.addresses().map(|a| a.protocol).collect()
1986    }
1987
1988    #[test]
1989    fn parses_simple_name_value_descriptor() {
1990        // reference test_4503
1991        let d = parse_ok(
1992            "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=my_host4)(PORT=1589))\
1993             (CONNECT_DATA=(SERVICE_NAME=my_service_name4)))",
1994        );
1995        let addr = d.first_address().expect("descriptor has an address");
1996        assert_eq!(addr.host.as_deref(), Some("my_host4"));
1997        assert_eq!(addr.port, 1589);
1998        assert_eq!(addr.protocol, Protocol::Tcp);
1999        assert_eq!(
2000            d.first_description().connect_data.service_name.as_deref(),
2001            Some("my_service_name4")
2002        );
2003    }
2004
2005    // --- EZConnect / EZConnect-Plus -------------------------------------
2006
2007    #[test]
2008    fn parses_easy_connect_with_port() {
2009        // reference test_4500
2010        let d = parse_ok("my_host:1578/my_service_name");
2011        let a = d.first_address().unwrap();
2012        assert_eq!(a.host.as_deref(), Some("my_host"));
2013        assert_eq!(a.port, 1578);
2014        assert_eq!(
2015            d.first_description().connect_data.service_name.as_deref(),
2016            Some("my_service_name")
2017        );
2018    }
2019
2020    #[test]
2021    fn parses_easy_connect_default_port() {
2022        // reference test_4501
2023        let d = parse_ok("my_host2/my_service_name2");
2024        let a = d.first_address().unwrap();
2025        assert_eq!(a.host.as_deref(), Some("my_host2"));
2026        assert_eq!(a.port, 1521);
2027    }
2028
2029    #[test]
2030    fn parses_easy_connect_drcp_server_type() {
2031        // reference test_4502
2032        let d = parse_ok("my_host3.org/my_service_name3:pooled");
2033        assert_eq!(
2034            d.first_description().connect_data.server_type,
2035            Some(ServerType::Pooled)
2036        );
2037        let d = parse_ok("my_host3/my_service_name3:ShArEd");
2038        assert_eq!(
2039            d.first_description().connect_data.server_type,
2040            Some(ServerType::Shared)
2041        );
2042    }
2043
2044    #[test]
2045    fn parses_easy_connect_tcps_protocol() {
2046        // reference test_4504
2047        let d = parse_ok("tcps://my_host6/my_service_name6");
2048        assert_eq!(d.first_address().unwrap().protocol, Protocol::Tcps);
2049    }
2050
2051    #[test]
2052    fn parses_easy_connect_no_service() {
2053        // reference test_4512
2054        let d = parse_ok("my_host15:1578/");
2055        let a = d.first_address().unwrap();
2056        assert_eq!(a.host.as_deref(), Some("my_host15"));
2057        assert_eq!(a.port, 1578);
2058        assert!(d.first_description().connect_data.service_name.is_none());
2059    }
2060
2061    #[test]
2062    fn parses_easy_connect_missing_port_value() {
2063        // reference test_4513
2064        let d = parse_ok("my_host17:/my_service_name17");
2065        let a = d.first_address().unwrap();
2066        assert_eq!(a.host.as_deref(), Some("my_host17"));
2067        assert_eq!(a.port, 1521);
2068        assert_eq!(
2069            d.first_description().connect_data.service_name.as_deref(),
2070            Some("my_service_name17")
2071        );
2072    }
2073
2074    #[test]
2075    fn parses_easy_connect_ipv6() {
2076        // reference test_4547
2077        let d = parse_ok("[::1]:4547/service_name_4547");
2078        let a = d.first_address().unwrap();
2079        assert_eq!(a.host.as_deref(), Some("::1"));
2080        assert_eq!(a.port, 4547);
2081        assert_eq!(
2082            d.first_description().connect_data.service_name.as_deref(),
2083            Some("service_name_4547")
2084        );
2085    }
2086
2087    #[test]
2088    fn parses_easy_connect_multiple_hosts_different_ports() {
2089        // reference test_4548
2090        let d = parse_ok("host4548a,host4548b:4548,host4548c,host4548d:4549/service_name_4548");
2091        assert_eq!(
2092            hosts(&d),
2093            vec!["host4548a", "host4548b", "host4548c", "host4548d"]
2094        );
2095        assert_eq!(ports(&d), vec![4548, 4548, 4549, 4549]);
2096    }
2097
2098    #[test]
2099    fn parses_easy_connect_multiple_address_lists() {
2100        // reference test_4549
2101        let d = parse_ok("host4549a;host4549b,host4549c:4549;host4549d/service_name_4549");
2102        assert_eq!(
2103            hosts(&d),
2104            vec!["host4549a", "host4549b", "host4549c", "host4549d"]
2105        );
2106        assert_eq!(ports(&d), vec![1521, 4549, 4549, 1521]);
2107    }
2108
2109    #[test]
2110    fn parses_easy_connect_degenerate_protocol() {
2111        // reference test_4552
2112        let d = parse_ok("//host_4552:4552/service_name_4552");
2113        let a = d.first_address().unwrap();
2114        assert_eq!(a.host.as_deref(), Some("host_4552"));
2115        assert_eq!(a.port, 4552);
2116    }
2117
2118    #[test]
2119    fn parses_easy_connect_instance_name() {
2120        // reference test_4571
2121        let d = parse_ok("host_4571:4571/service_4571/instance_4571");
2122        assert_eq!(
2123            d.first_description().connect_data.instance_name.as_deref(),
2124            Some("instance_4571")
2125        );
2126        assert_eq!(
2127            d.first_description().connect_data.service_name.as_deref(),
2128            Some("service_4571")
2129        );
2130    }
2131
2132    #[test]
2133    fn parses_easy_connect_extended_params() {
2134        // reference test_4517
2135        let d = parse_ok(
2136            "my_host21/my_server_name21?expire_time=5&retry_delay=10&retry_count=12&transport_connect_timeout=2.5",
2137        );
2138        let desc = d.first_description();
2139        assert_eq!(desc.expire_time, 5);
2140        assert_eq!(desc.retry_delay, 10);
2141        assert_eq!(desc.retry_count, 12);
2142        assert!((desc.tcp_connect_timeout - 2.5).abs() < 1e-9);
2143    }
2144
2145    #[test]
2146    fn parses_easy_connect_security_params() {
2147        // reference test_4582
2148        let d = parse_ok(
2149            "tcps://host_4580:4580/service_4580?ssl_server_dn_match=true&ssl_server_cert_dn='cn=sales'&wallet_location='/tmp/oracle'",
2150        );
2151        // Single quotes are preserved verbatim in EZConnect-Plus params
2152        // (only double quotes are stripped) — matches reference test_4582,
2153        // whose get_connect_string() keeps the single quotes.
2154        let sec = &d.first_description().security;
2155        assert!(sec.ssl_server_dn_match);
2156        assert_eq!(sec.ssl_server_cert_dn.as_deref(), Some("'cn=sales'"));
2157        assert_eq!(sec.wallet_location.as_deref(), Some("'/tmp/oracle'"));
2158    }
2159
2160    #[test]
2161    fn rejects_invalid_protocol_in_easy_connect() {
2162        // reference test_4505
2163        let err = parse("invalid_proto://my_host7/my_service_name7").unwrap_err();
2164        assert!(format!("{err}").contains("invalid protocol"));
2165    }
2166
2167    // --- diagnostics ----------------------------------------------------
2168
2169    #[test]
2170    fn diagnostic_points_at_unbalanced_paren() {
2171        let err = parse("(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1521))").unwrap_err();
2172        let msg = format!("{err}");
2173        assert!(msg.contains("offset"), "expected offset in: {msg}");
2174        assert!(msg.contains('^'), "expected caret context in: {msg}");
2175    }
2176
2177    #[test]
2178    fn diagnostic_for_missing_addresses() {
2179        // reference test_4546 (wrong container names -> no addresses)
2180        let err = parse(
2181            "(DESRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))",
2182        )
2183        .unwrap_err();
2184        assert!(format!("{err}").contains("no addresses are defined"));
2185    }
2186
2187    #[test]
2188    fn protocol_default_port_resolves_for_unported_address() {
2189        let d = parse_ok("tcps://h/svc");
2190        assert_eq!(d.first_address().unwrap().port, 2484);
2191    }
2192
2193    #[test]
2194    fn describe_dumps_addresses() {
2195        let d = parse_ok(
2196            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h1)(PORT=1521))\
2197             (CONNECT_DATA=(SERVICE_NAME=svc)))",
2198        );
2199        let text = d.describe();
2200        assert!(text.contains("tcp://h1:1521"));
2201        assert!(text.contains("service_name=svc"));
2202    }
2203
2204    #[test]
2205    fn keeps_protocols_for_multi_list_descriptor() {
2206        // reference test_4522
2207        let d = parse_ok(
2208            "(DESCRIPTION=(LOAD_BALANCE=ON)(RETRY_COUNT=5)(RETRY_DELAY=2)\
2209             (ADDRESS_LIST=(LOAD_BALANCE=ON)\
2210             (ADDRESS=(PROTOCOL=tcp)(PORT=1521)(HOST=my_host26))\
2211             (ADDRESS=(PROTOCOL=tcp)(PORT=222)(HOST=my_host27)))\
2212             (ADDRESS_LIST=(LOAD_BALANCE=ON)\
2213             (ADDRESS=(PROTOCOL=tcps)(PORT=5555)(HOST=my_host28))\
2214             (ADDRESS=(PROTOCOL=tcps)(PORT=444)(HOST=my_host29)))\
2215             (CONNECT_DATA=(SERVICE_NAME=my_service_name26)))",
2216        );
2217        assert_eq!(
2218            hosts(&d),
2219            vec!["my_host26", "my_host27", "my_host28", "my_host29"]
2220        );
2221        assert_eq!(ports(&d), vec![1521, 222, 5555, 444]);
2222        assert_eq!(
2223            protocols(&d),
2224            vec![Protocol::Tcp, Protocol::Tcp, Protocol::Tcps, Protocol::Tcps]
2225        );
2226    }
2227
2228    #[test]
2229    fn parses_multiple_descriptions() {
2230        // reference test_4523 (host ordering across descriptions)
2231        let d = parse_ok(
2232            "(DESCRIPTION_LIST=(FAIL_OVER=ON)(LOAD_BALANCE=OFF)\
2233             (DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(PORT=5001)(HOST=my_host30))\
2234             (ADDRESS=(PROTOCOL=tcp)(PORT=1521)(HOST=my_host31)))\
2235             (CONNECT_DATA=(SERVICE_NAME=svc27)))\
2236             (DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(PORT=5002)(HOST=my_host34)))\
2237             (CONNECT_DATA=(SERVICE_NAME=svc28))))",
2238        );
2239        assert_eq!(hosts(&d), vec!["my_host30", "my_host31", "my_host34"]);
2240        assert_eq!(d.descriptions.len(), 2);
2241    }
2242
2243    #[test]
2244    fn interleaves_address_and_address_list_small_first() {
2245        // reference test_4529
2246        let d = parse_ok(
2247            "(DESCRIPTION=\
2248             (ADDRESS=(PROTOCOL=tcp)(HOST=host1)(PORT=1521))\
2249             (ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(HOST=host2a)(PORT=1522))\
2250             (ADDRESS=(PROTOCOL=tcp)(HOST=host2b)(PORT=1523)))\
2251             (ADDRESS=(PROTOCOL=tcp)(HOST=host3)(PORT=1524))\
2252             (CONNECT_DATA=(SERVICE_NAME=svc)))",
2253        );
2254        assert_eq!(hosts(&d), vec!["host1", "host2a", "host2b", "host3"]);
2255    }
2256
2257    // --- corpus-differential table (valid inputs) -----------------------
2258
2259    /// Each row: (connect_string, first_host, first_port, service_name option,
2260    /// first_protocol). Drives a broad differential sweep matching the
2261    /// reference's parse results across EZConnect and descriptor forms.
2262    #[test]
2263    fn corpus_valid_inputs() {
2264        let cases: &[(&str, &str, u16, Option<&str>, Protocol)] = &[
2265            // EZConnect family
2266            ("h/s", "h", 1521, Some("s"), Protocol::Tcp),
2267            ("h:1600/s", "h", 1600, Some("s"), Protocol::Tcp),
2268            ("tcp://h/s", "h", 1521, Some("s"), Protocol::Tcp),
2269            ("tcps://h/s", "h", 2484, Some("s"), Protocol::Tcps),
2270            ("tcps://h:9999/s", "h", 9999, Some("s"), Protocol::Tcps),
2271            ("h.example.org/s.dom", "h.example.org", 1521, Some("s.dom"), Protocol::Tcp),
2272            ("h:1521/", "h", 1521, None, Protocol::Tcp),
2273            ("h:/s", "h", 1521, Some("s"), Protocol::Tcp),
2274            ("[2001:db8::1]:1521/s", "2001:db8::1", 1521, Some("s"), Protocol::Tcp),
2275            ("[::1]/s", "::1", 1521, Some("s"), Protocol::Tcp),
2276            ("//h:1521/s", "h", 1521, Some("s"), Protocol::Tcp),
2277            ("h1,h2:1700/s", "h1", 1700, Some("s"), Protocol::Tcp),
2278            ("h/s:dedicated", "h", 1521, Some("s"), Protocol::Tcp),
2279            ("h/s/inst", "h", 1521, Some("s"), Protocol::Tcp),
2280            ("h/s?sdu=16384", "h", 1521, Some("s"), Protocol::Tcp),
2281            ("h/s?pyo.stmtcachesize=40", "h", 1521, Some("s"), Protocol::Tcp),
2282            // descriptor family
2283            (
2284                "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=dh)(PORT=1599))(CONNECT_DATA=(SERVICE_NAME=ds)))",
2285                "dh",
2286                1599,
2287                Some("ds"),
2288                Protocol::Tcp,
2289            ),
2290            (
2291                "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=sh)(PORT=2484))(CONNECT_DATA=(SID=mysid)))",
2292                "sh",
2293                2484,
2294                None,
2295                Protocol::Tcps,
2296            ),
2297            (
2298                "(DESCRIPTION =(ADDRESS=(PROTOCOL=tcp) (HOST = wh) (PORT = 1521))(CONNECT_DATA=(SERVICE_NAME=ws)))",
2299                "wh",
2300                1521,
2301                Some("ws"),
2302                Protocol::Tcp,
2303            ),
2304            (
2305                "(DESCRIPTION=(ADDRESS=(HTTPS_PROXY=px)(HTTPS_PROXY_PORT=8080)(PROTOCOL=tcps)(HOST=ph)(PORT=443))(CONNECT_DATA=(SERVICE_NAME=ps)))",
2306                "ph",
2307                443,
2308                Some("ps"),
2309                Protocol::Tcps,
2310            ),
2311        ];
2312        for (cs, host, port, service, protocol) in cases {
2313            let d = parse_ok(cs);
2314            let a = d
2315                .first_address()
2316                .unwrap_or_else(|| panic!("no address for {cs:?}"));
2317            assert_eq!(a.host.as_deref(), Some(*host), "host mismatch for {cs:?}");
2318            assert_eq!(a.port, *port, "port mismatch for {cs:?}");
2319            assert_eq!(a.protocol, *protocol, "protocol mismatch for {cs:?}");
2320            assert_eq!(
2321                d.first_description().connect_data.service_name.as_deref(),
2322                *service,
2323                "service mismatch for {cs:?}"
2324            );
2325        }
2326    }
2327
2328    /// Each row: (connect_string, expected substring in the diagnostic).
2329    #[test]
2330    fn corpus_malformed_inputs() {
2331        let cases: &[(&str, &str)] = &[
2332            // unbalanced / structural
2333            (
2334                "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1)",
2335                "offset",
2336            ),
2337            ("(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp", "offset"),
2338            // missing addresses (reference DPY-2049)
2339            (
2340                "(DESRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))",
2341                "no addresses are defined",
2342            ),
2343            // invalid protocol (reference DPY-4021)
2344            ("badproto://h/s", "invalid protocol"),
2345            (
2346                "(DESCRIPTION=(ADDRESS=(PROTOCOL=ipc)(KEY=k))(CONNECT_DATA=(SERVICE_NAME=s)))",
2347                "invalid protocol",
2348            ),
2349            // invalid server type (reference DPY-4028)
2350            (
2351                "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVER=BOGUS)(SERVICE_NAME=s)))",
2352                "invalid server_type",
2353            ),
2354            // non-numeric RETRY_COUNT (reference DPY-4018)
2355            (
2356                "(DESCRIPTION=(RETRY_COUNT=wrong)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))",
2357                "not a non-negative integer",
2358            ),
2359            // simple value for a container keyword (reference DPY-4017)
2360            ("(address=5)", "container"),
2361            // mixed complex/simple data (reference DPY-4017)
2362            (
2363                "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVER=DEDICATED) SERVICE_NAME=s))",
2364                "offset",
2365            ),
2366            // empty
2367            ("", "must not be empty"),
2368        ];
2369        for (cs, needle) in cases {
2370            let err = parse(cs)
2371                .err()
2372                .unwrap_or_else(|| panic!("expected error for {cs:?}"));
2373            let msg = format!("{err}");
2374            assert!(
2375                msg.contains(needle),
2376                "diagnostic for {cs:?} = {msg:?} should contain {needle:?}"
2377            );
2378        }
2379    }
2380
2381    #[test]
2382    fn tns_alias_returns_none() {
2383        // A bare alphanumeric name is neither a descriptor nor an EZConnect
2384        // string; it must resolve via tnsnames.ora (parse returns None).
2385        assert!(parse("my_tns_alias")
2386            .expect("alias is not an error")
2387            .is_none());
2388    }
2389
2390    #[test]
2391    fn sdu_is_clamped() {
2392        // reference: SDU sanitised into 512..=2097152
2393        let d = parse_ok("(DESCRIPTION=(SDU=1)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))");
2394        assert_eq!(d.first_description().sdu, 512);
2395        let d = parse_ok("(DESCRIPTION=(SDU=99999999)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))");
2396        assert_eq!(d.first_description().sdu, 2_097_152);
2397    }
2398
2399    #[test]
2400    fn duration_units_parse() {
2401        // reference test_4511
2402        let base = "(DESCRIPTION=(TRANSPORT_CONNECT_TIMEOUT=UNIT)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))";
2403        let cases = [
2404            ("500 ms", 0.5_f64),
2405            ("15 SEC", 15.0),
2406            ("5 min", 300.0),
2407            ("34", 34.0),
2408        ];
2409        for (unit, expected) in cases {
2410            let d = parse_ok(&base.replace("UNIT", unit));
2411            assert!(
2412                (d.first_description().tcp_connect_timeout - expected).abs() < 1e-9,
2413                "duration {unit:?} -> {}",
2414                d.first_description().tcp_connect_timeout
2415            );
2416        }
2417    }
2418
2419    #[test]
2420    fn passthrough_extras_preserved_in_connect_data() {
2421        // reference test_4579 — unknown CONNECT_DATA keys are passed through.
2422        let d = parse_ok(
2423            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)(COLOCATION_TAG=Tag1)))",
2424        );
2425        let extra = &d.first_description().connect_data.extra;
2426        assert!(extra
2427            .iter()
2428            .any(|(k, v)| k == "COLOCATION_TAG" && v == "Tag1"));
2429    }
2430
2431    #[test]
2432    fn wallet_and_cert_dn_in_security() {
2433        // reference test_4515
2434        let d = parse_ok(
2435            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s))\
2436             (SECURITY=(SSL_SERVER_CERT_DN=\"CN=unknown\")(SSL_SERVER_DN_MATCH=Off)(MY_WALLET_DIRECTORY=\"/tmp/w\")))",
2437        );
2438        let sec = &d.first_description().security;
2439        assert_eq!(sec.ssl_server_cert_dn.as_deref(), Some("CN=unknown"));
2440        assert_eq!(sec.wallet_location.as_deref(), Some("/tmp/w"));
2441        assert!(!sec.ssl_server_dn_match);
2442    }
2443}
2444
2445#[cfg(test)]
2446mod tnsnames_tests {
2447    use super::tnsnames::TnsnamesReader;
2448    use super::*;
2449    use std::io::Write;
2450
2451    /// Writes `contents` to `<dir>/<name>` and returns nothing.
2452    fn write_file(dir: &std::path::Path, name: &str, contents: &str) {
2453        let path = dir.join(name);
2454        let mut f = std::fs::File::create(&path).expect("create tns file");
2455        f.write_all(contents.as_bytes()).expect("write tns file");
2456    }
2457
2458    fn temp_dir() -> std::path::PathBuf {
2459        let base = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".to_string());
2460        let unique = format!(
2461            "hk6_tns_{}_{}",
2462            std::process::id(),
2463            std::time::SystemTime::now()
2464                .duration_since(std::time::UNIX_EPOCH)
2465                .unwrap()
2466                .as_nanos()
2467        );
2468        let dir = std::path::Path::new(&base).join(unique);
2469        std::fs::create_dir_all(&dir).expect("create temp dir");
2470        dir
2471    }
2472
2473    #[test]
2474    fn resolves_simple_alias() {
2475        // reference test_7200
2476        let dir = temp_dir();
2477        write_file(
2478            &dir,
2479            "tnsnames.ora",
2480            "nsn_7200 = (DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=host_7200)(PORT=7200))\
2481             (CONNECT_DATA=(SERVICE_NAME=service_7200)))",
2482        );
2483        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2484        let cs = reader.get("nsn_7200").expect("alias present");
2485        let d = parse(cs).unwrap().unwrap();
2486        let a = d.first_address().unwrap();
2487        assert_eq!(a.host.as_deref(), Some("host_7200"));
2488        assert_eq!(a.port, 7200);
2489    }
2490
2491    #[test]
2492    fn missing_entry_is_none() {
2493        // reference test_7201
2494        let dir = temp_dir();
2495        write_file(&dir, "tnsnames.ora", "# no entries");
2496        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2497        assert!(reader.get("nsn_7201").is_none());
2498        assert!(reader.service_names().is_empty());
2499    }
2500
2501    #[test]
2502    fn missing_file_errors() {
2503        // reference test_7202
2504        let dir = temp_dir();
2505        let err = TnsnamesReader::read(&dir).unwrap_err();
2506        assert!(format!("{err}").contains("missing or unreadable"));
2507    }
2508
2509    #[test]
2510    fn ignores_garbage_lines() {
2511        // reference test_7203
2512        let dir = temp_dir();
2513        write_file(
2514            &dir,
2515            "tnsnames.ora",
2516            "some garbage data which is not a valid entry\n\
2517             nsn_7203 = host_7203:7203/service_7203\n",
2518        );
2519        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2520        assert!(reader.get("nsn_7203").is_some());
2521    }
2522
2523    #[test]
2524    fn multiple_aliases_one_line() {
2525        // reference test_7204
2526        let dir = temp_dir();
2527        write_file(
2528            &dir,
2529            "tnsnames.ora",
2530            "nsn_7204a,nsn_7204b = host_7204:7204/service_7204\n",
2531        );
2532        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2533        assert!(reader.get("nsn_7204a").is_some());
2534        assert!(reader.get("nsn_7204b").is_some());
2535        assert_eq!(reader.service_names(), vec!["NSN_7204A", "NSN_7204B"]);
2536    }
2537
2538    #[test]
2539    fn case_insensitive_alias_lookup() {
2540        let dir = temp_dir();
2541        write_file(&dir, "tnsnames.ora", "Nsn_X = host:1521/svc\n");
2542        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2543        assert!(reader.get("nsn_x").is_some());
2544        assert!(reader.get("NSN_X").is_some());
2545    }
2546
2547    #[test]
2548    fn ifile_same_directory() {
2549        // reference test_7207
2550        let dir = temp_dir();
2551        write_file(&dir, "inc_7207.ora", "nsn_7207b = host_b:72072/service_b");
2552        write_file(
2553            &dir,
2554            "tnsnames.ora",
2555            "nsn_7207a = host_a:72071/service_a\nifile = inc_7207.ora",
2556        );
2557        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2558        assert!(reader.get("nsn_7207a").is_some());
2559        assert!(reader.get("nsn_7207b").is_some());
2560    }
2561
2562    #[test]
2563    fn ifile_cycle_detected() {
2564        // reference test_7209
2565        let dir = temp_dir();
2566        write_file(
2567            &dir,
2568            "tnsnames.ora",
2569            "nsn_7209 = some_host/some_service\nIFILE = tnsnames.ora",
2570        );
2571        let err = TnsnamesReader::read(&dir).unwrap_err();
2572        assert!(format!("{err}").contains("cycle"));
2573    }
2574
2575    #[test]
2576    fn ifile_quoted_path() {
2577        // reference test_7223 style (double-quoted IFILE path)
2578        let dir = temp_dir();
2579        let inc = dir.join("inc_q.ora");
2580        write_file(&dir, "inc_q.ora", "nsn_q = host_q:1521/svc_q");
2581        write_file(
2582            &dir,
2583            "tnsnames.ora",
2584            &format!(
2585                "nsn_main = host_m:1521/svc_m\nifile = \"{}\"",
2586                inc.display()
2587            ),
2588        );
2589        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2590        assert!(reader.get("nsn_q").is_some());
2591    }
2592
2593    #[test]
2594    fn duplicate_entry_last_wins() {
2595        // reference test_7213
2596        let dir = temp_dir();
2597        write_file(
2598            &dir,
2599            "tnsnames.ora",
2600            "nsn = host_a:7213/svc_a\nother = h/s\nnsn = host_b:7213/svc_b\n",
2601        );
2602        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2603        let d = parse(reader.get("nsn").unwrap()).unwrap().unwrap();
2604        assert_eq!(d.first_address().unwrap().host.as_deref(), Some("host_b"));
2605    }
2606
2607    #[test]
2608    fn multiline_aliases() {
2609        // reference test_7219
2610        let dir = temp_dir();
2611        write_file(
2612            &dir,
2613            "tnsnames.ora",
2614            "nsn_a,\nnsn_b,\nnsn_c = host:1521/svc",
2615        );
2616        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2617        assert!(reader.get("nsn_a").is_some());
2618        assert!(reader.get("nsn_b").is_some());
2619        assert!(reader.get("nsn_c").is_some());
2620    }
2621
2622    #[test]
2623    fn embedded_comment_in_descriptor() {
2624        // reference test_7220
2625        let dir = temp_dir();
2626        write_file(
2627            &dir,
2628            "tnsnames.ora",
2629            "nsn_7220 = (DESCRIPTION=\n(ADDRESS=(PROTOCOL=TCP)(HOST=host_7220)(PORT=7220))\n\
2630             (CONNECT_DATA=\n(SERVICE_NAME=service_7220)\n# embedded comment\n)\n)\n",
2631        );
2632        let reader = TnsnamesReader::read(&dir).expect("read tnsnames");
2633        let d = parse(reader.get("nsn_7220").unwrap()).unwrap().unwrap();
2634        assert_eq!(
2635            d.first_address().unwrap().host.as_deref(),
2636            Some("host_7220")
2637        );
2638    }
2639
2640    #[test]
2641    fn missing_ifile_errors() {
2642        // reference test_7216
2643        let dir = temp_dir();
2644        write_file(&dir, "tnsnames.ora", "IFILE = missing.ora\n");
2645        let err = TnsnamesReader::read(&dir).unwrap_err();
2646        assert!(format!("{err}").contains("missing or unreadable"));
2647    }
2648
2649    // bead rust-oracledb-uf8: a deeply-nested descriptor must return a clean
2650    // Err, never recurse until the stack overflows and ABORTS the process.
2651    #[test]
2652    fn deeply_nested_descriptor_errors_not_crashes() {
2653        // 5000 levels of "(A=" + "1" + 5000 ")" — far past MAX_DESCRIPTOR_DEPTH
2654        // but small enough that the depth guard fires long before any real
2655        // stack pressure. Without the guard this overflows the stack.
2656        let depth = 5000;
2657        let mut s = String::with_capacity(depth * 4);
2658        for _ in 0..depth {
2659            s.push_str("(A=");
2660        }
2661        s.push('1');
2662        for _ in 0..depth {
2663            s.push(')');
2664        }
2665        let err = parse(&s).unwrap_err();
2666        assert!(
2667            format!("{err}").contains("nesting too deep"),
2668            "expected a nesting-depth error, got: {err}"
2669        );
2670    }
2671
2672    #[test]
2673    fn legitimately_deep_descriptor_still_parses() {
2674        // A realistic DESCRIPTION_LIST topology (~5 deep) must NOT be rejected.
2675        let ok = "(DESCRIPTION_LIST=(DESCRIPTION=(ADDRESS_LIST=\
2676                  (ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1521)))\
2677                  (CONNECT_DATA=(SERVICE_NAME=svc))))";
2678        assert!(parse(ok).is_ok(), "a real ~5-deep descriptor must parse");
2679    }
2680}