Skip to main content

guard_core_engine/
redis_schema.rs

1//! The Redis key schema (spec section 08, interop): every key pattern,
2//! every value format, and the ban-key migration, byte-compatible with the
3//! Go and Python family ports.
4//!
5//! A Rust deployment pointed at the same Redis as a Python guard-core or a
6//! Go port MUST interoperate byte-for-byte: shared bans, shared counters,
7//! shared caches. This module is the pure half - the key builders and the
8//! value codecs; the wire half lives in the facade's `redis_store` (the
9//! `redis` feature), which drives these shapes over a real connection.
10//!
11//! ## Key namespace
12//!
13//! Every key is `prefix + namespace + ":" + key` (plain concatenation, no
14//! normalization; the namespace itself may carry `:` and so may the inner
15//! key). The default prefix is `guard_core:`. `sha256hex(x)` is the
16//! lowercase hex SHA-256 of the UTF-8 encoding ([`identity_hash`]).
17//!
18//! | Pattern | Type | Builder |
19//! |---|---|---|
20//! | `{P}rate_limit:rate:{ip}` | zset | [`crate::distributed::rate_window_key`] |
21//! | `{P}rate_limit:rate:{ip}:{sha256hex(path)}` | zset | [`crate::distributed::rate_window_key_endpoint`] |
22//! | `{P}banned_ips:{ip}` | string | [`crate::distributed::ban_key`] |
23//! | `{P}banned_networks:{cidr}` | string | [`ban_network_key`] |
24//! | `{P}behavior_usage:behavior:usage:{sha256hex(endpoint)}:{sha256hex(ip)}` | zset | [`behavior_usage_key`] |
25//! | `{P}behavior_returns:behavior:return:{sha256hex(endpoint)}:{sha256hex(ip)}:{sha256hex(pattern)}` | zset | [`behavior_return_key`] |
26//! | `{P}security_headers:csp_config` / `hsts_config` / `custom_headers` | string (JSON) | [`security_headers_config_key`] |
27//! | `{P}patterns:custom` | string | [`patterns_custom_key`] |
28//! | `{P}ipinfo:database` | string | [`ipinfo_database_key`] |
29//! | `{P}cloud_ranges_v2:{provider}` | string | [`cloud_ranges_key`] |
30//! | `{P}cloud_ip_v2:{provider}` | string (JSON) | [`cloud_ip_key`] |
31//! | `{P}dynamic_rules:last_known` | string | [`dynamic_rules_last_known_key`] |
32//!
33//! The behavior keys' double segment (`behavior_usage:behavior:usage:`) is
34//! a verbatim reference oddity: namespace plus an in-key literal prefix.
35//! Do not clean it up.
36//!
37//! ## Value formats
38//!
39//! - `banned_ips` / `banned_networks`: the ban expiry as the Go
40//!   `FormatFloat(expiry, 'f', -1, 64)` decimal string
41//!   ([`format_expiry`]), read back with a plain float parse and
42//!   `now <= expiry` (the reference `_check_redis_exact`).
43//! - behavior zsets: member = 32 lowercase hex chars ([`random_member`]),
44//!   score = the float epoch; pruning is the exclusive
45//!   `ZREMRANGEBYSCORE key "-inf" "({window_start"` bound ([`exclusive_prune_bound`]).
46//! - `patterns:custom`: the custom patterns joined with `,`
47//!   ([`encode_patterns_custom`]); restored by splitting on `,`
48//!   ([`decode_patterns_custom`]).
49//! - `cloud_ranges_v2`: comma-joined, lexically sorted
50//!   `"<network>"` / `"<network>|<region>"` entries
51//!   ([`encode_cloud_ranges`] / [`decode_cloud_ranges`]); `<network>` is
52//!   the canonical masked CIDR string ([`canonical_network_string`]), an
53//!   unparseable prefix decodes as a corrupt-cache error, not a skip.
54//! - `cloud_ip_v2`: a JSON array of CIDR strings, sorted lexically
55//!   ([`encode_cloud_ip_v2`] / [`decode_cloud_ip_v2`]); a non-list or
56//!   undecodable payload is a cache miss.
57//! - `ipinfo:database`: the full `MaxMindDB` file bytes carried as the
58//!   latin-1 string of their byte values ([`latin1_to_wire`] /
59//!   [`wire_to_latin1`]).
60//! - `security_headers:*`: `json.dumps` of the config object
61//!   ([`encode_json_object`]).
62//! - `dynamic_rules:last_known`: the snapshot payload stored verbatim, no
63//!   TTL.
64//!
65//! ## Legacy ban-key migration
66//!
67//! [`migrate_legacy_ban_keys`] is the versioning bridge for IP
68//! canonicalization changes (the reference `_ipban_migration.py`, via the
69//! Go port's `migrateLegacyBanKeys`): SCAN the `{P}banned_ips:*` keys,
70//! canonicalize each raw IP segment, skip keys already canonical, move the
71//! longer expiry to the canonical key (`SET ... PX old_pttl`) when the
72//! canonical key's remaining TTL is shorter, and delete the legacy key.
73//! Failures never fail startup: callers log and continue.
74//!
75//! # Example
76//!
77//! ```
78//! use guard_core_engine::redis_schema::*;
79//!
80//! // The key grammar, byte-identical to the Python and Go ports.
81//! assert_eq!(
82//!     ban_network_key("guard_core:", "10.9.9.9/8").expect("valid cidr"),
83//!     "guard_core:banned_networks:10.0.0.0/8"
84//! );
85//! assert_eq!(
86//!     patterns_custom_key("guard_core:"),
87//!     "guard_core:patterns:custom"
88//! );
89//!
90//! // The ban expiry value: the Go FormatFloat('f', -1) decimal string.
91//! assert_eq!(format_expiry(1060.0), "1060");
92//!
93//! // The cloud ranges round-trip, sorted and comma-joined.
94//! let payload = encode_cloud_ranges(&[
95//!     (String::from("203.0.113.0/24"), Some("us-east")),
96//!     (String::from("198.51.100.0/24"), None),
97//! ]);
98//! assert_eq!(payload, "198.51.100.0/24,203.0.113.0/24|us-east");
99//! ```
100
101use 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
108/// The default key prefix (`SecurityConfig.redis_prefix`).
109pub const DEFAULT_REDIS_PREFIX: &str = "guard_core:";
110
111/// The security-headers config namespace.
112pub const SECURITY_HEADERS_NAMESPACE: &str = "security_headers";
113/// The CSP config key segment (`security_headers:csp_config`).
114pub const CSP_CONFIG_KEY: &str = "csp_config";
115/// The HSTS config key segment (`security_headers:hsts_config`).
116pub const HSTS_CONFIG_KEY: &str = "hsts_config";
117/// The custom-headers key segment (`security_headers:custom_headers`).
118pub const CUSTOM_HEADERS_KEY: &str = "custom_headers";
119/// The security-headers config TTL in seconds (the reference
120/// `_cache_configuration`).
121pub const SECURITY_HEADERS_TTL_SECONDS: u64 = 86_400;
122
123/// The custom-pattern registry namespace and key (`patterns:custom`).
124pub const PATTERNS_NAMESPACE: &str = "patterns";
125/// The custom-patterns key segment.
126pub const PATTERNS_CUSTOM_KEY: &str = "custom";
127
128/// The `GeoIP` database cache namespace and key (`ipinfo:database`).
129pub const IPINFO_NAMESPACE: &str = "ipinfo";
130/// The `ipinfo` database key segment.
131pub const IPINFO_DATABASE_KEY: &str = "database";
132/// The `GeoIP` database cache TTL default (the reference constructor
133/// `max_age`).
134pub const IPINFO_DATABASE_TTL_DEFAULT_SECONDS: u64 = 86_400;
135
136/// The cloud ranges cache namespace (`cloud_ranges_v2:{provider}`).
137pub const CLOUD_RANGES_NAMESPACE: &str = "cloud_ranges_v2";
138/// The cloud ranges cache TTL default (the reference `ttl` param).
139pub const CLOUD_RANGES_TTL_DEFAULT_SECONDS: u64 = 3_600;
140/// The cloud IP store protocol namespace (`cloud_ip_v2:{provider}`).
141pub const CLOUD_IP_NAMESPACE: &str = "cloud_ip_v2";
142
143/// The dynamic-rules namespace and last-known key
144/// (`dynamic_rules:last_known`, no TTL).
145pub const DYNAMIC_RULES_NAMESPACE: &str = "dynamic_rules";
146/// The last-known-rules key segment.
147pub const LAST_KNOWN_RULES_KEY: &str = "last_known";
148
149/// `{prefix}{namespace}:{key}`: the reference namespaced helper's plain
150/// concatenation.
151#[must_use]
152pub fn full_key(prefix: &str, namespace: &str, key: &str) -> String {
153    format!("{prefix}{namespace}:{key}")
154}
155
156/// The `{prefix}banned_networks:{cidr}` key: a CIDR ban, stored under the
157/// canonical network string (host bits cleared, the reference
158/// `str(ipaddress.ip_network(cidr, strict=False))`).
159///
160/// # Errors
161///
162/// [`IpGateError`] when `cidr` is neither a valid IP nor a valid CIDR
163/// range.
164pub 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/// The `{prefix}behavior_usage:behavior:usage:{sha256(endpoint)}:{sha256(ip)}`
173/// zset key: the reference behavior usage rules.
174///
175/// The doubled `behavior_usage:behavior:usage:` segment is a verbatim
176/// reference oddity; reproduce it exactly.
177#[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/// The behavior return-patterns zset key.
191///
192/// Shape:
193/// `{prefix}behavior_returns:behavior:return:{sha256(endpoint)}:{sha256(ip)}:{sha256(pattern)}`.
194#[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/// The `{prefix}security_headers:{segment}` config key for one of
209/// [`CSP_CONFIG_KEY`], [`HSTS_CONFIG_KEY`], [`CUSTOM_HEADERS_KEY`].
210#[must_use]
211pub fn security_headers_config_key(prefix: &str, segment: &str) -> String {
212    full_key(prefix, SECURITY_HEADERS_NAMESPACE, segment)
213}
214
215/// The `{prefix}patterns:custom` key: the custom-pattern registry.
216#[must_use]
217pub fn patterns_custom_key(prefix: &str) -> String {
218    full_key(prefix, PATTERNS_NAMESPACE, PATTERNS_CUSTOM_KEY)
219}
220
221/// The `{prefix}ipinfo:database` key: the `GeoIP` DB cache.
222#[must_use]
223pub fn ipinfo_database_key(prefix: &str) -> String {
224    full_key(prefix, IPINFO_NAMESPACE, IPINFO_DATABASE_KEY)
225}
226
227/// The `{prefix}cloud_ranges_v2:{provider}` key: the cloud-range cache.
228#[must_use]
229pub fn cloud_ranges_key(prefix: &str, provider: &str) -> String {
230    full_key(prefix, CLOUD_RANGES_NAMESPACE, provider)
231}
232
233/// The `{prefix}cloud_ip_v2:{provider}` key: the cloud IP store protocol.
234#[must_use]
235pub fn cloud_ip_key(prefix: &str, provider: &str) -> String {
236    full_key(prefix, CLOUD_IP_NAMESPACE, provider)
237}
238
239/// The `{prefix}dynamic_rules:last_known` key: the last-known dynamic
240/// rules snapshot (persisted, no TTL).
241#[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/// The ban expiry string, the Go port's `FormatFloat(expiry, 'f', -1, 64)`
247/// decimal form.
248///
249/// The Python reference writes `str(float)`, whose only difference is a
250/// trailing `.0` on integral values; readers on every family parse
251/// either, and the Go port is this family's wire reference.
252#[must_use]
253pub fn format_expiry(expiry: f64) -> String {
254    format!("{expiry}")
255}
256
257/// A fresh zset member: 32 lowercase hex chars, 16 random bytes
258/// hex-encoded.
259///
260/// The reference `uuid4().hex` on the Python side,
261/// `hex.EncodeToString(rand 16)` on the Go side; uniqueness is the
262/// contract, the shape is the interop guarantee.
263#[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/// The behavior window's prune bound: `"(x"`, the exclusive lower bound.
276///
277/// Shape: `ZREMRANGEBYSCORE key "-inf" "(<window_start>"`, exactly the
278/// reference's `f"({window_start}"`. The rate window prunes with the
279/// inclusive `0 window_start` pair instead - the two boundaries MUST NOT
280/// be swapped (spec section 08).
281#[must_use]
282pub fn exclusive_prune_bound(window_start: f64) -> String {
283    format!("({}", format_expiry(window_start))
284}
285
286/// The canonical network string of a bare IP or CIDR entry.
287///
288/// The host bits cleared, the family's own textual form (`10.9.9.9/8` ->
289/// `10.0.0.0/8`, a bare `10.0.0.9` -> `10.0.0.9/32`).
290#[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/// The custom-pattern registry payload: the patterns joined with `,`
297/// (the reference `",".join(custom_patterns)`).
298///
299/// A pattern containing a comma would corrupt this payload; the
300/// pattern-safety validation is the only guard, exactly as in the
301/// reference.
302#[must_use]
303pub fn encode_patterns_custom(patterns: &[String]) -> String {
304    patterns.join(",")
305}
306
307/// Split the registry payload back into patterns (the reference
308/// `cached_patterns.split(",")`).
309#[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/// A corrupt `cloud_ranges_v2` payload.
319///
320/// An entry whose prefix does not parse is a corrupt-cache error, not a
321/// skip (the reference `_decode_cached` raises).
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct CloudRangeDecodeError {
324    /// The offending entry.
325    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/// The `cloud_ranges_v2` payload: comma-joined, lexically sorted entries.
337///
338/// Each entry is `"<network>"` or `"<network>|<region>"` (the reference
339/// `",".join(sorted(_encode_cached(ranges, regions)))`). A region value
340/// carries at most one `|` annotation; callers MUST NOT let a region
341/// contain `|` (the reference `partition` semantics).
342#[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
356/// Decode the `cloud_ranges_v2` payload into `(networks, regions)`.
357///
358/// # Errors
359///
360/// [`CloudRangeDecodeError`] naming the first entry whose prefix does
361/// not parse as a network (a corrupt cache, never a skip).
362pub 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
389/// `str.partition` semantics: split at the first occurrence, keeping all
390/// three pieces (the separator empty when absent).
391fn 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/// The `cloud_ip_v2` payload: `json.dumps(sorted(ranges))`.
399///
400/// A JSON array of CIDR strings sorted lexically.
401#[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/// Decode the `cloud_ip_v2` payload: `Some(ranges)` for a JSON array of
413/// strings, `None` for anything else.
414///
415/// A non-list or undecodable payload is a cache miss triggering refresh,
416/// the reference `RedisCloudIpStore`.
417#[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/// A JSON object payload (`json.dumps` of a name -> value mapping), the
428/// `security_headers:*` config value shape. The pairs are written in the
429/// given order (Python dict insertion order).
430#[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/// Decode a JSON object payload into its string pairs (the
440/// `custom_headers` load arm). `None` when the payload is not an object of
441/// strings.
442#[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/// The `ipinfo:database` wire encoding: the `MaxMindDB` file bytes read
456/// as the latin-1 string of their byte values.
457///
458/// Each byte maps to the code point with the same number, which the
459/// reference stores with `decode_responses=True`. The reverse of
460/// [`wire_to_latin1`]; a reader MUST run exactly this to recover the
461/// binary DB.
462#[must_use]
463pub fn latin1_to_wire(bytes: &[u8]) -> String {
464    bytes.iter().map(|byte| char::from(*byte)).collect()
465}
466
467/// Recover the binary `GeoIP` database from the cached string.
468///
469/// Every char must be at or below U+00FF (the latin-1 range); anything
470/// above is a corrupt cache and reads as `None`.
471#[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/// The string-level IP canonicalization the migration runs on each
480/// scanned key segment (`_canonicalize_ip` / the Go `CanonicalizeIP`).
481///
482/// Strip the `[...]` bracket form, parse, collapse the IPv4-mapped form
483/// to its IPv4 text; an unparseable segment comes back unchanged.
484#[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
496/// The admin surface the legacy ban-key migration needs (the reference
497/// `RedisAdmin`, the sync seam pattern of [`crate::distributed`]).
498pub trait RedisAdminStore: Send + Sync {
499    /// `SCAN` with `MATCH pattern` (the observable `KEYS` result).
500    ///
501    /// # Errors
502    ///
503    /// [`StoreError`](crate::distributed::StoreError) on a backend failure.
504    fn scan_match(&self, pattern: &str) -> Result<Vec<String>, crate::distributed::StoreError>;
505
506    /// `PTTL key` in milliseconds: `-1` persistent, `-2` missing (the
507    /// Redis semantics; the seconds/milliseconds distinction is
508    /// normative).
509    ///
510    /// # Errors
511    ///
512    /// [`StoreError`](crate::distributed::StoreError) on a backend failure.
513    fn pttl_ms(&self, key: &str) -> Result<i64, crate::distributed::StoreError>;
514
515    /// `SET key value PX ttl_ms`: value and millisecond TTL land in one
516    /// command (the migration's write shape).
517    ///
518    /// # Errors
519    ///
520    /// [`StoreError`](crate::distributed::StoreError) on a backend failure.
521    fn set_px(
522        &self,
523        key: &str,
524        value: &str,
525        ttl_ms: i64,
526    ) -> Result<(), crate::distributed::StoreError>;
527
528    /// `DEL keys...`.
529    ///
530    /// # Errors
531    ///
532    /// [`StoreError`](crate::distributed::StoreError) on a backend failure.
533    fn delete_keys(&self, keys: &[String]) -> Result<(), crate::distributed::StoreError>;
534
535    /// `GET {prefix}{namespace}:{key}` (the namespaced read the migration
536    /// uses to carry the legacy value over). `Ok(None)` is a miss.
537    ///
538    /// # Errors
539    ///
540    /// [`StoreError`](crate::distributed::StoreError) on a backend failure.
541    fn get_key(
542        &self,
543        prefix: &str,
544        namespace: &str,
545        key: &str,
546    ) -> Result<Option<String>, crate::distributed::StoreError>;
547}
548
549/// The legacy ban-key migration (the reference `_ipban_migration.py`,
550/// via the Go port's `migrateLegacyBanKeys`).
551///
552/// For every `{prefix}banned_ips:*` key whose IP segment is not already
553/// canonical, carry the longer expiry over to the canonical key and
554/// delete the legacy one. Runs once at IP-ban-handler Redis
555/// initialization; a failure on one key is that key's loss, never a
556/// startup failure (callers log and continue, the reference's
557/// `"Legacy ban-key migration skipped: {reason}"`).
558///
559/// # Errors
560///
561/// The first [`StoreError`](crate::distributed::StoreError) from the scan
562/// itself; per-key errors are skipped (the reference logs and moves on).
563pub 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
575/// One key's migration pass. `Ok(false)` when the key was already
576/// canonical (untouched), `Ok(true)` when it was migrated or dropped.
577///
578/// # Errors
579///
580/// [`StoreError`](crate::distributed::StoreError) propagates from the
581/// backend calls (the reference logs and skips the key).
582pub 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        // Persistent or expired: delete, done.
601        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        // Keep the longer expiry on the canonical key.
608        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    // ---- Key grammar fixtures (byte parity with the Go/Python ports) ----
620
621    #[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    // ---- Value format fixtures ----
688
689    #[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        // Lexically sorted encoded entries, comma-joined: the exact bytes
737        // the Python reference writes for this set.
738        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        // An entry carrying host bits canonicalizes on decode, exactly the
759        // reference `ip_network(prefix)` parse.
760        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        // A non-list or undecodable payload is a cache miss.
786        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        // Every byte value round-trips, including the ones UTF-8 would
816        // mangle (>= 0x80).
817        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        // A char above the latin-1 range is a corrupt cache.
821        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    // ---- Migration ----
842
843    #[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            // A glob with only a trailing * is what the migration uses.
869            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        // Test scaffolding: the two guards live exactly for this loop.
895        #[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        // A legacy v4-mapped key with 60s left; the canonical key exists
925        // but with only 10s left.
926        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        // The canonical key now carries the legacy value with the longer
936        // (60s) expiry; the legacy key is gone.
937        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        // The canonical key kept its own 60s TTL; the legacy key is gone.
969        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    /// A store whose calls fail one stage at a time, so every `?` in the
1001    /// migration propagates a real backend error in a test.
1002    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            // The canonical key sits lower than the legacy row, so the
1022            // copy-on-longer-expiry write always runs.
1023            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        // A legacy key routes the value read, the two pttl reads, the
1074        // copy-on-longer-expiry write, and the delete through the store.
1075        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        // A persistent legacy row routes its delete through the store.
1121        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        // The canonical key's own pttl read is a separate store call.
1131        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        // A per-key failure inside the scan loop is swallowed by design.
1141        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        // pttl -1: the legacy row never expires, so the migration simply
1149        // drops it after carrying nothing over.
1150        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        // An expired (-2) legacy row lands in the same branch.
1161        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        // A segment that is not an IP canonicalizes to itself: skipped.
1196        admin.seed("guard_core:banned_ips:junk", "1735689600.5", 60_000);
1197        // A real legacy key next to it still migrates.
1198        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        // A key outside the banned_ips namespace is untouched.
1215        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        // A legacy (non-canonical) key with no value row is untouched.
1220        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}