use crate::random;
use rustlavel_core::Json;
use std::collections::{BTreeMap, BTreeSet};
pub const ID_BYTES: usize = 32;
pub fn is_valid_id(id: &str) -> bool {
(ID_BYTES * 2..=128).contains(&id.len())
&& id.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
#[derive(Debug, Clone, PartialEq)]
pub struct Session {
id: String,
data: BTreeMap<String, Json>,
fresh_flash: BTreeSet<String>,
stale_flash: BTreeSet<String>,
updated_at: u64,
dirty: bool,
}
impl Session {
pub const TOKEN_KEY: &'static str = "_token";
pub fn new() -> Self {
Session::with_id(Session::new_id())
}
pub fn with_id(id: impl Into<String>) -> Self {
Session {
id: id.into(),
data: BTreeMap::new(),
fresh_flash: BTreeSet::new(),
stale_flash: BTreeSet::new(),
updated_at: crate::unix_now(),
dirty: false,
}
}
pub fn new_id() -> String {
random::hex(ID_BYTES)
}
pub fn id(&self) -> &str {
&self.id
}
pub fn updated_at(&self) -> u64 {
self.updated_at
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn get(&self, key: &str) -> Option<&Json> {
self.data.get(key)
}
pub fn get_string(&self, key: &str) -> Option<String> {
match self.data.get(key)? {
Json::String(value) => Some(value.clone()),
Json::Null => None,
other => Some(other.to_string()),
}
}
pub fn has(&self, key: &str) -> bool {
self.data.contains_key(key)
}
pub fn put(&mut self, key: impl Into<String>, value: impl Into<Json>) {
self.data.insert(key.into(), value.into());
self.dirty = true;
}
pub fn forget(&mut self, key: &str) -> Option<Json> {
let removed = self.data.remove(key);
self.fresh_flash.remove(key);
self.stale_flash.remove(key);
if removed.is_some() {
self.dirty = true;
}
removed
}
pub fn flush(&mut self) {
self.data.clear();
self.fresh_flash.clear();
self.stale_flash.clear();
self.dirty = true;
}
pub fn all(&self) -> &BTreeMap<String, Json> {
&self.data
}
pub fn flash(&mut self, key: impl Into<String>, value: impl Into<Json>) {
let key = key.into();
self.fresh_flash.insert(key.clone());
self.put(key, value);
}
pub fn keep(&mut self, key: &str) {
if self.stale_flash.remove(key) {
self.fresh_flash.insert(key.to_string());
self.dirty = true;
}
}
pub fn reflash(&mut self) {
let carried = std::mem::take(&mut self.stale_flash);
if !carried.is_empty() {
self.dirty = true;
}
self.fresh_flash.extend(carried);
}
pub fn age_flash(&mut self) {
let expiring: Vec<String> =
self.stale_flash.difference(&self.fresh_flash).cloned().collect();
for key in expiring {
self.data.remove(&key);
self.dirty = true;
}
self.stale_flash = std::mem::take(&mut self.fresh_flash);
}
pub fn regenerate(&mut self) -> &str {
self.id = Session::new_id();
self.dirty = true;
&self.id
}
pub fn invalidate(&mut self) -> &str {
self.flush();
self.regenerate()
}
pub fn token(&mut self) -> String {
if let Some(token) = self.get_string(Session::TOKEN_KEY) {
return token;
}
let token = random::hex(ID_BYTES);
self.put(Session::TOKEN_KEY, token.clone());
token
}
pub fn to_json(&self) -> Json {
Json::object([
("data", Json::Object(self.data.clone())),
(
"flash",
Json::Array(self.stale_flash.iter().cloned().map(Json::String).collect()),
),
("updated_at", Json::Number(self.updated_at as f64)),
])
}
pub fn from_json(id: impl Into<String>, payload: &Json) -> Self {
let mut session = Session::with_id(id);
if let Some(Json::Object(data)) = payload.get("data") {
session.data = data.clone();
}
if let Some(Json::Array(flash)) = payload.get("flash") {
session.stale_flash =
flash.iter().filter_map(|key| key.as_str().map(str::to_string)).collect();
}
if let Some(updated_at) = payload.get("updated_at").and_then(Json::as_f64) {
session.updated_at = updated_at as u64;
}
session.dirty = false;
session
}
pub fn touch(&mut self) {
self.updated_at = crate::unix_now();
}
}
impl Default for Session {
fn default() -> Self {
Session::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_sessions_get_a_long_random_hexadecimal_id() {
let first = Session::new();
let second = Session::new();
assert_eq!(first.id().len(), ID_BYTES * 2);
assert!(is_valid_id(first.id()));
assert_ne!(first.id(), second.id());
}
#[test]
fn id_validation_rejects_anything_that_is_not_our_own_shape() {
assert!(is_valid_id(&Session::new_id()));
assert!(!is_valid_id(""));
assert!(!is_valid_id("short"));
assert!(!is_valid_id("../../../etc/passwd"));
assert!(!is_valid_id(&"g".repeat(64)));
assert!(!is_valid_id(&"A".repeat(64)));
assert!(!is_valid_id(&"a".repeat(129)));
}
#[test]
fn values_are_put_read_and_forgotten() {
let mut session = Session::new();
assert!(!session.is_dirty());
session.put("name", "Ada");
session.put("visits", 3);
assert_eq!(session.get_string("name").as_deref(), Some("Ada"));
assert_eq!(session.get("visits").and_then(Json::as_i64), Some(3));
assert!(session.has("name"));
assert!(session.is_dirty());
assert_eq!(session.forget("name"), Some(Json::from("Ada")));
assert!(!session.has("name"));
assert_eq!(session.forget("name"), None);
}
#[test]
fn flush_empties_the_session_but_keeps_the_id() {
let mut session = Session::new();
let id = session.id().to_string();
session.put("a", 1);
session.flash("b", 2);
session.flush();
assert!(session.all().is_empty());
assert_eq!(session.id(), id);
}
#[test]
fn flash_data_survives_exactly_one_request() {
let mut session = Session::new();
session.flash("status", "Profile saved");
assert_eq!(session.get_string("status").as_deref(), Some("Profile saved"));
session.age_flash();
assert_eq!(session.get_string("status").as_deref(), Some("Profile saved"));
session.age_flash();
assert_eq!(session.get_string("status"), None);
}
#[test]
fn ordinary_values_are_not_swept_away_with_the_flash() {
let mut session = Session::new();
session.put("user_id", 7);
session.flash("status", "hi");
session.age_flash();
session.age_flash();
assert_eq!(session.get("user_id").and_then(Json::as_i64), Some(7));
assert_eq!(session.get_string("status"), None);
}
#[test]
fn re_flashing_a_key_gives_it_another_request() {
let mut session = Session::new();
session.flash("status", "first");
session.age_flash();
session.flash("status", "second");
session.age_flash();
assert_eq!(session.get_string("status").as_deref(), Some("second"));
}
#[test]
fn keep_and_reflash_extend_flashed_values() {
let mut session = Session::new();
session.flash("a", 1);
session.flash("b", 2);
session.age_flash();
session.keep("a");
session.age_flash();
assert!(session.has("a"));
assert!(!session.has("b"));
session.flash("c", 3);
session.age_flash();
session.reflash();
session.age_flash();
assert!(session.has("c"));
}
#[test]
fn regenerate_rotates_the_id_and_keeps_the_data() {
let mut session = Session::new();
session.put("user_id", 7);
let before = session.id().to_string();
let after = session.regenerate().to_string();
assert_ne!(before, after);
assert!(is_valid_id(&after));
assert_eq!(session.get("user_id").and_then(Json::as_i64), Some(7));
}
#[test]
fn invalidate_rotates_the_id_and_drops_the_data() {
let mut session = Session::new();
session.put("user_id", 7);
let before = session.id().to_string();
session.invalidate();
assert_ne!(session.id(), before);
assert!(session.all().is_empty());
}
#[test]
fn the_csrf_token_is_generated_once_and_then_reused() {
let mut session = Session::new();
let first = session.token();
assert_eq!(first.len(), ID_BYTES * 2);
assert_eq!(session.token(), first);
assert_eq!(session.get_string(Session::TOKEN_KEY), Some(first));
}
#[test]
fn serialization_round_trips_data_and_pending_flash() {
let mut session = Session::new();
session.put("user_id", 7);
session.flash("status", "saved");
session.age_flash();
let restored = Session::from_json(session.id(), &session.to_json());
assert_eq!(restored.get("user_id").and_then(Json::as_i64), Some(7));
assert_eq!(restored.get_string("status").as_deref(), Some("saved"));
assert!(!restored.is_dirty(), "a freshly loaded session has not been changed yet");
let mut restored = restored;
restored.age_flash();
assert_eq!(restored.get_string("status"), None);
}
#[test]
fn a_corrupt_payload_yields_an_empty_session_rather_than_an_error() {
let session = Session::from_json(Session::new_id(), &Json::String("garbage".into()));
assert!(session.all().is_empty());
assert!(is_valid_id(session.id()));
}
}