gaze_mcp_core/
session_id.rs1use regex::Regex;
16use thiserror::Error;
17
18#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub enum SessionIdFormat {
27 Ulid,
30 Uuid,
33 Custom(Regex),
37}
38
39impl SessionIdFormat {
40 pub fn matches(&self, id: &str) -> bool {
42 match self {
43 Self::Ulid => is_canonical_ulid(id),
44 Self::Uuid => is_canonical_uuid(id),
45 Self::Custom(re) => re.is_match(id),
46 }
47 }
48
49 pub fn effective_entropy_bits(&self) -> u32 {
54 match self {
55 Self::Ulid => 80,
56 Self::Uuid => 122,
57 Self::Custom(_) => 0,
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct SessionIdPolicy {
69 pub min_entropy_bits: u32,
71 pub format_whitelist: Vec<SessionIdFormat>,
73}
74
75impl SessionIdPolicy {
76 pub fn new(min_entropy_bits: u32, format_whitelist: Vec<SessionIdFormat>) -> Self {
78 Self {
79 min_entropy_bits,
80 format_whitelist,
81 }
82 }
83
84 pub fn default_strict() -> Self {
86 Self {
87 min_entropy_bits: 80,
88 format_whitelist: vec![SessionIdFormat::Ulid, SessionIdFormat::Uuid],
89 }
90 }
91
92 pub fn validate(&self, id: &str) -> Result<(), SessionIdError> {
95 if id.is_empty() {
96 return Err(SessionIdError::Empty);
97 }
98 for fmt in &self.format_whitelist {
99 if fmt.matches(id) {
100 let bits = fmt.effective_entropy_bits();
101 if bits < self.min_entropy_bits {
102 return Err(SessionIdError::InsufficientEntropy {
103 required: self.min_entropy_bits,
104 actual: bits,
105 });
106 }
107 return Ok(());
108 }
109 }
110 Err(SessionIdError::DisallowedFormat)
111 }
112}
113
114#[derive(Debug, Error, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum SessionIdError {
118 #[error("session id is empty")]
120 Empty,
121 #[error("session id does not match any whitelisted format")]
123 DisallowedFormat,
124 #[error("session id entropy {actual} bits is below required floor {required}")]
127 InsufficientEntropy {
128 required: u32,
130 actual: u32,
132 },
133}
134
135fn is_canonical_ulid(id: &str) -> bool {
136 if id.len() != 26 {
137 return false;
138 }
139 let mut chars = id.chars();
140 let first = match chars.next() {
141 Some(c) => c,
142 None => return false,
143 };
144 if !matches!(first, '0'..='7') {
145 return false;
146 }
147 if !is_crockford_base32(first) {
148 return false;
149 }
150 chars.all(is_crockford_base32)
151}
152
153fn is_crockford_base32(c: char) -> bool {
154 matches!(c, '0'..='9' | 'A'..='H' | 'J'..='K' | 'M'..='N' | 'P'..='T' | 'V'..='Z')
155}
156
157fn is_canonical_uuid(id: &str) -> bool {
158 if id.len() != 36 {
159 return false;
160 }
161 let bytes = id.as_bytes();
162 for (i, b) in bytes.iter().enumerate() {
163 let expect_hyphen = matches!(i, 8 | 13 | 18 | 23);
164 if expect_hyphen {
165 if *b != b'-' {
166 return false;
167 }
168 } else if !b.is_ascii_hexdigit() {
169 return false;
170 }
171 }
172 true
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn default_strict_accepts_ulid() {
181 let policy = SessionIdPolicy::default_strict();
182 assert!(policy.validate("01HRT7K6P6X5Q9M0V8YQ4N7TBC").is_ok());
183 }
184
185 #[test]
186 fn default_strict_accepts_uuid() {
187 let policy = SessionIdPolicy::default_strict();
188 assert!(policy
189 .validate("550e8400-e29b-41d4-a716-446655440000")
190 .is_ok());
191 }
192
193 #[test]
194 fn default_strict_rejects_disallowed_format() {
195 let policy = SessionIdPolicy::default_strict();
196 assert_eq!(
197 policy.validate("session-1"),
198 Err(SessionIdError::DisallowedFormat)
199 );
200 }
201
202 #[test]
203 fn empty_always_rejected() {
204 let policy = SessionIdPolicy::default_strict();
205 assert_eq!(policy.validate(""), Err(SessionIdError::Empty));
206 }
207
208 #[test]
209 fn ulid_with_invalid_first_char_is_rejected() {
210 let policy = SessionIdPolicy::default_strict();
211 assert_eq!(
213 policy.validate("ZZZZZZZZZZZZZZZZZZZZZZZZZZ"),
214 Err(SessionIdError::DisallowedFormat)
215 );
216 }
217
218 #[test]
219 fn custom_format_passes_only_with_lowered_entropy_floor() {
220 let re = Regex::new(r"^sess-[a-z0-9]{8}$").unwrap();
221 let strict = SessionIdPolicy::new(80, vec![SessionIdFormat::Custom(re.clone())]);
223 assert_eq!(
224 strict.validate("sess-abcd1234"),
225 Err(SessionIdError::InsufficientEntropy {
226 required: 80,
227 actual: 0
228 })
229 );
230 let relaxed = SessionIdPolicy::new(0, vec![SessionIdFormat::Custom(re)]);
232 assert!(relaxed.validate("sess-abcd1234").is_ok());
233 }
234}