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