Skip to main content

gaze_mcp_core/
session_id.rs

1//! Session-id format + entropy policy for the chokepoint dispatcher.
2//!
3//! Adopters sometimes pass an external session id from the transport into
4//! the manifest (so audit rows correlate with an upstream conversation,
5//! request, or trace id). [`SessionIdPolicy`] gives gaze-mcp-core a fail-closed
6//! way to validate those ids before they reach the [`crate::manifest::ManifestStore`].
7//!
8//! The default policy ([`SessionIdPolicy::default_strict`]) accepts only
9//! ULID and canonical UUID strings and requires at least 80 bits of effective
10//! entropy. Adopters can relax via [`SessionIdFormat::Custom`] but the entropy
11//! floor still applies — Custom formats contribute zero estimated entropy by
12//! default, so a strict policy will reject them unless the adopter lowers
13//! `min_entropy_bits` explicitly.
14
15use regex::Regex;
16use thiserror::Error;
17
18/// Format whitelist entry for [`SessionIdPolicy`].
19///
20/// Built-in [`Self::Ulid`] and [`Self::Uuid`] cases carry conservative entropy
21/// estimates; [`Self::Custom`] takes an adopter-supplied regex and contributes
22/// zero entropy by default (the policy's `min_entropy_bits` decides whether
23/// it passes).
24#[derive(Debug, Clone)]
25#[non_exhaustive]
26pub enum SessionIdFormat {
27    /// Crockford Base32 ULID (26 ASCII characters; first character `0..='7'`).
28    /// Effective entropy: 80 bits (random component).
29    Ulid,
30    /// Canonical hyphenated UUID (`8-4-4-4-12` lowercase or uppercase hex).
31    /// Effective entropy: 122 bits (assuming v4).
32    Uuid,
33    /// Adopter-defined format. Validation is just the regex match; entropy
34    /// estimation is left to the adopter (defaults to 0 bits — combine with
35    /// a lower `min_entropy_bits` or an explicit override on the policy).
36    Custom(Regex),
37}
38
39impl SessionIdFormat {
40    /// True if `id` matches this format.
41    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    /// Conservative lower bound on the effective entropy of an id matching
50    /// this format. `Custom` returns 0 — adopters with a tight regex can lower
51    /// `SessionIdPolicy::min_entropy_bits` to a value the regex's structure
52    /// guarantees, or wrap their own pre-validation upstream.
53    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/// Policy for accepting transport-supplied session ids.
63///
64/// Build with [`Self::default_strict`] for the recommended posture (ULID +
65/// UUID only, 80-bit entropy floor) or construct manually for custom shapes.
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct SessionIdPolicy {
69    /// Minimum effective entropy (bits) required for the matched format.
70    pub min_entropy_bits: u32,
71    /// Ordered list of acceptable formats. The first matching format wins.
72    pub format_whitelist: Vec<SessionIdFormat>,
73}
74
75impl SessionIdPolicy {
76    /// Construct an explicit policy.
77    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    /// Strict fail-closed default: ULID + UUID only, 80-bit entropy floor.
85    pub fn default_strict() -> Self {
86        Self {
87            min_entropy_bits: 80,
88            format_whitelist: vec![SessionIdFormat::Ulid, SessionIdFormat::Uuid],
89        }
90    }
91
92    /// Validate `id` against the policy. Returns `Ok(())` if any whitelisted
93    /// format matches AND that format's effective entropy meets the floor.
94    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/// Reasons [`SessionIdPolicy::validate`] rejects an id.
115#[derive(Debug, Error, PartialEq, Eq)]
116#[non_exhaustive]
117pub enum SessionIdError {
118    /// The session id was empty. Always fails closed regardless of policy.
119    #[error("session id is empty")]
120    Empty,
121    /// No whitelisted [`SessionIdFormat`] matched the id.
122    #[error("session id does not match any whitelisted format")]
123    DisallowedFormat,
124    /// A whitelisted format matched but its effective entropy is below the
125    /// policy's `min_entropy_bits` floor.
126    #[error("session id entropy {actual} bits is below required floor {required}")]
127    InsufficientEntropy {
128        /// Required minimum entropy bits per [`SessionIdPolicy::min_entropy_bits`].
129        required: u32,
130        /// Effective entropy of the matched format.
131        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        // Leading char `Z` exceeds the 0..7 range (ulids overflow guard).
212        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        // Strict floor (80 bits) rejects Custom even on a regex match.
222        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        // Adopter who has an out-of-band entropy guarantee can lower the floor.
231        let relaxed = SessionIdPolicy::new(0, vec![SessionIdFormat::Custom(re)]);
232        assert!(relaxed.validate("sess-abcd1234").is_ok());
233    }
234}