Skip to main content

geode_client/
dsn.rs

1//! DSN (Data Source Name) parsing for Geode connections.
2//!
3//! See geode/docs/DSN.md for the full specification.
4//!
5//! Supports the following DSN formats:
6//! - `quic://host:port?options` - QUIC transport (recommended)
7//! - `grpc://host:port?options` - gRPC transport
8//! - `grpcs://host:port?options` - gRPC transport with TLS forced on
9//! - `host:port?options` - Defaults to QUIC
10//!
11//! # Examples
12//!
13//! ```
14//! use geode_client::dsn::{Dsn, Transport};
15//!
16//! // QUIC transport (explicit)
17//! let dsn = Dsn::parse("quic://localhost:3141").unwrap();
18//! assert_eq!(dsn.transport(), Transport::Quic);
19//!
20//! // gRPC transport
21//! let dsn = Dsn::parse("grpc://localhost:50051?tls=0").unwrap();
22//! assert_eq!(dsn.transport(), Transport::Grpc);
23//! assert!(!dsn.tls_enabled());
24//!
25//! // IPv6 support
26//! let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
27//! assert_eq!(dsn.host(), "::1");
28//! ```
29
30use crate::error::{Error, Result};
31use std::collections::HashMap;
32
33/// Default port for Geode connections
34pub const DEFAULT_PORT: u16 = 3141;
35
36/// Transport protocol to use for the connection.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum Transport {
39    /// QUIC transport with protobuf wire protocol
40    Quic,
41    /// gRPC transport using protobuf service definitions
42    Grpc,
43}
44
45impl std::fmt::Display for Transport {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Transport::Quic => write!(f, "quic"),
49            Transport::Grpc => write!(f, "grpc"),
50        }
51    }
52}
53
54/// Parsed DSN (Data Source Name) for Geode connections.
55///
56/// Contains all connection parameters extracted from a DSN string.
57#[derive(Clone, PartialEq)]
58pub struct Dsn {
59    transport: Transport,
60    host: String,
61    port: u16,
62    username: Option<String>,
63    password: Option<String>,
64    tls_enabled: bool,
65    skip_verify: bool,
66    page_size: usize,
67    client_name: String,
68    client_version: String,
69    conformance: String,
70    ca_cert: Option<String>,
71    client_cert: Option<String>,
72    client_key: Option<String>,
73    server_name: Option<String>,
74    connect_timeout_secs: Option<u64>,
75    graph: Option<String>,
76    /// Tenant ID for multi-tenant isolation (sent in HELLO when set)
77    tenant: Option<String>,
78    /// FLE role for field-level access control (sent in HELLO when set)
79    role: Option<String>,
80    options: HashMap<String, String>,
81}
82
83impl Default for Dsn {
84    fn default() -> Self {
85        Self {
86            transport: Transport::Quic,
87            host: "localhost".to_string(),
88            port: DEFAULT_PORT,
89            username: None,
90            password: None,
91            tls_enabled: true,
92            skip_verify: false,
93            page_size: 1000,
94            client_name: "geode-rust".to_string(),
95            client_version: env!("CARGO_PKG_VERSION").to_string(),
96            conformance: "min".to_string(),
97            ca_cert: None,
98            client_cert: None,
99            client_key: None,
100            server_name: None,
101            connect_timeout_secs: None,
102            graph: None,
103            tenant: None,
104            role: None,
105            options: HashMap::new(),
106        }
107    }
108}
109
110impl Dsn {
111    /// Parse a DSN string into a Dsn struct.
112    ///
113    /// # Supported formats
114    ///
115    /// - `quic://host:port?options` - QUIC transport (recommended)
116    /// - `grpc://host:port?options` - gRPC transport
117    /// - `grpcs://host:port?options` - gRPC transport with TLS forced on
118    /// - `host:port?options` - Defaults to QUIC
119    ///
120    /// # Supported options (query parameters)
121    ///
122    /// - `tls` - Enable/disable TLS (0/1/true/false, default: true)
123    /// - `insecure`, `skip_verify`, `insecure_skip_verify` - Skip TLS verification
124    /// - `page_size` - Results page size (default: 1000)
125    /// - `client_name` or `hello_name` - Client name
126    /// - `client_version` or `hello_ver` - Client version
127    /// - `conformance` - GQL conformance level
128    /// - `username` or `user` - Authentication username
129    /// - `password` or `pass` - Authentication password
130    /// - `ca` or `ca_cert` - Path to CA certificate
131    /// - `cert` or `client_cert` - Path to client certificate (mTLS)
132    /// - `key` or `client_key` - Path to client key (mTLS)
133    /// - `server_name` - SNI server name
134    /// - `connect_timeout` or `timeout` - Connection timeout in seconds
135    ///
136    /// # Errors
137    ///
138    /// Returns `Error::InvalidDsn` if:
139    /// - DSN is empty
140    /// - Scheme is unsupported
141    /// - Host is missing
142    /// - Port is invalid
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use geode_client::dsn::Dsn;
148    ///
149    /// let dsn = Dsn::parse("quic://localhost:3141").unwrap();
150    /// let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
151    /// let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
152    /// ```
153    pub fn parse(dsn: &str) -> Result<Self> {
154        let dsn = dsn.trim();
155        if dsn.is_empty() {
156            return Err(Error::invalid_dsn("DSN cannot be empty"));
157        }
158
159        // Determine scheme and parse accordingly
160        if dsn.starts_with("quic://") {
161            Self::parse_url(dsn, Transport::Quic)
162        } else if let Some(rest) = dsn.strip_prefix("grpcs://") {
163            // grpcs:// is gRPC with TLS forced on
164            let rewritten = format!("grpc://{}", rest);
165            let mut result = Self::parse_url(&rewritten, Transport::Grpc)?;
166            result.tls_enabled = true;
167            Ok(result)
168        } else if dsn.starts_with("grpc://") {
169            Self::parse_url(dsn, Transport::Grpc)
170        } else if dsn.contains("://") {
171            // Unsupported scheme - reject it
172            let scheme = dsn.split("://").next().unwrap_or("");
173            Err(Error::invalid_dsn(format!(
174                "Unsupported scheme '{}'. Supported schemes: quic://, grpc://, grpcs://",
175                scheme
176            )))
177        } else {
178            // Scheme-less format: host:port?options (defaults to QUIC)
179            Self::parse_legacy(dsn)
180        }
181    }
182
183    /// Parse URL-format DSN (quic://, grpc://)
184    fn parse_url(dsn: &str, transport: Transport) -> Result<Self> {
185        // Use url crate for proper parsing
186        let url = url::Url::parse(dsn)
187            .map_err(|e| Error::invalid_dsn(format!("Invalid URL format: {}", e)))?;
188
189        let host_raw = url
190            .host_str()
191            .ok_or_else(|| Error::invalid_dsn("Host is required"))?;
192
193        // Strip brackets from IPv6 addresses (url crate returns "[::1]" for IPv6)
194        let host = if host_raw.starts_with('[') && host_raw.ends_with(']') {
195            host_raw[1..host_raw.len() - 1].to_string()
196        } else {
197            host_raw.to_string()
198        };
199
200        if host.is_empty() {
201            return Err(Error::invalid_dsn("Host is required"));
202        }
203
204        let port = url.port().unwrap_or(DEFAULT_PORT);
205
206        // Extract username/password with percent-decoding
207        let username = if !url.username().is_empty() {
208            Some(
209                urlencoding::decode(url.username())
210                    .map_err(|e| Error::invalid_dsn(format!("Invalid username encoding: {}", e)))?
211                    .into_owned(),
212            )
213        } else {
214            None
215        };
216
217        let password = url.password().map(|p| {
218            urlencoding::decode(p)
219                .map(|s| s.into_owned())
220                .unwrap_or_else(|_| p.to_string())
221        });
222
223        // Parse query parameters
224        let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
225
226        // Extract graph from URL path (e.g. quic://host:3141/mygraph)
227        let graph = if !url.path().is_empty() && url.path() != "/" {
228            Some(url.path().trim_start_matches('/').to_string())
229        } else {
230            None
231        };
232
233        let mut result = Self {
234            transport,
235            host,
236            port,
237            username,
238            password,
239            graph,
240            ..Default::default()
241        };
242
243        result.apply_params(&params)?;
244
245        Ok(result)
246    }
247
248    /// Parse legacy format DSN (host:port?options)
249    fn parse_legacy(dsn: &str) -> Result<Self> {
250        // Split off query string
251        let (host_port, query_str) = if let Some(idx) = dsn.find('?') {
252            (&dsn[..idx], Some(&dsn[idx + 1..]))
253        } else {
254            (dsn, None)
255        };
256
257        // Parse host:port, handling IPv6 addresses like [::1]:3141
258        let (host, port) = Self::parse_host_port(host_port)?;
259
260        if host.is_empty() {
261            return Err(Error::invalid_dsn("Host is required"));
262        }
263
264        let mut result = Self {
265            transport: Transport::Quic, // Default to QUIC for legacy format
266            host,
267            port,
268            ..Default::default()
269        };
270
271        // Parse query parameters
272        if let Some(qs) = query_str {
273            let params: HashMap<String, String> = qs
274                .split('&')
275                .filter_map(|pair| {
276                    let mut parts = pair.splitn(2, '=');
277                    let key = parts.next()?;
278                    let value = parts.next().unwrap_or("");
279                    // Percent-decode values
280                    let decoded_value = urlencoding::decode(value)
281                        .map(|s| s.into_owned())
282                        .unwrap_or_else(|_| value.to_string());
283                    Some((key.to_string(), decoded_value))
284                })
285                .collect();
286
287            result.apply_params(&params)?;
288        }
289
290        Ok(result)
291    }
292
293    /// Parse host:port string, handling IPv6 addresses
294    fn parse_host_port(s: &str) -> Result<(String, u16)> {
295        // Handle IPv6 addresses: [::1]:3141 or [2001:db8::1]:3141
296        if s.starts_with('[') {
297            // IPv6 format
298            if let Some(bracket_end) = s.find(']') {
299                let host = s[1..bracket_end].to_string();
300                let remainder = &s[bracket_end + 1..];
301                let port = if let Some(rest) = remainder.strip_prefix(':') {
302                    rest.parse::<u16>()
303                        .map_err(|_| Error::invalid_dsn(format!("Invalid port: {}", rest)))?
304                } else if remainder.is_empty() {
305                    DEFAULT_PORT
306                } else {
307                    return Err(Error::invalid_dsn("Invalid IPv6 address format"));
308                };
309                return Ok((host, port));
310            } else {
311                return Err(Error::invalid_dsn("Unclosed bracket in IPv6 address"));
312            }
313        }
314
315        // IPv4 or hostname: find the last colon for port
316        if let Some(idx) = s.rfind(':') {
317            let host = s[..idx].to_string();
318            let port_str = &s[idx + 1..];
319            let port = port_str
320                .parse::<u16>()
321                .map_err(|_| Error::invalid_dsn(format!("Invalid port: {}", port_str)))?;
322            Ok((host, port))
323        } else {
324            // No port specified
325            Ok((s.to_string(), DEFAULT_PORT))
326        }
327    }
328
329    /// Apply query parameters to the DSN
330    fn apply_params(&mut self, params: &HashMap<String, String>) -> Result<()> {
331        for (key, value) in params {
332            match key.as_str() {
333                "tls" => {
334                    self.tls_enabled = parse_bool(value).unwrap_or(true);
335                }
336                "insecure_tls_skip_verify"
337                | "insecure"
338                | "skip_verify"
339                | "insecure_skip_verify" => {
340                    self.skip_verify = parse_bool(value).unwrap_or(false);
341                }
342                "page_size" => {
343                    self.page_size = value
344                        .parse()
345                        .map_err(|_| Error::invalid_dsn(format!("Invalid page_size: {}", value)))?;
346                }
347                "client_name" | "hello_name" => {
348                    self.client_name = value.clone();
349                }
350                "client_version" | "hello_ver" => {
351                    self.client_version = value.clone();
352                }
353                "conformance" => {
354                    self.conformance = value.clone();
355                }
356                "username" | "user" => {
357                    self.username = Some(value.clone());
358                }
359                "password" | "pass" => {
360                    self.password = Some(value.clone());
361                }
362                "ca" | "ca_cert" => {
363                    self.ca_cert = Some(value.clone());
364                }
365                "cert" | "client_cert" => {
366                    self.client_cert = Some(value.clone());
367                }
368                "key" | "client_key" => {
369                    self.client_key = Some(value.clone());
370                }
371                "server_name" => {
372                    self.server_name = Some(value.clone());
373                }
374                "connect_timeout" | "timeout" => {
375                    self.connect_timeout_secs = value.parse().ok();
376                }
377                "graph" => {
378                    self.graph = Some(value.to_string());
379                }
380                "tenant" | "tenant_id" => {
381                    self.tenant = Some(value.to_string());
382                }
383                "role" => {
384                    self.role = Some(value.to_string());
385                }
386                _ => {
387                    // Store unknown parameters for forward compatibility
388                    self.options.insert(key.clone(), value.clone());
389                }
390            }
391        }
392        Ok(())
393    }
394
395    /// Get the transport protocol.
396    pub fn transport(&self) -> Transport {
397        self.transport
398    }
399
400    /// Get the host.
401    pub fn host(&self) -> &str {
402        &self.host
403    }
404
405    /// Get the port.
406    pub fn port(&self) -> u16 {
407        self.port
408    }
409
410    /// Get the username if specified.
411    pub fn username(&self) -> Option<&str> {
412        self.username.as_deref()
413    }
414
415    /// Get the password if specified.
416    pub fn password(&self) -> Option<&str> {
417        self.password.as_deref()
418    }
419
420    /// Check if TLS is enabled.
421    pub fn tls_enabled(&self) -> bool {
422        self.tls_enabled
423    }
424
425    /// Check if TLS verification should be skipped.
426    pub fn skip_verify(&self) -> bool {
427        self.skip_verify
428    }
429
430    /// Get the page size.
431    pub fn page_size(&self) -> usize {
432        self.page_size
433    }
434
435    /// Get the client name.
436    pub fn client_name(&self) -> &str {
437        &self.client_name
438    }
439
440    /// Get the client version.
441    pub fn client_version(&self) -> &str {
442        &self.client_version
443    }
444
445    /// Get the conformance level.
446    pub fn conformance(&self) -> &str {
447        &self.conformance
448    }
449
450    /// Get additional options.
451    pub fn options(&self) -> &HashMap<String, String> {
452        &self.options
453    }
454
455    /// Get the CA certificate path if specified.
456    pub fn ca_cert(&self) -> Option<&str> {
457        self.ca_cert.as_deref()
458    }
459
460    /// Get the client certificate path if specified.
461    pub fn client_cert(&self) -> Option<&str> {
462        self.client_cert.as_deref()
463    }
464
465    /// Get the client key path if specified.
466    pub fn client_key(&self) -> Option<&str> {
467        self.client_key.as_deref()
468    }
469
470    /// Get the SNI server name if specified.
471    pub fn server_name(&self) -> Option<&str> {
472        self.server_name.as_deref()
473    }
474
475    /// Get the connection timeout in seconds if specified.
476    pub fn connect_timeout_secs(&self) -> Option<u64> {
477        self.connect_timeout_secs
478    }
479
480    /// Get the graph name if specified.
481    pub fn graph(&self) -> Option<&str> {
482        self.graph.as_deref()
483    }
484
485    /// Get the tenant ID if specified (multi-tenant isolation).
486    pub fn tenant(&self) -> Option<&str> {
487        self.tenant.as_deref()
488    }
489
490    /// Get the FLE role if specified (field-level access control).
491    pub fn role(&self) -> Option<&str> {
492        self.role.as_deref()
493    }
494
495    /// Get the host:port address string.
496    pub fn address(&self) -> String {
497        if self.host.contains(':') {
498            // IPv6 address - wrap in brackets
499            format!("[{}]:{}", self.host, self.port)
500        } else {
501            format!("{}:{}", self.host, self.port)
502        }
503    }
504
505    /// Format this DSN back into a connection-string form.
506    ///
507    /// Mirrors the Go reference client's `Config.FormatDSN`: emits
508    /// `scheme://userinfo@host:port?onlyNonDefaultParams`. Only parameters
509    /// that differ from the defaults are emitted, query keys are sorted for
510    /// determinism, and userinfo/values are percent-encoded.
511    ///
512    /// Credentials follow Go's rules: a username with a password becomes
513    /// `user:pass@`, a username alone becomes `user@`, and a password
514    /// without a username falls back to a `pass=` query parameter (since
515    /// userinfo cannot carry a password-only credential).
516    ///
517    /// The result round-trips through [`Dsn::parse`].
518    ///
519    /// # Example
520    ///
521    /// ```
522    /// use geode_client::dsn::Dsn;
523    ///
524    /// let dsn = Dsn::parse("quic://localhost:3141?page_size=500").unwrap();
525    /// let s = dsn.to_dsn();
526    /// assert_eq!(Dsn::parse(&s).unwrap(), dsn);
527    /// ```
528    pub fn to_dsn(&self) -> String {
529        let defaults = Dsn::default();
530
531        // Collect non-default query parameters as (key, value) pairs.
532        let mut params: Vec<(String, String)> = Vec::new();
533
534        if self.page_size != defaults.page_size {
535            params.push(("page_size".to_string(), self.page_size.to_string()));
536        }
537        if self.client_name != defaults.client_name {
538            params.push(("hello_name".to_string(), self.client_name.clone()));
539        }
540        if self.client_version != defaults.client_version {
541            params.push(("hello_ver".to_string(), self.client_version.clone()));
542        }
543        if self.conformance != defaults.conformance {
544            params.push(("conformance".to_string(), self.conformance.clone()));
545        }
546        if let Some(ca) = &self.ca_cert {
547            params.push(("ca".to_string(), ca.clone()));
548        }
549        if let Some(cert) = &self.client_cert {
550            params.push(("cert".to_string(), cert.clone()));
551        }
552        if let Some(key) = &self.client_key {
553            params.push(("key".to_string(), key.clone()));
554        }
555        if let Some(sn) = &self.server_name {
556            params.push(("server_name".to_string(), sn.clone()));
557        }
558        if self.skip_verify {
559            params.push(("insecure_tls_skip_verify".to_string(), "true".to_string()));
560        }
561        if !self.tls_enabled {
562            params.push(("tls".to_string(), "0".to_string()));
563        }
564        if let Some(graph) = &self.graph {
565            params.push(("graph".to_string(), graph.clone()));
566        }
567        if let Some(tenant) = &self.tenant {
568            params.push(("tenant".to_string(), tenant.clone()));
569        }
570        if let Some(role) = &self.role {
571            params.push(("role".to_string(), role.clone()));
572        }
573        if let Some(timeout) = self.connect_timeout_secs {
574            params.push(("connect_timeout".to_string(), timeout.to_string()));
575        }
576        // Preserve forward-compatible unknown options.
577        for (key, value) in &self.options {
578            params.push((key.clone(), value.clone()));
579        }
580
581        // Build userinfo, falling back to a pass= query param when only a
582        // password is present (userinfo requires a username).
583        let userinfo = match (&self.username, &self.password) {
584            (Some(user), Some(pass)) => format!(
585                "{}:{}@",
586                urlencoding::encode(user),
587                urlencoding::encode(pass)
588            ),
589            (Some(user), None) => format!("{}@", urlencoding::encode(user)),
590            (None, Some(pass)) => {
591                params.push(("pass".to_string(), pass.clone()));
592                String::new()
593            }
594            (None, None) => String::new(),
595        };
596
597        // Sort query keys for deterministic output (matches Go's url.Values).
598        params.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
599
600        let mut dsn = format!("{}://{}{}", self.transport, userinfo, self.address());
601
602        if !params.is_empty() {
603            let query: Vec<String> = params
604                .iter()
605                .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
606                .collect();
607            dsn.push('?');
608            dsn.push_str(&query.join("&"));
609        }
610
611        dsn
612    }
613}
614
615impl std::fmt::Display for Dsn {
616    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
617        f.write_str(&self.to_dsn())
618    }
619}
620
621impl std::fmt::Debug for Dsn {
622    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
623        f.debug_struct("Dsn")
624            .field("transport", &self.transport)
625            .field("host", &self.host)
626            .field("port", &self.port)
627            .field("username", &self.username)
628            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
629            .field("tls_enabled", &self.tls_enabled)
630            .field("skip_verify", &self.skip_verify)
631            .field("page_size", &self.page_size)
632            .field("client_name", &self.client_name)
633            .field("client_version", &self.client_version)
634            .field("conformance", &self.conformance)
635            .field("ca_cert", &self.ca_cert)
636            .field("client_cert", &self.client_cert)
637            .field("client_key", &self.client_key)
638            .field("server_name", &self.server_name)
639            .field("connect_timeout_secs", &self.connect_timeout_secs)
640            .field("graph", &self.graph)
641            .field("tenant", &self.tenant)
642            .field("role", &self.role)
643            .field("options", &self.options)
644            .finish()
645    }
646}
647
648/// Parse a boolean value from a string
649fn parse_bool(s: &str) -> Option<bool> {
650    match s.to_lowercase().as_str() {
651        "true" | "1" | "yes" | "on" => Some(true),
652        "false" | "0" | "no" | "off" => Some(false),
653        _ => None,
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    // ==========================================================================
662    // QUIC DSN Parsing Tests
663    // ==========================================================================
664
665    #[test]
666    fn test_dsn_parse_quic_basic() {
667        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
668        assert_eq!(dsn.transport(), Transport::Quic);
669        assert_eq!(dsn.host(), "localhost");
670        assert_eq!(dsn.port(), 3141);
671        assert!(dsn.tls_enabled());
672        assert!(!dsn.skip_verify());
673    }
674
675    #[test]
676    fn test_dsn_parse_quic_with_ip() {
677        let dsn = Dsn::parse("quic://127.0.0.1:3141").unwrap();
678        assert_eq!(dsn.transport(), Transport::Quic);
679        assert_eq!(dsn.host(), "127.0.0.1");
680        assert_eq!(dsn.port(), 3141);
681    }
682
683    #[test]
684    fn test_dsn_parse_quic_default_port() {
685        let dsn = Dsn::parse("quic://localhost").unwrap();
686        assert_eq!(dsn.port(), DEFAULT_PORT);
687    }
688
689    #[test]
690    fn test_dsn_parse_quic_with_options() {
691        let dsn = Dsn::parse("quic://localhost:3141?page_size=500&insecure=true").unwrap();
692        assert_eq!(dsn.page_size(), 500);
693        assert!(dsn.skip_verify());
694    }
695
696    // ==========================================================================
697    // gRPC DSN Parsing Tests
698    // ==========================================================================
699
700    #[test]
701    fn test_dsn_parse_grpc_basic() {
702        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
703        assert_eq!(dsn.transport(), Transport::Grpc);
704        assert_eq!(dsn.host(), "localhost");
705        assert_eq!(dsn.port(), 50051);
706    }
707
708    #[test]
709    fn test_dsn_parse_grpc_with_tls_disabled() {
710        let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
711        assert_eq!(dsn.transport(), Transport::Grpc);
712        assert_eq!(dsn.host(), "127.0.0.1");
713        assert_eq!(dsn.port(), 50051);
714        assert!(!dsn.tls_enabled());
715    }
716
717    #[test]
718    fn test_dsn_parse_grpc_with_tls_false() {
719        let dsn = Dsn::parse("grpc://localhost:50051?tls=false").unwrap();
720        assert!(!dsn.tls_enabled());
721    }
722
723    #[test]
724    fn test_dsn_parse_grpcs_scheme() {
725        let dsn = Dsn::parse("grpcs://localhost:50051").unwrap();
726        assert_eq!(dsn.transport(), Transport::Grpc);
727        assert!(dsn.tls_enabled());
728        assert_eq!(dsn.host(), "localhost");
729        assert_eq!(dsn.port(), 50051);
730    }
731
732    #[test]
733    fn test_dsn_parse_grpcs_tls_forced() {
734        // Even if tls=0 is passed, grpcs:// forces TLS on
735        let dsn = Dsn::parse("grpcs://localhost:50051?tls=0").unwrap();
736        assert_eq!(dsn.transport(), Transport::Grpc);
737        assert!(dsn.tls_enabled());
738    }
739
740    #[test]
741    fn test_dsn_parse_unsupported_schemes() {
742        // grcp:// is no longer supported
743        let err = Dsn::parse("grcp://localhost:50051").unwrap_err();
744        assert!(err.to_string().contains("Unsupported scheme"));
745
746        // geode:// is no longer supported
747        let err = Dsn::parse("geode://localhost:3141").unwrap_err();
748        assert!(err.to_string().contains("Unsupported scheme"));
749    }
750
751    // ==========================================================================
752    // IPv6 DSN Parsing Tests
753    // ==========================================================================
754
755    #[test]
756    fn test_dsn_parse_ipv6_grpc() {
757        let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
758        assert_eq!(dsn.transport(), Transport::Grpc);
759        assert_eq!(dsn.host(), "::1");
760        assert_eq!(dsn.port(), 50051);
761    }
762
763    #[test]
764    fn test_dsn_parse_ipv6_quic() {
765        let dsn = Dsn::parse("quic://[::1]:3141").unwrap();
766        assert_eq!(dsn.transport(), Transport::Quic);
767        assert_eq!(dsn.host(), "::1");
768        assert_eq!(dsn.port(), 3141);
769    }
770
771    #[test]
772    fn test_dsn_parse_ipv6_full_address() {
773        let dsn = Dsn::parse("grpc://[2001:db8::1]:50051").unwrap();
774        assert_eq!(dsn.host(), "2001:db8::1");
775        assert_eq!(dsn.port(), 50051);
776    }
777
778    #[test]
779    fn test_dsn_parse_ipv6_default_port() {
780        let dsn = Dsn::parse("quic://[::1]").unwrap();
781        assert_eq!(dsn.host(), "::1");
782        assert_eq!(dsn.port(), DEFAULT_PORT);
783    }
784
785    #[test]
786    fn test_dsn_address_ipv6() {
787        let dsn = Dsn::parse("grpc://[::1]:50051").unwrap();
788        assert_eq!(dsn.address(), "[::1]:50051");
789    }
790
791    // ==========================================================================
792    // Legacy DSN Format Tests
793    // ==========================================================================
794
795    #[test]
796    fn test_dsn_parse_legacy_host_port() {
797        let dsn = Dsn::parse("localhost:3141").unwrap();
798        assert_eq!(dsn.transport(), Transport::Quic); // Default to QUIC
799        assert_eq!(dsn.host(), "localhost");
800        assert_eq!(dsn.port(), 3141);
801    }
802
803    #[test]
804    fn test_dsn_parse_legacy_with_options() {
805        let dsn = Dsn::parse("localhost:3141?insecure=true&page_size=500").unwrap();
806        assert_eq!(dsn.transport(), Transport::Quic);
807        assert!(dsn.skip_verify());
808        assert_eq!(dsn.page_size(), 500);
809    }
810
811    #[test]
812    fn test_dsn_parse_legacy_ipv6() {
813        let dsn = Dsn::parse("[::1]:3141").unwrap();
814        assert_eq!(dsn.host(), "::1");
815        assert_eq!(dsn.port(), 3141);
816    }
817
818    // ==========================================================================
819    // Authentication Tests
820    // ==========================================================================
821
822    #[test]
823    fn test_dsn_parse_with_auth() {
824        let dsn = Dsn::parse("quic://admin:secret@localhost:3141").unwrap();
825        assert_eq!(dsn.username(), Some("admin"));
826        assert_eq!(dsn.password(), Some("secret"));
827    }
828
829    #[test]
830    fn test_dsn_parse_auth_via_query_params() {
831        let dsn = Dsn::parse("grpc://localhost:50051?username=admin&password=secret").unwrap();
832        assert_eq!(dsn.username(), Some("admin"));
833        assert_eq!(dsn.password(), Some("secret"));
834    }
835
836    #[test]
837    fn test_dsn_parse_auth_percent_encoded() {
838        let dsn = Dsn::parse("quic://user%40domain:p%40ss%3Dword@localhost:3141").unwrap();
839        assert_eq!(dsn.username(), Some("user@domain"));
840        assert_eq!(dsn.password(), Some("p@ss=word"));
841    }
842
843    // ==========================================================================
844    // Error Cases Tests
845    // ==========================================================================
846
847    #[test]
848    fn test_dsn_parse_empty() {
849        let err = Dsn::parse("").unwrap_err();
850        assert!(err.to_string().contains("empty"));
851    }
852
853    #[test]
854    fn test_dsn_parse_unsupported_scheme() {
855        let err = Dsn::parse("http://localhost:3141").unwrap_err();
856        assert!(err.to_string().contains("Unsupported scheme"));
857        assert!(err.to_string().contains("http"));
858    }
859
860    #[test]
861    fn test_dsn_parse_invalid_port() {
862        let err = Dsn::parse("quic://localhost:invalid").unwrap_err();
863        assert!(err.to_string().contains("port") || err.to_string().contains("Invalid"));
864    }
865
866    #[test]
867    fn test_dsn_parse_port_too_large() {
868        let err = Dsn::parse("quic://localhost:99999").unwrap_err();
869        assert!(err.to_string().contains("port") || err.to_string().contains("Invalid"));
870    }
871
872    #[test]
873    fn test_dsn_parse_missing_host() {
874        // URL crate handles this differently - empty host is valid for some schemes
875        // But we should reject it
876        let result = Dsn::parse("quic://:3141");
877        // This might parse as empty host or fail depending on URL parser
878        if let Ok(dsn) = result {
879            assert!(dsn.host().is_empty() || dsn.host() == "");
880        }
881    }
882
883    // ==========================================================================
884    // Query Parameter Tests
885    // ==========================================================================
886
887    #[test]
888    fn test_dsn_parse_all_options() {
889        let dsn = Dsn::parse(
890            "grpc://localhost:50051?tls=1&insecure=false&page_size=2000&client_name=test-app&conformance=full"
891        ).unwrap();
892
893        assert!(dsn.tls_enabled());
894        assert!(!dsn.skip_verify());
895        assert_eq!(dsn.page_size(), 2000);
896        assert_eq!(dsn.client_name(), "test-app");
897        assert_eq!(dsn.conformance(), "full");
898    }
899
900    #[test]
901    fn test_dsn_parse_unknown_options_preserved() {
902        let dsn = Dsn::parse("quic://localhost:3141?custom_option=value").unwrap();
903        assert_eq!(
904            dsn.options().get("custom_option"),
905            Some(&"value".to_string())
906        );
907    }
908
909    #[test]
910    fn test_dsn_parse_option_aliases() {
911        // Test username/user alias
912        let dsn1 = Dsn::parse("grpc://localhost:50051?user=admin").unwrap();
913        let dsn2 = Dsn::parse("grpc://localhost:50051?username=admin").unwrap();
914        assert_eq!(dsn1.username(), dsn2.username());
915
916        // Test password/pass alias
917        let dsn3 = Dsn::parse("grpc://localhost:50051?pass=secret").unwrap();
918        let dsn4 = Dsn::parse("grpc://localhost:50051?password=secret").unwrap();
919        assert_eq!(dsn3.password(), dsn4.password());
920
921        // Test skip_verify/insecure alias
922        let dsn5 = Dsn::parse("grpc://localhost:50051?skip_verify=true").unwrap();
923        let dsn6 = Dsn::parse("grpc://localhost:50051?insecure=true").unwrap();
924        assert_eq!(dsn5.skip_verify(), dsn6.skip_verify());
925    }
926
927    #[test]
928    fn test_dsn_parse_percent_encoded_values() {
929        let dsn = Dsn::parse("quic://localhost:3141?client_name=My%20App").unwrap();
930        assert_eq!(dsn.client_name(), "My App");
931    }
932
933    #[test]
934    fn test_dsn_parse_mtls_options() {
935        let dsn = Dsn::parse(
936            "grpc://localhost:50051?ca=/path/ca.crt&cert=/path/client.crt&key=/path/client.key",
937        )
938        .unwrap();
939        assert_eq!(dsn.ca_cert(), Some("/path/ca.crt"));
940        assert_eq!(dsn.client_cert(), Some("/path/client.crt"));
941        assert_eq!(dsn.client_key(), Some("/path/client.key"));
942    }
943
944    #[test]
945    fn test_dsn_parse_mtls_options_alt_names() {
946        let dsn = Dsn::parse("grpc://localhost:50051?ca_cert=/path/ca.crt&client_cert=/path/client.crt&client_key=/path/client.key").unwrap();
947        assert_eq!(dsn.ca_cert(), Some("/path/ca.crt"));
948        assert_eq!(dsn.client_cert(), Some("/path/client.crt"));
949        assert_eq!(dsn.client_key(), Some("/path/client.key"));
950    }
951
952    #[test]
953    fn test_dsn_parse_connect_timeout() {
954        let dsn = Dsn::parse("quic://localhost:3141?connect_timeout=60").unwrap();
955        assert_eq!(dsn.connect_timeout_secs(), Some(60));
956    }
957
958    #[test]
959    fn test_dsn_parse_server_name() {
960        let dsn = Dsn::parse("quic://localhost:3141?server_name=geode.example.com").unwrap();
961        assert_eq!(dsn.server_name(), Some("geode.example.com"));
962    }
963
964    // ==========================================================================
965    // insecure_tls_skip_verify Parameter Tests (Unified DSN)
966    // ==========================================================================
967
968    #[test]
969    fn test_dsn_parse_insecure_tls_skip_verify_primary() {
970        // Primary parameter name
971        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=true").unwrap();
972        assert!(dsn.skip_verify());
973
974        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=1").unwrap();
975        assert!(dsn.skip_verify());
976
977        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=yes").unwrap();
978        assert!(dsn.skip_verify());
979
980        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=on").unwrap();
981        assert!(dsn.skip_verify());
982
983        let dsn = Dsn::parse("quic://localhost:3141?insecure_tls_skip_verify=false").unwrap();
984        assert!(!dsn.skip_verify());
985    }
986
987    #[test]
988    fn test_dsn_parse_insecure_alias() {
989        // Alias: insecure
990        let dsn = Dsn::parse("quic://localhost:3141?insecure=true").unwrap();
991        assert!(dsn.skip_verify());
992
993        let dsn = Dsn::parse("grpc://localhost:50051?insecure=1").unwrap();
994        assert!(dsn.skip_verify());
995
996        let dsn = Dsn::parse("localhost:3141?insecure=yes").unwrap();
997        assert!(dsn.skip_verify());
998    }
999
1000    #[test]
1001    fn test_dsn_parse_skip_verify_alias() {
1002        // Alias: skip_verify
1003        let dsn = Dsn::parse("quic://localhost:3141?skip_verify=true").unwrap();
1004        assert!(dsn.skip_verify());
1005
1006        let dsn = Dsn::parse("grpc://localhost:50051?skip_verify=1").unwrap();
1007        assert!(dsn.skip_verify());
1008
1009        let dsn = Dsn::parse("quic://localhost:3141?skip_verify=on").unwrap();
1010        assert!(dsn.skip_verify());
1011    }
1012
1013    #[test]
1014    fn test_dsn_parse_insecure_skip_verify_alias() {
1015        // Alias: insecure_skip_verify
1016        let dsn = Dsn::parse("quic://localhost:3141?insecure_skip_verify=true").unwrap();
1017        assert!(dsn.skip_verify());
1018
1019        let dsn = Dsn::parse("grpc://localhost:50051?insecure_skip_verify=1").unwrap();
1020        assert!(dsn.skip_verify());
1021
1022        let dsn = Dsn::parse("localhost:3141?insecure_skip_verify=on").unwrap();
1023        assert!(dsn.skip_verify());
1024
1025        let dsn = Dsn::parse("quic://localhost:3141?insecure_skip_verify=false").unwrap();
1026        assert!(!dsn.skip_verify());
1027    }
1028
1029    #[test]
1030    fn test_dsn_skip_verify_default_false() {
1031        // Default should be false
1032        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
1033        assert!(!dsn.skip_verify());
1034
1035        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
1036        assert!(!dsn.skip_verify());
1037
1038        let dsn = Dsn::parse("localhost:3141").unwrap();
1039        assert!(!dsn.skip_verify());
1040    }
1041
1042    // ==========================================================================
1043    // Transport Display Tests
1044    // ==========================================================================
1045
1046    #[test]
1047    fn test_transport_display() {
1048        assert_eq!(Transport::Quic.to_string(), "quic");
1049        assert_eq!(Transport::Grpc.to_string(), "grpc");
1050    }
1051
1052    // ==========================================================================
1053    // Address Formatting Tests
1054    // ==========================================================================
1055
1056    #[test]
1057    fn test_dsn_address_ipv4() {
1058        let dsn = Dsn::parse("quic://192.168.1.1:3141").unwrap();
1059        assert_eq!(dsn.address(), "192.168.1.1:3141");
1060    }
1061
1062    #[test]
1063    fn test_dsn_address_hostname() {
1064        let dsn = Dsn::parse("grpc://geode.example.com:50051").unwrap();
1065        assert_eq!(dsn.address(), "geode.example.com:50051");
1066    }
1067
1068    // ==========================================================================
1069    // Acceptance Tests (from task requirements)
1070    // ==========================================================================
1071
1072    #[test]
1073    fn test_acceptance_quic_localhost() {
1074        // DSN parse: quic://localhost:1234 ⇒ transport=QUIC, host=localhost, port=1234
1075        let dsn = Dsn::parse("quic://localhost:1234").unwrap();
1076        assert_eq!(dsn.transport(), Transport::Quic);
1077        assert_eq!(dsn.host(), "localhost");
1078        assert_eq!(dsn.port(), 1234);
1079    }
1080
1081    #[test]
1082    fn test_acceptance_grpc_with_tls_disabled() {
1083        // DSN parse: grpc://127.0.0.1:50051?tls=0 ⇒ transport=GRPC, host=127.0.0.1, port=50051, tls disabled
1084        let dsn = Dsn::parse("grpc://127.0.0.1:50051?tls=0").unwrap();
1085        assert_eq!(dsn.transport(), Transport::Grpc);
1086        assert_eq!(dsn.host(), "127.0.0.1");
1087        assert_eq!(dsn.port(), 50051);
1088        assert!(!dsn.tls_enabled());
1089    }
1090
1091    #[test]
1092    fn test_acceptance_invalid_scheme() {
1093        // DSN parse invalid: http://localhost:1 ⇒ deterministic "unsupported scheme" error
1094        let err = Dsn::parse("http://localhost:1").unwrap_err();
1095        let err_str = err.to_string();
1096        assert!(
1097            err_str.contains("Unsupported scheme") || err_str.contains("unsupported"),
1098            "Expected 'unsupported scheme' error, got: {}",
1099            err_str
1100        );
1101    }
1102
1103    // ==========================================================================
1104    // Security Tests
1105    // ==========================================================================
1106
1107    #[test]
1108    fn test_dsn_debug_redacts_password() {
1109        let dsn = Dsn::parse("quic://user:secret@localhost:3141").unwrap();
1110        let debug_str = format!("{:?}", dsn);
1111        assert!(
1112            !debug_str.contains("secret"),
1113            "Debug output must not contain password"
1114        );
1115        assert!(
1116            debug_str.contains("REDACTED"),
1117            "Debug output must show REDACTED"
1118        );
1119    }
1120
1121    // ==========================================================================
1122    // DSN Round-trip (to_dsn) Tests
1123    // ==========================================================================
1124
1125    fn assert_roundtrip(dsn_str: &str) {
1126        let parsed = Dsn::parse(dsn_str).unwrap();
1127        let formatted = parsed.to_dsn();
1128        let reparsed = Dsn::parse(&formatted).unwrap_or_else(|e| {
1129            panic!(
1130                "reparse of {:?} (from {:?}) failed: {}",
1131                formatted, dsn_str, e
1132            )
1133        });
1134        assert_eq!(
1135            parsed, reparsed,
1136            "round-trip mismatch: {:?} -> {:?}",
1137            dsn_str, formatted
1138        );
1139    }
1140
1141    #[test]
1142    fn test_to_dsn_roundtrip_quic_basic() {
1143        assert_roundtrip("quic://localhost:3141");
1144    }
1145
1146    #[test]
1147    fn test_to_dsn_roundtrip_grpc_tls_disabled() {
1148        assert_roundtrip("grpc://127.0.0.1:50051?tls=0");
1149    }
1150
1151    #[test]
1152    fn test_to_dsn_roundtrip_page_size() {
1153        assert_roundtrip("quic://localhost:3141?page_size=2500");
1154    }
1155
1156    #[test]
1157    fn test_to_dsn_roundtrip_graph() {
1158        assert_roundtrip("quic://localhost:3141?graph=mygraph");
1159    }
1160
1161    // GAP-0841: tenant + role flow through the DSN so multi-tenant + FLE work.
1162    #[test]
1163    fn test_dsn_parses_tenant_and_role_gap0841() {
1164        let dsn = Dsn::parse("quic://localhost:3141?tenant=acme-corp&role=analyst").unwrap();
1165        assert_eq!(dsn.tenant(), Some("acme-corp"));
1166        assert_eq!(dsn.role(), Some("analyst"));
1167    }
1168
1169    #[test]
1170    fn test_dsn_tenant_id_alias_gap0841() {
1171        // `tenant_id` is accepted as an alias for `tenant`.
1172        let dsn = Dsn::parse("quic://localhost:3141?tenant_id=acme-corp").unwrap();
1173        assert_eq!(dsn.tenant(), Some("acme-corp"));
1174    }
1175
1176    #[test]
1177    fn test_to_dsn_roundtrip_tenant_and_role_gap0841() {
1178        assert_roundtrip("quic://localhost:3141?role=analyst&tenant=acme-corp");
1179    }
1180
1181    #[test]
1182    fn test_to_dsn_roundtrip_userpass() {
1183        assert_roundtrip("quic://admin:secret@localhost:3141");
1184    }
1185
1186    #[test]
1187    fn test_to_dsn_roundtrip_user_only() {
1188        assert_roundtrip("quic://admin@localhost:3141");
1189    }
1190
1191    #[test]
1192    fn test_to_dsn_roundtrip_password_only() {
1193        // password-only falls back to ?pass= (matches Go fix 6aee266)
1194        let parsed = Dsn::parse("quic://localhost:3141?pass=secret").unwrap();
1195        let formatted = parsed.to_dsn();
1196        assert!(
1197            formatted.contains("pass=secret"),
1198            "password-only DSN should emit pass= query param, got: {}",
1199            formatted
1200        );
1201        let reparsed = Dsn::parse(&formatted).unwrap();
1202        assert_eq!(parsed, reparsed);
1203        assert_eq!(reparsed.password(), Some("secret"));
1204        assert_eq!(reparsed.username(), None);
1205    }
1206
1207    #[test]
1208    fn test_to_dsn_roundtrip_ipv6() {
1209        assert_roundtrip("quic://[::1]:3141");
1210        assert_roundtrip("grpc://[2001:db8::1]:50051?tls=0");
1211    }
1212
1213    #[test]
1214    fn test_to_dsn_roundtrip_skip_verify() {
1215        assert_roundtrip("quic://localhost:3141?insecure_tls_skip_verify=true");
1216    }
1217
1218    #[test]
1219    fn test_to_dsn_roundtrip_full() {
1220        assert_roundtrip(
1221            "grpc://admin:p%40ss@host.example.com:50051?tls=0&page_size=500&conformance=full&graph=g1&connect_timeout=60",
1222        );
1223    }
1224
1225    #[test]
1226    fn test_to_dsn_scheme_present() {
1227        let dsn = Dsn::parse("grpc://localhost:50051").unwrap();
1228        assert!(dsn.to_dsn().starts_with("grpc://"));
1229        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
1230        assert!(dsn.to_dsn().starts_with("quic://"));
1231    }
1232
1233    #[test]
1234    fn test_dsn_debug_no_password_shows_none() {
1235        let dsn = Dsn::parse("quic://localhost:3141").unwrap();
1236        let debug_str = format!("{:?}", dsn);
1237        assert!(
1238            debug_str.contains("password: None"),
1239            "Debug output should show None when no password is set"
1240        );
1241    }
1242}