use crate::client::CacheKit;
#[cfg(any(
feature = "redis",
all(feature = "cachekitio", not(target_arch = "wasm32"))
))]
use {
crate::client::{CacheKitBuilder, SharedBackend},
crate::error::CachekitError,
std::time::Duration,
};
#[cfg(all(
any(
feature = "redis",
all(feature = "cachekitio", not(target_arch = "wasm32"))
),
not(any(target_arch = "wasm32", feature = "unsync"))
))]
fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
std::sync::Arc::new(b)
}
#[cfg(all(
any(
feature = "redis",
all(feature = "cachekitio", not(target_arch = "wasm32"))
),
any(target_arch = "wasm32", feature = "unsync")
))]
fn wrap(b: impl crate::backend::Backend + 'static) -> SharedBackend {
std::rc::Rc::new(b)
}
#[cfg(feature = "redis")]
fn minimal_defaults(backend: SharedBackend) -> CacheKitBuilder {
let builder = CacheKitBuilder::default()
.backend(backend)
.default_ttl(Duration::from_secs(300))
.l1_capacity(1000);
#[cfg(all(feature = "l1", not(feature = "unsync"), not(target_arch = "wasm32")))]
let builder = builder.swr_enabled(false);
builder
}
#[cfg(all(feature = "redis", feature = "encryption"))]
fn secure_defaults(
master_key: &[u8],
previous_keys: &[zeroize::Zeroizing<Vec<u8>>],
) -> Result<CacheKitBuilder, CachekitError> {
let previous: Vec<&[u8]> = previous_keys.iter().map(|k| k.as_slice()).collect();
let builder = CacheKitBuilder::default()
.default_ttl(Duration::from_secs(600))
.l1_capacity(1000)
.encryption_from_hex_decoded(master_key, &previous, "default")?;
#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
Ok(builder)
}
#[cfg(all(feature = "redis", feature = "encryption"))]
fn secure_hex_defaults(master_key_hex: &str) -> Result<CacheKitBuilder, CachekitError> {
let master_key = crate::config::decode_master_key_hex(master_key_hex, "master_key_hex")?;
secure_defaults(&master_key, &[])
}
#[cfg(all(feature = "redis", feature = "encryption"))]
fn secure_env_defaults() -> Result<CacheKitBuilder, CachekitError> {
let (master_key, previous) =
crate::config::master_keys_from_env("CacheKit::secure(url, master_key_hex)")?;
secure_defaults(&master_key, &previous)
}
#[cfg(all(feature = "redis", feature = "encryption"))]
async fn connect_secure(
redis_url: &str,
builder: CacheKitBuilder,
) -> Result<CacheKitBuilder, CachekitError> {
let backend = reconnecting_redis(redis_url)?;
drop(backend.connect().await?);
Ok(builder.backend(wrap(backend)))
}
#[cfg(feature = "redis")]
fn reconnecting_redis(
redis_url: &str,
) -> Result<crate::backend::redis::RedisBackend, CachekitError> {
crate::backend::redis::RedisBackend::builder()
.url(redis_url)
.auto_reconnect()
.build()
}
impl CacheKit {
#[cfg(feature = "redis")]
pub async fn minimal(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
let backend = crate::backend::redis::RedisBackend::builder()
.url(redis_url)
.build()?;
drop(backend.connect().await?);
Ok(minimal_defaults(wrap(backend)))
}
#[cfg(feature = "redis")]
pub async fn production(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
let backend = reconnecting_redis(redis_url)?;
drop(backend.connect().await?);
let builder = CacheKitBuilder::default()
.backend(wrap(backend))
.default_ttl(Duration::from_secs(600))
.l1_capacity(1000);
#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
let builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
Ok(builder)
}
#[cfg(all(feature = "redis", feature = "encryption"))]
pub async fn secure(
redis_url: &str,
master_key_hex: &str,
) -> Result<CacheKitBuilder, CachekitError> {
connect_secure(redis_url, secure_hex_defaults(master_key_hex)?).await
}
#[cfg(all(feature = "redis", feature = "encryption"))]
pub async fn secure_from_env(redis_url: &str) -> Result<CacheKitBuilder, CachekitError> {
connect_secure(redis_url, secure_env_defaults()?).await
}
#[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
pub fn io(api_key: &str) -> Result<CacheKitBuilder, CachekitError> {
let backend = crate::backend::cachekitio::CachekitIO::builder()
.api_key(api_key)
.build()?;
let builder = CacheKitBuilder::default()
.backend(wrap(backend))
.default_ttl(Duration::from_secs(3600))
.l1_capacity(1000);
#[cfg(all(feature = "reliability", not(target_arch = "wasm32")))]
let builder = {
let mut builder = builder.reliability(crate::reliability::ReliabilityConfig::default());
builder.retry_deadlines = Some(crate::reliability::CACHEKITIO_DEADLINES);
builder
};
Ok(builder)
}
#[cfg(all(feature = "cachekitio", not(target_arch = "wasm32")))]
pub fn io_from_env() -> Result<CacheKitBuilder, CachekitError> {
let api_key = std::env::var("CACHEKIT_API_KEY")
.ok()
.map(zeroize::Zeroizing::new)
.filter(|k| !k.is_empty())
.ok_or_else(|| {
CachekitError::Config(
"CACHEKIT_API_KEY is unset or empty: set it or pass the key to CacheKit::io(api_key)"
.to_owned(),
)
})?;
Self::io(&api_key)
}
}
#[cfg(all(test, feature = "redis", feature = "l1"))]
#[allow(clippy::expect_used)] mod tests {
#[test]
fn minimal_enables_l1_without_swr() {
let backend = crate::backend::redis::RedisBackend::builder()
.url("redis://localhost:6379")
.build()
.expect("valid URL");
let cache = super::minimal_defaults(super::wrap(backend))
.build()
.expect("minimal defaults must build");
assert!(
cache.l1.is_some(),
"spec/intent-presets.md § L1 Posture: minimal MUST enable L1"
);
#[cfg(all(not(feature = "unsync"), not(target_arch = "wasm32")))]
assert!(
!cache.swr_enabled,
"spec/intent-presets.md § L1 Posture: minimal MUST NOT enable SWR"
);
}
}
#[cfg(all(test, feature = "redis", feature = "encryption"))]
#[allow(clippy::expect_used)] mod secure_tests {
const MASTER_KEY_HEX: &str = "6161616161616161616161616161616161616161616161616161616161616161";
const CACHE_KEY: &str =
"users:get_user:61598716255080080f6456eb065c2e51badfaa4320b0efe97469c29cffee8875";
const CIPHERTEXT_HEX: &str =
"0d0e0f1011121314151617183c29af318238925ee76d081934adce133c0b4a7c5eb5704102b04582dcbf278ffd"; const PLAINTEXT_HEX: &str = "82a36167651ea46e616d65a5616c696365";
fn assert_decrypts_default_tenant_vector(builder: crate::CacheKitBuilder) {
let layer = builder
.encryption
.expect("secure preset must configure encryption");
assert_eq!(layer.tenant_id(), "default");
let ciphertext = hex::decode(CIPHERTEXT_HEX).expect("vector hex");
let plaintext = layer
.decrypt(&ciphertext, CACHE_KEY)
.expect("default-tenant vector must decrypt");
assert_eq!(hex::encode(plaintext), PLAINTEXT_HEX);
}
#[tokio::test]
async fn plain_set_on_secure_preset_stores_ciphertext() {
const VALUE: &str = "ssn 123-45-6789";
let backend = crate::backend::MemoryBackend::default();
let master_key = hex::decode(MASTER_KEY_HEX).expect("vector hex");
let cache = super::secure_defaults(&master_key, &[])
.expect("valid key")
.backend(super::wrap(backend.clone()))
.build()
.expect("secure defaults must build");
cache.set(CACHE_KEY, &VALUE).await.expect("plain set");
let stored = backend
.store
.lock()
.await
.get(CACHE_KEY)
.cloned()
.expect("plain set reached the backend");
let plaintext = crate::serializer::serialize(&VALUE).expect("serializes");
assert_ne!(stored, plaintext, "backend holds plaintext");
let independent = crate::EncryptionLayer::new(&master_key, "default").expect("valid key");
assert_eq!(
independent
.decrypt(&stored, CACHE_KEY)
.expect("backend bytes are ciphertext under the preset's key and tenant"),
plaintext
);
#[cfg(feature = "l1")]
assert_eq!(
cache
.l1
.as_ref()
.expect("secure preset enables L1")
.get_shared(CACHE_KEY)
.as_deref(),
Some(stored.as_slice()),
"L1 holds the same ciphertext as the backend"
);
assert_eq!(
cache.get::<String>(CACHE_KEY).await.expect("plain get"),
Some(VALUE.to_owned())
);
}
#[test]
fn hex_path_decrypts_default_tenant_vector() {
assert_decrypts_default_tenant_vector(
super::secure_defaults(&hex::decode(MASTER_KEY_HEX).expect("vector hex"), &[])
.expect("valid key"),
);
}
#[test]
fn hex_path_accepts_48_byte_key() {
let long = [0x61u8; 48];
assert!(super::secure_defaults(&long, &[]).is_ok());
let previous = [zeroize::Zeroizing::new(vec![0x62u8; 48])];
assert!(super::secure_defaults(&long, &previous).is_ok());
}
#[test]
#[serial_test::serial]
fn env_path_accepts_48_byte_keys() {
let (k2, k1) = ("22".repeat(48), "11".repeat(48));
assert!(secure_env_defaults_with(Some(&k2), Some(&k1)).is_ok());
assert!(secure_env_defaults_with(Some(&"22".repeat(32)), None).is_ok());
}
fn with_env<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
let saved: Vec<_> = vars
.iter()
.map(|(var, _)| (*var, std::env::var_os(var)))
.collect();
for (var, val) in vars {
match val {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
}
let out = f();
for (var, val) in saved {
match val {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
}
out
}
fn secure_env_defaults_with(
master: Option<&str>,
previous: Option<&str>,
) -> Result<crate::CacheKitBuilder, crate::CachekitError> {
with_env(
&[
("CACHEKIT_MASTER_KEY", master),
("CACHEKIT_PREVIOUS_MASTER_KEYS", previous),
],
super::secure_env_defaults,
)
}
#[test]
#[serial_test::serial]
fn env_path_decrypts_default_tenant_vector() {
assert_decrypts_default_tenant_vector(
secure_env_defaults_with(Some(MASTER_KEY_HEX), None).expect("valid key"),
);
}
#[cfg(feature = "cachekitio")]
#[test]
#[serial_test::serial]
fn from_env_encryption_from_env_decrypts_default_tenant_vector(
) -> Result<(), crate::CachekitError> {
let builder = with_env(
&[
("CACHEKIT_MASTER_KEY", Some(MASTER_KEY_HEX)),
("CACHEKIT_PREVIOUS_MASTER_KEYS", None),
("CACHEKIT_API_KEY", Some("test-key")),
("CACHEKIT_API_URL", None),
],
|| {
crate::CacheKit::from_env()?
.namespace("my-app")
.encryption_from_env()
},
)?;
assert_decrypts_default_tenant_vector(builder);
Ok(())
}
#[test]
#[serial_test::serial]
fn env_path_decrypts_under_previous_key_after_rotation() {
let k2 = "22".repeat(32);
let rotated = secure_env_defaults_with(Some(&k2), Some(MASTER_KEY_HEX))
.expect("valid rotation config");
assert_decrypts_default_tenant_vector(rotated);
let layer = secure_env_defaults_with(Some(&k2), None)
.expect("valid key")
.encryption
.expect("secure preset must configure encryption");
let ciphertext = hex::decode(CIPHERTEXT_HEX).expect("vector hex");
assert!(layer.decrypt(&ciphertext, CACHE_KEY).is_err());
}
struct AcceptRow {
name: String,
master_key_hex: String,
cache_key: String,
ciphertext: Vec<u8>,
plaintext_hex: String,
}
fn accept_rows() -> Vec<AcceptRow> {
let all: serde_json::Value =
serde_json::from_str(include_str!("../tests/vectors/encryption.json"))
.expect("vendored vector file must be valid JSON");
all["master_key_input"]["accept_vectors"]
.as_array()
.expect("accept_vectors must be an array")
.iter()
.map(|row| {
let get = |k: &str| row[k].as_str().expect("accept row field").to_owned();
AcceptRow {
name: get("name"),
master_key_hex: get("master_key_hex"),
cache_key: get("cache_key"),
ciphertext: hex::decode(get("ciphertext_hex")).expect("vector hex"),
plaintext_hex: get("plaintext_hex"),
}
})
.collect()
}
fn assert_decrypts_accept_row(builder: &crate::CacheKitBuilder, row: &AcceptRow) {
let layer = builder
.encryption
.as_ref()
.expect("secure preset must configure encryption");
assert_eq!(layer.tenant_id(), "default");
let plaintext = layer
.decrypt(&row.ciphertext, &row.cache_key)
.map(hex::encode)
.map_err(|e| format!("{e:?}"));
assert_eq!(
plaintext.as_deref(),
Ok(row.plaintext_hex.as_str()),
"{} must decrypt",
row.name
);
}
#[test]
fn hex_path_decrypts_master_key_input_accept_rows() {
for row in accept_rows() {
let builder = super::secure_hex_defaults(&row.master_key_hex).expect("valid key");
assert_decrypts_accept_row(&builder, &row);
}
}
#[test]
#[serial_test::serial]
fn env_path_decrypts_master_key_input_accept_rows() {
for row in accept_rows() {
let builder =
secure_env_defaults_with(Some(&row.master_key_hex), None).expect("valid key");
assert_decrypts_accept_row(&builder, &row);
}
}
#[test]
#[serial_test::serial]
fn env_path_reads_accept_rows_and_default_tenant_vector_across_rotation() {
for row in accept_rows() {
let builder = secure_env_defaults_with(Some(&row.master_key_hex), Some(MASTER_KEY_HEX))
.expect("valid rotation config");
assert_decrypts_accept_row(&builder, &row);
assert_decrypts_default_tenant_vector(builder);
}
}
#[test]
#[serial_test::serial]
fn env_path_blank_previous_keys_is_unset() {
assert_decrypts_default_tenant_vector(
secure_env_defaults_with(Some(MASTER_KEY_HEX), Some(" ")).expect("blank is unset"),
);
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn env_path_rejects_non_utf8_previous_keys() {
use std::os::unix::ffi::OsStringExt;
let saved = std::env::var_os("CACHEKIT_PREVIOUS_MASTER_KEYS");
std::env::set_var(
"CACHEKIT_PREVIOUS_MASTER_KEYS",
std::ffi::OsString::from_vec(vec![0x31, 0xff, 0x31]),
);
let saved_master = std::env::var_os("CACHEKIT_MASTER_KEY");
std::env::set_var("CACHEKIT_MASTER_KEY", MASTER_KEY_HEX);
let resolved = super::secure_env_defaults();
for (var, val) in [
("CACHEKIT_PREVIOUS_MASTER_KEYS", saved),
("CACHEKIT_MASTER_KEY", saved_master),
] {
match val {
Some(v) => std::env::set_var(var, v),
None => std::env::remove_var(var),
}
}
assert!(
matches!(
&resolved,
Err(crate::CachekitError::Config(msg)) if msg.contains("CACHEKIT_PREVIOUS_MASTER_KEYS")
),
"non-UTF-8 previous keys must be a Config error naming the variable; got {:?}",
resolved.err()
);
}
#[test]
#[serial_test::serial]
fn env_path_rejects_invalid_previous_keys() {
let k2 = "22".repeat(32);
let four = ["11", "33", "44", "55"].map(|b| b.repeat(32)).join(",");
for (previous, why) in [
("not-hex", "invalid hex"),
(&*format!("{MASTER_KEY_HEX},"), "empty entry"),
(&*"11".repeat(31), "short key"),
(&*four, "more than 3"),
(&*k2, "current key repeated"),
] {
assert!(
matches!(
secure_env_defaults_with(Some(&k2), Some(previous)),
Err(crate::CachekitError::Config(_))
),
"{why} must be a Config error"
);
}
}
}
#[cfg(all(
test,
feature = "cachekitio",
feature = "reliability",
not(target_arch = "wasm32")
))]
#[allow(clippy::expect_used)] mod io_deadline_tests {
use crate::reliability::CACHEKITIO_DEADLINES;
use crate::CacheKit;
#[test]
fn io_sets_the_cachekitio_retry_deadlines() {
let builder = CacheKit::io("ck_test_key").expect("io builds");
assert_eq!(builder.retry_deadlines, Some(CACHEKITIO_DEADLINES));
}
#[test]
fn replacing_the_backend_drops_the_deadlines() {
let other = crate::backend::cachekitio::CachekitIO::builder()
.api_key("ck_test_key")
.build()
.expect("builds");
let builder = CacheKit::io("ck_test_key")
.expect("io builds")
.backend(super::wrap(other));
assert_eq!(builder.retry_deadlines, None);
}
}
#[cfg(all(test, feature = "redis"))]
#[allow(clippy::expect_used)] mod redis_reconnect_tests {
use fred::prelude::ClientLike;
use crate::backend::redis::RedisBackend;
#[test]
fn presets_and_the_public_builder_share_one_reconnect_policy() {
let url = "redis://localhost:6379";
let preset = super::reconnecting_redis(url).expect("builds");
let public = RedisBackend::builder()
.url(url)
.auto_reconnect()
.build()
.expect("builds");
let policy = preset.client().client_reconnect_policy();
assert!(policy.is_some(), "presets reconnect");
assert_eq!(policy, public.client().client_reconnect_policy());
assert_eq!(
preset.client().connection_config().unresponsive.max_timeout,
public.client().connection_config().unresponsive.max_timeout
);
assert_eq!(
preset.client().perf_config().default_command_timeout,
public.client().perf_config().default_command_timeout
);
}
}