Skip to main content

eggress_pproxy_compat/
uri.rs

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