//! Validated session identifier newtype. //! //! [`SessionId`] wraps a `String` that has been checked for path-traversal //! characters. Construction via `SessionId::new(str)` validates the input //! once; the guarantee is then enforced by the type system for all //! downstream use. //! //! # Validation rules //! //! - Must not be empty //! - Must only contain alphanumeric characters, hyphens, and underscores use serde::{Deserialize, Serialize}; use std::fmt; use std::path::Path; use std::path::PathBuf; /// A validated session identifier. /// /// Guarantees the inner string is non-empty and contains no path-traversal /// characters (`/`, `\\`, `..`) or other unsafe delimiters. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct SessionId(String); impl SessionId { /// Validate and construct a `SessionId`. /// /// Returns `Err(msg)` if the input contains path separators, `..`, or /// is empty. pub fn new(id: &str) -> Result { if id.is_empty() { return Err("session id must not be empty".to_string()); } if id.contains('/') || id.contains('\\') || id.contains("..") { return Err(format!( "session id '{id}' must not contain path separators" )); } Ok(SessionId(id.to_string())) } /// Return the underlying string. pub fn as_str(&self) -> &str { &self.0 } /// Return the underlying owned string. pub fn into_string(self) -> String { self.0 } /// Append this session id as a component of `base_dir`, yielding /// `base_dir / self.0`. /// /// Safe because the id has been validated to contain no path separators. pub fn join_to(&self, base_dir: &Path) -> PathBuf { base_dir.join(&self.0) } } impl AsRef for SessionId { fn as_ref(&self) -> &str { &self.0 } } impl fmt::Display for SessionId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl From for String { fn from(sid: SessionId) -> Self { sid.0 } } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_uuids() { assert!(SessionId::new("550e8400-e29b-41d4-a716-446655440000").is_ok()); assert!(SessionId::new("my-session_123").is_ok()); } #[test] fn test_rejects_path_traversal() { assert!(SessionId::new("../etc/passwd").is_err()); assert!(SessionId::new("foo/../../bar").is_err()); assert!(SessionId::new("foo\\..\\bar").is_err()); } #[test] fn test_rejects_empty() { assert!(SessionId::new("").is_err()); } #[test] fn test_into_string() { let sid = SessionId::new("abc-123").unwrap(); assert_eq!(sid.into_string(), "abc-123"); } }