bashkit/fs/mountable.rs
1//! Mountable filesystem implementation.
2//!
3//! [`MountableFs`] allows mounting multiple filesystems at different paths,
4//! similar to Unix mount semantics.
5
6// RwLock.read()/write().unwrap() only panics on lock poisoning (prior panic
7// while holding lock). This is intentional - corrupted state should not propagate.
8#![allow(clippy::unwrap_used)]
9
10use async_trait::async_trait;
11use std::collections::BTreeMap;
12use std::io::Error as IoError;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, RwLock};
15
16use super::limits::{FsLimits, FsUsage};
17use super::traits::{DirEntry, FileSystem, FileType, Metadata};
18use crate::error::Result;
19use std::io::ErrorKind;
20
21/// Filesystem with Unix-style mount points.
22///
23/// `MountableFs` allows mounting different filesystem implementations at
24/// specific paths, similar to how Unix systems mount devices at directories.
25/// This enables complex multi-source filesystem setups.
26///
27/// # Features
28///
29/// - **Multiple mount points**: Mount different filesystems at different paths
30/// - **Nested mounts**: Mount filesystems within other mounts (longest-prefix matching)
31/// - **Dynamic mounting**: Add/remove mounts at runtime
32/// - **Cross-mount operations**: Copy/move files between different mounted filesystems
33///
34/// # Use Cases
35///
36/// - **Hybrid storage**: Combine in-memory temp storage with persistent data stores
37/// - **Multi-tenant isolation**: Mount separate filesystems for different tenants
38/// - **Plugin systems**: Each plugin gets its own mounted filesystem
39/// - **Testing**: Mount mock filesystems for specific paths
40///
41/// # Example: Basic Mounting
42///
43/// ```rust
44/// use bashkit::{Bash, FileSystem, InMemoryFs, MountableFs};
45/// use std::path::Path;
46/// use std::sync::Arc;
47///
48/// # #[tokio::main]
49/// # async fn main() -> bashkit::Result<()> {
50/// // Create root and separate data filesystem
51/// let root = Arc::new(InMemoryFs::new());
52/// let data_fs = Arc::new(InMemoryFs::new());
53///
54/// // Pre-populate data filesystem
55/// data_fs.write_file(Path::new("/users.json"), br#"["alice", "bob"]"#).await?;
56///
57/// // Create mountable filesystem
58/// let mountable = MountableFs::new(root.clone());
59///
60/// // Mount data_fs at /mnt/data
61/// mountable.mount("/mnt/data", data_fs.clone())?;
62///
63/// // Use with Bash
64/// let mut bash = Bash::builder().fs(Arc::new(mountable)).build();
65///
66/// // Access mounted filesystem
67/// let result = bash.exec("cat /mnt/data/users.json").await?;
68/// assert!(result.stdout.contains("alice"));
69///
70/// // Access root filesystem
71/// bash.exec("echo hello > /root.txt").await?;
72/// # Ok(())
73/// # }
74/// ```
75///
76/// # Example: Nested Mounts
77///
78/// ```rust
79/// use bashkit::{FileSystem, InMemoryFs, MountableFs};
80/// use std::path::Path;
81/// use std::sync::Arc;
82///
83/// # #[tokio::main]
84/// # async fn main() -> bashkit::Result<()> {
85/// let root = Arc::new(InMemoryFs::new());
86/// let outer = Arc::new(InMemoryFs::new());
87/// let inner = Arc::new(InMemoryFs::new());
88///
89/// outer.write_file(Path::new("/outer.txt"), b"outer").await?;
90/// inner.write_file(Path::new("/inner.txt"), b"inner").await?;
91///
92/// let mountable = MountableFs::new(root);
93/// mountable.mount("/mnt", outer)?;
94/// mountable.mount("/mnt/nested", inner)?;
95///
96/// // Access outer mount
97/// let content = mountable.read_file(Path::new("/mnt/outer.txt")).await?;
98/// assert_eq!(content, b"outer");
99///
100/// // Access nested mount (longest-prefix matching)
101/// let content = mountable.read_file(Path::new("/mnt/nested/inner.txt")).await?;
102/// assert_eq!(content, b"inner");
103/// # Ok(())
104/// # }
105/// ```
106///
107/// # Example: Dynamic Mount/Unmount
108///
109/// ```rust
110/// use bashkit::{FileSystem, InMemoryFs, MountableFs};
111/// use std::path::Path;
112/// use std::sync::Arc;
113///
114/// # #[tokio::main]
115/// # async fn main() -> bashkit::Result<()> {
116/// let root = Arc::new(InMemoryFs::new());
117/// let plugin_fs = Arc::new(InMemoryFs::new());
118/// plugin_fs.write_file(Path::new("/plugin.so"), b"binary").await?;
119///
120/// let mountable = MountableFs::new(root);
121///
122/// // Mount plugin filesystem
123/// mountable.mount("/plugins", plugin_fs)?;
124/// assert!(mountable.exists(Path::new("/plugins/plugin.so")).await?);
125///
126/// // Unmount when done
127/// mountable.unmount("/plugins")?;
128/// assert!(!mountable.exists(Path::new("/plugins/plugin.so")).await?);
129/// # Ok(())
130/// # }
131/// ```
132///
133/// # Path Resolution
134///
135/// When resolving a path, `MountableFs` uses longest-prefix matching to find
136/// the appropriate filesystem. For example, with mounts at `/mnt` and `/mnt/data`:
137///
138/// - `/mnt/file.txt` → resolves to `/mnt` mount
139/// - `/mnt/data/file.txt` → resolves to `/mnt/data` mount (longer prefix wins)
140/// - `/other/file.txt` → resolves to root filesystem
141pub struct MountableFs {
142 /// Root filesystem (for paths not covered by any mount)
143 root: Arc<dyn FileSystem>,
144 /// Mount points: path -> filesystem
145 /// BTreeMap ensures iteration in path order
146 mounts: RwLock<BTreeMap<PathBuf, Arc<dyn FileSystem>>>,
147}
148
149impl MountableFs {
150 /// Create a new `MountableFs` with the given root filesystem.
151 ///
152 /// The root filesystem is used for all paths that don't match any mount point.
153 ///
154 /// # Example
155 ///
156 /// ```rust
157 /// use bashkit::{FileSystem, InMemoryFs, MountableFs};
158 /// use std::path::Path;
159 /// use std::sync::Arc;
160 ///
161 /// # #[tokio::main]
162 /// # async fn main() -> bashkit::Result<()> {
163 /// let root = Arc::new(InMemoryFs::new());
164 /// let mountable = MountableFs::new(root);
165 ///
166 /// // Paths not covered by mounts go to root
167 /// mountable.write_file(Path::new("/tmp/test.txt"), b"hello").await?;
168 /// # Ok(())
169 /// # }
170 /// ```
171 pub fn new(root: Arc<dyn FileSystem>) -> Self {
172 Self {
173 root,
174 mounts: RwLock::new(BTreeMap::new()),
175 }
176 }
177
178 /// Mount a filesystem at the given path.
179 ///
180 /// After mounting, all operations on paths under the mount point will be
181 /// directed to the mounted filesystem.
182 ///
183 /// # Arguments
184 ///
185 /// * `path` - The mount point (must be an absolute path)
186 /// * `fs` - The filesystem to mount
187 ///
188 /// # Errors
189 ///
190 /// Returns an error if the path is not absolute.
191 ///
192 /// # Example
193 ///
194 /// ```rust
195 /// use bashkit::{FileSystem, InMemoryFs, MountableFs};
196 /// use std::path::Path;
197 /// use std::sync::Arc;
198 ///
199 /// # #[tokio::main]
200 /// # async fn main() -> bashkit::Result<()> {
201 /// let root = Arc::new(InMemoryFs::new());
202 /// let data_fs = Arc::new(InMemoryFs::new());
203 /// data_fs.write_file(Path::new("/data.txt"), b"data").await?;
204 ///
205 /// let mountable = MountableFs::new(root);
206 /// mountable.mount("/data", data_fs)?;
207 ///
208 /// // Access via mount point
209 /// let content = mountable.read_file(Path::new("/data/data.txt")).await?;
210 /// assert_eq!(content, b"data");
211 /// # Ok(())
212 /// # }
213 /// ```
214 pub fn mount(&self, path: impl AsRef<Path>, fs: Arc<dyn FileSystem>) -> Result<()> {
215 let path = Self::normalize_path(path.as_ref());
216
217 if !path.is_absolute() {
218 return Err(IoError::other("mount path must be absolute").into());
219 }
220
221 let mut mounts = self.mounts.write().unwrap();
222 mounts.insert(path, fs);
223 Ok(())
224 }
225
226 /// Unmount a filesystem at the given path.
227 ///
228 /// After unmounting, paths that previously resolved to the mounted filesystem
229 /// will fall back to the root filesystem or a shorter mount prefix.
230 ///
231 /// # Errors
232 ///
233 /// Returns an error if no filesystem is mounted at the given path.
234 ///
235 /// # Example
236 ///
237 /// ```rust
238 /// use bashkit::{FileSystem, InMemoryFs, MountableFs};
239 /// use std::path::Path;
240 /// use std::sync::Arc;
241 ///
242 /// # #[tokio::main]
243 /// # async fn main() -> bashkit::Result<()> {
244 /// let root = Arc::new(InMemoryFs::new());
245 /// let plugin = Arc::new(InMemoryFs::new());
246 /// plugin.write_file(Path::new("/lib.so"), b"binary").await?;
247 ///
248 /// let mountable = MountableFs::new(root);
249 /// mountable.mount("/plugin", plugin)?;
250 ///
251 /// // File is accessible
252 /// assert!(mountable.exists(Path::new("/plugin/lib.so")).await?);
253 ///
254 /// // Unmount
255 /// mountable.unmount("/plugin")?;
256 ///
257 /// // No longer accessible
258 /// assert!(!mountable.exists(Path::new("/plugin/lib.so")).await?);
259 /// # Ok(())
260 /// # }
261 /// ```
262 pub fn unmount(&self, path: impl AsRef<Path>) -> Result<()> {
263 let path = Self::normalize_path(path.as_ref());
264
265 let mut mounts = self.mounts.write().unwrap();
266 mounts
267 .remove(&path)
268 .ok_or_else(|| IoError::other("mount not found"))?;
269 Ok(())
270 }
271
272 /// Normalize a path for consistent lookups
273 fn normalize_path(path: &Path) -> PathBuf {
274 let mut result = PathBuf::new();
275
276 for component in path.components() {
277 match component {
278 std::path::Component::RootDir => {
279 result.push("/");
280 }
281 std::path::Component::Normal(name) => {
282 result.push(name);
283 }
284 std::path::Component::ParentDir => {
285 result.pop();
286 }
287 std::path::Component::CurDir => {}
288 std::path::Component::Prefix(_) => {}
289 }
290 }
291
292 if result.as_os_str().is_empty() {
293 result.push("/");
294 }
295
296 result
297 }
298
299 /// THREAT[TM-DOS-046]: Validate path using root filesystem limits before delegation.
300 fn validate_path(&self, path: &Path) -> Result<()> {
301 self.root
302 .limits()
303 .validate_path(path)
304 .map_err(|e| IoError::new(ErrorKind::InvalidInput, e.to_string()))?;
305 Ok(())
306 }
307
308 /// Resolve a path to the appropriate filesystem and relative path.
309 ///
310 /// Returns (filesystem, path_within_mount).
311 fn resolve(&self, path: &Path) -> (Arc<dyn FileSystem>, PathBuf) {
312 let path = Self::normalize_path(path);
313 let mounts = self.mounts.read().unwrap();
314
315 // Find the longest matching mount point
316 // BTreeMap iteration is in key order, but we need longest match
317 // So we iterate and keep track of the best match
318 let mut best_mount: Option<(&PathBuf, &Arc<dyn FileSystem>)> = None;
319
320 for (mount_path, fs) in mounts.iter() {
321 if path.starts_with(mount_path) {
322 match best_mount {
323 None => best_mount = Some((mount_path, fs)),
324 Some((best_path, _)) => {
325 if mount_path.components().count() > best_path.components().count() {
326 best_mount = Some((mount_path, fs));
327 }
328 }
329 }
330 }
331 }
332
333 match best_mount {
334 Some((mount_path, fs)) => {
335 // Calculate relative path within mount
336 let relative = path
337 .strip_prefix(mount_path)
338 .unwrap_or(Path::new(""))
339 .to_path_buf();
340
341 // Ensure we have an absolute path
342 let resolved = if relative.as_os_str().is_empty() {
343 PathBuf::from("/")
344 } else {
345 PathBuf::from("/").join(relative)
346 };
347
348 (Arc::clone(fs), resolved)
349 }
350 None => {
351 // Use root filesystem
352 (Arc::clone(&self.root), path)
353 }
354 }
355 }
356}
357
358#[async_trait]
359impl FileSystem for MountableFs {
360 async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
361 let (fs, resolved) = self.resolve(path);
362 fs.read_file(&resolved).await
363 }
364
365 async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
366 // THREAT[TM-DOS-046]: Validate path before delegation
367 self.validate_path(path)?;
368 let (fs, resolved) = self.resolve(path);
369 fs.write_file(&resolved, content).await
370 }
371
372 async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
373 self.validate_path(path)?;
374 let (fs, resolved) = self.resolve(path);
375 fs.append_file(&resolved, content).await
376 }
377
378 async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
379 self.validate_path(path)?;
380 let (fs, resolved) = self.resolve(path);
381 fs.mkdir(&resolved, recursive).await
382 }
383
384 async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
385 self.validate_path(path)?;
386 let (fs, resolved) = self.resolve(path);
387 fs.remove(&resolved, recursive).await
388 }
389
390 async fn stat(&self, path: &Path) -> Result<Metadata> {
391 let (fs, resolved) = self.resolve(path);
392 fs.stat(&resolved).await
393 }
394
395 async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
396 let path = Self::normalize_path(path);
397 let (fs, resolved) = self.resolve(&path);
398
399 let mut entries = fs.read_dir(&resolved).await?;
400
401 // Add mount points that are direct children of this directory
402 let mounts = self.mounts.read().unwrap();
403 for mount_path in mounts.keys() {
404 if mount_path.parent() == Some(&path) {
405 if let Some(name) = mount_path.file_name() {
406 // Check if this entry already exists
407 let name_str = name.to_string_lossy().to_string();
408 if !entries.iter().any(|e| e.name == name_str) {
409 entries.push(DirEntry {
410 name: name_str,
411 metadata: Metadata {
412 file_type: FileType::Directory,
413 size: 0,
414 mode: 0o755,
415 modified: std::time::SystemTime::now(),
416 created: std::time::SystemTime::now(),
417 },
418 });
419 }
420 }
421 }
422 }
423
424 Ok(entries)
425 }
426
427 async fn exists(&self, path: &Path) -> Result<bool> {
428 let path = Self::normalize_path(path);
429
430 // Check if this is a mount point
431 {
432 let mounts = self.mounts.read().unwrap();
433 if mounts.contains_key(&path) {
434 return Ok(true);
435 }
436 }
437
438 let (fs, resolved) = self.resolve(&path);
439 fs.exists(&resolved).await
440 }
441
442 async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
443 self.validate_path(from)?;
444 self.validate_path(to)?;
445 let (from_fs, from_resolved) = self.resolve(from);
446 let (to_fs, to_resolved) = self.resolve(to);
447
448 // Check if both paths resolve to the same filesystem
449 // We can only do efficient rename within the same filesystem
450 // For cross-mount rename, we need to copy + delete
451 if Arc::ptr_eq(&from_fs, &to_fs) {
452 from_fs.rename(&from_resolved, &to_resolved).await
453 } else {
454 // Cross-mount rename: copy then delete
455 let content = from_fs.read_file(&from_resolved).await?;
456 to_fs.write_file(&to_resolved, &content).await?;
457 from_fs.remove(&from_resolved, false).await
458 }
459 }
460
461 async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
462 self.validate_path(from)?;
463 self.validate_path(to)?;
464 let (from_fs, from_resolved) = self.resolve(from);
465 let (to_fs, to_resolved) = self.resolve(to);
466
467 if Arc::ptr_eq(&from_fs, &to_fs) {
468 from_fs.copy(&from_resolved, &to_resolved).await
469 } else {
470 // Cross-mount copy
471 let content = from_fs.read_file(&from_resolved).await?;
472 to_fs.write_file(&to_resolved, &content).await
473 }
474 }
475
476 async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
477 self.validate_path(link)?;
478 let (fs, resolved) = self.resolve(link);
479 fs.symlink(target, &resolved).await
480 }
481
482 async fn read_link(&self, path: &Path) -> Result<PathBuf> {
483 let (fs, resolved) = self.resolve(path);
484 fs.read_link(&resolved).await
485 }
486
487 async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
488 self.validate_path(path)?;
489 let (fs, resolved) = self.resolve(path);
490 fs.chmod(&resolved, mode).await
491 }
492
493 fn usage(&self) -> FsUsage {
494 // Aggregate usage from root and all mounts
495 let mut total = self.root.usage();
496
497 let mounts = self.mounts.read().unwrap();
498 for fs in mounts.values() {
499 let mount_usage = fs.usage();
500 total.total_bytes += mount_usage.total_bytes;
501 total.file_count += mount_usage.file_count;
502 total.dir_count += mount_usage.dir_count;
503 }
504
505 total
506 }
507
508 fn limits(&self) -> FsLimits {
509 // Return root filesystem limits as the overall limits
510 self.root.limits()
511 }
512}
513
514#[cfg(test)]
515#[allow(clippy::unwrap_used)]
516mod tests {
517 use super::*;
518 use crate::fs::InMemoryFs;
519
520 #[tokio::test]
521 async fn test_mount_and_access() {
522 let root = Arc::new(InMemoryFs::new());
523 let mounted = Arc::new(InMemoryFs::new());
524
525 // Write to mounted fs
526 mounted
527 .write_file(Path::new("/data.txt"), b"mounted data")
528 .await
529 .unwrap();
530
531 let mfs = MountableFs::new(root.clone());
532 mfs.mount("/mnt/data", mounted.clone()).unwrap();
533
534 // Access through mountable fs
535 let content = mfs
536 .read_file(Path::new("/mnt/data/data.txt"))
537 .await
538 .unwrap();
539 assert_eq!(content, b"mounted data");
540 }
541
542 #[tokio::test]
543 async fn test_write_to_mount() {
544 let root = Arc::new(InMemoryFs::new());
545 let mounted = Arc::new(InMemoryFs::new());
546
547 let mfs = MountableFs::new(root);
548 mfs.mount("/mnt", mounted.clone()).unwrap();
549
550 // Create directory and write file through mountable
551 mfs.mkdir(Path::new("/mnt/subdir"), false).await.unwrap();
552 mfs.write_file(Path::new("/mnt/subdir/test.txt"), b"hello")
553 .await
554 .unwrap();
555
556 // Verify it's in the mounted fs
557 let content = mounted
558 .read_file(Path::new("/subdir/test.txt"))
559 .await
560 .unwrap();
561 assert_eq!(content, b"hello");
562 }
563
564 #[tokio::test]
565 async fn test_nested_mounts() {
566 let root = Arc::new(InMemoryFs::new());
567 let outer = Arc::new(InMemoryFs::new());
568 let inner = Arc::new(InMemoryFs::new());
569
570 outer
571 .write_file(Path::new("/outer.txt"), b"outer")
572 .await
573 .unwrap();
574 inner
575 .write_file(Path::new("/inner.txt"), b"inner")
576 .await
577 .unwrap();
578
579 let mfs = MountableFs::new(root);
580 mfs.mount("/mnt", outer).unwrap();
581 mfs.mount("/mnt/nested", inner).unwrap();
582
583 // Access outer mount
584 let content = mfs.read_file(Path::new("/mnt/outer.txt")).await.unwrap();
585 assert_eq!(content, b"outer");
586
587 // Access nested mount
588 let content = mfs
589 .read_file(Path::new("/mnt/nested/inner.txt"))
590 .await
591 .unwrap();
592 assert_eq!(content, b"inner");
593 }
594
595 #[tokio::test]
596 async fn test_root_fallback() {
597 let root = Arc::new(InMemoryFs::new());
598 root.write_file(Path::new("/root.txt"), b"root data")
599 .await
600 .unwrap();
601
602 let mfs = MountableFs::new(root);
603
604 // Should access root fs
605 let content = mfs.read_file(Path::new("/root.txt")).await.unwrap();
606 assert_eq!(content, b"root data");
607 }
608
609 #[tokio::test]
610 async fn test_mount_point_in_readdir() {
611 let root = Arc::new(InMemoryFs::new());
612 let mounted = Arc::new(InMemoryFs::new());
613
614 let mfs = MountableFs::new(root);
615 mfs.mount("/mnt", mounted).unwrap();
616
617 // Read root directory should show mnt
618 let entries = mfs.read_dir(Path::new("/")).await.unwrap();
619 let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
620 assert!(names.contains(&&"mnt".to_string()));
621 }
622
623 #[tokio::test]
624 async fn test_unmount() {
625 let root = Arc::new(InMemoryFs::new());
626 let mounted = Arc::new(InMemoryFs::new());
627 mounted
628 .write_file(Path::new("/data.txt"), b"data")
629 .await
630 .unwrap();
631
632 let mfs = MountableFs::new(root);
633 mfs.mount("/mnt", mounted).unwrap();
634
635 // Should exist
636 assert!(mfs.exists(Path::new("/mnt/data.txt")).await.unwrap());
637
638 // Unmount
639 mfs.unmount("/mnt").unwrap();
640
641 // Should no longer exist (falls back to root which doesn't have it)
642 assert!(!mfs.exists(Path::new("/mnt/data.txt")).await.unwrap());
643 }
644}