1use std::fs;
22use std::path::{Path, PathBuf};
23
24const WINDOWS_RESERVED: [&str; 22] = [
27 "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
28 "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
29];
30
31#[derive(Debug, Clone)]
33pub struct MemoryEntry {
34 pub name: String,
36 pub modified: std::time::SystemTime,
38}
39
40#[derive(Debug, thiserror::Error)]
42pub enum FileMemoryError {
43 #[error("unsafe memory name `{name}`: {reason}")]
45 UnsafeName {
46 name: String,
48 reason: String,
50 },
51 #[error("memory `{0}` already exists")]
53 AlreadyExists(String),
54 #[error("memory `{0}` not found")]
56 NotFound(String),
57 #[error("old text not found in memory `{0}`")]
59 OldTextNotFound(String),
60 #[error("I/O error while accessing `{path}`: {source}")]
62 Io {
63 path: String,
65 #[source]
67 source: std::io::Error,
68 },
69}
70
71impl From<std::io::Error> for FileMemoryError {
72 fn from(source: std::io::Error) -> Self {
73 FileMemoryError::Io {
74 path: "<unknown>".to_string(),
75 source,
76 }
77 }
78}
79
80fn validated_name(name: &str) -> Result<String, FileMemoryError> {
83 if name.is_empty() {
84 return Err(unsafe_name(name, "name is empty"));
85 }
86 if name == "." || name == ".." {
87 return Err(unsafe_name(name, "path traversal"));
88 }
89 if name.contains('/') || name.contains('\\') {
90 return Err(unsafe_name(name, "path separators are not allowed"));
91 }
92 if name.contains(':') {
93 return Err(unsafe_name(
94 name,
95 "drive/stream designators (`:`) are not allowed",
96 ));
97 }
98 if name.trim() != name {
99 return Err(unsafe_name(
100 name,
101 "leading/trailing whitespace is not allowed",
102 ));
103 }
104 if name.contains('\0') {
105 return Err(unsafe_name(name, "NUL byte is not allowed"));
106 }
107 if name.len() > 255 {
108 return Err(unsafe_name(name, "name too long"));
109 }
110 let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase();
111 if WINDOWS_RESERVED.contains(&stem.as_str()) {
112 return Err(unsafe_name(name, "Windows reserved device name"));
113 }
114 Ok(name.to_string())
115}
116
117fn unsafe_name(name: &str, reason: &str) -> FileMemoryError {
119 FileMemoryError::UnsafeName {
120 name: name.to_string(),
121 reason: reason.to_string(),
122 }
123}
124
125pub struct FileMemoryStore {
127 root: PathBuf,
128}
129
130impl std::fmt::Debug for FileMemoryStore {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("FileMemoryStore")
133 .field("root", &self.root)
134 .finish()
135 }
136}
137
138impl FileMemoryStore {
139 pub fn new(root: impl Into<PathBuf>) -> Result<Self, FileMemoryError> {
142 let root = root.into();
143 fs::create_dir_all(&root)?;
144 let root = root.canonicalize().map_err(|source| FileMemoryError::Io {
145 path: root.display().to_string(),
146 source,
147 })?;
148 Ok(Self { root })
149 }
150
151 pub fn root(&self) -> &Path {
153 &self.root
154 }
155
156 fn path_for(&self, name: &str) -> Result<PathBuf, FileMemoryError> {
158 let name = validated_name(name)?;
159 Ok(self.root.join(format!("{name}.md")))
160 }
161
162 pub fn create(&self, name: &str, content: &str) -> Result<(), FileMemoryError> {
164 let path = self.path_for(name)?;
165 if path.exists() {
166 return Err(FileMemoryError::AlreadyExists(name.to_string()));
167 }
168 fs::write(&path, content).map_err(map_io(&path))?;
169 Ok(())
170 }
171
172 pub fn view(&self, name: &str) -> Result<String, FileMemoryError> {
174 let path = self.path_for(name)?;
175 if !path.exists() {
176 return Err(FileMemoryError::NotFound(name.to_string()));
177 }
178 fs::read_to_string(&path).map_err(map_io(&path))
179 }
180
181 pub fn write(&self, name: &str, content: &str) -> Result<(), FileMemoryError> {
186 let path = self.path_for(name)?;
187 fs::write(&path, content).map_err(map_io(&path))?;
188 Ok(())
189 }
190
191 pub fn append(&self, name: &str, content: &str) -> Result<(), FileMemoryError> {
193 let path = self.path_for(name)?;
194 if !path.exists() {
195 return Err(FileMemoryError::NotFound(name.to_string()));
196 }
197 let mut file = fs::OpenOptions::new()
198 .append(true)
199 .open(&path)
200 .map_err(map_io(&path))?;
201 std::io::Write::write_all(&mut file, content.as_bytes()).map_err(map_io(&path))?;
202 Ok(())
203 }
204
205 pub fn str_replace(&self, name: &str, old: &str, new: &str) -> Result<(), FileMemoryError> {
210 let path = self.path_for(name)?;
211 if !path.exists() {
212 return Err(FileMemoryError::NotFound(name.to_string()));
213 }
214 let content = fs::read_to_string(&path).map_err(map_io(&path))?;
215 if !content.contains(old) {
216 return Err(FileMemoryError::OldTextNotFound(name.to_string()));
217 }
218 let replaced = content.replacen(old, new, 1);
220 fs::write(&path, replaced).map_err(map_io(&path))?;
221 Ok(())
222 }
223
224 pub fn rename(&self, name: &str, new_name: &str) -> Result<(), FileMemoryError> {
226 let from = self.path_for(name)?;
227 let to = self.path_for(new_name)?;
228 if !from.exists() {
229 return Err(FileMemoryError::NotFound(name.to_string()));
230 }
231 if to.exists() {
232 return Err(FileMemoryError::AlreadyExists(new_name.to_string()));
233 }
234 fs::rename(&from, &to).map_err(map_io(&from))?;
235 Ok(())
236 }
237
238 pub fn delete(&self, name: &str) -> Result<(), FileMemoryError> {
240 let path = self.path_for(name)?;
241 if !path.exists() {
242 return Err(FileMemoryError::NotFound(name.to_string()));
243 }
244 fs::remove_file(&path).map_err(map_io(&path))?;
245 Ok(())
246 }
247
248 pub fn list(&self) -> Result<Vec<MemoryEntry>, FileMemoryError> {
250 let mut entries = Vec::new();
251 for entry in fs::read_dir(&self.root)? {
252 let entry = entry?;
253 let path = entry.path();
254 if path.extension().and_then(|e| e.to_str()) != Some("md") {
255 continue;
256 }
257 let name = path
258 .file_stem()
259 .and_then(|s| s.to_str())
260 .unwrap_or_default()
261 .to_string();
262 let modified = entry
263 .metadata()?
264 .modified()
265 .unwrap_or(std::time::UNIX_EPOCH);
266 entries.push(MemoryEntry { name, modified });
267 }
268 entries.sort_by(|a, b| b.modified.cmp(&a.modified));
269 Ok(entries)
270 }
271}
272
273fn map_io(path: &Path) -> impl FnOnce(std::io::Error) -> FileMemoryError + '_ {
275 move |source| FileMemoryError::Io {
276 path: path.display().to_string(),
277 source,
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn store() -> (tempfile::TempDir, FileMemoryStore) {
286 let dir = tempfile::tempdir().unwrap();
287 let store = FileMemoryStore::new(dir.path()).unwrap();
288 (dir, store)
289 }
290
291 #[test]
292 fn create_view_roundtrip() {
293 let (_d, s) = store();
294 s.create("facts", "# Facts\n\n- Rust is fast\n").unwrap();
295 assert_eq!(s.view("facts").unwrap(), "# Facts\n\n- Rust is fast\n");
296 }
297
298 #[test]
299 fn create_rejects_duplicate() {
300 let (_d, s) = store();
301 s.create("a", "one").unwrap();
302 assert!(matches!(
303 s.create("a", "two"),
304 Err(FileMemoryError::AlreadyExists(_))
305 ));
306 }
307
308 #[test]
309 fn append_adds_to_existing() {
310 let (_d, s) = store();
311 s.create("a", "first\n").unwrap();
312 s.append("a", "second\n").unwrap();
313 assert_eq!(s.view("a").unwrap(), "first\nsecond\n");
314 }
315
316 #[test]
317 fn append_requires_existing() {
318 let (_d, s) = store();
319 assert!(matches!(
320 s.append("nope", "x"),
321 Err(FileMemoryError::NotFound(_))
322 ));
323 }
324
325 #[test]
326 fn str_replace_is_single_and_explicit() {
327 let (_d, s) = store();
328 s.create("a", "x x x").unwrap();
329 s.str_replace("a", "x", "y").unwrap();
330 assert_eq!(s.view("a").unwrap(), "y x x");
331 assert!(matches!(
333 s.str_replace("a", "zzz", "q"),
334 Err(FileMemoryError::OldTextNotFound(_))
335 ));
336 }
337
338 #[test]
339 fn rename_moves_content() {
340 let (_d, s) = store();
341 s.create("a", "data").unwrap();
342 s.rename("a", "b").unwrap();
343 assert!(matches!(s.view("a"), Err(FileMemoryError::NotFound(_))));
344 assert_eq!(s.view("b").unwrap(), "data");
345 }
346
347 #[test]
348 fn rename_onto_existing_fails() {
349 let (_d, s) = store();
350 s.create("a", "x").unwrap();
351 s.create("b", "y").unwrap();
352 assert!(matches!(
353 s.rename("a", "b"),
354 Err(FileMemoryError::AlreadyExists(_))
355 ));
356 }
357
358 #[test]
359 fn delete_removes() {
360 let (_d, s) = store();
361 s.create("a", "x").unwrap();
362 s.delete("a").unwrap();
363 assert!(matches!(s.view("a"), Err(FileMemoryError::NotFound(_))));
364 assert!(s.list().unwrap().is_empty());
365 }
366
367 #[test]
368 fn list_newest_first() {
369 let (_d, s) = store();
370 s.create("old", "1").unwrap();
371 s.create("new", "2").unwrap();
372 let names: Vec<String> = s.list().unwrap().into_iter().map(|e| e.name).collect();
373 assert_eq!(names, vec!["new", "old"]);
374 }
375
376 #[test]
379 fn traversal_is_rejected() {
380 let (_d, s) = store();
381 for bad in [
382 "../evil",
383 "..",
384 ".",
385 "a/../evil",
386 "a\\..\\evil",
387 "C:\\evil",
388 "/etc/passwd",
389 "a:b",
390 ] {
391 assert!(
392 matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
393 "expected rejection for {bad:?}"
394 );
395 }
396 }
397
398 #[test]
399 fn reserved_windows_names_rejected() {
400 let (_d, s) = store();
401 for bad in ["CON", "con", "PRN", "NUL", "COM1", "LPT9", "CON.txt"] {
402 assert!(
403 matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
404 "expected rejection for {bad:?}"
405 );
406 }
407 }
408
409 #[test]
410 fn empty_and_padded_names_rejected() {
411 let (_d, s) = store();
412 for bad in ["", " closed"] {
413 assert!(
414 matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
415 "expected rejection for {bad:?}"
416 );
417 }
418 }
419}