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

            
7
use derive_deftly::Deftly;
8
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
9
use serde::Deserialize;
10
use std::net::{IpAddr, SocketAddr};
11
use tor_config::PaddingLevel;
12
use tor_config::derive::prelude::*;
13
use tor_socksproto::SocksAuth;
14
use tor_socksproto::SocksVersion;
15
use url::{Host, Url};
16

            
17
/// Error parsing a proxy URI string
18
#[derive(Debug, Clone, thiserror::Error)]
19
#[non_exhaustive]
20
pub 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)]
44
pub 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]
83
pub 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

            
102
impl std::str::FromStr for ProxyProtocol {
103
    type Err = ProxyProtocolParseError;
104

            
105
84
    fn from_str(s: &str) -> Result<Self, Self::Err> {
106
85
        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
2
            _ => ProxyProtocolParseError::InvalidFormat(s.to_string()),
114
3
        })?;
115

            
116
82
        let scheme_lower = url.scheme().to_ascii_lowercase();
117

            
118
82
        if url.query().is_some() || url.fragment().is_some() {
119
            return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
120
82
        }
121

            
122
82
        let path = url.path();
123
82
        if !path.is_empty() && path != "/" {
124
            return Err(ProxyProtocolParseError::InvalidFormat(s.to_string()));
125
82
        }
126

            
127
82
        let port = url.port().ok_or(ProxyProtocolParseError::InvalidPort)?;
128
80
        let host = url
129
80
            .host()
130
80
            .ok_or_else(|| ProxyProtocolParseError::InvalidAddress(s.to_string()))?;
131
80
        let ip = match host {
132
20
            Host::Ipv4(ip) => IpAddr::V4(ip),
133
12
            Host::Ipv6(ip) => IpAddr::V6(ip),
134
48
            Host::Domain(domain) => domain
135
48
                .parse::<IpAddr>()
136
49
                .map_err(|_| ProxyProtocolParseError::InvalidAddress(domain.to_string()))?,
137
        };
138
78
        let addr = SocketAddr::new(ip, port);
139

            
140
78
        match scheme_lower.as_str() {
141
78
            "http" => {
142
                // HTTP CONNECT: optional Basic auth via user:pass@host:port
143
26
                let user = url.username();
144
26
                let pass = url.password();
145
                // Reject password-only auth (http://:pass@host:port) - username is required
146
26
                if user.is_empty() && pass.is_some() {
147
2
                    return Err(ProxyProtocolParseError::InvalidFormat(
148
2
                        "password without username not supported".to_string(),
149
2
                    ));
150
24
                }
151
24
                let credentials = if user.is_empty() {
152
12
                    None
153
                } else {
154
12
                    let username = percent_decode_str(user)
155
12
                        .decode_utf8()
156
12
                        .map_err(|_| {
157
                            ProxyProtocolParseError::InvalidFormat(
158
                                "invalid UTF-8 in username".to_string(),
159
                            )
160
                        })?
161
12
                        .into_owned();
162
12
                    let password = pass
163
17
                        .map(|p| {
164
10
                            percent_decode_str(p).decode_utf8().map_err(|_| {
165
                                ProxyProtocolParseError::InvalidFormat(
166
                                    "invalid UTF-8 in password".to_string(),
167
                                )
168
                            })
169
10
                        })
170
12
                        .transpose()?
171
17
                        .map(|s| s.into_owned());
172
12
                    Some(HttpConnectAuth { username, password })
173
                };
174
24
                Ok(ProxyProtocol::HttpConnect { addr, credentials })
175
            }
176
52
            "socks4" | "socks4a" | "socks5" | "socks5h" => {
177
50
                let version = match scheme_lower.as_str() {
178
50
                    "socks4" | "socks4a" => SocksVersion::V4,
179
34
                    "socks5" | "socks5h" => SocksVersion::V5,
180
                    _ => unreachable!(),
181
                };
182
                // Check for authentication credentials (user:pass@host:port or user@host:port).
183
50
                let user = url.username();
184
50
                let pass = url.password();
185
50
                if version == SocksVersion::V4 && pass.is_some() {
186
2
                    return Err(ProxyProtocolParseError::UnsupportedPassword(
187
2
                        url.scheme().to_string(),
188
2
                    ));
189
48
                }
190
48
                let user_decoded = percent_decode_str(user).decode_utf8().map_err(|_| {
191
                    ProxyProtocolParseError::InvalidFormat("invalid UTF-8 in username".to_string())
192
                })?;
193
48
                let pass_decoded = pass
194
52
                    .map(|p| {
195
8
                        percent_decode_str(p).decode_utf8().map_err(|_| {
196
                            ProxyProtocolParseError::InvalidFormat(
197
                                "invalid UTF-8 in password".to_string(),
198
                            )
199
                        })
200
8
                    })
201
48
                    .transpose()?;
202
48
                let auth = if user.is_empty() && pass.is_none() {
203
32
                    SocksAuth::NoAuth
204
                } else {
205
16
                    match version {
206
6
                        SocksVersion::V4 => SocksAuth::Socks4(user_decoded.as_bytes().to_vec()),
207
                        SocksVersion::V5 => {
208
10
                            let pass = pass_decoded.as_deref().unwrap_or("");
209
10
                            SocksAuth::Username(
210
10
                                user_decoded.as_bytes().to_vec(),
211
10
                                pass.as_bytes().to_vec(),
212
10
                            )
213
                        }
214
                        _ => SocksAuth::NoAuth,
215
                    }
216
                };
217
48
                Ok(ProxyProtocol::Socks {
218
48
                    version,
219
48
                    auth,
220
48
                    addr,
221
48
                })
222
            }
223
2
            _ => Err(ProxyProtocolParseError::UnsupportedScheme(
224
2
                url.scheme().to_string(),
225
2
            )),
226
        }
