use guard_core_engine::distributed::{BanStore, SlidingWindowStore, StoreError};
#[derive(Debug)]
pub struct RedisStoreError(pub redis::RedisError);
impl core::fmt::Display for RedisStoreError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "redis store connection failed: {}", self.0)
}
}
impl std::error::Error for RedisStoreError {}
#[derive(Clone)]
pub struct RedisStore {
client: redis::Client,
}
impl RedisStore {
pub fn connect(url: &str) -> Result<Self, RedisStoreError> {
Ok(Self {
client: redis::Client::open(url).map_err(RedisStoreError)?,
})
}
fn connection(&self) -> Result<redis::Connection, StoreError> {
self.client
.get_connection()
.map_err(|error| StoreError(error.to_string()))
}
pub fn get_key(
&self,
prefix: &str,
namespace: &str,
key: &str,
) -> Result<Option<String>, StoreError> {
let mut connection = self.connection()?;
let full = guard_core_engine::redis_schema::full_key(prefix, namespace, key);
redis::cmd("GET")
.arg(full)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
pub fn set_key(
&self,
prefix: &str,
namespace: &str,
key: &str,
value: &str,
ttl_seconds: Option<u64>,
) -> Result<(), StoreError> {
let mut connection = self.connection()?;
let full = guard_core_engine::redis_schema::full_key(prefix, namespace, key);
let mut command = redis::cmd("SET");
command.arg(full).arg(value);
if let Some(ttl) = ttl_seconds
&& ttl > 0
{
command.arg("EX").arg(ttl);
}
command
.query::<()>(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
pub fn delete(&self, prefix: &str, namespace: &str, key: &str) -> Result<i64, StoreError> {
let mut connection = self.connection()?;
let full = guard_core_engine::redis_schema::full_key(prefix, namespace, key);
redis::cmd("DEL")
.arg(full)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
pub fn keys(&self, prefix: &str, pattern: &str) -> Result<Vec<String>, StoreError> {
let mut connection = self.connection()?;
redis::cmd("KEYS")
.arg(format!("{prefix}{pattern}"))
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
pub fn delete_pattern(&self, prefix: &str, pattern: &str) -> Result<i64, StoreError> {
let keys = self.keys(prefix, pattern)?;
if keys.is_empty() {
return Ok(0);
}
let mut connection = self.connection()?;
let mut command = redis::cmd("DEL");
for key in &keys {
command.arg(key);
}
command
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
pub fn record_sliding_window_hit(
&self,
prefix: &str,
namespace: &str,
key: &str,
now: f64,
window_start: f64,
ttl_seconds: u64,
) -> Result<u64, StoreError> {
let mut connection = self.connection()?;
let full = guard_core_engine::redis_schema::full_key(prefix, namespace, key);
let member = guard_core_engine::redis_schema::random_member();
let count: i64 = redis::pipe()
.atomic()
.zadd(&full, member, now)
.ignore()
.cmd("ZREMRANGEBYSCORE")
.arg(&full)
.arg("-inf")
.arg(guard_core_engine::redis_schema::exclusive_prune_bound(
window_start,
))
.ignore()
.zcard(&full)
.expire(&full, i64::try_from(ttl_seconds).unwrap_or(i64::MAX))
.ignore()
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))?;
Ok(u64::try_from(count).unwrap_or(u64::MAX))
}
pub fn migrate_legacy_ban_keys(&self, prefix: &str) -> Result<(), StoreError> {
guard_core_engine::redis_schema::migrate_legacy_ban_keys(self, prefix)
}
}
impl SlidingWindowStore for RedisStore {
fn record_hit(&self, key: &str, now: f64, window: u64) -> Result<u64, StoreError> {
let mut connection = self.connection()?;
let window_start = now - f64::from(u32::try_from(window).unwrap_or(u32::MAX));
let count: i64 = redis::pipe()
.atomic()
.zadd(key, now.to_string(), now)
.ignore()
.zrembyscore(key, 0_f64, window_start)
.ignore()
.cmd("EXPIRE")
.arg(key)
.arg(window * 2)
.ignore()
.zcard(key)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))?;
Ok(u64::try_from(count).unwrap_or(u64::MAX))
}
}
impl BanStore for RedisStore {
fn set_ban(&self, key: &str, expiry: f64, ttl_seconds: u64) -> Result<(), StoreError> {
let mut connection = self.connection()?;
redis::cmd("SET")
.arg(key)
.arg(expiry.to_string())
.arg("EX")
.arg(ttl_seconds)
.query::<()>(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
fn get_ban(&self, key: &str) -> Result<Option<f64>, StoreError> {
let mut connection = self.connection()?;
let stored: Option<String> = redis::cmd("GET")
.arg(key)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))?;
Ok(stored.and_then(|value| value.parse::<f64>().ok()))
}
fn delete_ban(&self, key: &str) -> Result<(), StoreError> {
let mut connection = self.connection()?;
redis::cmd("DEL")
.arg(key)
.query::<i64>(&mut connection)
.map(|_| ())
.map_err(|error| StoreError(error.to_string()))
}
}
impl guard_core_engine::redis_schema::RedisAdminStore for RedisStore {
fn scan_match(&self, pattern: &str) -> Result<Vec<String>, StoreError> {
let mut connection = self.connection()?;
let mut keys = Vec::new();
let mut cursor = String::from("0");
loop {
let (batch, next): (Vec<String>, String) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))?;
keys.extend(batch);
cursor = next;
if cursor == "0" {
return Ok(keys);
}
}
}
fn pttl_ms(&self, key: &str) -> Result<i64, StoreError> {
let mut connection = self.connection()?;
redis::cmd("PTTL")
.arg(key)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
fn set_px(&self, key: &str, value: &str, ttl_ms: i64) -> Result<(), StoreError> {
let mut connection = self.connection()?;
redis::cmd("SET")
.arg(key)
.arg(value)
.arg("PX")
.arg(ttl_ms)
.query::<()>(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
fn delete_keys(&self, keys: &[String]) -> Result<(), StoreError> {
if keys.is_empty() {
return Ok(());
}
let mut connection = self.connection()?;
let mut command = redis::cmd("DEL");
for key in keys {
command.arg(key);
}
command
.query::<i64>(&mut connection)
.map(|_| ())
.map_err(|error| StoreError(error.to_string()))
}
fn get_key(
&self,
prefix: &str,
namespace: &str,
key: &str,
) -> Result<Option<String>, StoreError> {
RedisStore::get_key(self, prefix, namespace, key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn connect_fails_closed_on_an_unroutable_url() {
let store = RedisStore::connect("redis://127.0.0.1:1");
assert!(store.is_ok(), "client construction is lazy");
}
#[test]
fn store_impls_are_object_safe() {
let store: Arc<dyn SlidingWindowStore> =
Arc::new(RedisStore::connect("redis://127.0.0.1:1").expect("lazy client"));
let _bans: Arc<dyn BanStore> =
Arc::new(RedisStore::connect("redis://127.0.0.1:1").expect("lazy client"));
let _ = store;
}
#[test]
fn the_store_implements_the_admin_seam_for_the_migration() {
let _admin: Arc<dyn guard_core_engine::redis_schema::RedisAdminStore> =
Arc::new(RedisStore::connect("redis://127.0.0.1:1").expect("lazy client"));
}
#[test]
fn store_is_cheaply_clonable_and_shares_the_client() {
let store = RedisStore::connect("redis://127.0.0.1:1").expect("lazy client");
let clone = store.clone();
let first: *const redis::Client = &store.client;
let second: *const redis::Client = &clone.client;
assert_eq!(first, second, "a clone shares the pooled client");
}
#[test]
fn operations_fail_closed_with_a_backend_error_shape() {
let store = RedisStore::connect("redis://127.0.0.1:1").expect("lazy client");
let error = store
.get_key("guard_core:", "patterns", "custom")
.expect_err("unroutable");
assert!(error.to_string().contains("distributed store error"));
assert!(
store
.set_key("guard_core:", "patterns", "custom", "x", None)
.is_err()
);
assert!(store.delete("guard_core:", "patterns", "custom").is_err());
assert!(store.keys("guard_core:", "banned_ips:*").is_err());
assert!(store.delete_pattern("guard_core:", "banned_ips:*").is_err());
assert!(
store
.record_sliding_window_hit(
"guard_core:",
"behavior_usage",
"behavior:usage:a:b",
1_000.0,
940.0,
60
)
.is_err()
);
assert!(
store.migrate_legacy_ban_keys("guard_core:").is_err(),
"the migration surfaces the scan failure, never a panic"
);
}
}