Skip to main content

eggress_uri/
lib.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5/// Specification for a proxy chain (one or more hops).
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub struct ProxyChainSpec {
8    /// Ordered list of proxy hops.
9    pub hops: Vec<ProxyHopSpec>,
10}
11
12/// Specification for a single proxy hop.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14pub struct ProxyHopSpec {
15    /// Supported protocols for this hop.
16    pub protocols: Vec<ProtocolSpec>,
17    /// Endpoint address.
18    pub endpoint: EndpointSpec,
19    /// Optional credentials.
20    #[serde(default)]
21    pub credentials: Option<CredentialSpec>,
22    /// Optional routing rule.
23    #[serde(default)]
24    pub rule: Option<String>,
25    /// Optional local bind address.
26    #[serde(default)]
27    pub local_bind: Option<String>,
28    /// Whether to wrap this hop in TLS.
29    #[serde(default)]
30    pub tls: bool,
31    /// Optional SNI override for TLS (defaults to endpoint host).
32    #[serde(default)]
33    pub server_name: Option<String>,
34    /// Explicit certificate bypass for compatibility transports.
35    #[serde(default)]
36    pub insecure: bool,
37    /// Ordered pproxy SSR plugin names.
38    #[serde(default)]
39    pub plugins: Vec<String>,
40    /// Optional pproxy SSR user/auth prefix from the URI fragment.
41    #[serde(default)]
42    pub auth_prefix: Option<String>,
43}
44
45/// Supported proxy protocols.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47pub enum ProtocolSpec {
48    Http,
49    HttpOnly,
50    Socks4,
51    Socks5,
52    Shadowsocks,
53    ShadowsocksR,
54    Trojan,
55    Http2,
56    Http3,
57    Quic,
58    WebSocket,
59    Raw,
60    Ssh,
61    Unix,
62}
63
64/// Endpoint address specification.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66pub struct EndpointSpec {
67    pub host: String,
68    pub port: u16,
69}
70
71/// Credential specification.
72#[derive(Clone, Deserialize, PartialEq, Eq)]
73pub struct CredentialSpec {
74    pub username: String,
75    pub password: String,
76}
77
78impl Serialize for CredentialSpec {
79    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
80        // Never emit the plaintext password through serialization: any
81        // diagnostic or audit path that JSON-encodes a parsed chain must
82        // stay credential-free, mirroring the redacting `Debug` impl.
83        use serde::ser::SerializeStruct;
84        const PASSWORD_PLACEHOLDER: &str = "****";
85        let mut state = serializer.serialize_struct("CredentialSpec", 2)?;
86        state.serialize_field("username", &self.username)?;
87        state.serialize_field("password", PASSWORD_PLACEHOLDER)?;
88        state.end()
89    }
90}
91
92impl fmt::Debug for CredentialSpec {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        // Never emit the plaintext password through Debug (logging, panics,
95        // assertion messages); mirror the redacting `Display` behavior.
96        f.debug_struct("CredentialSpec")
97            .field("username", &self.username)
98            .field("password", &"****")
99            .finish()
100    }
101}
102
103/// Errors that can occur during URI parsing.
104#[derive(Debug, thiserror::Error)]
105pub enum UriParseError {
106    #[error("invalid URI format: {message}")]
107    InvalidFormat {
108        message: String,
109        span: Option<usize>,
110    },
111    #[error("unsupported protocol: {0}")]
112    UnsupportedProtocol(String),
113    #[error("missing host")]
114    MissingHost,
115    #[error("invalid port: {0}")]
116    InvalidPort(String),
117    #[error("empty host not allowed")]
118    EmptyHost,
119    #[error("duplicate hop separator")]
120    DuplicateHopSeparator,
121}
122
123/// A redacted display wrapper that hides credentials.
124pub struct RedactedUri<'a> {
125    chain: &'a ProxyChainSpec,
126}
127
128/// Redact userinfo from an arbitrary proxy URI while preserving its endpoint.
129///
130/// This is intentionally tolerant of URI forms that are not parseable as a
131/// native [`ProxyChainSpec`], so callers can safely use it for diagnostics.
132pub fn redact_proxy_uri(uri: &str) -> String {
133    let Some(scheme_end) = uri.find("://") else {
134        // No `://` — still redact `user:pass@host` style credentials so
135        // raw `user:pass@host` or base64-decoded blobs never leak when
136        // callers fallback via `unwrap_or_else(|_| redact_proxy_uri(uri))`.
137        if let Some(at_pos) = find_at_outside_brackets(uri) {
138            if uri[..at_pos].contains(':') {
139                return format!("****@{}", &uri[at_pos + 1..]);
140            }
141            // Conservative: any `@` outside brackets may carry userinfo
142            return format!("****@{}", &uri[at_pos + 1..]);
143        }
144        return uri.to_string();
145    };
146    let after_scheme = &uri[scheme_end + 3..];
147    match find_at_outside_brackets(after_scheme) {
148        Some(at_pos) => format!(
149            "{}****@{}",
150            &uri[..scheme_end + 3],
151            &after_scheme[at_pos + 1..]
152        ),
153        None => uri.to_string(),
154    }
155}
156
157impl<'a> RedactedUri<'a> {
158    pub fn new(chain: &'a ProxyChainSpec) -> Self {
159        Self { chain }
160    }
161}
162
163impl fmt::Display for RedactedUri<'_> {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        let hops: Vec<String> = self
166            .chain
167            .hops
168            .iter()
169            .map(|hop| {
170                let mut proto_parts: Vec<&str> = hop
171                    .protocols
172                    .iter()
173                    .map(|p| match p {
174                        ProtocolSpec::Http => "http",
175                        ProtocolSpec::HttpOnly => "httponly",
176                        ProtocolSpec::Socks4 => "socks4",
177                        ProtocolSpec::Socks5 => "socks5",
178                        ProtocolSpec::Shadowsocks => "shadowsocks",
179                        ProtocolSpec::ShadowsocksR => "ssr",
180                        ProtocolSpec::Trojan => "trojan",
181                        ProtocolSpec::Http2 => "h2",
182                        ProtocolSpec::Http3 => "h3",
183                        ProtocolSpec::Quic => "quic",
184                        ProtocolSpec::WebSocket => "ws",
185                        ProtocolSpec::Raw => "raw",
186                        ProtocolSpec::Ssh => "ssh",
187                        ProtocolSpec::Unix => "unix",
188                    })
189                    .collect();
190                if hop.tls {
191                    proto_parts.push("tls");
192                }
193                let proto_str = proto_parts.join("+");
194
195                let endpoint_str = if hop.endpoint.host.contains(':') {
196                    format!("[{}]:{}", hop.endpoint.host, hop.endpoint.port)
197                } else {
198                    format!("{}:{}", hop.endpoint.host, hop.endpoint.port)
199                };
200
201                let cred_str = if hop.credentials.is_some() {
202                    "****:****@"
203                } else {
204                    ""
205                };
206
207                let rule_str = match &hop.rule {
208                    Some(rule) => format!("?rule={}", rule),
209                    None => String::new(),
210                };
211
212                let bind_str = match &hop.local_bind {
213                    Some(bind) => format!("@{}", bind),
214                    None => String::new(),
215                };
216
217                format!(
218                    "{}://{}{}{}{}",
219                    proto_str, cred_str, endpoint_str, rule_str, bind_str
220                )
221            })
222            .collect();
223
224        write!(f, "{}", hops.join("__"))
225    }
226}
227
228/// Parse a proxy chain URI string into a chain specification.
229///
230/// Grammar:
231/// - Protocol lists joined with `+` (e.g., `http+socks4+socks5`)
232/// - Proxy hops joined with `__` (e.g., `socks5://hop1:1080__http://hop2:8080`)
233/// - Standard URI components: scheme, host, port
234/// - Bracketed IPv6 (e.g., `[::1]`)
235/// - Credentials in userinfo (e.g., `user:pass@host:port`)
236/// - Query parameters for rules (e.g., `?rule=regex`)
237/// - Local bind modifier (e.g., `@127.0.0.1`)
238pub fn parse_proxy_chain(uri: &str) -> Result<ProxyChainSpec, UriParseError> {
239    if uri.is_empty() {
240        return Err(UriParseError::InvalidFormat {
241            message: "empty URI".to_string(),
242            span: None,
243        });
244    }
245
246    // Split on `__` for hop separator
247    let hop_strings = split_hops(uri)?;
248
249    if hop_strings.is_empty() {
250        return Err(UriParseError::InvalidFormat {
251            message: "no hops found".to_string(),
252            span: None,
253        });
254    }
255
256    let hops: Vec<ProxyHopSpec> = hop_strings
257        .iter()
258        .enumerate()
259        .map(|(i, s)| parse_hop(s, i).map_err(|e| add_hop_context(e, i)))
260        .collect::<Result<Vec<_>, _>>()?;
261
262    Ok(ProxyChainSpec { hops })
263}
264
265fn split_hops(uri: &str) -> Result<Vec<String>, UriParseError> {
266    // Split on `__` but not inside brackets or other contexts
267    let mut hops = Vec::new();
268    let mut current = String::new();
269    let chars: Vec<char> = uri.chars().collect();
270    let len = chars.len();
271    let mut i = 0;
272    let mut bracket_depth: usize = 0;
273
274    while i < len {
275        if chars[i] == '[' {
276            bracket_depth += 1;
277            current.push(chars[i]);
278        } else if chars[i] == ']' {
279            if bracket_depth == 0 {
280                return Err(UriParseError::InvalidFormat {
281                    message: "unmatched ']'".to_string(),
282                    span: Some(i),
283                });
284            }
285            bracket_depth -= 1;
286            current.push(chars[i]);
287        } else if bracket_depth == 0 && i + 1 < len && chars[i] == '_' && chars[i + 1] == '_' {
288            if (i > 0 && chars[i - 1] == '_') || (i + 2 < len && chars[i + 2] == '_') {
289                return Err(UriParseError::DuplicateHopSeparator);
290            }
291            hops.push(current.clone());
292            current.clear();
293            i += 2;
294            continue;
295        } else {
296            current.push(chars[i]);
297        }
298        i += 1;
299    }
300
301    if bracket_depth != 0 {
302        return Err(UriParseError::InvalidFormat {
303            message: "unmatched '['".to_string(),
304            span: None,
305        });
306    }
307
308    if !current.is_empty() {
309        hops.push(current);
310    }
311
312    Ok(hops)
313}
314
315fn parse_hop(hop_str: &str, _hop_index: usize) -> Result<ProxyHopSpec, UriParseError> {
316    let mut remaining = hop_str;
317
318    // Parse local bind modifier (trailing `@<bind>`)
319    // We look for the LAST '@' after "://". If what follows is a bare
320    // host or host:port (no scheme), it is treated as a bind address.
321    // The earlier '@' (if any) is the credentials separator.
322    let local_bind = if let Some(at_pos) = find_last_at_outside_scheme(remaining) {
323        let after_at = &remaining[at_pos + 1..];
324        let before_at = &remaining[..at_pos];
325        let scheme_end = before_at.find("://").unwrap_or(0);
326        let before_endpoint = &before_at[scheme_end + 3..];
327        let base_endpoint = before_endpoint
328            .split_once('?')
329            .map_or(before_endpoint, |(endpoint, _)| endpoint);
330        let has_earlier_userinfo = before_endpoint.contains('@');
331        // Small per-hop speculative parse (1-3 hops typical); double parse is
332        // bounded and keeps the @-heuristic without extra allocation. B-06.
333        let base_is_endpoint = parse_endpoint(base_endpoint).is_ok();
334        let trailing_bind_has_endpoint = before_endpoint
335            .rsplit_once('@')
336            .map(|(_, candidate)| {
337                parse_endpoint(candidate.split_once('?').map_or(candidate, |(ep, _)| ep)).is_ok()
338            })
339            .unwrap_or(false);
340        let is_trailing_bind = if has_earlier_userinfo {
341            trailing_bind_has_endpoint
342        } else {
343            base_is_endpoint
344        };
345        let has_userinfo = !is_trailing_bind;
346        // pproxy @<bind> only accepts IP/socket literals (B-06); hostname
347        // binds are intentionally rejected here.
348        let bind_is_address = after_at.parse::<std::net::IpAddr>().is_ok()
349            || after_at.parse::<std::net::SocketAddr>().is_ok();
350        if has_userinfo || after_at.contains('/') || after_at.contains('?') || !bind_is_address {
351            // A credential separator, path/query suffix, or non-address @ is
352            // not the native trailing local-bind modifier.
353            None
354        } else {
355            let bind = after_at.to_string();
356            remaining = before_at;
357            Some(bind)
358        }
359    } else {
360        None
361    };
362
363    // Split scheme from the rest
364    let (protocols, tls, after_scheme) = if let Some(colon_pos) = remaining.find("://") {
365        let scheme_part = &remaining[..colon_pos];
366        let rest = &remaining[colon_pos + 3..];
367        let (protocols, tls) = parse_protocols(scheme_part)?;
368        (protocols, tls, rest)
369    } else {
370        return Err(UriParseError::InvalidFormat {
371            message: "missing scheme (expected protocol://)".to_string(),
372            span: None,
373        });
374    };
375
376    let (after_scheme, auth_prefix) = after_scheme
377        .split_once('#')
378        .map_or((after_scheme, None), |(value, auth)| {
379            (value, Some(auth.to_string()))
380        });
381
382    // Check for empty host
383    if after_scheme.is_empty() {
384        return Err(UriParseError::MissingHost);
385    }
386
387    if protocols == [ProtocolSpec::Unix] {
388        return Ok(ProxyHopSpec {
389            protocols,
390            endpoint: EndpointSpec {
391                host: after_scheme.to_string(),
392                port: 0,
393            },
394            credentials: None,
395            rule: None,
396            local_bind,
397            tls,
398            server_name: None,
399            insecure: false,
400            plugins: Vec::new(),
401            auth_prefix,
402        });
403    }
404
405    // Split credentials and endpoint+query
406    let (credentials, endpoint_and_query) =
407        if let Some(at_pos) = find_at_outside_brackets(after_scheme) {
408            let userinfo = &after_scheme[..at_pos];
409            let rest = &after_scheme[at_pos + 1..];
410            let creds = parse_credentials(userinfo, &protocols)?;
411            (Some(creds), rest)
412        } else {
413            (None, after_scheme)
414        };
415
416    let (endpoint_and_query, plugin_path) = endpoint_and_query
417        .split_once('/')
418        .map_or((endpoint_and_query, None), |(endpoint, path)| {
419            (endpoint, Some(path))
420        });
421    let plugins = plugin_path
422        .unwrap_or_default()
423        .trim_start_matches(',')
424        .split(',')
425        .filter(|name| !name.is_empty())
426        .map(str::to_string)
427        .collect();
428
429    // Split endpoint from query string
430    let (endpoint_str, query_str) = if let Some(q_pos) = endpoint_and_query.find('?') {
431        let ep = &endpoint_and_query[..q_pos];
432        let q = &endpoint_and_query[q_pos + 1..];
433        (ep, Some(q))
434    } else {
435        (endpoint_and_query, None)
436    };
437
438    // pproxy defaults SSH's endpoint port to 22. Keep the native shorthand
439    // aligned with that compatibility form while retaining strict host:port
440    // parsing for every other protocol.
441    let endpoint = if protocols == [ProtocolSpec::Ssh]
442        && !endpoint_str.starts_with('[')
443        && !endpoint_str.contains(':')
444    {
445        EndpointSpec {
446            host: endpoint_str.to_string(),
447            port: 22,
448        }
449    } else {
450        parse_endpoint(endpoint_str)?
451    };
452
453    // Empty hosts are valid for listener bind addresses, but proxy-chain hops
454    // are outbound endpoints and cannot be executed without a host.
455    if endpoint.host.is_empty() {
456        return Err(UriParseError::EmptyHost);
457    }
458
459    // Parse query parameters
460    let rule = parse_query_rule(query_str);
461    let insecure = query_str.is_some_and(|query| {
462        query
463            .split('&')
464            .any(|param| param == "insecure" || param == "insecure=true")
465    });
466
467    // Validate port range
468    if endpoint.port == 0 {
469        return Err(UriParseError::InvalidPort("port cannot be 0".to_string()));
470    }
471
472    Ok(ProxyHopSpec {
473        protocols,
474        endpoint,
475        credentials,
476        rule,
477        local_bind,
478        tls,
479        server_name: None,
480        insecure,
481        plugins,
482        auth_prefix,
483    })
484}
485
486fn parse_protocols(scheme: &str) -> Result<(Vec<ProtocolSpec>, bool), UriParseError> {
487    let parts: Vec<&str> = scheme.split('+').collect();
488    if parts.is_empty() {
489        return Err(UriParseError::InvalidFormat {
490            message: "empty protocol list".to_string(),
491            span: None,
492        });
493    }
494
495    let mut protocols = Vec::new();
496    let mut tls = false;
497
498    for p in &parts {
499        match *p {
500            "http" => protocols.push(ProtocolSpec::Http),
501            "httponly" => protocols.push(ProtocolSpec::HttpOnly),
502            "socks4" | "socks4a" => protocols.push(ProtocolSpec::Socks4),
503            "socks5" => protocols.push(ProtocolSpec::Socks5),
504            "shadowsocks" | "ss" => protocols.push(ProtocolSpec::Shadowsocks),
505            "ssr" => protocols.push(ProtocolSpec::ShadowsocksR),
506            "trojan" => protocols.push(ProtocolSpec::Trojan),
507            "h2" => protocols.push(ProtocolSpec::Http2),
508            "h3" => protocols.push(ProtocolSpec::Http3),
509            "quic" => protocols.push(ProtocolSpec::Quic),
510            "ws" | "wss" => protocols.push(ProtocolSpec::WebSocket),
511            "raw" | "tunnel" => protocols.push(ProtocolSpec::Raw),
512            "ssh" => protocols.push(ProtocolSpec::Ssh),
513            "unix" => protocols.push(ProtocolSpec::Unix),
514            "tls" => tls = true,
515            _ => return Err(UriParseError::UnsupportedProtocol(p.to_string())),
516        }
517    }
518
519    if protocols.is_empty() {
520        return Err(UriParseError::InvalidFormat {
521            message: "no protocol specified".to_string(),
522            span: None,
523        });
524    }
525
526    Ok((protocols, tls))
527}
528
529fn parse_endpoint(endpoint: &str) -> Result<EndpointSpec, UriParseError> {
530    if endpoint.is_empty() {
531        return Err(UriParseError::MissingHost);
532    }
533
534    // Handle bracketed IPv6: [::1]:8080
535    if endpoint.starts_with('[') {
536        let close_bracket = endpoint
537            .find(']')
538            .ok_or_else(|| UriParseError::InvalidFormat {
539                message: "unterminated IPv6 bracket".to_string(),
540                span: None,
541            })?;
542
543        let host = &endpoint[1..close_bracket];
544
545        let after_bracket = &endpoint[close_bracket + 1..];
546        if !after_bracket.starts_with(':') {
547            return Err(UriParseError::InvalidFormat {
548                message: "expected ':' after IPv6 bracket".to_string(),
549                span: None,
550            });
551        }
552
553        let port_str = &after_bracket[1..];
554        let port = parse_port(port_str)?;
555
556        return Ok(EndpointSpec {
557            host: host.to_string(),
558            port,
559        });
560    }
561
562    // Regular host:port
563    let colon_pos = endpoint
564        .rfind(':')
565        .ok_or_else(|| UriParseError::InvalidFormat {
566            message: "missing port".to_string(),
567            span: None,
568        })?;
569
570    let host = &endpoint[..colon_pos];
571    let port_str = &endpoint[colon_pos + 1..];
572
573    if host.contains(':') {
574        return Err(UriParseError::InvalidFormat {
575            message: format!(
576                "endpoint '{endpoint}' contains multiple ':' separators; \
577                 use [ipv6]:port form for IPv6 literals"
578            ),
579            span: None,
580        });
581    }
582
583    let port = parse_port(port_str)?;
584
585    Ok(EndpointSpec {
586        host: host.to_string(),
587        port,
588    })
589}
590
591fn parse_port(port_str: &str) -> Result<u16, UriParseError> {
592    if port_str.is_empty() {
593        return Err(UriParseError::InvalidPort("empty port".to_string()));
594    }
595
596    port_str
597        .parse::<u16>()
598        .map_err(|e| UriParseError::InvalidPort(format!("{}: {}", port_str, e)))
599}
600
601fn parse_credentials(
602    userinfo: &str,
603    protocols: &[ProtocolSpec],
604) -> Result<CredentialSpec, UriParseError> {
605    let Some(colon_pos) = userinfo.find(':') else {
606        if protocols.contains(&ProtocolSpec::Trojan) && !userinfo.is_empty() {
607            return Ok(CredentialSpec {
608                username: String::new(),
609                password: percent_decode(userinfo)?,
610            });
611        }
612
613        return Err(UriParseError::InvalidFormat {
614            message: "missing ':' in credentials".to_string(),
615            span: None,
616        });
617    };
618
619    let username = percent_decode(&userinfo[..colon_pos])?;
620    let password = percent_decode(&userinfo[colon_pos + 1..])?;
621
622    if username.is_empty() && password.is_empty() {
623        return Err(UriParseError::InvalidFormat {
624            message: "empty credentials".to_string(),
625            span: None,
626        });
627    }
628
629    Ok(CredentialSpec { username, password })
630}
631
632fn parse_query_rule(query: Option<&str>) -> Option<String> {
633    let query = query?;
634
635    // Look for rule=<value> parameter
636    for param in query.split('&') {
637        if let Some(eq_pos) = param.find('=') {
638            let key = &param[..eq_pos];
639            let value = &param[eq_pos + 1..];
640            if key == "rule" && !value.is_empty() {
641                return Some(value.to_string());
642            }
643        }
644    }
645
646    None
647}
648
649/// Percent-decode `input`, tolerating invalid `%` escapes as literals.
650///
651/// Invalid escapes (e.g. `%ZZ` or trailing `%`) are kept verbatim rather
652/// than rejected. This intentionally mirrors Python `pproxy`/`urllib.parse.unquote`
653/// behaviour so credential round-trips remain compatible; strict rejection
654/// would cause auth mismatches for passwords containing literal `%`.
655fn percent_decode(input: &str) -> Result<String, UriParseError> {
656    let mut result = Vec::with_capacity(input.len());
657    let bytes = input.as_bytes();
658    let mut i = 0;
659    while i < bytes.len() {
660        if bytes[i] == b'%' && i + 2 < bytes.len() {
661            let hi = hex_val(bytes[i + 1]);
662            let lo = hex_val(bytes[i + 2]);
663            if let (Some(h), Some(l)) = (hi, lo) {
664                result.push((h << 4) | l);
665                i += 3;
666                continue;
667            }
668        }
669        result.push(bytes[i]);
670        i += 1;
671    }
672    let decoded = String::from_utf8(result).map_err(|_| UriParseError::InvalidFormat {
673        message: "invalid UTF-8 in percent-encoded sequence".to_string(),
674        span: None,
675    })?;
676    if decoded.contains('\0') {
677        return Err(UriParseError::InvalidFormat {
678            message: "NUL byte in percent-decoded credentials".to_string(),
679            span: None,
680        });
681    }
682    Ok(decoded)
683}
684
685fn hex_val(b: u8) -> Option<u8> {
686    match b {
687        b'0'..=b'9' => Some(b - b'0'),
688        b'a'..=b'f' => Some(b - b'a' + 10),
689        b'A'..=b'F' => Some(b - b'A' + 10),
690        _ => None,
691    }
692}
693
694/// Find the position of the LAST `@` that's not inside brackets.
695/// The userinfo separator is the last unbracketed `@` after the
696/// scheme, not the first; a raw password containing `@` must not be
697/// truncated by the parser.
698fn find_at_outside_brackets(s: &str) -> Option<usize> {
699    let mut last_at: Option<usize> = None;
700    let mut bracket_depth = 0u32;
701    for (i, c) in s.char_indices() {
702        match c {
703            '[' => bracket_depth += 1,
704            ']' => bracket_depth = bracket_depth.saturating_sub(1),
705            '@' if bracket_depth == 0 => last_at = Some(i),
706            _ => {}
707        }
708    }
709    last_at
710}
711
712/// Find last '@' that's outside brackets and not part of a scheme.
713/// Returns the position of the last '@' after `://` that could be the
714/// bind separator. The caller must still check whether the part after
715/// the '@' looks like a bare address (no colon → bind) vs. a
716/// `user:pass` credential pair (contains colon).
717fn find_last_at_outside_scheme(s: &str) -> Option<usize> {
718    let scheme_end = s.find("://")?;
719    let after_scheme = &s[scheme_end + 3..];
720    // Find the LAST '@' in the part after ://, outside brackets
721    let mut last_at: Option<usize> = None;
722    let mut bracket_depth = 0u32;
723    for (i, c) in after_scheme.char_indices() {
724        match c {
725            '[' => bracket_depth += 1,
726            ']' => bracket_depth = bracket_depth.saturating_sub(1),
727            '@' if bracket_depth == 0 => {
728                last_at = Some(scheme_end + 3 + i);
729            }
730            _ => {}
731        }
732    }
733    last_at
734}
735
736fn add_hop_context(mut err: UriParseError, hop_index: usize) -> UriParseError {
737    if let UriParseError::InvalidFormat {
738        ref mut message, ..
739    } = err
740    {
741        *message = format!("hop {}: {}", hop_index, message);
742    }
743    err
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn test_parse_empty_uri() {
752        assert!(parse_proxy_chain("").is_err());
753    }
754
755    #[test]
756    fn test_protocol_spec_serialization() {
757        let spec = ProtocolSpec::Socks5;
758        let json = serde_json::to_string(&spec).unwrap();
759        assert_eq!(json, "\"Socks5\"");
760    }
761
762    #[test]
763    fn test_credential_serialization_redacts_password() {
764        let creds = CredentialSpec {
765            username: "alice".to_string(),
766            password: "hunter2".to_string(),
767        };
768        let json = serde_json::to_string(&creds).unwrap();
769        assert!(json.contains("alice"), "username should survive: {json}");
770        assert!(
771            !json.contains("hunter2"),
772            "plaintext password must never be serialized: {json}"
773        );
774        assert!(json.contains("****"), "password should be redacted: {json}");
775    }
776
777    #[test]
778    fn test_redact_proxy_uri_handles_ipv6_and_at_signs() {
779        assert_eq!(
780            redact_proxy_uri("http://user:p@ss@[::1]:8080"),
781            "http://****@[::1]:8080"
782        );
783    }
784
785    #[test]
786    fn test_simple_http() {
787        let result = parse_proxy_chain("http://host:8080").unwrap();
788        assert_eq!(result.hops.len(), 1);
789        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Http]);
790        assert_eq!(result.hops[0].endpoint.host, "host");
791        assert_eq!(result.hops[0].endpoint.port, 8080);
792        assert!(result.hops[0].credentials.is_none());
793    }
794
795    #[test]
796    fn test_simple_socks4() {
797        let result = parse_proxy_chain("socks4://host:1080").unwrap();
798        assert_eq!(result.hops.len(), 1);
799        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Socks4]);
800        assert_eq!(result.hops[0].endpoint.port, 1080);
801    }
802
803    #[test]
804    fn test_simple_socks5() {
805        let result = parse_proxy_chain("socks5://host:1080").unwrap();
806        assert_eq!(result.hops.len(), 1);
807        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Socks5]);
808    }
809
810    #[test]
811    fn test_multiple_protocols() {
812        let result = parse_proxy_chain("http+socks4+socks5://host:8080").unwrap();
813        assert_eq!(result.hops.len(), 1);
814        assert_eq!(
815            result.hops[0].protocols,
816            vec![
817                ProtocolSpec::Http,
818                ProtocolSpec::Socks4,
819                ProtocolSpec::Socks5
820            ]
821        );
822    }
823
824    #[test]
825    fn test_credentials() {
826        let result = parse_proxy_chain("http+socks5://user:pass@host:8080").unwrap();
827        assert_eq!(result.hops.len(), 1);
828        assert!(result.hops[0].credentials.is_some());
829        let creds = result.hops[0].credentials.as_ref().unwrap();
830        assert_eq!(creds.username, "user");
831        assert_eq!(creds.password, "pass");
832    }
833
834    #[test]
835    fn test_trojan_password_only_credentials() {
836        let result = parse_proxy_chain("trojan://secret@proxy.example:443").unwrap();
837        assert_eq!(result.hops.len(), 1);
838        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Trojan]);
839        let creds = result.hops[0].credentials.as_ref().unwrap();
840        assert_eq!(creds.username, "");
841        assert_eq!(creds.password, "secret");
842    }
843
844    #[test]
845    fn test_password_only_credentials_rejected_for_non_trojan() {
846        let err = parse_proxy_chain("http://secret@proxy.example:8080").unwrap_err();
847        assert!(matches!(err, UriParseError::InvalidFormat { .. }));
848    }
849
850    #[test]
851    fn test_percent_decoded_at_in_password() {
852        let result = parse_proxy_chain("http://user:p%40ss@proxy:8080").unwrap();
853        assert_eq!(result.hops.len(), 1);
854        let creds = result.hops[0].credentials.as_ref().unwrap();
855        assert_eq!(creds.username, "user");
856        assert_eq!(creds.password, "p@ss");
857    }
858
859    #[test]
860    fn test_percent_decoded_colon_in_password() {
861        let result = parse_proxy_chain("http://user:pass%3Aword@proxy:8080").unwrap();
862        assert_eq!(result.hops.len(), 1);
863        let creds = result.hops[0].credentials.as_ref().unwrap();
864        assert_eq!(creds.username, "user");
865        assert_eq!(creds.password, "pass:word");
866    }
867
868    #[test]
869    fn test_percent_decoded_at_in_username() {
870        let result = parse_proxy_chain("http://user%40name:pass@proxy:8080").unwrap();
871        assert_eq!(result.hops.len(), 1);
872        let creds = result.hops[0].credentials.as_ref().unwrap();
873        assert_eq!(creds.username, "user@name");
874        assert_eq!(creds.password, "pass");
875    }
876
877    #[test]
878    fn test_percent_decoded_utf8_credentials() {
879        let result = parse_proxy_chain("http://user:p%C3%A9ss@proxy:8080").unwrap();
880        let creds = result.hops[0].credentials.as_ref().unwrap();
881        assert_eq!(creds.password, "péss");
882    }
883
884    #[test]
885    fn test_named_host() {
886        let result = parse_proxy_chain("socks5://proxy.example:1080").unwrap();
887        assert_eq!(result.hops.len(), 1);
888        assert_eq!(result.hops[0].endpoint.host, "proxy.example");
889        assert_eq!(result.hops[0].endpoint.port, 1080);
890    }
891
892    #[test]
893    fn test_ssh_defaults_to_port_22() {
894        let result = parse_proxy_chain("ssh://user:pass@proxy.example").unwrap();
895        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Ssh]);
896        assert_eq!(result.hops[0].endpoint.host, "proxy.example");
897        assert_eq!(result.hops[0].endpoint.port, 22);
898    }
899
900    #[test]
901    fn test_two_hops() {
902        let result = parse_proxy_chain("socks5://hop1:1080__http://hop2:8080").unwrap();
903        assert_eq!(result.hops.len(), 2);
904        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Socks5]);
905        assert_eq!(result.hops[0].endpoint.host, "hop1");
906        assert_eq!(result.hops[0].endpoint.port, 1080);
907        assert_eq!(result.hops[1].protocols, vec![ProtocolSpec::Http]);
908        assert_eq!(result.hops[1].endpoint.host, "hop2");
909        assert_eq!(result.hops[1].endpoint.port, 8080);
910    }
911
912    #[test]
913    fn test_triple_hop_separator_is_rejected() {
914        assert!(matches!(
915            parse_proxy_chain("socks5://a:1080___http://b:8080"),
916            Err(UriParseError::DuplicateHopSeparator)
917        ));
918    }
919
920    #[test]
921    fn test_ipv6_bracketed() {
922        let result = parse_proxy_chain("http://[::1]:8080").unwrap();
923        assert_eq!(result.hops.len(), 1);
924        assert_eq!(result.hops[0].endpoint.host, "::1");
925        assert_eq!(result.hops[0].endpoint.port, 8080);
926    }
927
928    #[test]
929    fn test_ipv6_full() {
930        let result = parse_proxy_chain("http://[2001:db8::1]:1080").unwrap();
931        assert_eq!(result.hops.len(), 1);
932        assert_eq!(result.hops[0].endpoint.host, "2001:db8::1");
933        assert_eq!(result.hops[0].endpoint.port, 1080);
934    }
935
936    #[test]
937    fn test_unsupported_protocol() {
938        let result = parse_proxy_chain("ftp://host:80");
939        assert!(result.is_err());
940        match result {
941            Err(UriParseError::UnsupportedProtocol(p)) => assert_eq!(p, "ftp"),
942            _ => panic!("expected UnsupportedProtocol error"),
943        }
944    }
945
946    #[test]
947    fn test_quic_scheme_is_accepted() {
948        let result = parse_proxy_chain("quic+http://host:443").unwrap();
949        assert_eq!(
950            result.hops[0].protocols,
951            vec![ProtocolSpec::Quic, ProtocolSpec::Http]
952        );
953    }
954
955    #[test]
956    fn test_h3_scheme_is_accepted() {
957        let result = parse_proxy_chain("h3://host:443").unwrap();
958        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Http3]);
959    }
960
961    #[test]
962    fn test_missing_scheme() {
963        let result = parse_proxy_chain("host:80");
964        assert!(result.is_err());
965    }
966
967    #[test]
968    fn test_empty_host_with_port_is_rejected() {
969        let result = parse_proxy_chain("http://:80");
970        assert!(matches!(result, Err(UriParseError::EmptyHost)));
971    }
972
973    #[test]
974    fn test_invalid_port() {
975        let result = parse_proxy_chain("http://host:99999");
976        assert!(result.is_err());
977    }
978
979    #[test]
980    fn test_port_zero() {
981        let result = parse_proxy_chain("http://host:0");
982        assert!(result.is_err());
983    }
984
985    #[test]
986    fn test_query_rule() {
987        let result = parse_proxy_chain("http://host:80?rule=regex").unwrap();
988        assert_eq!(result.hops[0].rule.as_deref(), Some("regex"));
989    }
990
991    #[test]
992    fn test_query_no_rule() {
993        let result = parse_proxy_chain("http://host:80?foo=bar").unwrap();
994        assert!(result.hops[0].rule.is_none());
995    }
996
997    #[test]
998    fn test_redacted_display() {
999        let spec = ProxyChainSpec {
1000            hops: vec![ProxyHopSpec {
1001                protocols: vec![ProtocolSpec::Http],
1002                endpoint: EndpointSpec {
1003                    host: "proxy.example".to_string(),
1004                    port: 8080,
1005                },
1006                credentials: Some(CredentialSpec {
1007                    username: "user".to_string(),
1008                    password: "secret".to_string(),
1009                }),
1010                rule: None,
1011                local_bind: None,
1012                tls: false,
1013                server_name: None,
1014                insecure: false,
1015                plugins: Vec::new(),
1016                auth_prefix: None,
1017            }],
1018        };
1019        let redacted = RedactedUri::new(&spec);
1020        let display = format!("{}", redacted);
1021        assert!(display.contains("****:****@"));
1022        assert!(!display.contains("secret"));
1023    }
1024
1025    #[test]
1026    fn test_redacted_display_no_creds() {
1027        let spec = ProxyChainSpec {
1028            hops: vec![ProxyHopSpec {
1029                protocols: vec![ProtocolSpec::Socks5],
1030                endpoint: EndpointSpec {
1031                    host: "proxy.example".to_string(),
1032                    port: 1080,
1033                },
1034                credentials: None,
1035                rule: None,
1036                local_bind: None,
1037                tls: false,
1038                server_name: None,
1039                insecure: false,
1040                plugins: Vec::new(),
1041                auth_prefix: None,
1042            }],
1043        };
1044        let redacted = RedactedUri::new(&spec);
1045        let display = format!("{}", redacted);
1046        assert_eq!(display, "socks5://proxy.example:1080");
1047    }
1048
1049    #[test]
1050    fn test_roundtrip_simple() {
1051        let original = "http://proxy.example:8080";
1052        let spec = parse_proxy_chain(original).unwrap();
1053        let redacted = RedactedUri::new(&spec).to_string();
1054        assert_eq!(redacted, original);
1055    }
1056
1057    #[test]
1058    fn test_roundtrip_multi_hop() {
1059        let original = "socks5://hop1:1080__http://hop2:8080";
1060        let spec = parse_proxy_chain(original).unwrap();
1061        let redacted = RedactedUri::new(&spec).to_string();
1062        assert_eq!(redacted, original);
1063    }
1064
1065    #[test]
1066    fn test_roundtrip_multi_protocol() {
1067        let original = "http+socks5://proxy:8080";
1068        let spec = parse_proxy_chain(original).unwrap();
1069        let redacted = RedactedUri::new(&spec).to_string();
1070        assert_eq!(redacted, original);
1071    }
1072
1073    #[test]
1074    fn test_roundtrip_ipv6() {
1075        let original = "http://[::1]:8080";
1076        let spec = parse_proxy_chain(original).unwrap();
1077        let redacted = RedactedUri::new(&spec).to_string();
1078        assert_eq!(redacted, original);
1079    }
1080
1081    #[test]
1082    fn test_roundtrip_with_rule() {
1083        let original = "http://proxy:8080?rule=regex";
1084        let spec = parse_proxy_chain(original).unwrap();
1085        let redacted = RedactedUri::new(&spec).to_string();
1086        assert_eq!(redacted, original);
1087    }
1088
1089    #[test]
1090    fn test_complex_multi_hop_with_creds() {
1091        let original = "socks5://hop1:1080__http://user:pass@hop2:8080";
1092        let spec = parse_proxy_chain(original).unwrap();
1093        assert_eq!(spec.hops.len(), 2);
1094        assert!(spec.hops[1].credentials.is_some());
1095    }
1096
1097    #[test]
1098    fn test_unterminated_bracket() {
1099        let result = parse_proxy_chain("http://[::1:8080");
1100        assert!(result.is_err());
1101    }
1102
1103    #[test]
1104    fn test_mismatched_brackets_are_rejected() {
1105        // A stray ']' must not silently shift the hop-splitting grammar.
1106        assert!(parse_proxy_chain("]__http://host:80").is_err());
1107        assert!(parse_proxy_chain("http://]host:80").is_err());
1108    }
1109
1110    #[test]
1111    fn test_credential_debug_is_redacted() {
1112        let creds = CredentialSpec {
1113            username: "alice".to_string(),
1114            password: "s3cret".to_string(),
1115        };
1116        let rendered = format!("{:?}", creds);
1117        assert!(rendered.contains("alice"));
1118        assert!(
1119            !rendered.contains("s3cret"),
1120            "debug leaked password: {rendered}"
1121        );
1122    }
1123
1124    #[test]
1125    fn test_shadowsocks_scheme() {
1126        let result =
1127            parse_proxy_chain("shadowsocks://aes-256-gcm:secret@proxy.example:8388").unwrap();
1128        assert_eq!(result.hops.len(), 1);
1129        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Shadowsocks]);
1130        assert_eq!(result.hops[0].endpoint.host, "proxy.example");
1131        assert_eq!(result.hops[0].endpoint.port, 8388);
1132        let creds = result.hops[0].credentials.as_ref().unwrap();
1133        assert_eq!(creds.username, "aes-256-gcm");
1134        assert_eq!(creds.password, "secret");
1135    }
1136
1137    #[test]
1138    fn test_shadowsocks_ss_scheme() {
1139        let result = parse_proxy_chain("ss://aes-128-gcm:pass@host:1080").unwrap();
1140        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Shadowsocks]);
1141    }
1142
1143    #[test]
1144    fn test_shadowsocks_roundtrip() {
1145        let original = "shadowsocks://aes-256-gcm:secret@proxy.example:8388";
1146        let spec = parse_proxy_chain(original).unwrap();
1147        assert_eq!(spec.hops.len(), 1);
1148        assert_eq!(spec.hops[0].protocols, vec![ProtocolSpec::Shadowsocks]);
1149        let redacted = RedactedUri::new(&spec).to_string();
1150        assert!(redacted.starts_with("shadowsocks://"));
1151        assert!(redacted.contains("****:****@"));
1152        assert!(redacted.contains("proxy.example:8388"));
1153    }
1154
1155    #[test]
1156    fn test_tls_suffix_parses_to_tls_flag() {
1157        let result = parse_proxy_chain("socks5+tls://proxy.example:1080").unwrap();
1158        assert_eq!(result.hops.len(), 1);
1159        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Socks5]);
1160        assert!(result.hops[0].tls);
1161        assert_eq!(result.hops[0].endpoint.host, "proxy.example");
1162        assert_eq!(result.hops[0].endpoint.port, 1080);
1163    }
1164
1165    #[test]
1166    fn test_tls_only_protocol_with_other() {
1167        let result = parse_proxy_chain("http+tls://proxy.example:443").unwrap();
1168        assert_eq!(result.hops.len(), 1);
1169        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Http]);
1170        assert!(result.hops[0].tls);
1171    }
1172
1173    #[test]
1174    fn test_tls_suffix_roundtrip() {
1175        let original = "socks5+tls://proxy.example:1080";
1176        let spec = parse_proxy_chain(original).unwrap();
1177        let redacted = RedactedUri::new(&spec).to_string();
1178        assert_eq!(redacted, original);
1179    }
1180
1181    #[test]
1182    fn test_socks4a_scheme() {
1183        let result = parse_proxy_chain("socks4a://host:1080").unwrap();
1184        assert_eq!(result.hops[0].protocols, vec![ProtocolSpec::Socks4]);
1185    }
1186
1187    #[test]
1188    fn test_password_containing_at_sign() {
1189        // Regression: a raw '@' inside the password must not be treated
1190        // as the userinfo/host separator. The userinfo separator is the
1191        // LAST unbracketed '@' after the scheme.
1192        let result = parse_proxy_chain("http://admin:s3cret_p@ssw0rd@proxy:8080").unwrap();
1193        let creds = result.hops[0].credentials.as_ref().unwrap();
1194        assert_eq!(creds.username, "admin");
1195        assert_eq!(creds.password, "s3cret_p@ssw0rd");
1196        assert_eq!(result.hops[0].endpoint.host, "proxy");
1197        assert_eq!(result.hops[0].endpoint.port, 8080);
1198    }
1199
1200    #[test]
1201    fn test_password_containing_at_sign_redacted() {
1202        // Regression: the redacted display must not leak any part of a
1203        // password that contains '@'.
1204        let result = parse_proxy_chain("http://admin:s3cret_p@ssw0rd@proxy:8080").unwrap();
1205        let redacted = RedactedUri::new(&result).to_string();
1206        assert_eq!(redacted, "http://****:****@proxy:8080");
1207        assert!(!redacted.contains("s3cret_p"));
1208        assert!(!redacted.contains("ssw0rd"));
1209    }
1210
1211    #[test]
1212    fn test_password_containing_at_sign_ipv6_endpoint() {
1213        // Regression: bracketed IPv6 must still allow '@' inside the
1214        // userinfo without being confused for an endpoint '@'.
1215        let result = parse_proxy_chain("http://user:p@ss@[::1]:8080").unwrap();
1216        let creds = result.hops[0].credentials.as_ref().unwrap();
1217        assert_eq!(creds.username, "user");
1218        assert_eq!(creds.password, "p@ss");
1219        assert_eq!(result.hops[0].endpoint.host, "::1");
1220        assert_eq!(result.hops[0].endpoint.port, 8080);
1221    }
1222}
1223
1224#[cfg(test)]
1225mod proptest_tests {
1226    use super::*;
1227    use proptest::prelude::*;
1228
1229    fn arb_protocol() -> impl Strategy<Value = ProtocolSpec> {
1230        prop_oneof![
1231            Just(ProtocolSpec::Http),
1232            Just(ProtocolSpec::Socks4),
1233            Just(ProtocolSpec::Socks5),
1234            Just(ProtocolSpec::Shadowsocks),
1235            Just(ProtocolSpec::Trojan),
1236            Just(ProtocolSpec::Http2),
1237            Just(ProtocolSpec::WebSocket),
1238            Just(ProtocolSpec::Raw),
1239        ]
1240    }
1241
1242    fn arb_host() -> impl Strategy<Value = String> {
1243        prop_oneof![
1244            // Regular hostname
1245            "[a-z][a-z0-9]{0,15}".prop_map(|s| format!("host-{}", s)),
1246            // Simple IP-like
1247            "[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}",
1248        ]
1249    }
1250
1251    fn arb_port() -> impl Strategy<Value = u16> {
1252        (1u16..65535).boxed()
1253    }
1254
1255    fn arb_hop() -> impl Strategy<Value = ProxyHopSpec> {
1256        (
1257            prop::collection::vec(arb_protocol(), 1..4),
1258            arb_host(),
1259            arb_port(),
1260            prop::option::of("[a-z]{1,10}".prop_map(|s| (s.clone(), s))),
1261            prop::option::of("[a-z]{1,10}"),
1262        )
1263            .prop_map(|(protocols, host, port, credentials, rule)| ProxyHopSpec {
1264                protocols,
1265                endpoint: EndpointSpec { host, port },
1266                credentials: credentials.map(|(u, p)| CredentialSpec {
1267                    username: u,
1268                    password: p,
1269                }),
1270                rule,
1271                local_bind: None,
1272                tls: false,
1273                server_name: None,
1274                insecure: false,
1275                plugins: Vec::new(),
1276                auth_prefix: None,
1277            })
1278    }
1279
1280    fn arb_chain() -> impl Strategy<Value = ProxyChainSpec> {
1281        prop::collection::vec(arb_hop(), 1..3).prop_map(|hops| ProxyChainSpec { hops })
1282    }
1283
1284    proptest! {
1285        #[test]
1286        fn test_parse_never_panics(input in ".*{0,100}") {
1287            let _ = parse_proxy_chain(&input);
1288        }
1289
1290        #[test]
1291        fn test_valid_chain_roundtrips(spec in arb_chain()) {
1292            let display = RedactedUri::new(&spec).to_string();
1293            let parsed = parse_proxy_chain(&display);
1294            prop_assert!(parsed.is_ok(), "Failed to parse: {}", display);
1295        }
1296
1297        #[test]
1298        fn test_hop_separator_split(port in 1u16..65535u16) {
1299            let input = format!("http://a:{}__http://b:{}", port, port);
1300            let result = parse_proxy_chain(&input);
1301            prop_assert!(result.is_ok(), "Failed to parse: {}", input);
1302        }
1303
1304        #[test]
1305        fn test_protocol_separator(port in 1u16..65535u16) {
1306            let input = format!("http+socks5://a:{}", port);
1307            let result = parse_proxy_chain(&input);
1308            prop_assert!(result.is_ok(), "Failed to parse: {}", input);
1309        }
1310    }
1311}