Skip to main content

rustledger_loader/
vfs.rs

1//! Virtual filesystem abstraction for platform-agnostic file loading.
2//!
3//! This module provides a trait for abstracting file system operations,
4//! enabling the loader to work with both real filesystems and in-memory
5//! file maps (useful for WASM environments).
6
7use crate::LoadError;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13/// Abstract file system interface for file loading.
14///
15/// This trait allows the loader to work with different file system backends:
16/// - [`DiskFileSystem`]: Reads from the actual filesystem (default)
17/// - [`VirtualFileSystem`]: Reads from an in-memory file map (for WASM)
18pub trait FileSystem: Send + Sync + std::fmt::Debug {
19    /// Read file content at the given path.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`LoadError::Io`] if the file cannot be read.
24    fn read(&self, path: &Path) -> Result<Arc<str>, LoadError>;
25
26    /// Check if a file exists at the given path.
27    fn exists(&self, path: &Path) -> bool;
28
29    /// Check if a *directory* exists at the given path.
30    ///
31    /// Separate from [`FileSystem::exists`] because the two answers genuinely
32    /// differ per backend: a virtual filesystem is a flat file map with no
33    /// directories in it at all, so `exists` on a directory is always false
34    /// there — using it to validate `option "documents"` roots would warn on
35    /// every in-memory load. See the per-impl notes.
36    fn dir_exists(&self, path: &Path) -> bool;
37
38    /// Check if a path is a GPG-encrypted file.
39    ///
40    /// For virtual filesystems, this always returns false since
41    /// encrypted files should be decrypted before being added.
42    fn is_encrypted(&self, path: &Path) -> bool;
43
44    /// Normalize a path for this filesystem.
45    ///
46    /// For disk filesystems, this makes paths absolute.
47    /// For virtual filesystems, this just cleans up the path.
48    fn normalize(&self, path: &Path) -> PathBuf;
49
50    /// Whether this filesystem supports parallel file reads.
51    ///
52    /// Disk filesystems return `true` — multiple files can be read
53    /// concurrently from different threads. Virtual filesystems return
54    /// `false` since they may use shared mutable state.
55    fn supports_parallel_read(&self) -> bool {
56        false
57    }
58
59    /// Expand a glob pattern and return matching paths.
60    ///
61    /// # Errors
62    ///
63    /// Returns an error string if the pattern is invalid.
64    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
65        let _ = pattern;
66        Err("glob is not supported by this filesystem".to_string())
67    }
68
69    /// Decrypt an encrypted file at `path`, returning its plaintext.
70    ///
71    /// The default implementation shells out to `gpg --batch --decrypt` — the
72    /// native path, which uses the user's keyring and gpg-agent. Sandboxed
73    /// filesystems (the WASI component) override this to delegate to a host
74    /// capability, since a WASI guest can neither spawn `gpg` nor reach the
75    /// keyring (#1667). Only called when [`is_encrypted`](Self::is_encrypted)
76    /// returned `true`.
77    ///
78    /// # Errors
79    ///
80    /// Returns [`LoadError::Decryption`] if decryption fails.
81    fn decrypt(&self, path: &Path) -> Result<Arc<str>, LoadError> {
82        crate::decrypt_gpg_file(path).map(Arc::from)
83    }
84}
85
86/// Default filesystem that reads from disk.
87///
88/// This is the standard implementation used by the CLI and other
89/// filesystem-based tools.
90#[derive(Debug, Default, Clone)]
91pub struct DiskFileSystem;
92
93impl FileSystem for DiskFileSystem {
94    fn dir_exists(&self, path: &Path) -> bool {
95        // `is_dir`, not `exists`: a regular file named `docs` is not a
96        // document root, and reporting it as one would be a false pass.
97        path.is_dir()
98    }
99
100    fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
101        let bytes = fs::read(path).map_err(|e| LoadError::Io {
102            path: path.to_path_buf(),
103            source: e,
104        })?;
105
106        // Try zero-copy conversion first (common case), fall back to lossy
107        let content = match String::from_utf8(bytes) {
108            Ok(s) => s,
109            Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
110        };
111
112        Ok(content.into())
113    }
114
115    fn exists(&self, path: &Path) -> bool {
116        path.exists()
117    }
118
119    fn is_encrypted(&self, path: &Path) -> bool {
120        match path.extension().and_then(|e| e.to_str()) {
121            Some("gpg") => true,
122            Some("asc") => {
123                // Check for PGP header in the first 1024 bytes.
124                // Only read what we need instead of the entire file.
125                use std::io::Read;
126                let Ok(file) = std::fs::File::open(path) else {
127                    return false;
128                };
129                let mut buf = [0u8; 1024];
130                let n = file.take(1024).read(&mut buf).unwrap_or(0);
131                let header = String::from_utf8_lossy(&buf[..n]);
132                header.contains("-----BEGIN PGP MESSAGE-----")
133            }
134            _ => false,
135        }
136    }
137
138    fn normalize(&self, path: &Path) -> PathBuf {
139        // Try canonicalize first (works on most platforms, resolves symlinks)
140        if let Ok(canonical) = path.canonicalize() {
141            return canonical;
142        }
143
144        // Fallback: make absolute without resolving symlinks (WASI-compatible)
145        if path.is_absolute() {
146            path.to_path_buf()
147        } else if let Ok(cwd) = std::env::current_dir() {
148            // Join with current directory and clean up the path
149            let mut result = cwd;
150            for component in path.components() {
151                match component {
152                    std::path::Component::ParentDir => {
153                        result.pop();
154                    }
155                    std::path::Component::Normal(s) => {
156                        result.push(s);
157                    }
158                    std::path::Component::CurDir => {}
159                    std::path::Component::RootDir => {
160                        result = PathBuf::from("/");
161                    }
162                    std::path::Component::Prefix(p) => {
163                        result = PathBuf::from(p.as_os_str());
164                    }
165                }
166            }
167            result
168        } else {
169            // Last resort: just return the path as-is
170            path.to_path_buf()
171        }
172    }
173
174    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
175        let entries = glob::glob(pattern).map_err(|e| e.to_string())?;
176        // Skip entries that error (e.g., permission denied) rather than
177        // failing the entire glob. The loader will catch missing/unreadable
178        // files later when it tries to read them.
179        let mut matched: Vec<PathBuf> = entries.filter_map(Result::ok).collect();
180        matched.sort();
181        Ok(matched)
182    }
183
184    fn supports_parallel_read(&self) -> bool {
185        true
186    }
187}
188
189/// In-memory virtual filesystem for WASM and testing.
190///
191/// This implementation stores files in a `HashMap`, allowing the loader
192/// to resolve includes without actual filesystem access. This is essential
193/// for WASM environments where filesystem access is not available.
194///
195/// # Example
196///
197/// ```
198/// use rustledger_loader::VirtualFileSystem;
199/// use std::path::PathBuf;
200///
201/// let mut vfs = VirtualFileSystem::new();
202/// vfs.add_file("main.beancount", "include \"accounts.beancount\"");
203/// vfs.add_file("accounts.beancount", "2024-01-01 open Assets:Bank USD");
204/// ```
205#[derive(Debug, Default, Clone)]
206pub struct VirtualFileSystem {
207    files: HashMap<PathBuf, Arc<str>>,
208}
209
210impl VirtualFileSystem {
211    /// Create a new empty virtual filesystem.
212    #[must_use]
213    pub fn new() -> Self {
214        Self::default()
215    }
216
217    /// Add a file to the virtual filesystem.
218    ///
219    /// The path is normalized to handle different path separators
220    /// and relative paths consistently.
221    pub fn add_file(&mut self, path: impl AsRef<Path>, content: impl Into<String>) {
222        let normalized = normalize_vfs_path(path.as_ref());
223        self.files.insert(normalized, content.into().into());
224    }
225
226    /// Add multiple files from a map.
227    ///
228    /// This is a convenience method for adding many files at once.
229    pub fn add_files(
230        &mut self,
231        files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
232    ) {
233        for (path, content) in files {
234            self.add_file(path, content);
235        }
236    }
237
238    /// Create a virtual filesystem from a map of files.
239    #[must_use]
240    pub fn from_files(
241        files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
242    ) -> Self {
243        let mut vfs = Self::new();
244        vfs.add_files(files);
245        vfs
246    }
247
248    /// Get the number of files in the virtual filesystem.
249    #[must_use]
250    pub fn len(&self) -> usize {
251        self.files.len()
252    }
253
254    /// Check if the virtual filesystem is empty.
255    #[must_use]
256    pub fn is_empty(&self) -> bool {
257        self.files.is_empty()
258    }
259}
260
261impl FileSystem for VirtualFileSystem {
262    fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
263        let normalized = normalize_vfs_path(path);
264
265        self.files
266            .get(&normalized)
267            .cloned()
268            .ok_or_else(|| LoadError::Io {
269                path: path.to_path_buf(),
270                source: std::io::Error::new(
271                    std::io::ErrorKind::NotFound,
272                    format!("file not found in virtual filesystem: {}", path.display()),
273                ),
274            })
275    }
276
277    fn exists(&self, path: &Path) -> bool {
278        let normalized = normalize_vfs_path(path);
279        self.files.contains_key(&normalized)
280    }
281
282    fn dir_exists(&self, _path: &Path) -> bool {
283        // A virtual filesystem is a flat map of file paths to contents; it has
284        // no directory entries, so it can neither confirm nor disprove that a
285        // document root exists. Answer "yes" so callers do not manufacture an
286        // E7006 for every in-memory load. Routing this through `exists` would
287        // always be false and would do exactly that.
288        true
289    }
290
291    fn is_encrypted(&self, _path: &Path) -> bool {
292        // Virtual filesystem doesn't support encrypted files
293        // Users should decrypt before adding to VFS
294        false
295    }
296
297    fn normalize(&self, path: &Path) -> PathBuf {
298        // For virtual filesystem, just clean up the path without making it absolute
299        normalize_vfs_path(path)
300    }
301
302    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
303        // Normalize the pattern the same way stored keys are normalized,
304        // so that backslashes or leading "./" in the pattern still match.
305        let normalized = pattern.replace('\\', "/");
306        let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
307        let glob_pattern = glob::Pattern::new(normalized).map_err(|e| e.to_string())?;
308        let mut matched: Vec<PathBuf> = self
309            .files
310            .keys()
311            .filter(|path| glob_pattern.matches_path(path))
312            .cloned()
313            .collect();
314        matched.sort();
315        Ok(matched)
316    }
317}
318
319/// Normalize a path for virtual filesystem storage and lookup.
320///
321/// This handles:
322/// - Converting backslashes to forward slashes
323/// - Removing leading `./`
324/// - Simplifying `..` components where possible
325fn normalize_vfs_path(path: &Path) -> PathBuf {
326    let path_str = path.to_string_lossy();
327
328    // Convert backslashes to forward slashes
329    let normalized = path_str.replace('\\', "/");
330
331    // Remove leading ./
332    let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
333
334    // Build normalized path
335    let mut components = Vec::new();
336    for part in normalized.split('/') {
337        match part {
338            "" | "." => {}
339            ".." => {
340                // Only pop if we have non-root components
341                if !components.is_empty() && components.last() != Some(&"..") {
342                    components.pop();
343                } else {
344                    components.push("..");
345                }
346            }
347            _ => components.push(part),
348        }
349    }
350
351    if components.is_empty() {
352        PathBuf::from(".")
353    } else {
354        PathBuf::from(components.join("/"))
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn test_normalize_vfs_path() {
364        assert_eq!(
365            normalize_vfs_path(Path::new("foo/bar")),
366            PathBuf::from("foo/bar")
367        );
368        assert_eq!(
369            normalize_vfs_path(Path::new("./foo/bar")),
370            PathBuf::from("foo/bar")
371        );
372        assert_eq!(
373            normalize_vfs_path(Path::new("foo/../bar")),
374            PathBuf::from("bar")
375        );
376        assert_eq!(
377            normalize_vfs_path(Path::new("foo/./bar")),
378            PathBuf::from("foo/bar")
379        );
380        assert_eq!(
381            normalize_vfs_path(Path::new("foo\\bar")),
382            PathBuf::from("foo/bar")
383        );
384    }
385
386    #[test]
387    fn test_virtual_filesystem_basic() {
388        let mut vfs = VirtualFileSystem::new();
389        vfs.add_file("test.beancount", "2024-01-01 open Assets:Bank USD");
390
391        assert!(vfs.exists(Path::new("test.beancount")));
392        assert!(!vfs.exists(Path::new("nonexistent.beancount")));
393
394        let content = vfs.read(Path::new("test.beancount")).unwrap();
395        assert_eq!(&*content, "2024-01-01 open Assets:Bank USD");
396    }
397
398    #[test]
399    fn test_virtual_filesystem_path_normalization() {
400        let mut vfs = VirtualFileSystem::new();
401        vfs.add_file("foo/bar.beancount", "content");
402
403        // Should find with normalized path
404        assert!(vfs.exists(Path::new("foo/bar.beancount")));
405        assert!(vfs.exists(Path::new("./foo/bar.beancount")));
406
407        // Content should be accessible
408        let content = vfs.read(Path::new("./foo/bar.beancount")).unwrap();
409        assert_eq!(&*content, "content");
410    }
411
412    #[test]
413    fn test_virtual_filesystem_not_encrypted() {
414        let vfs = VirtualFileSystem::new();
415
416        // Virtual filesystem never reports files as encrypted
417        assert!(!vfs.is_encrypted(Path::new("test.gpg")));
418        assert!(!vfs.is_encrypted(Path::new("test.asc")));
419    }
420
421    #[test]
422    fn test_virtual_filesystem_from_files() {
423        let vfs = VirtualFileSystem::from_files([
424            ("a.beancount", "content a"),
425            ("b.beancount", "content b"),
426        ]);
427
428        assert_eq!(vfs.len(), 2);
429        assert!(vfs.exists(Path::new("a.beancount")));
430        assert!(vfs.exists(Path::new("b.beancount")));
431    }
432}