smos_domain/value_objects/
memory_key.rs1use crate::error::DomainError;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
15pub struct MemoryKey(String);
16
17impl MemoryKey {
18 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 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 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}