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::{DIR_ARCHIVE, DIR_JOURNAL, DIR_MEDIA, DIR_USER_ROOT, FileEntry, FsError};
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    /// List all `.md` files in the vault with their sizes.
429    /// Returns `(posix_path, size_bytes)` pairs. Skips dot-files and dot-dirs.
430    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    // ── Private helpers ─────────────────────────────────────
437
438    #[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    /// Collect all .md file paths and sizes (for frontmatter scanning).
550    #[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
597// ============================================================================
598// Free Functions
599// ============================================================================
600
601/// Compute MD5 hash of a filename (first 11 hex characters).
602pub 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
608/// Compute short hash (first 5 hex characters).
609pub 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
615/// Sanitize a filename by replacing forbidden characters.
616pub 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
624/// Reverse sanitize: restore original forbidden characters.
625pub 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
635/// Get display name from filename: capitalized, without `.md` extension.
636pub 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
646/// Check if a filename represents a checklist item.
647pub 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
659/// Filter: exclude checklist files.
660pub 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
671/// Filter: exclude system directories.
672pub 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
680/// Filter: exclude system files.
681pub 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
689/// Filter: only directories.
690pub fn only_dirs(files: &[FileEntry]) -> Vec<FileEntry> {
691    files.iter().filter(|f| f.is_dir).cloned().collect()
692}
693
694/// Filter: only files (not directories).
695pub fn only_files(files: &[FileEntry]) -> Vec<FileEntry> {
696    files.iter().filter(|f| !f.is_dir).cloned().collect()
697}
698
699/// Filter: only user markdown files (exclude system files, dirs, non-md).
700pub 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
710/// Sort files by ctime descending (newest first).
711pub fn sort_by_ctime_desc(files: &mut [FileEntry]) {
712    files.sort_by_key(|a| Reverse(a.ctime));
713}
714
715/// Extract filenames from a list of file entries.
716pub fn only_filenames(files: &[FileEntry]) -> Vec<String> {
717    files.iter().map(|f| f.name.clone()).collect()
718}
719
720/// Split a POSIX-style path like "brain/Rust.md" into (dir, filename).
721/// Root-level files like "Chat.md" become ("/", "Chat.md").
722pub 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
732// ── Internal helpers ────────────────────────────────────────
733
734fn 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// ============================================================================
775// Tests
776// ============================================================================
777
778#[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); // 1 KB
929        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]; // PNG header fragment
936        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); // 1 KB
947        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}