Files
shoes/src/client_proxy_selector.rs
T
2025-11-26 22:59:30 +04:00

2295 lines
84 KiB
Rust

use log::{debug, error};
use lru::LruCache;
use parking_lot::RwLock;
use std::hash::Hash;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
use crate::address::{Address, NetLocation};
use crate::address::{AddressMask, NetLocationMask};
use crate::option_util::OneOrSome;
use crate::resolver::{resolve_single_address, Resolver};
/// Cache key for routing decisions.
/// We cache based on the destination address and port.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum RoutingCacheKey {
/// Hostname destination (e.g., "example.com:443")
Hostname { hostname: String, port: u16 },
/// IPv4 destination
Ipv4 { addr: Ipv4Addr, port: u16 },
/// IPv6 destination
Ipv6 { addr: Ipv6Addr, port: u16 },
}
impl RoutingCacheKey {
fn from_location(location: &NetLocation) -> Self {
match location.address() {
Address::Hostname(hostname) => RoutingCacheKey::Hostname {
hostname: hostname.to_lowercase(),
port: location.port(),
},
Address::Ipv4(addr) => RoutingCacheKey::Ipv4 {
addr: *addr,
port: location.port(),
},
Address::Ipv6(addr) => RoutingCacheKey::Ipv6 {
addr: *addr,
port: location.port(),
},
}
}
}
/// Cached routing decision.
#[derive(Debug, Clone, Copy)]
pub(crate) enum CachedDecision {
/// Index into the rules Vec for an Allow decision
Allow(usize),
/// Block decision
Block,
}
/// LRU cache for routing decisions.
/// Uses RwLock for concurrent access - reads use peek() which doesn't require mutable access.
pub struct RoutingCache {
inner: RwLock<LruCache<RoutingCacheKey, CachedDecision>>,
}
impl std::fmt::Debug for RoutingCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let cache = self.inner.read();
f.debug_struct("RoutingCache")
.field("len", &cache.len())
.field("cap", &cache.cap())
.finish()
}
}
impl RoutingCache {
/// Default cache capacity
pub const DEFAULT_CAPACITY: usize = 10_000;
/// Create a new routing cache with the specified capacity.
pub fn new(capacity: usize) -> Self {
let cap = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(1).unwrap());
Self {
inner: RwLock::new(LruCache::new(cap)),
}
}
/// Look up a cached decision.
/// Uses peek() which doesn't update LRU order (avoids write lock).
#[inline]
pub fn get(&self, location: &NetLocation) -> Option<CachedDecision> {
let key = RoutingCacheKey::from_location(location);
self.inner.read().peek(&key).copied()
}
/// Insert a decision into the cache.
#[inline]
pub fn insert(&self, location: &NetLocation, decision: CachedDecision) {
let key = RoutingCacheKey::from_location(location);
self.inner.write().put(key, decision);
}
#[cfg(test)]
fn clear(&self) {
self.inner.write().clear();
}
#[cfg(test)]
fn len(&self) -> usize {
self.inner.read().len()
}
#[cfg(test)]
fn is_empty(&self) -> bool {
self.inner.read().is_empty()
}
}
#[derive(Debug)]
pub struct ConnectRule<T> {
pub masks: Vec<NetLocationMask>,
pub action: ConnectAction<T>,
}
impl<T> ConnectRule<T> {
pub fn new(masks: Vec<NetLocationMask>, action: ConnectAction<T>) -> Self {
Self { masks, action }
}
}
#[derive(Debug)]
pub enum ConnectAction<T> {
Allow {
override_address: Option<NetLocation>,
client_proxies: OneOrSome<T>,
next_proxy_index: AtomicU32,
},
Block,
}
impl<T> ConnectAction<T> {
pub fn new_allow(override_address: Option<NetLocation>, client_proxies: OneOrSome<T>) -> Self {
ConnectAction::Allow {
override_address,
client_proxies,
next_proxy_index: AtomicU32::new(0),
}
}
pub fn new_block() -> Self {
ConnectAction::Block
}
pub fn to_decision(&self, target_location: NetLocation) -> ConnectDecision<'_, T> {
match self {
ConnectAction::Allow {
override_address,
client_proxies,
next_proxy_index,
} => {
let client_proxy = match client_proxies {
OneOrSome::One(item) => item,
OneOrSome::Some(v) => select_proxy(v, next_proxy_index),
};
ConnectDecision::Allow {
client_proxy,
remote_location: match override_address {
Some(l) => {
if l.port() > 0 {
l.clone()
} else {
// If port of 0 is specified for the replacement location,
// take the requested port.
NetLocation::new(l.address().clone(), target_location.port())
}
}
None => target_location,
},
}
}
ConnectAction::Block => ConnectDecision::Block,
}
}
}
/// Threshold for enabling cache based on rule count.
/// If rule count exceeds this value, caching is enabled even without DNS resolution.
const CACHE_RULE_THRESHOLD: usize = 16;
// TODO: Replace linear rule matching with radix set/trie
#[derive(Debug)]
pub struct ClientProxySelector<T> {
rules: Vec<ConnectRule<T>>,
default_rule_index: Option<usize>,
/// If false, hostname rules will not trigger DNS resolution to match against IP-based
/// destinations. This is useful when a huge blocklist or rule list is provided.
/// However, this means that the user needs to make sure DNS resolutions are not done
/// manually before hitting the server. Default is false (don't resolve).
resolve_rule_hostnames: bool,
/// LRU cache for routing decisions. Speeds up repeated lookups for the same destination.
/// None if caching is disabled (few rules and no DNS resolution).
cache: Option<RoutingCache>,
}
unsafe impl<T: Send> Send for ClientProxySelector<T> {}
unsafe impl<T: Sync> Sync for ClientProxySelector<T> {}
#[derive(Debug)]
pub enum ConnectDecision<'a, T> {
Allow {
client_proxy: &'a T,
remote_location: NetLocation,
},
Block,
}
impl<T> ClientProxySelector<T> {
pub fn new(rules: Vec<ConnectRule<T>>) -> Self {
Self::with_options(rules, false)
}
/// Create a new ClientProxySelector with configurable hostname resolution behavior.
///
/// # Arguments
/// * `rules` - The list of routing rules
/// * `resolve_rule_hostnames` - If true, hostname rules will be resolved via DNS to match
/// against IP-based destinations. If false (default), hostname rules only match hostname
/// destinations directly. Setting this to false is more performant for large rule sets.
pub fn with_options(rules: Vec<ConnectRule<T>>, resolve_rule_hostnames: bool) -> Self {
Self::with_options_and_cache_size(
rules,
resolve_rule_hostnames,
RoutingCache::DEFAULT_CAPACITY,
)
}
/// Create a new ClientProxySelector with configurable hostname resolution and cache size.
///
/// # Arguments
/// * `rules` - The list of routing rules
/// * `resolve_rule_hostnames` - If true, hostname rules will be resolved via DNS to match
/// against IP-based destinations. If false (default), hostname rules only match hostname
/// destinations directly.
/// * `cache_capacity` - Maximum number of routing decisions to cache. Set to 0 to disable caching.
///
/// # Caching behavior
/// Caching is automatically enabled when:
/// - `resolve_rule_hostnames` is true (DNS lookups are expensive), OR
/// - Rule count exceeds `CACHE_RULE_THRESHOLD` (16)
///
/// For simple configurations with few rules and no DNS resolution, caching is disabled
/// as the overhead of cache key construction exceeds the cost of linear rule matching.
pub fn with_options_and_cache_size(
rules: Vec<ConnectRule<T>>,
resolve_rule_hostnames: bool,
cache_capacity: usize,
) -> Self {
let mut default_rule_index: Option<usize> = None;
// find a default rule which we'll use for multidirectional forwarding..
// TODO: ideally, we'd check the rule for each target during multidirectional forwarding
// and use the right one instead.
for (i, rule) in rules.iter().enumerate() {
// if it allows forwarding only to a single address, we don't want to use that as
// the default decision.
if let ConnectAction::Allow {
ref override_address,
..
} = rule.action
{
if override_address.is_some() {
continue;
}
}
let is_cover_rule = rule.masks.iter().any(|mask| mask.address_mask.netmask == 0);
if is_cover_rule {
default_rule_index = Some(i);
break;
}
}
// Enable caching if:
// 1. DNS resolution is enabled (expensive operation), OR
// 2. Many rules (linear scan becomes expensive)
let cache = if resolve_rule_hostnames || rules.len() > CACHE_RULE_THRESHOLD {
Some(RoutingCache::new(cache_capacity.max(1)))
} else {
None
};
Self {
rules,
default_rule_index,
resolve_rule_hostnames,
cache,
}
}
pub fn default_decision(&self) -> ConnectDecision<'_, T> {
match self.default_rule_index {
Some(i) => {
let rule = &self.rules[i];
// the remote location is unused because we don't choose a default rule with
// an override_address, so just pass a port of 0.
rule.action.to_decision(NetLocation::UNSPECIFIED)
}
None => ConnectDecision::Block,
}
}
/// Judge a connection request, using the cache for faster repeated lookups.
///
/// This is the primary method for routing decisions. It first checks the cache,
/// and only performs the full rule matching if there's a cache miss.
///
/// Note: Caching is only enabled when `resolve_rule_hostnames` is true or there are
/// more than 16 rules. For simple configurations, direct rule matching is faster.
pub async fn judge<'a>(
&'a self,
location: NetLocation,
resolver: &Arc<dyn Resolver>,
) -> std::io::Result<ConnectDecision<'a, T>> {
// If caching is disabled, go directly to rule matching
let cache = match &self.cache {
Some(c) => c,
None => return self.judge_uncached(location, resolver).await,
};
// Fast path: check cache first
if let Some(cached) = cache.get(&location) {
return Ok(self.cached_to_decision(cached, location));
}
// Slow path: full rule matching
match match_rule(
&self.rules,
&location,
resolver,
self.resolve_rule_hostnames,
)
.await?
{
Some((rule_index, rule)) => {
// Cache the result
cache.insert(&location, CachedDecision::Allow(rule_index));
Ok(rule.action.to_decision(location))
}
None => {
// Cache the block decision
cache.insert(&location, CachedDecision::Block);
Ok(ConnectDecision::Block)
}
}
}
/// Judge without using the cache. Useful for testing or when cache bypass is needed.
pub async fn judge_uncached<'a>(
&'a self,
location: NetLocation,
resolver: &Arc<dyn Resolver>,
) -> std::io::Result<ConnectDecision<'a, T>> {
match match_rule(
&self.rules,
&location,
resolver,
self.resolve_rule_hostnames,
)
.await?
{
Some((_rule_index, rule)) => Ok(rule.action.to_decision(location)),
None => Ok(ConnectDecision::Block),
}
}
/// Convert a cached decision back to a ConnectDecision.
#[inline]
fn cached_to_decision(
&self,
cached: CachedDecision,
location: NetLocation,
) -> ConnectDecision<'_, T> {
match cached {
CachedDecision::Allow(rule_index) => {
self.rules[rule_index].action.to_decision(location)
}
CachedDecision::Block => ConnectDecision::Block,
}
}
#[cfg(test)]
fn cache_size(&self) -> usize {
self.cache.as_ref().map_or(0, |c| c.len())
}
#[cfg(test)]
fn clear_cache(&self) {
if let Some(cache) = &self.cache {
cache.clear();
}
}
#[cfg(test)]
fn is_cache_enabled(&self) -> bool {
self.cache.is_some()
}
}
#[inline]
fn select_proxy<'a, T>(proxy_list: &'a [T], index: &'a AtomicU32) -> &'a T {
match proxy_list.len() {
0 => {
panic!("Empty proxy list");
}
1 => &proxy_list[0],
_ => {
let proxy_index = index.fetch_add(1, Ordering::Relaxed) as usize;
&proxy_list[proxy_index % proxy_list.len()]
}
}
}
#[inline]
fn ip_to_u128(ip: IpAddr) -> u128 {
match ip {
IpAddr::V4(addr) => ipv4_to_u128(addr),
IpAddr::V6(addr) => ipv6_to_u128(addr),
}
}
#[inline]
fn ipv4_to_u128(ip: Ipv4Addr) -> u128 {
ipv6_to_u128(ip.to_ipv6_mapped())
}
#[inline]
fn ipv6_to_u128(ip: Ipv6Addr) -> u128 {
u128::from(ip)
}
#[inline]
fn matches_domain(base_domain: &str, hostname: &str) -> bool {
if hostname.ends_with(base_domain) {
let hostname_len = hostname.len();
let base_domain_len = base_domain.len();
if hostname_len == base_domain_len {
true
} else {
// hostname_len > base_domain_len since hostname ends with base_domain.
hostname.as_bytes()[hostname_len - base_domain_len - 1] == b'.'
}
} else {
false
}
}
/// Returns the matching rule and its index in the rules Vec.
#[inline]
async fn match_rule<'a, T>(
rules: &'a [ConnectRule<T>],
location: &NetLocation,
resolver: &Arc<dyn Resolver>,
resolve_rule_hostnames: bool,
) -> std::io::Result<Option<(usize, &'a ConnectRule<T>)>> {
// We only resolve when necessary.
let mut resolved_ip: Option<u128> = None;
for (rule_index, rule) in rules.iter().enumerate() {
for mask in rule.masks.iter() {
match match_mask(
mask,
location,
&mut resolved_ip,
resolver,
resolve_rule_hostnames,
)
.await
{
Ok(is_match) => {
if is_match {
debug!("Found matching mask for {location} -> {mask:?}");
return Ok(Some((rule_index, rule)));
}
}
Err(MatchMaskError::Fatal(e)) => {
return Err(std::io::Error::other(format!(
"fatal error while matching mask for {location}: {e}"
)));
}
Err(MatchMaskError::NonFatal(e)) => {
error!("Non-fatal error while trying to match mask for {location}: {e}");
}
}
}
}
Ok(None)
}
enum MatchMaskError {
NonFatal(std::io::Error),
Fatal(std::io::Error),
}
// Helper function for testing - exposed for unit tests
#[cfg(test)]
pub fn matches_domain_for_test(base_domain: &str, hostname: &str) -> bool {
matches_domain(base_domain, hostname)
}
#[inline]
async fn match_mask(
location_mask: &NetLocationMask,
location: &NetLocation,
resolved_ip: &mut Option<u128>,
resolver: &Arc<dyn Resolver>,
resolve_rule_hostnames: bool,
) -> std::result::Result<bool, MatchMaskError> {
let NetLocationMask {
address_mask: AddressMask { address, netmask },
port,
} = location_mask;
let netmask = *netmask;
let port = *port;
if port > 0 && port != location.port() {
return Ok(false);
}
if netmask == 0 {
return Ok(true);
}
if let Some(hostname) = address.hostname() {
if let Some(remote_hostname) = location.address().hostname() {
return Ok(matches_domain(hostname, remote_hostname));
}
// We don't care about netmasks when hostnames are provided, so we can do direct matching
// without resolving when both the remote location and the provided rule address are hostnames,
// and simply return if it doesn't match.
if !resolve_rule_hostnames {
return Ok(false);
}
}
let masked_ip = match resolved_ip {
Some(ip) => *ip,
None => {
// fatal error if the destination we are trying to get to cannot be resolved.
let socket_addr = resolve_single_address(resolver, location)
.await
.map_err(MatchMaskError::Fatal)?;
let ip = ip_to_u128(socket_addr.ip());
resolved_ip.replace(ip);
ip
}
} & netmask;
match address {
Address::Ipv4(ip_addr) => {
let mask = ipv4_to_u128(*ip_addr) & netmask;
if mask == masked_ip {
return Ok(true);
}
}
Address::Ipv6(ip_addr) => {
let mask = ipv6_to_u128(*ip_addr) & netmask;
if mask == masked_ip {
return Ok(true);
}
}
Address::Hostname(_) => {
// non-fatal error when the rule address cannot be resolved.
// TODO: could this be cached?
let socket_addrs = resolver
.resolve_location(&NetLocation::new(address.clone(), port))
.await
.map_err(MatchMaskError::NonFatal)?;
for socket_addr in socket_addrs {
let mask = ip_to_u128(socket_addr.ip()) & netmask;
if mask == masked_ip {
return Ok(true);
}
}
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::resolver::Resolver;
use std::future::Future;
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use std::pin::Pin;
// =========================================================================
// Test Infrastructure
// =========================================================================
/// A mock resolver for testing that returns predefined results
#[derive(Debug)]
struct MockResolver {
mappings: std::collections::HashMap<String, Vec<SocketAddr>>,
}
impl MockResolver {
fn new() -> Self {
Self {
mappings: std::collections::HashMap::new(),
}
}
fn with_mapping(mut self, hostname: &str, port: u16, addrs: Vec<IpAddr>) -> Self {
let key = format!("{}:{}", hostname, port);
let socket_addrs: Vec<SocketAddr> = addrs
.into_iter()
.map(|ip| SocketAddr::new(ip, port))
.collect();
self.mappings.insert(key, socket_addrs);
self
}
}
impl Resolver for MockResolver {
fn resolve_location(
&self,
location: &NetLocation,
) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
let key = format!("{}:{}", location.address(), location.port());
let result = self.mappings.get(&key).cloned();
Box::pin(async move {
result.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("No mock mapping for {}", key),
)
})
})
}
}
/// Helper to create a mock resolver as Arc<dyn Resolver>
fn mock_resolver() -> Arc<dyn Resolver> {
Arc::new(MockResolver::new())
}
/// Helper to create a mock resolver with mappings as Arc<dyn Resolver>
#[allow(dead_code)]
fn mock_resolver_with_mappings(mappings: Vec<(&str, u16, Vec<IpAddr>)>) -> Arc<dyn Resolver> {
let mut resolver = MockResolver::new();
for (hostname, port, addrs) in mappings {
resolver = resolver.with_mapping(hostname, port, addrs);
}
Arc::new(resolver)
}
/// Simple proxy type for testing
#[derive(Debug, Clone, PartialEq)]
struct TestProxy {
name: String,
}
impl TestProxy {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
}
}
}
/// Helper to create an allow rule with a single proxy
fn allow_rule(masks: Vec<&str>, proxy_name: &str) -> ConnectRule<TestProxy> {
let masks: Vec<NetLocationMask> = masks
.into_iter()
.map(|s| NetLocationMask::from(s).unwrap())
.collect();
ConnectRule::new(
masks,
ConnectAction::new_allow(None, OneOrSome::One(TestProxy::new(proxy_name))),
)
}
/// Helper to create an allow rule with multiple proxies (for round-robin)
fn allow_rule_multi(masks: Vec<&str>, proxy_names: Vec<&str>) -> ConnectRule<TestProxy> {
let masks: Vec<NetLocationMask> = masks
.into_iter()
.map(|s| NetLocationMask::from(s).unwrap())
.collect();
let proxies: Vec<TestProxy> = proxy_names.into_iter().map(TestProxy::new).collect();
ConnectRule::new(
masks,
ConnectAction::new_allow(None, OneOrSome::Some(proxies)),
)
}
/// Helper to create a block rule
fn block_rule(masks: Vec<&str>) -> ConnectRule<TestProxy> {
let masks: Vec<NetLocationMask> = masks
.into_iter()
.map(|s| NetLocationMask::from(s).unwrap())
.collect();
ConnectRule::new(masks, ConnectAction::new_block())
}
/// Helper to create an allow rule with address override
fn allow_rule_with_override(
masks: Vec<&str>,
proxy_name: &str,
override_addr: &str,
) -> ConnectRule<TestProxy> {
let masks: Vec<NetLocationMask> = masks
.into_iter()
.map(|s| NetLocationMask::from(s).unwrap())
.collect();
let override_location = NetLocation::from_str(override_addr, Some(0)).unwrap();
ConnectRule::new(
masks,
ConnectAction::new_allow(
Some(override_location),
OneOrSome::One(TestProxy::new(proxy_name)),
),
)
}
// =========================================================================
// Domain Matching Tests (matches_domain function)
// =========================================================================
#[test]
fn test_matches_domain_exact_match() {
assert!(matches_domain_for_test("example.com", "example.com"));
assert!(matches_domain_for_test("foo.bar.com", "foo.bar.com"));
}
#[test]
fn test_matches_domain_subdomain_match() {
assert!(matches_domain_for_test("example.com", "www.example.com"));
assert!(matches_domain_for_test(
"example.com",
"sub.www.example.com"
));
assert!(matches_domain_for_test("bar.com", "foo.bar.com"));
}
#[test]
fn test_matches_domain_no_match_different_domain() {
assert!(!matches_domain_for_test("example.com", "notexample.com"));
assert!(!matches_domain_for_test("example.com", "example.org"));
assert!(!matches_domain_for_test("foo.com", "bar.com"));
}
#[test]
fn test_matches_domain_no_match_partial_suffix() {
// "malicious-example.com" ends with "example.com" as a string,
// but it's NOT a subdomain - this is a critical security test
assert!(!matches_domain_for_test(
"example.com",
"malicious-example.com"
));
assert!(!matches_domain_for_test("example.com", "fakeexample.com"));
assert!(!matches_domain_for_test("bar.com", "foobar.com"));
}
#[test]
fn test_matches_domain_no_match_base_longer() {
assert!(!matches_domain_for_test("www.example.com", "example.com"));
assert!(!matches_domain_for_test("sub.example.com", "example.com"));
}
#[test]
fn test_matches_domain_empty_strings() {
assert!(!matches_domain_for_test("example.com", ""));
assert!(!matches_domain_for_test("", "example.com"));
assert!(matches_domain_for_test("", "")); // Both empty matches
}
#[test]
fn test_matches_domain_single_label() {
assert!(matches_domain_for_test("localhost", "localhost"));
assert!(!matches_domain_for_test("localhost", "notlocalhost"));
assert!(!matches_domain_for_test("host", "localhost")); // partial suffix
}
// =========================================================================
// IPv4 Address Matching Tests
// =========================================================================
#[tokio::test]
async fn test_ipv4_exact_match() {
let rules = vec![
allow_rule(vec!["192.168.1.1/32"], "proxy1"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "proxy1");
}
ConnectDecision::Block => panic!("Expected Allow, got Block"),
}
}
#[tokio::test]
async fn test_ipv4_exact_match_no_match() {
let rules = vec![
allow_rule(vec!["192.168.1.1/32"], "proxy1"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Different IP should fall through to default
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 2)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow, got Block"),
}
}
#[tokio::test]
async fn test_ipv4_cidr_24() {
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "lan"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// All addresses in 192.168.1.0/24 should match
for last_octet in [0u8, 1, 100, 254, 255] {
let location =
NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, last_octet)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(
client_proxy.name, "lan",
"Failed for 192.168.1.{}",
last_octet
);
}
ConnectDecision::Block => panic!("Expected Allow for 192.168.1.{}", last_octet),
}
}
// Address outside the range should not match
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_ipv4_cidr_16() {
let rules = vec![
allow_rule(vec!["10.0.0.0/8"], "private_a"),
allow_rule(vec!["172.16.0.0/12"], "private_b"),
allow_rule(vec!["192.168.0.0/16"], "private_c"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Test 10.0.0.0/8 range
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 255, 255, 255)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "private_a");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Test 172.16.0.0/12 range (172.16.0.0 - 172.31.255.255)
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(172, 20, 5, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "private_b");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Test 192.168.0.0/16 range
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 100, 50)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "private_c");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Public IP should go to default
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_ipv4_localhost() {
let rules = vec![
allow_rule(vec!["127.0.0.0/8"], "loopback"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 0, 0, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "loopback");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// 127.x.x.x should all match loopback
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 255, 255, 255)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "loopback");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
// =========================================================================
// IPv6 Address Matching Tests
// =========================================================================
#[tokio::test]
async fn test_ipv6_exact_match() {
let rules = vec![
allow_rule(vec!["::1/128"], "loopback"),
allow_rule(vec!["::/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv6(Ipv6Addr::LOCALHOST), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "loopback");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_ipv6_cidr() {
let rules = vec![
allow_rule(vec!["fe80::/10"], "link_local"),
allow_rule(vec!["fc00::/7"], "unique_local"),
allow_rule(vec!["::/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Link-local address
let location = NetLocation::new(Address::Ipv6("fe80::1".parse().unwrap()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "link_local");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Unique local address (fc00::/7 covers fc00:: and fd00::)
let location = NetLocation::new(Address::Ipv6("fd00::1234".parse().unwrap()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "unique_local");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Global unicast should go to default
let location = NetLocation::new(Address::Ipv6("2001:db8::1".parse().unwrap()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
// =========================================================================
// Hostname/Domain Matching Tests
// =========================================================================
#[tokio::test]
async fn test_hostname_exact_match() {
let rules = vec![
allow_rule(vec!["example.com"], "example_proxy"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Hostname("example.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "example_proxy");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_hostname_subdomain_match() {
let rules = vec![
allow_rule(vec!["example.com"], "example_proxy"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Subdomain should match
let location = NetLocation::new(Address::Hostname("www.example.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "example_proxy");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Deep subdomain should match
let location = NetLocation::new(Address::Hostname("sub.www.example.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "example_proxy");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_hostname_no_false_subdomain_match() {
// SECURITY CRITICAL: malicious-example.com should NOT match example.com rule
let rules = vec![
allow_rule(vec!["example.com"], "example_proxy"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Hostname("malicious-example.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(
client_proxy.name, "default",
"SECURITY: malicious-example.com should NOT match example.com rule"
);
}
ConnectDecision::Block => panic!("Expected Allow"),
}
let location = NetLocation::new(Address::Hostname("fakeexample.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_hostname_different_tld() {
let rules = vec![
allow_rule(vec!["example.com"], "example_proxy"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Different TLD should not match
let location = NetLocation::new(Address::Hostname("example.org".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
// =========================================================================
// Port Matching Tests
// =========================================================================
#[tokio::test]
async fn test_port_specific_rule() {
let rules = vec![
allow_rule(vec!["0.0.0.0/0:443"], "https_proxy"),
allow_rule(vec!["0.0.0.0/0:80"], "http_proxy"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Port 443 should match https_proxy
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "https_proxy");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Port 80 should match http_proxy
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "http_proxy");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Other ports should go to default
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 8080);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_port_with_ip_restriction() {
let rules = vec![
allow_rule(vec!["192.168.1.0/24:22"], "ssh_lan"),
allow_rule(vec!["0.0.0.0/0:22"], "ssh_default"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// LAN SSH should match ssh_lan
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 22);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "ssh_lan");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// External SSH should match ssh_default
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 22);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "ssh_default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// LAN HTTP should go to default (not ssh_lan because port doesn't match)
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
// =========================================================================
// Rule Priority Tests (First Match Wins)
// =========================================================================
#[tokio::test]
async fn test_first_rule_wins() {
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "specific"),
allow_rule(vec!["192.168.0.0/16"], "less_specific"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// 192.168.1.x should match the first (more specific) rule
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "specific");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// 192.168.2.x should match the second rule
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "less_specific");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_rule_order_matters_security() {
// SECURITY: If a block rule comes after an allow rule for overlapping ranges,
// the allow rule takes precedence
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "allowed"),
block_rule(vec!["192.168.0.0/16"]),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// 192.168.1.x matches the allow rule first
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "allowed");
}
ConnectDecision::Block => panic!("Expected Allow for 192.168.1.1"),
}
// 192.168.2.x should be blocked
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 2, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for 192.168.2.1"),
ConnectDecision::Block => {} // Expected
}
}
// =========================================================================
// Block Action Tests
// =========================================================================
#[tokio::test]
async fn test_block_rule() {
let rules = vec![
block_rule(vec!["192.168.1.0/24"]),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Blocked range
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block"),
ConnectDecision::Block => {} // Expected
}
// Non-blocked range
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_block_hostname() {
let rules = vec![
block_rule(vec!["blocked.com"]),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Blocked hostname
let location = NetLocation::new(Address::Hostname("blocked.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for blocked.com"),
ConnectDecision::Block => {} // Expected
}
// Subdomain of blocked hostname should also be blocked
let location = NetLocation::new(Address::Hostname("www.blocked.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for www.blocked.com"),
ConnectDecision::Block => {} // Expected
}
}
// =========================================================================
// Default Rule Tests
// =========================================================================
#[tokio::test]
async fn test_no_default_rule_blocks() {
let rules = vec![allow_rule(vec!["192.168.1.0/24"], "lan")];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Non-matching address with no default rule should block
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block when no default rule"),
ConnectDecision::Block => {} // Expected
}
}
#[tokio::test]
async fn test_default_decision() {
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "lan"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
// default_decision should return the catch-all rule
let decision = selector.default_decision();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_default_decision_no_catchall() {
// Rules without a catch-all should return Block for default_decision
let rules = vec![allow_rule(vec!["192.168.1.0/24"], "lan")];
let selector = ClientProxySelector::new(rules);
let decision = selector.default_decision();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block when no catch-all rule"),
ConnectDecision::Block => {} // Expected
}
}
#[tokio::test]
async fn test_default_decision_skips_override_rules() {
// Rules with override_address should not be used as default
let rules = vec![
allow_rule_with_override(vec!["0.0.0.0/0"], "redirect", "10.0.0.1:8080"),
allow_rule(vec!["192.168.0.0/16"], "fallback"),
];
let selector = ClientProxySelector::new(rules);
// default_decision should skip the override rule and use the next catch-all
let decision = selector.default_decision();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "fallback");
}
ConnectDecision::Block => {} // Also acceptable if no valid default found
}
}
// =========================================================================
// Round-Robin Proxy Selection Tests
// =========================================================================
#[tokio::test]
async fn test_round_robin_proxy_selection() {
let rules = vec![allow_rule_multi(
vec!["0.0.0.0/0"],
vec!["proxy1", "proxy2", "proxy3"],
)];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let mut seen = vec![];
for _ in 0..6 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
seen.push(client_proxy.name.clone());
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
// Should cycle through proxies in order
assert_eq!(seen[0], "proxy1");
assert_eq!(seen[1], "proxy2");
assert_eq!(seen[2], "proxy3");
assert_eq!(seen[3], "proxy1");
assert_eq!(seen[4], "proxy2");
assert_eq!(seen[5], "proxy3");
}
#[tokio::test]
async fn test_single_proxy_no_rotation() {
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "only_proxy")];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
for _ in 0..5 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "only_proxy");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
}
// =========================================================================
// Address Override Tests
// =========================================================================
#[tokio::test]
async fn test_address_override() {
let rules = vec![allow_rule_with_override(
vec!["0.0.0.0/0"],
"proxy",
"10.0.0.1:8080",
)];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow {
client_proxy,
remote_location,
} => {
assert_eq!(client_proxy.name, "proxy");
assert_eq!(remote_location.address().to_string(), "10.0.0.1");
assert_eq!(remote_location.port(), 8080);
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_address_override_preserve_port() {
// Override with port 0 should preserve the original port
let rules = vec![allow_rule_with_override(
vec!["0.0.0.0/0"],
"proxy",
"10.0.0.1:0",
)];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow {
remote_location, ..
} => {
assert_eq!(remote_location.address().to_string(), "10.0.0.1");
assert_eq!(remote_location.port(), 443); // Original port preserved
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
// =========================================================================
// Multiple Masks Per Rule Tests
// =========================================================================
#[tokio::test]
async fn test_multiple_masks_in_rule() {
let rules = vec![
allow_rule(
vec!["192.168.1.0/24", "192.168.2.0/24", "10.0.0.0/8"],
"internal",
),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// All three ranges should match "internal"
for ip in [
Ipv4Addr::new(192, 168, 1, 1),
Ipv4Addr::new(192, 168, 2, 100),
Ipv4Addr::new(10, 50, 50, 50),
] {
let location = NetLocation::new(Address::Ipv4(ip), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "internal", "Failed for {}", ip);
}
ConnectDecision::Block => panic!("Expected Allow for {}", ip),
}
}
// Other IPs should go to default
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_multiple_hostnames_in_rule() {
let rules = vec![
allow_rule(
vec!["google.com", "youtube.com", "gmail.com"],
"google_proxy",
),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
for hostname in [
"google.com",
"www.google.com",
"youtube.com",
"mail.gmail.com",
] {
let location = NetLocation::new(Address::Hostname(hostname.to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "google_proxy", "Failed for {}", hostname);
}
ConnectDecision::Block => panic!("Expected Allow for {}", hostname),
}
}
}
// =========================================================================
// Edge Cases and Security Tests
// =========================================================================
#[tokio::test]
async fn test_empty_rules_blocks_all() {
let rules: Vec<ConnectRule<TestProxy>> = vec![];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block with empty rules"),
ConnectDecision::Block => {} // Expected
}
}
#[tokio::test]
async fn test_ipv4_mapped_ipv6() {
// IPv4-mapped IPv6 addresses (::ffff:192.168.1.1) should be handled correctly
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "lan"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Regular IPv4
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "lan");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_broadcast_and_special_addresses() {
let rules = vec![
block_rule(vec!["255.255.255.255/32"]), // Broadcast
block_rule(vec!["0.0.0.0/32"]), // Unspecified
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Broadcast should be blocked
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::BROADCAST), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for broadcast"),
ConnectDecision::Block => {} // Expected
}
// Unspecified should be blocked
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::UNSPECIFIED), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { .. } => panic!("Expected Block for unspecified"),
ConnectDecision::Block => {} // Expected
}
}
#[tokio::test]
async fn test_case_sensitivity_hostname() {
let rules = vec![
allow_rule(vec!["Example.COM"], "example_proxy"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Note: DNS hostnames are case-insensitive by spec, but our implementation
// does case-sensitive matching. This test documents current behavior.
let location = NetLocation::new(Address::Hostname("example.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
// Current behavior: case-sensitive, so this goes to default
// If we want case-insensitive, this should be "example_proxy"
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_concurrent_access_round_robin() {
// Test that concurrent access to round-robin is safe (uses AtomicU32)
use std::sync::Arc as StdArc;
let rules = vec![allow_rule_multi(
vec!["0.0.0.0/0"],
vec!["p1", "p2", "p3", "p4"],
)];
let selector = StdArc::new(ClientProxySelector::new(rules));
let resolver = mock_resolver();
let mut handles = vec![];
for _ in 0..10 {
let selector = selector.clone();
let resolver = resolver.clone();
handles.push(tokio::spawn(async move {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let _ = selector.judge(location, &resolver).await;
}));
}
for handle in handles {
handle.await.unwrap();
}
// If we got here without panic, concurrent access is safe
}
// =========================================================================
// Regression Tests for Specific Scenarios
// =========================================================================
#[tokio::test]
async fn test_localhost_bypass() {
// Common pattern: bypass proxy for localhost
// Note: With resolve_rule_hostnames=false (default), hostname destinations
// won't match IP rules unless the hostname rule comes first.
let rules = vec![
allow_rule(vec!["localhost"], "direct_host"), // Hostname rule first
allow_rule_with_override(vec!["127.0.0.0/8"], "direct", "127.0.0.1:0"),
allow_rule(vec!["0.0.0.0/0"], "proxy"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// 127.0.0.1 should use direct (matches IP rule)
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(127, 0, 0, 1)), 8080);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "direct");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// localhost hostname should use direct_host (matches hostname rule)
let location = NetLocation::new(Address::Hostname("localhost".to_string()), 8080);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "direct_host");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_mixed_ip_and_hostname_rules() {
// Note: With resolve_rule_hostnames=false (default), hostname destinations
// encountering IP rules will attempt to resolve the hostname. To avoid this,
// place hostname rules before IP rules, or use resolve_rule_hostnames=true
// with proper DNS mock mappings.
let rules = vec![
block_rule(vec!["malware.com"]),
allow_rule(vec!["trusted.com"], "trusted"), // Hostname rules before IP rules
allow_rule(vec!["192.168.0.0/16"], "lan"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
let resolver = mock_resolver();
// Malware hostname blocked
let location = NetLocation::new(Address::Hostname("malware.com".to_string()), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
assert!(matches!(decision, ConnectDecision::Block));
// LAN IP allowed through lan proxy
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "lan");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Trusted hostname allowed through trusted proxy
let location = NetLocation::new(Address::Hostname("api.trusted.com".to_string()), 443);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "trusted");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
// =========================================================================
// Routing Cache Tests
// =========================================================================
#[test]
fn test_routing_cache_key_from_location_hostname() {
let location = NetLocation::new(Address::Hostname("Example.COM".to_string()), 443);
let key = RoutingCacheKey::from_location(&location);
match key {
RoutingCacheKey::Hostname { hostname, port } => {
assert_eq!(hostname, "example.com"); // Should be lowercased
assert_eq!(port, 443);
}
_ => panic!("Expected Hostname key"),
}
}
#[test]
fn test_routing_cache_key_from_location_ipv4() {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let key = RoutingCacheKey::from_location(&location);
match key {
RoutingCacheKey::Ipv4 { addr, port } => {
assert_eq!(addr, Ipv4Addr::new(192, 168, 1, 1));
assert_eq!(port, 80);
}
_ => panic!("Expected Ipv4 key"),
}
}
#[test]
fn test_routing_cache_key_from_location_ipv6() {
let location = NetLocation::new(Address::Ipv6(Ipv6Addr::LOCALHOST), 8080);
let key = RoutingCacheKey::from_location(&location);
match key {
RoutingCacheKey::Ipv6 { addr, port } => {
assert_eq!(addr, Ipv6Addr::LOCALHOST);
assert_eq!(port, 8080);
}
_ => panic!("Expected Ipv6 key"),
}
}
#[test]
fn test_routing_cache_basic_operations() {
let cache = RoutingCache::new(100);
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
// Initially no entry
assert!(cache.get(&location).is_none());
// Insert and retrieve
cache.insert(&location, CachedDecision::Allow(5));
assert_eq!(cache.len(), 1);
let cached = cache.get(&location);
assert!(cached.is_some());
match cached.unwrap() {
CachedDecision::Allow(idx) => assert_eq!(idx, 5),
CachedDecision::Block => panic!("Expected Allow"),
}
}
#[test]
fn test_routing_cache_block_decision() {
let cache = RoutingCache::new(100);
let location = NetLocation::new(Address::Hostname("blocked.com".to_string()), 443);
cache.insert(&location, CachedDecision::Block);
let cached = cache.get(&location);
assert!(matches!(cached, Some(CachedDecision::Block)));
}
#[test]
fn test_routing_cache_different_ports() {
let cache = RoutingCache::new(100);
let loc_80 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
let loc_443 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 443);
cache.insert(&loc_80, CachedDecision::Allow(1));
cache.insert(&loc_443, CachedDecision::Allow(2));
// Same IP, different ports should be different cache entries
match cache.get(&loc_80).unwrap() {
CachedDecision::Allow(idx) => assert_eq!(idx, 1),
_ => panic!("Expected Allow(1)"),
}
match cache.get(&loc_443).unwrap() {
CachedDecision::Allow(idx) => assert_eq!(idx, 2),
_ => panic!("Expected Allow(2)"),
}
}
#[test]
fn test_routing_cache_hostname_case_insensitive() {
let cache = RoutingCache::new(100);
// Insert with uppercase
let loc_upper = NetLocation::new(Address::Hostname("EXAMPLE.COM".to_string()), 443);
cache.insert(&loc_upper, CachedDecision::Allow(1));
// Retrieve with lowercase - should find it
let loc_lower = NetLocation::new(Address::Hostname("example.com".to_string()), 443);
assert!(cache.get(&loc_lower).is_some());
// Retrieve with mixed case - should find it
let loc_mixed = NetLocation::new(Address::Hostname("Example.Com".to_string()), 443);
assert!(cache.get(&loc_mixed).is_some());
}
#[test]
fn test_routing_cache_clear() {
let cache = RoutingCache::new(100);
// Add some entries
for i in 0..10 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, i)), 80);
cache.insert(&location, CachedDecision::Allow(i as usize));
}
assert_eq!(cache.len(), 10);
// Clear
cache.clear();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
// Verify entries are gone
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 0)), 80);
assert!(cache.get(&location).is_none());
}
#[test]
fn test_routing_cache_lru_eviction() {
// Small cache to test eviction
let cache = RoutingCache::new(3);
// Insert 3 entries
let loc1 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 1)), 80);
let loc2 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 2)), 80);
let loc3 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 3)), 80);
cache.insert(&loc1, CachedDecision::Allow(1));
cache.insert(&loc2, CachedDecision::Allow(2));
cache.insert(&loc3, CachedDecision::Allow(3));
assert_eq!(cache.len(), 3);
// Insert 4th entry - should evict the oldest (loc1)
let loc4 = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 0, 0, 4)), 80);
cache.insert(&loc4, CachedDecision::Allow(4));
assert_eq!(cache.len(), 3);
// loc1 should be evicted
assert!(cache.get(&loc1).is_none());
// Others should still be present
assert!(cache.get(&loc2).is_some());
assert!(cache.get(&loc3).is_some());
assert!(cache.get(&loc4).is_some());
}
#[test]
fn test_routing_cache_overwrite() {
let cache = RoutingCache::new(100);
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
// Insert Allow
cache.insert(&location, CachedDecision::Allow(1));
match cache.get(&location).unwrap() {
CachedDecision::Allow(idx) => assert_eq!(idx, 1),
_ => panic!("Expected Allow(1)"),
}
// Overwrite with Block
cache.insert(&location, CachedDecision::Block);
assert!(matches!(cache.get(&location), Some(CachedDecision::Block)));
// Overwrite with different Allow
cache.insert(&location, CachedDecision::Allow(5));
match cache.get(&location).unwrap() {
CachedDecision::Allow(idx) => assert_eq!(idx, 5),
_ => panic!("Expected Allow(5)"),
}
}
/// Helper to create a selector with caching enabled (via resolve_rule_hostnames=true)
fn selector_with_cache(rules: Vec<ConnectRule<TestProxy>>) -> ClientProxySelector<TestProxy> {
ClientProxySelector::with_options(rules, true)
}
#[tokio::test]
async fn test_selector_cache_disabled_for_few_rules() {
// With few rules and resolve_rule_hostnames=false, cache should be disabled
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "lan"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::new(rules);
assert!(!selector.is_cache_enabled());
// Routing should still work correctly
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "lan");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
#[tokio::test]
async fn test_selector_cache_enabled_with_dns_resolution() {
// With resolve_rule_hostnames=true, cache should be enabled even with few rules
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "lan"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = ClientProxySelector::with_options(rules, true);
assert!(selector.is_cache_enabled());
}
#[tokio::test]
async fn test_selector_cache_enabled_with_many_rules() {
// With many rules (>16), cache should be enabled even without DNS resolution
let mut rules: Vec<ConnectRule<TestProxy>> = (0..20)
.map(|i| allow_rule(vec![&format!("10.0.{}.0/24", i)], &format!("rule{}", i)))
.collect();
rules.push(allow_rule(vec!["0.0.0.0/0"], "default"));
let selector = ClientProxySelector::new(rules);
assert!(selector.is_cache_enabled());
}
#[tokio::test]
async fn test_selector_cache_hit() {
// Use selector_with_cache to ensure caching is enabled for this test
let rules = vec![
allow_rule(vec!["192.168.1.0/24"], "lan"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
// First call - cache miss
assert_eq!(selector.cache_size(), 0);
let decision = selector.judge(location.clone(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "lan");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Should be cached now
assert_eq!(selector.cache_size(), 1);
// Second call - cache hit (same result)
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "lan");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Still only 1 entry (was a hit, not a new insert)
assert_eq!(selector.cache_size(), 1);
}
#[tokio::test]
async fn test_selector_cache_block_decision() {
let rules = vec![
block_rule(vec!["192.168.1.0/24"]),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
// First call - cache miss, should block
let decision = selector.judge(location.clone(), &resolver).await.unwrap();
assert!(matches!(decision, ConnectDecision::Block));
assert_eq!(selector.cache_size(), 1);
// Second call - cache hit, still blocks
let decision = selector.judge(location, &resolver).await.unwrap();
assert!(matches!(decision, ConnectDecision::Block));
}
#[tokio::test]
async fn test_selector_clear_cache() {
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
// Make some cached entries
for i in 0..5 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, i)), 80);
let _ = selector.judge(location, &resolver).await.unwrap();
}
assert_eq!(selector.cache_size(), 5);
// Clear cache
selector.clear_cache();
assert_eq!(selector.cache_size(), 0);
}
#[tokio::test]
async fn test_selector_judge_uncached() {
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(1, 2, 3, 4)), 80);
// Use judge_uncached - should NOT populate cache
let decision = selector
.judge_uncached(location.clone(), &resolver)
.await
.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
// Cache should still be empty
assert_eq!(selector.cache_size(), 0);
}
#[tokio::test]
async fn test_selector_cache_different_destinations() {
let rules = vec![
allow_rule(vec!["192.168.0.0/16"], "lan"),
allow_rule(vec!["10.0.0.0/8"], "private"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
// Hit different rules
let loc_lan = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 1)), 80);
let loc_private = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 1)), 80);
let loc_public = NetLocation::new(Address::Ipv4(Ipv4Addr::new(8, 8, 8, 8)), 80);
let d1 = selector.judge(loc_lan.clone(), &resolver).await.unwrap();
let d2 = selector
.judge(loc_private.clone(), &resolver)
.await
.unwrap();
let d3 = selector.judge(loc_public.clone(), &resolver).await.unwrap();
// Verify correct routing
match d1 {
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "lan"),
_ => panic!("Expected lan"),
}
match d2 {
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "private"),
_ => panic!("Expected private"),
}
match d3 {
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "default"),
_ => panic!("Expected default"),
}
// All 3 should be cached
assert_eq!(selector.cache_size(), 3);
// Verify cache hits return same results
let d1_cached = selector.judge(loc_lan, &resolver).await.unwrap();
match d1_cached {
ConnectDecision::Allow { client_proxy, .. } => assert_eq!(client_proxy.name, "lan"),
_ => panic!("Expected lan from cache"),
}
}
#[tokio::test]
async fn test_selector_cache_hostname_destinations() {
let rules = vec![
allow_rule(vec!["google.com"], "google"),
allow_rule(vec!["0.0.0.0/0"], "default"),
];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
// Hostname match
let loc_google = NetLocation::new(Address::Hostname("www.google.com".to_string()), 443);
let decision = selector.judge(loc_google.clone(), &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "google");
}
_ => panic!("Expected google"),
}
// Should be cached
assert_eq!(selector.cache_size(), 1);
// Case-insensitive cache hit
let loc_google_upper =
NetLocation::new(Address::Hostname("WWW.GOOGLE.COM".to_string()), 443);
let decision = selector.judge(loc_google_upper, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "google");
}
_ => panic!("Expected google from cache"),
}
// Should still be 1 entry (case-insensitive hit)
assert_eq!(selector.cache_size(), 1);
}
#[tokio::test]
async fn test_selector_cache_with_custom_size() {
// Use resolve_rule_hostnames=true to enable caching with custom size
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
let selector = ClientProxySelector::with_options_and_cache_size(rules, true, 5);
let resolver = mock_resolver();
// Fill cache to capacity
for i in 0..5 {
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, i)), 80);
let _ = selector.judge(location, &resolver).await.unwrap();
}
assert_eq!(selector.cache_size(), 5);
// Add one more - should evict oldest
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 100)), 80);
let _ = selector.judge(location, &resolver).await.unwrap();
assert_eq!(selector.cache_size(), 5); // Still 5, one was evicted
// First entry should be evicted - verify by checking the cache is full
// but we can't directly access the internal cache to verify eviction
// So we verify indirectly: if we add another entry, size stays at 5
let location = NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, 0, 200)), 80);
let _ = selector.judge(location, &resolver).await.unwrap();
assert_eq!(selector.cache_size(), 5);
}
#[tokio::test]
async fn test_selector_concurrent_cache_access() {
use std::sync::Arc as StdArc;
let rules = vec![allow_rule(vec!["0.0.0.0/0"], "default")];
let selector = StdArc::new(selector_with_cache(rules));
let resolver = mock_resolver();
// Spawn multiple tasks accessing the cache concurrently
let mut handles = vec![];
for i in 0..10 {
let selector = selector.clone();
let resolver = resolver.clone();
handles.push(tokio::spawn(async move {
for j in 0..100 {
let location =
NetLocation::new(Address::Ipv4(Ipv4Addr::new(10, 0, i as u8, j as u8)), 80);
let decision = selector.judge(location, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "default");
}
ConnectDecision::Block => panic!("Expected Allow"),
}
}
}));
}
// Wait for all tasks
for handle in handles {
handle.await.unwrap();
}
// Should have cached entries (exact number depends on timing)
assert!(selector.cache_size() > 0);
}
#[tokio::test]
async fn test_selector_cache_preserves_rule_priority() {
// Ensure caching doesn't break rule priority
let rules = vec![
allow_rule(vec!["192.168.1.100/32"], "specific"), // Rule 0
allow_rule(vec!["192.168.1.0/24"], "subnet"), // Rule 1
allow_rule(vec!["192.168.0.0/16"], "network"), // Rule 2
allow_rule(vec!["0.0.0.0/0"], "default"), // Rule 3
];
let selector = selector_with_cache(rules);
let resolver = mock_resolver();
// Specific IP should match rule 0 (most specific)
let loc_specific = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 100)), 80);
let decision = selector
.judge(loc_specific.clone(), &resolver)
.await
.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "specific");
}
_ => panic!("Expected specific"),
}
// Verify from cache - should still be "specific"
let decision_cached = selector.judge(loc_specific, &resolver).await.unwrap();
match decision_cached {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "specific");
}
_ => panic!("Expected specific from cache"),
}
// Different IP in same subnet should match rule 1
let loc_subnet = NetLocation::new(Address::Ipv4(Ipv4Addr::new(192, 168, 1, 50)), 80);
let decision = selector.judge(loc_subnet, &resolver).await.unwrap();
match decision {
ConnectDecision::Allow { client_proxy, .. } => {
assert_eq!(client_proxy.name, "subnet");
}
_ => panic!("Expected subnet"),
}
}
}