227
84
    }
228
}
229

            
230
impl std::fmt::Display for ProxyProtocol {
231
18
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232
18
        match self {
233
            ProxyProtocol::Socks {
234
12
                version,
235
12
                auth,
236
12
                addr,
237
            } => {
238
                // Use SocksVersion's Display impl for the scheme (e.g., "socks5")
239
12
                match auth {
240
6
                    SocksAuth::NoAuth => write!(f, "{}://{}", version, addr),
241
2
                    SocksAuth::Socks4(user_id) => {
242
                        // SOCKS4: user@host format (no password in SOCKS4)
243
2
                        let user = String::from_utf8_lossy(user_id);
244
2
                        match encode_userinfo(*version, *addr, &user, None) {
245
2
                            Some((user_encoded, _)) => {
246
2
                                write!(f, "{}://{}@{}", version, user_encoded, addr)
247
                            }
248
                            None => write!(f, "{}://{}@{}", version, user, addr),
249
                        }
250
                    }
251
4
                    SocksAuth::Username(user, pass) => {
252
                        // SOCKS5: user:pass@host format
253
4
                        let user = String::from_utf8_lossy(user);
254
4
                        let pass = String::from_utf8_lossy(pass);
255
4
                        match encode_userinfo(*version, *addr, &user, Some(&pass)) {
256
4
                            Some((user_encoded, pass_encoded)) => {
257
4
                                let pass_encoded = pass_encoded.unwrap_or_default();
258
4
                                write!(
259
4
                                    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
6
            ProxyProtocol::HttpConnect { addr, credentials } => {
272
6
                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
4
                    let (user_encoded, pass_encoded) =
276
4
                        encode_userinfo_http(*addr, &auth.username, auth.password.as_deref())
277
4
                            .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
4
                    if let Some(p) = pass_encoded {
290
4
                        write!(f, "http://{}:{}@{}", user_encoded, p, addr)
291
                    } else {
292
                        write!(f, "http://{}@{}", user_encoded, addr)
293
                    }
294
                } else {
295
2
                    write!(f, "http://{}", addr)
296
                }
297
            }
298
        }
299
18
    }
300
}
301

            
302
impl ProxyProtocol {
303
    /// Check whether the proxy server address is on the loopback interface.
304
8
    pub fn is_loopback(&self) -> bool {
305
8
        let addr = match self {
306
4
            ProxyProtocol::Socks { addr, .. } => addr,
307
4
            ProxyProtocol::HttpConnect { addr, .. } => addr,
308
        };
309
8
        addr.ip().is_loopback()
310
8
    }
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.
316
const 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).
327
fn 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.
335
10
fn encode_userinfo_with_scheme(
336
10
    scheme: &str,
337
10
    addr: SocketAddr,
338
10
    username: &str,
339
10
    password: Option<&str>,
340
10
) -> Option<(String, Option<String>)> {
341
10
    let url_str = format!("{}://{}", scheme, addr);
342
10
    let mut url = Url::parse(&url_str).ok()?;
343
10
    if url.set_username(username).is_err() {
344
        return None;
345
10
    }
346
10
    if url.set_password(password).is_err() {
347
        return None;
348
10
    }
349
10
    let user_encoded = url.username().to_string();
350
10
    let pass_encoded = url.password().map(str::to_string);
351
10
    Some((user_encoded, pass_encoded))
352
10
}
353

            
354
/// URL-encodes username and optional password for HTTP CONNECT proxy userinfo display.
355
4
fn encode_userinfo_http(
356
4
    addr: SocketAddr,
357
4
    username: &str,
358
4
    password: Option<&str>,
359
4
) -> Option<(String, Option<String>)> {
360
4
    encode_userinfo_with_scheme("http", addr, username, password)
361
4
}
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.
367
6
fn encode_userinfo(
368
6
    version: SocksVersion,
369
6
    addr: SocketAddr,
370
6
    username: &str,
371
6
    password: Option<&str>,
372
6
) -> Option<(String, Option<String>)> {
373
6
    encode_userinfo_with_scheme(&version.to_string(), addr, username, password)
374
6
}
375

            
376
impl 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)]
389
4
fn deserialize_outbound_proxy<'de, D>(
390
4
    deserializer: D,
391
4
) -> Result<Option<Option<ProxyProtocol>>, D::Error>
392
4
where
393
4
    D: serde::Deserializer<'de>,
394
{
395
4
    let value = Option::<String>::deserialize(deserializer)?;
396
4
    match value {
397
        None => Ok(None),
398
4
        Some(s) => {
399
4
            if s.trim().is_empty() {
400
4
                return Ok(Some(None));
401
            }
402
            let parsed = s.parse().map_err(serde::de::Error::custom)?;
403
            Ok(Some(Some(parsed)))
404
        }
405
    }
406
4
}
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)]
414
pub 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
impl 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")]
435
impl ChannelConfig {
436
    /// The padding level (accessor for testing)
437
    pub fn padding(&self) -> PaddingLevel {
438
        self.padding
439
    }
440
}
441

            
442
#[cfg(test)]
443
mod 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
}