1use std::cmp::Reverse;
8use std::collections::HashMap;
9use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use md5::{Digest as Md5Digest, Md5};
14
15use crate::types::{DIR_ARCHIVE, DIR_JOURNAL, DIR_MEDIA, DIR_USER_ROOT, FileEntry, FsError};
16
17const FORBIDDEN_CHARS: &[(&str, &str)] = &[
19 ("<", "<"),
20 (">", ">"),
21 (":", "꞉"),
22 ("\"", "″"),
23 ("|", "⼁"),
24 ("\\", "\"),
25 ("?", "?"),
26 ("*", "﹡"),
27 ("\x00", ""),
28 ("/", "/"),
29];
30
31pub const SYSTEM_DIRS: &[&str] = &["archive", "media", "journal", "insights", "img"];
33
34pub const SYSTEM_FILES: &[&str] = &[
36 "Chat.md", "Later.md", "Done.md", "Shop.md", "Watch.md", "Read.md",
37];
38
39const IGNORED_NAMES: &[&str] = &[".", "..", ".obsidian", ".gitignore", ".DS_Store", ".git"];
41
42#[derive(Clone, Debug)]
51pub struct VirtualFs {
52 root: PathBuf,
53 quota_kb: i64,
54}
55
56impl VirtualFs {
57 pub fn new(root: PathBuf) -> std::io::Result<Self> {
61 if !root.exists() {
62 std::fs::create_dir_all(&root)?;
63 }
64 Ok(Self { root, quota_kb: 0 })
65 }
66
67 pub fn with_quota(mut self, quota_kb: i64) -> Self {
69 self.quota_kb = quota_kb;
70 self
71 }
72
73 pub fn root(&self) -> &Path {
75 &self.root
76 }
77
78 pub fn quota_kb(&self) -> i64 {
80 self.quota_kb
81 }
82
83 pub fn safe_path(&self, dir: &str, filename: &str) -> Result<PathBuf, FsError> {
89 let dir_trimmed = dir.trim();
90 if dir_trimmed.starts_with("..") {
91 return Err(FsError::UnsafePath);
92 }
93
94 let relative: PathBuf = if dir == DIR_USER_ROOT {
95 if filename.is_empty() {
96 return Ok(self.root.clone());
97 }
98 PathBuf::from(filename)
99 } else {
100 PathBuf::from(dir).join(filename)
101 };
102
103 let rel_str = relative.to_string_lossy();
104 if rel_str.starts_with('/') || rel_str.starts_with("../") {
105 return Err(FsError::UnsafePath);
106 }
107
108 let full = self.root.join(&relative);
109
110 let stripped = full
112 .strip_prefix(&self.root)
113 .map_err(|_| FsError::UnsafePath)?;
114 let (normalized, escaped) = normalize_path(stripped);
115 if escaped || normalized.to_string_lossy().contains("..") {
116 return Err(FsError::UnsafePath);
117 }
118
119 Ok(self.root.join(&normalized))
120 }
121
122 pub fn read_path(&self, path: &str) -> Result<String, FsError> {
127 let (dir, filename) = split_posix_path(path);
128 self.read(dir, filename)
129 }
130
131 pub fn write_path(&self, path: &str, content: &str) -> Result<(), FsError> {
133 let (dir, filename) = split_posix_path(path);
134 self.write(dir, filename, content)
135 }
136
137 pub fn delete_path(&self, path: &str) -> Result<(), FsError> {
139 let (dir, filename) = split_posix_path(path);
140 self.del(dir, filename)
141 }
142
143 pub fn rename_path(&self, old_path: &str, new_path: &str) -> Result<(), FsError> {
145 let (old_dir, old_filename) = split_posix_path(old_path);
146 let (new_dir, new_filename) = split_posix_path(new_path);
147 self.rename(old_dir, old_filename, new_dir, new_filename)
148 }
149
150 pub fn exists_path(&self, path: &str) -> Result<bool, FsError> {
152 let (dir, filename) = split_posix_path(path);
153 self.exists(dir, filename)
154 }
155
156 pub fn mtime_path(&self, path: &str) -> Result<i64, FsError> {
158 let (dir, filename) = split_posix_path(path);
159 self.mtime(dir, filename)
160 }
161
162 pub fn exists(&self, dir: &str, filename: &str) -> Result<bool, FsError> {
166 let path = self.safe_path(dir, filename)?;
167 Ok(path.exists())
168 }
169
170 pub fn read(&self, dir: &str, filename: &str) -> Result<String, FsError> {
172 let path = self.safe_path(dir, filename)?;
173 let mut file = std::fs::File::open(&path)?;
174 let mut contents = String::new();
175 file.read_to_string(&mut contents)?;
176 Ok(contents)
177 }
178
179 pub fn write(&self, dir: &str, filename: &str, content: &str) -> Result<(), FsError> {
181 let path = self.safe_path(dir, filename)?;
182
183 if let Some(parent) = path.parent() {
184 std::fs::create_dir_all(parent)?;
185 }
186
187 if self.quota_kb > 0 {
188 let new_size = content.len() as i64;
189 let old_size = std::fs::metadata(&path)
190 .map(|m| m.len() as i64)
191 .unwrap_or(0);
192 let used = self.calculate_used_quota()?;
193 let available = (self.quota_kb * 1024) - used;
194 if (new_size - old_size) > available {
195 return Err(FsError::QuotaExceeded);
196 }
197 }
198
199 let mut file = std::fs::File::create(&path)?;
200 file.write_all(content.as_bytes())?;
201 Ok(())
202 }
203
204 pub fn read_bytes(&self, dir: &str, filename: &str) -> Result<Vec<u8>, FsError> {
206 let path = self.safe_path(dir, filename)?;
207 Ok(std::fs::read(&path)?)
208 }
209
210 pub fn write_bytes(&self, dir: &str, filename: &str, data: &[u8]) -> Result<(), FsError> {
213 let path = self.safe_path(dir, filename)?;
214
215 if let Some(parent) = path.parent() {
216 std::fs::create_dir_all(parent)?;
217 }
218
219 if self.quota_kb > 0 {
220 let new_size = data.len() as i64;
221 let old_size = std::fs::metadata(&path)
222 .map(|m| m.len() as i64)
223 .unwrap_or(0);
224 let used = self.calculate_used_quota()?;
225 let available = (self.quota_kb * 1024) - used;
226 if (new_size - old_size) > available {
227 return Err(FsError::QuotaExceeded);
228 }
229 }
230
231 std::fs::write(&path, data)?;
232 Ok(())
233 }
234
235 pub fn read_path_bytes(&self, path: &str) -> Result<Vec<u8>, FsError> {
237 let (dir, filename) = split_posix_path(path);
238 self.read_bytes(dir, filename)
239 }
240
241 pub fn write_path_bytes(&self, path: &str, data: &[u8]) -> Result<(), FsError> {
243 let (dir, filename) = split_posix_path(path);
244 self.write_bytes(dir, filename, data)
245 }
246
247 pub fn del(&self, dir: &str, filename: &str) -> Result<(), FsError> {
249 let path = self.safe_path(dir, filename)?;
250 std::fs::remove_file(&path)?;
251 Ok(())
252 }
253
254 pub fn rename(
256 &self,
257 old_dir: &str,
258 old_filename: &str,
259 new_dir: &str,
260 new_filename: &str,
261 ) -> Result<(), FsError> {
262 let old_path = self.safe_path(old_dir, old_filename)?;
263 let new_path = self.safe_path(new_dir, new_filename)?;
264 if let Some(parent) = new_path.parent() {
265 std::fs::create_dir_all(parent)?;
266 }
267 std::fs::rename(&old_path, &new_path)?;
268 Ok(())
269 }
270
271 pub fn make_dir(&self, dir: &str) -> Result<(), FsError> {
273 let path = self.safe_path(dir, "")?;
274 std::fs::create_dir_all(&path)?;
275 Ok(())
276 }
277
278 pub fn touch(&self, dir: &str, filename: &str) -> Result<(), FsError> {
280 let path = self.safe_path(dir, filename)?;
281 if path.exists() {
282 let now = SystemTime::now();
283 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(now))?;
284 } else {
285 self.write(dir, filename, "")?;
286 }
287 Ok(())
288 }
289
290 pub fn ctime(&self, dir: &str, filename: &str) -> Result<i64, FsError> {
294 let path = self.safe_path(dir, filename)?;
295 let meta = std::fs::metadata(&path)?;
296 Ok(mtime_to_ms(meta.modified()?))
297 }
298
299 pub fn mtime(&self, dir: &str, filename: &str) -> Result<i64, FsError> {
301 let path = self.safe_path(dir, filename)?;
302 let meta = std::fs::metadata(&path)?;
303 Ok(mtime_to_ms(meta.modified()?))
304 }
305
306 pub fn mtimes(&self, root: &str, extensions: &[&str]) -> Result<HashMap<String, i64>, FsError> {
308 let root_path = self.safe_path(root, "")?;
309 let mut result = HashMap::new();
310 self.walk_dir(&root_path, &root_path, extensions, &mut result)?;
311 Ok(result)
312 }
313
314 pub fn files_and_dirs(&self, dir: &str) -> Result<Vec<FileEntry>, FsError> {
318 let user_path = self.safe_path(dir, "")?;
319 if !user_path.exists() {
320 return Ok(vec![]);
321 }
322
323 let mut entries = Vec::new();
324 for entry in std::fs::read_dir(&user_path)? {
325 let entry = entry?;
326 let path = entry.path();
327 let name = path
328 .file_name()
329 .and_then(|n| n.to_str())
330 .unwrap_or("")
331 .to_string();
332
333 if IGNORED_NAMES.contains(&name.as_str()) {
334 continue;
335 }
336
337 let meta = std::fs::metadata(&path)?;
338 let is_dir = meta.is_dir();
339 let ctime = mtime_to_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
340 let hash = hash_filename(&name);
341 let display_name = display_name(&name);
342 let has_content = !is_dir && meta.len() > 0;
343
344 entries.push(FileEntry::new(
345 name,
346 hash,
347 display_name,
348 ctime,
349 has_content,
350 is_dir,
351 dir.to_string(),
352 ));
353 }
354 Ok(entries)
355 }
356
357 pub fn dirs(&self) -> Result<Vec<FileEntry>, FsError> {
359 Ok(self
360 .files_and_dirs(DIR_USER_ROOT)?
361 .into_iter()
362 .filter(|f| f.is_dir)
363 .collect())
364 }
365
366 pub fn is_multiline(&self, dir: &str, filename: &str) -> Result<bool, FsError> {
368 let content = self.read(dir, filename)?;
369 Ok(!content.trim().is_empty())
370 }
371
372 pub fn create_system_dirs(&self) -> Result<(), FsError> {
374 for dir in [DIR_ARCHIVE, DIR_MEDIA, DIR_JOURNAL] {
375 self.make_dir(dir)?;
376 }
377 Ok(())
378 }
379
380 pub fn unhash(&self, dir: &str, filename_hash: &str) -> Result<String, FsError> {
382 if dir == DIR_USER_ROOT && filename_hash == DIR_USER_ROOT {
383 return Ok(DIR_USER_ROOT.to_string());
384 }
385 let files = self.files_and_dirs(dir)?;
386 for file in &files {
387 if hash_filename(&file.name).starts_with(filename_hash) {
388 return Ok(file.name.clone());
389 }
390 }
391 for file in &files {
392 if file.name.starts_with(filename_hash) {
393 return Ok(file.name.clone());
394 }
395 }
396 Err(FsError::CannotUnhash)
397 }
398
399 pub fn search_files_by_name(&self, query: &str) -> Result<Vec<FileEntry>, FsError> {
401 let query_lower = query.to_lowercase().trim().to_string();
402 if query_lower.contains('/') {
403 return Err(FsError::UnsafePath);
404 }
405
406 let mut notes = Vec::new();
407 self.collect_md_files(&self.root, &self.root, &mut notes)?;
408
409 if !query_lower.is_empty() {
410 let matching: Vec<FileEntry> = notes
411 .iter()
412 .filter(|f| {
413 let top = f.parent_dir.split('/').next().unwrap_or("");
414 top.to_lowercase().starts_with(&query_lower)
415 || f.display_name.to_lowercase().contains(&query_lower)
416 })
417 .cloned()
418 .collect();
419 if !matching.is_empty() {
420 notes = matching;
421 }
422 }
423
424 notes.sort_by_key(|a| Reverse(a.ctime));
425 Ok(notes)
426 }
427
428 pub fn all_md_files(&self) -> Result<Vec<(String, i64)>, FsError> {
431 let mut result = Vec::new();
432 self.collect_md_paths(&self.root, &self.root, &mut result)?;
433 Ok(result)
434 }
435
436 #[allow(clippy::only_used_in_recursion)]
439 fn walk_dir(
440 &self,
441 root_path: &Path,
442 current_path: &Path,
443 extensions: &[&str],
444 result: &mut HashMap<String, i64>,
445 ) -> Result<(), FsError> {
446 if !current_path.is_dir() {
447 return Ok(());
448 }
449 for entry in std::fs::read_dir(current_path)? {
450 let entry = entry?;
451 let path = entry.path();
452 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
453
454 if filename.starts_with('.') {
455 continue;
456 }
457
458 if path.is_dir() {
459 self.walk_dir(root_path, &path, extensions, result)?;
460 } else {
461 if !extensions.is_empty() {
462 let ext = path
463 .extension()
464 .and_then(|e| e.to_str())
465 .map(|e| format!(".{e}"));
466 let ext_match = ext
467 .as_ref()
468 .map(|e| extensions.contains(&e.as_str()))
469 .unwrap_or(false);
470 if !ext_match {
471 continue;
472 }
473 }
474
475 let rel = path
476 .strip_prefix(root_path)
477 .map_err(|_| FsError::UnsafePath)?;
478 let display = rel.to_string_lossy();
479 let display_path = if display.starts_with('/') || display.starts_with('\\') {
480 display[1..].to_string()
481 } else {
482 display.to_string()
483 };
484
485 let meta = std::fs::metadata(&path)?;
486 result.insert(display_path, mtime_to_ms(meta.modified()?));
487 }
488 }
489 Ok(())
490 }
491
492 #[allow(clippy::only_used_in_recursion)]
493 fn collect_md_files(
494 &self,
495 root_path: &Path,
496 current_path: &Path,
497 files: &mut Vec<FileEntry>,
498 ) -> Result<(), FsError> {
499 if !current_path.is_dir() {
500 return Ok(());
501 }
502 for entry in std::fs::read_dir(current_path)? {
503 let entry = entry?;
504 let path = entry.path();
505 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
506
507 if path.is_dir() {
508 if filename.starts_with('.') {
509 continue;
510 }
511 self.collect_md_files(root_path, &path, files)?;
512 } else {
513 if !filename.ends_with(".md") || filename.starts_with('.') {
514 continue;
515 }
516
517 let meta = std::fs::metadata(&path)?;
518 let rel = path
519 .strip_prefix(root_path)
520 .map_err(|_| FsError::UnsafePath)?;
521 let parent = rel
522 .parent()
523 .map(|p| p.to_string_lossy().to_string())
524 .unwrap_or_default();
525 let parent_str = if parent.is_empty() || parent == "." {
526 DIR_USER_ROOT.to_string()
527 } else {
528 parent
529 };
530
531 let ctime = mtime_to_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
532 let hash = hash_filename(filename);
533 let display_name = display_name(filename);
534
535 files.push(FileEntry::new(
536 filename.to_string(),
537 hash,
538 display_name,
539 ctime,
540 meta.len() > 0,
541 false,
542 parent_str,
543 ));
544 }
545 }
546 Ok(())
547 }
548
549 #[allow(clippy::only_used_in_recursion)]
551 fn collect_md_paths(
552 &self,
553 root_path: &Path,
554 current_path: &Path,
555 result: &mut Vec<(String, i64)>,
556 ) -> Result<(), FsError> {
557 if !current_path.is_dir() {
558 return Ok(());
559 }
560 for entry in std::fs::read_dir(current_path)? {
561 let entry = entry?;
562 let path = entry.path();
563 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
564 if filename.starts_with('.') {
565 continue;
566 }
567 if path.is_dir() {
568 self.collect_md_paths(root_path, &path, result)?;
569 } else if filename.ends_with(".md") {
570 let meta = std::fs::metadata(&path)?;
571 let rel = path
572 .strip_prefix(root_path)
573 .map_err(|_| FsError::UnsafePath)?;
574 result.push((rel.to_string_lossy().to_string(), meta.len() as i64));
575 }
576 }
577 Ok(())
578 }
579
580 fn calculate_used_quota(&self) -> std::io::Result<i64> {
581 let mut total = 0i64;
582 if self.root.exists() {
583 for entry in std::fs::read_dir(&self.root)? {
584 let entry = entry?;
585 let meta = entry.metadata()?;
586 if meta.is_file() {
587 total += meta.len() as i64;
588 } else if meta.is_dir() {
589 total += dir_size(entry.path())?;
590 }
591 }
592 }
593 Ok(total)
594 }
595}
596
597pub fn hash_filename(filename: &str) -> String {
603 let mut hasher = Md5::new();
604 hasher.update(filename.as_bytes());
605 hex::encode(hasher.finalize())[..11].to_string()
606}
607
608pub fn short_hash(filename: &str) -> String {
610 let mut hasher = Md5::new();
611 hasher.update(filename.as_bytes());
612 hex::encode(hasher.finalize())[..5].to_string()
613}
614
615pub fn sanitize_filename(filename: &str) -> String {
617 let mut result = filename.to_string();
618 for (forbidden, safe) in FORBIDDEN_CHARS {
619 result = result.replace(forbidden, safe);
620 }
621 result
622}
623
624pub fn unsanitize_filename(filename: &str) -> String {
626 let mut result = filename.to_string();
627 for (forbidden, safe) in FORBIDDEN_CHARS {
628 if !forbidden.is_empty() && *forbidden != "\x00" {
629 result = result.replace(safe, forbidden);
630 }
631 }
632 result
633}
634
635pub fn display_name(filename: &str) -> String {
637 let trimmed = filename.trim();
638 let without_ext = trimmed.strip_suffix(".md").unwrap_or(trimmed);
639 let mut chars = without_ext.chars();
640 match chars.next() {
641 None => String::new(),
642 Some(first) => first.to_uppercase().chain(chars).collect(),
643 }
644}
645
646pub fn is_checklist_item(filename: &str) -> bool {
648 let trimmed = filename.trim();
649 if !trimmed.starts_with('-') {
650 return false;
651 }
652 if let Some(pos) = trimmed.rfind('-') {
653 pos > 0 && pos < trimmed.len() - 1
654 } else {
655 false
656 }
657}
658
659pub fn exclude_checklists(files: &[FileEntry]) -> Vec<FileEntry> {
661 files
662 .iter()
663 .filter(|f| {
664 let name = f.name.trim_end_matches(".md");
665 !(name.starts_with('_') && name.ends_with('_'))
666 })
667 .cloned()
668 .collect()
669}
670
671pub fn exclude_system_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
673 files
674 .iter()
675 .filter(|f| !SYSTEM_DIRS.contains(&f.name.as_str()))
676 .cloned()
677 .collect()
678}
679
680pub fn exclude_system_files(files: &[FileEntry]) -> Vec<FileEntry> {
682 files
683 .iter()
684 .filter(|f| !SYSTEM_FILES.contains(&f.name.as_str()))
685 .cloned()
686 .collect()
687}
688
689pub fn only_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
691 files.iter().filter(|f| f.is_dir).cloned().collect()
692}
693
694pub fn only_files(files: &[FileEntry]) -> Vec<FileEntry> {
696 files.iter().filter(|f| !f.is_dir).cloned().collect()
697}
698
699pub fn only_user_md_files(files: &[FileEntry]) -> Vec<FileEntry> {
701 files
702 .iter()
703 .filter(|f| {
704 !f.is_dir && f.name.ends_with(".md") && !SYSTEM_FILES.contains(&f.name.as_str())
705 })
706 .cloned()
707 .collect()
708}
709
710pub fn sort_by_ctime_desc(files: &mut [FileEntry]) {
712 files.sort_by_key(|a| Reverse(a.ctime));
713}
714
715pub fn only_filenames(files: &[FileEntry]) -> Vec<String> {
717 files.iter().map(|f| f.name.clone()).collect()
718}
719
720pub fn split_posix_path(path: &str) -> (&str, &str) {
723 let path = path.trim_start_matches('/');
724 if let Some(slash_pos) = path.rfind('/') {
725 let (dir, file) = path.split_at(slash_pos);
726 (dir, &file[1..])
727 } else {
728 (crate::types::DIR_USER_ROOT, path)
729 }
730}
731
732fn normalize_path(path: &Path) -> (PathBuf, bool) {
735 let mut components = Vec::new();
736 let mut escaped = false;
737 for component in path.components() {
738 match component {
739 std::path::Component::Normal(s) => components.push(s),
740 std::path::Component::ParentDir => {
741 if components.is_empty() {
742 escaped = true;
743 } else {
744 components.pop();
745 }
746 }
747 std::path::Component::CurDir => {}
748 std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
749 }
750 }
751 (components.iter().collect(), escaped)
752}
753
754fn mtime_to_ms(time: SystemTime) -> i64 {
755 time.duration_since(SystemTime::UNIX_EPOCH)
756 .map(|d| d.as_millis() as i64)
757 .unwrap_or(0)
758}
759
760fn dir_size(path: PathBuf) -> std::io::Result<i64> {
761 let mut total = 0i64;
762 for entry in std::fs::read_dir(path)? {
763 let entry = entry?;
764 let meta = entry.metadata()?;
765 if meta.is_file() {
766 total += meta.len() as i64;
767 } else if meta.is_dir() {
768 total += dir_size(entry.path())?;
769 }
770 }
771 Ok(total)
772}
773
774#[cfg(test)]
779mod tests {
780 use super::*;
781 use tempfile::TempDir;
782
783 fn test_fs() -> (VirtualFs, TempDir) {
784 let dir = TempDir::new().unwrap();
785 let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
786 (fs, dir)
787 }
788
789 #[test]
790 fn test_write_and_read() {
791 let (fs, _t) = test_fs();
792 fs.write("brain", "test.md", "Hello").unwrap();
793 assert_eq!(fs.read("brain", "test.md").unwrap(), "Hello");
794 }
795
796 #[test]
797 fn test_exists() {
798 let (fs, _t) = test_fs();
799 assert!(!fs.exists("/", "nope.md").unwrap());
800 fs.write("/", "exists.md", "x").unwrap();
801 assert!(fs.exists("/", "exists.md").unwrap());
802 }
803
804 #[test]
805 fn test_delete() {
806 let (fs, _t) = test_fs();
807 fs.write("/", "del.md", "x").unwrap();
808 fs.del("/", "del.md").unwrap();
809 assert!(!fs.exists("/", "del.md").unwrap());
810 }
811
812 #[test]
813 fn test_rename() {
814 let (fs, _t) = test_fs();
815 fs.write("/", "old.md", "data").unwrap();
816 fs.rename("/", "old.md", "/", "new.md").unwrap();
817 assert!(!fs.exists("/", "old.md").unwrap());
818 assert_eq!(fs.read("/", "new.md").unwrap(), "data");
819 }
820
821 #[test]
822 fn test_path_traversal_rejected() {
823 let (fs, _t) = test_fs();
824 assert!(fs.safe_path("../etc", "passwd").is_err());
825 assert!(fs.safe_path("a", "../../etc/passwd").is_err());
826 }
827
828 #[test]
829 fn test_touch_creates_file() {
830 let (fs, _t) = test_fs();
831 fs.touch("/", "new.md").unwrap();
832 assert!(fs.exists("/", "new.md").unwrap());
833 }
834
835 #[test]
836 fn test_hash_filename_deterministic() {
837 assert_eq!(hash_filename("test.md"), hash_filename("test.md"));
838 assert_eq!(hash_filename("test.md").len(), 11);
839 }
840
841 #[test]
842 fn test_display_name() {
843 assert_eq!(display_name("rust.md"), "Rust");
844 assert_eq!(display_name(" filename "), "Filename");
845 }
846
847 #[test]
848 fn test_sanitize_roundtrip() {
849 let original = "test/file:name";
850 let sanitized = sanitize_filename(original);
851 assert_ne!(sanitized, original);
852 assert_eq!(unsanitize_filename(&sanitized), original);
853 }
854
855 #[test]
856 fn test_files_and_dirs() {
857 let (fs, _t) = test_fs();
858 fs.make_dir("brain").unwrap();
859 fs.write("brain", "Rust.md", "content").unwrap();
860 let entries = fs.files_and_dirs("brain").unwrap();
861 assert_eq!(entries.len(), 1);
862 assert_eq!(entries[0].name, "Rust.md");
863 }
864
865 #[test]
866 fn test_create_system_dirs() {
867 let (fs, _t) = test_fs();
868 fs.create_system_dirs().unwrap();
869 assert!(fs.exists(DIR_ARCHIVE, "").unwrap());
870 assert!(fs.exists(DIR_MEDIA, "").unwrap());
871 assert!(fs.exists(DIR_JOURNAL, "").unwrap());
872 }
873
874 #[test]
875 fn test_mtimes() {
876 let (fs, _t) = test_fs();
877 fs.write("/", "a.md", "a").unwrap();
878 let mtimes = fs.mtimes("/", &[".md"]).unwrap();
879 assert!(mtimes.contains_key("a.md"));
880 }
881
882 #[test]
883 fn test_search_files_by_name() {
884 let (fs, _t) = test_fs();
885 fs.make_dir("brain").unwrap();
886 fs.write("brain", "Rust.md", "").unwrap();
887 let results = fs.search_files_by_name("brain").unwrap();
888 assert_eq!(results.len(), 1);
889 }
890
891 #[test]
892 fn test_unhash() {
893 let (fs, _t) = test_fs();
894 fs.write("/", "target.md", "x").unwrap();
895 let h = hash_filename("target.md");
896 assert_eq!(fs.unhash("/", &h).unwrap(), "target.md");
897 }
898
899 #[test]
900 fn test_filter_functions() {
901 let f = FileEntry::new(
902 "a.md".into(),
903 "h".into(),
904 "A".into(),
905 0,
906 true,
907 false,
908 "/".into(),
909 );
910 let d = FileEntry::new(
911 "dir".into(),
912 "h".into(),
913 "Dir".into(),
914 0,
915 false,
916 true,
917 "/".into(),
918 );
919 assert_eq!(only_dirs(&[f.clone(), d.clone()]).len(), 1);
920 assert_eq!(only_files(&[f.clone(), d]).len(), 1);
921 }
922
923 #[test]
924 fn test_quota_enforcement() {
925 let dir = TempDir::new().unwrap();
926 let fs = VirtualFs::new(dir.path().to_path_buf())
927 .unwrap()
928 .with_quota(1); assert!(fs.write("/", "big.md", &"x".repeat(2048)).is_err());
930 }
931
932 #[test]
933 fn test_read_write_bytes() {
934 let (fs, _t) = test_fs();
935 let data: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]; fs.write_bytes("media", "image.png", data).unwrap();
937 let read_back = fs.read_bytes("media", "image.png").unwrap();
938 assert_eq!(read_back, data);
939 }
940
941 #[test]
942 fn test_write_bytes_quota() {
943 let dir = TempDir::new().unwrap();
944 let fs = VirtualFs::new(dir.path().to_path_buf())
945 .unwrap()
946 .with_quota(1); let big = vec![0u8; 2048];
948 assert!(fs.write_bytes("/", "big.bin", &big).is_err());
949 }
950
951 #[test]
952 fn test_path_bytes_roundtrip() {
953 let (fs, _t) = test_fs();
954 let data = b"\x00\x01\x02\xFF binary data";
955 fs.write_path_bytes("sub/file.bin", data).unwrap();
956 let read_back = fs.read_path_bytes("sub/file.bin").unwrap();
957 assert_eq!(read_back, data);
958 }
959}