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