#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PageDir {
Entities,
Concepts,
Synthesis,
}
impl PageDir {
#[must_use]
pub fn as_subdir(self) -> &'static str {
match self {
Self::Entities => "entities",
Self::Concepts => "concepts",
Self::Synthesis => "synthesis",
}
}
#[must_use]
pub fn all() -> [Self; 3] {
[Self::Entities, Self::Concepts, Self::Synthesis]
}
}
const MAX_ID_LEN: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Id {
pub raw: String,
pub dir: PageDir,
}
impl Id {
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
if raw.len() > MAX_ID_LEN {
return None;
}
let (dir, suffix) = Self::split_prefix(raw)?;
if !is_valid_suffix(suffix) {
return None;
}
Some(Self { raw: raw.to_owned(), dir })
}
#[must_use]
pub fn dir_for(raw: &str) -> Option<PageDir> {
Self::split_prefix(raw).map(|(dir, _)| dir)
}
fn split_prefix(raw: &str) -> Option<(PageDir, &str)> {
raw.strip_prefix("ent-")
.map(|s| (PageDir::Entities, s))
.or_else(|| raw.strip_prefix("con-").map(|s| (PageDir::Concepts, s)))
.or_else(|| raw.strip_prefix("syn-").map(|s| (PageDir::Synthesis, s)))
}
}
fn is_valid_suffix(suffix: &str) -> bool {
if suffix.is_empty() || suffix.starts_with('-') || suffix.ends_with('-') {
return false;
}
let mut prev_hyphen = false;
for c in suffix.chars() {
let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-';
if !ok || (c == '-' && prev_hyphen) {
return false;
}
prev_hyphen = c == '-';
}
true
}
impl std::fmt::Display for Id {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
const MAX_SESSION_ID_LEN: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SessionId {
pub raw: String,
}
impl SessionId {
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
if raw.is_empty() || raw.len() > MAX_SESSION_ID_LEN {
return None;
}
if raw.starts_with('.') || raw.contains("..") {
return None;
}
if !raw.chars().all(is_session_char) {
return None;
}
Some(Self { raw: raw.to_owned() })
}
}
fn is_session_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
}
impl std::fmt::Display for SessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.raw)
}
}
#[cfg(test)]
mod tests {
use super::{Id, PageDir, SessionId};
#[test]
fn parses_entity_prefix() {
let id = Id::parse("ent-redis").unwrap();
assert_eq!(id.dir, PageDir::Entities);
}
#[test]
fn parses_concept_prefix() {
let id = Id::parse("con-rag").unwrap();
assert_eq!(id.dir, PageDir::Concepts);
}
#[test]
fn parses_synthesis_prefix() {
let id = Id::parse("syn-x").unwrap();
assert_eq!(id.dir, PageDir::Synthesis);
}
#[test]
fn rejects_unknown_prefix() {
assert!(Id::parse("xxx-broken").is_none());
}
#[test]
fn rejects_empty_suffix() {
assert!(Id::parse("ent-").is_none());
assert!(Id::parse("con-").is_none());
assert!(Id::parse("syn-").is_none());
}
#[test]
fn rejects_path_traversal_segments() {
assert!(Id::parse("ent-..").is_none());
assert!(Id::parse("ent-../../etc/passwd").is_none());
assert!(Id::parse("ent-/abs/path").is_none());
assert!(Id::parse("ent-..\\..\\etc").is_none());
assert!(Id::parse("ent-foo/bar").is_none());
}
#[test]
fn rejects_uppercase_and_unicode() {
assert!(Id::parse("ent-Redis").is_none());
assert!(Id::parse("ent-café").is_none());
assert!(Id::parse("ent-foo\0bar").is_none());
}
#[test]
fn rejects_hyphen_edges_and_doubles() {
assert!(Id::parse("ent--foo").is_none());
assert!(Id::parse("ent-foo-").is_none());
assert!(Id::parse("ent-foo--bar").is_none());
}
#[test]
fn rejects_overlong_id() {
let long_suffix: String = "a".repeat(200);
let raw = format!("ent-{long_suffix}");
assert!(Id::parse(&raw).is_none());
}
#[test]
fn accepts_multi_segment_slug() {
let id = Id::parse("syn-redis-vs-memcached").unwrap();
assert_eq!(id.dir, PageDir::Synthesis);
assert_eq!(id.raw, "syn-redis-vs-memcached");
}
#[test]
fn accepts_digits_and_mixed() {
assert!(Id::parse("ent-redis-7").is_some());
assert!(Id::parse("con-rfc-3339").is_some());
}
#[test]
fn session_id_accepts_realistic_shapes() {
assert!(SessionId::parse("sess-001").is_some());
assert!(SessionId::parse("sess-A").is_some());
assert!(SessionId::parse("550e8400-e29b-41d4-a716-446655440000").is_some());
assert!(SessionId::parse("claude_2025_05_12").is_some());
assert!(SessionId::parse("Session.001").is_some());
}
#[test]
fn session_id_rejects_path_traversal() {
assert!(SessionId::parse("../../etc/foo").is_none());
assert!(SessionId::parse("..").is_none());
assert!(SessionId::parse("foo..bar").is_none());
assert!(SessionId::parse("/abs/path").is_none());
assert!(SessionId::parse("a/b").is_none());
assert!(SessionId::parse("a\\b").is_none());
}
#[test]
fn session_id_rejects_hidden_and_empty() {
assert!(SessionId::parse("").is_none());
assert!(SessionId::parse(".hidden").is_none());
}
#[test]
fn session_id_rejects_non_ascii_and_nul() {
assert!(SessionId::parse("sess-café").is_none());
assert!(SessionId::parse("sess\0bar").is_none());
}
#[test]
fn session_id_rejects_overlong() {
let raw = "a".repeat(200);
assert!(SessionId::parse(&raw).is_none());
}
}