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