1use crate::error::{Error, Result};
31use std::collections::HashMap;
32
33pub const DEFAULT_PORT: u16 = 3141;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum Transport {
39 Quic,
41 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#[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: Option<String>,
78 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 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 if dsn.starts_with("quic://") {
161 Self::parse_url(dsn, Transport::Quic)
162 } else if let Some(rest) = dsn.strip_prefix("grpcs://") {
163 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 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 Self::parse_legacy(dsn)
180 }
181 }
182
183 fn parse_url(dsn: &str, transport: Transport) -> Result<Self> {
185 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 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 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 let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
225
226 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(¶ms)?;
244
245 Ok(result)
246 }
247
248 fn parse_legacy(dsn: &str) -> Result<Self> {
250 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 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, host,
267 port,
268 ..Default::default()
269 };
270
271 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 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(¶ms)?;
288 }
289
290 Ok(result)
291 }
292
293 fn parse_host_port(s: &str) -> Result<(String, u16)> {
295 if s.starts_with('[') {
297 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 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 Ok((s.to_string(), DEFAULT_PORT))
326 }
327 }
328
329 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 self.options.insert(key.clone(), value.clone());
389 }
390 }
391 }
392 Ok(())
393 }
394
395 pub fn transport(&self) -> Transport {
397 self.transport
398 }
399
400 pub fn host(&self) -> &str {
402 &self.host
403 }
404
405 pub fn port(&self) -> u16 {
407 self.port
408 }
409
410 pub fn username(&self) -> Option<&str> {
412 self.username.as_deref()
413 }
414
415 pub fn password(&self) -> Option<&str> {
417 self.password.as_deref()
418 }
419
420 pub fn tls_enabled(&self) -> bool {
422 self.tls_enabled
423 }
424
425 pub fn skip_verify(&self) -> bool {
427 self.skip_verify
428 }
429
430 pub fn page_size(&self) -> usize {
432 self.page_size
433 }
434
435 pub fn client_name(&self) -> &str {
437 &self.client_name
438 }
439
440 pub fn client_version(&self) -> &str {
442 &self.client_version
443 }
444
445 pub fn conformance(&self) -> &str {
447 &self.conformance
448 }
449
450 pub fn options(&self) -> &HashMap<String, String> {
452 &self.options
453 }
454
455 pub fn ca_cert(&self) -> Option<&str> {
457 self.ca_cert.as_deref()
458 }
459
460 pub fn client_cert(&self) -> Option<&str> {
462 self.client_cert.as_deref()
463 }
464
465 pub fn client_key(&self) -> Option<&str> {
467 self.client_key.as_deref()
468 }
469
470 pub fn server_name(&self) -> Option<&str> {
472 self.server_name.as_deref()
473 }
474
475 pub fn connect_timeout_secs(&self) -> Option<u64> {
477 self.connect_timeout_secs
478 }
479
480 pub fn graph(&self) -> Option<&str> {
482 self.graph.as_deref()
483 }
484
485 pub fn tenant(&self) -> Option<&str> {
487 self.tenant.as_deref()
488 }
489
490 pub fn role(&self) -> Option<&str> {
492 self.role.as_deref()
493 }
494
495 pub fn address(&self) -> String {
497 if self.host.contains(':') {
498 format!("[{}]:{}", self.host, self.port)
500 } else {
501 format!("{}:{}", self.host, self.port)
502 }
503 }
504
505 pub fn to_dsn(&self) -> String {
529 let defaults = Dsn::default();
530
531 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 for (key, value) in &self.options {
578 params.push((key.clone(), value.clone()));
579 }
580
581 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 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
648fn 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 #[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 #[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 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 let err = Dsn::parse("grcp://localhost:50051").unwrap_err();
744 assert!(err.to_string().contains("Unsupported scheme"));
745
746 let err = Dsn::parse("geode://localhost:3141").unwrap_err();
748 assert!(err.to_string().contains("Unsupported scheme"));
749 }
750
751 #[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 #[test]
796 fn test_dsn_parse_legacy_host_port() {
797 let dsn = Dsn::parse("localhost:3141").unwrap();
798 assert_eq!(dsn.transport(), Transport::Quic); 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 #[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 #[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 let result = Dsn::parse("quic://:3141");
877 if let Ok(dsn) = result {
879 assert!(dsn.host().is_empty() || dsn.host() == "");
880 }
881 }
882
883 #[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 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 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 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 #[test]
969 fn test_dsn_parse_insecure_tls_skip_verify_primary() {
970 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 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 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 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 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 #[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 #[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 #[test]
1073 fn test_acceptance_quic_localhost() {
1074 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 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 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 #[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 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 #[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 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 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}