Skip to main content

rust_bash/vfs/
readwrite.rs

1//! ReadWriteFs — thin `std::fs` passthrough implementing `VirtualFs`.
2//!
3//! When `root` is set, all paths are resolved relative to it and path
4//! traversal beyond the root is rejected with `PermissionDenied`.
5//!
6//! # Safety (TOCTOU)
7//!
8//! Between path resolution and the actual `std::fs` operation, symlinks
9//! could theoretically be swapped. This is inherent to real-FS operations
10//! and matches the behavior of other chroot-like implementations.
11
12use std::io::Write;
13use std::os::unix::fs::PermissionsExt;
14use std::path::{Component, Path, PathBuf};
15use std::sync::Arc;
16
17use crate::platform::SystemTime;
18
19use crate::error::VfsError;
20use crate::interpreter::pattern::glob_match;
21
22use super::{DirEntry, Metadata, NodeType, VirtualFs};
23
24/// A passthrough filesystem backed by `std::fs`.
25///
26/// With no root restriction, all operations delegate directly to the real
27/// filesystem. When a root is set, paths are confined to the subtree under
28/// that root — acting like a lightweight chroot.
29///
30/// # Example
31///
32/// ```ignore
33/// use rust_bash::{RustBashBuilder, ReadWriteFs};
34/// use std::sync::Arc;
35///
36/// let rwfs = ReadWriteFs::with_root("/tmp/sandbox").unwrap();
37/// let mut shell = RustBashBuilder::new()
38///     .fs(Arc::new(rwfs))
39///     .cwd("/")
40///     .build()
41///     .unwrap();
42///
43/// shell.exec("echo hello > /output.txt").unwrap(); // writes to /tmp/sandbox/output.txt
44/// ```
45pub struct ReadWriteFs {
46    root: Option<PathBuf>,
47}
48
49impl ReadWriteFs {
50    /// Create a ReadWriteFs with unrestricted access to the real filesystem.
51    pub fn new() -> Self {
52        Self { root: None }
53    }
54
55    /// Create a ReadWriteFs restricted to paths under `root`.
56    ///
57    /// All paths are resolved relative to `root`. Path traversal beyond
58    /// `root` (via `..` or symlinks) is rejected with `PermissionDenied`.
59    /// The root directory must exist and is canonicalized on construction.
60    pub fn with_root(root: impl Into<PathBuf>) -> std::io::Result<Self> {
61        let root = root.into().canonicalize()?;
62        Ok(Self { root: Some(root) })
63    }
64
65    /// Resolve a virtual path to a real filesystem path (does not follow the
66    /// final path component if it is a symlink).
67    ///
68    /// When `root` is None, paths are returned as-is.
69    /// When `root` is set:
70    /// 1. Strip leading `/` from path, join with root
71    /// 2. Logically normalize (resolve `.` and `..`)
72    /// 3. Canonicalize the *parent* of the final component (follows symlinks
73    ///    in intermediate directories for security)
74    /// 4. Append the final component without following it
75    /// 5. Verify result starts with root
76    fn resolve(&self, path: &Path) -> Result<PathBuf, VfsError> {
77        let Some(root) = &self.root else {
78            return Ok(path.to_path_buf());
79        };
80
81        // Strip leading '/' to make the path relative to root.
82        let lossy = path.to_string_lossy();
83        let rel_str = lossy.trim_start_matches('/');
84        let joined = if rel_str.is_empty() {
85            root.clone()
86        } else {
87            root.join(rel_str)
88        };
89
90        // Logically resolve . and .. without touching the filesystem.
91        let normalized = logical_normalize(&joined);
92
93        // Quick check: after logical normalization, must still be under root.
94        if !normalized.starts_with(root) {
95            return Err(VfsError::PermissionDenied(path.to_path_buf()));
96        }
97
98        // If the normalized path IS root (e.g., virtual "/"), canonicalize it.
99        if normalized == *root {
100            return Ok(root.clone());
101        }
102
103        // Split into parent and final component.
104        let name = normalized
105            .file_name()
106            .expect("normalized path has a filename")
107            .to_owned();
108        let parent = normalized.parent().unwrap_or(root);
109
110        // Canonicalize the parent (follows symlinks in intermediate dirs).
111        let canonical_parent = canonicalize_existing(parent, path, root)?;
112
113        // Security check on the parent.
114        if !canonical_parent.starts_with(root) {
115            return Err(VfsError::PermissionDenied(path.to_path_buf()));
116        }
117
118        Ok(canonical_parent.join(name))
119    }
120
121    /// Like `resolve`, but also verifies that the *final* component (if it
122    /// is a symlink) doesn't escape the root.  Use for operations that follow
123    /// symlinks (read_file, stat, write_file, etc.).
124    ///
125    /// When the final component is a symlink, returns the canonical (target)
126    /// path to close the TOCTOU gap for the last component.
127    fn resolve_follow(&self, path: &Path) -> Result<PathBuf, VfsError> {
128        let resolved = self.resolve(path)?;
129        if let Some(root) = &self.root {
130            match std::fs::symlink_metadata(&resolved) {
131                Ok(meta) if meta.is_symlink() => {
132                    let canonical =
133                        std::fs::canonicalize(&resolved).map_err(|e| map_io_error(e, path))?;
134                    if !canonical.starts_with(root) {
135                        return Err(VfsError::PermissionDenied(path.to_path_buf()));
136                    }
137                    return Ok(canonical);
138                }
139                Ok(_) => {}
140                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
141                Err(e) => return Err(map_io_error(e, path)),
142            }
143        }
144        Ok(resolved)
145    }
146
147    /// Check whether a real path is within the root (for glob walking).
148    fn is_within_root(&self, real_path: &Path) -> bool {
149        let Some(root) = &self.root else {
150            return true;
151        };
152        match std::fs::canonicalize(real_path) {
153            Ok(canonical) => canonical.starts_with(root),
154            Err(_) => real_path.starts_with(root),
155        }
156    }
157
158    /// Recursive glob walker over the real directory tree.
159    fn glob_walk(
160        &self,
161        real_dir: &Path,
162        components: &[&str],
163        virtual_path: PathBuf,
164        results: &mut Vec<PathBuf>,
165        max: usize,
166    ) {
167        if results.len() >= max || components.is_empty() {
168            if components.is_empty() {
169                results.push(virtual_path);
170            }
171            return;
172        }
173
174        let pattern = components[0];
175        let rest = &components[1..];
176
177        if pattern == "**" {
178            // Zero directories — advance past **
179            self.glob_walk(real_dir, rest, virtual_path.clone(), results, max);
180
181            // One or more directories — recurse into each child
182            let Ok(entries) = std::fs::read_dir(real_dir) else {
183                return;
184            };
185            for entry in entries.flatten() {
186                if results.len() >= max {
187                    return;
188                }
189                let name = entry.file_name().to_string_lossy().into_owned();
190                if name.starts_with('.') {
191                    continue;
192                }
193                let child_real = real_dir.join(&name);
194                let child_virtual = virtual_path.join(&name);
195
196                let is_dir = entry
197                    .file_type()
198                    .is_ok_and(|ft| ft.is_dir() || ft.is_symlink());
199                if is_dir && self.is_within_root(&child_real) {
200                    // Continue with ** (recurse deeper)
201                    self.glob_walk(&child_real, components, child_virtual, results, max);
202                }
203            }
204        } else {
205            let Ok(entries) = std::fs::read_dir(real_dir) else {
206                return;
207            };
208            for entry in entries.flatten() {
209                if results.len() >= max {
210                    return;
211                }
212                let name = entry.file_name().to_string_lossy().into_owned();
213                // Skip hidden files unless pattern explicitly starts with '.'
214                if name.starts_with('.') && !pattern.starts_with('.') {
215                    continue;
216                }
217                if glob_match(pattern, &name) {
218                    let child_real = real_dir.join(&name);
219                    let child_virtual = virtual_path.join(&name);
220                    if rest.is_empty() {
221                        results.push(child_virtual);
222                    } else {
223                        let is_dir = entry
224                            .file_type()
225                            .is_ok_and(|ft| ft.is_dir() || ft.is_symlink());
226                        if is_dir && self.is_within_root(&child_real) {
227                            self.glob_walk(&child_real, rest, child_virtual, results, max);
228                        }
229                    }
230                }
231            }
232        }
233    }
234}
235
236impl Default for ReadWriteFs {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242// ---------------------------------------------------------------------------
243// VirtualFs implementation
244// ---------------------------------------------------------------------------
245
246impl VirtualFs for ReadWriteFs {
247    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {
248        let resolved = self.resolve_follow(path)?;
249        std::fs::read(&resolved).map_err(|e| map_io_error(e, path))
250    }
251
252    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {
253        let resolved = self.resolve_follow(path)?;
254        std::fs::write(&resolved, content).map_err(|e| map_io_error(e, path))
255    }
256
257    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {
258        let resolved = self.resolve_follow(path)?;
259        let mut file = std::fs::OpenOptions::new()
260            .append(true)
261            .open(&resolved)
262            .map_err(|e| map_io_error(e, path))?;
263        file.write_all(content).map_err(|e| map_io_error(e, path))
264    }
265
266    fn remove_file(&self, path: &Path) -> Result<(), VfsError> {
267        let resolved = self.resolve(path)?;
268        std::fs::remove_file(&resolved).map_err(|e| map_io_error(e, path))
269    }
270
271    fn mkdir(&self, path: &Path) -> Result<(), VfsError> {
272        let resolved = self.resolve(path)?;
273        std::fs::create_dir(&resolved).map_err(|e| map_io_error(e, path))
274    }
275
276    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError> {
277        let resolved = self.resolve(path)?;
278        std::fs::create_dir_all(&resolved).map_err(|e| map_io_error(e, path))
279    }
280
281    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {
282        let resolved = self.resolve_follow(path)?;
283        let entries = std::fs::read_dir(&resolved).map_err(|e| map_io_error(e, path))?;
284        let mut result = Vec::new();
285        for entry in entries {
286            let entry = entry.map_err(|e| map_io_error(e, path))?;
287            let ft = entry.file_type().map_err(|e| map_io_error(e, path))?;
288            let node_type = if ft.is_dir() {
289                NodeType::Directory
290            } else if ft.is_symlink() {
291                NodeType::Symlink
292            } else {
293                NodeType::File
294            };
295            result.push(DirEntry {
296                name: entry.file_name().to_string_lossy().into_owned(),
297                node_type,
298            });
299        }
300        Ok(result)
301    }
302
303    fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {
304        let resolved = self.resolve(path)?;
305        std::fs::remove_dir(&resolved).map_err(|e| map_io_error(e, path))
306    }
307
308    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> {
309        let resolved = self.resolve(path)?;
310        std::fs::remove_dir_all(&resolved).map_err(|e| map_io_error(e, path))
311    }
312
313    fn exists(&self, path: &Path) -> bool {
314        match self.resolve(path) {
315            Ok(resolved) => resolved.exists(),
316            Err(_) => false,
317        }
318    }
319
320    fn stat(&self, path: &Path) -> Result<Metadata, VfsError> {
321        let resolved = self.resolve_follow(path)?;
322        let meta = std::fs::metadata(&resolved).map_err(|e| map_io_error(e, path))?;
323        Ok(map_metadata(&meta))
324    }
325
326    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError> {
327        let resolved = self.resolve(path)?;
328        let meta = std::fs::symlink_metadata(&resolved).map_err(|e| map_io_error(e, path))?;
329        Ok(map_metadata(&meta))
330    }
331
332    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError> {
333        let resolved = self.resolve_follow(path)?;
334        let perms = std::fs::Permissions::from_mode(mode);
335        std::fs::set_permissions(&resolved, perms).map_err(|e| map_io_error(e, path))
336    }
337
338    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> {
339        let resolved = self.resolve_follow(path)?;
340        let file = std::fs::File::options()
341            .write(true)
342            .open(&resolved)
343            .map_err(|e| map_io_error(e, path))?;
344        file.set_times(std::fs::FileTimes::new().set_modified(mtime))
345            .map_err(|e| map_io_error(e, path))
346    }
347
348    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {
349        let resolved_link = self.resolve(link)?;
350        // If rooted and target is absolute, resolve it too so the on-disk
351        // symlink points to the correct real location.
352        let actual_target = if target.is_absolute() && self.root.is_some() {
353            self.resolve(target)?
354        } else {
355            target.to_path_buf()
356        };
357        std::os::unix::fs::symlink(&actual_target, &resolved_link)
358            .map_err(|e| map_io_error(e, link))
359    }
360
361    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {
362        let resolved_src = self.resolve_follow(src)?;
363        let resolved_dst = self.resolve(dst)?;
364        std::fs::hard_link(&resolved_src, &resolved_dst).map_err(|e| {
365            if e.kind() == std::io::ErrorKind::NotFound {
366                map_io_error(e, src)
367            } else {
368                map_io_error(e, dst)
369            }
370        })
371    }
372
373    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError> {
374        let resolved = self.resolve(path)?;
375        let target = std::fs::read_link(&resolved).map_err(|e| map_io_error(e, path))?;
376        // If rooted and target is absolute, convert back to virtual.
377        if let Some(root) = &self.root
378            && target.is_absolute()
379            && let Ok(rel) = target.strip_prefix(root)
380        {
381            return Ok(PathBuf::from("/").join(rel));
382        }
383        Ok(target)
384    }
385
386    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError> {
387        let resolved = self.resolve(path)?;
388        let canonical = std::fs::canonicalize(&resolved).map_err(|e| map_io_error(e, path))?;
389        if let Some(root) = &self.root {
390            if !canonical.starts_with(root) {
391                return Err(VfsError::PermissionDenied(path.to_path_buf()));
392            }
393            let rel = canonical.strip_prefix(root).unwrap();
394            Ok(PathBuf::from("/").join(rel))
395        } else {
396            Ok(canonical)
397        }
398    }
399
400    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {
401        let resolved_src = self.resolve_follow(src)?;
402        let resolved_dst = self.resolve(dst)?;
403        std::fs::copy(&resolved_src, &resolved_dst).map_err(|e| {
404            if e.kind() == std::io::ErrorKind::NotFound {
405                map_io_error(e, src)
406            } else {
407                map_io_error(e, dst)
408            }
409        })?;
410        Ok(())
411    }
412
413    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {
414        let resolved_src = self.resolve(src)?;
415        let resolved_dst = self.resolve(dst)?;
416        std::fs::rename(&resolved_src, &resolved_dst).map_err(|e| {
417            if e.kind() == std::io::ErrorKind::NotFound {
418                map_io_error(e, src)
419            } else {
420                map_io_error(e, dst)
421            }
422        })
423    }
424
425    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError> {
426        let is_absolute = pattern.starts_with('/');
427        let abs_pattern = if is_absolute {
428            pattern.to_string()
429        } else {
430            let cwd_str = cwd.to_str().unwrap_or("/").trim_end_matches('/');
431            format!("{cwd_str}/{pattern}")
432        };
433
434        let components: Vec<&str> = abs_pattern.split('/').filter(|s| !s.is_empty()).collect();
435
436        // Always walk from the real root — pattern components are absolute.
437        let real_root = self.resolve(Path::new("/"))?;
438
439        let mut results = Vec::new();
440        let max = 100_000;
441        self.glob_walk(
442            &real_root,
443            &components,
444            PathBuf::from("/"),
445            &mut results,
446            max,
447        );
448
449        results.sort();
450        results.dedup();
451
452        if !is_absolute {
453            results = results
454                .into_iter()
455                .filter_map(|p| p.strip_prefix(cwd).ok().map(|r| r.to_path_buf()))
456                .collect();
457        }
458
459        Ok(results)
460    }
461
462    fn deep_clone(&self) -> Arc<dyn VirtualFs> {
463        // ReadWriteFs is a passthrough — there's no in-memory state to isolate.
464        // Subshell writes hit the real filesystem, same as the parent.
465        Arc::new(Self {
466            root: self.root.clone(),
467        })
468    }
469}
470
471// ---------------------------------------------------------------------------
472// Helpers
473// ---------------------------------------------------------------------------
474
475/// Logically normalize a path by resolving `.` and `..` without filesystem access.
476fn logical_normalize(path: &Path) -> PathBuf {
477    let mut parts: Vec<&std::ffi::OsStr> = Vec::new();
478    for comp in path.components() {
479        match comp {
480            Component::RootDir | Component::Prefix(_) => {
481                parts.clear();
482            }
483            Component::CurDir => {}
484            Component::ParentDir => {
485                parts.pop();
486            }
487            Component::Normal(c) => parts.push(c),
488        }
489    }
490    let mut result = PathBuf::from("/");
491    for part in parts {
492        result.push(part);
493    }
494    result
495}
496
497/// Canonicalize a path, walking up to find the deepest existing ancestor
498/// when the full path doesn't exist.  Non-existent tail components are
499/// appended back after canonicalizing the existing prefix.
500fn canonicalize_existing(path: &Path, original: &Path, root: &Path) -> Result<PathBuf, VfsError> {
501    let mut existing = path.to_path_buf();
502    let mut tail: Vec<std::ffi::OsString> = Vec::new();
503    while !existing.exists() {
504        match existing.file_name() {
505            Some(name) => {
506                tail.push(name.to_owned());
507                existing.pop();
508            }
509            None => break,
510        }
511    }
512    let canonical = if existing.exists() {
513        std::fs::canonicalize(&existing).map_err(|e| map_io_error(e, original))?
514    } else {
515        existing
516    };
517
518    // Security check on the canonicalized existing portion.
519    if !canonical.starts_with(root) {
520        return Err(VfsError::PermissionDenied(original.to_path_buf()));
521    }
522
523    let mut result = canonical;
524    for component in tail.into_iter().rev() {
525        result.push(component);
526    }
527    Ok(result)
528}
529
530/// Map `std::io::Error` to `VfsError`.
531fn map_io_error(err: std::io::Error, path: &Path) -> VfsError {
532    let p = path.to_path_buf();
533    match err.kind() {
534        std::io::ErrorKind::NotFound => VfsError::NotFound(p),
535        std::io::ErrorKind::AlreadyExists => VfsError::AlreadyExists(p),
536        std::io::ErrorKind::PermissionDenied => VfsError::PermissionDenied(p),
537        std::io::ErrorKind::DirectoryNotEmpty => VfsError::DirectoryNotEmpty(p),
538        std::io::ErrorKind::NotADirectory => VfsError::NotADirectory(p),
539        std::io::ErrorKind::IsADirectory => VfsError::IsADirectory(p),
540        _ => VfsError::IoError(err.to_string()),
541    }
542}
543
544/// Map `std::fs::Metadata` to our `vfs::Metadata`.
545fn map_metadata(meta: &std::fs::Metadata) -> Metadata {
546    let node_type = if meta.is_symlink() {
547        NodeType::Symlink
548    } else if meta.is_dir() {
549        NodeType::Directory
550    } else {
551        NodeType::File
552    };
553    Metadata {
554        node_type,
555        size: meta.len(),
556        mode: meta.permissions().mode(),
557        mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
558    }
559}