use crate::capabilities::{render_with_hints, View};
use crate::cli::{Cli, FirewallAction};
use crate::error::{is_service_unknown, FezError, Result};
use crate::protocol::client::BridgeClient;
use crate::transport;
use serde_json::{json, Value};
const FW_NAME: &str = "org.fedoraproject.FirewallD1";
const FW_PATH: &str = "/org/fedoraproject/FirewallD1";
const FW_IFACE: &str = "org.fedoraproject.FirewallD1";
const FW_ZONE_IFACE: &str = "org.fedoraproject.FirewallD1.zone";
const FW_CONFIG_PATH: &str = "/org/fedoraproject/FirewallD1/config";
const FW_CONFIG_IFACE: &str = "org.fedoraproject.FirewallD1.config";
const FW_CONFIG_ZONE_IFACE: &str = "org.fedoraproject.FirewallD1.config.zone";
pub fn dispatch(cli: &Cli, action: &FirewallAction) -> i32 {
let view = run(cli, action);
render_with_hints(cli, view, error_hints)
}
fn error_hints(e: &FezError) -> Option<Value> {
match e {
FezError::DependencyMissing { .. } => Some(json!({
"checkService": "fez services status firewalld.service --json",
"install": "dnf install firewalld",
})),
FezError::UnsupportedApi(method) => Some(json!({
"unsupported": format!(
"firewalld on this host does not expose {method}; treat the feature as unsupported"
),
})),
_ => None,
}
}
fn dependency_missing() -> FezError {
FezError::DependencyMissing {
component: "firewalld".into(),
dbus_name: FW_NAME.into(),
remediation: "Install firewalld on the target (dnf install firewalld) and enable+start it (systemctl enable --now firewalld.service), then retry.".into(),
}
}
fn run(cli: &Cli, action: &FirewallAction) -> Result<View> {
let transport = transport::from_host(cli.host.as_deref());
let mut client = BridgeClient::connect(transport.as_ref())?;
let host = client.host().to_string();
match action {
FirewallAction::Status => {
let ch = open_channel(&mut client, false)?;
status(&mut client, &ch, host)
}
FirewallAction::List => {
let ch = open_channel(&mut client, false)?;
list(&mut client, &ch, host)
}
FirewallAction::Show { zone } => {
let ch = open_channel(&mut client, false)?;
show(&mut client, &ch, host, zone)
}
FirewallAction::Services => {
let ch = open_channel(&mut client, false)?;
services(&mut client, &ch, host)
}
_ => mutate(cli, &mut client, host, action),
}
}
fn open_channel(client: &mut BridgeClient, privileged: bool) -> Result<String> {
if privileged {
client.dbus_open_privileged(FW_NAME)
} else {
client.dbus_open(FW_NAME)
}
}
fn fw_call(
client: &mut BridgeClient,
channel: &str,
iface: &str,
method: &str,
args: Value,
) -> Result<Value> {
fw_call_path(client, channel, FW_PATH, iface, method, args)
}
fn fw_call_path(
client: &mut BridgeClient,
channel: &str,
path: &str,
iface: &str,
method: &str,
args: Value,
) -> Result<Value> {
client
.dbus_call(channel, path, iface, method, args)
.map_err(|e| map_fw_error(e, method))
}
fn map_fw_error(e: FezError, method: &str) -> FezError {
match e {
FezError::Dbus { ref name, .. } if is_service_unknown(name) => dependency_missing(),
FezError::Dbus { ref name, .. } if name.contains("UnknownMethod") => {
FezError::UnsupportedApi(method.to_string())
}
FezError::Problem(ref p) if p == "not-found" || p == "not-supported" => {
dependency_missing()
}
other => other,
}
}
fn arg_str_vec(out: &Value) -> Vec<String> {
out.get(0)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn arg_str(out: &Value) -> String {
out.get(0).and_then(Value::as_str).unwrap_or("").to_string()
}
fn arg_bool(out: &Value) -> bool {
out.get(0).and_then(Value::as_bool).unwrap_or(false)
}
fn ports_from_reply(out: &Value) -> Vec<String> {
out.get(0)
.and_then(Value::as_array)
.map(|a| a.iter().map(port_label).filter(|s| !s.is_empty()).collect())
.unwrap_or_default()
}
fn port_label(entry: &Value) -> String {
let port = entry.get(0).and_then(Value::as_str).unwrap_or("");
let proto = entry.get(1).and_then(Value::as_str).unwrap_or("");
if port.is_empty() || proto.is_empty() {
String::new()
} else {
format!("{port}/{proto}")
}
}
fn parse_port_spec(spec: &str) -> Result<(u16, String)> {
let (port, proto) = spec
.split_once('/')
.ok_or_else(|| FezError::NotFound(format!("port spec {spec:?} (expected port/proto)")))?;
let port: u16 = port
.parse()
.map_err(|_| FezError::NotFound(format!("port {port:?} (expected 1-65535)")))?;
if proto.is_empty() {
return Err(FezError::NotFound(format!(
"port spec {spec:?} (empty protocol)"
)));
}
Ok((port, proto.to_string()))
}
fn compute_drift(
runtime_services: &[String],
permanent_services: &[String],
runtime_ports: &[String],
permanent_ports: &[String],
runtime_masquerade: bool,
permanent_masquerade: bool,
) -> Vec<String> {
let mut drift = Vec::new();
for s in runtime_services {
if !permanent_services.contains(s) {
drift.push(format!("+service {s}"));
}
}
for s in permanent_services {
if !runtime_services.contains(s) {
drift.push(format!("-service {s}"));
}
}
for p in runtime_ports {
if !permanent_ports.contains(p) {
drift.push(format!("+port {p}"));
}
}
for p in permanent_ports {
if !runtime_ports.contains(p) {
drift.push(format!("-port {p}"));
}
}
if runtime_masquerade && !permanent_masquerade {
drift.push("+masquerade".to_string());
}
if permanent_masquerade && !runtime_masquerade {
drift.push("-masquerade".to_string());
}
drift
}
fn is_config_info_denied(e: &FezError) -> bool {
match e {
FezError::Dbus { name, message } => {
name.contains("NotAuthorized")
|| name.contains("AccessDenied")
|| message.contains("config.info")
}
_ => false,
}
}
fn permanent_zone(
client: &mut BridgeClient,
channel: &str,
zone: &str,
) -> Result<(Vec<String>, Vec<String>, bool)> {
let obj = fw_call_path(
client,
channel,
FW_CONFIG_PATH,
FW_CONFIG_IFACE,
"getZoneByName",
json!([zone]),
)?;
let zone_path = arg_str(&obj);
let services = arg_str_vec(&fw_call_path(
client,
channel,
&zone_path,
FW_CONFIG_ZONE_IFACE,
"getServices",
json!([]),
)?);
let ports = ports_from_reply(&fw_call_path(
client,
channel,
&zone_path,
FW_CONFIG_ZONE_IFACE,
"getPorts",
json!([]),
)?);
let masquerade = arg_bool(&fw_call_path(
client,
channel,
&zone_path,
FW_CONFIG_ZONE_IFACE,
"getMasquerade",
json!([]),
)?);
Ok((services, ports, masquerade))
}
fn runtime_zone(
client: &mut BridgeClient,
channel: &str,
zone: &str,
) -> Result<(Vec<String>, Vec<String>, bool)> {
let services = arg_str_vec(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getServices",
json!([zone]),
)?);
let ports = ports_from_reply(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getPorts",
json!([zone]),
)?);
let masquerade = arg_bool(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getMasquerade",
json!([zone]),
)?);
Ok((services, ports, masquerade))
}
fn status(client: &mut BridgeClient, channel: &str, host: String) -> Result<View> {
let default_zone = arg_str(&fw_call(
client,
channel,
FW_IFACE,
"getDefaultZone",
json!([]),
)?);
let panic = arg_bool(&fw_call(
client,
channel,
FW_IFACE,
"queryPanicMode",
json!([]),
)?);
let (rt_services, rt_ports, rt_masq) = runtime_zone(client, channel, &default_zone)?;
let priv_channel = open_channel(client, true)?;
let drift = match permanent_zone(client, &priv_channel, &default_zone) {
Ok((perm_services, perm_ports, perm_masq)) => Some(compute_drift(
&rt_services,
&perm_services,
&rt_ports,
&perm_ports,
rt_masq,
perm_masq,
)),
Err(e) if is_config_info_denied(&e) => None,
Err(e) => return Err(e),
};
let data = json!({
"running": true,
"default_zone": default_zone,
"panic_mode": panic,
"masquerade": rt_masq,
"pending_changes": drift.clone().unwrap_or_default(),
"pending_changes_available": drift.is_some(),
});
let mut human = format!(
"running: yes\ndefault zone: {default_zone}\npanic mode: {}\nmasquerade: {}\n",
if panic { "on" } else { "off" },
if rt_masq { "on" } else { "off" }
);
let hints = match drift {
Some(drift) if drift.is_empty() => {
human.push_str("pending: none\n");
None
}
Some(drift) => {
human.push_str(&format!("pending: {}\n", drift.join(", ")));
Some(json!({
"warning": "uncommitted runtime changes; run `fez firewall confirm` to persist or `fez firewall reload` to discard",
"pending": drift,
}))
}
None => {
human.push_str("pending: unavailable (permanent config not readable)\n");
Some(json!({
"warning": "permanent firewall config was not readable; runtime status is shown but pending_changes may be incomplete",
"follow_up": "Check firewalld config.info authorization for the target user or run `fez firewall status` from a context allowed by polkit."
}))
}
};
Ok(View::new("FirewallStatus", host, data, human).with_hints_opt(hints))
}
fn list(client: &mut BridgeClient, channel: &str, host: String) -> Result<View> {
let zones = arg_str_vec(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getZones",
json!([]),
)?);
let default_zone = arg_str(&fw_call(
client,
channel,
FW_IFACE,
"getDefaultZone",
json!([]),
)?);
let columns = ["zone", "default", "services", "ports", "interfaces"];
let mut rows = Vec::new();
let mut human = format!(
"{:<12} {:<8} {:<24} {:<16} {}\n",
"ZONE", "DEFAULT", "SERVICES", "PORTS", "INTERFACES"
);
for zone in &zones {
let (services, ports, _masquerade) = runtime_zone(client, channel, zone)?;
let interfaces = arg_str_vec(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getInterfaces",
json!([zone]),
)?);
let is_default = *zone == default_zone;
human.push_str(&format!(
"{:<12} {:<8} {:<24} {:<16} {}\n",
zone,
if is_default { "yes" } else { "" },
services.join(","),
ports.join(","),
interfaces.join(","),
));
rows.push(json!([
zone,
is_default,
services.join(","),
ports.join(","),
interfaces.join(","),
]));
}
Ok(View::new(
"FirewallZoneList",
host,
crate::envelope::table_data(&columns, rows),
human,
))
}
fn show(client: &mut BridgeClient, channel: &str, host: String, zone: &str) -> Result<View> {
let zones = arg_str_vec(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getZones",
json!([]),
)?);
if !zones.iter().any(|z| z == zone) {
return Err(FezError::NotFound(format!("firewall zone {zone}")));
}
let (services, ports, masquerade) = runtime_zone(client, channel, zone)?;
let interfaces = arg_str_vec(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getInterfaces",
json!([zone]),
)?);
let sources = arg_str_vec(&fw_call(
client,
channel,
FW_ZONE_IFACE,
"getSources",
json!([zone]),
)?);
let data = json!({
"zone": zone,
"services": services,
"ports": ports,
"interfaces": interfaces,
"sources": sources,
"masquerade": masquerade,
});
let human = format!(
"Zone: {zone}\nServices: {}\nPorts: {}\nInterfaces: {}\nSources: {}\nMasquerade: {}\n",
services.join(", "),
ports.join(", "),
interfaces.join(", "),
sources.join(", "),
if masquerade { "on" } else { "off" },
);
Ok(View::new("FirewallZone", host, data, human))
}
fn services(client: &mut BridgeClient, channel: &str, host: String) -> Result<View> {
let mut catalog = arg_str_vec(&fw_call(
client,
channel,
FW_IFACE,
"listServices",
json!([]),
)?);
catalog.sort();
let mut human = String::new();
for s in &catalog {
human.push_str(s);
human.push('\n');
}
Ok(View::new(
"FirewallServiceCatalog",
host,
json!({ "services": catalog }),
human,
))
}
fn session_services() -> Vec<String> {
vec!["ssh".to_string()]
}
fn session_port_from(ssh_connection: &str) -> Option<u16> {
ssh_connection.split_whitespace().nth(3)?.parse().ok()
}
fn session_ports() -> Vec<u16> {
std::env::var("SSH_CONNECTION")
.ok()
.and_then(|c| session_port_from(&c))
.into_iter()
.collect()
}
fn effective_zone(
client: &mut BridgeClient,
channel: &str,
requested: &Option<String>,
) -> Result<String> {
match requested {
Some(z) => Ok(z.clone()),
None => Ok(arg_str(&fw_call(
client,
channel,
FW_IFACE,
"getDefaultZone",
json!([]),
)?)),
}
}
fn mutate(
cli: &Cli,
client: &mut BridgeClient,
host: String,
action: &FirewallAction,
) -> Result<View> {
let channel = open_channel(client, true)?;
match action {
FirewallAction::AddService {
service,
zone,
timeout,
} => {
let zone = effective_zone(client, &channel, zone)?;
let t = i64::from(timeout.unwrap_or(0));
run_audited(
client,
&channel,
&host,
"add-service",
&format!("{zone}:{service}"),
FW_ZONE_IFACE,
"addService",
json!([zone, service, t]),
)?;
Ok(change_view(
host,
"add-service",
&zone,
&format!("service {service}"),
*timeout,
))
}
FirewallAction::RemoveService { service, zone } => {
let zone = effective_zone(client, &channel, zone)?;
crate::safety::check_firewall_service_removal(service, &session_services(), cli.force)?;
run_audited(
client,
&channel,
&host,
"remove-service",
&format!("{zone}:{service}"),
FW_ZONE_IFACE,
"removeService",
json!([zone, service]),
)?;
Ok(change_view(
host,
"remove-service",
&zone,
&format!("service {service}"),
None,
))
}
FirewallAction::AddPort {
port,
zone,
timeout,
} => {
let (p, proto) = parse_port_spec(port)?;
let zone = effective_zone(client, &channel, zone)?;
let t = i64::from(timeout.unwrap_or(0));
run_audited(
client,
&channel,
&host,
"add-port",
&format!("{zone}:{p}/{proto}"),
FW_ZONE_IFACE,
"addPort",
json!([zone, p.to_string(), proto, t]),
)?;
Ok(change_view(
host,
"add-port",
&zone,
&format!("port {p}/{proto}"),
*timeout,
))
}
FirewallAction::RemovePort { port, zone } => {
let (p, proto) = parse_port_spec(port)?;
let zone = effective_zone(client, &channel, zone)?;
crate::safety::check_firewall_port_removal(p, &session_ports(), cli.force)?;
run_audited(
client,
&channel,
&host,
"remove-port",
&format!("{zone}:{p}/{proto}"),
FW_ZONE_IFACE,
"removePort",
json!([zone, p.to_string(), proto]),
)?;
Ok(change_view(
host,
"remove-port",
&zone,
&format!("port {p}/{proto}"),
None,
))
}
FirewallAction::SetDefaultZone { zone } => {
crate::safety::check_firewall_default_zone(cli.force)?;
run_audited(
client,
&channel,
&host,
"set-default-zone",
zone,
FW_IFACE,
"setDefaultZone",
json!([zone]),
)?;
Ok(change_view(
host,
"set-default-zone",
zone,
"default zone",
None,
))
}
FirewallAction::Reload => {
let default_zone = arg_str(&fw_call(
client,
&channel,
FW_IFACE,
"getDefaultZone",
json!([]),
)?);
let (rt_s, rt_p, rt_m) = runtime_zone(client, &channel, &default_zone)?;
let has_drift = match permanent_zone(client, &channel, &default_zone) {
Ok((pm_s, pm_p, pm_m)) => {
!compute_drift(&rt_s, &pm_s, &rt_p, &pm_p, rt_m, pm_m).is_empty()
}
Err(e) if is_config_info_denied(&e) => true,
Err(e) => return Err(e),
};
crate::safety::check_firewall_reload(has_drift, cli.force)?;
run_audited(
client,
&channel,
&host,
"reload",
"firewall",
FW_IFACE,
"reload",
json!([]),
)?;
Ok(reload_view(host))
}
FirewallAction::Confirm => {
run_audited(
client,
&channel,
&host,
"confirm",
"firewall",
FW_IFACE,
"runtimeToPermanent",
json!([]),
)?;
Ok(confirm_view(host))
}
FirewallAction::Panic { state } => {
let on = state == "on";
if on {
crate::safety::check_firewall_panic_on(cli.force)?;
}
let method = if on {
"enablePanicMode"
} else {
"disablePanicMode"
};
run_audited(
client,
&channel,
&host,
&format!("panic-{state}"),
"firewall",
FW_IFACE,
method,
json!([]),
)?;
Ok(panic_view(host, on))
}
FirewallAction::Masquerade {
state,
zone,
timeout,
} => {
let on = state == "on";
let zone = effective_zone(client, &channel, zone)?;
if !on {
crate::safety::check_firewall_masquerade_off(cli.force)?;
}
let (method, args) = if on {
let t = i64::from(timeout.unwrap_or(0));
("addMasquerade", json!([zone, t]))
} else {
("removeMasquerade", json!([zone]))
};
run_audited(
client,
&channel,
&host,
&format!("masquerade-{state}"),
&zone,
FW_ZONE_IFACE,
method,
args,
)?;
Ok(masquerade_view(
host,
&zone,
on,
if on { *timeout } else { None },
))
}
FirewallAction::Status
| FirewallAction::List
| FirewallAction::Show { .. }
| FirewallAction::Services => Err(FezError::Problem("read action routed to mutate".into())),
}
}
#[allow(clippy::too_many_arguments)]
fn run_audited(
client: &mut BridgeClient,
channel: &str,
host: &str,
operation: &str,
target: &str,
iface: &str,
method: &str,
args: Value,
) -> Result<()> {
let sink = crate::audit::sink_from_env();
let ctx = crate::audit::AuditContext::new(
&crate::audit::actor(),
host,
operation,
target,
&crate::audit::correlation_id(),
);
sink.write(&ctx.record(crate::audit::Outcome::Attempt));
let exec = fw_call(client, channel, iface, method, args);
match &exec {
Ok(_) => sink.write(&ctx.record(crate::audit::Outcome::Ok)),
Err(e) => sink.write(&ctx.record(crate::audit::Outcome::Error(e.to_string()))),
}
exec.map(|_| ())
}
fn confirm_hint() -> Value {
json!({
"persisted": false,
"note": "runtime-only change; run `fez firewall confirm` to persist it",
})
}
fn change_view(host: String, op: &str, zone: &str, what: &str, timeout: Option<u32>) -> View {
let mut data = json!({
"operation": op,
"zone": zone,
"change": what,
"persisted": false,
});
if let Some(t) = timeout {
data["timeout"] = json!(t);
}
let human = format!("{op} {what} in zone {zone} (runtime only)\n");
View::new("FirewallChange", host, data, human).with_hints(confirm_hint())
}
fn reload_view(host: String) -> View {
View::new(
"FirewallChange",
host,
json!({"operation": "reload", "persisted": true}),
"reloaded permanent config into runtime\n".into(),
)
}
fn confirm_view(host: String) -> View {
View::new(
"FirewallConfirm",
host,
json!({"operation": "confirm", "persisted": true}),
"runtime config committed to permanent\n".into(),
)
}
fn panic_view(host: String, on: bool) -> View {
View::new(
"FirewallChange",
host,
json!({"operation": "panic", "panic_mode": on, "persisted": false}),
format!("panic mode {}\n", if on { "enabled" } else { "disabled" }),
)
}
fn masquerade_view(host: String, zone: &str, on: bool, timeout: Option<u32>) -> View {
let mut data = json!({
"operation": "masquerade",
"zone": zone,
"change": if on { "+masquerade" } else { "-masquerade" },
"masquerade": on,
"persisted": false,
});
if let Some(t) = timeout {
data["timeout"] = json!(t);
}
let human = format!(
"masquerade {} in zone {zone} (runtime only)\n",
if on { "enabled" } else { "disabled" }
);
View::new("FirewallChange", host, data, human).with_hints(confirm_hint())
}
#[cfg(test)]
mod tests {
use super::*;
fn dbus(name: &str) -> FezError {
FezError::Dbus {
name: name.into(),
message: "boom".into(),
}
}
#[test]
fn map_fw_error_service_unknown_is_dependency_missing() {
let mapped = map_fw_error(
dbus("org.freedesktop.DBus.Error.ServiceUnknown"),
"getZones",
);
assert_eq!(mapped.code(), "dependency-missing");
assert_eq!(mapped.exit_code(), 9);
assert_eq!(
map_fw_error(
dbus("org.freedesktop.DBus.Error.NameHasNoOwner"),
"getZones"
)
.code(),
"dependency-missing"
);
}
#[test]
fn map_fw_error_unknown_method_is_unsupported_api() {
let mapped = map_fw_error(
dbus("org.freedesktop.DBus.Error.UnknownMethod"),
"getMasquerade",
);
assert_eq!(mapped.code(), "unsupported-api");
assert_eq!(mapped.exit_code(), 12);
assert!(matches!(
mapped,
FezError::UnsupportedApi(ref m) if m == "getMasquerade"
));
}
#[test]
fn map_fw_error_channel_problem_is_dependency_missing() {
for problem in ["not-found", "not-supported"] {
let mapped = map_fw_error(FezError::Problem(problem.into()), "getZones");
assert_eq!(
mapped.code(),
"dependency-missing",
"Problem({problem}) should map to dependency-missing"
);
}
}
#[test]
fn map_fw_error_passes_through_unrelated_errors() {
assert_eq!(
map_fw_error(
FezError::Problem("authentication-failed".into()),
"getZones"
)
.code(),
"auth-failed"
);
let denied = FezError::AccessDenied {
remediation: "enable sudo".into(),
};
assert_eq!(map_fw_error(denied, "getZones").code(), "access-denied");
}
#[test]
fn parse_port_spec_splits_port_and_proto() {
assert_eq!(
parse_port_spec("8080/tcp").unwrap(),
(8080, "tcp".to_string())
);
assert_eq!(parse_port_spec("53/udp").unwrap(), (53, "udp".to_string()));
}
#[test]
fn parse_port_spec_rejects_garbage() {
assert!(parse_port_spec("nope").is_err());
assert!(parse_port_spec("8080").is_err());
assert!(parse_port_spec("99999/tcp").is_err()); assert!(parse_port_spec("80/").is_err());
}
#[test]
fn port_label_joins_port_and_proto() {
assert_eq!(port_label(&json!(["9090", "tcp"])), "9090/tcp");
assert_eq!(port_label(&json!([])), "");
}
#[test]
fn drift_reports_runtime_only_ports() {
let runtime_ports = vec!["9090/tcp".to_string()];
let permanent_ports: Vec<String> = vec![];
let runtime_services = vec!["ssh".to_string()];
let permanent_services = vec!["ssh".to_string()];
let drift = compute_drift(
&runtime_services,
&permanent_services,
&runtime_ports,
&permanent_ports,
false,
false,
);
assert_eq!(drift, vec!["+port 9090/tcp".to_string()]);
}
#[test]
fn drift_empty_when_runtime_matches_permanent() {
let s = vec!["ssh".to_string()];
let p: Vec<String> = vec![];
assert!(compute_drift(&s, &s, &p, &p, false, false).is_empty());
}
#[test]
fn drift_reports_removed_service() {
let runtime_services: Vec<String> = vec![];
let permanent_services = vec!["http".to_string()];
let p: Vec<String> = vec![];
let drift = compute_drift(&runtime_services, &permanent_services, &p, &p, false, false);
assert_eq!(drift, vec!["-service http".to_string()]);
}
#[test]
fn drift_reports_masquerade_added_at_runtime() {
let s = vec!["ssh".to_string()];
let p: Vec<String> = vec![];
let drift = compute_drift(&s, &s, &p, &p, true, false);
assert_eq!(drift, vec!["+masquerade".to_string()]);
}
#[test]
fn drift_reports_masquerade_removed_at_runtime() {
let s = vec!["ssh".to_string()];
let p: Vec<String> = vec![];
let drift = compute_drift(&s, &s, &p, &p, false, true);
assert_eq!(drift, vec!["-masquerade".to_string()]);
}
#[test]
fn drift_empty_when_masquerade_matches() {
let s = vec!["ssh".to_string()];
let p: Vec<String> = vec![];
assert!(compute_drift(&s, &s, &p, &p, true, true).is_empty());
}
#[test]
fn session_port_parses_ssh_connection() {
assert_eq!(session_port_from("10.0.0.1 5520 10.0.0.2 22"), Some(22));
assert_eq!(session_port_from("10.0.0.1 5520 10.0.0.2 2222"), Some(2222));
}
#[test]
fn session_port_none_when_absent_or_malformed() {
assert_eq!(session_port_from(""), None);
assert_eq!(session_port_from("garbage"), None);
assert_eq!(session_port_from("a b c notaport"), None);
}
#[test]
fn session_services_always_includes_ssh() {
assert_eq!(session_services(), vec!["ssh".to_string()]);
}
}