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
427impl ChannelConfig {
428    /// Return the outbound proxy configured for this channel, if any.
429    pub fn outbound_proxy(&self) -> Option<&ProxyProtocol> {
430        self.outbound_proxy.as_ref()
431    }
432}
433
434#[cfg(feature = "testing")]
435impl ChannelConfig {
436    /// The padding level (accessor for testing)
437    pub fn padding(&self) -> PaddingLevel {
438        self.padding
439    }
440}
441
442#[cfg(test)]
443mod test {
444    // @@ begin test lint list maintained by maint/add_warning @@
445    #![allow(clippy::bool_assert_comparison)]
446    #![allow(clippy::clone_on_copy)]
447    #![allow(clippy::dbg_macro)]
448    #![allow(clippy::mixed_attributes_style)]
449    #![allow(clippy::print_stderr)]
450    #![allow(clippy::print_stdout)]
451    #![allow(clippy::single_char_pattern)]
452    #![allow(clippy::unwrap_used)]
453    #![allow(clippy::unchecked_time_subtraction)]
454    #![allow(clippy::useless_vec)]
455    #![allow(clippy::needless_pass_by_value)]
456    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
457    use super::*;
458
459    #[test]
460    fn channel_config() {
461        let config = ChannelConfig::default();
462
463        assert_eq!(PaddingLevel::Normal, config.padding);
464    }
465
466    #[test]
467    fn proxy_protocol_parse_socks5_basic() {
468        let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
469        match p {
470            ProxyProtocol::Socks {
471                version,
472                auth,
473                addr,
474            } => {
475                assert_eq!(version, SocksVersion::V5);
476                assert_eq!(auth, SocksAuth::NoAuth);
477                assert_eq!(addr, "127.0.0.1:1080".parse().unwrap());
478            }
479            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
480        }
481    }
482
483    #[test]
484    fn proxy_protocol_parse_socks5_with_auth() {
485        let p: ProxyProtocol = "socks5://myuser:mypass@192.168.1.1:9050".parse().unwrap();
486        match p {
487            ProxyProtocol::Socks {
488                version,
489                auth,
490                addr,
491            } => {
492                assert_eq!(version, SocksVersion::V5);
493                assert_eq!(
494                    auth,
495                    SocksAuth::Username(b"myuser".to_vec(), b"mypass".to_vec())
496                );
497                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
498            }
499            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
500        }
501    }
502
503    #[test]
504    fn proxy_protocol_parse_socks4() {
505        let p: ProxyProtocol = "socks4://10.0.0.1:1080".parse().unwrap();
506        match p {
507            ProxyProtocol::Socks {
508                version,
509                auth,
510                addr,
511            } => {
512                assert_eq!(version, SocksVersion::V4);
513                assert_eq!(auth, SocksAuth::NoAuth);
514                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
515            }
516            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
517        }
518    }
519
520    #[test]
521    fn proxy_protocol_parse_socks4a() {
522        let p: ProxyProtocol = "socks4a://10.0.0.1:1080".parse().unwrap();
523        match p {
524            ProxyProtocol::Socks { version, auth, .. } => {
525                assert_eq!(version, SocksVersion::V4);
526                assert_eq!(auth, SocksAuth::NoAuth);
527            }
528            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
529        }
530    }
531
532    #[test]
533    fn proxy_protocol_parse_ipv6() {
534        let p: ProxyProtocol = "socks5://[::1]:1080".parse().unwrap();
535        match p {
536            ProxyProtocol::Socks { addr, .. } => {
537                assert_eq!(addr, "[::1]:1080".parse().unwrap());
538            }
539            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
540        }
541    }
542
543    #[test]
544    fn proxy_protocol_display_roundtrip() {
545        for uri in [
546            "socks5://127.0.0.1:1080",
547            "socks4://10.0.0.1:9050",
548            "socks5://user:pass@192.168.1.1:1080",
549            "socks5://[::1]:1080",
550            "http://127.0.0.1:8080",
551            "http://user:pass@192.168.1.1:3128",
552        ] {
553            let p: ProxyProtocol = uri.parse().unwrap();
554            let s = p.to_string();
555            let p2: ProxyProtocol = s.parse().unwrap();
556            assert_eq!(p, p2, "Round-trip failed for: {}", uri);
557        }
558    }
559
560    #[test]
561    fn proxy_protocol_parse_errors() {
562        // Missing scheme
563        assert!("127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
564
565        // Invalid scheme
566        assert!("invalid://127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
567
568        // Missing port
569        assert!("socks5://127.0.0.1".parse::<ProxyProtocol>().is_err());
570
571        // Invalid address
572        assert!("socks5://not-an-ip:1080".parse::<ProxyProtocol>().is_err());
573
574        // SOCKS4 does not support passwords
575        assert!(
576            "socks4://user:pass@10.0.0.1:1080"
577                .parse::<ProxyProtocol>()
578                .is_err()
579        );
580    }
581
582    #[test]
583    fn proxy_protocol_case_insensitive() {
584        // Scheme parsing should be case-insensitive
585        let p1: ProxyProtocol = "SOCKS5://127.0.0.1:1080".parse().unwrap();
586        let p2: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
587        let p3: ProxyProtocol = "SoCkS5://127.0.0.1:1080".parse().unwrap();
588
589        assert_eq!(p1, p2);
590        assert_eq!(p2, p3);
591    }
592
593    #[test]
594    fn proxy_protocol_parse_socks5h() {
595        // socks5h:// should be treated as socks5
596        let p: ProxyProtocol = "socks5h://127.0.0.1:1080".parse().unwrap();
597        match p {
598            ProxyProtocol::Socks { version, auth, .. } => {
599                assert_eq!(version, SocksVersion::V5);
600                assert_eq!(auth, SocksAuth::NoAuth);
601            }
602            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
603        }
604    }
605
606    #[test]
607    fn proxy_protocol_parse_socks4_user_only() {
608        // SOCKS4 with user only (no password)
609        let p: ProxyProtocol = "socks4://myuser@10.0.0.1:1080".parse().unwrap();
610        match p {
611            ProxyProtocol::Socks {
612                version,
613                auth,
614                addr,
615            } => {
616                assert_eq!(version, SocksVersion::V4);
617                assert_eq!(auth, SocksAuth::Socks4(b"myuser".to_vec()));
618                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
619            }
620            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
621        }
622    }
623
624    #[test]
625    fn proxy_protocol_parse_socks5_user_only() {
626        // SOCKS5 with user only (empty password)
627        let p: ProxyProtocol = "socks5://myuser@192.168.1.1:9050".parse().unwrap();
628        match p {
629            ProxyProtocol::Socks {
630                version,
631                auth,
632                addr,
633            } => {
634                assert_eq!(version, SocksVersion::V5);
635                assert_eq!(auth, SocksAuth::Username(b"myuser".to_vec(), b"".to_vec()));
636                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
637            }
638            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
639        }
640    }
641
642    #[test]
643    fn proxy_protocol_percent_encoding_roundtrip() {
644        // Test percent-encoding round-trip for special characters
645        // User with @ and : characters that need encoding
646        let p = ProxyProtocol::Socks {
647            version: SocksVersion::V5,
648            auth: SocksAuth::Username(b"user@domain".to_vec(), b"pass:word".to_vec()),
649            addr: "127.0.0.1:1080".parse().unwrap(),
650        };
651        let s = p.to_string();
652        // Should contain percent-encoded characters
653        assert!(s.contains("%40"), "@ should be encoded as %40");
654        assert!(
655            s.contains("%3A") || s.contains("%3a"),
656            ": in password should be encoded"
657        );
658
659        // Parse it back
660        let p2: ProxyProtocol = s.parse().unwrap();
661        assert_eq!(p, p2, "Round-trip failed for percent-encoded URI");
662    }
663
664    #[test]
665    fn proxy_protocol_socks4_user_roundtrip() {
666        // SOCKS4 user-only format should round-trip
667        let uri = "socks4://testuser@10.0.0.1:1080";
668        let p: ProxyProtocol = uri.parse().unwrap();
669        let s = p.to_string();
670        let p2: ProxyProtocol = s.parse().unwrap();
671        assert_eq!(p, p2, "SOCKS4 user-only round-trip failed");
672    }
673
674    #[test]
675    fn proxy_protocol_parse_http_connect_basic() {
676        let p: ProxyProtocol = "http://127.0.0.1:8080".parse().unwrap();
677        match p {
678            ProxyProtocol::HttpConnect { addr, credentials } => {
679                assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
680                assert!(credentials.is_none());
681            }
682            _ => panic!("expected HttpConnect"),
683        }
684    }
685
686    #[test]
687    fn proxy_protocol_parse_http_connect_with_auth() {
688        let p: ProxyProtocol = "http://myuser:mypass@192.168.1.1:3128".parse().unwrap();
689        match p {
690            ProxyProtocol::HttpConnect { addr, credentials } => {
691                assert_eq!(addr, "192.168.1.1:3128".parse().unwrap());
692                let auth = credentials.expect("expected credentials");
693                assert_eq!(auth.username, "myuser");
694                assert_eq!(auth.password.as_deref(), Some("mypass"));
695            }
696            _ => panic!("expected HttpConnect"),
697        }
698    }
699
700    #[test]
701    fn proxy_protocol_parse_http_connect_ipv6() {
702        let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
703        match p {
704            ProxyProtocol::HttpConnect { addr, .. } => {
705                assert_eq!(addr, "[::1]:8080".parse().unwrap());
706            }
707            _ => panic!("expected HttpConnect"),
708        }
709    }
710
711    #[test]
712    fn proxy_protocol_parse_http_connect_user_only() {
713        // user@host means username only; password is None (empty when building Basic auth)
714        let p: ProxyProtocol = "http://myuser@127.0.0.1:8080".parse().unwrap();
715        match p {
716            ProxyProtocol::HttpConnect { credentials, .. } => {
717                let auth = credentials.expect("expected credentials");
718                assert_eq!(auth.username, "myuser");
719                assert!(auth.password.is_none());
720            }
721            _ => panic!("expected HttpConnect"),
722        }
723    }
724
725    #[test]
726    fn proxy_protocol_reject_password_only() {
727        // http://:pass@host:port is invalid - username is required for auth
728        let result: Result<ProxyProtocol, _> = "http://:secretpass@127.0.0.1:8080".parse();
729        assert!(result.is_err());
730        let err = result.unwrap_err();
731        assert!(
732            err.to_string().contains("password without username"),
733            "error should mention password without username: {}",
734            err
735        );
736    }
737
738    #[test]
739    fn proxy_protocol_is_loopback() {
740        // Loopback IPv4
741        let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
742        assert!(p.is_loopback());
743
744        // Loopback IPv6
745        let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
746        assert!(p.is_loopback());
747
748        // Non-loopback IPv4
749        let p: ProxyProtocol = "socks5://10.0.0.1:1080".parse().unwrap();
750        assert!(!p.is_loopback());
751
752        // Non-loopback IPv6
753        let p: ProxyProtocol = "http://[2001:db8::1]:8080".parse().unwrap();
754        assert!(!p.is_loopback());
755    }
756
757    #[test]
758    fn proxy_protocol_http_connect_percent_encoding_roundtrip() {
759        // Test percent-encoding round-trip for HTTP CONNECT with special characters
760        // Username contains @ and password contains : - both need encoding
761        let p = ProxyProtocol::HttpConnect {
762            addr: "127.0.0.1:8080".parse().unwrap(),
763            credentials: Some(HttpConnectAuth {
764                username: "user@domain".to_string(),
765                password: Some("pass:word".to_string()),
766            }),
767        };
768        let s = p.to_string();
769
770        // Verify percent-encoded characters are present
771        assert!(s.contains("%40"), "@ should be encoded as %40: {}", s);
772        assert!(
773            s.contains("%3A") || s.contains("%3a"),
774            ": in password should be encoded: {}",
775            s
776        );
777
778        // Parse it back and verify equality
779        let p2: ProxyProtocol = s.parse().unwrap();
780        assert_eq!(
781            p, p2,
782            "Round-trip failed for percent-encoded HTTP CONNECT URI"
783        );
784    }
785
786    #[test]
787    fn proxy_protocol_http_connect_parse_percent_encoded() {
788        // Parse an already percent-encoded URI and verify credentials decode correctly
789        let p: ProxyProtocol = "http://user%40domain:pass%3Aword@127.0.0.1:8080"
790            .parse()
791            .unwrap();
792        match p {
793            ProxyProtocol::HttpConnect { addr, credentials } => {
794                assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
795                let auth = credentials.expect("expected credentials");
796                assert_eq!(
797                    auth.username, "user@domain",
798                    "username should decode %40 to @"
799                );
800                assert_eq!(
801                    auth.password.as_deref(),
802                    Some("pass:word"),
803                    "password should decode %3A to :"
804                );
805            }
806            _ => panic!("expected HttpConnect"),
807        }
808    }
809}