Skip to main content

oracledb_protocol/net/connectstring/
mod.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
431// ---------------------------------------------------------------------------
432// Diagnostics helpers
433// ---------------------------------------------------------------------------
434
435/// The raw connect string is included so the message is self-describing; a
436/// caret-context snippet is appended pointing at `offset` (a char index into
437/// the trimmed string) so the operator can see exactly where parsing failed.
438fn err_descriptor(connect_string: &str, char_offset: usize, reason: &str) -> ProtocolError {
439    let trimmed = connect_string.trim();
440    let snippet = context_snippet(trimmed, char_offset);
441    ProtocolError::InvalidConnectDescriptor(format!(
442        "invalid connect descriptor \"{connect_string}\": {reason} at offset {char_offset}\n{snippet}"
443    ))
444}
445
446fn err_cannot_parse(connect_string: &str) -> ProtocolError {
447    ProtocolError::InvalidConnectDescriptor(format!(
448        "cannot parse connect string \"{connect_string}\""
449    ))
450}
451
452/// Builds a two-line snippet: a window of the input around `char_offset` and a
453/// caret `^` underneath the offending character.
454fn context_snippet(trimmed: &str, char_offset: usize) -> String {
455    let chars: Vec<char> = trimmed.chars().collect();
456    let start = char_offset.saturating_sub(20);
457    let end = (char_offset + 20).min(chars.len());
458    let window: String = chars[start..end].iter().collect();
459    let caret_pos = char_offset - start;
460    let mut caret = String::new();
461    for _ in 0..caret_pos {
462        caret.push(' ');
463    }
464    caret.push('^');
465    format!("  {window}\n  {caret}")
466}
467
468// ---------------------------------------------------------------------------
469// Descriptor argument tree
470// ---------------------------------------------------------------------------
471
472/// A parsed value in the descriptor argument tree: either a simple string or a
473/// nested key/value map (a parenthesised sub-node).
474#[derive(Clone, Debug)]
475enum ArgValue {
476    Simple(String),
477    Node(ArgMap),
478}
479
480/// A descriptor node: maps lower-cased keys to one or more values. The reference
481/// stores repeated keys as a Python list; we model that as a `Vec` per key.
482#[derive(Clone, Debug, Default)]
483struct ArgMap {
484    entries: Vec<(String, Vec<ArgValue>)>,
485}
486
487impl ArgMap {
488    fn get(&self, key: &str) -> Option<&Vec<ArgValue>> {
489        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
490    }
491
492    fn take(&mut self, key: &str) -> Option<Vec<ArgValue>> {
493        if let Some(idx) = self.entries.iter().position(|(k, _)| k == key) {
494            Some(self.entries.remove(idx).1)
495        } else {
496            None
497        }
498    }
499
500    fn push(&mut self, key: String, value: ArgValue) {
501        if let Some((_, values)) = self.entries.iter_mut().find(|(k, _)| *k == key) {
502            values.push(value);
503        } else {
504            self.entries.push((key, vec![value]));
505        }
506    }
507}
508
509/// Alternative parameter names accepted inside descriptors (reference
510/// `ALTERNATIVE_PARAM_NAMES`): the listener keyword maps to the canonical key.
511///
512/// `connect_timeout` is aliased to `tcp_connect_timeout` beyond the reference's
513/// table (bead `rust-oracledb-clvm`, F1): the thin driver applies a single
514/// connect deadline across dial + ACCEPT + AUTH, so an operator-set
515/// `CONNECT_TIMEOUT` is honored as that deadline rather than silently ignored
516/// (the footgun the bead calls out). python-oracledb thin drops DESCRIPTION
517/// `CONNECT_TIMEOUT`; this is a deliberate, documented footgun-removal.
518fn canonical_param_name(name: &str) -> &str {
519    match name {
520        "pool_connection_class" => "cclass",
521        "pool_purity" => "purity",
522        "server" => "server_type",
523        "transport_connect_timeout" | "connect_timeout" => "tcp_connect_timeout",
524        "my_wallet_directory" => "wallet_location",
525        other => other,
526    }
527}
528
529/// Container keywords that may not take a simple (non-parenthesised) value
530/// (reference `CONTAINER_PARAM_NAMES`).
531fn is_container_param(name: &str) -> bool {
532    matches!(
533        name,
534        "address"
535            | "address_list"
536            | "connect_data"
537            | "description"
538            | "description_list"
539            | "security"
540    )
541}
542
543// ---------------------------------------------------------------------------
544// Descriptor tokenizer / recursive-descent parser
545// ---------------------------------------------------------------------------
546
547/// Recursive-descent parser for TNS connect descriptors. Mirrors the reference
548/// `ConnectStringParser` (`_parse_descriptor_key_value_pair`): it tokenises
549/// keywords, simple values, and quoted strings while tracking nested parens.
550/// Maximum nesting depth for a TNS connect descriptor. Real topologies
551/// (DESCRIPTION_LIST > DESCRIPTION > ADDRESS_LIST > ADDRESS / CONNECT_DATA >
552/// SECURITY ...) are well under 10 deep; 128 is far beyond any legitimate
553/// descriptor. The cap converts an attacker/garbage deeply-nested input into a
554/// clean `Result::Err` instead of unbounded recursion that overflows the stack
555/// and ABORTS the process (an uncatchable crash, not a recoverable panic) —
556/// bead rust-oracledb-uf8.
557const MAX_DESCRIPTOR_DEPTH: usize = 128;
558
559struct DescriptorParser<'a> {
560    chars: &'a [char],
561    raw: &'a str,
562    /// Confirmed cursor (chars consumed).
563    pos: usize,
564    /// Lookahead cursor.
565    temp_pos: usize,
566    /// Current parenthesis nesting depth (guards against stack overflow).
567    depth: usize,
568}
569
570impl<'a> DescriptorParser<'a> {
571    fn new(chars: &'a [char], raw: &'a str) -> Self {
572        Self {
573            chars,
574            raw,
575            pos: 0,
576            temp_pos: 0,
577            depth: 0,
578        }
579    }
580
581    fn current(&self) -> char {
582        self.chars[self.temp_pos]
583    }
584
585    fn skip_spaces(&mut self) {
586        while self.temp_pos < self.chars.len() && self.chars[self.temp_pos].is_whitespace() {
587            self.temp_pos += 1;
588        }
589    }
590
591    /// Parses a keyword: alphanumeric plus `_` and `.` (reference
592    /// `parse_keyword`).
593    fn parse_keyword(&mut self) {
594        while self.temp_pos < self.chars.len() {
595            let ch = self.current();
596            if !ch.is_alphanumeric() && ch != '_' && ch != '.' {
597                break;
598            }
599            self.temp_pos += 1;
600        }
601    }
602
603    /// Parses a quoted string body, consuming the closing quote (reference
604    /// `parse_quoted_string`). On entry `temp_pos` is just past the opening
605    /// quote.
606    fn parse_quoted_string(&mut self, quote: char) -> Result<()> {
607        while self.temp_pos < self.chars.len() {
608            let ch = self.current();
609            self.temp_pos += 1;
610            if ch == quote {
611                self.pos = self.temp_pos;
612                return Ok(());
613            }
614        }
615        let reason = if quote == '\'' {
616            "missing ending quote (')"
617        } else {
618            "missing ending quote (\")"
619        };
620        Err(err_descriptor(self.raw, self.temp_pos, reason))
621    }
622
623    /// Parses a top-level descriptor node. On entry the opening `(` has already
624    /// been consumed (reference `_parse_descriptor` calls
625    /// `_parse_descriptor_key_value_pair` once on the implicit root).
626    fn parse_descriptor(&mut self) -> Result<ArgMap> {
627        let mut args = ArgMap::default();
628        self.parse_key_value_pair(&mut args)?;
629        Ok(args)
630    }
631
632    /// Parses one `(KEY=VALUE)` pair into `args`. Assumes the opening `(` for
633    /// this pair was already consumed. Directly mirrors the reference
634    /// `_parse_descriptor_key_value_pair`.
635    fn parse_key_value_pair(&mut self, args: &mut ArgMap) -> Result<()> {
636        let mut is_simple_value = false;
637        let mut simple_start = 0usize;
638        let mut value: Option<ArgValue> = None;
639
640        // parse keyword
641        self.skip_spaces();
642        let start_pos = self.temp_pos;
643        self.parse_keyword();
644        if self.temp_pos == start_pos {
645            return Err(err_descriptor(
646                self.raw,
647                self.temp_pos,
648                "expected a keyword",
649            ));
650        }
651        let raw_name: String = self.chars[start_pos..self.temp_pos]
652            .iter()
653            .collect::<String>()
654            .to_ascii_lowercase();
655        let name = canonical_param_name(&raw_name).to_string();
656
657        // look for equals sign
658        self.skip_spaces();
659        let mut ch = '\0';
660        if self.temp_pos < self.chars.len() {
661            ch = self.current();
662        }
663        if ch != '=' {
664            return Err(err_descriptor(
665                self.raw,
666                self.temp_pos,
667                "expected '=' after keyword",
668            ));
669        }
670        self.temp_pos += 1;
671        self.skip_spaces();
672
673        // parse value
674        while self.temp_pos < self.chars.len() {
675            ch = self.current();
676            if ch == '"' {
677                if is_simple_value {
678                    return Err(err_descriptor(
679                        self.raw,
680                        self.temp_pos,
681                        "unexpected quote inside a simple value",
682                    ));
683                }
684                self.temp_pos += 1;
685                let q_start = self.temp_pos;
686                self.parse_quoted_string('"')?;
687                if self.temp_pos > q_start + 1 {
688                    let v: String = self.chars[q_start..self.temp_pos - 1].iter().collect();
689                    value = Some(ArgValue::Simple(v));
690                }
691                break;
692            } else if ch == '(' {
693                if is_simple_value {
694                    return Err(err_descriptor(
695                        self.raw,
696                        self.temp_pos,
697                        "unexpected '(' inside a simple value",
698                    ));
699                }
700                self.temp_pos += 1;
701                let mut node = match value.take() {
702                    Some(ArgValue::Node(n)) => n,
703                    _ => ArgMap::default(),
704                };
705                self.depth += 1;
706                if self.depth > MAX_DESCRIPTOR_DEPTH {
707                    return Err(err_descriptor(
708                        self.raw,
709                        self.temp_pos,
710                        "connect descriptor nesting too deep",
711                    ));
712                }
713                let result = self.parse_key_value_pair(&mut node);
714                self.depth -= 1;
715                result?;
716                value = Some(ArgValue::Node(node));
717                continue;
718            } else if ch == ')' {
719                break;
720            } else if !is_simple_value && !ch.is_whitespace() {
721                if value.is_some() || is_container_param(&name) {
722                    return Err(err_descriptor(
723                        self.raw,
724                        self.temp_pos,
725                        "unexpected simple value for a container keyword",
726                    ));
727                }
728                simple_start = self.temp_pos;
729                is_simple_value = true;
730            }
731            self.temp_pos += 1;
732        }
733        if is_simple_value {
734            let v: String = self.chars[simple_start..self.temp_pos]
735                .iter()
736                .collect::<String>()
737                .trim()
738                .to_string();
739            value = Some(ArgValue::Simple(v));
740        }
741        self.skip_spaces();
742        if self.temp_pos < self.chars.len() {
743            ch = self.current();
744            if ch != ')' {
745                return Err(err_descriptor(
746                    self.raw,
747                    self.temp_pos,
748                    "expected ')' to close the keyword",
749                ));
750            }
751            self.temp_pos += 1;
752        } else {
753            return Err(err_descriptor(
754                self.raw,
755                self.temp_pos,
756                "unbalanced parenthesis: expected ')'",
757            ));
758        }
759        self.skip_spaces();
760        self.pos = self.temp_pos;
761
762        if let Some(value) = value {
763            self.set_descriptor_arg(args, name, value);
764        }
765        Ok(())
766    }
767
768    /// Stores a value in `args`, mirroring the reference `_set_descriptor_arg`
769    /// special handling for `address` vs `address_list` interleaving.
770    fn set_descriptor_arg(&self, args: &mut ArgMap, name: String, value: ArgValue) {
771        if args.get(&name).is_none() {
772            if name == "address" && args.get("address_list").is_some() {
773                let mut wrapper = ArgMap::default();
774                wrapper.push("address".to_string(), value);
775                self.set_descriptor_arg(args, "address_list".to_string(), ArgValue::Node(wrapper));
776                return;
777            } else if name == "address_list" && args.get("address").is_some() {
778                let addresses = args.take("address").unwrap_or_default();
779                // existing addresses become their own address_list nodes,
780                // preserving order before the new list.
781                for addr in addresses {
782                    let mut wrapper = ArgMap::default();
783                    wrapper.push("address".to_string(), addr);
784                    args.push("address_list".to_string(), ArgValue::Node(wrapper));
785                }
786                args.push(name, value);
787                return;
788            }
789            args.push(name, value);
790        } else {
791            args.push(name, value);
792        }
793    }
794}
795
796// ---------------------------------------------------------------------------
797// tnsnames.ora parsing
798// ---------------------------------------------------------------------------
799
800/// Parses `tnsnames.ora` files into an alias -> connect-descriptor map.
801///
802/// Mirrors the reference `TnsnamesFileParser` / `TnsnamesFileReader`:
803/// comment (`#`) handling, multi-line paren-balanced values, comma-separated
804/// alias lists, and `IFILE` includes (resolved relative to the including file's
805/// directory) with cycle detection. Aliases are upper-cased; the last
806/// definition of a duplicate alias wins.
807pub mod tnsnames;
808
809mod builders;
810use builders::build_descriptor;
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    fn parse_ok(input: &str) -> Descriptor {
816        parse(input)
817            .unwrap_or_else(|e| panic!("parse({input:?}) should succeed but failed: {e}"))
818            .unwrap_or_else(|| panic!("parse({input:?}) should be a descriptor, not a tns alias"))
819    }
820
821    /// Flattened host list across all descriptions/lists (host order),
822    /// mirroring python-oracledb's `params.host` for the multi-address case.
823    fn hosts(d: &Descriptor) -> Vec<String> {
824        d.addresses().filter_map(|a| a.host.clone()).collect()
825    }
826
827    fn ports(d: &Descriptor) -> Vec<u16> {
828        d.addresses().map(|a| a.port).collect()
829    }
830
831    fn protocols(d: &Descriptor) -> Vec<Protocol> {
832        d.addresses().map(|a| a.protocol).collect()
833    }
834
835    #[test]
836    fn parses_simple_name_value_descriptor() {
837        // reference test_4503
838        let d = parse_ok(
839            "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=my_host4)(PORT=1589))\
840             (CONNECT_DATA=(SERVICE_NAME=my_service_name4)))",
841        );
842        let addr = d.first_address().expect("descriptor has an address");
843        assert_eq!(addr.host.as_deref(), Some("my_host4"));
844        assert_eq!(addr.port, 1589);
845        assert_eq!(addr.protocol, Protocol::Tcp);
846        assert_eq!(
847            d.first_description().connect_data.service_name.as_deref(),
848            Some("my_service_name4")
849        );
850    }
851
852    // --- EZConnect / EZConnect-Plus -------------------------------------
853
854    #[test]
855    fn parses_easy_connect_with_port() {
856        // reference test_4500
857        let d = parse_ok("my_host:1578/my_service_name");
858        let a = d.first_address().unwrap();
859        assert_eq!(a.host.as_deref(), Some("my_host"));
860        assert_eq!(a.port, 1578);
861        assert_eq!(
862            d.first_description().connect_data.service_name.as_deref(),
863            Some("my_service_name")
864        );
865    }
866
867    #[test]
868    fn parses_easy_connect_default_port() {
869        // reference test_4501
870        let d = parse_ok("my_host2/my_service_name2");
871        let a = d.first_address().unwrap();
872        assert_eq!(a.host.as_deref(), Some("my_host2"));
873        assert_eq!(a.port, 1521);
874    }
875
876    #[test]
877    fn parses_easy_connect_drcp_server_type() {
878        // reference test_4502
879        let d = parse_ok("my_host3.org/my_service_name3:pooled");
880        assert_eq!(
881            d.first_description().connect_data.server_type,
882            Some(ServerType::Pooled)
883        );
884        let d = parse_ok("my_host3/my_service_name3:ShArEd");
885        assert_eq!(
886            d.first_description().connect_data.server_type,
887            Some(ServerType::Shared)
888        );
889    }
890
891    #[test]
892    fn parses_easy_connect_tcps_protocol() {
893        // reference test_4504
894        let d = parse_ok("tcps://my_host6/my_service_name6");
895        assert_eq!(d.first_address().unwrap().protocol, Protocol::Tcps);
896    }
897
898    #[test]
899    fn parses_easy_connect_no_service() {
900        // reference test_4512
901        let d = parse_ok("my_host15:1578/");
902        let a = d.first_address().unwrap();
903        assert_eq!(a.host.as_deref(), Some("my_host15"));
904        assert_eq!(a.port, 1578);
905        assert!(d.first_description().connect_data.service_name.is_none());
906    }
907
908    #[test]
909    fn parses_easy_connect_missing_port_value() {
910        // reference test_4513
911        let d = parse_ok("my_host17:/my_service_name17");
912        let a = d.first_address().unwrap();
913        assert_eq!(a.host.as_deref(), Some("my_host17"));
914        assert_eq!(a.port, 1521);
915        assert_eq!(
916            d.first_description().connect_data.service_name.as_deref(),
917            Some("my_service_name17")
918        );
919    }
920
921    #[test]
922    fn parses_easy_connect_ipv6() {
923        // reference test_4547
924        let d = parse_ok("[::1]:4547/service_name_4547");
925        let a = d.first_address().unwrap();
926        assert_eq!(a.host.as_deref(), Some("::1"));
927        assert_eq!(a.port, 4547);
928        assert_eq!(
929            d.first_description().connect_data.service_name.as_deref(),
930            Some("service_name_4547")
931        );
932    }
933
934    #[test]
935    fn parses_easy_connect_multiple_hosts_different_ports() {
936        // reference test_4548
937        let d = parse_ok("host4548a,host4548b:4548,host4548c,host4548d:4549/service_name_4548");
938        assert_eq!(
939            hosts(&d),
940            vec!["host4548a", "host4548b", "host4548c", "host4548d"]
941        );
942        assert_eq!(ports(&d), vec![4548, 4548, 4549, 4549]);
943    }
944
945    #[test]
946    fn parses_easy_connect_multiple_address_lists() {
947        // reference test_4549
948        let d = parse_ok("host4549a;host4549b,host4549c:4549;host4549d/service_name_4549");
949        assert_eq!(
950            hosts(&d),
951            vec!["host4549a", "host4549b", "host4549c", "host4549d"]
952        );
953        assert_eq!(ports(&d), vec![1521, 4549, 4549, 1521]);
954    }
955
956    #[test]
957    fn parses_easy_connect_degenerate_protocol() {
958        // reference test_4552
959        let d = parse_ok("//host_4552:4552/service_name_4552");
960        let a = d.first_address().unwrap();
961        assert_eq!(a.host.as_deref(), Some("host_4552"));
962        assert_eq!(a.port, 4552);
963    }
964
965    #[test]
966    fn parses_easy_connect_instance_name() {
967        // reference test_4571
968        let d = parse_ok("host_4571:4571/service_4571/instance_4571");
969        assert_eq!(
970            d.first_description().connect_data.instance_name.as_deref(),
971            Some("instance_4571")
972        );
973        assert_eq!(
974            d.first_description().connect_data.service_name.as_deref(),
975            Some("service_4571")
976        );
977    }
978
979    #[test]
980    fn parses_easy_connect_extended_params() {
981        // reference test_4517
982        let d = parse_ok(
983            "my_host21/my_server_name21?expire_time=5&retry_delay=10&retry_count=12&transport_connect_timeout=2.5",
984        );
985        let desc = d.first_description();
986        assert_eq!(desc.expire_time, 5);
987        assert_eq!(desc.retry_delay, 10);
988        assert_eq!(desc.retry_count, 12);
989        assert!((desc.tcp_connect_timeout - 2.5).abs() < 1e-9);
990    }
991
992    #[test]
993    fn connect_timeout_aliases_transport_connect_timeout() {
994        // bead rust-oracledb-clvm (F1): CONNECT_TIMEOUT is honored as the
995        // transport/connect deadline (aliased to tcp_connect_timeout) in both a
996        // full DESCRIPTION and an EZConnect string, so it is never silently
997        // dropped.
998        let d = parse_ok(
999            "(DESCRIPTION=(CONNECT_TIMEOUT=3)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1521))\
1000             (CONNECT_DATA=(SERVICE_NAME=svc)))",
1001        );
1002        assert!((d.first_description().tcp_connect_timeout - 3.0).abs() < 1e-9);
1003        let ez = parse_ok("h:1521/svc?connect_timeout=4");
1004        assert!((ez.first_description().tcp_connect_timeout - 4.0).abs() < 1e-9);
1005    }
1006
1007    #[test]
1008    fn parses_easy_connect_security_params() {
1009        // reference test_4582
1010        let d = parse_ok(
1011            "tcps://host_4580:4580/service_4580?ssl_server_dn_match=true&ssl_server_cert_dn='cn=sales'&wallet_location='/tmp/oracle'",
1012        );
1013        // Single quotes are preserved verbatim in EZConnect-Plus params
1014        // (only double quotes are stripped) — matches reference test_4582,
1015        // whose get_connect_string() keeps the single quotes.
1016        let sec = &d.first_description().security;
1017        assert!(sec.ssl_server_dn_match);
1018        assert_eq!(sec.ssl_server_cert_dn.as_deref(), Some("'cn=sales'"));
1019        assert_eq!(sec.wallet_location.as_deref(), Some("'/tmp/oracle'"));
1020    }
1021
1022    #[test]
1023    fn rejects_invalid_protocol_in_easy_connect() {
1024        // reference test_4505
1025        let err = parse("invalid_proto://my_host7/my_service_name7").unwrap_err();
1026        assert!(format!("{err}").contains("invalid protocol"));
1027    }
1028
1029    // --- diagnostics ----------------------------------------------------
1030
1031    #[test]
1032    fn diagnostic_points_at_unbalanced_paren() {
1033        let err = parse("(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1521))").unwrap_err();
1034        let msg = format!("{err}");
1035        assert!(msg.contains("offset"), "expected offset in: {msg}");
1036        assert!(msg.contains('^'), "expected caret context in: {msg}");
1037    }
1038
1039    #[test]
1040    fn diagnostic_for_missing_addresses() {
1041        // reference test_4546 (wrong container names -> no addresses)
1042        let err = parse(
1043            "(DESRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))",
1044        )
1045        .unwrap_err();
1046        assert!(format!("{err}").contains("no addresses are defined"));
1047    }
1048
1049    #[test]
1050    fn protocol_default_port_resolves_for_unported_address() {
1051        let d = parse_ok("tcps://h/svc");
1052        assert_eq!(d.first_address().unwrap().port, 2484);
1053    }
1054
1055    #[test]
1056    fn describe_dumps_addresses() {
1057        let d = parse_ok(
1058            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h1)(PORT=1521))\
1059             (CONNECT_DATA=(SERVICE_NAME=svc)))",
1060        );
1061        let text = d.describe();
1062        assert!(text.contains("tcp://h1:1521"));
1063        assert!(text.contains("service_name=svc"));
1064    }
1065
1066    #[test]
1067    fn keeps_protocols_for_multi_list_descriptor() {
1068        // reference test_4522
1069        let d = parse_ok(
1070            "(DESCRIPTION=(LOAD_BALANCE=ON)(RETRY_COUNT=5)(RETRY_DELAY=2)\
1071             (ADDRESS_LIST=(LOAD_BALANCE=ON)\
1072             (ADDRESS=(PROTOCOL=tcp)(PORT=1521)(HOST=my_host26))\
1073             (ADDRESS=(PROTOCOL=tcp)(PORT=222)(HOST=my_host27)))\
1074             (ADDRESS_LIST=(LOAD_BALANCE=ON)\
1075             (ADDRESS=(PROTOCOL=tcps)(PORT=5555)(HOST=my_host28))\
1076             (ADDRESS=(PROTOCOL=tcps)(PORT=444)(HOST=my_host29)))\
1077             (CONNECT_DATA=(SERVICE_NAME=my_service_name26)))",
1078        );
1079        assert_eq!(
1080            hosts(&d),
1081            vec!["my_host26", "my_host27", "my_host28", "my_host29"]
1082        );
1083        assert_eq!(ports(&d), vec![1521, 222, 5555, 444]);
1084        assert_eq!(
1085            protocols(&d),
1086            vec![Protocol::Tcp, Protocol::Tcp, Protocol::Tcps, Protocol::Tcps]
1087        );
1088    }
1089
1090    #[test]
1091    fn parses_multiple_descriptions() {
1092        // reference test_4523 (host ordering across descriptions)
1093        let d = parse_ok(
1094            "(DESCRIPTION_LIST=(FAIL_OVER=ON)(LOAD_BALANCE=OFF)\
1095             (DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(PORT=5001)(HOST=my_host30))\
1096             (ADDRESS=(PROTOCOL=tcp)(PORT=1521)(HOST=my_host31)))\
1097             (CONNECT_DATA=(SERVICE_NAME=svc27)))\
1098             (DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(PORT=5002)(HOST=my_host34)))\
1099             (CONNECT_DATA=(SERVICE_NAME=svc28))))",
1100        );
1101        assert_eq!(hosts(&d), vec!["my_host30", "my_host31", "my_host34"]);
1102        assert_eq!(d.descriptions.len(), 2);
1103    }
1104
1105    #[test]
1106    fn interleaves_address_and_address_list_small_first() {
1107        // reference test_4529
1108        let d = parse_ok(
1109            "(DESCRIPTION=\
1110             (ADDRESS=(PROTOCOL=tcp)(HOST=host1)(PORT=1521))\
1111             (ADDRESS_LIST=(ADDRESS=(PROTOCOL=tcp)(HOST=host2a)(PORT=1522))\
1112             (ADDRESS=(PROTOCOL=tcp)(HOST=host2b)(PORT=1523)))\
1113             (ADDRESS=(PROTOCOL=tcp)(HOST=host3)(PORT=1524))\
1114             (CONNECT_DATA=(SERVICE_NAME=svc)))",
1115        );
1116        assert_eq!(hosts(&d), vec!["host1", "host2a", "host2b", "host3"]);
1117    }
1118
1119    // --- corpus-differential table (valid inputs) -----------------------
1120
1121    /// Each row: (connect_string, first_host, first_port, service_name option,
1122    /// first_protocol). Drives a broad differential sweep matching the
1123    /// reference's parse results across EZConnect and descriptor forms.
1124    #[test]
1125    fn corpus_valid_inputs() {
1126        let cases: &[(&str, &str, u16, Option<&str>, Protocol)] = &[
1127            // EZConnect family
1128            ("h/s", "h", 1521, Some("s"), Protocol::Tcp),
1129            ("h:1600/s", "h", 1600, Some("s"), Protocol::Tcp),
1130            ("tcp://h/s", "h", 1521, Some("s"), Protocol::Tcp),
1131            ("tcps://h/s", "h", 2484, Some("s"), Protocol::Tcps),
1132            ("tcps://h:9999/s", "h", 9999, Some("s"), Protocol::Tcps),
1133            ("h.example.org/s.dom", "h.example.org", 1521, Some("s.dom"), Protocol::Tcp),
1134            ("h:1521/", "h", 1521, None, Protocol::Tcp),
1135            ("h:/s", "h", 1521, Some("s"), Protocol::Tcp),
1136            ("[2001:db8::1]:1521/s", "2001:db8::1", 1521, Some("s"), Protocol::Tcp),
1137            ("[::1]/s", "::1", 1521, Some("s"), Protocol::Tcp),
1138            ("//h:1521/s", "h", 1521, Some("s"), Protocol::Tcp),
1139            ("h1,h2:1700/s", "h1", 1700, Some("s"), Protocol::Tcp),
1140            ("h/s:dedicated", "h", 1521, Some("s"), Protocol::Tcp),
1141            ("h/s/inst", "h", 1521, Some("s"), Protocol::Tcp),
1142            ("h/s?sdu=16384", "h", 1521, Some("s"), Protocol::Tcp),
1143            ("h/s?pyo.stmtcachesize=40", "h", 1521, Some("s"), Protocol::Tcp),
1144            // descriptor family
1145            (
1146                "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=dh)(PORT=1599))(CONNECT_DATA=(SERVICE_NAME=ds)))",
1147                "dh",
1148                1599,
1149                Some("ds"),
1150                Protocol::Tcp,
1151            ),
1152            (
1153                "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=sh)(PORT=2484))(CONNECT_DATA=(SID=mysid)))",
1154                "sh",
1155                2484,
1156                None,
1157                Protocol::Tcps,
1158            ),
1159            (
1160                "(DESCRIPTION =(ADDRESS=(PROTOCOL=tcp) (HOST = wh) (PORT = 1521))(CONNECT_DATA=(SERVICE_NAME=ws)))",
1161                "wh",
1162                1521,
1163                Some("ws"),
1164                Protocol::Tcp,
1165            ),
1166            (
1167                "(DESCRIPTION=(ADDRESS=(HTTPS_PROXY=px)(HTTPS_PROXY_PORT=8080)(PROTOCOL=tcps)(HOST=ph)(PORT=443))(CONNECT_DATA=(SERVICE_NAME=ps)))",
1168                "ph",
1169                443,
1170                Some("ps"),
1171                Protocol::Tcps,
1172            ),
1173        ];
1174        for (cs, host, port, service, protocol) in cases {
1175            let d = parse_ok(cs);
1176            let a = d
1177                .first_address()
1178                .unwrap_or_else(|| panic!("no address for {cs:?}"));
1179            assert_eq!(a.host.as_deref(), Some(*host), "host mismatch for {cs:?}");
1180            assert_eq!(a.port, *port, "port mismatch for {cs:?}");
1181            assert_eq!(a.protocol, *protocol, "protocol mismatch for {cs:?}");
1182            assert_eq!(
1183                d.first_description().connect_data.service_name.as_deref(),
1184                *service,
1185                "service mismatch for {cs:?}"
1186            );
1187        }
1188    }
1189
1190    /// Each row: (connect_string, expected substring in the diagnostic).
1191    #[test]
1192    fn corpus_malformed_inputs() {
1193        let cases: &[(&str, &str)] = &[
1194            // unbalanced / structural
1195            (
1196                "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1)",
1197                "offset",
1198            ),
1199            ("(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp", "offset"),
1200            // missing addresses (reference DPY-2049)
1201            (
1202                "(DESRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))",
1203                "no addresses are defined",
1204            ),
1205            // invalid protocol (reference DPY-4021)
1206            ("badproto://h/s", "invalid protocol"),
1207            (
1208                "(DESCRIPTION=(ADDRESS=(PROTOCOL=ipc)(KEY=k))(CONNECT_DATA=(SERVICE_NAME=s)))",
1209                "invalid protocol",
1210            ),
1211            // invalid server type (reference DPY-4028)
1212            (
1213                "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVER=BOGUS)(SERVICE_NAME=s)))",
1214                "invalid server_type",
1215            ),
1216            // non-numeric RETRY_COUNT (reference DPY-4018)
1217            (
1218                "(DESCRIPTION=(RETRY_COUNT=wrong)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))",
1219                "not a non-negative integer",
1220            ),
1221            // simple value for a container keyword (reference DPY-4017)
1222            ("(address=5)", "container"),
1223            // mixed complex/simple data (reference DPY-4017)
1224            (
1225                "(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVER=DEDICATED) SERVICE_NAME=s))",
1226                "offset",
1227            ),
1228            // empty
1229            ("", "must not be empty"),
1230        ];
1231        for (cs, needle) in cases {
1232            let err = parse(cs)
1233                .err()
1234                .unwrap_or_else(|| panic!("expected error for {cs:?}"));
1235            let msg = format!("{err}");
1236            assert!(
1237                msg.contains(needle),
1238                "diagnostic for {cs:?} = {msg:?} should contain {needle:?}"
1239            );
1240        }
1241    }
1242
1243    #[test]
1244    fn tns_alias_returns_none() {
1245        // A bare alphanumeric name is neither a descriptor nor an EZConnect
1246        // string; it must resolve via tnsnames.ora (parse returns None).
1247        assert!(parse("my_tns_alias")
1248            .expect("alias is not an error")
1249            .is_none());
1250    }
1251
1252    #[test]
1253    fn sdu_is_clamped() {
1254        // reference: SDU sanitised into 512..=2097152
1255        let d = parse_ok("(DESCRIPTION=(SDU=1)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))");
1256        assert_eq!(d.first_description().sdu, 512);
1257        let d = parse_ok("(DESCRIPTION=(SDU=99999999)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))");
1258        assert_eq!(d.first_description().sdu, 2_097_152);
1259    }
1260
1261    #[test]
1262    fn duration_units_parse() {
1263        // reference test_4511
1264        let base = "(DESCRIPTION=(TRANSPORT_CONNECT_TIMEOUT=UNIT)(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)))";
1265        let cases = [
1266            ("500 ms", 0.5_f64),
1267            ("15 SEC", 15.0),
1268            ("5 min", 300.0),
1269            ("34", 34.0),
1270        ];
1271        for (unit, expected) in cases {
1272            let d = parse_ok(&base.replace("UNIT", unit));
1273            assert!(
1274                (d.first_description().tcp_connect_timeout - expected).abs() < 1e-9,
1275                "duration {unit:?} -> {}",
1276                d.first_description().tcp_connect_timeout
1277            );
1278        }
1279    }
1280
1281    #[test]
1282    fn passthrough_extras_preserved_in_connect_data() {
1283        // reference test_4579 — unknown CONNECT_DATA keys are passed through.
1284        let d = parse_ok(
1285            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcp)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s)(COLOCATION_TAG=Tag1)))",
1286        );
1287        let extra = &d.first_description().connect_data.extra;
1288        assert!(extra
1289            .iter()
1290            .any(|(k, v)| k == "COLOCATION_TAG" && v == "Tag1"));
1291    }
1292
1293    #[test]
1294    fn wallet_and_cert_dn_in_security() {
1295        // reference test_4515
1296        let d = parse_ok(
1297            "(DESCRIPTION=(ADDRESS=(PROTOCOL=tcps)(HOST=h)(PORT=1))(CONNECT_DATA=(SERVICE_NAME=s))\
1298             (SECURITY=(SSL_SERVER_CERT_DN=\"CN=unknown\")(SSL_SERVER_DN_MATCH=Off)(MY_WALLET_DIRECTORY=\"/tmp/w\")))",
1299        );
1300        let sec = &d.first_description().security;
1301        assert_eq!(sec.ssl_server_cert_dn.as_deref(), Some("CN=unknown"));
1302        assert_eq!(sec.wallet_location.as_deref(), Some("/tmp/w"));
1303        assert!(!sec.ssl_server_dn_match);
1304    }
1305}