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_key(|entry| std::cmp::Reverse(entry.modified));
271 Ok(entries)
272 }
273}
274
275fn map_io(path: &Path) -> impl FnOnce(std::io::Error) -> FileMemoryError + '_ {
277 move |source| FileMemoryError::Io {
278 path: path.display().to_string(),
279 source,
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn store() -> (tempfile::TempDir, FileMemoryStore) {
288 let dir = tempfile::tempdir().unwrap();
289 let store = FileMemoryStore::new(dir.path()).unwrap();
290 (dir, store)
291 }
292
293 #[test]
294 fn create_view_roundtrip() {
295 let (_d, s) = store();
296 s.create("facts", "# Facts\n\n- Rust is fast\n").unwrap();
297 assert_eq!(s.view("facts").unwrap(), "# Facts\n\n- Rust is fast\n");
298 }
299
300 #[test]
301 fn create_rejects_duplicate() {
302 let (_d, s) = store();
303 s.create("a", "one").unwrap();
304 assert!(matches!(
305 s.create("a", "two"),
306 Err(FileMemoryError::AlreadyExists(_))
307 ));
308 }
309
310 #[test]
311 fn append_adds_to_existing() {
312 let (_d, s) = store();
313 s.create("a", "first\n").unwrap();
314 s.append("a", "second\n").unwrap();
315 assert_eq!(s.view("a").unwrap(), "first\nsecond\n");
316 }
317
318 #[test]
319 fn append_requires_existing() {
320 let (_d, s) = store();
321 assert!(matches!(
322 s.append("nope", "x"),
323 Err(FileMemoryError::NotFound(_))
324 ));
325 }
326
327 #[test]
328 fn str_replace_is_single_and_explicit() {
329 let (_d, s) = store();
330 s.create("a", "x x x").unwrap();
331 s.str_replace("a", "x", "y").unwrap();
332 assert_eq!(s.view("a").unwrap(), "y x x");
333 assert!(matches!(
335 s.str_replace("a", "zzz", "q"),
336 Err(FileMemoryError::OldTextNotFound(_))
337 ));
338 }
339
340 #[test]
341 fn rename_moves_content() {
342 let (_d, s) = store();
343 s.create("a", "data").unwrap();
344 s.rename("a", "b").unwrap();
345 assert!(matches!(s.view("a"), Err(FileMemoryError::NotFound(_))));
346 assert_eq!(s.view("b").unwrap(), "data");
347 }
348
349 #[test]
350 fn rename_onto_existing_fails() {
351 let (_d, s) = store();
352 s.create("a", "x").unwrap();
353 s.create("b", "y").unwrap();
354 assert!(matches!(
355 s.rename("a", "b"),
356 Err(FileMemoryError::AlreadyExists(_))
357 ));
358 }
359
360 #[test]
361 fn delete_removes() {
362 let (_d, s) = store();
363 s.create("a", "x").unwrap();
364 s.delete("a").unwrap();
365 assert!(matches!(s.view("a"), Err(FileMemoryError::NotFound(_))));
366 assert!(s.list().unwrap().is_empty());
367 }
368
369 #[test]
370 fn list_newest_first() {
371 let (_d, s) = store();
372 s.create("old", "1").unwrap();
373 s.create("new", "2").unwrap();
374 let names: Vec<String> = s.list().unwrap().into_iter().map(|e| e.name).collect();
375 assert_eq!(names, vec!["new", "old"]);
376 }
377
378 #[test]
381 fn traversal_is_rejected() {
382 let (_d, s) = store();
383 for bad in [
384 "../evil",
385 "..",
386 ".",
387 "a/../evil",
388 "a\\..\\evil",
389 "C:\\evil",
390 "/etc/passwd",
391 "a:b",
392 ] {
393 assert!(
394 matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
395 "expected rejection for {bad:?}"
396 );
397 }
398 }
399
400 #[test]
401 fn reserved_windows_names_rejected() {
402 let (_d, s) = store();
403 for bad in ["CON", "con", "PRN", "NUL", "COM1", "LPT9", "CON.txt"] {
404 assert!(
405 matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
406 "expected rejection for {bad:?}"
407 );
408 }
409 }
410
411 #[test]
412 fn empty_and_padded_names_rejected() {
413 let (_d, s) = store();
414 for bad in ["", " closed"] {
415 assert!(
416 matches!(s.create(bad, "x"), Err(FileMemoryError::UnsafeName { .. })),
417 "expected rejection for {bad:?}"
418 );
419 }
420 }
421}