Skip to main content

oxios_markdown/
fs.rs

1//! Sandboxed filesystem abstraction for the knowledge base.
2//!
3//! Ported from files.md (`server/fs/fs.go`, `core/fs.rs`) by Artem Zakirullin.
4//! Each knowledge base has its own root directory. All paths are validated
5//! to prevent path traversal attacks.
6
7use 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::{FileEntry, FsError, DIR_ARCHIVE, DIR_JOURNAL, DIR_MEDIA, DIR_USER_ROOT};
16
17/// Forbidden filename characters and their safe replacements.
18const FORBIDDEN_CHARS: &[(&str, &str)] = &[
19    ("<", "<"),
20    (">", ">"),
21    (":", "꞉"),
22    ("\"", "″"),
23    ("|", "⼁"),
24    ("\\", "\"),
25    ("?", "?"),
26    ("*", "﹡"),
27    ("\x00", ""),
28    ("/", "/"),
29];
30
31/// System directories to exclude from user-facing listings.
32pub const SYSTEM_DIRS: &[&str] = &["archive", "media", "journal", "insights", "img"];
33
34/// System files to exclude from user-facing listings.
35pub const SYSTEM_FILES: &[&str] = &[
36    "Chat.md", "Later.md", "Done.md", "Shop.md", "Watch.md", "Read.md",
37];
38
39/// Files/dirs to ignore during listing.
40const IGNORED_NAMES: &[&str] = &[".", "..", ".obsidian", ".gitignore", ".DS_Store", ".git"];
41
42// ============================================================================
43// VirtualFs
44// ============================================================================
45
46/// Sandboxed filesystem for a single knowledge base.
47///
48/// All file operations are constrained to the root directory.
49/// Path traversal attempts are rejected.
50#[derive(Clone, Debug)]
51pub struct VirtualFs {
52    root: PathBuf,
53    quota_kb: i64,
54}
55
56impl VirtualFs {
57    /// Create a new VirtualFs rooted at the given directory.
58    ///
59    /// Creates the directory if it doesn't exist.
60    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    /// Set a storage quota in kilobytes (0 = unlimited).
68    pub fn with_quota(mut self, quota_kb: i64) -> Self {
69        self.quota_kb = quota_kb;
70        self
71    }
72
73    /// Get the root path.
74    pub fn root(&self) -> &Path {
75        &self.root
76    }
77
78    /// Get the configured quota in KB (0 = unlimited).
79    pub fn quota_kb(&self) -> i64 {
80        self.quota_kb
81    }
82
83    // ── Path Safety ──────────────────────────────────────────
84
85    /// Build a safe absolute path from a directory and filename.
86    ///
87    /// Rejects path traversal attempts (e.g., `../../etc/passwd`).
88    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        // Normalize and verify we didn't escape root
111        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    // ── POSIX Path API (단일 path 문자열) ────────────────────
123
124    /// Read file content by POSIX-style relative path.
125    /// `path` examples: "Rust.md", "brain/Rust.md", "journal/2024.08 August.md"
126    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    /// Write file content by POSIX-style relative path.
132    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    /// Delete file by POSIX-style relative path.
138    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    /// Rename/move file by POSIX-style relative paths.
144    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    /// Check if file exists by POSIX-style relative path.
151    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    /// Get mtime by POSIX-style relative path.
157    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    // ── Basic I/O ───────────────────────────────────────────
163
164    /// Check if a file or directory exists.
165    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    /// Read file contents as a string.
171    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    /// Write content to a file, creating parent directories as needed.
180    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    /// Read a file as raw bytes.
205    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    /// Write raw bytes to a file, creating parent directories as needed.
211    /// Respects the configured quota (same logic as `write()`).
212    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    /// Read a file by POSIX path as raw bytes.
236    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    /// Write raw bytes to a file by POSIX path.
242    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    /// Delete a file.
248    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    /// Rename/move a file.
255    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    /// Create a directory.
272    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    /// Touch a file: create if missing, update mtime if present.
279    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    // ── Metadata ─────────────────────────────────────────────
291
292    /// Get the ctime/mtime of a file in milliseconds since epoch.
293    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    /// Get the modification time of a file in milliseconds since epoch.
300    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    /// Recursively collect mtimes for all files with given extensions.
307    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    // ── Listing ─────────────────────────────────────────────
315
316    /// List files and directories in a directory.
317    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    /// List only directories in the root.
358    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    /// Check if a file has non-whitespace content.
367    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    /// Create the standard system directories (archive, media, journal).
373    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    /// Reverse a hash to find the original filename.
381    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    /// Search files by name across the entire knowledge base.
400    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    // ── Private helpers ─────────────────────────────────────
429
430    #[allow(clippy::only_used_in_recursion)]
431    fn walk_dir(
432        &self,
433        root_path: &Path,
434        current_path: &Path,
435        extensions: &[&str],
436        result: &mut HashMap<String, i64>,
437    ) -> Result<(), FsError> {
438        if !current_path.is_dir() {
439            return Ok(());
440        }
441        for entry in std::fs::read_dir(current_path)? {
442            let entry = entry?;
443            let path = entry.path();
444            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
445
446            if filename.starts_with('.') {
447                continue;
448            }
449
450            if path.is_dir() {
451                self.walk_dir(root_path, &path, extensions, result)?;
452            } else {
453                if !extensions.is_empty() {
454                    let ext = path
455                        .extension()
456                        .and_then(|e| e.to_str())
457                        .map(|e| format!(".{e}"));
458                    let ext_match = ext
459                        .as_ref()
460                        .map(|e| extensions.contains(&e.as_str()))
461                        .unwrap_or(false);
462                    if !ext_match {
463                        continue;
464                    }
465                }
466
467                let rel = path
468                    .strip_prefix(root_path)
469                    .map_err(|_| FsError::UnsafePath)?;
470                let display = rel.to_string_lossy();
471                let display_path = if display.starts_with('/') || display.starts_with('\\') {
472                    display[1..].to_string()
473                } else {
474                    display.to_string()
475                };
476
477                let meta = std::fs::metadata(&path)?;
478                result.insert(display_path, mtime_to_ms(meta.modified()?));
479            }
480        }
481        Ok(())
482    }
483
484    #[allow(clippy::only_used_in_recursion)]
485    fn collect_md_files(
486        &self,
487        root_path: &Path,
488        current_path: &Path,
489        files: &mut Vec<FileEntry>,
490    ) -> Result<(), FsError> {
491        if !current_path.is_dir() {
492            return Ok(());
493        }
494        for entry in std::fs::read_dir(current_path)? {
495            let entry = entry?;
496            let path = entry.path();
497            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
498
499            if path.is_dir() {
500                if filename.starts_with('.') {
501                    continue;
502                }
503                self.collect_md_files(root_path, &path, files)?;
504            } else {
505                if !filename.ends_with(".md") || filename.starts_with('.') {
506                    continue;
507                }
508
509                let meta = std::fs::metadata(&path)?;
510                let rel = path
511                    .strip_prefix(root_path)
512                    .map_err(|_| FsError::UnsafePath)?;
513                let parent = rel
514                    .parent()
515                    .map(|p| p.to_string_lossy().to_string())
516                    .unwrap_or_default();
517                let parent_str = if parent.is_empty() || parent == "." {
518                    DIR_USER_ROOT.to_string()
519                } else {
520                    parent
521                };
522
523                let ctime = mtime_to_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
524                let hash = hash_filename(filename);
525                let display_name = display_name(filename);
526
527                files.push(FileEntry::new(
528                    filename.to_string(),
529                    hash,
530                    display_name,
531                    ctime,
532                    meta.len() > 0,
533                    false,
534                    parent_str,
535                ));
536            }
537        }
538        Ok(())
539    }
540
541    fn calculate_used_quota(&self) -> std::io::Result<i64> {
542        let mut total = 0i64;
543        if self.root.exists() {
544            for entry in std::fs::read_dir(&self.root)? {
545                let entry = entry?;
546                let meta = entry.metadata()?;
547                if meta.is_file() {
548                    total += meta.len() as i64;
549                } else if meta.is_dir() {
550                    total += dir_size(entry.path())?;
551                }
552            }
553        }
554        Ok(total)
555    }
556}
557
558// ============================================================================
559// Free Functions
560// ============================================================================
561
562/// Compute MD5 hash of a filename (first 11 hex characters).
563pub fn hash_filename(filename: &str) -> String {
564    let mut hasher = Md5::new();
565    hasher.update(filename.as_bytes());
566    hex::encode(hasher.finalize())[..11].to_string()
567}
568
569/// Compute short hash (first 5 hex characters).
570pub fn short_hash(filename: &str) -> String {
571    let mut hasher = Md5::new();
572    hasher.update(filename.as_bytes());
573    hex::encode(hasher.finalize())[..5].to_string()
574}
575
576/// Sanitize a filename by replacing forbidden characters.
577pub fn sanitize_filename(filename: &str) -> String {
578    let mut result = filename.to_string();
579    for (forbidden, safe) in FORBIDDEN_CHARS {
580        result = result.replace(forbidden, safe);
581    }
582    result
583}
584
585/// Reverse sanitize: restore original forbidden characters.
586pub fn unsanitize_filename(filename: &str) -> String {
587    let mut result = filename.to_string();
588    for (forbidden, safe) in FORBIDDEN_CHARS {
589        if !forbidden.is_empty() && *forbidden != "\x00" {
590            result = result.replace(safe, forbidden);
591        }
592    }
593    result
594}
595
596/// Get display name from filename: capitalized, without `.md` extension.
597pub fn display_name(filename: &str) -> String {
598    let trimmed = filename.trim();
599    let without_ext = trimmed.strip_suffix(".md").unwrap_or(trimmed);
600    let mut chars = without_ext.chars();
601    match chars.next() {
602        None => String::new(),
603        Some(first) => first.to_uppercase().chain(chars).collect(),
604    }
605}
606
607/// Check if a filename represents a checklist item.
608pub fn is_checklist_item(filename: &str) -> bool {
609    let trimmed = filename.trim();
610    if !trimmed.starts_with('-') {
611        return false;
612    }
613    if let Some(pos) = trimmed.rfind('-') {
614        pos > 0 && pos < trimmed.len() - 1
615    } else {
616        false
617    }
618}
619
620/// Filter: exclude checklist files.
621pub fn exclude_checklists(files: &[FileEntry]) -> Vec<FileEntry> {
622    files
623        .iter()
624        .filter(|f| {
625            let name = f.name.trim_end_matches(".md");
626            !(name.starts_with('_') && name.ends_with('_'))
627        })
628        .cloned()
629        .collect()
630}
631
632/// Filter: exclude system directories.
633pub fn exclude_system_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
634    files
635        .iter()
636        .filter(|f| !SYSTEM_DIRS.contains(&f.name.as_str()))
637        .cloned()
638        .collect()
639}
640
641/// Filter: exclude system files.
642pub fn exclude_system_files(files: &[FileEntry]) -> Vec<FileEntry> {
643    files
644        .iter()
645        .filter(|f| !SYSTEM_FILES.contains(&f.name.as_str()))
646        .cloned()
647        .collect()
648}
649
650/// Filter: only directories.
651pub fn only_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
652    files.iter().filter(|f| f.is_dir).cloned().collect()
653}
654
655/// Filter: only files (not directories).
656pub fn only_files(files: &[FileEntry]) -> Vec<FileEntry> {
657    files.iter().filter(|f| !f.is_dir).cloned().collect()
658}
659
660/// Filter: only user markdown files (exclude system files, dirs, non-md).
661pub fn only_user_md_files(files: &[FileEntry]) -> Vec<FileEntry> {
662    files
663        .iter()
664        .filter(|f| {
665            !f.is_dir && f.name.ends_with(".md") && !SYSTEM_FILES.contains(&f.name.as_str())
666        })
667        .cloned()
668        .collect()
669}
670
671/// Sort files by ctime descending (newest first).
672pub fn sort_by_ctime_desc(files: &mut [FileEntry]) {
673    files.sort_by_key(|a| Reverse(a.ctime));
674}
675
676/// Extract filenames from a list of file entries.
677pub fn only_filenames(files: &[FileEntry]) -> Vec<String> {
678    files.iter().map(|f| f.name.clone()).collect()
679}
680
681/// Split a POSIX-style path like "brain/Rust.md" into (dir, filename).
682/// Root-level files like "Chat.md" become ("/", "Chat.md").
683pub fn split_posix_path(path: &str) -> (&str, &str) {
684    let path = path.trim_start_matches('/');
685    if let Some(slash_pos) = path.rfind('/') {
686        let (dir, file) = path.split_at(slash_pos);
687        (dir, &file[1..])
688    } else {
689        (crate::types::DIR_USER_ROOT, path)
690    }
691}
692
693// ── Internal helpers ────────────────────────────────────────
694
695fn normalize_path(path: &Path) -> (PathBuf, bool) {
696    let mut components = Vec::new();
697    let mut escaped = false;
698    for component in path.components() {
699        match component {
700            std::path::Component::Normal(s) => components.push(s),
701            std::path::Component::ParentDir => {
702                if components.is_empty() {
703                    escaped = true;
704                } else {
705                    components.pop();
706                }
707            }
708            std::path::Component::CurDir => {}
709            std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
710        }
711    }
712    (components.iter().collect(), escaped)
713}
714
715fn mtime_to_ms(time: SystemTime) -> i64 {
716    time.duration_since(SystemTime::UNIX_EPOCH)
717        .map(|d| d.as_millis() as i64)
718        .unwrap_or(0)
719}
720
721fn dir_size(path: PathBuf) -> std::io::Result<i64> {
722    let mut total = 0i64;
723    for entry in std::fs::read_dir(path)? {
724        let entry = entry?;
725        let meta = entry.metadata()?;
726        if meta.is_file() {
727            total += meta.len() as i64;
728        } else if meta.is_dir() {
729            total += dir_size(entry.path())?;
730        }
731    }
732    Ok(total)
733}
734
735// ============================================================================
736// Tests
737// ============================================================================
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use tempfile::TempDir;
743
744    fn test_fs() -> (VirtualFs, TempDir) {
745        let dir = TempDir::new().unwrap();
746        let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
747        (fs, dir)
748    }
749
750    #[test]
751    fn test_write_and_read() {
752        let (fs, _t) = test_fs();
753        fs.write("brain", "test.md", "Hello").unwrap();
754        assert_eq!(fs.read("brain", "test.md").unwrap(), "Hello");
755    }
756
757    #[test]
758    fn test_exists() {
759        let (fs, _t) = test_fs();
760        assert!(!fs.exists("/", "nope.md").unwrap());
761        fs.write("/", "exists.md", "x").unwrap();
762        assert!(fs.exists("/", "exists.md").unwrap());
763    }
764
765    #[test]
766    fn test_delete() {
767        let (fs, _t) = test_fs();
768        fs.write("/", "del.md", "x").unwrap();
769        fs.del("/", "del.md").unwrap();
770        assert!(!fs.exists("/", "del.md").unwrap());
771    }
772
773    #[test]
774    fn test_rename() {
775        let (fs, _t) = test_fs();
776        fs.write("/", "old.md", "data").unwrap();
777        fs.rename("/", "old.md", "/", "new.md").unwrap();
778        assert!(!fs.exists("/", "old.md").unwrap());
779        assert_eq!(fs.read("/", "new.md").unwrap(), "data");
780    }
781
782    #[test]
783    fn test_path_traversal_rejected() {
784        let (fs, _t) = test_fs();
785        assert!(fs.safe_path("../etc", "passwd").is_err());
786        assert!(fs.safe_path("a", "../../etc/passwd").is_err());
787    }
788
789    #[test]
790    fn test_touch_creates_file() {
791        let (fs, _t) = test_fs();
792        fs.touch("/", "new.md").unwrap();
793        assert!(fs.exists("/", "new.md").unwrap());
794    }
795
796    #[test]
797    fn test_hash_filename_deterministic() {
798        assert_eq!(hash_filename("test.md"), hash_filename("test.md"));
799        assert_eq!(hash_filename("test.md").len(), 11);
800    }
801
802    #[test]
803    fn test_display_name() {
804        assert_eq!(display_name("rust.md"), "Rust");
805        assert_eq!(display_name(" filename "), "Filename");
806    }
807
808    #[test]
809    fn test_sanitize_roundtrip() {
810        let original = "test/file:name";
811        let sanitized = sanitize_filename(original);
812        assert_ne!(sanitized, original);
813        assert_eq!(unsanitize_filename(&sanitized), original);
814    }
815
816    #[test]
817    fn test_files_and_dirs() {
818        let (fs, _t) = test_fs();
819        fs.make_dir("brain").unwrap();
820        fs.write("brain", "Rust.md", "content").unwrap();
821        let entries = fs.files_and_dirs("brain").unwrap();
822        assert_eq!(entries.len(), 1);
823        assert_eq!(entries[0].name, "Rust.md");
824    }
825
826    #[test]
827    fn test_create_system_dirs() {
828        let (fs, _t) = test_fs();
829        fs.create_system_dirs().unwrap();
830        assert!(fs.exists(DIR_ARCHIVE, "").unwrap());
831        assert!(fs.exists(DIR_MEDIA, "").unwrap());
832        assert!(fs.exists(DIR_JOURNAL, "").unwrap());
833    }
834
835    #[test]
836    fn test_mtimes() {
837        let (fs, _t) = test_fs();
838        fs.write("/", "a.md", "a").unwrap();
839        let mtimes = fs.mtimes("/", &[".md"]).unwrap();
840        assert!(mtimes.contains_key("a.md"));
841    }
842
843    #[test]
844    fn test_search_files_by_name() {
845        let (fs, _t) = test_fs();
846        fs.make_dir("brain").unwrap();
847        fs.write("brain", "Rust.md", "").unwrap();
848        let results = fs.search_files_by_name("brain").unwrap();
849        assert_eq!(results.len(), 1);
850    }
851
852    #[test]
853    fn test_unhash() {
854        let (fs, _t) = test_fs();
855        fs.write("/", "target.md", "x").unwrap();
856        let h = hash_filename("target.md");
857        assert_eq!(fs.unhash("/", &h).unwrap(), "target.md");
858    }
859
860    #[test]
861    fn test_filter_functions() {
862        let f = FileEntry::new(
863            "a.md".into(),
864            "h".into(),
865            "A".into(),
866            0,
867            true,
868            false,
869            "/".into(),
870        );
871        let d = FileEntry::new(
872            "dir".into(),
873            "h".into(),
874            "Dir".into(),
875            0,
876            false,
877            true,
878            "/".into(),
879        );
880        assert_eq!(only_dirs(&[f.clone(), d.clone()]).len(), 1);
881        assert_eq!(only_files(&[f.clone(), d]).len(), 1);
882    }
883
884    #[test]
885    fn test_quota_enforcement() {
886        let dir = TempDir::new().unwrap();
887        let fs = VirtualFs::new(dir.path().to_path_buf())
888            .unwrap()
889            .with_quota(1); // 1 KB
890        assert!(fs.write("/", "big.md", &"x".repeat(2048)).is_err());
891    }
892
893    #[test]
894    fn test_read_write_bytes() {
895        let (fs, _t) = test_fs();
896        let data: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]; // PNG header fragment
897        fs.write_bytes("media", "image.png", data).unwrap();
898        let read_back = fs.read_bytes("media", "image.png").unwrap();
899        assert_eq!(read_back, data);
900    }
901
902    #[test]
903    fn test_write_bytes_quota() {
904        let dir = TempDir::new().unwrap();
905        let fs = VirtualFs::new(dir.path().to_path_buf())
906            .unwrap()
907            .with_quota(1); // 1 KB
908        let big = vec![0u8; 2048];
909        assert!(fs.write_bytes("/", "big.bin", &big).is_err());
910    }
911
912    #[test]
913    fn test_path_bytes_roundtrip() {
914        let (fs, _t) = test_fs();
915        let data = b"\x00\x01\x02\xFF binary data";
916        fs.write_path_bytes("sub/file.bin", data).unwrap();
917        let read_back = fs.read_path_bytes("sub/file.bin").unwrap();
918        assert_eq!(read_back, data);
919    }
920}