use crate::Result;
use crate::error::RouteError;
use crate::network::route::{InstalledExclusionRoute, NextHop};
use crate::utils::command::run_command;
use ipnet::IpNet;
use serde::Deserialize;
use std::net::IpAddr;
use std::process::Output;
use std::str::FromStr;
use tracing::warn;
const POWERSHELL_COMMAND: &str = "powershell.exe";
pub fn add_routes(
networks: &[IpNet],
gateway: &IpAddr,
interface_name: &str,
remote_address: Option<IpAddr>,
) -> Result<Option<InstalledExclusionRoute>> {
let tunnel_if_index = resolve_interface_index(interface_name)?;
let exclusion = match remote_address {
Some(server) => match install_exclusion_for_server(&server, tunnel_if_index) {
Ok(token) => Some(token),
Err(err) => {
if any_route_covers_address(networks, &server) {
return Err(RouteError::ExclusionRequired { server }.into());
}
warn!(
%server,
"exclusion route could not be installed but routes do not \
cover the server; continuing: {err}"
);
None
}
},
None => None,
};
if let Err(add_err) = add_user_routes_with_index(networks, gateway, tunnel_if_index) {
if let Some(ref token) = exclusion {
if let Err(rm_err) = remove_exclusion_route(token) {
warn!(
"failed to roll back exclusion route for {}: {rm_err}",
token.destination
);
}
}
return Err(add_err);
}
Ok(exclusion)
}
fn install_exclusion_for_server(
server: &IpAddr,
tunnel_if_index: u32,
) -> Result<InstalledExclusionRoute> {
let next_hop = get_route_to(server)?;
if is_self_referential_next_hop(server, &next_hop, tunnel_if_index) {
return Err(RouteError::PlatformError {
message: format!(
"route-to-server lookup for {server} resolved to a self-referential \
next-hop ({next_hop:?}); refusing to install exclusion route"
),
}
.into());
}
add_exclusion_route(server, &next_hop)
}
fn is_self_referential_next_hop(server: &IpAddr, next_hop: &NextHop, tunnel_if_index: u32) -> bool {
let tunnel_index_str = tunnel_if_index.to_string();
match next_hop {
NextHop::Gateway { address, interface } => {
address == server || interface == &tunnel_index_str
}
NextHop::OnLink { interface } => interface == &tunnel_index_str,
}
}
fn add_user_routes_with_index(networks: &[IpNet], gateway: &IpAddr, if_index: u32) -> Result<()> {
if networks.is_empty() {
return Ok(());
}
let script = build_user_routes_script(networks, gateway, if_index);
let args = vec!["-NoProfile", "-NonInteractive", "-Command", &script];
let output = run_command(POWERSHELL_COMMAND, &args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute user route add command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for user route add command: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RouteError::AddFailed {
destination: format!("{} network(s) via ifIndex {}", networks.len(), if_index),
message: stderr.trim().to_string(),
}
.into());
}
Ok(())
}
pub(crate) fn any_route_covers_address(routes: &[IpNet], address: &IpAddr) -> bool {
routes.iter().any(|net| net.contains(address))
}
fn escape_ps_single_quoted(value: &str) -> String {
value
.replace('\'', "''") .replace('\u{2018}', "''") .replace('\u{2019}', "''") .replace('\u{201A}', "''") .replace('\u{201B}', "''") }
fn resolve_interface_index(interface_name: &str) -> Result<u32> {
let safe_name = escape_ps_single_quoted(interface_name);
let ps_script = format!(
"$ErrorActionPreference = 'Stop'; (Get-NetAdapter -Name '{}' -ErrorAction Stop).ifIndex",
safe_name
);
let args = vec!["-NoProfile", "-NonInteractive", "-Command", &ps_script];
let output = run_command(POWERSHELL_COMMAND, &args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to resolve interface index for '{interface_name}': {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for interface index resolution: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RouteError::PlatformError {
message: format!(
"failed to resolve interface index for '{interface_name}': {}",
stderr.trim()
),
}
.into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.trim().parse::<u32>().map_err(|e| {
RouteError::PlatformError {
message: format!(
"unexpected interface index value '{}' for '{interface_name}': {e}",
stdout.trim()
),
}
.into()
})
}
fn build_user_routes_script(networks: &[IpNet], gateway: &IpAddr, interface_index: u32) -> String {
let mut script = String::from("$ErrorActionPreference = 'Stop'; ");
let gateway_str = gateway.to_string();
for network in networks {
script.push_str(&format!(
"New-NetRoute -DestinationPrefix '{}' -InterfaceIndex {} -NextHop '{}' -PolicyStore ActiveStore; ",
network, interface_index, gateway_str
));
}
script
}
pub fn get_route_to(address: &IpAddr) -> Result<NextHop> {
let output = run_find_net_route(address)?;
let stdout = String::from_utf8_lossy(&output.stdout);
parse_find_net_route_json(&stdout, address)
}
fn run_find_net_route(address: &IpAddr) -> Result<Output> {
let addr_str = address.to_string();
let ps_script = format!(
"Find-NetRoute -RemoteIPAddress '{}' | Select-Object -Property InterfaceIndex,NextHop | ConvertTo-Json",
addr_str
);
let args = vec!["-NoProfile", "-NonInteractive", "-Command", &ps_script];
let output = run_command(POWERSHELL_COMMAND, &args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute Find-NetRoute command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for Find-NetRoute command: {e}"),
})?;
if !output.status.success() {
return Err(RouteError::NotFound {
destination: addr_str,
}
.into());
}
Ok(output)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct FindNetRouteEntry {
interface_index: u32,
next_hop: Option<String>,
}
fn parse_find_net_route_json(json_output: &str, address: &IpAddr) -> Result<NextHop> {
let entries = parse_entries(json_output).map_err(|e| RouteError::PlatformError {
message: format!("failed to parse Find-NetRoute output for {address}: {e}"),
})?;
let (entry, next_hop_str) = entries
.iter()
.filter_map(|e| e.next_hop.as_deref().map(|nh| (e, nh)))
.find(|(_, nh)| is_onlink_next_hop(nh) || IpAddr::from_str(nh).is_ok())
.ok_or_else(|| RouteError::NotFound {
destination: address.to_string(),
})?;
let interface = entry.interface_index.to_string();
if is_onlink_next_hop(next_hop_str) {
Ok(NextHop::OnLink { interface })
} else {
let gw_addr = IpAddr::from_str(next_hop_str).map_err(|_| RouteError::PlatformError {
message: format!(
"invalid NextHop address '{}' in Find-NetRoute output for {address}",
next_hop_str
),
})?;
Ok(NextHop::Gateway {
address: gw_addr,
interface,
})
}
}
fn parse_entries(json_output: &str) -> std::result::Result<Vec<FindNetRouteEntry>, String> {
let trimmed = json_output.trim();
if trimmed.is_empty() {
return Err("empty output".to_string());
}
if let Ok(entries) = serde_json::from_str::<Vec<FindNetRouteEntry>>(trimmed) {
return Ok(entries);
}
serde_json::from_str::<FindNetRouteEntry>(trimmed)
.map(|e| vec![e])
.map_err(|e| e.to_string())
}
fn is_onlink_next_hop(next_hop: &str) -> bool {
matches!(next_hop, "0.0.0.0" | "::")
}
pub fn add_exclusion_route(server: &IpAddr, next_hop: &NextHop) -> Result<InstalledExclusionRoute> {
let script = exclusion_route_add_script(server, next_hop);
let args = vec!["-NoProfile", "-NonInteractive", "-Command", &script];
let output = run_command(POWERSHELL_COMMAND, &args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute exclusion route add command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for exclusion route add command: {e}"),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RouteError::AddFailed {
destination: server.to_string(),
message: stderr.trim().to_string(),
}
.into());
}
Ok(InstalledExclusionRoute {
destination: *server,
next_hop: next_hop.clone(),
})
}
pub fn remove_exclusion_route(exclusion: &InstalledExclusionRoute) -> Result<()> {
let script = exclusion_route_remove_script(&exclusion.destination, &exclusion.next_hop);
let args = vec!["-NoProfile", "-NonInteractive", "-Command", &script];
let output = run_command(POWERSHELL_COMMAND, &args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute exclusion route remove command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for exclusion route remove command: {e}"),
})?;
if output.status.success() {
return Ok(());
}
warn!(
destination = %exclusion.destination,
"next-hop-specific exclusion route removal failed; retrying with destination-only removal"
);
let fallback_script =
exclusion_route_remove_fallback_script(&exclusion.destination, &exclusion.next_hop);
let fallback_args = vec![
"-NoProfile",
"-NonInteractive",
"-Command",
&fallback_script,
];
let fallback_output = run_command(POWERSHELL_COMMAND, &fallback_args)
.map_err(|e| RouteError::PlatformError {
message: format!("failed to execute fallback exclusion route remove command: {e}"),
})?
.wait_with_output()
.map_err(|e| RouteError::PlatformError {
message: format!("failed to wait for fallback exclusion route remove command: {e}"),
})?;
if !fallback_output.status.success() {
return Err(RouteError::RemoveFailed {
destination: exclusion.destination.to_string(),
}
.into());
}
Ok(())
}
fn host_prefix_for(server: &IpAddr) -> String {
match server {
IpAddr::V4(_) => format!("{server}/32"),
IpAddr::V6(_) => format!("{server}/128"),
}
}
fn interface_index_of(next_hop: &NextHop) -> &str {
match next_hop {
NextHop::Gateway { interface, .. } | NextHop::OnLink { interface } => interface,
}
}
fn exclusion_route_add_script(server: &IpAddr, next_hop: &NextHop) -> String {
let prefix = host_prefix_for(server);
let interface_index = interface_index_of(next_hop);
let next_hop_arg = if let NextHop::Gateway { address, .. } = next_hop {
format!(" -NextHop '{address}'")
} else {
String::new()
};
let try_body = format!(
"New-NetRoute -DestinationPrefix '{prefix}' -InterfaceIndex {interface_index} -PolicyStore ActiveStore{next_hop_arg} | Out-Null"
);
let catch_body = format!(
"if ($_.Exception.NativeErrorCode -eq [Microsoft.Management.Infrastructure.NativeErrorCode]::AlreadyExists) {{ \
Write-Error \"refusing to adopt a pre-existing exclusion route for '{prefix}' on ifIndex {interface_index} that Quincy did not install\"; \
exit 1 \
}}; \
throw"
);
format!(
"$ErrorActionPreference = 'Stop'; \
try {{ {try_body} }} \
catch [Microsoft.Management.Infrastructure.CimException] {{ {catch_body} }}"
)
}
fn exclusion_route_remove_script(server: &IpAddr, next_hop: &NextHop) -> String {
let prefix = host_prefix_for(server);
let interface_index = interface_index_of(next_hop);
let mut script = format!(
"Remove-NetRoute -DestinationPrefix '{prefix}' -InterfaceIndex {interface_index} -PolicyStore ActiveStore"
);
if let NextHop::Gateway { address, .. } = next_hop {
script.push_str(&format!(" -NextHop '{address}'"));
}
script.push_str(" -Confirm:$false");
script
}
fn exclusion_route_remove_fallback_script(server: &IpAddr, next_hop: &NextHop) -> String {
let prefix = host_prefix_for(server);
let interface_index = interface_index_of(next_hop);
let remove_cmd = format!(
"Remove-NetRoute -DestinationPrefix '{prefix}' -InterfaceIndex {interface_index} -PolicyStore ActiveStore -Confirm:$false"
);
format!(
"$ErrorActionPreference = 'Stop'; \
try {{ {remove_cmd} }} \
catch [Microsoft.Management.Infrastructure.CimException] {{ \
if ($_.Exception.NativeErrorCode -ne [Microsoft.Management.Infrastructure.NativeErrorCode]::NotFound) {{ throw }} \
}}"
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::{Ipv4Addr, Ipv6Addr};
#[test]
fn parse_entries_single_object() {
let json = r#"{"InterfaceIndex": 12, "NextHop": "192.168.1.1"}"#;
let entries = parse_entries(json).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].interface_index, 12);
assert_eq!(entries[0].next_hop.as_deref(), Some("192.168.1.1"));
}
#[test]
fn parse_entries_array() {
let json = r#"[
{"InterfaceIndex": 12, "NextHop": "192.168.1.1"},
{"InterfaceIndex": 1, "NextHop": "0.0.0.0"}
]"#;
let entries = parse_entries(json).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].interface_index, 12);
assert_eq!(entries[0].next_hop.as_deref(), Some("192.168.1.1"));
assert_eq!(entries[1].interface_index, 1);
assert_eq!(entries[1].next_hop.as_deref(), Some("0.0.0.0"));
}
#[test]
fn parse_entries_null_next_hop() {
let json = r#"{"InterfaceIndex": 3, "NextHop": null}"#;
let entries = parse_entries(json).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].interface_index, 3);
assert_eq!(entries[0].next_hop, None);
}
#[test]
fn parse_entries_mixed_null_and_valid() {
let json = r#"[
{"InterfaceIndex": 3, "NextHop": null},
{"InterfaceIndex": 12, "NextHop": "192.168.1.1"},
{"InterfaceIndex": 1, "NextHop": "0.0.0.0"}
]"#;
let entries = parse_entries(json).unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].next_hop, None);
assert_eq!(entries[1].next_hop.as_deref(), Some("192.168.1.1"));
}
#[test]
fn parse_entries_empty_string() {
assert!(parse_entries("").is_err());
assert!(parse_entries(" ").is_err());
}
#[test]
fn parse_entries_invalid_json() {
assert!(parse_entries("not json").is_err());
}
#[test]
fn onlink_ipv4_zero() {
assert!(is_onlink_next_hop("0.0.0.0"));
}
#[test]
fn onlink_ipv6_unspecified() {
assert!(is_onlink_next_hop("::"));
}
#[test]
fn ipv4_gateway() {
let json = r#"[
{
"InterfaceIndex": 12,
"NextHop": "192.168.1.1"
},
{
"InterfaceIndex": 1,
"NextHop": "0.0.0.0"
}
]"#;
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
let hop = parse_find_net_route_json(json, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
assert_eq!(interface, "12");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn ipv4_onlink() {
let json = r#"{
"InterfaceIndex": 5,
"NextHop": "0.0.0.0"
}"#;
let addr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5));
let hop = parse_find_net_route_json(json, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "5");
}
_ => panic!("expected OnLink, got {hop:?}"),
}
}
#[test]
fn ipv6_gateway() {
let json = r#"[
{
"InterfaceIndex": 7,
"NextHop": "fe80::1"
},
{
"InterfaceIndex": 1,
"NextHop": "::"
}
]"#;
let addr = IpAddr::V6(Ipv6Addr::new(0x2001, 0x4860, 0x4860, 0, 0, 0, 0, 0x8888));
let hop = parse_find_net_route_json(json, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(
address,
IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1))
);
assert_eq!(interface, "7");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn ipv6_onlink() {
let json = r#"{
"InterfaceIndex": 7,
"NextHop": "::"
}"#;
let addr = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = parse_find_net_route_json(json, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "7");
}
_ => panic!("expected OnLink, got {hop:?}"),
}
}
#[test]
fn empty_json_returns_error() {
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(parse_find_net_route_json("", &addr).is_err());
}
#[test]
fn empty_array_returns_not_found() {
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(parse_find_net_route_json("[]", &addr).is_err());
}
#[test]
fn invalid_next_hop_address_returns_error() {
let json = r#"{"InterfaceIndex": 12, "NextHop": "not-an-ip"}"#;
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(parse_find_net_route_json(json, &addr).is_err());
}
#[test]
fn null_next_hop_single_entry_returns_not_found() {
let json = r#"{"InterfaceIndex": 3, "NextHop": null}"#;
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(parse_find_net_route_json(json, &addr).is_err());
}
#[test]
fn null_next_hop_skipped_picks_valid_gateway() {
let json = r#"[
{"InterfaceIndex": 3, "NextHop": null},
{"InterfaceIndex": 12, "NextHop": "192.168.1.1"},
{"InterfaceIndex": 1, "NextHop": "0.0.0.0"}
]"#;
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
let hop = parse_find_net_route_json(json, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)));
assert_eq!(interface, "12");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
#[test]
fn null_next_hop_skipped_picks_valid_onlink() {
let json = r#"[
{"InterfaceIndex": 3, "NextHop": null},
{"InterfaceIndex": 5, "NextHop": "0.0.0.0"}
]"#;
let addr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 5));
let hop = parse_find_net_route_json(json, &addr).unwrap();
match hop {
NextHop::OnLink { interface } => {
assert_eq!(interface, "5");
}
_ => panic!("expected OnLink, got {hop:?}"),
}
}
#[test]
fn all_null_next_hops_returns_not_found() {
let json = r#"[
{"InterfaceIndex": 3, "NextHop": null},
{"InterfaceIndex": 5, "NextHop": null}
]"#;
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(parse_find_net_route_json(json, &addr).is_err());
}
#[test]
fn invalid_next_hop_skipped_picks_valid() {
let json = r#"[
{"InterfaceIndex": 3, "NextHop": "not-an-ip"},
{"InterfaceIndex": 12, "NextHop": "10.0.0.1"}
]"#;
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
let hop = parse_find_net_route_json(json, &addr).unwrap();
match hop {
NextHop::Gateway { address, interface } => {
assert_eq!(address, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
assert_eq!(interface, "12");
}
_ => panic!("expected Gateway, got {hop:?}"),
}
}
mod exclusion_windows {
use super::*;
#[test]
fn add_gateway_ipv4_contains_new_netroute() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "12".to_string(),
};
let script = exclusion_route_add_script(&server, &hop);
assert!(script.contains(
"New-NetRoute -DestinationPrefix '203.0.113.1/32' -InterfaceIndex 12 -PolicyStore ActiveStore -NextHop '192.168.1.1'"
));
}
#[test]
fn add_gateway_ipv6_contains_new_netroute() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::Gateway {
address: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
interface: "7".to_string(),
};
let script = exclusion_route_add_script(&server, &hop);
assert!(script.contains(
"New-NetRoute -DestinationPrefix '2001:db8::1/128' -InterfaceIndex 7 -PolicyStore ActiveStore -NextHop 'fe80::1'"
));
}
#[test]
fn add_onlink_ipv4_omits_next_hop() {
let server = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "5".to_string(),
};
let script = exclusion_route_add_script(&server, &hop);
assert!(script.contains(
"New-NetRoute -DestinationPrefix '10.0.0.5/32' -InterfaceIndex 5 -PolicyStore ActiveStore |"
));
assert!(!script.contains("-NextHop"));
}
#[test]
fn add_script_uses_locale_independent_duplicate_detection() {
let server = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
interface: "3".to_string(),
};
let script = exclusion_route_add_script(&server, &hop);
assert!(
script.contains("[Microsoft.Management.Infrastructure.CimException]"),
"script must catch CimException specifically: {script}"
);
assert!(
script.contains(
"[Microsoft.Management.Infrastructure.NativeErrorCode]::AlreadyExists"
),
"script must compare against AlreadyExists enum value: {script}"
);
assert!(
!script.to_lowercase().contains("already exists"),
"script must not rely on English 'already exists' text: {script}"
);
}
#[test]
fn add_script_rethrows_non_already_exists_cim_errors() {
let server = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
interface: "3".to_string(),
};
let script = exclusion_route_add_script(&server, &hop);
assert!(
script.contains("throw"),
"add script must re-throw non-AlreadyExists CIM errors: {script}"
);
}
#[test]
fn remove_gateway_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "12".to_string(),
};
let script = exclusion_route_remove_script(&server, &hop);
assert_eq!(
script,
"Remove-NetRoute -DestinationPrefix '203.0.113.1/32' -InterfaceIndex 12 -PolicyStore ActiveStore -NextHop '192.168.1.1' -Confirm:$false"
);
}
#[test]
fn remove_onlink_ipv6() {
let server = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "7".to_string(),
};
let script = exclusion_route_remove_script(&server, &hop);
assert_eq!(
script,
"Remove-NetRoute -DestinationPrefix 'fe80::5/128' -InterfaceIndex 7 -PolicyStore ActiveStore -Confirm:$false"
);
}
#[test]
fn remove_gateway_ipv6() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::Gateway {
address: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
interface: "7".to_string(),
};
let script = exclusion_route_remove_script(&server, &hop);
assert_eq!(
script,
"Remove-NetRoute -DestinationPrefix '2001:db8::1/128' -InterfaceIndex 7 -PolicyStore ActiveStore -NextHop 'fe80::1' -Confirm:$false"
);
}
#[test]
fn fallback_remove_ipv4() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "12".to_string(),
};
let script = exclusion_route_remove_fallback_script(&server, &hop);
assert!(script.contains(
"Remove-NetRoute -DestinationPrefix '203.0.113.1/32' -InterfaceIndex 12 -PolicyStore ActiveStore -Confirm:$false"
));
}
#[test]
fn fallback_remove_ipv6() {
let server = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1));
let hop = NextHop::Gateway {
address: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
interface: "7".to_string(),
};
let script = exclusion_route_remove_fallback_script(&server, &hop);
assert!(script.contains(
"Remove-NetRoute -DestinationPrefix '2001:db8::1/128' -InterfaceIndex 7 -PolicyStore ActiveStore -Confirm:$false"
));
}
#[test]
fn fallback_remove_omits_next_hop_but_scopes_interface_and_store() {
let server = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
interface: "3".to_string(),
};
let script = exclusion_route_remove_fallback_script(&server, &hop);
assert!(
!script.contains(" -NextHop "),
"fallback script must not pass -NextHop as a CLI arg: {script}"
);
assert!(
script.contains("-InterfaceIndex 3"),
"fallback script must scope to the intended interface: {script}"
);
assert!(
script.contains("-PolicyStore ActiveStore"),
"fallback script must scope to ActiveStore: {script}"
);
}
#[test]
fn fallback_remove_swallows_not_found_benignly() {
let server = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
interface: "3".to_string(),
};
let script = exclusion_route_remove_fallback_script(&server, &hop);
assert!(
script.contains("[Microsoft.Management.Infrastructure.CimException]"),
"fallback must catch CimException specifically: {script}"
);
assert!(
script.contains("[Microsoft.Management.Infrastructure.NativeErrorCode]::NotFound"),
"fallback must compare against the NotFound enum value: {script}"
);
assert!(
script.contains("throw"),
"fallback must re-throw non-NotFound CIM errors: {script}"
);
assert!(
!script.to_lowercase().contains("not found ") && !script.to_lowercase().contains("does not exist")
&& !script.to_lowercase().contains("object not found"),
"fallback must not rely on English error text: {script}"
);
}
#[test]
fn primary_remove_does_not_swallow_not_found() {
let server = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
interface: "3".to_string(),
};
let script = exclusion_route_remove_script(&server, &hop);
assert!(
!script.contains("NativeErrorCode"),
"primary remove must not inspect NativeErrorCode (must let errors propagate): {script}"
);
assert!(
!script.contains("CimException"),
"primary remove must not catch CimException (must let errors propagate): {script}"
);
assert!(
!script.contains("try"),
"primary remove must not wrap in try/catch (must let errors propagate): {script}"
);
}
}
mod self_referential {
use super::*;
#[test]
fn gateway_with_server_as_address_is_self_referential() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1)),
interface: "12".to_string(),
};
assert!(is_self_referential_next_hop(&server, &hop, 7));
}
#[test]
fn gateway_on_tunnel_interface_is_self_referential() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "42".to_string(),
};
assert!(is_self_referential_next_hop(&server, &hop, 42));
}
#[test]
fn onlink_on_tunnel_interface_is_self_referential() {
let server = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "42".to_string(),
};
assert!(is_self_referential_next_hop(&server, &hop, 42));
}
#[test]
fn gateway_via_real_router_on_other_interface_is_ok() {
let server = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1));
let hop = NextHop::Gateway {
address: IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
interface: "12".to_string(),
};
assert!(!is_self_referential_next_hop(&server, &hop, 42));
}
#[test]
fn onlink_on_other_interface_is_ok() {
let server = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5));
let hop = NextHop::OnLink {
interface: "12".to_string(),
};
assert!(!is_self_referential_next_hop(&server, &hop, 42));
}
}
mod ps_escape {
use super::*;
#[test]
fn plain_name_unchanged() {
assert_eq!(escape_ps_single_quoted("Ethernet"), "Ethernet");
}
#[test]
fn name_with_spaces_unchanged() {
assert_eq!(escape_ps_single_quoted("Wi-Fi 2"), "Wi-Fi 2");
}
#[test]
fn single_quote_is_doubled() {
assert_eq!(escape_ps_single_quoted("Adapter'Name"), "Adapter''Name");
}
#[test]
fn multiple_quotes_are_doubled() {
assert_eq!(escape_ps_single_quoted("it's a 'test'"), "it''s a ''test''");
}
#[test]
fn empty_string() {
assert_eq!(escape_ps_single_quoted(""), "");
}
#[test]
fn left_single_quotation_mark_escaped() {
assert_eq!(
escape_ps_single_quoted("Adapter\u{2018}Name"),
"Adapter''Name"
);
}
#[test]
fn right_single_quotation_mark_escaped() {
assert_eq!(
escape_ps_single_quoted("Adapter\u{2019}Name"),
"Adapter''Name"
);
}
#[test]
fn single_low_9_quotation_mark_escaped() {
assert_eq!(
escape_ps_single_quoted("Adapter\u{201A}Name"),
"Adapter''Name"
);
}
#[test]
fn single_high_reversed_9_quotation_mark_escaped() {
assert_eq!(
escape_ps_single_quoted("Adapter\u{201B}Name"),
"Adapter''Name"
);
}
#[test]
fn mixed_ascii_and_smart_quotes_all_escaped() {
let input = "a'b\u{2018}c\u{2019}d\u{201A}e\u{201B}f";
let escaped = escape_ps_single_quoted(input);
assert_eq!(escaped, "a''b''c''d''e''f");
}
}
#[test]
fn covers_address_in_default_route() {
let nets = vec!["0.0.0.0/0".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(any_route_covers_address(&nets, &addr));
}
#[test]
fn covers_address_in_subnet() {
let nets = vec!["10.0.0.0/8".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3));
assert!(any_route_covers_address(&nets, &addr));
}
#[test]
fn does_not_cover_address_outside_subnet() {
let nets = vec!["10.0.0.0/8".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1));
assert!(!any_route_covers_address(&nets, &addr));
}
#[test]
fn covers_ipv6_in_default() {
let nets = vec!["::/0".parse::<IpNet>().unwrap()];
let addr: IpAddr = "2001:db8::1".parse().unwrap();
assert!(any_route_covers_address(&nets, &addr));
}
#[test]
fn does_not_cover_ipv4_in_ipv6_route() {
let nets = vec!["::/0".parse::<IpNet>().unwrap()];
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(!any_route_covers_address(&nets, &addr));
}
#[test]
fn covers_empty_routes() {
let addr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8));
assert!(!any_route_covers_address(&[], &addr));
}
mod user_routes_script {
use super::*;
#[test]
fn single_ipv4_route() {
let networks = vec!["10.0.0.0/8".parse::<IpNet>().unwrap()];
let gateway: IpAddr = "192.168.1.1".parse().unwrap();
let script = build_user_routes_script(&networks, &gateway, 12);
assert_eq!(
script,
"$ErrorActionPreference = 'Stop'; \
New-NetRoute -DestinationPrefix '10.0.0.0/8' -InterfaceIndex 12 -NextHop '192.168.1.1' -PolicyStore ActiveStore; "
);
}
#[test]
fn multiple_ipv4_routes() {
let networks: Vec<IpNet> = vec![
"10.0.0.0/8".parse().unwrap(),
"172.16.0.0/12".parse().unwrap(),
"192.168.0.0/16".parse().unwrap(),
];
let gateway: IpAddr = "10.255.0.1".parse().unwrap();
let script = build_user_routes_script(&networks, &gateway, 42);
assert!(script.starts_with("$ErrorActionPreference = 'Stop'; "));
assert!(script.contains(
"New-NetRoute -DestinationPrefix '10.0.0.0/8' -InterfaceIndex 42 -NextHop '10.255.0.1' -PolicyStore ActiveStore; "
));
assert!(script.contains(
"New-NetRoute -DestinationPrefix '172.16.0.0/12' -InterfaceIndex 42 -NextHop '10.255.0.1' -PolicyStore ActiveStore; "
));
assert!(script.contains(
"New-NetRoute -DestinationPrefix '192.168.0.0/16' -InterfaceIndex 42 -NextHop '10.255.0.1' -PolicyStore ActiveStore; "
));
}
#[test]
fn ipv6_route() {
let networks = vec!["2001:db8::/32".parse::<IpNet>().unwrap()];
let gateway: IpAddr = "fe80::1".parse().unwrap();
let script = build_user_routes_script(&networks, &gateway, 7);
assert_eq!(
script,
"$ErrorActionPreference = 'Stop'; \
New-NetRoute -DestinationPrefix '2001:db8::/32' -InterfaceIndex 7 -NextHop 'fe80::1' -PolicyStore ActiveStore; "
);
}
#[test]
fn mixed_ipv4_and_ipv6_routes() {
let networks: Vec<IpNet> = vec!["0.0.0.0/0".parse().unwrap(), "::/0".parse().unwrap()];
let gateway: IpAddr = "10.0.0.1".parse().unwrap();
let script = build_user_routes_script(&networks, &gateway, 5);
assert!(script.contains(
"New-NetRoute -DestinationPrefix '0.0.0.0/0' -InterfaceIndex 5 -NextHop '10.0.0.1' -PolicyStore ActiveStore; "
));
assert!(script.contains(
"New-NetRoute -DestinationPrefix '::/0' -InterfaceIndex 5 -NextHop '10.0.0.1' -PolicyStore ActiveStore; "
));
}
#[test]
fn empty_networks_produces_only_preamble() {
let gateway: IpAddr = "10.0.0.1".parse().unwrap();
let script = build_user_routes_script(&[], &gateway, 5);
assert_eq!(script, "$ErrorActionPreference = 'Stop'; ");
}
}
}