1use std::sync::Arc;
8use std::time::Duration;
9
10use crate::EggressError;
11
12#[derive(Debug, Clone)]
14pub struct OutboundInfo {
15 pub local_addr: Option<std::net::SocketAddr>,
17 pub peer_addr: Option<std::net::SocketAddr>,
19 pub hop_count: usize,
21}
22
23pub struct UdpAssociation {
28 pub relay_addr: std::net::SocketAddr,
30 pub control_stream: Option<eggress_core::BoxStream>,
32 pub target: eggress_core::TargetAddr,
34}
35
36async fn resolve_endpoint_addr(
40 endpoint: &eggress_uri::EndpointSpec,
41) -> Option<std::net::SocketAddr> {
42 if let Ok(ip) = endpoint.host.parse::<std::net::IpAddr>() {
43 return Some(std::net::SocketAddr::new(ip, endpoint.port));
44 }
45 let lookup = format!("{}:{}", endpoint.host, endpoint.port);
46 let mut addresses = tokio::net::lookup_host(&lookup).await.ok()?;
47 addresses.next()
48}
49
50pub struct OutboundConnector {
56 runtime_config: Option<Arc<eggress_config::compile::RuntimeConfig>>,
57 chain_executor: eggress_core::chain::ChainExecutor,
58 direct: bool,
59}
60
61impl OutboundConnector {
62 pub fn from_toml(config_toml: &str) -> Result<Self, EggressError> {
64 let config: eggress_config::model::ConfigFile =
65 toml::from_str(config_toml).map_err(|e| EggressError::Config(e.to_string()))?;
66
67 if let Some(version) = config.version {
68 if version != 1 {
69 return Err(EggressError::Config(format!(
70 "unsupported config version: {version}"
71 )));
72 }
73 }
74
75 eggress_config::validate::validate_config(&config).map_err(|errors| {
76 let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
77 EggressError::Config(messages.join("; "))
78 })?;
79
80 let runtime_config = eggress_config::compile::compile_config(&config)
81 .map_err(|e| EggressError::Config(e.to_string()))?;
82
83 if runtime_config.upstreams.is_empty() {
84 return Err(EggressError::Config("no upstreams configured".to_string()));
85 }
86
87 let upstream = &runtime_config.upstreams[0];
88 if upstream.chain.hops.is_empty() {
89 return Err(EggressError::Config("upstream chain is empty".to_string()));
90 }
91
92 #[cfg(feature = "ssh")]
93 let chain_executor = eggress_server::build_chain_executor(None, None, None);
94 #[cfg(not(feature = "ssh"))]
95 let chain_executor = eggress_server::build_chain_executor(None, None);
96
97 Ok(Self {
98 runtime_config: Some(Arc::new(runtime_config)),
99 chain_executor,
100 direct: false,
101 })
102 }
103
104 #[cfg(feature = "pproxy-compat")]
113 pub fn from_pproxy_uri(uri: &str) -> Result<Self, EggressError> {
114 let redacted_expr = redact_pproxy_expression(uri);
115 let chain = eggress_pproxy_compat::uri::parse_pproxy_chain(uri)
116 .map_err(|e| map_compat_parse_error(uri, &redacted_expr, e))?;
117 if chain.hops.len() == 1 && chain.hops[0].scheme == "direct" {
118 #[cfg(feature = "ssh")]
119 let executor = eggress_server::build_chain_executor(None, None, None);
120 #[cfg(not(feature = "ssh"))]
121 let executor = eggress_server::build_chain_executor(None, None);
122 return Ok(Self {
123 runtime_config: None,
124 chain_executor: executor,
125 direct: true,
126 });
127 }
128 if chain.hops.iter().any(|hop| hop.is_backward()) {
129 return Err(EggressError::UnsupportedFeature {
130 feature: "backward-upstream".to_string(),
131 message: format!(
132 "pproxy chain '{}' uses a backward (+in) role which OutboundConnector cannot execute",
133 chain.redacted_display()
134 ),
135 });
136 }
137 let unsupported = eggress_pproxy_compat::uri::validate_chain_hops(&chain);
138 if !unsupported.is_empty() {
139 let roles = unsupported
140 .iter()
141 .map(|(_, scheme)| scheme.clone())
142 .collect::<Vec<_>>()
143 .join(", ");
144 return Err(EggressError::UnsupportedFeature {
145 feature: "chain-unsupported-hop".to_string(),
146 message: format!(
147 "pproxy chain '{}' contains unsupported hop role(s): {}",
148 chain.redacted_display(),
149 roles
150 ),
151 });
152 }
153 let default_args = eggress_pproxy_compat::PproxyArgs::default_args();
154 let chains = [chain.clone()];
155 let output = eggress_pproxy_compat::translate_from_uris(&default_args, &[], &chains)
156 .map_err(|e| map_compat_translate_error(&chain, uri, &redacted_expr, e))?;
157 if !output.unsupported.is_empty() {
158 let first_feature = output.unsupported[0].feature.to_string();
159 let details = output
160 .unsupported
161 .iter()
162 .map(|u| u.to_string())
163 .collect::<Vec<_>>()
164 .join("; ");
165 let details = scrub_message_with_chain(&chain, uri, &redacted_expr, details);
166 return Err(EggressError::UnsupportedFeature {
167 feature: first_feature,
168 message: format!(
169 "pproxy chain '{}' is not executable outbound: {}",
170 chain.redacted_display(),
171 details
172 ),
173 });
174 }
175 Self::from_toml(&output.toml)
176 .map_err(|e| sanitize_connector_error(&chain, uri, &redacted_expr, e))
177 }
178
179 pub async fn connect_tcp(
183 &self,
184 host: &str,
185 port: u16,
186 ) -> Result<(eggress_core::BoxStream, OutboundInfo), EggressError> {
187 let target = eggress_core::TargetAddr {
188 host: if let Ok(ip) = host.parse::<std::net::IpAddr>() {
189 eggress_core::TargetHost::Ip(ip)
190 } else {
191 eggress_core::TargetHost::Domain(host.to_string())
192 },
193 port,
194 };
195
196 if self.direct {
197 let stream = eggress_core::connector::DirectConnector
198 .connect_with_options(&target, &eggress_core::connector::ConnectOptions::default())
199 .await
200 .map_err(|e| EggressError::Runtime(e.to_string()))?;
201 return Ok((
202 stream,
203 OutboundInfo {
204 local_addr: None,
205 peer_addr: None,
206 hop_count: 0,
207 },
208 ));
209 }
210
211 let runtime_config = self.runtime_config.as_ref().ok_or_else(|| {
212 EggressError::Runtime("outbound runtime configuration is unavailable".to_string())
213 })?;
214 let upstream = &runtime_config.upstreams[0];
215 let chain = &upstream.chain;
216
217 let first_hop = &chain.hops[0];
219 let peer_addr = resolve_endpoint_addr(&first_hop.endpoint).await;
220
221 let stream = self
222 .chain_executor
223 .execute(&chain.hops, &target)
224 .await
225 .map_err(|e| EggressError::Runtime(e.to_string()))?;
226
227 let info = OutboundInfo {
228 local_addr: None,
229 peer_addr,
230 hop_count: chain.hops.len(),
231 };
232
233 Ok((stream, info))
234 }
235
236 pub async fn connect_tcp_timeout(
238 &self,
239 host: &str,
240 port: u16,
241 timeout: Duration,
242 ) -> Result<(eggress_core::BoxStream, OutboundInfo), EggressError> {
243 tokio::time::timeout(timeout, self.connect_tcp(host, port))
244 .await
245 .map_err(|_| EggressError::Runtime("connection timed out".to_string()))?
246 }
247
248 pub async fn associate_udp(
256 &self,
257 _target_host: &str,
258 _target_port: u16,
259 ) -> Result<UdpAssociation, EggressError> {
260 Err(EggressError::Runtime(
261 "UDP association through OutboundConnector is not yet implemented; \
262 use the listener-based approach for UDP"
263 .to_string(),
264 ))
265 }
266
267 pub fn upstream_count(&self) -> usize {
269 self.runtime_config
270 .as_ref()
271 .map_or(0, |config| config.upstreams.len())
272 }
273
274 pub fn validate_outbound_config(config_toml: &str) -> Result<usize, EggressError> {
278 let config: eggress_config::model::ConfigFile =
279 toml::from_str(config_toml).map_err(|e| EggressError::Config(e.to_string()))?;
280
281 if let Some(version) = config.version {
282 if version != 1 {
283 return Err(EggressError::Config(format!(
284 "unsupported config version: {version}"
285 )));
286 }
287 }
288
289 eggress_config::validate::validate_config(&config).map_err(|errors| {
290 let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
291 EggressError::Config(messages.join("; "))
292 })?;
293
294 let runtime_config = eggress_config::compile::compile_config(&config)
295 .map_err(|e| EggressError::Config(e.to_string()))?;
296
297 if runtime_config.upstreams.is_empty() {
298 return Err(EggressError::Config(
299 "no upstreams configured; cannot make outbound connections".to_string(),
300 ));
301 }
302
303 let upstream = &runtime_config.upstreams[0];
304 let chain = &upstream.chain;
305
306 if chain.hops.is_empty() {
307 return Err(EggressError::Config(
308 "upstream chain is empty; cannot make outbound connections".to_string(),
309 ));
310 }
311
312 Ok(chain.hops.len())
313 }
314}
315
316#[cfg(feature = "pproxy-compat")]
324fn redact_pproxy_expression(input: &str) -> String {
325 split_redaction_hops(input)
326 .iter()
327 .map(|segment| redact_pproxy_hop(segment))
328 .collect::<Vec<_>>()
329 .join("__")
330}
331
332#[cfg(feature = "pproxy-compat")]
336fn split_redaction_hops(input: &str) -> Vec<&str> {
337 let mut out = Vec::new();
338 let mut start = 0;
339 let mut bracket = 0u32;
340 let mut brace = 0u32;
341 let bytes = input.as_bytes();
342 let mut i = 0;
343 while i < bytes.len() {
344 match bytes[i] as char {
345 '[' => bracket += 1,
346 ']' => bracket = bracket.saturating_sub(1),
347 '{' => brace += 1,
348 '}' => brace = brace.saturating_sub(1),
349 '_' if i + 1 < bytes.len() && bytes[i + 1] == b'_' && bracket == 0 && brace == 0 => {
350 out.push(&input[start..i]);
351 i += 1;
352 start = i + 1;
353 }
354 _ => {}
355 }
356 i += 1;
357 }
358 out.push(&input[start..]);
359 out
360}
361
362#[cfg(feature = "pproxy-compat")]
363fn redact_pproxy_hop(segment: &str) -> String {
364 if segment.is_empty() {
365 return String::new();
366 }
367 if let Ok(parsed) = eggress_pproxy_compat::uri::parse_pproxy_uri(segment) {
368 return parsed.redacted_display();
369 }
370 fallback_redact_hop(segment)
371}
372
373#[cfg(feature = "pproxy-compat")]
377fn fallback_redact_hop(segment: &str) -> String {
378 let (before_hash, has_fragment) = match segment.find('#') {
379 Some(pos) => (&segment[..pos], true),
380 None => (segment, false),
381 };
382 let frag_suffix = if has_fragment { "#****" } else { "" };
383 if before_hash.starts_with("unix://") {
384 return format!("unix://****{frag_suffix}");
385 }
386 let Some(scheme_end) = before_hash.find("://") else {
387 if let Some(at) = find_last_at_outside_brackets(before_hash) {
388 return format!("****:****@{}{frag_suffix}", &before_hash[at + 1..]);
389 }
390 return format!("{before_hash}{frag_suffix}");
391 };
392 let scheme = &before_hash[..scheme_end];
393 let after = &before_hash[scheme_end + 3..];
394 if let Some(at) = find_last_at_outside_brackets(after) {
395 format!("{}://****:****@{}{frag_suffix}", scheme, &after[at + 1..])
396 } else {
397 format!("{before_hash}{frag_suffix}")
398 }
399}
400
401#[cfg(feature = "pproxy-compat")]
402fn find_last_at_outside_brackets(s: &str) -> Option<usize> {
403 let mut last = None;
404 let mut depth = 0u32;
405 for (i, c) in s.char_indices() {
406 match c {
407 '[' => depth += 1,
408 ']' => depth = depth.saturating_sub(1),
409 '@' if depth == 0 => last = Some(i),
410 _ => {}
411 }
412 }
413 last
414}
415
416#[cfg(feature = "pproxy-compat")]
419fn redact_credentials_in_text(text: &str) -> String {
420 let mut out = String::with_capacity(text.len());
421 let mut rest = text;
422 while let Some(pos) = rest.find("://") {
423 out.push_str(&rest[..pos + 3]);
424 rest = &rest[pos + 3..];
425 let mut token_end = rest.len();
426 for (i, c) in rest.char_indices() {
427 if c.is_whitespace() || matches!(c, '"' | '\'' | '`' | '<' | '>' | '(' | ')') {
428 token_end = i;
429 break;
430 }
431 }
432 let (token, remainder) = (&rest[..token_end], &rest[token_end..]);
433 let (before_hash, fragment) = match token.find('#') {
434 Some(p) => (&token[..p], Some(&token[p..])),
435 None => (token, None),
436 };
437 if let Some(at) = find_last_at_outside_brackets(before_hash) {
438 out.push_str("****:****@");
439 out.push_str(&before_hash[at + 1..]);
440 } else {
441 out.push_str(before_hash);
442 }
443 if let Some(frag) = fragment {
444 if frag.contains(':') || frag.contains('@') {
445 out.push_str("#****");
446 } else {
447 out.push_str(frag);
448 }
449 }
450 rest = remainder;
451 }
452 out.push_str(rest);
453 out
454}
455
456#[cfg(feature = "pproxy-compat")]
459fn percent_encode_for_scrub(s: &str) -> String {
460 let mut out = String::with_capacity(s.len());
461 for b in s.bytes() {
462 match b {
463 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
464 out.push(b as char);
465 }
466 _ => out.push_str(&format!("%{b:02X}")),
467 }
468 }
469 out
470}
471
472#[cfg(feature = "pproxy-compat")]
473fn chain_credential_terms(chain: &eggress_pproxy_compat::uri::PproxyChain) -> Vec<String> {
474 let mut terms = Vec::new();
475 for hop in &chain.hops {
476 for value in [&hop.username, &hop.password].into_iter().flatten() {
477 if !value.is_empty() {
478 terms.push(value.clone());
479 }
480 }
481 if let Some(fragment) = &hop.auth_fragment {
482 if !fragment.is_empty() {
483 terms.push(fragment.clone());
484 if let Some((user, pass)) = fragment.split_once(':') {
485 if !user.is_empty() {
486 terms.push(user.to_string());
487 }
488 if !pass.is_empty() {
489 terms.push(pass.to_string());
490 }
491 }
492 }
493 }
494 }
495 terms.sort_by_key(|term| std::cmp::Reverse(term.len()));
496 terms
497}
498
499#[cfg(feature = "pproxy-compat")]
503fn scrub_message_with_chain(
504 chain: &eggress_pproxy_compat::uri::PproxyChain,
505 original_uri: &str,
506 redacted_expr: &str,
507 message: String,
508) -> String {
509 let mut msg = message.replace(original_uri, redacted_expr);
510 for term in chain_credential_terms(chain) {
511 if !term.is_empty() {
512 msg = msg.replace(term.as_str(), "****");
513 let encoded = percent_encode_for_scrub(&term);
514 if encoded != term {
515 msg = msg.replace(encoded.as_str(), "****");
516 }
517 }
518 }
519 redact_credentials_in_text(&msg)
520}
521
522#[cfg(feature = "pproxy-compat")]
523fn map_compat_parse_error(
524 uri: &str,
525 redacted_expr: &str,
526 error: eggress_pproxy_compat::CompatError,
527) -> EggressError {
528 let raw = error.to_string();
529 let mut detail = raw.replace(uri, redacted_expr);
530 detail = redact_credentials_in_text(&detail);
531 let message = format!("invalid pproxy chain '{redacted_expr}': {detail}");
532 match error {
533 eggress_pproxy_compat::CompatError::UnsupportedProtocol(protocol) => {
534 EggressError::UnsupportedFeature {
535 feature: protocol,
536 message,
537 }
538 }
539 eggress_pproxy_compat::CompatError::UnsupportedFeature { feature, .. } => {
540 EggressError::UnsupportedFeature {
541 feature: feature.to_string(),
542 message,
543 }
544 }
545 _ => EggressError::Config(message),
546 }
547}
548
549#[cfg(feature = "pproxy-compat")]
550fn map_compat_translate_error(
551 chain: &eggress_pproxy_compat::uri::PproxyChain,
552 uri: &str,
553 redacted_expr: &str,
554 error: eggress_pproxy_compat::CompatError,
555) -> EggressError {
556 let detail = scrub_message_with_chain(chain, uri, redacted_expr, error.to_string());
557 let message = format!(
558 "pproxy chain '{}' failed translation: {}",
559 chain.redacted_display(),
560 detail
561 );
562 match error {
563 eggress_pproxy_compat::CompatError::UnsupportedProtocol(protocol) => {
564 EggressError::UnsupportedFeature {
565 feature: protocol,
566 message,
567 }
568 }
569 eggress_pproxy_compat::CompatError::UnsupportedFeature { feature, .. } => {
570 EggressError::UnsupportedFeature {
571 feature: feature.to_string(),
572 message,
573 }
574 }
575 _ => EggressError::Config(message),
576 }
577}
578
579#[cfg(feature = "pproxy-compat")]
580fn sanitize_connector_error(
581 chain: &eggress_pproxy_compat::uri::PproxyChain,
582 uri: &str,
583 redacted_expr: &str,
584 error: EggressError,
585) -> EggressError {
586 match error {
587 EggressError::Config(message) => {
588 EggressError::Config(scrub_message_with_chain(chain, uri, redacted_expr, message))
589 }
590 EggressError::Runtime(message) => {
591 EggressError::Runtime(scrub_message_with_chain(chain, uri, redacted_expr, message))
592 }
593 EggressError::Startup(message) => {
594 EggressError::Startup(scrub_message_with_chain(chain, uri, redacted_expr, message))
595 }
596 EggressError::Reload(message) => {
597 EggressError::Reload(scrub_message_with_chain(chain, uri, redacted_expr, message))
598 }
599 EggressError::Shutdown(message) => {
600 EggressError::Shutdown(scrub_message_with_chain(chain, uri, redacted_expr, message))
601 }
602 EggressError::UnsupportedFeature { feature, message } => EggressError::UnsupportedFeature {
603 feature,
604 message: scrub_message_with_chain(chain, uri, redacted_expr, message),
605 },
606 EggressError::Internal(message) => {
607 EggressError::Internal(scrub_message_with_chain(chain, uri, redacted_expr, message))
608 }
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
617 fn test_outbound_connector_from_toml() {
618 let config = r#"
619 version = 1
620 [[listeners]]
621 name = "test"
622 bind = "127.0.0.1:0"
623 protocols = ["socks5"]
624 [[upstreams]]
625 id = "direct"
626 uri = "socks5://127.0.0.1:1080"
627 "#;
628 let connector = OutboundConnector::from_toml(config).unwrap();
629 assert_eq!(connector.upstream_count(), 1);
630 }
631
632 #[test]
633 fn test_validate_no_upstreams() {
634 let config = r#"
635 version = 1
636 [[listeners]]
637 name = "test"
638 bind = "127.0.0.1:0"
639 protocols = ["socks5"]
640 "#;
641 let result = OutboundConnector::validate_outbound_config(config);
642 assert!(result.is_err());
643 assert!(result.unwrap_err().to_string().contains("no upstreams"));
644 }
645
646 #[test]
647 fn test_validate_empty_chain() {
648 let config = r#"
649 version = 1
650 [[listeners]]
651 name = "test"
652 bind = "127.0.0.1:0"
653 protocols = ["socks5"]
654 [[upstreams]]
655 id = "up"
656 uri = "socks5://127.0.0.1:1080"
657 "#;
658 let result = OutboundConnector::validate_outbound_config(config);
659 assert!(result.is_ok());
660 }
661
662 #[test]
663 fn test_from_pproxy_uri() {
664 let connector = OutboundConnector::from_pproxy_uri("socks5://127.0.0.1:1080").unwrap();
665 assert_eq!(connector.upstream_count(), 1);
666 }
667
668 #[cfg(feature = "pproxy-compat")]
669 #[test]
670 fn test_from_pproxy_uri_single_http() {
671 let connector =
672 OutboundConnector::from_pproxy_uri("http://127.0.0.1:8080").expect("single HTTP");
673 let runtime = connector
674 .runtime_config
675 .as_ref()
676 .expect("single-hop connector has runtime config");
677 assert_eq!(runtime.upstreams.len(), 1);
678 assert_eq!(runtime.upstreams[0].chain.hops.len(), 1);
679 assert!(
680 runtime.upstreams[0].chain.hops[0]
681 .protocols
682 .contains(&eggress_uri::ProtocolSpec::Http),
683 "expected HTTP hop, got {:?}",
684 runtime.upstreams[0].chain.hops[0].protocols
685 );
686 }
687
688 #[cfg(feature = "pproxy-compat")]
689 #[test]
690 fn test_from_pproxy_uri_two_hop_chain() {
691 let connector =
692 OutboundConnector::from_pproxy_uri("socks5://127.0.0.1:1080__http://127.0.0.1:8080")
693 .expect("two-hop chain should construct");
694 let runtime = connector
695 .runtime_config
696 .as_ref()
697 .expect("chained connector has runtime config");
698 assert_eq!(runtime.upstreams.len(), 1);
699 let hops = &runtime.upstreams[0].chain.hops;
700 assert_eq!(hops.len(), 2, "expected two ordered hops, got {hops:?}");
701 assert!(
702 hops[0]
703 .protocols
704 .contains(&eggress_uri::ProtocolSpec::Socks5),
705 "hop 0 should be SOCKS5, got {:?}",
706 hops[0].protocols
707 );
708 assert!(
709 hops[1].protocols.contains(&eggress_uri::ProtocolSpec::Http),
710 "hop 1 should be HTTP, got {:?}",
711 hops[1].protocols
712 );
713 assert!(!connector.direct);
714 }
715
716 #[cfg(feature = "pproxy-compat")]
717 #[test]
718 fn test_from_pproxy_uri_three_hop_chain() {
719 let connector = OutboundConnector::from_pproxy_uri(
720 "socks5://127.0.0.1:1080__http://127.0.0.1:8080__socks4://127.0.0.1:1081",
721 )
722 .expect("three-hop chain should construct");
723 let runtime = connector.runtime_config.as_ref().expect("runtime config");
724 let hops = &runtime.upstreams[0].chain.hops;
725 assert_eq!(hops.len(), 3);
726 assert!(hops[0]
727 .protocols
728 .contains(&eggress_uri::ProtocolSpec::Socks5));
729 assert!(hops[1].protocols.contains(&eggress_uri::ProtocolSpec::Http));
730 assert!(hops[2]
731 .protocols
732 .contains(&eggress_uri::ProtocolSpec::Socks4));
733 }
734
735 #[cfg(feature = "pproxy-compat")]
736 #[test]
737 fn test_from_pproxy_uri_direct_fast_path() {
738 let connector = OutboundConnector::from_pproxy_uri("direct://").expect("direct://");
739 assert!(connector.direct);
740 assert!(connector.runtime_config.is_none());
741 assert_eq!(connector.upstream_count(), 0);
742 }
743
744 #[cfg(feature = "pproxy-compat")]
745 #[test]
746 fn test_from_pproxy_uri_malformed_chain_rejected() {
747 for uri in ["socks5://127.0.0.1:1080__", "__socks5://127.0.0.1:1080"] {
748 let result = OutboundConnector::from_pproxy_uri(uri);
749 assert!(result.is_err(), "malformed chain should fail: {uri}");
750 }
751 }
752
753 #[cfg(feature = "pproxy-compat")]
754 #[test]
755 fn test_from_pproxy_uri_multihop_direct_not_collapsed() {
756 let result = OutboundConnector::from_pproxy_uri("socks5://127.0.0.1:1080__direct://");
757 assert!(result.is_err(), "multi-hop direct must fail closed");
758 }
759
760 #[cfg(feature = "pproxy-compat")]
761 #[test]
762 fn test_from_pproxy_uri_unsupported_hop_fails_closed() {
763 let result =
764 OutboundConnector::from_pproxy_uri("socks5://127.0.0.1:1080__redir://127.0.0.1:1234");
765 assert!(result.is_err(), "unsupported hop must fail closed");
766 }
767
768 #[cfg(feature = "pproxy-compat")]
769 #[test]
770 fn test_from_pproxy_uri_valid_credentialed_chain_keeps_credentials() {
771 let connector = OutboundConnector::from_pproxy_uri(
772 "socks5://user1:pass1@127.0.0.1:1080__http://user2:pass2@127.0.0.1:8080",
773 )
774 .expect("credentialed chain should construct");
775 let runtime = connector.runtime_config.as_ref().expect("runtime config");
776 let hops = &runtime.upstreams[0].chain.hops;
777 assert_eq!(hops.len(), 2);
778 let first = hops[0].credentials.as_ref().expect("hop 0 credentials");
779 assert_eq!(first.username, "user1");
780 assert_eq!(first.password, "pass1");
781 let second = hops[1].credentials.as_ref().expect("hop 1 credentials");
782 assert_eq!(second.username, "user2");
783 assert_eq!(second.password, "pass2");
784 }
785
786 #[cfg(feature = "pproxy-compat")]
787 #[test]
788 fn test_from_pproxy_uri_malformed_chain_redacts_credentials() {
789 let uri =
790 "socks5://user_a:secret_a@127.0.0.1:1080__http://user_b:secret_b@127.0.0.1:8080__";
791 let err = match OutboundConnector::from_pproxy_uri(uri) {
792 Ok(_) => panic!("trailing __ must fail"),
793 Err(err) => err,
794 };
795 let rendered = format!("{err:?} {err}");
796 for secret in ["user_a", "secret_a", "user_b", "secret_b"] {
797 assert!(
798 !rendered.contains(secret),
799 "error leaked {secret:?}: {rendered}"
800 );
801 }
802 }
803}