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