From 25f8a4cfaecac1cc1c5f083a12af8a2232b83a18 Mon Sep 17 00:00:00 2001 From: Rob Ede Date: Sun, 31 Jul 2022 05:14:59 +0100 Subject: [PATCH] add sled session backend --- .gitignore | 1 + actix-session/Cargo.toml | 8 +- actix-session/examples/basic.rs | 8 +- actix-session/src/lib.rs | 17 ++ actix-session/src/storage/cookie.rs | 18 +-- actix-session/src/storage/interface.rs | 2 +- actix-session/src/storage/mod.rs | 12 +- actix-session/src/storage/redis_actor.rs | 16 +- actix-session/src/storage/redis_rs.rs | 34 ++-- actix-session/src/storage/sled.rs | 189 +++++++++++++++++++++++ 10 files changed, 265 insertions(+), 40 deletions(-) create mode 100644 actix-session/src/storage/sled.rs diff --git a/.gitignore b/.gitignore index ec2e10ce9..11c381807 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,6 @@ guide/build/ *.pyc *.pid *.sock +*.db *~ .DS_Store diff --git a/actix-session/Cargo.toml b/actix-session/Cargo.toml index 4430df909..110c63e8c 100644 --- a/actix-session/Cargo.toml +++ b/actix-session/Cargo.toml @@ -21,8 +21,9 @@ name = "actix_session" path = "src/lib.rs" [features] -default = [] +default = ["sled-session"] cookie-session = [] +sled-session = ["sled"] redis-actor-session = ["actix-redis", "actix", "futures-core", "rand"] redis-rs-session = ["redis", "rand"] redis-rs-tls-session = ["redis-rs-session", "redis/tokio-native-tls-comp"] @@ -40,6 +41,9 @@ serde = { version = "1" } serde_json = { version = "1" } tracing = { version = "0.1.30", default-features = false, features = ["log"] } +# sled-session +sled = { version = "0.34", optional = true } + # redis-actor-session actix = { version = "0.13", default-features = false, optional = true } actix-redis = { version = "0.12", optional = true } @@ -57,7 +61,7 @@ log = "0.4" [[example]] name = "basic" -required-features = ["redis-actor-session"] +required-features = ["sled-session"] [[example]] name = "authentication" diff --git a/actix-session/examples/basic.rs b/actix-session/examples/basic.rs index 3f41c68dc..aeea07253 100644 --- a/actix-session/examples/basic.rs +++ b/actix-session/examples/basic.rs @@ -1,4 +1,4 @@ -use actix_session::{storage::RedisActorSessionStore, Session, SessionMiddleware}; +use actix_session::{storage::SledSessionStore, Session, SessionMiddleware}; use actix_web::{cookie::Key, middleware, web, App, Error, HttpRequest, HttpServer, Responder}; /// simple handler @@ -21,7 +21,9 @@ async fn main() -> std::io::Result<()> { env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); // The signing key would usually be read from a configuration file/environment variables. - let signing_key = Key::generate(); + let signing_key = Key::from(&[0; 64]); + + let sled_session_store = SledSessionStore::new("./session.db").unwrap(); log::info!("starting HTTP server at http://localhost:8080"); @@ -31,7 +33,7 @@ async fn main() -> std::io::Result<()> { .wrap(middleware::Logger::default()) // cookie session middleware .wrap(SessionMiddleware::new( - RedisActorSessionStore::new("127.0.0.1:6379"), + sled_session_store.clone(), signing_key.clone(), )) // register simple route, handle all methods diff --git a/actix-session/src/lib.rs b/actix-session/src/lib.rs index e115a4ebf..2fff6ded0 100644 --- a/actix-session/src/lib.rs +++ b/actix-session/src/lib.rs @@ -156,6 +156,23 @@ pub mod test_helpers { use crate::{config::CookieContentSecurity, storage::SessionStore}; + /// Prints name of function it is called in. + /// + /// Taken from: https://docs.rs/stdext/0.3.1/src/stdext/macros.rs.html + macro_rules! function_name { + () => {{ + // Okay, this is ugly, I get it. However, this is the best we can get on a stable rust. + fn f() {} + fn type_name_of(_: T) -> &'static str { + std::any::type_name::() + } + let name = type_name_of(f); + // `3` is the length of the `::f`. + &name[..name.len() - 3] + }}; + } + pub(crate) use function_name; + /// Generate a random cookie signing/encryption key. pub fn key() -> Key { Key::generate() diff --git a/actix-session/src/storage/cookie.rs b/actix-session/src/storage/cookie.rs index 10cc05bc6..728d1524d 100644 --- a/actix-session/src/storage/cookie.rs +++ b/actix-session/src/storage/cookie.rs @@ -1,13 +1,9 @@ -use std::convert::TryInto; +use std::convert::TryInto as _; use actix_web::cookie::time::Duration; -use anyhow::Error; -use super::SessionKey; -use crate::storage::{ - interface::{LoadError, SaveError, SessionState, UpdateError}, - SessionStore, -}; +use super::{LoadError, SaveError, SessionKey, SessionState, UpdateError}; +use crate::storage::SessionStore; /// Use the session key, stored in the session cookie, as storage backend for the session state. /// @@ -67,7 +63,7 @@ impl SessionStore for CookieSessionStore { _ttl: &Duration, ) -> Result { let session_key = serde_json::to_string(&session_state) - .map_err(anyhow::Error::new) + .map_err(Into::into) .map_err(SaveError::Serialization)?; Ok(session_key @@ -90,7 +86,11 @@ impl SessionStore for CookieSessionStore { }) } - async fn update_ttl(&self, _session_key: &SessionKey, _ttl: &Duration) -> Result<(), Error> { + async fn update_ttl( + &self, + _session_key: &SessionKey, + _ttl: &Duration, + ) -> Result<(), anyhow::Error> { Ok(()) } diff --git a/actix-session/src/storage/interface.rs b/actix-session/src/storage/interface.rs index 2b52c59ed..48252bce7 100644 --- a/actix-session/src/storage/interface.rs +++ b/actix-session/src/storage/interface.rs @@ -101,7 +101,7 @@ pub enum UpdateError { Serialization(anyhow::Error), /// Something went wrong when updating the session state. - #[display(fmt = "Something went wrong when updating the session state.")] + #[display(fmt = "Something went wrong when updating the session state")] Other(anyhow::Error), } diff --git a/actix-session/src/storage/mod.rs b/actix-session/src/storage/mod.rs index 1a74a8c6f..e4d437cd6 100644 --- a/actix-session/src/storage/mod.rs +++ b/actix-session/src/storage/mod.rs @@ -3,12 +3,16 @@ mod interface; mod session_key; +pub(crate) use self::interface::SessionState; pub use self::interface::{LoadError, SaveError, SessionStore, UpdateError}; pub use self::session_key::SessionKey; #[cfg(feature = "cookie-session")] mod cookie; +#[cfg(feature = "sled-session")] +mod sled; + #[cfg(feature = "redis-actor-session")] mod redis_actor; @@ -19,8 +23,10 @@ mod redis_rs; mod utils; #[cfg(feature = "cookie-session")] -pub use cookie::CookieSessionStore; +pub use self::cookie::CookieSessionStore; #[cfg(feature = "redis-actor-session")] -pub use redis_actor::{RedisActorSessionStore, RedisActorSessionStoreBuilder}; +pub use self::redis_actor::{RedisActorSessionStore, RedisActorSessionStoreBuilder}; #[cfg(feature = "redis-rs-session")] -pub use redis_rs::{RedisSessionStore, RedisSessionStoreBuilder}; +pub use self::redis_rs::{RedisSessionStore, RedisSessionStoreBuilder}; +#[cfg(feature = "sled-session")] +pub use self::sled::SledSessionStore; diff --git a/actix-session/src/storage/redis_actor.rs b/actix-session/src/storage/redis_actor.rs index 744f01156..9a4c824d9 100644 --- a/actix-session/src/storage/redis_actor.rs +++ b/actix-session/src/storage/redis_actor.rs @@ -1,14 +1,9 @@ use actix::Addr; use actix_redis::{resp_array, Command, RedisActor, RespValue}; use actix_web::cookie::time::Duration; -use anyhow::Error; -use super::SessionKey; -use crate::storage::{ - interface::{LoadError, SaveError, SessionState, UpdateError}, - utils::generate_session_key, - SessionStore, -}; +use super::{LoadError, SaveError, SessionKey, SessionState, UpdateError}; +use crate::storage::{utils::generate_session_key, SessionStore}; /// Use Redis as session storage backend. /// @@ -156,6 +151,7 @@ impl SessionStore for RedisActorSessionStore { let body = serde_json::to_string(&session_state) .map_err(Into::into) .map_err(SaveError::Serialization)?; + let session_key = generate_session_key(); let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); @@ -239,7 +235,11 @@ impl SessionStore for RedisActorSessionStore { } } - async fn update_ttl(&self, session_key: &SessionKey, ttl: &Duration) -> Result<(), Error> { + async fn update_ttl( + &self, + session_key: &SessionKey, + ttl: &Duration, + ) -> Result<(), anyhow::Error> { let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); let cmd = Command(resp_array![ diff --git a/actix-session/src/storage/redis_rs.rs b/actix-session/src/storage/redis_rs.rs index 04a780279..77a0b6263 100644 --- a/actix-session/src/storage/redis_rs.rs +++ b/actix-session/src/storage/redis_rs.rs @@ -1,15 +1,11 @@ -use std::{convert::TryInto, sync::Arc}; +use std::{convert::TryInto as _, sync::Arc}; use actix_web::cookie::time::Duration; -use anyhow::{Context, Error}; +use anyhow::Context as _; use redis::{aio::ConnectionManager, AsyncCommands, Cmd, FromRedisValue, RedisResult, Value}; -use super::SessionKey; -use crate::storage::{ - interface::{LoadError, SaveError, SessionState, UpdateError}, - utils::generate_session_key, - SessionStore, -}; +use super::{LoadError, SaveError, SessionKey, SessionState, UpdateError}; +use crate::storage::{utils::generate_session_key, SessionStore}; /// Use Redis as session storage backend. /// @@ -139,8 +135,8 @@ impl SessionStore for RedisSessionStore { async fn load(&self, session_key: &SessionKey) -> Result, LoadError> { let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); - let value: Option = self - .execute_command(redis::cmd("GET").arg(&[&cache_key])) + let value = self + .execute_command::>(redis::cmd("GET").arg(&[&cache_key])) .await .map_err(Into::into) .map_err(LoadError::Other)?; @@ -161,6 +157,7 @@ impl SessionStore for RedisSessionStore { let body = serde_json::to_string(&session_state) .map_err(Into::into) .map_err(SaveError::Serialization)?; + let session_key = generate_session_key(); let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); @@ -169,7 +166,7 @@ impl SessionStore for RedisSessionStore { &body, "NX", // NX: only set the key if it does not already exist "EX", // EX: set expiry - &format!("{}", ttl.whole_seconds()), + ttl.whole_seconds().to_string().as_str(), ])) .await .map_err(Into::into) @@ -194,9 +191,9 @@ impl SessionStore for RedisSessionStore { .execute_command(redis::cmd("SET").arg(&[ &cache_key, &body, - "XX", // XX: Only set the key if it already exist. + "XX", // XX: only set the key if it already exists "EX", // EX: set expiry - &format!("{}", ttl.whole_seconds()), + ttl.whole_seconds().to_string().as_str(), ])) .await .map_err(Into::into) @@ -223,7 +220,11 @@ impl SessionStore for RedisSessionStore { } } - async fn update_ttl(&self, session_key: &SessionKey, ttl: &Duration) -> Result<(), Error> { + async fn update_ttl( + &self, + session_key: &SessionKey, + ttl: &Duration, + ) -> Result<(), anyhow::Error> { let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); self.client @@ -235,6 +236,7 @@ impl SessionStore for RedisSessionStore { )?, ) .await?; + Ok(()) } @@ -321,12 +323,14 @@ mod tests { async fn loading_an_invalid_session_state_returns_deserialization_error() { let store = redis_store().await; let session_key = generate_session_key(); + store .client .clone() .set::<_, _, ()>(session_key.as_ref(), "random-thing-which-is-not-json") .await .unwrap(); + assert!(matches!( store.load(&session_key).await.unwrap_err(), LoadError::Deserialization(_), @@ -338,10 +342,12 @@ mod tests { let store = redis_store().await; let session_key = generate_session_key(); let initial_session_key = session_key.as_ref().to_owned(); + let updated_session_key = store .update(session_key, HashMap::new(), &time::Duration::seconds(1)) .await .unwrap(); + assert_ne!(initial_session_key, updated_session_key.as_ref()); } } diff --git a/actix-session/src/storage/sled.rs b/actix-session/src/storage/sled.rs new file mode 100644 index 000000000..ebec84986 --- /dev/null +++ b/actix-session/src/storage/sled.rs @@ -0,0 +1,189 @@ +use std::{path::Path, sync::Arc}; + +use actix_web::cookie::time::Duration; +use async_trait::async_trait; + +use super::{ + utils::generate_session_key, LoadError, SaveError, SessionKey, SessionState, SessionStore, + UpdateError, +}; + +#[derive(Clone)] +struct CacheConfiguration { + cache_keygen: Arc String + Send + Sync>, +} + +impl Default for CacheConfiguration { + fn default() -> Self { + Self { + cache_keygen: Arc::new(str::to_owned), + } + } +} + +/// TODO +#[cfg_attr(docsrs, doc(cfg(feature = "sled-session")))] +#[derive(Clone)] +pub struct SledSessionStore { + configuration: CacheConfiguration, + db: sled::Db, +} + +impl SledSessionStore { + /// TODO + pub fn new(db_path: impl AsRef) -> Result { + Ok(Self { + configuration: CacheConfiguration::default(), + db: sled::open(db_path)?, + }) + } +} + +#[async_trait(?Send)] +impl SessionStore for SledSessionStore { + async fn load(&self, session_key: &SessionKey) -> Result, LoadError> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + let value = self + .db + .get(cache_key) + .map_err(Into::into) + .map_err(LoadError::Other)?; + + match value { + None => Ok(None), + Some(value) => Ok(serde_json::from_slice(&value) + .map_err(Into::into) + .map_err(LoadError::Deserialization)?), + } + } + + async fn save( + &self, + session_state: SessionState, + ttl: &Duration, + ) -> Result { + let session_key = generate_session_key(); + + self.update(session_key, session_state, ttl) + .await + .map_err(|err| match err { + UpdateError::Serialization(err) => SaveError::Serialization(err), + UpdateError::Other(err) => SaveError::Other(err), + }) + } + + async fn update( + &self, + session_key: SessionKey, + session_state: SessionState, + _ttl: &Duration, + ) -> Result { + let body = serde_json::to_vec(&session_state) + .map_err(Into::into) + .map_err(UpdateError::Serialization)?; + + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + self.db + .insert(cache_key, body) + .map_err(Into::into) + .map_err(UpdateError::Other)?; + + Ok(session_key) + } + + async fn update_ttl( + &self, + session_key: &SessionKey, + _ttl: &Duration, + ) -> Result<(), anyhow::Error> { + let _cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + Ok(()) + } + + async fn delete(&self, session_key: &SessionKey) -> Result<(), anyhow::Error> { + let cache_key = (self.configuration.cache_keygen)(session_key.as_ref()); + + self.db + .drop_tree(&cache_key) + .map_err(Into::into) + .map_err(UpdateError::Other)?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use actix_web::cookie::time; + + use super::*; + use crate::test_helpers::{acceptance_test_suite, function_name}; + + fn sled_db(fn_name: &str) -> SledSessionStore { + let db_name = fn_name.replace("::", ".").replace(".{{closure}}", ""); + + SledSessionStore::new( + [ + env!("CARGO_TARGET_DIR"), + "/tmp-actix-session-tests/", + &db_name, + "-", + rand::random::().to_string().as_str(), + ".db", + ] + .concat(), + ) + .unwrap() + } + + #[actix_web::test] + async fn session_workflow() { + let store = sled_db(function_name!()); + // TODO: use invalidation_supported = true + acceptance_test_suite(move || store.clone(), false).await; + } + + #[actix_web::test] + async fn loading_a_missing_session_returns_none() { + let store = sled_db(function_name!()); + let session_key = generate_session_key(); + assert!(store.load(&session_key).await.unwrap().is_none()); + } + + #[actix_web::test] + async fn loading_an_invalid_session_state_returns_deserialization_error() { + let store = sled_db(function_name!()); + let session_key = generate_session_key(); + + store + .db + .insert(session_key.as_ref(), "random-thing-which-is-not-json") + .unwrap(); + + assert!(matches!( + store.load(&session_key).await.unwrap_err(), + LoadError::Deserialization(_), + )); + } + + // ignored until TTL handling is implemented + #[ignore] + #[actix_web::test] + async fn updating_of_an_expired_state_is_handled_gracefully() { + let store = sled_db(function_name!()); + let session_key = generate_session_key(); + let initial_session_key = session_key.as_ref().to_owned(); + + let updated_session_key = store + .update(session_key, HashMap::new(), &time::Duration::seconds(1)) + .await + .unwrap(); + + assert_ne!(initial_session_key, updated_session_key.as_ref()); + } +}