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