Skip to main content

eggress_embed/
outbound.rs

1//! Native outbound connector for proxy chains.
2//!
3//! This module provides [`OutboundConnector`], which compiles a TOML config
4//! and executes the chain engine directly to open TCP connections through a
5//! configured proxy chain without starting a listener service.
6
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::EggressError;
11
12/// Metadata about an established outbound connection.
13#[derive(Debug, Clone)]
14pub struct OutboundInfo {
15    /// The local address of the underlying TCP connection (if available).
16    pub local_addr: Option<std::net::SocketAddr>,
17    /// The remote address of the first hop.
18    pub peer_addr: Option<std::net::SocketAddr>,
19    /// The chain hops that were traversed.
20    pub hop_count: usize,
21}
22
23/// A UDP association through a SOCKS5 proxy.
24///
25/// Contains the relay address to send/receive UDP datagrams and
26/// the control stream that must remain open for the association lifetime.
27pub struct UdpAssociation {
28    /// The UDP relay address of the SOCKS5 proxy.
29    pub relay_addr: std::net::SocketAddr,
30    /// The control TCP stream (must stay open for the association).
31    pub control_stream: Option<eggress_core::BoxStream>,
32    /// The target address for datagrams.
33    pub target: eggress_core::TargetAddr,
34}
35
36/// Resolve a proxy endpoint address (host:port) to a SocketAddr.
37///
38/// For IP addresses, returns directly. For domains, performs DNS lookup.
39async 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
50/// A native outbound connector that executes the chain engine directly.
51///
52/// This compiles routing/upstream state from a TOML config and provides
53/// methods to open TCP connections through the configured proxy chain
54/// without starting a listener service.
55pub 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    /// Create a connector from a TOML config string.
63    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    /// Create a connector from a pproxy-style remote expression.
105    ///
106    /// Accepts a single pproxy URI or a canonical `__`-separated multi-hop
107    /// chain (e.g. `"socks5://127.0.0.1:1080__http://127.0.0.1:8080"`).
108    /// The expression is parsed with the compatibility chain parser and
109    /// translated through the existing compatibility layer, then executed
110    /// in-process via `ChainExecutor`. No listener is started.
111    /// Unsupported chain members fail construction instead of being dropped.
112    #[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    /// Connect to a target host:port through the configured proxy chain.
180    ///
181    /// Returns the connected stream and connection metadata.
182    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        // Resolve the first hop endpoint address for metadata
218        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    /// Connect with a timeout.
237    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    /// Create a UDP association through the configured proxy chain.
249    ///
250    /// Returns a `UdpAssociation` with the relay address to send/receive
251    /// UDP datagrams through the proxy chain.
252    ///
253    /// UDP association requires SOCKS5 with UDP ASSOCIATE support.
254    /// This method establishes the association and returns channel endpoints.
255    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    /// Get the number of upstreams configured.
268    pub fn upstream_count(&self) -> usize {
269        self.runtime_config
270            .as_ref()
271            .map_or(0, |config| config.upstreams.len())
272    }
273
274    /// Validate that the config is usable for outbound connections.
275    ///
276    /// Returns the number of hops in the first upstream's chain.
277    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/// Redact credentials in a pproxy remote expression without requiring it
317/// to parse successfully.
318///
319/// Valid hops use the typed `PproxyUri::redacted_display` so bind addresses
320/// and plugin names survive; unparseable segments fall back to aggressive
321/// syntax-local redaction. The exact rendering is not contractual; absence
322/// of secrets is.
323#[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/// Split a pproxy expression on `__` while ignoring separators inside
333/// bracketed IPv6 literals and brace-delimited fixed targets.
334/// Never fails; unmatched brackets are treated literally.
335#[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/// Aggressive fallback for hops that do not parse: hide anything that
374/// could be userinfo and any `#` auth fragment. Over-redaction is
375/// acceptable here; leakage is not.
376#[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/// Redact `scheme://...@...` userinfo occurrences embedded in free-form
417/// diagnostic text, plus `#` auth fragments that carry credentials.
418#[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/// Percent-encode mirroring the compatibility translator so scrubbing
457/// catches credentials that reappear in generated config URIs.
458#[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/// Scrub a diagnostic message of the original expression and every
500/// credential term carried by the parsed chain (raw and percent-encoded),
501/// plus any generic `://...@` userinfo that remains.
502#[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}