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