1use crate::error::CompatError;
2use eggress_uri::syntax as uri_syntax;
3
4#[derive(Debug, Clone)]
6pub struct PproxyUri {
7 pub scheme: String,
9 pub username: Option<String>,
11 pub password: Option<String>,
13 pub host: String,
15 pub port: u16,
17 pub tls: bool,
19 pub ssl: bool,
21 pub inbound: bool,
23 pub backward_num: u32,
25 pub rule: Option<String>,
27 pub rules_file: Option<String>,
29 pub rule_suffix: Option<String>,
31 pub path: Option<String>,
33 pub protocol_chain: Vec<String>,
35 pub transport_modifiers: Vec<String>,
37 pub local_bind: Option<String>,
39 pub fixed_target: Option<String>,
41 pub plugins: Vec<PproxyPluginSpec>,
43 pub auth_fragment: Option<String>,
45 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 pub fn is_reverse_listener(&self) -> bool {
58 matches!(
59 self.scheme.as_str(),
60 "bind" | "listen" | "backward" | "rebind"
61 )
62 }
63
64 pub fn is_backward(&self) -> bool {
67 self.inbound
68 }
69
70 pub fn backward_num(&self) -> u32 {
74 self.backward_num
75 }
76
77 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 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
176fn 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
187fn format_host_for_uri(host: &str) -> String {
192 uri_syntax::format_host(host)
193}
194
195pub fn parse_pproxy_uri(uri: &str) -> Result<PproxyUri, CompatError> {
206 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 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 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 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 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 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 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 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
456fn split_top_level(input: &str, delimiter: char) -> (&str, Option<&str>) {
461 uri_syntax::split_once_outside_brackets(input, delimiter)
462}
463
464fn 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
563fn 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 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 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 "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 = ¶m[..eq_pos];
605 let value = ¶m[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#[derive(Debug, Clone)]
625pub struct PproxyChain {
626 pub raw: String,
628 pub hops: Vec<PproxyUri>,
630}
631
632impl PproxyChain {
633 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
643pub fn parse_pproxy_chain(uri: &str) -> Result<PproxyChain, CompatError> {
651 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 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 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
693fn 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
714pub 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 _ => {} }
727 }
728 unsupported
729}
730
731pub 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 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 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 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 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 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 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}