Skip to main content

tor_chanmgr/
config.rs

1//! Configuration for a channel manager (and, therefore, channels)
2//!
3//! # Semver note
4//!
5//! Most types in this module are re-exported by `arti-client`.
6
7use derive_deftly::Deftly;
8use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
9use serde::Deserialize;
10use std::net::{IpAddr, SocketAddr};
11use tor_config::PaddingLevel;
12use tor_config::derive::prelude::*;
13use tor_socksproto::SocksAuth;
14use tor_socksproto::SocksVersion;
15use url::{Host, Url};
16
17/// Error parsing a proxy URI string
18#[derive(Debug, Clone, thiserror::Error)]
19#[non_exhaustive]
20pub enum ProxyProtocolParseError {
21    /// Proxy URI has an unsupported or missing scheme.
22    #[error("unsupported or missing proxy scheme: {0}")]
23    UnsupportedScheme(String),
24    /// Proxy URI includes a password for a scheme that does not support it.
25    #[error("password not supported for proxy scheme: {0}")]
26    UnsupportedPassword(String),
27    /// Proxy URI had an invalid or unparsable address.
28    #[error("invalid proxy address: {0}")]
29    InvalidAddress(String),
30    /// Proxy URI is missing a port or has an invalid port.
31    #[error("missing or invalid port")]
32    InvalidPort,
33    /// Proxy URI does not match the expected format.
34    #[error("invalid proxy URI format: {0}")]
35    InvalidFormat(String),
36}
37
38/// Authentication credentials for HTTP CONNECT proxy.
39///
40/// This struct enforces the invariant that a password can only exist when a username
41/// is present. If you have both username and password, use the struct directly. If you
42/// only have a username, set password to `None`.
43#[derive(Debug, Clone, Eq, PartialEq)]
44pub struct HttpConnectAuth {
45    /// Username for Basic auth (required when auth is present)
46    pub username: String,
47    /// Optional password for Basic auth
48    pub password: Option<String>,
49}
50
51/// Information about what proxy protocol to use, and how to use it.
52///
53/// This type can be parsed from a URI string using the same format as curl's
54/// proxy URL syntax (see <https://curl.se/docs/url-syntax.html>).
55///
56/// Supported formats:
57///
58/// - `socks4://ip:port` - SOCKS4 proxy
59/// - `socks4://user@ip:port` - SOCKS4 proxy with user ID
60/// - `socks4a://ip:port` - SOCKS4a proxy (treated same as socks4)
61/// - `socks5://ip:port` - SOCKS5 proxy without auth
62/// - `socks5://user:pass@ip:port` - SOCKS5 proxy with username/password auth
63/// - `socks5://user@ip:port` - SOCKS5 proxy with username only (empty password)
64/// - `socks5h://ip:port` - SOCKS5 with remote hostname resolution (treated same as socks5)
65///
66/// - Hostnames for the proxy server itself are not supported (applies to all proxy types).
67/// - Credentials must be embedded in the URI; curl's `-U user:pass` style is not supported.
68/// - For `socks4://`, passwords are not supported and will return an error.
69/// - Special characters in credentials are percent-encoded using the `url` crate's
70///   userinfo encoding.
71///
72/// HTTP CONNECT:
73///
74/// Hostnames for the proxy server itself are not supported (only IP addresses).
75///
76/// - `http://ip:port` - HTTP CONNECT proxy without auth
77/// - `http://user:pass@ip:port` - HTTP CONNECT proxy with Basic auth (RFC 7617)
78/// - `http://user@ip:port` - HTTP CONNECT proxy with username only (empty password)
79#[derive(
80    Debug, Clone, Eq, PartialEq, serde_with::DeserializeFromStr, serde_with::SerializeDisplay,
81)]
82#[non_exhaustive]
83pub enum ProxyProtocol {
84    /// Connect via SOCKS 4, SOCKS 4a, or SOCKS 5.
85    Socks {
86        /// The SOCKS version to use
87        version: SocksVersion,
88        /// The authentication method to use
89        auth: SocksAuth,
90        /// The proxy server address
91        addr: SocketAddr,
92    },
93    /// Connect via HTTP CONNECT proxy.
94    HttpConnect {
95        /// The proxy server address
96        addr: SocketAddr,
97        /// Optional credentials for Basic auth (RFC 7617)
98        credentials: Option<HttpConnectAuth>,
99    },
100}
101
102impl std::str::FromStr for ProxyProtocol {
103    type Err = ProxyProtocolParseError;
104
105    fn from_str(s: &str) -> Result<Self, Self::Err> {
106        let url = Url::parse(s).map_err(|e| match e {
107            url::ParseError::InvalidPort => ProxyProtocolParseError::InvalidPort,
108            url::ParseError::InvalidIpv4Address
109            | url::ParseError::InvalidIpv6Address
110            | url::ParseError::EmptyHost
111            | url::ParseError::InvalidDomainCharacter
112            | url::ParseError::IdnaError => ProxyProtocolParseError::InvalidAddress(s.to_string()),
113            _ => ProxyProtocolParseError::InvalidFormat(s.to_string()),
114        })?;
115
116        let scheme_lower = url.scheme().to_ascii_lowercase();
117
118        if url.query().is_some() || url.fragment().is_some() {
119            return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
120        }
121
122        let path = url.path();
123        if !path.is_empty() && path != "/" {
124            return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
125        }
126
127        let port = url.port().ok_or(ProxyProtocolParseError::InvalidPort)?;
128        let host = url
129            .host()
130            .ok_or_else(|| ProxyProtocolParseError::InvalidAddress(s.to_string()))?;
131        let ip = match host {
132            Host::Ipv4(ip) => IpAddr::V4(ip),
133            Host::Ipv6(ip) => IpAddr::V6(ip),
134            Host::Domain(domain) => domain
135                .parse::<IpAddr>()
136                .map_err(|_| ProxyProtocolParseError::InvalidAddress(domain.to_string()))?,
137        };
138        let addr = SocketAddr::new(ip, port);
139
140        match scheme_lower.as_str() {
141            "http" => {
142                // HTTP CONNECT: optional Basic auth via user:pass@host:port
143                let user = url.username();
144                let pass = url.password();
145                // Reject password-only auth (http://:pass@host:port) - username is required
146                if user.is_empty() && pass.is_some() {
147                    return Err(ProxyProtocolParseError::InvalidFormat(
148                        "password without username not supported".to_string(),
149                    ));
150                }
151                let credentials = if user.is_empty() {
152                    None
153                } else {
154                    let username = percent_decode_str(user)
155                        .decode_utf8()
156                        .map_err(|_| {
157                            ProxyProtocolParseError::InvalidFormat(
158                                "invalid UTF-8 in username".to_string(),
159                            )
160                        })?
161                        .into_owned();
162                    let password = pass
163                        .map(|p| {
164                            percent_decode_str(p).decode_utf8().map_err(|_| {
165                                ProxyProtocolParseError::InvalidFormat(
166                                    "invalid UTF-8 in password".to_string(),
167                                )
168                            })
169                        })
170                        .transpose()?
171                        .map(|s| s.into_owned());
172                    Some(HttpConnectAuth { username, password })
173                };
174                Ok(ProxyProtocol::HttpConnect { addr, credentials })
175            }
176            "socks4" | "socks4a" | "socks5" | "socks5h" => {
177                let version = match scheme_lower.as_str() {
178                    "socks4" | "socks4a" => SocksVersion::V4,
179                    "socks5" | "socks5h" => SocksVersion::V5,
180                    _ => unreachable!(),
181                };
182                // Check for authentication credentials (user:pass@host:port or user@host:port).
183                let user = url.username();
184                let pass = url.password();
185                if version == SocksVersion::V4 && pass.is_some() {
186                    return Err(ProxyProtocolParseError::UnsupportedPassword(
187                        url.scheme().to_string(),
188                    ));
189                }
190                let user_decoded = percent_decode_str(user).decode_utf8().map_err(|_| {
191                    ProxyProtocolParseError::InvalidFormat("invalid UTF-8 in username".to_string())
192                })?;
193                let pass_decoded = pass
194                    .map(|p| {
195                        percent_decode_str(p).decode_utf8().map_err(|_| {
196                            ProxyProtocolParseError::InvalidFormat(
197                                "invalid UTF-8 in password".to_string(),
198                            )
199                        })
200                    })
201                    .transpose()?;
202                let auth = if user.is_empty() && pass.is_none() {
203                    SocksAuth::NoAuth
204                } else {
205                    match version {
206                        SocksVersion::V4 => SocksAuth::Socks4(user_decoded.as_bytes().to_vec()),
207                        SocksVersion::V5 => {
208                            let pass = pass_decoded.as_deref().unwrap_or("");
209                            SocksAuth::Username(
210                                user_decoded.as_bytes().to_vec(),
211                                pass.as_bytes().to_vec(),
212                            )
213                        }
214                        _ => SocksAuth::NoAuth,
215                    }
216                };
217                Ok(ProxyProtocol::Socks {
218                    version,
219                    auth,
220                    addr,
221                })
222            }
223            _ => Err(ProxyProtocolParseError::UnsupportedScheme(
224                url.scheme().to_string(),
225            )),
226        }
227    }
228}
229
230impl std::fmt::Display for ProxyProtocol {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        match self {
233            ProxyProtocol::Socks {
234                version,
235                auth,
236                addr,
237            } => {
238                // Use SocksVersion's Display impl for the scheme (e.g., "socks5")
239                match auth {
240                    SocksAuth::NoAuth => write!(f, "{}://{}", version, addr),
241                    SocksAuth::Socks4(user_id) => {
242                        // SOCKS4: user@host format (no password in SOCKS4)
243                        let user = String::from_utf8_lossy(user_id);
244                        match encode_userinfo(*version, *addr, &user, None) {
245                            Some((user_encoded, _)) => {
246                                write!(f, "{}://{}@{}", version, user_encoded, addr)
247                            }
248                            None => write!(f, "{}://{}@{}", version, user, addr),
249                        }
250                    }
251                    SocksAuth::Username(user, pass) => {
252                        // SOCKS5: user:pass@host format
253                        let user = String::from_utf8_lossy(user);
254                        let pass = String::from_utf8_lossy(pass);
255                        match encode_userinfo(*version, *addr, &user, Some(&pass)) {
256                            Some((user_encoded, pass_encoded)) => {
257                                let pass_encoded = pass_encoded.unwrap_or_default();
258                                write!(
259                                    f,
260                                    "{}://{}:{}@{}",
261                                    version, user_encoded, pass_encoded, addr
262                                )
263                            }
264                            None => write!(f, "{}://{}:{}@{}", version, user, pass, addr),
265                        }
266                    }
267                    // Handle potential future auth types
268                    _ => write!(f, "{}://{}", version, addr),
269                }
270            }
271            ProxyProtocol::HttpConnect { addr, credentials } => {
272                if let Some(auth) = credentials {
273                    // encode_userinfo_http should always succeed for valid SocketAddr,
274                    // but if it fails, we still percent-encode to produce a valid URI
275                    let (user_encoded, pass_encoded) =
276                        encode_userinfo_http(*addr, &auth.username, auth.password.as_deref())
277                            .unwrap_or_else(|| {
278                                // Fallback: use url crate to percent-encode directly
279                                debug_assert!(
280                                    false,
281                                    "encode_userinfo_http failed for addr={}, user={}",
282                                    addr, auth.username
283                                );
284                                let encoded_user = percent_encode_userinfo(&auth.username);
285                                let encoded_pass =
286                                    auth.password.as_ref().map(|p| percent_encode_userinfo(p));
287                                (encoded_user, encoded_pass)
288                            });
289                    if let Some(p) = pass_encoded {
290                        write!(f, "http://{}:{}@{}", user_encoded, p, addr)
291                    } else {
292                        write!(f, "http://{}@{}", user_encoded, addr)
293                    }
294                } else {
295                    write!(f, "http://{}", addr)
296                }
297            }
298        }
299    }
300}
301
302impl ProxyProtocol {
303    /// Check whether the proxy server address is on the loopback interface.
304    pub fn is_loopback(&self) -> bool {
305        let addr = match self {
306            ProxyProtocol::Socks { addr, .. } => addr,
307            ProxyProtocol::HttpConnect { addr, .. } => addr,
308        };
309        addr.ip().is_loopback()
310    }
311}
312
313/// Characters that must be percent-encoded in userinfo (RFC 3986 section 3.2.1).
314/// This includes: gen-delims (:/?#[]@) and sub-delims (!$&'()*+,;=) except those allowed.
315/// For userinfo, we encode: : @ / ? # [ ] and space, plus control characters.
316const USERINFO_ENCODE_SET: &AsciiSet = &CONTROLS
317    .add(b' ')
318    .add(b':')
319    .add(b'@')
320    .add(b'/')
321    .add(b'?')
322    .add(b'#')
323    .add(b'[')
324    .add(b']');
325
326/// Percent-encode a string for use in URI userinfo (username or password).
327fn percent_encode_userinfo(s: &str) -> String {
328    utf8_percent_encode(s, USERINFO_ENCODE_SET).to_string()
329}
330
331/// URL-encodes username and optional password for a given scheme and address.
332///
333/// Builds a URL from `scheme://addr`, sets username/password, and returns
334/// the percent-encoded forms suitable for URI userinfo display.
335fn encode_userinfo_with_scheme(
336    scheme: &str,
337    addr: SocketAddr,
338    username: &str,
339    password: Option<&str>,
340) -> Option<(String, Option<String>)> {
341    let url_str = format!("{}://{}", scheme, addr);
342    let mut url = Url::parse(&url_str).ok()?;
343    if url.set_username(username).is_err() {
344        return None;
345    }
346    if url.set_password(password).is_err() {
347        return None;
348    }
349    let user_encoded = url.username().to_string();
350    let pass_encoded = url.password().map(str::to_string);
351    Some((user_encoded, pass_encoded))
352}
353
354/// URL-encodes username and optional password for HTTP CONNECT proxy userinfo display.
355fn encode_userinfo_http(
356    addr: SocketAddr,
357    username: &str,
358    password: Option<&str>,
359) -> Option<(String, Option<String>)> {
360    encode_userinfo_with_scheme("http", addr, username, password)
361}
362
363/// URL-encodes username and optional password for SOCKS proxy userinfo display.
364///
365/// Uses `Url` parsing to produce percent-encoded forms suitable for
366/// `socks://user:pass@host:port` style output.
367fn encode_userinfo(
368    version: SocksVersion,
369    addr: SocketAddr,
370    username: &str,
371    password: Option<&str>,
372) -> Option<(String, Option<String>)> {
373    encode_userinfo_with_scheme(&version.to_string(), addr, username, password)
374}
375
376impl ProxyProtocol {
377    /// Create a new SOCKS proxy configuration with no authentication
378    pub fn socks_no_auth(version: SocksVersion, addr: SocketAddr) -> Self {
379        ProxyProtocol::Socks {
380            version,
381            auth: SocksAuth::NoAuth,
382            addr,
383        }
384    }
385}
386
387/// Deserialize an outbound proxy, treating empty strings as unset.
388#[allow(clippy::option_option)]
389fn deserialize_outbound_proxy<'de, D>(
390    deserializer: D,
391) -> Result<Option<Option<ProxyProtocol>>, D::Error>
392where
393    D: serde::Deserializer<'de>,
394{
395    let value = Option::<String>::deserialize(deserializer)?;
396    match value {
397        None => Ok(None),
398        Some(s) => {
399            if s.trim().is_empty() {
400                return Ok(Some(None));
401            }
402            let parsed = s.parse().map_err(serde::de::Error::custom)?;
403            Ok(Some(Some(parsed)))
404        }
405    }
406}
407
408/// Channel configuration
409///
410/// This type is immutable once constructed.  To build one, use
411/// [`ChannelConfigBuilder`], or deserialize it from a string.
412#[derive(Debug, Clone, Deftly, Eq, PartialEq)]
413#[derive_deftly(TorConfig)]
414pub struct ChannelConfig {
415    /// Control of channel padding
416    #[deftly(tor_config(default))]
417    pub(crate) padding: PaddingLevel,
418
419    /// Outbound proxy to use for all direct connections
420    #[deftly(tor_config(
421        default,
422        serde = r#" deserialize_with = "deserialize_outbound_proxy" "#
423    ))]
424    pub(crate) outbound_proxy: Option<ProxyProtocol>,
425}
426
427#[cfg(feature = "testing")]
428impl ChannelConfig {
429    /// The padding level (accessor for testing)
430    pub fn padding(&self) -> PaddingLevel {
431        self.padding
432    }
433}
434
435#[cfg(test)]
436mod test {
437    // @@ begin test lint list maintained by maint/add_warning @@
438    #![allow(clippy::bool_assert_comparison)]
439    #![allow(clippy::clone_on_copy)]
440    #![allow(clippy::dbg_macro)]
441    #![allow(clippy::mixed_attributes_style)]
442    #![allow(clippy::print_stderr)]
443    #![allow(clippy::print_stdout)]
444    #![allow(clippy::single_char_pattern)]
445    #![allow(clippy::unwrap_used)]
446    #![allow(clippy::unchecked_time_subtraction)]
447    #![allow(clippy::useless_vec)]
448    #![allow(clippy::needless_pass_by_value)]
449    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
450    use super::*;
451
452    #[test]
453    fn channel_config() {
454        let config = ChannelConfig::default();
455
456        assert_eq!(PaddingLevel::Normal, config.padding);
457    }
458
459    #[test]
460    fn proxy_protocol_parse_socks5_basic() {
461        let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
462        match p {
463            ProxyProtocol::Socks {
464                version,
465                auth,
466                addr,
467            } => {
468                assert_eq!(version, SocksVersion::V5);
469                assert_eq!(auth, SocksAuth::NoAuth);
470                assert_eq!(addr, "127.0.0.1:1080".parse().unwrap());
471            }
472            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
473        }
474    }
475
476    #[test]
477    fn proxy_protocol_parse_socks5_with_auth() {
478        let p: ProxyProtocol = "socks5://myuser:mypass@192.168.1.1:9050".parse().unwrap();
479        match p {
480            ProxyProtocol::Socks {
481                version,
482                auth,
483                addr,
484            } => {
485                assert_eq!(version, SocksVersion::V5);
486                assert_eq!(
487                    auth,
488                    SocksAuth::Username(b"myuser".to_vec(), b"mypass".to_vec())
489                );
490                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
491            }
492            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
493        }
494    }
495
496    #[test]
497    fn proxy_protocol_parse_socks4() {
498        let p: ProxyProtocol = "socks4://10.0.0.1:1080".parse().unwrap();
499        match p {
500            ProxyProtocol::Socks {
501                version,
502                auth,
503                addr,
504            } => {
505                assert_eq!(version, SocksVersion::V4);
506                assert_eq!(auth, SocksAuth::NoAuth);
507                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
508            }
509            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
510        }
511    }
512
513    #[test]
514    fn proxy_protocol_parse_socks4a() {
515        let p: ProxyProtocol = "socks4a://10.0.0.1:1080".parse().unwrap();
516        match p {
517            ProxyProtocol::Socks { version, auth, .. } => {
518                assert_eq!(version, SocksVersion::V4);
519                assert_eq!(auth, SocksAuth::NoAuth);
520            }
521            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
522        }
523    }
524
525    #[test]
526    fn proxy_protocol_parse_ipv6() {
527        let p: ProxyProtocol = "socks5://[::1]:1080".parse().unwrap();
528        match p {
529            ProxyProtocol::Socks { addr, .. } => {
530                assert_eq!(addr, "[::1]:1080".parse().unwrap());
531            }
532            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
533        }
534    }
535
536    #[test]
537    fn proxy_protocol_display_roundtrip() {
538        for uri in [
539            "socks5://127.0.0.1:1080",
540            "socks4://10.0.0.1:9050",
541            "socks5://user:pass@192.168.1.1:1080",
542            "socks5://[::1]:1080",
543            "http://127.0.0.1:8080",
544            "http://user:pass@192.168.1.1:3128",
545        ] {
546            let p: ProxyProtocol = uri.parse().unwrap();
547            let s = p.to_string();
548            let p2: ProxyProtocol = s.parse().unwrap();
549            assert_eq!(p, p2, "Round-trip failed for: {}", uri);
550        }
551    }
552
553    #[test]
554    fn proxy_protocol_parse_errors() {
555        // Missing scheme
556        assert!("127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
557
558        // Invalid scheme
559        assert!("invalid://127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
560
561        // Missing port
562        assert!("socks5://127.0.0.1".parse::<ProxyProtocol>().is_err());
563
564        // Invalid address
565        assert!("socks5://not-an-ip:1080".parse::<ProxyProtocol>().is_err());
566
567        // SOCKS4 does not support passwords
568        assert!(
569            "socks4://user:pass@10.0.0.1:1080"
570                .parse::<ProxyProtocol>()
571                .is_err()
572        );
573    }
574
575    #[test]
576    fn proxy_protocol_case_insensitive() {
577        // Scheme parsing should be case-insensitive
578        let p1: ProxyProtocol = "SOCKS5://127.0.0.1:1080".parse().unwrap();
579        let p2: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
580        let p3: ProxyProtocol = "SoCkS5://127.0.0.1:1080".parse().unwrap();
581
582        assert_eq!(p1, p2);
583        assert_eq!(p2, p3);
584    }
585
586    #[test]
587    fn proxy_protocol_parse_socks5h() {
588        // socks5h:// should be treated as socks5
589        let p: ProxyProtocol = "socks5h://127.0.0.1:1080".parse().unwrap();
590        match p {
591            ProxyProtocol::Socks { version, auth, .. } => {
592                assert_eq!(version, SocksVersion::V5);
593                assert_eq!(auth, SocksAuth::NoAuth);
594            }
595            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
596        }
597    }
598
599    #[test]
600    fn proxy_protocol_parse_socks4_user_only() {
601        // SOCKS4 with user only (no password)
602        let p: ProxyProtocol = "socks4://myuser@10.0.0.1:1080".parse().unwrap();
603        match p {
604            ProxyProtocol::Socks {
605                version,
606                auth,
607                addr,
608            } => {
609                assert_eq!(version, SocksVersion::V4);
610                assert_eq!(auth, SocksAuth::Socks4(b"myuser".to_vec()));
611                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
612            }
613            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
614        }
615    }
616
617    #[test]
618    fn proxy_protocol_parse_socks5_user_only() {
619        // SOCKS5 with user only (empty password)
620        let p: ProxyProtocol = "socks5://myuser@192.168.1.1:9050".parse().unwrap();
621        match p {
622            ProxyProtocol::Socks {
623                version,
624                auth,
625                addr,
626            } => {
627                assert_eq!(version, SocksVersion::V5);
628                assert_eq!(auth, SocksAuth::Username(b"myuser".to_vec(), b"".to_vec()));
629                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
630            }
631            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
632        }
633    }
634
635    #[test]
636    fn proxy_protocol_percent_encoding_roundtrip() {
637        // Test percent-encoding round-trip for special characters
638        // User with @ and : characters that need encoding
639        let p = ProxyProtocol::Socks {
640            version: SocksVersion::V5,
641            auth: SocksAuth::Username(b"user@domain".to_vec(), b"pass:word".to_vec()),
642            addr: "127.0.0.1:1080".parse().unwrap(),
643        };
644        let s = p.to_string();
645        // Should contain percent-encoded characters
646        assert!(s.contains("%40"), "@ should be encoded as %40");
647        assert!(
648            s.contains("%3A") || s.contains("%3a"),
649            ": in password should be encoded"
650        );
651
652        // Parse it back
653        let p2: ProxyProtocol = s.parse().unwrap();
654        assert_eq!(p, p2, "Round-trip failed for percent-encoded URI");
655    }
656
657    #[test]
658    fn proxy_protocol_socks4_user_roundtrip() {
659        // SOCKS4 user-only format should round-trip
660        let uri = "socks4://testuser@10.0.0.1:1080";
661        let p: ProxyProtocol = uri.parse().unwrap();
662        let s = p.to_string();
663        let p2: ProxyProtocol = s.parse().unwrap();
664        assert_eq!(p, p2, "SOCKS4 user-only round-trip failed");
665    }
666
667    #[test]
668    fn proxy_protocol_parse_http_connect_basic() {
669        let p: ProxyProtocol = "http://127.0.0.1:8080".parse().unwrap();
670        match p {
671            ProxyProtocol::HttpConnect { addr, credentials } => {
672                assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
673                assert!(credentials.is_none());
674            }
675            _ => panic!("expected HttpConnect"),
676        }
677    }
678
679    #[test]
680    fn proxy_protocol_parse_http_connect_with_auth() {
681        let p: ProxyProtocol = "http://myuser:mypass@192.168.1.1:3128".parse().unwrap();
682        match p {
683            ProxyProtocol::HttpConnect { addr, credentials } => {
684                assert_eq!(addr, "192.168.1.1:3128".parse().unwrap());
685                let auth = credentials.expect("expected credentials");
686                assert_eq!(auth.username, "myuser");
687                assert_eq!(auth.password.as_deref(), Some("mypass"));
688            }
689            _ => panic!("expected HttpConnect"),
690        }
691    }
692
693    #[test]
694    fn proxy_protocol_parse_http_connect_ipv6() {
695        let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
696        match p {
697            ProxyProtocol::HttpConnect { addr, .. } => {
698                assert_eq!(addr, "[::1]:8080".parse().unwrap());
699            }
700            _ => panic!("expected HttpConnect"),
701        }
702    }
703
704    #[test]
705    fn proxy_protocol_parse_http_connect_user_only() {
706        // user@host means username only; password is None (empty when building Basic auth)
707        let p: ProxyProtocol = "http://myuser@127.0.0.1:8080".parse().unwrap();
708        match p {
709            ProxyProtocol::HttpConnect { credentials, .. } => {
710                let auth = credentials.expect("expected credentials");
711                assert_eq!(auth.username, "myuser");
712                assert!(auth.password.is_none());
713            }
714            _ => panic!("expected HttpConnect"),
715        }
716    }
717
718    #[test]
719    fn proxy_protocol_reject_password_only() {
720        // http://:pass@host:port is invalid - username is required for auth
721        let result: Result<ProxyProtocol, _> = "http://:secretpass@127.0.0.1:8080".parse();
722        assert!(result.is_err());
723        let err = result.unwrap_err();
724        assert!(
725            err.to_string().contains("password without username"),
726            "error should mention password without username: {}",
727            err
728        );
729    }
730
731    #[test]
732    fn proxy_protocol_is_loopback() {
733        // Loopback IPv4
734        let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
735        assert!(p.is_loopback());
736
737        // Loopback IPv6
738        let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
739        assert!(p.is_loopback());
740
741        // Non-loopback IPv4
742        let p: ProxyProtocol = "socks5://10.0.0.1:1080".parse().unwrap();
743        assert!(!p.is_loopback());
744
745        // Non-loopback IPv6
746        let p: ProxyProtocol = "http://[2001:db8::1]:8080".parse().unwrap();
747        assert!(!p.is_loopback());
748    }
749
750    #[test]
751    fn proxy_protocol_http_connect_percent_encoding_roundtrip() {
752        // Test percent-encoding round-trip for HTTP CONNECT with special characters
753        // Username contains @ and password contains : - both need encoding
754        let p = ProxyProtocol::HttpConnect {
755            addr: "127.0.0.1:8080".parse().unwrap(),
756            credentials: Some(HttpConnectAuth {
757                username: "user@domain".to_string(),
758                password: Some("pass:word".to_string()),
759            }),
760        };
761        let s = p.to_string();
762
763        // Verify percent-encoded characters are present
764        assert!(s.contains("%40"), "@ should be encoded as %40: {}", s);
765        assert!(
766            s.contains("%3A") || s.contains("%3a"),
767            ": in password should be encoded: {}",
768            s
769        );
770
771        // Parse it back and verify equality
772        let p2: ProxyProtocol = s.parse().unwrap();
773        assert_eq!(
774            p, p2,
775            "Round-trip failed for percent-encoded HTTP CONNECT URI"
776        );
777    }
778
779    #[test]
780    fn proxy_protocol_http_connect_parse_percent_encoded() {
781        // Parse an already percent-encoded URI and verify credentials decode correctly
782        let p: ProxyProtocol = "http://user%40domain:pass%3Aword@127.0.0.1:8080"
783            .parse()
784            .unwrap();
785        match p {
786            ProxyProtocol::HttpConnect { addr, credentials } => {
787                assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
788                let auth = credentials.expect("expected credentials");
789                assert_eq!(
790                    auth.username, "user@domain",
791                    "username should decode %40 to @"
792                );
793                assert_eq!(
794                    auth.password.as_deref(),
795                    Some("pass:word"),
796                    "password should decode %3A to :"
797                );
798            }
799            _ => panic!("expected HttpConnect"),
800        }
801    }
802}