use regex::Regex;
use thiserror::Error;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SessionIdFormat {
Ulid,
Uuid,
Custom(Regex),
}
impl SessionIdFormat {
pub fn matches(&self, id: &str) -> bool {
match self {
Self::Ulid => is_canonical_ulid(id),
Self::Uuid => is_canonical_uuid(id),
Self::Custom(re) => re.is_match(id),
}
}
pub fn effective_entropy_bits(&self) -> u32 {
match self {
Self::Ulid => 80,
Self::Uuid => 122,
Self::Custom(_) => 0,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SessionIdPolicy {
pub min_entropy_bits: u32,
pub format_whitelist: Vec<SessionIdFormat>,
}
impl SessionIdPolicy {
pub fn new(min_entropy_bits: u32, format_whitelist: Vec<SessionIdFormat>) -> Self {
Self {
min_entropy_bits,
format_whitelist,
}
}
pub fn default_strict() -> Self {
Self {
min_entropy_bits: 80,
format_whitelist: vec![SessionIdFormat::Ulid, SessionIdFormat::Uuid],
}
}
pub fn validate(&self, id: &str) -> Result<(), SessionIdError> {
if id.is_empty() {
return Err(SessionIdError::Empty);
}
for fmt in &self.format_whitelist {
if fmt.matches(id) {
let bits = fmt.effective_entropy_bits();
if bits < self.min_entropy_bits {
return Err(SessionIdError::InsufficientEntropy {
required: self.min_entropy_bits,
actual: bits,
});
}
return Ok(());
}
}
Err(SessionIdError::DisallowedFormat)
}
}
#[derive(Debug, Error, PartialEq, Eq)]
#[non_exhaustive]
pub enum SessionIdError {
#[error("session id is empty")]
Empty,
#[error("session id does not match any whitelisted format")]
DisallowedFormat,
#[error("session id entropy {actual} bits is below required floor {required}")]
InsufficientEntropy {
required: u32,
actual: u32,
},
}
fn is_canonical_ulid(id: &str) -> bool {
if id.len() != 26 {
return false;
}
let mut chars = id.chars();
let first = match chars.next() {
Some(c) => c,
None => return false,
};
if !matches!(first, '0'..='7') {
return false;
}
if !is_crockford_base32(first) {
return false;
}
chars.all(is_crockford_base32)
}
fn is_crockford_base32(c: char) -> bool {
matches!(c, '0'..='9' | 'A'..='H' | 'J'..='K' | 'M'..='N' | 'P'..='T' | 'V'..='Z')
}
fn is_canonical_uuid(id: &str) -> bool {
if id.len() != 36 {
return false;
}
let bytes = id.as_bytes();
for (i, b) in bytes.iter().enumerate() {
let expect_hyphen = matches!(i, 8 | 13 | 18 | 23);
if expect_hyphen {
if *b != b'-' {
return false;
}
} else if !b.is_ascii_hexdigit() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_strict_accepts_ulid() {
let policy = SessionIdPolicy::default_strict();
assert!(policy.validate("01HRT7K6P6X5Q9M0V8YQ4N7TBC").is_ok());
}
#[test]
fn default_strict_accepts_uuid() {
let policy = SessionIdPolicy::default_strict();
assert!(policy
.validate("550e8400-e29b-41d4-a716-446655440000")
.is_ok());
}
#[test]
fn default_strict_rejects_disallowed_format() {
let policy = SessionIdPolicy::default_strict();
assert_eq!(
policy.validate("session-1"),
Err(SessionIdError::DisallowedFormat)
);
}
#[test]
fn empty_always_rejected() {
let policy = SessionIdPolicy::default_strict();
assert_eq!(policy.validate(""), Err(SessionIdError::Empty));
}
#[test]
fn ulid_with_invalid_first_char_is_rejected() {
let policy = SessionIdPolicy::default_strict();
assert_eq!(
policy.validate("ZZZZZZZZZZZZZZZZZZZZZZZZZZ"),
Err(SessionIdError::DisallowedFormat)
);
}
#[test]
fn custom_format_passes_only_with_lowered_entropy_floor() {
let re = Regex::new(r"^sess-[a-z0-9]{8}$").unwrap();
let strict = SessionIdPolicy::new(80, vec![SessionIdFormat::Custom(re.clone())]);
assert_eq!(
strict.validate("sess-abcd1234"),
Err(SessionIdError::InsufficientEntropy {
required: 80,
actual: 0
})
);
let relaxed = SessionIdPolicy::new(0, vec![SessionIdFormat::Custom(re)]);
assert!(relaxed.validate("sess-abcd1234").is_ok());
}
}