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