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    fn walk_dir(
431        &self,
432        root_path: &Path,
433        current_path: &Path,
434        extensions: &[&str],
435        result: &mut HashMap<String, i64>,
436    ) -> Result<(), FsError> {
437        if !current_path.is_dir() {
438            return Ok(());
439        }
440        for entry in std::fs::read_dir(current_path)? {
441            let entry = entry?;
442            let path = entry.path();
443            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
444
445            if filename.starts_with('.') {
446                continue;
447            }
448
449            if path.is_dir() {
450                self.walk_dir(root_path, &path, extensions, result)?;
451            } else {
452                if !extensions.is_empty() {
453                    let ext = path
454                        .extension()
455                        .and_then(|e| e.to_str())
456                        .map(|e| format!(".{}", e));
457                    let ext_match = ext
458                        .as_ref()
459                        .map(|e| extensions.contains(&e.as_str()))
460                        .unwrap_or(false);
461                    if !ext_match {
462                        continue;
463                    }
464                }
465
466                let rel = path
467                    .strip_prefix(root_path)
468                    .map_err(|_| FsError::UnsafePath)?;
469                let display = rel.to_string_lossy();
470                let display_path = if display.starts_with('/') || display.starts_with('\\') {
471                    display[1..].to_string()
472                } else {
473                    display.to_string()
474                };
475
476                let meta = std::fs::metadata(&path)?;
477                result.insert(display_path, mtime_to_ms(meta.modified()?));
478            }
479        }
480        Ok(())
481    }
482
483    fn collect_md_files(
484        &self,
485        root_path: &Path,
486        current_path: &Path,
487        files: &mut Vec<FileEntry>,
488    ) -> Result<(), FsError> {
489        if !current_path.is_dir() {
490            return Ok(());
491        }
492        for entry in std::fs::read_dir(current_path)? {
493            let entry = entry?;
494            let path = entry.path();
495            let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
496
497            if path.is_dir() {
498                if filename.starts_with('.') {
499                    continue;
500                }
501                self.collect_md_files(root_path, &path, files)?;
502            } else {
503                if !filename.ends_with(".md") || filename.starts_with('.') {
504                    continue;
505                }
506
507                let meta = std::fs::metadata(&path)?;
508                let rel = path
509                    .strip_prefix(root_path)
510                    .map_err(|_| FsError::UnsafePath)?;
511                let parent = rel
512                    .parent()
513                    .map(|p| p.to_string_lossy().to_string())
514                    .unwrap_or_default();
515                let parent_str = if parent.is_empty() || parent == "." {
516                    DIR_USER_ROOT.to_string()
517                } else {
518                    parent
519                };
520
521                let ctime = mtime_to_ms(meta.modified().unwrap_or(SystemTime::UNIX_EPOCH));
522                let hash = hash_filename(filename);
523                let display_name = display_name(filename);
524
525                files.push(FileEntry::new(
526                    filename.to_string(),
527                    hash,
528                    display_name,
529                    ctime,
530                    meta.len() > 0,
531                    false,
532                    parent_str,
533                ));
534            }
535        }
536        Ok(())
537    }
538
539    fn calculate_used_quota(&self) -> std::io::Result<i64> {
540        let mut total = 0i64;
541        if self.root.exists() {
542            for entry in std::fs::read_dir(&self.root)? {
543                let entry = entry?;
544                let meta = entry.metadata()?;
545                if meta.is_file() {
546                    total += meta.len() as i64;
547                } else if meta.is_dir() {
548                    total += dir_size(entry.path())?;
549                }
550            }
551        }
552        Ok(total)
553    }
554}
555
556// ============================================================================
557// Free Functions
558// ============================================================================
559
560/// Compute MD5 hash of a filename (first 11 hex characters).
561pub fn hash_filename(filename: &str) -> String {
562    let mut hasher = Md5::new();
563    hasher.update(filename.as_bytes());
564    hex::encode(hasher.finalize())[..11].to_string()
565}
566
567/// Compute short hash (first 5 hex characters).
568pub fn short_hash(filename: &str) -> String {
569    let mut hasher = Md5::new();
570    hasher.update(filename.as_bytes());
571    hex::encode(hasher.finalize())[..5].to_string()
572}
573
574/// Sanitize a filename by replacing forbidden characters.
575pub fn sanitize_filename(filename: &str) -> String {
576    let mut result = filename.to_string();
577    for (forbidden, safe) in FORBIDDEN_CHARS {
578        result = result.replace(forbidden, safe);
579    }
580    result
581}
582
583/// Reverse sanitize: restore original forbidden characters.
584pub fn unsanitize_filename(filename: &str) -> String {
585    let mut result = filename.to_string();
586    for (forbidden, safe) in FORBIDDEN_CHARS {
587        if !forbidden.is_empty() && *forbidden != "\x00" {
588            result = result.replace(safe, forbidden);
589        }
590    }
591    result
592}
593
594/// Get display name from filename: capitalized, without `.md` extension.
595pub fn display_name(filename: &str) -> String {
596    let trimmed = filename.trim();
597    let without_ext = trimmed.strip_suffix(".md").unwrap_or(trimmed);
598    let mut chars = without_ext.chars();
599    match chars.next() {
600        None => String::new(),
601        Some(first) => first.to_uppercase().chain(chars).collect(),
602    }
603}
604
605/// Check if a filename represents a checklist item.
606pub fn is_checklist_item(filename: &str) -> bool {
607    let trimmed = filename.trim();
608    if !trimmed.starts_with('-') {
609        return false;
610    }
611    if let Some(pos) = trimmed.rfind('-') {
612        pos > 0 && pos < trimmed.len() - 1
613    } else {
614        false
615    }
616}
617
618/// Filter: exclude checklist files.
619pub fn exclude_checklists(files: &[FileEntry]) -> Vec<FileEntry> {
620    files
621        .iter()
622        .filter(|f| {
623            let name = f.name.trim_end_matches(".md");
624            !(name.starts_with('_') && name.ends_with('_'))
625        })
626        .cloned()
627        .collect()
628}
629
630/// Filter: exclude system directories.
631pub fn exclude_system_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
632    files
633        .iter()
634        .filter(|f| !SYSTEM_DIRS.contains(&f.name.as_str()))
635        .cloned()
636        .collect()
637}
638
639/// Filter: exclude system files.
640pub fn exclude_system_files(files: &[FileEntry]) -> Vec<FileEntry> {
641    files
642        .iter()
643        .filter(|f| !SYSTEM_FILES.contains(&f.name.as_str()))
644        .cloned()
645        .collect()
646}
647
648/// Filter: only directories.
649pub fn only_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
650    files.iter().filter(|f| f.is_dir).cloned().collect()
651}
652
653/// Filter: only files (not directories).
654pub fn only_files(files: &[FileEntry]) -> Vec<FileEntry> {
655    files.iter().filter(|f| !f.is_dir).cloned().collect()
656}
657
658/// Filter: only user markdown files (exclude system files, dirs, non-md).
659pub fn only_user_md_files(files: &[FileEntry]) -> Vec<FileEntry> {
660    files
661        .iter()
662        .filter(|f| {
663            !f.is_dir && f.name.ends_with(".md") && !SYSTEM_FILES.contains(&f.name.as_str())
664        })
665        .cloned()
666        .collect()
667}
668
669/// Sort files by ctime descending (newest first).
670pub fn sort_by_ctime_desc(files: &mut [FileEntry]) {
671    files.sort_by_key(|a| Reverse(a.ctime));
672}
673
674/// Extract filenames from a list of file entries.
675pub fn only_filenames(files: &[FileEntry]) -> Vec<String> {
676    files.iter().map(|f| f.name.clone()).collect()
677}
678
679/// Split a POSIX-style path like "brain/Rust.md" into (dir, filename).
680/// Root-level files like "Chat.md" become ("/", "Chat.md").
681pub fn split_posix_path(path: &str) -> (&str, &str) {
682    let path = path.trim_start_matches('/');
683    if let Some(slash_pos) = path.rfind('/') {
684        let (dir, file) = path.split_at(slash_pos);
685        (dir, &file[1..])
686    } else {
687        (crate::types::DIR_USER_ROOT, path)
688    }
689}
690
691// ── Internal helpers ────────────────────────────────────────
692
693fn normalize_path(path: &Path) -> (PathBuf, bool) {
694    let mut components = Vec::new();
695    let mut escaped = false;
696    for component in path.components() {
697        match component {
698            std::path::Component::Normal(s) => components.push(s),
699            std::path::Component::ParentDir => {
700                if components.is_empty() {
701                    escaped = true;
702                } else {
703                    components.pop();
704                }
705            }
706            std::path::Component::CurDir => {}
707            std::path::Component::RootDir | std::path::Component::Prefix(_) => {}
708        }
709    }
710    (components.iter().collect(), escaped)
711}
712
713fn mtime_to_ms(time: SystemTime) -> i64 {
714    time.duration_since(SystemTime::UNIX_EPOCH)
715        .map(|d| d.as_millis() as i64)
716        .unwrap_or(0)
717}
718
719fn dir_size(path: PathBuf) -> std::io::Result<i64> {
720    let mut total = 0i64;
721    for entry in std::fs::read_dir(path)? {
722        let entry = entry?;
723        let meta = entry.metadata()?;
724        if meta.is_file() {
725            total += meta.len() as i64;
726        } else if meta.is_dir() {
727            total += dir_size(entry.path())?;
728        }
729    }
730    Ok(total)
731}
732
733// ============================================================================
734// Tests
735// ============================================================================
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use tempfile::TempDir;
741
742    fn test_fs() -> (VirtualFs, TempDir) {
743        let dir = TempDir::new().unwrap();
744        let fs = VirtualFs::new(dir.path().to_path_buf()).unwrap();
745        (fs, dir)
746    }
747
748    #[test]
749    fn test_write_and_read() {
750        let (fs, _t) = test_fs();
751        fs.write("brain", "test.md", "Hello").unwrap();
752        assert_eq!(fs.read("brain", "test.md").unwrap(), "Hello");
753    }
754
755    #[test]
756    fn test_exists() {
757        let (fs, _t) = test_fs();
758        assert!(!fs.exists("/", "nope.md").unwrap());
759        fs.write("/", "exists.md", "x").unwrap();
760        assert!(fs.exists("/", "exists.md").unwrap());
761    }
762
763    #[test]
764    fn test_delete() {
765        let (fs, _t) = test_fs();
766        fs.write("/", "del.md", "x").unwrap();
767        fs.del("/", "del.md").unwrap();
768        assert!(!fs.exists("/", "del.md").unwrap());
769    }
770
771    #[test]
772    fn test_rename() {
773        let (fs, _t) = test_fs();
774        fs.write("/", "old.md", "data").unwrap();
775        fs.rename("/", "old.md", "/", "new.md").unwrap();
776        assert!(!fs.exists("/", "old.md").unwrap());
777        assert_eq!(fs.read("/", "new.md").unwrap(), "data");
778    }
779
780    #[test]
781    fn test_path_traversal_rejected() {
782        let (fs, _t) = test_fs();
783        assert!(fs.safe_path("../etc", "passwd").is_err());
784        assert!(fs.safe_path("a", "../../etc/passwd").is_err());
785    }
786
787    #[test]
788    fn test_touch_creates_file() {
789        let (fs, _t) = test_fs();
790        fs.touch("/", "new.md").unwrap();
791        assert!(fs.exists("/", "new.md").unwrap());
792    }
793
794    #[test]
795    fn test_hash_filename_deterministic() {
796        assert_eq!(hash_filename("test.md"), hash_filename("test.md"));
797        assert_eq!(hash_filename("test.md").len(), 11);
798    }
799
800    #[test]
801    fn test_display_name() {
802        assert_eq!(display_name("rust.md"), "Rust");
803        assert_eq!(display_name(" filename "), "Filename");
804    }
805
806    #[test]
807    fn test_sanitize_roundtrip() {
808        let original = "test/file:name";
809        let sanitized = sanitize_filename(original);
810        assert_ne!(sanitized, original);
811        assert_eq!(unsanitize_filename(&sanitized), original);
812    }
813
814    #[test]
815    fn test_files_and_dirs() {
816        let (fs, _t) = test_fs();
817        fs.make_dir("brain").unwrap();
818        fs.write("brain", "Rust.md", "content").unwrap();
819        let entries = fs.files_and_dirs("brain").unwrap();
820        assert_eq!(entries.len(), 1);
821        assert_eq!(entries[0].name, "Rust.md");
822    }
823
824    #[test]
825    fn test_create_system_dirs() {
826        let (fs, _t) = test_fs();
827        fs.create_system_dirs().unwrap();
828        assert!(fs.exists(DIR_ARCHIVE, "").unwrap());
829        assert!(fs.exists(DIR_MEDIA, "").unwrap());
830        assert!(fs.exists(DIR_JOURNAL, "").unwrap());
831    }
832
833    #[test]
834    fn test_mtimes() {
835        let (fs, _t) = test_fs();
836        fs.write("/", "a.md", "a").unwrap();
837        let mtimes = fs.mtimes("/", &[".md"]).unwrap();
838        assert!(mtimes.contains_key("a.md"));
839    }
840
841    #[test]
842    fn test_search_files_by_name() {
843        let (fs, _t) = test_fs();
844        fs.make_dir("brain").unwrap();
845        fs.write("brain", "Rust.md", "").unwrap();
846        let results = fs.search_files_by_name("brain").unwrap();
847        assert_eq!(results.len(), 1);
848    }
849
850    #[test]
851    fn test_unhash() {
852        let (fs, _t) = test_fs();
853        fs.write("/", "target.md", "x").unwrap();
854        let h = hash_filename("target.md");
855        assert_eq!(fs.unhash("/", &h).unwrap(), "target.md");
856    }
857
858    #[test]
859    fn test_filter_functions() {
860        let f = FileEntry::new(
861            "a.md".into(),
862            "h".into(),
863            "A".into(),
864            0,
865            true,
866            false,
867            "/".into(),
868        );
869        let d = FileEntry::new(
870            "dir".into(),
871            "h".into(),
872            "Dir".into(),
873            0,
874            false,
875            true,
876            "/".into(),
877        );
878        assert_eq!(only_dirs(&[f.clone(), d.clone()]).len(), 1);
879        assert_eq!(only_files(&[f.clone(), d]).len(), 1);
880    }
881
882    #[test]
883    fn test_quota_enforcement() {
884        let dir = TempDir::new().unwrap();
885        let fs = VirtualFs::new(dir.path().to_path_buf())
886            .unwrap()
887            .with_quota(1); // 1 KB
888        assert!(fs.write("/", "big.md", &"x".repeat(2048)).is_err());
889    }
890
891    #[test]
892    fn test_read_write_bytes() {
893        let (fs, _t) = test_fs();
894        let data: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A]; // PNG header fragment
895        fs.write_bytes("media", "image.png", data).unwrap();
896        let read_back = fs.read_bytes("media", "image.png").unwrap();
897        assert_eq!(read_back, data);
898    }
899
900    #[test]
901    fn test_write_bytes_quota() {
902        let dir = TempDir::new().unwrap();
903        let fs = VirtualFs::new(dir.path().to_path_buf())
904            .unwrap()
905            .with_quota(1); // 1 KB
906        let big = vec![0u8; 2048];
907        assert!(fs.write_bytes("/", "big.bin", &big).is_err());
908    }
909
910    #[test]
911    fn test_path_bytes_roundtrip() {
912        let (fs, _t) = test_fs();
913        let data = b"\x00\x01\x02\xFF binary data";
914        fs.write_path_bytes("sub/file.bin", data).unwrap();
915        let read_back = fs.read_path_bytes("sub/file.bin").unwrap();
916        assert_eq!(read_back, data);
917    }
918}