Skip to main content

smos_domain/value_objects/
memory_key.rs

1//! `MemoryKey` โ€” path-traversal-safe namespace identifier.
2//!
3//! The key is also a directory name on disk (ยง6 storage layout) and a ChromaDB
4//! collection name, so it must reject anything that could escape its directory:
5//! no `/`, no `\`, no `..`, no leading dots. The validation rules below mirror
6//! what the adapter layer uses when reading/writing markdown files.
7
8use crate::error::DomainError;
9use serde::{Deserialize, Serialize};
10
11/// A safe namespace for memories. Resolved from the requested person name
12/// by the application-layer `route_request` helper
13/// (`request.model = "bob" โ†’ MemoryKey("bob")`).
14#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct MemoryKey(String);
16
17impl MemoryKey {
18    /// Validate and wrap a raw string.
19    ///
20    /// Rules: non-empty, first char alphanumeric, remaining chars in
21    /// `[A-Za-z0-9_.-]`, no path separators, no `..`, no leading dot.
22    pub fn from_raw(s: &str) -> Result<Self, DomainError> {
23        if is_safe_memory_key(s) {
24            Ok(Self(s.to_string()))
25        } else {
26            Err(DomainError::UnsafeMemoryKey(s.to_string()))
27        }
28    }
29
30    /// Default namespace used when the model name carries no prefix.
31    pub fn shared() -> Self {
32        Self("shared".to_string())
33    }
34
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40fn is_safe_memory_key(s: &str) -> bool {
41    if s.is_empty() || s.len() > 64 {
42        return false;
43    }
44    let mut chars = s.chars();
45    let Some(first) = chars.next() else {
46        return false;
47    };
48    if !first.is_ascii_alphanumeric() {
49        return false;
50    }
51    // Reject path-traversal sequences and path separators. `..` (as substring
52    // or whole value) covers the parent-directory attack; explicit `== ".."`
53    // is unnecessary because it is subsumed by `contains("..")`.
54    if s.contains("..") || s.contains('/') || s.contains('\\') {
55        return false;
56    }
57    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
58}
59
60impl std::fmt::Display for MemoryKey {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(&self.0)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::error::DomainError;
70
71    #[test]
72    fn accepts_simple_alphanumeric() {
73        assert!(MemoryKey::from_raw("origa").is_ok());
74    }
75
76    #[test]
77    fn accepts_shared_keyword() {
78        assert_eq!(MemoryKey::from_raw("shared").unwrap().as_str(), "shared");
79    }
80
81    #[test]
82    fn accepts_dotted_and_dashed_names() {
83        assert!(MemoryKey::from_raw("my-project_v2").is_ok());
84        assert!(MemoryKey::from_raw("analog.finder").is_ok());
85        assert!(MemoryKey::from_raw("a1-b2.c3").is_ok());
86    }
87
88    #[test]
89    fn rejects_empty() {
90        assert!(matches!(
91            MemoryKey::from_raw(""),
92            Err(DomainError::UnsafeMemoryKey(_))
93        ));
94    }
95
96    #[test]
97    fn rejects_dot_dot() {
98        assert!(MemoryKey::from_raw("..").is_err());
99    }
100
101    #[test]
102    fn rejects_path_traversal_with_slash() {
103        assert!(MemoryKey::from_raw("a/b").is_err());
104        assert!(MemoryKey::from_raw("/etc/passwd").is_err());
105    }
106
107    #[test]
108    fn rejects_path_traversal_with_backslash() {
109        assert!(MemoryKey::from_raw("a\\b").is_err());
110    }
111
112    #[test]
113    fn rejects_embedded_dot_dot() {
114        assert!(MemoryKey::from_raw("a..b").is_err());
115    }
116
117    #[test]
118    fn rejects_leading_dot() {
119        assert!(MemoryKey::from_raw(".hidden").is_err());
120    }
121
122    #[test]
123    fn rejects_leading_dash() {
124        assert!(MemoryKey::from_raw("-dash").is_err());
125    }
126
127    #[test]
128    fn rejects_spaces_and_special() {
129        assert!(MemoryKey::from_raw("hello world").is_err());
130        assert!(MemoryKey::from_raw("origa!").is_err());
131    }
132
133    #[test]
134    fn shared_default_is_canonical() {
135        assert_eq!(MemoryKey::shared().as_str(), "shared");
136    }
137
138    #[test]
139    fn display_matches_as_str() {
140        let key = MemoryKey::from_raw("origa").unwrap();
141        assert_eq!(key.to_string(), "origa");
142    }
143}