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}