Skip to main content

eggress_pproxy_compat/
uri.rs

1use crate::error::CompatError;
2
3/// Parsed pproxy-style URI.
4#[derive(Debug, Clone)]
5pub struct PproxyUri {
6    /// Protocol scheme (e.g. "socks5", "http", "socks4", "trojan", "bind", "listen", "backward").
7    pub scheme: String,
8    /// Optional username.
9    pub username: Option<String>,
10    /// Optional password.
11    pub password: Option<String>,
12    /// Host (empty string means bind to all interfaces).
13    pub host: String,
14    /// Port number.
15    pub port: u16,
16    /// Whether TLS is requested (+tls suffix).
17    pub tls: bool,
18    /// Whether SSL modifier was used (+ssl suffix, treated as unsupported variant of +tls).
19    pub ssl: bool,
20    /// Whether this is a reverse/inbound URI (+in suffix).
21    pub inbound: bool,
22    /// Count of `+in` tokens parsed from the scheme (backward connection count).
23    pub backward_num: u32,
24    /// Optional rule parameter from query string.
25    pub rule: Option<String>,
26    /// Optional rules_file parameter from query string (pproxy URI-attached rule file).
27    pub rules_file: Option<String>,
28    /// Canonical pproxy rule suffix when the query is not `rule=`/`rules_file=`.
29    pub rule_suffix: Option<String>,
30    /// Optional path (used for unix:// scheme).
31    pub path: Option<String>,
32    /// Protocol tokens in the original `scheme`, excluding transport modifiers.
33    pub protocol_chain: Vec<String>,
34    /// Non-protocol scheme modifiers, in source order (`tls`, `ssl`, `in`, ...).
35    pub transport_modifiers: Vec<String>,
36    /// pproxy's optional outbound source binding (`/@localbind`).
37    pub local_bind: Option<String>,
38    /// Fixed destination used by tunnel-style protocols.
39    pub fixed_target: Option<String>,
40    /// Comma-delimited plugin metadata, retained in source order.
41    pub plugins: Vec<PproxyPluginSpec>,
42    /// Fragment authentication, kept separately from URL userinfo.
43    pub auth_fragment: Option<String>,
44    /// The raw URI, retained for diagnostics.
45    pub raw: String,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PproxyPluginSpec {
50    pub name: String,
51    pub options: Option<String>,
52}
53
54impl PproxyUri {
55    /// Returns true if this is a reverse proxy listener URI (bind/listen/backward/rebind scheme).
56    pub fn is_reverse_listener(&self) -> bool {
57        matches!(
58            self.scheme.as_str(),
59            "bind" | "listen" | "backward" | "rebind"
60        )
61    }
62
63    /// Returns true if this is a backward/upstream URI with the `+in` modifier
64    /// (e.g., `socks5+in://...`).
65    pub fn is_backward(&self) -> bool {
66        self.inbound
67    }
68
69    /// Returns the number of `+in` tokens parsed from the scheme (the backward
70    /// connection count). A single `+in` yields 1; multiple `+in+in` yields 2, etc.
71    /// Returns 0 if no `+in` modifier is present.
72    pub fn backward_num(&self) -> u32 {
73        self.backward_num
74    }
75
76    /// Redacted display — credentials shown as `****:****`, Unix paths shown as `unix://****`.
77    pub fn redacted_display(&self) -> String {
78        if self.scheme == "unix" {
79            if let Some(ref p) = self.path {
80                let redacted_path = redact_unix_path(p);
81                return format!("unix://{}", redacted_path);
82            }
83            return "unix://****".to_string();
84        }
85
86        let cred_str = if self.username.is_some() {
87            "****:****@"
88        } else {
89            ""
90        };
91        let rule_str = match &self.rule {
92            Some(r) => format!("?rule={}", r),
93            None => String::new(),
94        };
95        let rules_file_str = match &self.rules_file {
96            Some(rf) => format!("?rules_file={}", rf),
97            None => String::new(),
98        };
99        let suffix = self
100            .rule_suffix
101            .as_deref()
102            .map(|r| format!("?{r}"))
103            .unwrap_or_default();
104        let bind = self
105            .local_bind
106            .as_deref()
107            .map(|b| format!("/@{}", b))
108            .unwrap_or_default();
109        let plugins = if self.plugins.is_empty() {
110            String::new()
111        } else {
112            format!(
113                ",{}",
114                self.plugins
115                    .iter()
116                    .map(|p| p.name.as_str())
117                    .collect::<Vec<_>>()
118                    .join(",")
119            )
120        };
121        let target = self
122            .fixed_target
123            .as_deref()
124            .map(|t| format!("{{{t}}}"))
125            .unwrap_or_else(|| self.endpoint_display());
126        format!(
127            "{}://{}{}{}{}{}{}{}",
128            self.scheme_with_tls(),
129            cred_str,
130            target,
131            rule_str,
132            rules_file_str,
133            suffix,
134            bind,
135            plugins,
136        )
137    }
138
139    pub(crate) fn scheme_with_tls(&self) -> String {
140        let mut parts = if self.protocol_chain.is_empty() {
141            vec![self.scheme.clone()]
142        } else {
143            self.protocol_chain.clone()
144        };
145        // `wss` already carries the TLS scheme semantics. Keep its stable
146        // redacted display as `wss://...` while retaining `self.tls` for
147        // translation and listener transport setup.
148        if self.tls && !parts.iter().any(|p| p == "tls") && !parts.iter().any(|p| p == "wss") {
149            parts.push("tls".to_string());
150        }
151        if self.ssl && !parts.iter().any(|p| p == "ssl") {
152            parts.push("ssl".to_string());
153        }
154        if self.inbound {
155            for _ in 0..self.backward_num.max(1) {
156                parts.push("in".to_string());
157            }
158        }
159        parts.join("+")
160    }
161
162    pub(crate) fn endpoint_display(&self) -> String {
163        format!("{}:{}", format_host_for_uri(&self.host), self.port)
164    }
165
166    pub(crate) fn bind_display(&self) -> String {
167        if self.host.is_empty() {
168            format!("0.0.0.0:{}", self.port)
169        } else {
170            self.endpoint_display()
171        }
172    }
173}
174
175/// Redact a Unix socket path for display, preserving only the filename.
176fn redact_unix_path(path: &str) -> String {
177    match path.rfind('/') {
178        Some(pos) => {
179            let dir = &path[..=pos];
180            format!("{}****", dir)
181        }
182        None => "****".to_string(),
183    }
184}
185
186fn format_host_for_uri(host: &str) -> String {
187    if host.is_empty() {
188        String::new()
189    } else if host.contains(':') {
190        format!("[{}]", host)
191    } else {
192        host.to_string()
193    }
194}
195
196/// Parse a single pproxy-style URI into our typed representation.
197///
198/// Supports:
199/// - `scheme://host:port`
200/// - `scheme://user:pass@host:port`
201/// - `scheme+tls://host:port`
202/// - `scheme://host:port?rule=regex`
203/// - `unix:///path/to/socket`
204/// - `redir://:12345`
205/// - `redir://127.0.0.1:12345`
206pub fn parse_pproxy_uri(uri: &str) -> Result<PproxyUri, CompatError> {
207    // The Python compatibility helpers historically accepted a listener URI
208    // followed by a legacy `;` or `__` remote suffix. The typed single-URI
209    // parser describes the listener portion; callers that need every hop use
210    // `parse_pproxy_chain`.
211    let parse_uri = if uri.contains("__") {
212        split_chain_hops(uri)?.into_iter().next().unwrap_or(uri)
213    } else {
214        uri.split_once(';').map_or(uri, |(head, _)| head)
215    };
216    let (without_fragment, auth_fragment) = split_top_level(parse_uri, '#');
217    let (before_query, query) = split_top_level(without_fragment, '?');
218
219    // Extract scheme
220    let (scheme_part, after_scheme) = if let Some(colon_pos) = before_query.find("://") {
221        let scheme = &before_query[..colon_pos];
222        let rest = &before_query[colon_pos + 3..];
223        (scheme.to_string(), rest)
224    } else {
225        return Err(CompatError::InvalidUri {
226            message: format!("missing scheme in URI: {}", uri),
227        });
228    };
229
230    // Parse protocol tokens and transport modifiers. Keeping both lists avoids
231    // treating a combined listener as one protocol during translation.
232    let mut tls = false;
233    let mut ssl = false;
234    let mut inbound = false;
235    let mut backward_num: u32 = 0;
236    let mut protocol_chain = Vec::new();
237    let mut transport_modifiers = Vec::new();
238    let mut fixed_target = None;
239    for token in split_scheme_tokens(&scheme_part)? {
240        let (token, token_target) = parse_protocol_token(token)?;
241        if let Some(target) = token_target {
242            if !matches!(token, "tunnel" | "raw" | "ws" | "wss" | "h2") {
243                return Err(CompatError::InvalidUri {
244                    message: format!("fixed target is not supported on '{token}'"),
245                });
246            }
247            if fixed_target.replace(target).is_some() {
248                return Err(CompatError::InvalidUri {
249                    message: "URI contains more than one fixed-target protocol token".to_string(),
250                });
251            }
252        }
253        match token {
254            "tls" => {
255                tls = true;
256                transport_modifiers.push(token.to_string());
257            }
258            "ssl" | "secure" => {
259                ssl = true;
260                tls = true;
261                transport_modifiers.push(token.to_string());
262            }
263            "in" => {
264                inbound = true;
265                backward_num += 1;
266                transport_modifiers.push(token.to_string());
267            }
268            "" => {
269                return Err(CompatError::InvalidUri {
270                    message: "empty protocol or modifier in scheme".to_string(),
271                })
272            }
273            token => protocol_chain.push(token.to_string()),
274        }
275    }
276    if protocol_chain.is_empty() {
277        return Err(CompatError::InvalidUri {
278            message: "URI scheme has no protocol".to_string(),
279        });
280    }
281    let scheme = protocol_chain.join("+");
282
283    if protocol_chain.iter().any(|token| token == "wss") {
284        tls = true;
285    }
286
287    // Validate known schemes
288    for protocol in &protocol_chain {
289        match protocol.as_str() {
290            "http" | "https" | "socks4" | "socks4a" | "socks5" | "trojan" | "ss"
291            | "shadowsocks" | "ssr" | "direct" | "ssh" | "unix" | "redir" | "h2" | "ws" | "wss"
292            | "raw" | "tunnel" | "bind" | "listen" | "backward" | "rebind" | "httponly"
293            | "echo" | "quic" | "h3" => {}
294            other => {
295                return Err(CompatError::UnsupportedProtocol(other.to_string()));
296            }
297        }
298    }
299
300    // Handle unix:// scheme — path-based, not host:port
301    if scheme == "unix" {
302        let path = if after_scheme.starts_with('/') {
303            after_scheme.to_string()
304        } else if after_scheme.is_empty() {
305            return Err(CompatError::InvalidUri {
306                message: "unix:// URI requires a path (e.g. unix:///tmp/socket)".to_string(),
307            });
308        } else {
309            // Treat bare content as a relative path
310            format!("/{}", after_scheme)
311        };
312        let (rule, rules_file, rule_suffix) = query
313            .map(extract_query_params)
314            .unwrap_or((None, None, None));
315        return Ok(PproxyUri {
316            scheme,
317            username: None,
318            password: None,
319            host: String::new(),
320            port: 0,
321            tls,
322            ssl,
323            inbound,
324            backward_num,
325            rule,
326            rules_file,
327            rule_suffix,
328            path: Some(path),
329            protocol_chain,
330            transport_modifiers,
331            local_bind: None,
332            fixed_target: None,
333            plugins: Vec::new(),
334            auth_fragment: auth_fragment.map(str::to_string),
335            raw: uri.to_string(),
336        });
337    }
338
339    let (endpoint_part, path_part) = split_top_level(after_scheme, '/');
340    let (credentials, endpoint_str) =
341        if let Some(at_pos) = find_last_at_outside_brackets(endpoint_part) {
342            let (user, pass) = parse_userinfo(&endpoint_part[..at_pos])?;
343            (Some((user, pass)), &endpoint_part[at_pos + 1..])
344        } else {
345            (None, endpoint_part)
346        };
347    let endpoint_fixed_target = if endpoint_str.starts_with('{') {
348        if !endpoint_str.ends_with('}') || endpoint_str.len() <= 2 {
349            return Err(CompatError::InvalidUri {
350                message: "fixed target braces are malformed or empty".to_string(),
351            });
352        }
353        Some(endpoint_str[1..endpoint_str.len() - 1].to_string())
354    } else if endpoint_str.contains('{') || endpoint_str.contains('}') {
355        return Err(CompatError::InvalidUri {
356            message: "fixed target braces are malformed".to_string(),
357        });
358    } else {
359        None
360    };
361    if let Some(ref target) = endpoint_fixed_target {
362        if fixed_target.is_some() {
363            return Err(CompatError::InvalidUri {
364                message: "fixed target is specified both in the protocol and endpoint".to_string(),
365            });
366        }
367        fixed_target = Some(target.clone());
368    }
369    if let Some(target) = fixed_target.as_deref() {
370        let (target_host, _, target_port_specified) = parse_endpoint(target)?;
371        if target_host.is_empty() || !target_port_specified {
372            return Err(CompatError::InvalidUri {
373                message: "fixed target must contain a non-empty host and port".to_string(),
374            });
375        }
376    }
377    // A canonical token target and the URI endpoint have independent roles:
378    // `tunnel{target}://listener` and `ws{target}://listener` bind the
379    // listener endpoint while fixing the relay destination. The legacy
380    // `raw://{target}` form uses the endpoint extension itself as the target.
381    let endpoint_for_parse = endpoint_fixed_target.as_deref().unwrap_or(endpoint_str);
382    let (host, mut port, port_specified) = parse_endpoint(endpoint_for_parse)?;
383    if !port_specified && !host.is_empty() {
384        if let Some(default) = default_port_for_scheme(&scheme) {
385            port = default;
386        }
387    }
388
389    let (local_bind, plugins) = parse_path_metadata(path_part);
390    for plugin in &plugins {
391        if !matches!(
392            plugin.name.as_str(),
393            "plain"
394                | "origin"
395                | "http_simple"
396                | "tls1.2_ticket_auth"
397                | "verify_simple"
398                | "verify_deflate"
399        ) {
400            return Err(CompatError::InvalidUri {
401                message: format!(
402                    "unknown pproxy plugin '{}'; existing plugins: plain, origin, http_simple, tls1.2_ticket_auth, verify_simple, verify_deflate",
403                    plugin.name
404                ),
405            });
406        }
407    }
408    let (rule, rules_file, rule_suffix) = query
409        .map(extract_query_params)
410        .unwrap_or((None, None, None));
411    let (fragment_user, fragment_password) = auth_fragment
412        .filter(|a| !a.is_empty())
413        .map(parse_userinfo)
414        .transpose()?
415        .map_or((None, None), |(u, p)| (Some(u), Some(p)));
416    let credentials = credentials.or_else(|| fragment_user.zip(fragment_password));
417
418    Ok(PproxyUri {
419        scheme,
420        username: credentials.as_ref().map(|c| c.0.clone()),
421        password: credentials.as_ref().map(|c| c.1.clone()),
422        host,
423        port,
424        tls,
425        ssl,
426        inbound,
427        backward_num,
428        rule,
429        rules_file,
430        path: None,
431        rule_suffix,
432        protocol_chain,
433        transport_modifiers,
434        local_bind,
435        fixed_target,
436        plugins,
437        auth_fragment: auth_fragment.map(str::to_string),
438        raw: uri.to_string(),
439    })
440}
441
442fn split_top_level(input: &str, delimiter: char) -> (&str, Option<&str>) {
443    let mut bracket = 0u32;
444    let mut brace = 0u32;
445    for (idx, ch) in input.char_indices() {
446        match ch {
447            '[' => bracket += 1,
448            ']' => bracket = bracket.saturating_sub(1),
449            '{' => brace += 1,
450            '}' => brace = brace.saturating_sub(1),
451            _ => {}
452        }
453        if ch == delimiter && bracket == 0 && brace == 0 {
454            return (&input[..idx], Some(&input[idx + 1..]));
455        }
456    }
457    (input, None)
458}
459
460/// Split a protocol expression without treating a brace-delimited tunnel
461/// target as a protocol separator.
462fn split_scheme_tokens(scheme: &str) -> Result<Vec<&str>, CompatError> {
463    let mut tokens = Vec::new();
464    let mut start = 0;
465    let mut brace_depth = 0u32;
466    for (idx, ch) in scheme.char_indices() {
467        match ch {
468            '{' => {
469                if brace_depth != 0 {
470                    return Err(CompatError::InvalidUri {
471                        message: "nested fixed-target braces are not valid".to_string(),
472                    });
473                }
474                brace_depth = 1;
475            }
476            '}' => {
477                if brace_depth == 0 {
478                    return Err(CompatError::InvalidUri {
479                        message: "unmatched fixed-target brace".to_string(),
480                    });
481                }
482                brace_depth = 0;
483            }
484            '+' if brace_depth == 0 => {
485                tokens.push(&scheme[start..idx]);
486                start = idx + 1;
487            }
488            _ => {}
489        }
490    }
491    if brace_depth != 0 {
492        return Err(CompatError::InvalidUri {
493            message: "unterminated fixed-target brace".to_string(),
494        });
495    }
496    tokens.push(&scheme[start..]);
497    Ok(tokens)
498}
499
500fn parse_protocol_token(token: &str) -> Result<(&str, Option<String>), CompatError> {
501    let Some(open) = token.find('{') else {
502        if token.contains('}') {
503            return Err(CompatError::InvalidUri {
504                message: "unmatched fixed-target brace".to_string(),
505            });
506        }
507        return Ok((token, None));
508    };
509    if open == 0 || !token.ends_with('}') {
510        return Err(CompatError::InvalidUri {
511            message: "malformed fixed-target protocol token".to_string(),
512        });
513    }
514    let target = &token[open + 1..token.len() - 1];
515    if target.is_empty() || target.contains('{') || target.contains('}') {
516        return Err(CompatError::InvalidUri {
517            message: "fixed-target protocol token has an empty or malformed target".to_string(),
518        });
519    }
520    Ok((&token[..open], Some(target.to_string())))
521}
522
523fn parse_path_metadata(path: Option<&str>) -> (Option<String>, Vec<PproxyPluginSpec>) {
524    let Some(path) = path else {
525        return (None, Vec::new());
526    };
527    let (bind, plugin_text) = if let Some(rest) = path.strip_prefix("@") {
528        (Some(rest), None)
529    } else if let Some(pos) = path.find("/@") {
530        (Some(&path[pos + 2..]), None)
531    } else {
532        (None, Some(path.trim_start_matches('/')))
533    };
534    let (bind, plugin_text) = if let Some(bind) = bind {
535        let (b, p) = bind
536            .split_once(',')
537            .map_or((bind, None), |(b, p)| (b, Some(p)));
538        (Some(b.to_string()), p)
539    } else {
540        (bind.map(str::to_string), plugin_text)
541    };
542    let plugins = plugin_text
543        .unwrap_or_default()
544        .split(',')
545        .filter(|s| !s.is_empty())
546        .map(|spec| {
547            let (name, options) = spec
548                .split_once('=')
549                .map_or((spec, None), |(n, o)| (n, Some(o.to_string())));
550            PproxyPluginSpec {
551                name: name.to_string(),
552                options,
553            }
554        })
555        .collect();
556    (bind, plugins)
557}
558
559/// Find the position of the LAST unbracketed `@` in `s`. The userinfo
560/// separator is the last `@` after the scheme, not the first; a raw
561/// password containing `@` must not be truncated by the parser.
562fn find_last_at_outside_brackets(s: &str) -> Option<usize> {
563    let mut last_at: Option<usize> = None;
564    let mut bracket_depth = 0u32;
565    for (i, c) in s.char_indices() {
566        match c {
567            '[' => bracket_depth += 1,
568            ']' => bracket_depth = bracket_depth.saturating_sub(1),
569            '@' if bracket_depth == 0 => last_at = Some(i),
570            _ => {}
571        }
572    }
573    last_at
574}
575
576fn parse_userinfo(userinfo: &str) -> Result<(String, String), CompatError> {
577    match userinfo.find(':') {
578        Some(colon_pos) => {
579            let user = userinfo[..colon_pos].to_string();
580            let pass = userinfo[colon_pos + 1..].to_string();
581            Ok((user, pass))
582        }
583        None => {
584            // No colon: treat as password-only (e.g. Trojan: trojan://password@host:port)
585            Ok((String::new(), userinfo.to_string()))
586        }
587    }
588}
589
590fn parse_endpoint(endpoint: &str) -> Result<(String, u16, bool), CompatError> {
591    if endpoint.is_empty() {
592        return Ok((String::new(), 0, false));
593    }
594
595    // Handle bracketed IPv6: [::1]:8080
596    if endpoint.starts_with('[') {
597        let close = endpoint.find(']').ok_or_else(|| CompatError::InvalidUri {
598            message: "unterminated IPv6 bracket".to_string(),
599        })?;
600        let host = &endpoint[1..close];
601        let after = &endpoint[close + 1..];
602        if !after.starts_with(':') {
603            return Err(CompatError::InvalidUri {
604                message: "expected ':' after IPv6 bracket".to_string(),
605            });
606        }
607        let port = after[1..]
608            .parse::<u16>()
609            .map_err(|e| CompatError::InvalidUri {
610                message: format!("invalid port: {}", e),
611            })?;
612        return Ok((host.to_string(), port, true));
613    }
614
615    // Regular host:port
616    let colon_pos = match endpoint.rfind(':') {
617        Some(pos) => pos,
618        None => {
619            return Ok((endpoint.to_string(), 0, false));
620        }
621    };
622    let host = &endpoint[..colon_pos];
623    let port_str = &endpoint[colon_pos + 1..];
624    let port = port_str
625        .parse::<u16>()
626        .map_err(|e| CompatError::InvalidUri {
627            message: format!("invalid port '{}': {}", port_str, e),
628        })?;
629
630    Ok((host.to_string(), port, true))
631}
632
633fn default_port_for_scheme(scheme: &str) -> Option<u16> {
634    match scheme {
635        // pproxy's proxy_by_uri() uses 8080 for every non-SSH endpoint when
636        // the port is omitted. Keep conventional native defaults out of this
637        // compatibility parser.
638        "ssh" => Some(22),
639        "unix" | "direct" => None,
640        _ => Some(8080),
641    }
642}
643
644fn extract_query_params(query: &str) -> (Option<String>, Option<String>, Option<String>) {
645    let mut rule = None;
646    let mut rules_file = None;
647    for param in query.split('&') {
648        if let Some(eq_pos) = param.find('=') {
649            let key = &param[..eq_pos];
650            let value = &param[eq_pos + 1..];
651            if !value.is_empty() {
652                match key {
653                    "rule" => rule = Some(value.to_string()),
654                    "rules_file" => rules_file = Some(value.to_string()),
655                    _ => {}
656                }
657            }
658        }
659    }
660    let suffix = if rule.is_none() && rules_file.is_none() && !query.is_empty() {
661        Some(query.to_string())
662    } else {
663        None
664    };
665    (rule, rules_file, suffix)
666}
667
668/// A parsed pproxy chain (one or more hops separated by `__`).
669#[derive(Debug, Clone)]
670pub struct PproxyChain {
671    /// The raw input string.
672    pub raw: String,
673    /// Parsed hops in order (left = first hop, right = the last hop).
674    pub hops: Vec<PproxyUri>,
675}
676
677impl PproxyChain {
678    /// Redacted display showing all hops separated by `__`.
679    pub fn redacted_display(&self) -> String {
680        self.hops
681            .iter()
682            .map(|h| h.redacted_display())
683            .collect::<Vec<_>>()
684            .join("__")
685    }
686}
687
688/// Parse a pproxy chain URI (one or more hops separated by `__`).
689///
690/// Single-hop URIs without `__` are valid chains with one hop.
691/// Returns an error for:
692/// - Leading, trailing, or doubled `__` separators
693/// - Empty hop segments
694/// - Semicolon or comma separators (not supported in pproxy)
695pub fn parse_pproxy_chain(uri: &str) -> Result<PproxyChain, CompatError> {
696    // Semicolons are never pproxy chain separators. Commas belong to plugin
697    // metadata and must remain inside their hop.
698    if uri.contains(';') {
699        return Err(CompatError::InvalidUri {
700            message: format!(
701                "semicolon and comma are not chain separators in pproxy; use '__' (double underscore) to separate hops: {}",
702                uri
703            ),
704        });
705    }
706
707    // Check for leading/trailing __
708    if uri.starts_with("__") || uri.ends_with("__") {
709        return Err(CompatError::InvalidUri {
710            message: format!("chain URI has leading or trailing '__' separator: {}", uri),
711        });
712    }
713
714    // Check for doubled ____
715    if uri.contains("____") {
716        return Err(CompatError::InvalidUri {
717            message: format!("chain URI has doubled '____' separator: {}", uri),
718        });
719    }
720
721    let mut hops = Vec::new();
722    for segment in split_chain_hops(uri)? {
723        if segment.is_empty() {
724            return Err(CompatError::InvalidUri {
725                message: format!("chain URI has empty hop segment: {}", uri),
726            });
727        }
728        let hop = parse_pproxy_uri(segment)?;
729        hops.push(hop);
730    }
731
732    Ok(PproxyChain {
733        raw: uri.to_string(),
734        hops,
735    })
736}
737
738fn split_chain_hops(uri: &str) -> Result<Vec<&str>, CompatError> {
739    let mut result = Vec::new();
740    let mut start = 0;
741    let mut bracket = 0u32;
742    let mut brace = 0u32;
743    let bytes = uri.as_bytes();
744    let mut i = 0;
745    while i < bytes.len() {
746        match bytes[i] as char {
747            '[' => bracket += 1,
748            ']' => {
749                if bracket == 0 {
750                    return Err(CompatError::InvalidUri {
751                        message: format!("chain URI has unmatched ']': {}", uri),
752                    });
753                }
754                bracket -= 1;
755            }
756            '{' => brace += 1,
757            '}' => {
758                if brace == 0 {
759                    return Err(CompatError::InvalidUri {
760                        message: format!("chain URI has unmatched '}}': {}", uri),
761                    });
762                }
763                brace -= 1;
764            }
765            '_' if i + 1 < bytes.len() && bytes[i + 1] == b'_' && bracket == 0 && brace == 0 => {
766                result.push(&uri[start..i]);
767                i += 1;
768                start = i + 1;
769            }
770            _ => {}
771        }
772        i += 1;
773    }
774    if bracket != 0 {
775        return Err(CompatError::InvalidUri {
776            message: format!("chain URI has unmatched '[': {}", uri),
777        });
778    }
779    if brace != 0 {
780        return Err(CompatError::InvalidUri {
781            message: format!("chain URI has unmatched '{{': {}", uri),
782        });
783    }
784    result.push(&uri[start..]);
785    Ok(result)
786}
787
788/// Check if any hop in a chain uses an unsupported protocol for chaining.
789///
790/// Returns a list of (hop_index, protocol_name) for unsupported hops.
791pub fn validate_chain_hops(chain: &PproxyChain) -> Vec<(usize, String)> {
792    let mut unsupported = Vec::new();
793    for (idx, hop) in chain.hops.iter().enumerate() {
794        match hop.scheme.as_str() {
795            "ssh" if cfg!(feature = "ssh") => {}
796            "ssh" | "redir" | "direct" => {
797                unsupported.push((idx, hop.scheme.clone()));
798            }
799            _ => {} // http, https, socks4, socks4a, socks5, trojan, ss, ssr, shadowsocks are supported
800        }
801    }
802    unsupported
803}
804
805/// Check if a Shadowsocks method name is a known legacy stream cipher.
806///
807/// Legacy stream ciphers lack authentication and are not supported by eggress.
808/// This function is used for diagnostic purposes in the pproxy compat layer.
809pub fn is_legacy_ss_method(method: &str) -> bool {
810    let method = method.to_ascii_lowercase();
811    let method = method.strip_suffix('!').unwrap_or(&method);
812    let method = method.strip_suffix("-py").unwrap_or(method);
813    matches!(
814        method,
815        "table"
816            | "aes-128-cfb1"
817            | "aes-192-cfb1"
818            | "aes-256-cfb1"
819            | "aes-128-cfb8"
820            | "aes-192-cfb8"
821            | "aes-256-cfb8"
822            | "aes-128-ctr"
823            | "aes-192-ctr"
824            | "aes-256-ctr"
825            | "aes-128-cfb"
826            | "aes-192-cfb"
827            | "aes-256-cfb"
828            | "aes-128-ofb"
829            | "aes-192-ofb"
830            | "aes-256-ofb"
831            | "rc4"
832            | "rc4-md5"
833            | "bf-cfb"
834            | "cast5-cfb"
835            | "des-cfb"
836            | "camellia-128-cfb"
837            | "camellia-192-cfb"
838            | "camellia-256-cfb"
839            | "idea-cfb"
840            | "rc2-cfb"
841            | "seed-cfb"
842            | "chacha20-ietf"
843            | "chacha20"
844            | "xchacha20"
845            | "xchacha20-ietf"
846            | "salsa20"
847            | "xsalsa20"
848    )
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn test_simple_socks5() {
857        let uri = parse_pproxy_uri("socks5://127.0.0.1:1080").unwrap();
858        assert_eq!(uri.scheme, "socks5");
859        assert_eq!(uri.host, "127.0.0.1");
860        assert_eq!(uri.port, 1080);
861        assert!(uri.username.is_none());
862        assert!(!uri.tls);
863    }
864
865    #[test]
866    fn test_http_with_auth() {
867        let uri = parse_pproxy_uri("http://user:pass@proxy:8080").unwrap();
868        assert_eq!(uri.scheme, "http");
869        assert_eq!(uri.username.as_deref(), Some("user"));
870        assert_eq!(uri.password.as_deref(), Some("pass"));
871        assert_eq!(uri.host, "proxy");
872        assert_eq!(uri.port, 8080);
873    }
874
875    #[test]
876    fn test_socks4() {
877        let uri = parse_pproxy_uri("socks4://0.0.0.0:1080").unwrap();
878        assert_eq!(uri.scheme, "socks4");
879        assert_eq!(uri.host, "0.0.0.0");
880        assert_eq!(uri.port, 1080);
881    }
882
883    #[test]
884    fn test_tls_suffix() {
885        let uri = parse_pproxy_uri("socks5+tls://proxy:1080").unwrap();
886        assert!(uri.tls);
887        assert_eq!(uri.scheme, "socks5");
888    }
889
890    #[test]
891    fn test_with_rule() {
892        let uri = parse_pproxy_uri("socks5://127.0.0.1:1080?rule=.*\\.com").unwrap();
893        assert_eq!(uri.rule.as_deref(), Some(".*\\.com"));
894    }
895
896    #[test]
897    fn test_trojan() {
898        let uri = parse_pproxy_uri("trojan://password@server:443").unwrap();
899        assert_eq!(uri.scheme, "trojan");
900        assert_eq!(uri.password.as_deref(), Some("password"));
901    }
902
903    #[test]
904    fn test_empty_host() {
905        let uri = parse_pproxy_uri("socks5://:1080").unwrap();
906        assert_eq!(uri.host, "");
907        assert_eq!(uri.port, 1080);
908    }
909
910    #[test]
911    fn test_ipv6() {
912        let uri = parse_pproxy_uri("socks5://[::1]:1080").unwrap();
913        assert_eq!(uri.host, "::1");
914        assert_eq!(uri.port, 1080);
915    }
916
917    #[test]
918    fn test_unsupported_scheme() {
919        let err = parse_pproxy_uri("ftp://host:22").unwrap_err();
920        match err {
921            CompatError::UnsupportedProtocol(p) => assert_eq!(p, "ftp"),
922            _ => panic!("expected UnsupportedProtocol"),
923        }
924    }
925
926    #[test]
927    fn test_missing_scheme() {
928        let err = parse_pproxy_uri("host:8080").unwrap_err();
929        match err {
930            CompatError::InvalidUri { .. } => {}
931            _ => panic!("expected InvalidUri"),
932        }
933    }
934
935    #[test]
936    fn test_redacted_display() {
937        let uri = parse_pproxy_uri("http://user:pass@proxy:8080").unwrap();
938        let display = uri.redacted_display();
939        assert!(display.contains("****:****@"));
940        assert!(!display.contains("pass"));
941    }
942
943    #[test]
944    fn test_redacted_display_no_creds() {
945        let uri = parse_pproxy_uri("socks5://127.0.0.1:1080").unwrap();
946        let display = uri.redacted_display();
947        assert_eq!(display, "socks5://127.0.0.1:1080");
948    }
949
950    #[test]
951    fn test_redacted_display_tls_suffix_in_scheme() {
952        let uri = parse_pproxy_uri("socks5+tls://proxy:1080").unwrap();
953        assert_eq!(uri.redacted_display(), "socks5+tls://proxy:1080");
954    }
955
956    #[test]
957    fn test_redacted_display_explicit_zero_port() {
958        let uri = parse_pproxy_uri("socks5://host:0").unwrap();
959        assert_eq!(uri.redacted_display(), "socks5://host:0");
960    }
961
962    #[test]
963    fn test_endpoint_display_brackets_ipv6() {
964        let uri = parse_pproxy_uri("socks5://[::1]:1080").unwrap();
965        assert_eq!(uri.endpoint_display(), "[::1]:1080");
966    }
967
968    #[test]
969    fn test_unix_socket_path() {
970        let uri = parse_pproxy_uri("unix:///tmp/eggress.sock").unwrap();
971        assert_eq!(uri.scheme, "unix");
972        assert_eq!(uri.path.as_deref(), Some("/tmp/eggress.sock"));
973        assert!(uri.host.is_empty());
974        assert_eq!(uri.port, 0);
975    }
976
977    #[test]
978    fn test_unix_socket_relative_path() {
979        let uri = parse_pproxy_uri("unix://var/run/proxy.sock").unwrap();
980        assert_eq!(uri.scheme, "unix");
981        assert_eq!(uri.path.as_deref(), Some("/var/run/proxy.sock"));
982    }
983
984    #[test]
985    fn test_unix_socket_empty_path_errors() {
986        let err = parse_pproxy_uri("unix://").unwrap_err();
987        match err {
988            CompatError::InvalidUri { message } => {
989                assert!(message.contains("requires a path"));
990            }
991            _ => panic!("expected InvalidUri for empty unix path"),
992        }
993    }
994
995    #[test]
996    fn test_unix_redacted_display() {
997        let uri = parse_pproxy_uri("unix:///tmp/secret.sock").unwrap();
998        let display = uri.redacted_display();
999        assert_eq!(display, "unix:///tmp/****");
1000        assert!(!display.contains("secret"));
1001    }
1002
1003    #[test]
1004    fn test_unix_redacted_display_nested() {
1005        let uri = parse_pproxy_uri("unix:///var/run/myapp/secret.sock").unwrap();
1006        let display = uri.redacted_display();
1007        assert_eq!(display, "unix:///var/run/myapp/****");
1008    }
1009
1010    #[test]
1011    fn test_redir_colon_port() {
1012        let uri = parse_pproxy_uri("redir://:12345").unwrap();
1013        assert_eq!(uri.scheme, "redir");
1014        assert_eq!(uri.host, "");
1015        assert_eq!(uri.port, 12345);
1016        assert!(uri.path.is_none());
1017    }
1018
1019    #[test]
1020    fn test_redir_host_port() {
1021        let uri = parse_pproxy_uri("redir://127.0.0.1:12345").unwrap();
1022        assert_eq!(uri.scheme, "redir");
1023        assert_eq!(uri.host, "127.0.0.1");
1024        assert_eq!(uri.port, 12345);
1025    }
1026
1027    #[test]
1028    fn test_redir_bind_display() {
1029        let uri = parse_pproxy_uri("redir://:12345").unwrap();
1030        assert_eq!(uri.bind_display(), "0.0.0.0:12345");
1031    }
1032
1033    #[test]
1034    fn test_redir_specific_bind_display() {
1035        let uri = parse_pproxy_uri("redir://127.0.0.1:12345").unwrap();
1036        assert_eq!(uri.bind_display(), "127.0.0.1:12345");
1037    }
1038
1039    #[test]
1040    fn test_redir_redacted_display() {
1041        let uri = parse_pproxy_uri("redir://:12345").unwrap();
1042        assert_eq!(uri.redacted_display(), "redir://:12345");
1043    }
1044
1045    #[test]
1046    fn test_bind_uri() {
1047        let uri = parse_pproxy_uri("bind://0.0.0.0:8080").unwrap();
1048        assert_eq!(uri.scheme, "bind");
1049        assert_eq!(uri.host, "0.0.0.0");
1050        assert_eq!(uri.port, 8080);
1051        assert!(uri.is_reverse_listener());
1052        assert!(!uri.inbound);
1053    }
1054
1055    #[test]
1056    fn test_listen_uri() {
1057        let uri = parse_pproxy_uri("listen://127.0.0.1:9090").unwrap();
1058        assert_eq!(uri.scheme, "listen");
1059        assert!(uri.is_reverse_listener());
1060    }
1061
1062    #[test]
1063    fn test_backward_uri() {
1064        let uri = parse_pproxy_uri("backward://0.0.0.0:8080").unwrap();
1065        assert_eq!(uri.scheme, "backward");
1066        assert!(uri.is_reverse_listener());
1067    }
1068
1069    #[test]
1070    fn test_rebind_uri() {
1071        let uri = parse_pproxy_uri("rebind://0.0.0.0:8080").unwrap();
1072        assert_eq!(uri.scheme, "rebind");
1073        assert!(uri.is_reverse_listener());
1074    }
1075
1076    #[test]
1077    fn test_bind_with_auth() {
1078        let uri = parse_pproxy_uri("bind://user:pass@0.0.0.0:8080").unwrap();
1079        assert_eq!(uri.scheme, "bind");
1080        assert_eq!(uri.username.as_deref(), Some("user"));
1081        assert_eq!(uri.password.as_deref(), Some("pass"));
1082        assert!(uri.is_reverse_listener());
1083    }
1084
1085    #[test]
1086    fn test_bind_with_tls() {
1087        let uri = parse_pproxy_uri("bind+tls://0.0.0.0:8443").unwrap();
1088        assert_eq!(uri.scheme, "bind");
1089        assert!(uri.tls);
1090        assert!(uri.is_reverse_listener());
1091    }
1092
1093    #[test]
1094    fn test_bind_with_inbound_modifier() {
1095        let uri = parse_pproxy_uri("socks5+in://0.0.0.0:1080").unwrap();
1096        assert_eq!(uri.scheme, "socks5");
1097        assert!(uri.inbound);
1098    }
1099
1100    #[test]
1101    fn test_bind_redacted_display() {
1102        let uri = parse_pproxy_uri("bind://user:pass@0.0.0.0:8080").unwrap();
1103        let display = uri.redacted_display();
1104        assert!(display.contains("****:****@"));
1105        assert!(!display.contains("pass"));
1106    }
1107
1108    #[test]
1109    fn test_bind_tls_in_redacted_display() {
1110        let uri = parse_pproxy_uri("bind+tls://0.0.0.0:8443").unwrap();
1111        assert_eq!(uri.redacted_display(), "bind+tls://0.0.0.0:8443");
1112    }
1113
1114    #[test]
1115    fn test_not_reverse_schemes() {
1116        let uri = parse_pproxy_uri("socks5://127.0.0.1:1080").unwrap();
1117        assert!(!uri.is_reverse_listener());
1118
1119        let uri = parse_pproxy_uri("http://proxy:8080").unwrap();
1120        assert!(!uri.is_reverse_listener());
1121    }
1122
1123    #[test]
1124    fn test_inbound_modifier() {
1125        let uri = parse_pproxy_uri("socks5+in://acceptor:1080").unwrap();
1126        assert!(uri.is_backward());
1127        assert!(!uri.is_reverse_listener());
1128        assert_eq!(uri.backward_num(), 1);
1129    }
1130
1131    #[test]
1132    fn test_multiple_inbound_tokens() {
1133        let uri = parse_pproxy_uri("socks5+in+in://acceptor:1080").unwrap();
1134        assert!(uri.is_backward());
1135        assert_eq!(uri.backward_num(), 2);
1136    }
1137
1138    #[test]
1139    fn test_backward_num_zero_without_in() {
1140        let uri = parse_pproxy_uri("socks5://proxy:1080").unwrap();
1141        assert!(!uri.is_backward());
1142        assert_eq!(uri.backward_num(), 0);
1143    }
1144
1145    #[test]
1146    fn test_parse_two_hop_chain() {
1147        let chain = parse_pproxy_chain("http://hop1:8080__socks5://hop2:1080").unwrap();
1148        assert_eq!(chain.hops.len(), 2);
1149        assert_eq!(chain.hops[0].scheme, "http");
1150        assert_eq!(chain.hops[0].host, "hop1");
1151        assert_eq!(chain.hops[0].port, 8080);
1152        assert_eq!(chain.hops[1].scheme, "socks5");
1153        assert_eq!(chain.hops[1].host, "hop2");
1154        assert_eq!(chain.hops[1].port, 1080);
1155    }
1156
1157    #[test]
1158    fn test_parse_three_hop_chain() {
1159        let chain = parse_pproxy_chain("http://h1:80__socks5://h2:1080__socks4://h3:1080").unwrap();
1160        assert_eq!(chain.hops.len(), 3);
1161    }
1162
1163    #[test]
1164    fn test_parse_single_hop_chain() {
1165        let chain = parse_pproxy_chain("socks5://proxy:1080").unwrap();
1166        assert_eq!(chain.hops.len(), 1);
1167        assert_eq!(chain.hops[0].scheme, "socks5");
1168    }
1169
1170    #[test]
1171    fn test_parse_chain_with_creds() {
1172        let chain = parse_pproxy_chain("http://user:pass@h1:80__socks5://h2:1080").unwrap();
1173        assert_eq!(chain.hops.len(), 2);
1174        assert_eq!(chain.hops[0].username.as_deref(), Some("user"));
1175        assert_eq!(chain.hops[0].password.as_deref(), Some("pass"));
1176    }
1177
1178    #[test]
1179    fn test_parse_chain_with_tls_modifier() {
1180        let chain = parse_pproxy_chain("socks5+tls://h1:1080__http://h2:80").unwrap();
1181        assert!(chain.hops[0].tls);
1182        assert!(!chain.hops[1].tls);
1183    }
1184
1185    #[test]
1186    fn test_parse_chain_semicolon_rejected() {
1187        let err = parse_pproxy_chain("http://h1:80;socks5://h2:1080").unwrap_err();
1188        match err {
1189            CompatError::InvalidUri { message } => {
1190                assert!(message.contains("semicolon"));
1191            }
1192            _ => panic!("expected InvalidUri for semicolon"),
1193        }
1194    }
1195
1196    #[test]
1197    fn test_parse_chain_plugin_comma_preserved() {
1198        let chain = parse_pproxy_chain("http://h1:80/,verify_simple").unwrap();
1199        assert_eq!(chain.hops[0].plugins[0].name, "verify_simple");
1200    }
1201
1202    #[test]
1203    fn test_parse_chain_leading_separator() {
1204        let err = parse_pproxy_chain("__http://h1:80").unwrap_err();
1205        match err {
1206            CompatError::InvalidUri { message } => {
1207                assert!(message.contains("leading"));
1208            }
1209            _ => panic!("expected InvalidUri for leading separator"),
1210        }
1211    }
1212
1213    #[test]
1214    fn test_parse_chain_trailing_separator() {
1215        let err = parse_pproxy_chain("http://h1:80__").unwrap_err();
1216        match err {
1217            CompatError::InvalidUri { message } => {
1218                assert!(message.contains("trailing"));
1219            }
1220            _ => panic!("expected InvalidUri for trailing separator"),
1221        }
1222    }
1223
1224    #[test]
1225    fn test_parse_chain_empty_segment() {
1226        let err = parse_pproxy_chain("http://h1:80____socks5://h2:1080").unwrap_err();
1227        match err {
1228            CompatError::InvalidUri { message } => {
1229                assert!(message.contains("doubled"));
1230            }
1231            _ => panic!("expected InvalidUri for doubled separator"),
1232        }
1233    }
1234
1235    #[test]
1236    fn test_chain_redacted_display() {
1237        let chain = parse_pproxy_chain("http://user:pass@h1:80__socks5://h2:1080").unwrap();
1238        let display = chain.redacted_display();
1239        assert!(display.contains("****"));
1240        assert!(!display.contains("pass"));
1241        assert!(display.contains("__"));
1242    }
1243
1244    #[test]
1245    fn test_validate_chain_hops_all_supported() {
1246        let chain = parse_pproxy_chain("http://h1:80__socks5://h2:1080").unwrap();
1247        let unsupported = validate_chain_hops(&chain);
1248        assert!(unsupported.is_empty());
1249    }
1250
1251    #[test]
1252    fn test_validate_chain_hops_ssh_unsupported() {
1253        let chain = parse_pproxy_chain("http://h1:80__ssh://h2:22").unwrap();
1254        let unsupported = validate_chain_hops(&chain);
1255        #[cfg(feature = "ssh")]
1256        assert!(unsupported.is_empty());
1257        #[cfg(not(feature = "ssh"))]
1258        {
1259            assert_eq!(unsupported.len(), 1);
1260            assert_eq!(unsupported[0], (1, "ssh".to_string()));
1261        }
1262    }
1263
1264    #[test]
1265    fn test_validate_chain_hops_ssr_supported() {
1266        let chain = parse_pproxy_chain("http://h1:80__ssr://h2:8388").unwrap();
1267        let unsupported = validate_chain_hops(&chain);
1268        assert!(unsupported.is_empty());
1269    }
1270
1271    #[test]
1272    fn test_default_port_socks5() {
1273        let uri = parse_pproxy_uri("socks5://host").unwrap();
1274        assert_eq!(uri.host, "host");
1275        assert_eq!(uri.port, 8080);
1276    }
1277
1278    #[test]
1279    fn test_default_port_http() {
1280        let uri = parse_pproxy_uri("http://host").unwrap();
1281        assert_eq!(uri.host, "host");
1282        assert_eq!(uri.port, 8080);
1283    }
1284
1285    #[test]
1286    fn test_default_port_https() {
1287        let uri = parse_pproxy_uri("https://host").unwrap();
1288        assert_eq!(uri.host, "host");
1289        assert_eq!(uri.port, 8080);
1290    }
1291
1292    #[test]
1293    fn test_default_port_trojan() {
1294        let uri = parse_pproxy_uri("trojan://password@host").unwrap();
1295        assert_eq!(uri.host, "host");
1296        assert_eq!(uri.port, 8080);
1297    }
1298
1299    #[test]
1300    fn test_default_port_shadowsocks() {
1301        let uri = parse_pproxy_uri("ss://method:pass@host").unwrap();
1302        assert_eq!(uri.host, "host");
1303        assert_eq!(uri.port, 8080);
1304    }
1305
1306    #[test]
1307    fn test_explicit_port_overrides_default() {
1308        let uri = parse_pproxy_uri("socks5://host:9090").unwrap();
1309        assert_eq!(uri.host, "host");
1310        assert_eq!(uri.port, 9090);
1311    }
1312
1313    #[test]
1314    fn test_empty_port_with_colon() {
1315        let uri = parse_pproxy_uri("socks5://:1080").unwrap();
1316        assert_eq!(uri.host, "");
1317        assert_eq!(uri.port, 1080);
1318    }
1319
1320    #[test]
1321    fn test_chain_default_ports() {
1322        let chain = parse_pproxy_chain("socks5://h1__http://h2").unwrap();
1323        assert_eq!(chain.hops[0].port, 8080);
1324        assert_eq!(chain.hops[1].port, 8080);
1325    }
1326
1327    #[test]
1328    fn test_explicit_zero_port_preserved_socks5() {
1329        let uri = parse_pproxy_uri("socks5://127.0.0.1:0").unwrap();
1330        assert_eq!(uri.host, "127.0.0.1");
1331        assert_eq!(uri.port, 0);
1332    }
1333
1334    #[test]
1335    fn test_explicit_zero_port_preserved_http() {
1336        let uri = parse_pproxy_uri("http://example.com:0").unwrap();
1337        assert_eq!(uri.host, "example.com");
1338        assert_eq!(uri.port, 0);
1339    }
1340
1341    #[test]
1342    fn test_explicit_zero_port_preserved_https() {
1343        let uri = parse_pproxy_uri("https://example.com:0").unwrap();
1344        assert_eq!(uri.host, "example.com");
1345        assert_eq!(uri.port, 0);
1346    }
1347
1348    #[test]
1349    fn test_explicit_zero_port_preserved_trojan() {
1350        let uri = parse_pproxy_uri("trojan://password@example.com:0").unwrap();
1351        assert_eq!(uri.host, "example.com");
1352        assert_eq!(uri.port, 0);
1353    }
1354
1355    #[test]
1356    fn test_password_containing_at_sign() {
1357        // Regression: raw '@' inside the password must not be treated as
1358        // the userinfo/host separator. The userinfo separator is the LAST
1359        // unbracketed '@' after the scheme.
1360        let uri = parse_pproxy_uri("socks5://admin:s3cret_p@ssw0rd@127.0.0.1:1080").unwrap();
1361        assert_eq!(uri.scheme, "socks5");
1362        assert_eq!(uri.username.as_deref(), Some("admin"));
1363        assert_eq!(uri.password.as_deref(), Some("s3cret_p@ssw0rd"));
1364        assert_eq!(uri.host, "127.0.0.1");
1365        assert_eq!(uri.port, 1080);
1366    }
1367
1368    #[test]
1369    fn test_password_containing_at_sign_redacted_display() {
1370        // Regression: redacted display must not leak any part of the
1371        // password even when the password contains '@'.
1372        let uri = parse_pproxy_uri("socks5://admin:s3cret_p@ssw0rd@127.0.0.1:1080").unwrap();
1373        let display = uri.redacted_display();
1374        assert_eq!(display, "socks5://****:****@127.0.0.1:1080");
1375        assert!(!display.contains("s3cret_p"));
1376        assert!(!display.contains("ssw0rd"));
1377        assert!(!display.contains("admin"));
1378    }
1379
1380    #[test]
1381    fn test_password_containing_at_sign_chain() {
1382        // Regression: last '@' must also be honored inside chain hops.
1383        let chain = parse_pproxy_chain("socks5://user:p@ss@proxy1:1080__http://h2:8080").unwrap();
1384        assert_eq!(chain.hops.len(), 2);
1385        assert_eq!(chain.hops[0].username.as_deref(), Some("user"));
1386        assert_eq!(chain.hops[0].password.as_deref(), Some("p@ss"));
1387        assert_eq!(chain.hops[0].host, "proxy1");
1388        assert_eq!(chain.hops[0].port, 1080);
1389    }
1390
1391    #[test]
1392    fn test_password_containing_at_sign_redir() {
1393        // Regression: redir:// must also use the LAST unbracketed '@'.
1394        let uri = parse_pproxy_uri("redir://admin:s3cret_p@ssw0rd@127.0.0.1:12345").unwrap();
1395        assert_eq!(uri.scheme, "redir");
1396        assert_eq!(uri.username.as_deref(), Some("admin"));
1397        assert_eq!(uri.password.as_deref(), Some("s3cret_p@ssw0rd"));
1398        assert_eq!(uri.host, "127.0.0.1");
1399        assert_eq!(uri.port, 12345);
1400        assert_eq!(uri.redacted_display(), "redir://****:****@127.0.0.1:12345");
1401    }
1402
1403    #[test]
1404    fn test_password_containing_at_sign_shadowsocks() {
1405        // Regression: ss:// userinfo "method:password@host:port" with '@' in
1406        // the password must keep the full password.
1407        let uri = parse_pproxy_uri("ss://aes-256-gcm:p@ssw0rd@proxy:8388").unwrap();
1408        assert_eq!(uri.scheme, "ss");
1409        assert_eq!(uri.username.as_deref(), Some("aes-256-gcm"));
1410        assert_eq!(uri.password.as_deref(), Some("p@ssw0rd"));
1411        assert_eq!(uri.host, "proxy");
1412        assert_eq!(uri.port, 8388);
1413    }
1414
1415    #[test]
1416    fn test_password_containing_at_sign_trojan() {
1417        // Regression: trojan:// accepts password-only creds; '@' in the
1418        // password must be preserved.
1419        let uri = parse_pproxy_uri("trojan://my_p@ssw0rd@server:443").unwrap();
1420        assert_eq!(uri.scheme, "trojan");
1421        assert_eq!(uri.username.as_deref(), Some(""));
1422        assert_eq!(uri.password.as_deref(), Some("my_p@ssw0rd"));
1423        assert_eq!(uri.host, "server");
1424        assert_eq!(uri.port, 443);
1425    }
1426
1427    #[test]
1428    fn test_with_rules_file() {
1429        let uri =
1430            parse_pproxy_uri("socks5://127.0.0.1:1080?rules_file=/path/to/rules.txt").unwrap();
1431        assert_eq!(uri.rules_file.as_deref(), Some("/path/to/rules.txt"));
1432    }
1433
1434    #[test]
1435    fn test_with_rules_file_and_rule() {
1436        let uri =
1437            parse_pproxy_uri("socks5://127.0.0.1:1080?rule=.*\\.com&rules_file=/path/to/rules.txt")
1438                .unwrap();
1439        assert_eq!(uri.rule.as_deref(), Some(".*\\.com"));
1440        assert_eq!(uri.rules_file.as_deref(), Some("/path/to/rules.txt"));
1441    }
1442
1443    #[test]
1444    fn test_combined_listener_tokens_and_modifiers() {
1445        let uri = parse_pproxy_uri("http+socks4+socks5+tls+in+in://:8080").unwrap();
1446        assert_eq!(uri.protocol_chain, ["http", "socks4", "socks5"]);
1447        assert_eq!(uri.backward_num(), 2);
1448        assert!(uri.tls);
1449        assert_eq!(uri.scheme, "http+socks4+socks5");
1450    }
1451
1452    #[test]
1453    fn test_fragment_auth_local_bind_and_plugins() {
1454        let uri =
1455            parse_pproxy_uri("http://proxy:8080/@192.0.2.1,verify_simple,plain#user:pass").unwrap();
1456        assert_eq!(uri.local_bind.as_deref(), Some("192.0.2.1"));
1457        assert_eq!(uri.plugins.len(), 2);
1458        assert_eq!(uri.plugins[0].name, "verify_simple");
1459        assert_eq!(uri.auth_fragment.as_deref(), Some("user:pass"));
1460        assert_eq!(uri.username.as_deref(), Some("user"));
1461        assert!(!uri.redacted_display().contains("secret"));
1462        assert!(!uri.redacted_display().contains("pass"));
1463    }
1464
1465    #[test]
1466    fn test_fixed_target_and_raw_rule_suffix() {
1467        let uri = parse_pproxy_uri("tunnel://{example.com:443}?example\\.com$").unwrap();
1468        assert_eq!(uri.fixed_target.as_deref(), Some("example.com:443"));
1469        assert_eq!(uri.rule_suffix.as_deref(), Some("example\\.com$"));
1470    }
1471
1472    #[test]
1473    fn test_chain_split_does_not_split_fixed_target() {
1474        let chain = parse_pproxy_chain("tunnel://{example.com:443}__socks5://proxy:1080").unwrap();
1475        assert_eq!(chain.hops.len(), 2);
1476        assert_eq!(
1477            chain.hops[0].fixed_target.as_deref(),
1478            Some("example.com:443")
1479        );
1480    }
1481}