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
4
                                    "{}://{}:{}@{}",
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
#[cfg(feature = "testing")]
428
impl ChannelConfig {
429
    /// The padding level (accessor for testing)
430
    pub fn padding(&self) -> PaddingLevel {
431
        self.padding
432
    }
433
}
434

            
435
#[cfg(test)]
436
mod test {
437
    // @@ begin test lint list maintained by maint/add_warning @@
438
    #![allow(clippy::bool_assert_comparison)]
439
    #![allow(clippy::clone_on_copy)]
440
    #![allow(clippy::dbg_macro)]
441
    #![allow(clippy::mixed_attributes_style)]
442
    #![allow(clippy::print_stderr)]
443
    #![allow(clippy::print_stdout)]
444
    #![allow(clippy::single_char_pattern)]
445
    #![allow(clippy::unwrap_used)]
446
    #![allow(clippy::unchecked_time_subtraction)]
447
    #![allow(clippy::useless_vec)]
448
    #![allow(clippy::needless_pass_by_value)]
449
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
450
    use super::*;
451

            
452
    #[test]
453
    fn channel_config() {
454
        let config = ChannelConfig::default();
455

            
456
        assert_eq!(PaddingLevel::Normal, config.padding);
457
    }
458

            
459
    #[test]
460
    fn proxy_protocol_parse_socks5_basic() {
461
        let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
462
        match p {
463
            ProxyProtocol::Socks {
464
                version,
465
                auth,
466
                addr,
467
            } => {
468
                assert_eq!(version, SocksVersion::V5);
469
                assert_eq!(auth, SocksAuth::NoAuth);
470
                assert_eq!(addr, "127.0.0.1:1080".parse().unwrap());
471
            }
472
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
473
        }
474
    }
475

            
476
    #[test]
477
    fn proxy_protocol_parse_socks5_with_auth() {
478
        let p: ProxyProtocol = "socks5://myuser:mypass@192.168.1.1:9050".parse().unwrap();
479
        match p {
480
            ProxyProtocol::Socks {
481
                version,
482
                auth,
483
                addr,
484
            } => {
485
                assert_eq!(version, SocksVersion::V5);
486
                assert_eq!(
487
                    auth,
488
                    SocksAuth::Username(b"myuser".to_vec(), b"mypass".to_vec())
489
                );
490
                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
491
            }
492
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
493
        }
494
    }
495

            
496
    #[test]
497
    fn proxy_protocol_parse_socks4() {
498
        let p: ProxyProtocol = "socks4://10.0.0.1:1080".parse().unwrap();
499
        match p {
500
            ProxyProtocol::Socks {
501
                version,
502
                auth,
503
                addr,
504
            } => {
505
                assert_eq!(version, SocksVersion::V4);
506
                assert_eq!(auth, SocksAuth::NoAuth);
507
                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
508
            }
509
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
510
        }
511
    }
512

            
513
    #[test]
514
    fn proxy_protocol_parse_socks4a() {
515
        let p: ProxyProtocol = "socks4a://10.0.0.1:1080".parse().unwrap();
516
        match p {
517
            ProxyProtocol::Socks { version, auth, .. } => {
518
                assert_eq!(version, SocksVersion::V4);
519
                assert_eq!(auth, SocksAuth::NoAuth);
520
            }
521
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
522
        }
523
    }
524

            
525
    #[test]
526
    fn proxy_protocol_parse_ipv6() {
527
        let p: ProxyProtocol = "socks5://[::1]:1080".parse().unwrap();
528
        match p {
529
            ProxyProtocol::Socks { addr, .. } => {
530
                assert_eq!(addr, "[::1]:1080".parse().unwrap());
531
            }
532
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
533
        }
534
    }
535

            
536
    #[test]
537
    fn proxy_protocol_display_roundtrip() {
538
        for uri in [
539
            "socks5://127.0.0.1:1080",
540
            "socks4://10.0.0.1:9050",
541
            "socks5://user:pass@192.168.1.1:1080",
542
            "socks5://[::1]:1080",
543
            "http://127.0.0.1:8080",
544
            "http://user:pass@192.168.1.1:3128",
545
        ] {
546
            let p: ProxyProtocol = uri.parse().unwrap();
547
            let s = p.to_string();
548
            let p2: ProxyProtocol = s.parse().unwrap();
549
            assert_eq!(p, p2, "Round-trip failed for: {}", uri);
550
        }
551
    }
552

            
553
    #[test]
554
    fn proxy_protocol_parse_errors() {
555
        // Missing scheme
556
        assert!("127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
557

            
558
        // Invalid scheme
559
        assert!("invalid://127.0.0.1:1080".parse::<ProxyProtocol>().is_err());
560

            
561
        // Missing port
562
        assert!("socks5://127.0.0.1".parse::<ProxyProtocol>().is_err());
563

            
564
        // Invalid address
565
        assert!("socks5://not-an-ip:1080".parse::<ProxyProtocol>().is_err());
566

            
567
        // SOCKS4 does not support passwords
568
        assert!(
569
            "socks4://user:pass@10.0.0.1:1080"
570
                .parse::<ProxyProtocol>()
571
                .is_err()
572
        );
573
    }
574

            
575
    #[test]
576
    fn proxy_protocol_case_insensitive() {
577
        // Scheme parsing should be case-insensitive
578
        let p1: ProxyProtocol = "SOCKS5://127.0.0.1:1080".parse().unwrap();
579
        let p2: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
580
        let p3: ProxyProtocol = "SoCkS5://127.0.0.1:1080".parse().unwrap();
581

            
582
        assert_eq!(p1, p2);
583
        assert_eq!(p2, p3);
584
    }
585

            
586
    #[test]
587
    fn proxy_protocol_parse_socks5h() {
588
        // socks5h:// should be treated as socks5
589
        let p: ProxyProtocol = "socks5h://127.0.0.1:1080".parse().unwrap();
590
        match p {
591
            ProxyProtocol::Socks { version, auth, .. } => {
592
                assert_eq!(version, SocksVersion::V5);
593
                assert_eq!(auth, SocksAuth::NoAuth);
594
            }
595
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
596
        }
597
    }
598

            
599
    #[test]
600
    fn proxy_protocol_parse_socks4_user_only() {
601
        // SOCKS4 with user only (no password)
602
        let p: ProxyProtocol = "socks4://myuser@10.0.0.1:1080".parse().unwrap();
603
        match p {
604
            ProxyProtocol::Socks {
605
                version,
606
                auth,
607
                addr,
608
            } => {
609
                assert_eq!(version, SocksVersion::V4);
610
                assert_eq!(auth, SocksAuth::Socks4(b"myuser".to_vec()));
611
                assert_eq!(addr, "10.0.0.1:1080".parse().unwrap());
612
            }
613
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
614
        }
615
    }
616

            
617
    #[test]
618
    fn proxy_protocol_parse_socks5_user_only() {
619
        // SOCKS5 with user only (empty password)
620
        let p: ProxyProtocol = "socks5://myuser@192.168.1.1:9050".parse().unwrap();
621
        match p {
622
            ProxyProtocol::Socks {
623
                version,
624
                auth,
625
                addr,
626
            } => {
627
                assert_eq!(version, SocksVersion::V5);
628
                assert_eq!(auth, SocksAuth::Username(b"myuser".to_vec(), b"".to_vec()));
629
                assert_eq!(addr, "192.168.1.1:9050".parse().unwrap());
630
            }
631
            ProxyProtocol::HttpConnect { .. } => panic!("expected Socks"),
632
        }
633
    }
634

            
635
    #[test]
636
    fn proxy_protocol_percent_encoding_roundtrip() {
637
        // Test percent-encoding round-trip for special characters
638
        // User with @ and : characters that need encoding
639
        let p = ProxyProtocol::Socks {
640
            version: SocksVersion::V5,
641
            auth: SocksAuth::Username(b"user@domain".to_vec(), b"pass:word".to_vec()),
642
            addr: "127.0.0.1:1080".parse().unwrap(),
643
        };
644
        let s = p.to_string();
645
        // Should contain percent-encoded characters
646
        assert!(s.contains("%40"), "@ should be encoded as %40");
647
        assert!(
648
            s.contains("%3A") || s.contains("%3a"),
649
            ": in password should be encoded"
650
        );
651

            
652
        // Parse it back
653
        let p2: ProxyProtocol = s.parse().unwrap();
654
        assert_eq!(p, p2, "Round-trip failed for percent-encoded URI");
655
    }
656

            
657
    #[test]
658
    fn proxy_protocol_socks4_user_roundtrip() {
659
        // SOCKS4 user-only format should round-trip
660
        let uri = "socks4://testuser@10.0.0.1:1080";
661
        let p: ProxyProtocol = uri.parse().unwrap();
662
        let s = p.to_string();
663
        let p2: ProxyProtocol = s.parse().unwrap();
664
        assert_eq!(p, p2, "SOCKS4 user-only round-trip failed");
665
    }
666

            
667
    #[test]
668
    fn proxy_protocol_parse_http_connect_basic() {
669
        let p: ProxyProtocol = "http://127.0.0.1:8080".parse().unwrap();
670
        match p {
671
            ProxyProtocol::HttpConnect { addr, credentials } => {
672
                assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
673
                assert!(credentials.is_none());
674
            }
675
            _ => panic!("expected HttpConnect"),
676
        }
677
    }
678

            
679
    #[test]
680
    fn proxy_protocol_parse_http_connect_with_auth() {
681
        let p: ProxyProtocol = "http://myuser:mypass@192.168.1.1:3128".parse().unwrap();
682
        match p {
683
            ProxyProtocol::HttpConnect { addr, credentials } => {
684
                assert_eq!(addr, "192.168.1.1:3128".parse().unwrap());
685
                let auth = credentials.expect("expected credentials");
686
                assert_eq!(auth.username, "myuser");
687
                assert_eq!(auth.password.as_deref(), Some("mypass"));
688
            }
689
            _ => panic!("expected HttpConnect"),
690
        }
691
    }
692

            
693
    #[test]
694
    fn proxy_protocol_parse_http_connect_ipv6() {
695
        let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
696
        match p {
697
            ProxyProtocol::HttpConnect { addr, .. } => {
698
                assert_eq!(addr, "[::1]:8080".parse().unwrap());
699
            }
700
            _ => panic!("expected HttpConnect"),
701
        }
702
    }
703

            
704
    #[test]
705
    fn proxy_protocol_parse_http_connect_user_only() {
706
        // user@host means username only; password is None (empty when building Basic auth)
707
        let p: ProxyProtocol = "http://myuser@127.0.0.1:8080".parse().unwrap();
708
        match p {
709
            ProxyProtocol::HttpConnect { credentials, .. } => {
710
                let auth = credentials.expect("expected credentials");
711
                assert_eq!(auth.username, "myuser");
712
                assert!(auth.password.is_none());
713
            }
714
            _ => panic!("expected HttpConnect"),
715
        }
716
    }
717

            
718
    #[test]
719
    fn proxy_protocol_reject_password_only() {
720
        // http://:pass@host:port is invalid - username is required for auth
721
        let result: Result<ProxyProtocol, _> = "http://:secretpass@127.0.0.1:8080".parse();
722
        assert!(result.is_err());
723
        let err = result.unwrap_err();
724
        assert!(
725
            err.to_string().contains("password without username"),
726
            "error should mention password without username: {}",
727
            err
728
        );
729
    }
730

            
731
    #[test]
732
    fn proxy_protocol_is_loopback() {
733
        // Loopback IPv4
734
        let p: ProxyProtocol = "socks5://127.0.0.1:1080".parse().unwrap();
735
        assert!(p.is_loopback());
736

            
737
        // Loopback IPv6
738
        let p: ProxyProtocol = "http://[::1]:8080".parse().unwrap();
739
        assert!(p.is_loopback());
740

            
741
        // Non-loopback IPv4
742
        let p: ProxyProtocol = "socks5://10.0.0.1:1080".parse().unwrap();
743
        assert!(!p.is_loopback());
744

            
745
        // Non-loopback IPv6
746
        let p: ProxyProtocol = "http://[2001:db8::1]:8080".parse().unwrap();
747
        assert!(!p.is_loopback());
748
    }
749

            
750
    #[test]
751
    fn proxy_protocol_http_connect_percent_encoding_roundtrip() {
752
        // Test percent-encoding round-trip for HTTP CONNECT with special characters
753
        // Username contains @ and password contains : - both need encoding
754
        let p = ProxyProtocol::HttpConnect {
755
            addr: "127.0.0.1:8080".parse().unwrap(),
756
            credentials: Some(HttpConnectAuth {
757
                username: "user@domain".to_string(),
758
                password: Some("pass:word".to_string()),
759
            }),
760
        };
761
        let s = p.to_string();
762

            
763
        // Verify percent-encoded characters are present
764
        assert!(s.contains("%40"), "@ should be encoded as %40: {}", s);
765
        assert!(
766
            s.contains("%3A") || s.contains("%3a"),
767
            ": in password should be encoded: {}",
768
            s
769
        );
770

            
771
        // Parse it back and verify equality
772
        let p2: ProxyProtocol = s.parse().unwrap();
773
        assert_eq!(
774
            p, p2,
775
            "Round-trip failed for percent-encoded HTTP CONNECT URI"
776
        );
777
    }
778

            
779
    #[test]
780
    fn proxy_protocol_http_connect_parse_percent_encoded() {
781
        // Parse an already percent-encoded URI and verify credentials decode correctly
782
        let p: ProxyProtocol = "http://user%40domain:pass%3Aword@127.0.0.1:8080"
783
            .parse()
784
            .unwrap();
785
        match p {
786
            ProxyProtocol::HttpConnect { addr, credentials } => {
787
                assert_eq!(addr, "127.0.0.1:8080".parse().unwrap());
788
                let auth = credentials.expect("expected credentials");
789
                assert_eq!(
790
                    auth.username, "user@domain",
791
                    "username should decode %40 to @"
792
                );
793
                assert_eq!(
794
                    auth.password.as_deref(),
795
                    Some("pass:word"),
796
                    "password should decode %3A to :"
797
                );
798
            }
799
            _ => panic!("expected HttpConnect"),
800
        }
801
    }
802
}