use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum KillSwitchMode {
#[default]
Off,
Auto,
AlwaysOn,
}
impl KillSwitchMode {
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::Off => "Off",
Self::Auto => "Block on drop",
Self::AlwaysOn => "VPN-only",
}
}
#[must_use]
pub const fn cli_verb(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "block-on-drop",
Self::AlwaysOn => "vpn-only",
}
}
#[must_use]
pub fn from_cli_verb(verb: &str) -> Option<Self> {
match verb.to_ascii_lowercase().as_str() {
"off" => Some(Self::Off),
"block-on-drop" => Some(Self::Auto),
"vpn-only" => Some(Self::AlwaysOn),
_ => None,
}
}
#[must_use]
pub const fn one_liner(self) -> &'static str {
match self {
Self::Off => "All traffic flows. If the VPN drops, your real IP is exposed.",
Self::Auto => "If the VPN drops unexpectedly, block all traffic until you reconnect.",
Self::AlwaysOn => "Only traffic through active VPN tunnels. No internet without a VPN.",
}
}
#[must_use]
pub const fn desired_state(
self,
old_state: KillSwitchState,
is_connected: bool,
) -> KillSwitchState {
match self {
Self::Off => KillSwitchState::Disabled,
Self::Auto => {
if is_connected {
KillSwitchState::Armed
} else if matches!(old_state, KillSwitchState::Blocking) {
KillSwitchState::Blocking
} else {
KillSwitchState::Armed
}
}
Self::AlwaysOn => KillSwitchState::Blocking,
}
}
#[must_use]
pub const fn behavior_lines(self) -> (&'static str, &'static str) {
match self {
Self::Off => (
"VPN up: all traffic flows freely.",
"VPN down: real IPv4 (and IPv6, if present) exposed.",
),
Self::Auto => (
"VPN up: browse normally.",
"VPN down: traffic blocks until reconnect or `release-killswitch`.",
),
Self::AlwaysOn => (
"VPN up: only tunnel traffic permitted.",
"VPN down: no internet at all (canonical kill-switch shape).",
),
}
}
}
impl KillSwitchMode {
#[must_use]
pub fn next(self) -> Self {
match self {
Self::Off => Self::Auto,
Self::Auto => Self::AlwaysOn,
Self::AlwaysOn => Self::Off,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum KillSwitchState {
#[default]
Disabled,
Armed,
Blocking,
}
impl KillSwitchState {
#[must_use]
pub const fn is_blocking(self) -> bool {
matches!(self, Self::Blocking)
}
#[must_use]
pub const fn display_status(self) -> &'static str {
match self {
Self::Disabled => "Inactive",
Self::Armed => "Watching",
Self::Blocking => "Blocking",
}
}
#[must_use]
pub const fn cli_verb(self) -> &'static str {
match self {
Self::Disabled => "inactive",
Self::Armed => "watching",
Self::Blocking => "blocking",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mode_cycle() {
assert_eq!(KillSwitchMode::Off.next(), KillSwitchMode::Auto);
assert_eq!(KillSwitchMode::Auto.next(), KillSwitchMode::AlwaysOn);
assert_eq!(KillSwitchMode::AlwaysOn.next(), KillSwitchMode::Off);
}
#[test]
fn test_state_is_blocking() {
assert!(!KillSwitchState::Disabled.is_blocking());
assert!(!KillSwitchState::Armed.is_blocking());
assert!(KillSwitchState::Blocking.is_blocking());
}
#[test]
fn off_mode_always_disabled() {
for old in [
KillSwitchState::Disabled,
KillSwitchState::Armed,
KillSwitchState::Blocking,
] {
for is_connected in [false, true] {
assert_eq!(
KillSwitchMode::Off.desired_state(old, is_connected),
KillSwitchState::Disabled,
"Off should always resolve to Disabled (old={old:?}, is_connected={is_connected})"
);
}
}
}
#[test]
fn auto_mode_is_armed_when_connected_blocking_when_dropped() {
assert_eq!(
KillSwitchMode::Auto.desired_state(KillSwitchState::Armed, true),
KillSwitchState::Armed
);
assert_eq!(
KillSwitchMode::Auto.desired_state(KillSwitchState::Disabled, false),
KillSwitchState::Armed
);
assert_eq!(
KillSwitchMode::Auto.desired_state(KillSwitchState::Armed, false),
KillSwitchState::Armed
);
assert_eq!(
KillSwitchMode::Auto.desired_state(KillSwitchState::Blocking, false),
KillSwitchState::Blocking
);
}
#[test]
fn always_on_resolves_to_blocking_regardless_of_connection_or_history() {
for old in [
KillSwitchState::Disabled,
KillSwitchState::Armed,
KillSwitchState::Blocking,
] {
for is_connected in [false, true] {
assert_eq!(
KillSwitchMode::AlwaysOn.desired_state(old, is_connected),
KillSwitchState::Blocking,
"AlwaysOn must always resolve to Blocking — that's the \
whole point of the mode. old={old:?}, \
is_connected={is_connected}"
);
}
}
}
#[test]
fn cli_verb_roundtrips_for_every_variant() {
for mode in [
KillSwitchMode::Off,
KillSwitchMode::Auto,
KillSwitchMode::AlwaysOn,
] {
assert_eq!(
KillSwitchMode::from_cli_verb(mode.cli_verb()),
Some(mode),
"cli_verb / from_cli_verb must round-trip for {mode:?}"
);
}
assert_eq!(KillSwitchMode::Off.cli_verb(), "off");
assert_eq!(KillSwitchMode::Auto.cli_verb(), "block-on-drop");
assert_eq!(KillSwitchMode::AlwaysOn.cli_verb(), "vpn-only");
}
#[test]
fn from_cli_verb_rejects_legacy_and_prose_forms() {
assert_eq!(
KillSwitchMode::from_cli_verb("VPN-ONLY"),
Some(KillSwitchMode::AlwaysOn),
"slugs must be case-insensitive"
);
assert_eq!(
KillSwitchMode::from_cli_verb("Block-On-Drop"),
Some(KillSwitchMode::Auto)
);
for rejected in [
"auto",
"always",
"always-on",
"alwayson",
"VPN only", "blockondrop",
"",
"vpn-only-extra",
] {
assert!(
KillSwitchMode::from_cli_verb(rejected).is_none(),
"must reject '{rejected}' — only the canonical slugs are valid"
);
}
}
}