1use std::collections::BTreeMap;
102use std::net::IpAddr;
103use std::str::FromStr;
104
105use crate::distributed::identity_hash;
106use crate::ip_gate::{IpGateError, parse_network_entry};
107
108pub const DEFAULT_REDIS_PREFIX: &str = "guard_core:";
110
111pub const SECURITY_HEADERS_NAMESPACE: &str = "security_headers";
113pub const CSP_CONFIG_KEY: &str = "csp_config";
115pub const HSTS_CONFIG_KEY: &str = "hsts_config";
117pub const CUSTOM_HEADERS_KEY: &str = "custom_headers";
119pub const SECURITY_HEADERS_TTL_SECONDS: u64 = 86_400;
122
123pub const PATTERNS_NAMESPACE: &str = "patterns";
125pub const PATTERNS_CUSTOM_KEY: &str = "custom";
127
128pub const IPINFO_NAMESPACE: &str = "ipinfo";
130pub const IPINFO_DATABASE_KEY: &str = "database";
132pub const IPINFO_DATABASE_TTL_DEFAULT_SECONDS: u64 = 86_400;
135
136pub const CLOUD_RANGES_NAMESPACE: &str = "cloud_ranges_v2";
138pub const CLOUD_RANGES_TTL_DEFAULT_SECONDS: u64 = 3_600;
140pub const CLOUD_IP_NAMESPACE: &str = "cloud_ip_v2";
142
143pub const DYNAMIC_RULES_NAMESPACE: &str = "dynamic_rules";
146pub const LAST_KNOWN_RULES_KEY: &str = "last_known";
148
149#[must_use]
152pub fn full_key(prefix: &str, namespace: &str, key: &str) -> String {
153 format!("{prefix}{namespace}:{key}")
154}
155
156pub fn ban_network_key(prefix: &str, cidr: &str) -> Result<String, IpGateError> {
165 let canonical = canonical_network_string(cidr).ok_or_else(|| IpGateError {
166 list: "banned_networks",
167 entry: cidr.to_owned(),
168 })?;
169 Ok(full_key(prefix, "banned_networks", &canonical))
170}
171
172#[must_use]
178pub fn behavior_usage_key(prefix: &str, endpoint_id: &str, ip: &str) -> String {
179 full_key(
180 prefix,
181 "behavior_usage",
182 &format!(
183 "behavior:usage:{}:{}",
184 identity_hash(endpoint_id),
185 identity_hash(ip)
186 ),
187 )
188}
189
190#[must_use]
195pub fn behavior_return_key(prefix: &str, endpoint_id: &str, ip: &str, pattern: &str) -> String {
196 full_key(
197 prefix,
198 "behavior_returns",
199 &format!(
200 "behavior:return:{}:{}:{}",
201 identity_hash(endpoint_id),
202 identity_hash(ip),
203 identity_hash(pattern)
204 ),
205 )
206}
207
208#[must_use]
211pub fn security_headers_config_key(prefix: &str, segment: &str) -> String {
212 full_key(prefix, SECURITY_HEADERS_NAMESPACE, segment)
213}
214
215#[must_use]
217pub fn patterns_custom_key(prefix: &str) -> String {
218 full_key(prefix, PATTERNS_NAMESPACE, PATTERNS_CUSTOM_KEY)
219}
220
221#[must_use]
223pub fn ipinfo_database_key(prefix: &str) -> String {
224 full_key(prefix, IPINFO_NAMESPACE, IPINFO_DATABASE_KEY)
225}
226
227#[must_use]
229pub fn cloud_ranges_key(prefix: &str, provider: &str) -> String {
230 full_key(prefix, CLOUD_RANGES_NAMESPACE, provider)
231}
232
233#[must_use]
235pub fn cloud_ip_key(prefix: &str, provider: &str) -> String {
236 full_key(prefix, CLOUD_IP_NAMESPACE, provider)
237}
238
239#[must_use]
242pub fn dynamic_rules_last_known_key(prefix: &str) -> String {
243 full_key(prefix, DYNAMIC_RULES_NAMESPACE, LAST_KNOWN_RULES_KEY)
244}
245
246#[must_use]
253pub fn format_expiry(expiry: f64) -> String {
254 format!("{expiry}")
255}
256
257#[must_use]
264pub fn random_member() -> String {
265 let bytes: [u8; 16] = rand::random();
266 bytes
267 .iter()
268 .fold(String::with_capacity(32), |mut out, byte| {
269 use core::fmt::Write as _;
270 let _ = write!(out, "{byte:02x}");
271 out
272 })
273}
274
275#[must_use]
282pub fn exclusive_prune_bound(window_start: f64) -> String {
283 format!("({}", format_expiry(window_start))
284}
285
286#[must_use]
291pub fn canonical_network_string(entry: &str) -> Option<String> {
292 let network = parse_network_entry(entry)?;
293 Some(network.to_string())
294}
295
296#[must_use]
303pub fn encode_patterns_custom(patterns: &[String]) -> String {
304 patterns.join(",")
305}
306
307#[must_use]
310pub fn decode_patterns_custom(payload: &str) -> Vec<&str> {
311 if payload.is_empty() {
312 Vec::new()
313 } else {
314 payload.split(',').collect()
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct CloudRangeDecodeError {
324 pub entry: String,
326}
327
328impl core::fmt::Display for CloudRangeDecodeError {
329 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
330 write!(f, "corrupt cloud range entry: {}", self.entry)
331 }
332}
333
334impl std::error::Error for CloudRangeDecodeError {}
335
336#[must_use]
343pub fn encode_cloud_ranges(ranges: &[(String, Option<&str>)]) -> String {
344 let mut encoded: Vec<String> = ranges
345 .iter()
346 .map(|(network, region)| {
347 region
348 .as_ref()
349 .map_or_else(|| network.clone(), |region| format!("{network}|{region}"))
350 })
351 .collect();
352 encoded.sort();
353 encoded.join(",")
354}
355
356pub fn decode_cloud_ranges(
363 payload: &str,
364) -> Result<(Vec<String>, BTreeMap<String, String>), CloudRangeDecodeError> {
365 let mut networks = Vec::new();
366 let mut regions = BTreeMap::new();
367 for entry in payload.split(',') {
368 if entry.is_empty() {
369 continue;
370 }
371 let (prefix, separator, region) = partition(entry, '|');
372 let Some(network) = canonical_network_string(prefix) else {
373 return Err(CloudRangeDecodeError {
374 entry: entry.to_owned(),
375 });
376 };
377 if !separator || region.is_empty() {
378 networks.push(network);
379 } else {
380 networks.push(network.clone());
381 regions.insert(network, region.to_owned());
382 }
383 }
384 networks.sort();
385 networks.dedup();
386 Ok((networks, regions))
387}
388
389fn partition(text: &str, separator: char) -> (&str, bool, &str) {
392 match text.split_once(separator) {
393 Some((before, after)) => (before, true, after),
394 None => (text, false, ""),
395 }
396}
397
398#[must_use]
402pub fn encode_cloud_ip_v2(ranges: &[String]) -> String {
403 let mut sorted: Vec<&String> = ranges.iter().collect();
404 sorted.sort();
405 let items: Vec<serde_json::Value> = sorted
406 .iter()
407 .map(|range| serde_json::Value::String((*range).clone()))
408 .collect();
409 serde_json::Value::Array(items).to_string()
410}
411
412#[must_use]
418pub fn decode_cloud_ip_v2(payload: &str) -> Option<Vec<String>> {
419 let value: serde_json::Value = serde_json::from_str(payload).ok()?;
420 let items = value.as_array()?;
421 items
422 .iter()
423 .map(|item| item.as_str().map(str::to_owned))
424 .collect()
425}
426
427#[must_use]
431pub fn encode_json_object(pairs: &[(String, String)]) -> String {
432 let mut map = serde_json::Map::new();
433 for (name, value) in pairs {
434 map.insert(name.clone(), serde_json::Value::String(value.clone()));
435 }
436 serde_json::Value::Object(map).to_string()
437}
438
439#[must_use]
443pub fn decode_json_object(payload: &str) -> Option<Vec<(String, String)>> {
444 let value: serde_json::Value = serde_json::from_str(payload).ok()?;
445 let map = value.as_object()?;
446 Some(
447 map.iter()
448 .filter_map(|(name, value)| {
449 value.as_str().map(|value| (name.clone(), value.to_owned()))
450 })
451 .collect(),
452 )
453}
454
455#[must_use]
463pub fn latin1_to_wire(bytes: &[u8]) -> String {
464 bytes.iter().map(|byte| char::from(*byte)).collect()
465}
466
467#[must_use]
472pub fn wire_to_latin1(payload: &str) -> Option<Vec<u8>> {
473 payload
474 .chars()
475 .map(|c| u32::from(c).try_into().ok())
476 .collect()
477}
478
479#[must_use]
485pub fn canonicalize_ip_string(raw: &str) -> String {
486 let stripped = raw
487 .strip_prefix('[')
488 .and_then(|value| value.strip_suffix(']'))
489 .unwrap_or(raw);
490 IpAddr::from_str(stripped).map_or_else(
491 |_| raw.to_owned(),
492 |addr| crate::ip_gate::canonical(addr).to_string(),
493 )
494}
495
496pub trait RedisAdminStore: Send + Sync {
499 fn scan_match(&self, pattern: &str) -> Result<Vec<String>, crate::distributed::StoreError>;
505
506 fn pttl_ms(&self, key: &str) -> Result<i64, crate::distributed::StoreError>;
514
515 fn set_px(
522 &self,
523 key: &str,
524 value: &str,
525 ttl_ms: i64,
526 ) -> Result<(), crate::distributed::StoreError>;
527
528 fn delete_keys(&self, keys: &[String]) -> Result<(), crate::distributed::StoreError>;
534
535 fn get_key(
542 &self,
543 prefix: &str,
544 namespace: &str,
545 key: &str,
546 ) -> Result<Option<String>, crate::distributed::StoreError>;
547}
548
549pub fn migrate_legacy_ban_keys(
564 store: &dyn RedisAdminStore,
565 prefix: &str,
566) -> Result<(), crate::distributed::StoreError> {
567 let pattern = format!("{prefix}banned_ips:*");
568 let keys = store.scan_match(&pattern)?;
569 for key in keys {
570 let _ = migrate_one_ban_key(store, prefix, &key);
571 }
572 Ok(())
573}
574
575pub fn migrate_one_ban_key(
583 store: &dyn RedisAdminStore,
584 prefix: &str,
585 key: &str,
586) -> Result<bool, crate::distributed::StoreError> {
587 let key_prefix = format!("{prefix}banned_ips:");
588 let Some(raw_ip) = key.strip_prefix(&key_prefix) else {
589 return Ok(false);
590 };
591 let canonical_ip = canonicalize_ip_string(raw_ip);
592 if canonical_ip == raw_ip {
593 return Ok(false);
594 }
595 let Some(value) = store.get_key(prefix, "banned_ips", raw_ip)? else {
596 return Ok(false);
597 };
598 let old_pttl = store.pttl_ms(key)?;
599 if old_pttl <= 0 {
600 store.delete_keys(std::slice::from_ref(&key.to_owned()))?;
602 return Ok(true);
603 }
604 let canonical_key = format!("{key_prefix}{canonical_ip}");
605 let new_pttl = store.pttl_ms(&canonical_key)?;
606 if new_pttl < old_pttl {
607 store.set_px(&canonical_key, &value, old_pttl)?;
609 }
610 store.delete_keys(std::slice::from_ref(&key.to_owned()))?;
611 Ok(true)
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use std::sync::Mutex;
618
619 #[test]
622 fn key_layouts_match_the_reference_grammar() {
623 let p = "guard_core:";
624 assert_eq!(
625 ban_network_key(p, "10.9.9.9/8").expect("valid"),
626 "guard_core:banned_networks:10.0.0.0/8"
627 );
628 assert_eq!(
629 ban_network_key(p, "192.0.2.9").expect("valid"),
630 "guard_core:banned_networks:192.0.2.9/32",
631 "a bare IP ban canonicalizes to its single-host network form"
632 );
633 assert_eq!(
634 behavior_usage_key(p, "endpoint:/api/users", "192.0.2.9"),
635 "guard_core:behavior_usage:behavior:usage:\
636 63141da573fd6925654bd16c44894798afd79c2ec3fc2668e877ca61ef090043:\
637 d27fb1b45c2670fa64c03d01d426156940ddb94b545556c84f544abb737f0e76"
638 );
639 assert_eq!(
640 behavior_return_key(p, "endpoint:/api/users", "192.0.2.9", "(?i)union.*select"),
641 "guard_core:behavior_returns:behavior:return:\
642 63141da573fd6925654bd16c44894798afd79c2ec3fc2668e877ca61ef090043:\
643 d27fb1b45c2670fa64c03d01d426156940ddb94b545556c84f544abb737f0e76:\
644 d34240a593ec6d8fed32c72e0ed162e1f0cd98eb536df504f77169c1abac52a6"
645 );
646 assert_eq!(
647 security_headers_config_key(p, CSP_CONFIG_KEY),
648 "guard_core:security_headers:csp_config"
649 );
650 assert_eq!(
651 security_headers_config_key(p, HSTS_CONFIG_KEY),
652 "guard_core:security_headers:hsts_config"
653 );
654 assert_eq!(
655 security_headers_config_key(p, CUSTOM_HEADERS_KEY),
656 "guard_core:security_headers:custom_headers"
657 );
658 assert_eq!(patterns_custom_key(p), "guard_core:patterns:custom");
659 assert_eq!(ipinfo_database_key(p), "guard_core:ipinfo:database");
660 assert_eq!(cloud_ranges_key(p, "AWS"), "guard_core:cloud_ranges_v2:AWS");
661 assert_eq!(cloud_ip_key(p, "GCP"), "guard_core:cloud_ip_v2:GCP");
662 assert_eq!(
663 dynamic_rules_last_known_key(p),
664 "guard_core:dynamic_rules:last_known"
665 );
666 }
667
668 #[test]
669 fn ban_network_key_fails_closed_on_a_bad_cidr() {
670 let error = ban_network_key("guard_core:", "not-a-network").unwrap_err();
671 assert_eq!(error.list, "banned_networks");
672 assert_eq!(error.entry, "not-a-network");
673 }
674
675 #[test]
676 fn ipv6_networks_canonicalize_compressed() {
677 assert_eq!(
678 canonical_network_string("2001:0db8:0000:0000:0000:0000:0000:0000/32").expect("valid"),
679 "2001:db8::/32"
680 );
681 assert_eq!(
682 ban_network_key("guard_core:", "2001:0db8::/32").expect("valid"),
683 "guard_core:banned_networks:2001:db8::/32"
684 );
685 }
686
687 #[test]
690 fn expiry_strings_match_the_go_format_float_shape() {
691 assert_eq!(format_expiry(1060.0), "1060");
692 assert_eq!(format_expiry(1_735_689_600.123_456), "1735689600.123456");
693 assert_eq!(format_expiry(1_000.5), "1000.5");
694 }
695
696 #[test]
697 fn random_members_are_the_reference_hex_shape() {
698 let first = random_member();
699 let second = random_member();
700 assert_eq!(first.len(), 32);
701 assert!(
702 first
703 .chars()
704 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
705 );
706 assert_ne!(first, second, "uniqueness is the contract");
707 }
708
709 #[test]
710 fn the_behavior_prune_bound_is_exclusive() {
711 assert_eq!(exclusive_prune_bound(100.5), "(100.5");
712 }
713
714 #[test]
715 fn patterns_custom_round_trips_comma_joined() {
716 let patterns = vec![
717 String::from("(?i)union.*select"),
718 String::from(r"(?i)<script>"),
719 ];
720 let payload = encode_patterns_custom(&patterns);
721 assert_eq!(payload, "(?i)union.*select,(?i)<script>");
722 assert_eq!(
723 decode_patterns_custom(&payload),
724 vec!["(?i)union.*select", "(?i)<script>"]
725 );
726 assert!(decode_patterns_custom("").is_empty());
727 }
728
729 #[test]
730 fn cloud_ranges_round_trip_sorted_with_regions() {
731 let payload = encode_cloud_ranges(&[
732 (String::from("203.0.113.0/24"), Some("us-east")),
733 (String::from("198.51.100.0/24"), None),
734 (String::from("2001:db8::/32"), Some("eu-west")),
735 ]);
736 assert_eq!(
739 payload,
740 "198.51.100.0/24,2001:db8::/32|eu-west,203.0.113.0/24|us-east"
741 );
742 let (networks, regions) = decode_cloud_ranges(&payload).expect("decodable");
743 assert_eq!(
744 networks,
745 vec![
746 String::from("198.51.100.0/24"),
747 String::from("2001:db8::/32"),
748 String::from("203.0.113.0/24"),
749 ]
750 );
751 assert_eq!(regions.get("2001:db8::/32"), Some(&"eu-west".to_owned()));
752 assert_eq!(regions.get("203.0.113.0/24"), Some(&"us-east".to_owned()));
753 assert!(!regions.contains_key("198.51.100.0/24"));
754 }
755
756 #[test]
757 fn cloud_ranges_decode_normalizes_host_bits() {
758 let (networks, _) = decode_cloud_ranges("198.51.100.7/24").expect("decodable");
761 assert_eq!(networks, vec![String::from("198.51.100.0/24")]);
762 }
763
764 #[test]
765 fn cloud_ranges_decode_fails_closed_on_a_corrupt_entry() {
766 let error = decode_cloud_ranges("198.51.100.0/24,garbage").unwrap_err();
767 assert_eq!(error.entry, "garbage");
768 assert_eq!(error.to_string(), "corrupt cloud range entry: garbage");
769 }
770
771 #[test]
772 fn cloud_ip_v2_is_a_sorted_json_array() {
773 let payload = encode_cloud_ip_v2(&[
774 String::from("203.0.113.0/24"),
775 String::from("198.51.100.0/24"),
776 ]);
777 assert_eq!(payload, r#"["198.51.100.0/24","203.0.113.0/24"]"#);
778 assert_eq!(
779 decode_cloud_ip_v2(&payload),
780 Some(vec![
781 String::from("198.51.100.0/24"),
782 String::from("203.0.113.0/24"),
783 ])
784 );
785 assert_eq!(decode_cloud_ip_v2(r#"{"a":1}"#), None);
787 assert_eq!(decode_cloud_ip_v2("not json"), None);
788 assert_eq!(decode_cloud_ip_v2("[1,2]"), None);
789 }
790
791 #[test]
792 fn json_object_round_trips_in_order() {
793 let payload = encode_json_object(&[
794 (String::from("X-Frame-Options"), String::from("SAMEORIGIN")),
795 (String::from("X-Actuators"), String::from("none")),
796 ]);
797 assert_eq!(
798 payload,
799 r#"{"X-Frame-Options":"SAMEORIGIN","X-Actuators":"none"}"#
800 );
801 assert_eq!(
802 decode_json_object(&payload),
803 Some(vec![
804 (String::from("X-Frame-Options"), String::from("SAMEORIGIN")),
805 (String::from("X-Actuators"), String::from("none")),
806 ])
807 );
808 assert_eq!(decode_json_object("[]"), None);
809 assert_eq!(decode_json_object("not json at all"), None);
810 assert_eq!(decode_json_object(r"{\Broken"), None);
811 }
812
813 #[test]
814 fn latin1_carries_arbitrary_binary_payloads() {
815 let db_bytes: Vec<u8> = (0..=255).collect();
818 let wire = latin1_to_wire(&db_bytes);
819 assert_eq!(wire_to_latin1(&wire), Some(db_bytes));
820 assert_eq!(wire_to_latin1("guar\u{100}"), None);
822 }
823
824 #[test]
825 fn ip_string_canonicalization_mirrors_the_references() {
826 assert_eq!(canonicalize_ip_string("::ffff:192.0.2.9"), "192.0.2.9");
827 assert_eq!(canonicalize_ip_string("[::ffff:192.0.2.9]"), "192.0.2.9");
828 assert_eq!(canonicalize_ip_string("2001:0db8::0001"), "2001:db8::1");
829 assert_eq!(
830 canonicalize_ip_string("192.0.2.9"),
831 "192.0.2.9",
832 "already canonical: unchanged"
833 );
834 assert_eq!(
835 canonicalize_ip_string("not-an-ip"),
836 "not-an-ip",
837 "unparseable segments come back unchanged"
838 );
839 }
840
841 #[derive(Default)]
844 struct MemoryAdmin {
845 strings: Mutex<BTreeMap<String, String>>,
846 ttls_ms: Mutex<BTreeMap<String, i64>>,
847 }
848
849 impl MemoryAdmin {
850 fn seed(&self, key: &str, value: &str, ttl_ms: i64) {
851 self.strings
852 .lock()
853 .expect("strings")
854 .insert(key.to_owned(), value.to_owned());
855 self.ttls_ms
856 .lock()
857 .expect("ttls")
858 .insert(key.to_owned(), ttl_ms);
859 }
860
861 fn has(&self, key: &str) -> bool {
862 self.strings.lock().expect("strings").contains_key(key)
863 }
864 }
865
866 impl RedisAdminStore for MemoryAdmin {
867 fn scan_match(&self, pattern: &str) -> Result<Vec<String>, crate::distributed::StoreError> {
868 let stem = pattern.strip_suffix('*').unwrap_or(pattern);
870 Ok(self
871 .strings
872 .lock()
873 .expect("strings")
874 .keys()
875 .filter(|key| key.starts_with(stem))
876 .cloned()
877 .collect())
878 }
879
880 fn pttl_ms(&self, key: &str) -> Result<i64, crate::distributed::StoreError> {
881 Ok(*self.ttls_ms.lock().expect("ttls").get(key).unwrap_or(&-2))
882 }
883
884 fn set_px(
885 &self,
886 key: &str,
887 value: &str,
888 ttl_ms: i64,
889 ) -> Result<(), crate::distributed::StoreError> {
890 self.seed(key, value, ttl_ms);
891 Ok(())
892 }
893
894 #[allow(clippy::significant_drop_tightening)]
896 fn delete_keys(&self, keys: &[String]) -> Result<(), crate::distributed::StoreError> {
897 let mut strings = self.strings.lock().expect("strings");
898 let mut ttls = self.ttls_ms.lock().expect("ttls");
899 for key in keys {
900 strings.remove(key);
901 ttls.remove(key);
902 }
903 Ok(())
904 }
905
906 fn get_key(
907 &self,
908 prefix: &str,
909 namespace: &str,
910 key: &str,
911 ) -> Result<Option<String>, crate::distributed::StoreError> {
912 Ok(self
913 .strings
914 .lock()
915 .expect("strings")
916 .get(&full_key(prefix, namespace, key))
917 .cloned())
918 }
919 }
920
921 #[test]
922 fn migration_moves_the_longer_expiry_to_the_canonical_key() {
923 let admin = MemoryAdmin::default();
924 admin.seed(
927 "guard_core:banned_ips:::ffff:192.0.2.9",
928 "1735689600.5",
929 60_000,
930 );
931 admin.seed("guard_core:banned_ips:192.0.2.9", "1735689600.5", 10_000);
932
933 migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
934
935 assert_eq!(
938 admin
939 .strings
940 .lock()
941 .expect("strings")
942 .get("guard_core:banned_ips:192.0.2.9"),
943 Some(&"1735689600.5".to_owned())
944 );
945 assert_eq!(
946 admin
947 .ttls_ms
948 .lock()
949 .expect("ttls")
950 .get("guard_core:banned_ips:192.0.2.9"),
951 Some(&60_000)
952 );
953 assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
954 }
955
956 #[test]
957 fn migration_keeps_the_canonical_key_when_it_is_already_longer() {
958 let admin = MemoryAdmin::default();
959 admin.seed(
960 "guard_core:banned_ips:::ffff:192.0.2.9",
961 "1735689600.5",
962 10_000,
963 );
964 admin.seed("guard_core:banned_ips:192.0.2.9", "1735689600.5", 60_000);
965
966 migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
967
968 assert_eq!(
970 admin
971 .ttls_ms
972 .lock()
973 .expect("ttls")
974 .get("guard_core:banned_ips:192.0.2.9"),
975 Some(&60_000)
976 );
977 assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
978 }
979
980 #[test]
981 fn migration_drops_expired_and_persistent_legacy_keys() {
982 let admin = MemoryAdmin::default();
983 admin.seed("guard_core:banned_ips:::ffff:192.0.2.8", "1000.0", 0);
984 admin.seed("guard_core:banned_ips:::ffff:192.0.2.7", "1000.0", -1);
985
986 migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
987
988 assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.8"));
989 assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.7"));
990 assert!(
991 !admin.has("guard_core:banned_ips:192.0.2.8"),
992 "an expired legacy ban is dropped, not carried over"
993 );
994 assert!(
995 !admin.has("guard_core:banned_ips:192.0.2.7"),
996 "a persistent legacy ban is dropped, not carried over"
997 );
998 }
999
1000 struct FailingAdmin {
1003 stage: &'static str,
1004 }
1005
1006 impl RedisAdminStore for FailingAdmin {
1007 fn scan_match(
1008 &self,
1009 _pattern: &str,
1010 ) -> Result<Vec<String>, crate::distributed::StoreError> {
1011 if self.stage == "scan" {
1012 return Err(crate::distributed::StoreError("scan down".into()));
1013 }
1014 Ok(vec!["guard_core:banned_ips:::ffff:192.0.2.9".to_owned()])
1015 }
1016
1017 fn pttl_ms(&self, key: &str) -> Result<i64, crate::distributed::StoreError> {
1018 if self.stage == "pttl" {
1019 return Err(crate::distributed::StoreError("pttl down".into()));
1020 }
1021 if key.contains("ffff") {
1024 if self.stage == "persistent_delete" {
1025 return Ok(-1);
1026 }
1027 Ok(60_000)
1028 } else {
1029 if self.stage == "pttl_canonical" {
1030 return Err(crate::distributed::StoreError("canonical pttl down".into()));
1031 }
1032 Ok(10_000)
1033 }
1034 }
1035
1036 fn set_px(
1037 &self,
1038 _key: &str,
1039 _value: &str,
1040 _ttl_ms: i64,
1041 ) -> Result<(), crate::distributed::StoreError> {
1042 if self.stage == "set_px" {
1043 return Err(crate::distributed::StoreError("set_px down".into()));
1044 }
1045 Ok(())
1046 }
1047
1048 fn delete_keys(&self, _keys: &[String]) -> Result<(), crate::distributed::StoreError> {
1049 if self.stage == "delete" || self.stage == "persistent_delete" {
1050 return Err(crate::distributed::StoreError("delete down".into()));
1051 }
1052 Ok(())
1053 }
1054
1055 fn get_key(
1056 &self,
1057 _prefix: &str,
1058 _namespace: &str,
1059 _key: &str,
1060 ) -> Result<Option<String>, crate::distributed::StoreError> {
1061 if self.stage == "get" {
1062 return Err(crate::distributed::StoreError("get down".into()));
1063 }
1064 Ok(Some("1735689600.5".to_owned()))
1065 }
1066 }
1067
1068 #[test]
1069 fn migration_backend_failures_propagate_from_their_stage() {
1070 let error = migrate_legacy_ban_keys(&FailingAdmin { stage: "scan" }, "guard_core:")
1071 .expect_err("scan failure");
1072 assert_eq!(error.0, "scan down");
1073 let error = migrate_one_ban_key(
1076 &FailingAdmin { stage: "get" },
1077 "guard_core:",
1078 "guard_core:banned_ips:::ffff:192.0.2.9",
1079 )
1080 .expect_err("value read failure");
1081 assert_eq!(error.0, "get down");
1082 let error = migrate_one_ban_key(
1083 &FailingAdmin { stage: "pttl" },
1084 "guard_core:",
1085 "guard_core:banned_ips:::ffff:192.0.2.9",
1086 )
1087 .expect_err("pttl failure");
1088 assert_eq!(error.0, "pttl down");
1089 let error = migrate_one_ban_key(
1090 &FailingAdmin { stage: "set_px" },
1091 "guard_core:",
1092 "guard_core:banned_ips:::ffff:192.0.2.9",
1093 )
1094 .expect_err("copy failure");
1095 assert_eq!(error.0, "set_px down");
1096 let error = migrate_one_ban_key(
1097 &FailingAdmin { stage: "delete" },
1098 "guard_core:",
1099 "guard_core:banned_ips:::ffff:192.0.2.9",
1100 )
1101 .expect_err("delete failure");
1102 assert_eq!(error.0, "delete down");
1103 }
1104
1105 #[test]
1106 fn a_healthy_failing_admin_migrates_end_to_end() {
1107 let admin = FailingAdmin { stage: "ok" };
1108 assert!(
1109 migrate_one_ban_key(
1110 &admin,
1111 "guard_core:",
1112 "guard_core:banned_ips:::ffff:192.0.2.9"
1113 )
1114 .expect("migrates")
1115 );
1116 }
1117
1118 #[test]
1119 fn migration_backend_failures_propagate_from_the_later_stages() {
1120 let error = migrate_one_ban_key(
1122 &FailingAdmin {
1123 stage: "persistent_delete",
1124 },
1125 "guard_core:",
1126 "guard_core:banned_ips:::ffff:192.0.2.9",
1127 )
1128 .expect_err("persistent delete failure");
1129 assert_eq!(error.0, "delete down");
1130 let error = migrate_one_ban_key(
1132 &FailingAdmin {
1133 stage: "pttl_canonical",
1134 },
1135 "guard_core:",
1136 "guard_core:banned_ips:::ffff:192.0.2.9",
1137 )
1138 .expect_err("canonical pttl failure");
1139 assert_eq!(error.0, "canonical pttl down");
1140 let result = migrate_legacy_ban_keys(&FailingAdmin { stage: "get" }, "guard_core:");
1142 assert_eq!(result, Ok(()));
1143 }
1144
1145 #[test]
1146 fn a_persistent_legacy_key_is_deleted_without_a_copy() {
1147 let admin = MemoryAdmin::default();
1148 admin.seed("guard_core:banned_ips:::ffff:192.0.2.9", "1735689600.5", -1);
1151 assert!(
1152 migrate_one_ban_key(
1153 &admin,
1154 "guard_core:",
1155 "guard_core:banned_ips:::ffff:192.0.2.9"
1156 )
1157 .expect("migrates")
1158 );
1159 assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
1160 admin.seed("guard_core:banned_ips:::ffff:192.0.2.7", "1735689600.5", -2);
1162 assert!(
1163 migrate_one_ban_key(
1164 &admin,
1165 "guard_core:",
1166 "guard_core:banned_ips:::ffff:192.0.2.7"
1167 )
1168 .expect("migrates")
1169 );
1170 assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.7"));
1171 }
1172
1173 #[test]
1174 fn migration_touches_only_non_canonical_keys() {
1175 let admin = MemoryAdmin::default();
1176 admin.seed("guard_core:banned_ips:192.0.2.9", "1735689600.5", 60_000);
1177
1178 migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
1179
1180 assert!(admin.has("guard_core:banned_ips:192.0.2.9"));
1181 assert_eq!(
1182 admin
1183 .ttls_ms
1184 .lock()
1185 .expect("ttls")
1186 .get("guard_core:banned_ips:192.0.2.9"),
1187 Some(&60_000),
1188 "a canonical key is left untouched"
1189 );
1190 }
1191
1192 #[test]
1193 fn one_bad_key_never_fails_the_rest() {
1194 let admin = MemoryAdmin::default();
1195 admin.seed("guard_core:banned_ips:junk", "1735689600.5", 60_000);
1197 admin.seed(
1199 "guard_core:banned_ips:::ffff:192.0.2.9",
1200 "1735689600.5",
1201 60_000,
1202 );
1203
1204 migrate_legacy_ban_keys(&admin, "guard_core:").expect("scan");
1205
1206 assert!(admin.has("guard_core:banned_ips:junk"));
1207 assert!(!admin.has("guard_core:banned_ips:::ffff:192.0.2.9"));
1208 assert!(admin.has("guard_core:banned_ips:192.0.2.9"));
1209 }
1210
1211 #[test]
1212 fn one_key_migration_skips_foreign_prefixes_and_missing_values() {
1213 let admin = MemoryAdmin::default();
1214 assert!(
1216 !migrate_one_ban_key(&admin, "guard_core:", "guard_core:ratelimit:192.0.2.9").unwrap(),
1217 "a foreign namespace never migrates"
1218 );
1219 assert!(
1221 !migrate_one_ban_key(
1222 &admin,
1223 "guard_core:",
1224 "guard_core:banned_ips:::ffff:192.0.2.9"
1225 )
1226 .unwrap(),
1227 "no value: nothing to carry over"
1228 );
1229 }
1230
1231 #[test]
1232 fn cloud_ranges_decode_skips_empty_entries() {
1233 let (networks, regions) =
1234 decode_cloud_ranges(",203.0.113.0/24|us-east,,198.51.100.0/24,").expect("decodable");
1235 assert_eq!(
1236 networks,
1237 vec![
1238 String::from("198.51.100.0/24"),
1239 String::from("203.0.113.0/24"),
1240 ]
1241 );
1242 assert_eq!(regions.get("203.0.113.0/24"), Some(&"us-east".to_owned()));
1243 }
1244}