use std::collections::BTreeMap;
use std::net::IpAddr;
use std::str::FromStr;
use crate::distributed::identity_hash;
use crate::ip_gate::{IpGateError, parse_network_entry};
pub const DEFAULT_REDIS_PREFIX: &str = "guard_core:";
pub const SECURITY_HEADERS_NAMESPACE: &str = "security_headers";
pub const CSP_CONFIG_KEY: &str = "csp_config";
pub const HSTS_CONFIG_KEY: &str = "hsts_config";
pub const CUSTOM_HEADERS_KEY: &str = "custom_headers";
pub const SECURITY_HEADERS_TTL_SECONDS: u64 = 86_400;
pub const PATTERNS_NAMESPACE: &str = "patterns";
pub const PATTERNS_CUSTOM_KEY: &str = "custom";
pub const IPINFO_NAMESPACE: &str = "ipinfo";
pub const IPINFO_DATABASE_KEY: &str = "database";
pub const IPINFO_DATABASE_TTL_DEFAULT_SECONDS: u64 = 86_400;
pub const CLOUD_RANGES_NAMESPACE: &str = "cloud_ranges_v2";
pub const CLOUD_RANGES_TTL_DEFAULT_SECONDS: u64 = 3_600;
pub const CLOUD_IP_NAMESPACE: &str = "cloud_ip_v2";
pub const DYNAMIC_RULES_NAMESPACE: &str = "dynamic_rules";
pub const LAST_KNOWN_RULES_KEY: &str = "last_known";
#[must_use]
pub fn full_key(prefix: &str, namespace: &str, key: &str) -> String {
format!("{prefix}{namespace}:{key}")
}
pub fn ban_network_key(prefix: &str, cidr: &str) -> Result<String, IpGateError> {
let canonical = canonical_network_string(cidr).ok_or_else(|| IpGateError {
list: "banned_networks",
entry: cidr.to_owned(),
})?;
Ok(full_key(prefix, "banned_networks", &canonical))
}
#[must_use]
pub fn behavior_usage_key(prefix: &str, endpoint_id: &str, ip: &str) -> String {
full_key(
prefix,
"behavior_usage",
&format!(
"behavior:usage:{}:{}",
identity_hash(endpoint_id),
identity_hash(ip)
),
)
}
#[must_use]
pub fn behavior_return_key(prefix: &str, endpoint_id: &str, ip: &str, pattern: &str) -> String {
full_key(
prefix,
"behavior_returns",
&format!(
"behavior:return:{}:{}:{}",
identity_hash(endpoint_id),
identity_hash(ip),
identity_hash(pattern)
),
)
}
#[must_use]
pub fn security_headers_config_key(prefix: &str, segment: &str) -> String {
full_key(prefix, SECURITY_HEADERS_NAMESPACE, segment)
}
#[must_use]
pub fn patterns_custom_key(prefix: &str) -> String {
full_key(prefix, PATTERNS_NAMESPACE, PATTERNS_CUSTOM_KEY)
}
#[must_use]
pub fn ipinfo_database_key(prefix: &str) -> String {
full_key(prefix, IPINFO_NAMESPACE, IPINFO_DATABASE_KEY)
}
#[must_use]
pub fn cloud_ranges_key(prefix: &str, provider: &str) -> String {
full_key(prefix, CLOUD_RANGES_NAMESPACE, provider)
}
#[must_use]
pub fn cloud_ip_key(prefix: &str, provider: &str) -> String {
full_key(prefix, CLOUD_IP_NAMESPACE, provider)
}
#[must_use]
pub fn dynamic_rules_last_known_key(prefix: &str) -> String {
full_key(prefix, DYNAMIC_RULES_NAMESPACE, LAST_KNOWN_RULES_KEY)
}
#[must_use]
pub fn format_expiry(expiry: f64) -> String {
format!("{expiry}")
}
#[must_use]
pub fn random_member() -> String {
let bytes: [u8; 16] = rand::random();
bytes
.iter()
.fold(String::with_capacity(32), |mut out, byte| {
use core::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
out
})
}
#[must_use]
pub fn exclusive_prune_bound(window_start: f64) -> String {
format!("({}", format_expiry(window_start))
}
#[must_use]
pub fn canonical_network_string(entry: &str) -> Option<String> {
let network = parse_network_entry(entry)?;
Some(network.to_string())
}
#[must_use]
pub fn encode_patterns_custom(patterns: &[String]) -> String {
patterns.join(",")
}
#[must_use]
pub fn decode_patterns_custom(payload: &str) -> Vec<&str> {
if payload.is_empty() {
Vec::new()
} else {
payload.split(',').collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloudRangeDecodeError {
pub entry: String,
}
impl core::fmt::Display for CloudRangeDecodeError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "corrupt cloud range entry: {}", self.entry)
}
}
impl std::error::Error for CloudRangeDecodeError {}
#[must_use]
pub fn encode_cloud_ranges(ranges: &[(String, Option<&str>)]) -> String {
let mut encoded: Vec<String> = ranges
.iter()
.map(|(network, region)| {
region
.as_ref()
.map_or_else(|| network.clone(), |region| format!("{network}|{region}"))
})
.collect();
encoded.sort();
encoded.join(",")
}
pub fn decode_cloud_ranges(
payload: &str,
) -> Result<(Vec<String>, BTreeMap<String, String>), CloudRangeDecodeError> {
let mut networks = Vec::new();
let mut regions = BTreeMap::new();
for entry in payload.split(',') {
if entry.is_empty() {
continue;
}
let (prefix, separator, region) = partition(entry, '|');
let Some(network) = canonical_network_string(prefix) else {
return Err(CloudRangeDecodeError {
entry: entry.to_owned(),
});
};
if !separator || region.is_empty() {
networks.push(network);
} else {
networks.push(network.clone());
regions.insert(network, region.to_owned());
}
}
networks.sort();
networks.dedup();
Ok((networks, regions))
}
fn partition(text: &str, separator: char) -> (&str, bool, &str) {
match text.split_once(separator) {
Some((before, after)) => (before, true, after),
None => (text, false, ""),
}
}
#[must_use]
pub fn encode_cloud_ip_v2(ranges: &[String]) -> String {
let mut sorted: Vec<&String> = ranges.iter().collect();
sorted.sort();
let items: Vec<serde_json::Value> = sorted
.iter()
.map(|range| serde_json::Value::String((*range).clone()))
.collect();
serde_json::Value::Array(items).to_string()
}
#[must_use]
pub fn decode_cloud_ip_v2(payload: &str) -> Option<Vec<String>> {
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
let items = value.as_array()?;
items
.iter()
.map(|item| item.as_str().map(str::to_owned))
.collect()
}
#[must_use]
pub fn encode_json_object(pairs: &[(String, String)]) -> String {
let mut map = serde_json::Map::new();
for (name, value) in pairs {
map.insert(name.clone(), serde_json::Value::String(value.clone()));
}
serde_json::Value::Object(map).to_string()
}
#[must_use]
pub fn decode_json_object(payload: &str) -> Option<Vec<(String, String)>> {
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
let map = value.as_object()?;
Some(
map.iter()
.filter_map(|(name, value)| {
value.as_str().map(|value| (name.clone(), value.to_owned()))
})
.collect(),
)
}
#[must_use]
pub fn latin1_to_wire(bytes: &[u8]) -> String {
bytes.iter().map(|byte| char::from(*byte)).collect()
}
#[must_use]
pub fn wire_to_latin1(payload: &str) -> Option<Vec<u8>> {
payload
.chars()
.map(|c| u32::from(c).try_into().ok())
.collect()
}
#[must_use]
pub fn canonicalize_ip_string(raw: &str) -> String {
let stripped = raw
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.unwrap_or(raw);
IpAddr::from_str(stripped).map_or_else(
|_| raw.to_owned(),
|addr| crate::ip_gate::canonical(addr).to_string(),
)
}
pub trait RedisAdminStore: Send + Sync {
fn scan_match(&self, pattern: &str) -> Result<Vec<String>, crate::distributed::StoreError>;
fn pttl_ms(&self, key: &str) -> Result<i64, crate::distributed::StoreError>;
fn set_px(
&self,
key: &str,
value: &str,
ttl_ms: i64,
) -> Result<(), crate::distributed::StoreError>;
fn delete_keys(&self, keys: &[String]) -> Result<(), crate::distributed::StoreError>;
fn get_key(
&self,
prefix: &str,
namespace: &str,
key: &str,
) -> Result<Option<String>, crate::distributed::StoreError>;
}
pub fn migrate_legacy_ban_keys(
store: &dyn RedisAdminStore,
prefix: &str,
) -> Result<(), crate::distributed::StoreError> {
let pattern = format!("{prefix}banned_ips:*");
let keys = store.scan_match(&pattern)?;
for key in keys {
let _ = migrate_one_ban_key(store, prefix, &key);
}
Ok(())
}
pub fn migrate_one_ban_key(
store: &dyn RedisAdminStore,
prefix: &str,
key: &str,
) -> Result<bool, crate::distributed::StoreError> {
let key_prefix = format!("{prefix}banned_ips:");
let Some(raw_ip) = key.strip_prefix(&key_prefix) else {
return Ok(false);
};
let canonical_ip = canonicalize_ip_string(raw_ip);
if canonical_ip == raw_ip {
return Ok(false);
}
let Some(value) = store.get_key(prefix, "banned_ips", raw_ip)? else {
return Ok(false);
};
let old_pttl = store.pttl_ms(key)?;
if old_pttl <= 0 {
store.delete_keys(std::slice::from_ref(&key.to_owned()))?;
return Ok(true);
}
let canonical_key = format!("{key_prefix}{canonical_ip}");
let new_pttl = store.pttl_ms(&canonical_key)?;
if new_pttl < old_pttl {
store.set_px(&canonical_key, &value, old_pttl)?;
}
store.delete_keys(std::slice::from_ref(&key.to_owned()))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn key_layouts_match_the_reference_grammar() {
let p = "guard_core:";
assert_eq!(
ban_network_key(p, "10.9.9.9/8").expect("valid"),
"guard_core:banned_networks:10.0.0.0/8"
);
assert_eq!(
ban_network_key(p, "192.0.2.9").expect("valid"),
"guard_core:banned_networks:192.0.2.9/32",
"a bare IP ban canonicalizes to its single-host network form"
);
assert_eq!(
behavior_usage_key(p, "endpoint:/api/users", "192.0.2.9"),
"guard_core:behavior_usage:behavior:usage:\
63141da573fd6925654bd16c44894798afd79c2ec3fc2668e877ca61ef090043:\
d27fb1b45c2670fa64c03d01d426156940ddb94b545556c84f544abb737f0e76"
);
assert_eq!(
behavior_return_key(p, "endpoint:/api/users", "192.0.2.9", "(?i)union.*select"),
"guard_core:behavior_returns:behavior:return:\
63141da573fd6925654bd16c44894798afd79c2ec3fc2668e877ca61ef090043:\
d27fb1b45c2670fa64c03d01d426156940ddb94b545556c84f544abb737f0e76:\
d34240a593ec6d8fed32c72e0ed162e1f0cd98eb536df504f77169c1abac52a6"
);
assert_eq!(
security_headers_config_key(p, CSP_CONFIG_KEY),
"guard_core:security_headers:csp_config"
);
assert_eq!(
security_headers_config_key(p, HSTS_CONFIG_KEY),
"guard_core:security_headers:hsts_config"
);
assert_eq!(
security_headers_config_key(p, CUSTOM_HEADERS_KEY),
"guard_core:security_headers:custom_headers"
);
assert_eq!(patterns_custom_key(p), "guard_core:patterns:custom");
assert_eq!(ipinfo_database_key(p), "guard_core:ipinfo:database");
assert_eq!(cloud_ranges_key(p, "AWS"), "guard_core:cloud_ranges_v2:AWS");
assert_eq!(cloud_ip_key(p, "GCP"), "guard_core:cloud_ip_v2:GCP");
assert_eq!(
dynamic_rules_last_known_key(p),
"guard_core:dynamic_rules:last_known"
);
}
#[test]
fn ban_network_key_fails_closed_on_a_bad_cidr() {
let error = ban_network_key("guard_core:", "not-a-network").unwrap_err();
assert_eq!(error.list, "banned_networks");
assert_eq!(error.entry, "not-a-network");
}
#[test]
fn ipv6_networks_canonicalize_compressed() {
assert_eq!(
canonical_network_string("2001:0db8:0000:0000:0000:0000:0000:0000/32").expect("valid"),
"2001:db8::/32"
);
assert_eq!(
ban_network_key("guard_core:", "2001:0db8::/32").expect("valid"),
"guard_core:banned_networks:2001:db8::/32"
);
}
#[test]
fn expiry_strings_match_the_go_format_float_shape() {
assert_eq!(format_expiry(1060.0), "1060");
assert_eq!(format_expiry(1_735_689_600.123_456), "1735689600.123456");
assert_eq!(format_expiry(1_000.5), "1000.5");
}
#[test]
fn random_members_are_the_reference_hex_shape() {
let first = random_member();
let second = random_member();
assert_eq!(first.len(), 32);
assert!(
first
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
assert_ne!(first, second, "uniqueness is the contract");
}
#[test]
fn the_behavior_prune_bound_is_exclusive() {
assert_eq!(exclusive_prune_bound(100.5), "(100.5");
}
#[test]
fn patterns_custom_round_trips_comma_joined() {
let patterns = vec![
String::from("(?i)union.*select"),
String::from(r"(?i)<script>"),
];
let payload = encode_patterns_custom(&patterns);
assert_eq!(payload, "(?i)union.*select,(?i)<script>");
assert_eq!(
decode_patterns_custom(&payload),
vec!["(?i)union.*select", "(?i)<script>"]
);
assert!(decode_patterns_custom("").is_empty());
}
#[test]
fn cloud_ranges_round_trip_sorted_with_regions() {
let payload = encode_cloud_ranges(&[
(String::from("203.0.113.0/24"), Some("us-east")),
(String::from("198.51.100.0/24"), None),
(String::from("2001:db8::/32"), Some("eu-west")),
]);
assert_eq!(
payload,
"198.51.100.0/24,2001:db8::/32|eu-west,203.0.113.0/24|us-east"
);
let (networks, regions) = decode_cloud_ranges(&payload).expect("decodable");
assert_eq!(
networks,
vec![
String::from("198.51.100.0/24"),
String::from("2001:db8::/32"),
String::from("203.0.113.0/24"),
]
);
assert_eq!(regions.get("2001:db8::/32"), Some(&"eu-west".to_owned()));
assert_eq!(regions.get("203.0.113.0/24"), Some(&"us-east".to_owned()));
assert!(!regions.contains_key("198.51.100.0/24"));
}
#[test]
fn cloud_ranges_decode_normalizes_host_bits() {
let (networks, _) = decode_cloud_ranges("198.51.100.7/24").expect("decodable");
assert_eq!(networks, vec![String::from("198.51.100.0/24")]);
}
#[test]
fn cloud_ranges_decode_fails_closed_on_a_corrupt_entry() {
let error = decode_cloud_ranges("198.51.100.0/24,garbage").unwrap_err();
assert_eq!(error.entry, "garbage");
assert_eq!(error.to_string(), "corrupt cloud range entry: garbage");
}
#[test]
fn cloud_ip_v2_is_a_sorted_json_array() {
let payload = encode_cloud_ip_v2(&[
String::from("203.0.113.0/24"),
String::from("198.51.100.0/24"),
]);
assert_eq!(payload, r#"["198.51.100.0/24","203.0.113.0/24"]"#);
assert_eq!(
decode_cloud_ip_v2(&payload),
Some(vec![
String::from("198.51.100.0/24"),
String::from("203.0.113.0/24"),
])
);
assert_eq!(decode_cloud_ip_v2(r#"{"a":1}"#), None);
assert_eq!(decode_cloud_ip_v2("not json"), None);
assert_eq!(decode_cloud_ip_v2("[1,2]"), None);
}
#[test]
fn json_object_round_trips_in_order() {
let payload = encode_json_object(&[
(String::from("X-Frame-Options"), String::from("SAMEORIGIN")),
(String::from("X-Actuators"), String::from("none")),
]);
assert_eq!(
payload,
r#"{"X-Frame-Options":"SAMEORIGIN","X-Actuators":"none"}"#
);
assert_eq!(
decode_json_object(&payload),
Some(vec![
(String::from("X-Frame-Options"), String::from("SAMEORIGIN")),
(String::from("X-Actuators"), String::from("none")),
])
);
assert_eq!(decode_json_object("[]"), None);
assert_eq!(decode_json_object("not json at all"), None);
assert_eq!(decode_json_object(r"{\Broken"), None);
}
#[test]
fn latin1_carries_arbitrary_binary_payloads() {
let db_bytes: Vec<u8> = (0..=255).collect();
let wire = latin1_to_wire(&db_bytes);
assert_eq!(wire_to_latin1(&wire), Some(db_bytes));
assert_eq!(wire_to_latin1("guar\u{100}"), None);
}
#[test]
fn ip_string_canonicalization_mirrors_the_references() {
assert_eq!(canonicalize_ip_string("::ffff:192.0.2.9"), "192.0.2.9");
assert_eq!(canonicalize_ip_string("[::ffff:192.0.2.9]"), "192.0.2.9");
assert_eq!(canonicalize_ip_string("2001:0db8::0001"), "2001:db8::1");
assert_eq!(
canonicalize_ip_string("192.0.2.9"),
"192.0.2.9",
"already canonical: unchanged"
);
assert_eq!(
canonicalize_ip_string("not-an-ip"),
"not-an-ip",
"unparseable segments come back unchanged"
);
}
#[derive(Default)]
struct MemoryAdmin {
strings: Mutex<BTreeMap<String, String>>,
ttls_ms: Mutex<BTreeMap<String, i64>>,
}
impl MemoryAdmin {
fn seed(&self, key: &str, value: &str, ttl_ms: i64) {
self.strings
.lock()
.expect("strings")
.insert(key.to_owned(), value.to_owned());
self.ttls_ms
.lock()
.expect("ttls")
.insert(key.to_owned(), ttl_ms);
}
fn has(&self, key: &str) -> bool {
self.strings.lock().expect("strings").contains_key(key)
}
}
impl RedisAdminStore for MemoryAdmin {
fn scan_match(&self, pattern: &str) -> Result<Vec<String>, crate::distributed::StoreError> {
let stem = pattern.strip_suffix('*').unwrap_or(pattern);
Ok(self
.strings
.lock()
.expect("strings")
.keys()
.filter(|key| key.starts_with(stem))
.cloned()
.collect())
}
fn pttl_ms(&self, key: &str) -> Result<i64, crate::distributed::StoreError> {
Ok(*self.ttls_ms.lock().expect("ttls").get(key).unwrap_or(&-2))
}
fn set_px(
&self,
key: &str,
value: &str,
ttl_ms: i64,
) -> Result<(), crate::distributed::StoreError> {
self.seed(key, value, ttl_ms);
Ok(())
}
#[allow(clippy::significant_drop_tightening)]
fn delete_keys(&self, keys: &[String]) -> Result<(), crate::distributed::StoreError> {
let mut strings = self.strings.lock().expect("strings");
let mut ttls = self.ttls_ms.lock().expect("ttls");
for key in keys {
strings.remove(key);
ttls.remove(key);
}
Ok(())
}
fn get_key(
&self,
prefix: &str,
namespace: &str,
key: &str,
) -> Result<Option<String>, crate::distributed::StoreError> {
Ok(self
.strings
.lock()
.expect("strings")
.get(&full_key(prefix, namespace, key))
.cloned())
}
}
#[test]
fn migration_moves_the_longer_expiry_to_the_canonical_key() {
let admin = MemoryAdmin::default();
admin.seed(
"guard_core:banned_ips:::ffff:192.0.2.9",
"1735689600.5",
60_000,
);
admin.seed("guard_core:banned_ips:192.0.2.9", "1735689600.5", 10_000);
migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
assert_eq!(
admin
.strings
.lock()
.expect("strings")
.get("guard_core:banned_ips:192.0.2.9"),
Some(&"1735689600.5".to_owned())
);
assert_eq!(
admin
.ttls_ms
.lock()
.expect("ttls")
.get("guard_core:banned_ips:192.0.2.9"),
Some(&60_000)
);
assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
}
#[test]
fn migration_keeps_the_canonical_key_when_it_is_already_longer() {
let admin = MemoryAdmin::default();
admin.seed(
"guard_core:banned_ips:::ffff:192.0.2.9",
"1735689600.5",
10_000,
);
admin.seed("guard_core:banned_ips:192.0.2.9", "1735689600.5", 60_000);
migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
assert_eq!(
admin
.ttls_ms
.lock()
.expect("ttls")
.get("guard_core:banned_ips:192.0.2.9"),
Some(&60_000)
);
assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
}
#[test]
fn migration_drops_expired_and_persistent_legacy_keys() {
let admin = MemoryAdmin::default();
admin.seed("guard_core:banned_ips:::ffff:192.0.2.8", "1000.0", 0);
admin.seed("guard_core:banned_ips:::ffff:192.0.2.7", "1000.0", -1);
migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.8"));
assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.7"));
assert!(
!admin.has("guard_core:banned_ips:192.0.2.8"),
"an expired legacy ban is dropped, not carried over"
);
assert!(
!admin.has("guard_core:banned_ips:192.0.2.7"),
"a persistent legacy ban is dropped, not carried over"
);
}
struct FailingAdmin {
stage: &'static str,
}
impl RedisAdminStore for FailingAdmin {
fn scan_match(
&self,
_pattern: &str,
) -> Result<Vec<String>, crate::distributed::StoreError> {
if self.stage == "scan" {
return Err(crate::distributed::StoreError("scan down".into()));
}
Ok(vec!["guard_core:banned_ips:::ffff:192.0.2.9".to_owned()])
}
fn pttl_ms(&self, key: &str) -> Result<i64, crate::distributed::StoreError> {
if self.stage == "pttl" {
return Err(crate::distributed::StoreError("pttl down".into()));
}
if key.contains("ffff") {
if self.stage == "persistent_delete" {
return Ok(-1);
}
Ok(60_000)
} else {
if self.stage == "pttl_canonical" {
return Err(crate::distributed::StoreError("canonical pttl down".into()));
}
Ok(10_000)
}
}
fn set_px(
&self,
_key: &str,
_value: &str,
_ttl_ms: i64,
) -> Result<(), crate::distributed::StoreError> {
if self.stage == "set_px" {
return Err(crate::distributed::StoreError("set_px down".into()));
}
Ok(())
}
fn delete_keys(&self, _keys: &[String]) -> Result<(), crate::distributed::StoreError> {
if self.stage == "delete" || self.stage == "persistent_delete" {
return Err(crate::distributed::StoreError("delete down".into()));
}
Ok(())
}
fn get_key(
&self,
_prefix: &str,
_namespace: &str,
_key: &str,
) -> Result<Option<String>, crate::distributed::StoreError> {
if self.stage == "get" {
return Err(crate::distributed::StoreError("get down".into()));
}
Ok(Some("1735689600.5".to_owned()))
}
}
#[test]
fn migration_backend_failures_propagate_from_their_stage() {
let error = migrate_legacy_ban_keys(&FailingAdmin { stage: "scan" }, "guard_core:")
.expect_err("scan failure");
assert_eq!(error.0, "scan down");
let error = migrate_one_ban_key(
&FailingAdmin { stage: "get" },
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9",
)
.expect_err("value read failure");
assert_eq!(error.0, "get down");
let error = migrate_one_ban_key(
&FailingAdmin { stage: "pttl" },
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9",
)
.expect_err("pttl failure");
assert_eq!(error.0, "pttl down");
let error = migrate_one_ban_key(
&FailingAdmin { stage: "set_px" },
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9",
)
.expect_err("copy failure");
assert_eq!(error.0, "set_px down");
let error = migrate_one_ban_key(
&FailingAdmin { stage: "delete" },
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9",
)
.expect_err("delete failure");
assert_eq!(error.0, "delete down");
}
#[test]
fn a_healthy_failing_admin_migrates_end_to_end() {
let admin = FailingAdmin { stage: "ok" };
assert!(
migrate_one_ban_key(
&admin,
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9"
)
.expect("migrates")
);
}
#[test]
fn migration_backend_failures_propagate_from_the_later_stages() {
let error = migrate_one_ban_key(
&FailingAdmin {
stage: "persistent_delete",
},
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9",
)
.expect_err("persistent delete failure");
assert_eq!(error.0, "delete down");
let error = migrate_one_ban_key(
&FailingAdmin {
stage: "pttl_canonical",
},
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9",
)
.expect_err("canonical pttl failure");
assert_eq!(error.0, "canonical pttl down");
let result = migrate_legacy_ban_keys(&FailingAdmin { stage: "get" }, "guard_core:");
assert_eq!(result, Ok(()));
}
#[test]
fn a_persistent_legacy_key_is_deleted_without_a_copy() {
let admin = MemoryAdmin::default();
admin.seed("guard_core:banned_ips:::ffff:192.0.2.9", "1735689600.5", -1);
assert!(
migrate_one_ban_key(
&admin,
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9"
)
.expect("migrates")
);
assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
admin.seed("guard_core:banned_ips:::ffff:192.0.2.7", "1735689600.5", -2);
assert!(
migrate_one_ban_key(
&admin,
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.7"
)
.expect("migrates")
);
assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.7"));
}
#[test]
fn migration_touches_only_non_canonical_keys() {
let admin = MemoryAdmin::default();
admin.seed("guard_core:banned_ips:192.0.2.9", "1735689600.5", 60_000);
migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
assert!(admin.has("guard_core:banned_ips:192.0.2.9"));
assert_eq!(
admin
.ttls_ms
.lock()
.expect("ttls")
.get("guard_core:banned_ips:192.0.2.9"),
Some(&60_000),
"a canonical key is left untouched"
);
}
#[test]
fn one_bad_key_never_fails_the_rest() {
let admin = MemoryAdmin::default();
admin.seed("guard_core:banned_ips:junk", "1735689600.5", 60_000);
admin.seed(
"guard_core:banned_ips:::ffff:192.0.2.9",
"1735689600.5",
60_000,
);
migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
assert!(admin.has("guard_core:banned_ips:junk"));
assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
assert!(admin.has("guard_core:banned_ips:192.0.2.9"));
}
#[test]
fn one_key_migration_skips_foreign_prefixes_and_missing_values() {
let admin = MemoryAdmin::default();
assert!(
!migrate_one_ban_key(&admin, "guard_core:", "guard_core:ratelimit:192.0.2.9").unwrap(),
"a foreign namespace never migrates"
);
assert!(
!migrate_one_ban_key(
&admin,
"guard_core:",
"guard_core:banned_ips:::ffff:192.0.2.9"
)
.unwrap(),
"no value: nothing to carry over"
);
}
#[test]
fn cloud_ranges_decode_skips_empty_entries() {
let (networks, regions) =
decode_cloud_ranges(",203.0.113.0/24|us-east,,198.51.100.0/24,").expect("decodable");
assert_eq!(
networks,
vec![
String::from("198.51.100.0/24"),
String::from("203.0.113.0/24"),
]
);
assert_eq!(regions.get("203.0.113.0/24"), Some(&"us-east".to_owned()));
}
}