Skip to main content

bashkit/fs/
realfs.rs

1// Decision: RealFs is a FsBackend that delegates to the real host filesystem,
2// scoped to a root directory. It supports readonly and readwrite modes.
3// Security: path traversal is prevented by canonicalizing the resolved path or
4// the nearest existing ancestor, then checking the root prefix before I/O.
5// This module is only available with the `realfs` feature flag.
6
7//! Real filesystem backend.
8//!
9//! [`RealFs`] provides access to a directory on the host filesystem as an
10//! [`FsBackend`]. It is gated behind the `realfs` feature flag because it
11//! intentionally breaks the sandbox boundary.
12//!
13//! # Security
14//!
15//! - All paths are resolved relative to a configured root directory.
16//! - Path traversal via `..` or symlink hops in missing path suffixes is
17//!   blocked by canonicalizing the resolved path or nearest existing ancestor
18//!   and checking it stays under the root.
19//! - Readonly mode rejects all write operations at the backend level.
20//!
21//! # Modes
22//!
23//! | Mode | Reads | Writes | Use case |
24//! |------|-------|--------|----------|
25//! | `RealFsMode::ReadOnly` | Yes | No | Expose host files to scripts safely |
26//! | `RealFsMode::ReadWrite` | Yes | Yes | Let scripts modify host files (dangerous) |
27//!
28//! # Builder API (Recommended)
29//!
30//! The easiest way to use RealFs is through the builder on [`Bash`](crate::Bash):
31//!
32//! ```rust,no_run
33//! use bashkit::Bash;
34//!
35//! // Readonly: host files visible at /mnt/data, writes go to in-memory overlay
36//! let bash = Bash::builder()
37//!     .mount_real_readonly_at("/tmp", "/mnt/data")
38//!     .build();
39//!
40//! // Read-write: scripts can modify host files (dangerous!)
41//! let bash = Bash::builder()
42//!     .mount_real_readwrite_at("/tmp", "/mnt/workspace")
43//!     .build();
44//! ```
45//!
46//! # Direct Usage
47//!
48//! For full control, create a `RealFs` backend and wrap it with
49//! [`PosixFs`](super::PosixFs):
50//!
51//! ```rust,no_run
52//! use bashkit::PosixFs;
53//! use bashkit::{RealFs, RealFsMode};
54//! use std::sync::Arc;
55//!
56//! let backend = RealFs::new("/tmp", RealFsMode::ReadOnly).unwrap();
57//! let fs = Arc::new(PosixFs::new(backend));
58//! let bash = bashkit::Bash::builder().fs(fs).build();
59//! ```
60//!
61//! # CLI
62//!
63//! ```bash
64//! bashkit --mount-ro /path/to/data:/mnt/data -c 'cat /mnt/data/file.txt'
65//! bashkit --mount-rw /path/to/out:/mnt/out -c 'echo hi > /mnt/out/result.txt'
66//! ```
67
68use crate::time_compat::SystemTime;
69use async_trait::async_trait;
70use std::io::{Error as IoError, ErrorKind};
71use std::path::{Path, PathBuf};
72
73use super::backend::FsBackend;
74use super::limits::{FsLimits, FsUsage};
75use super::traits::{DirEntry, FileType, Metadata};
76use crate::error::Result;
77
78/// Access mode for the real filesystem backend.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum RealFsMode {
81    /// Read-only access. All write operations return permission denied.
82    ReadOnly,
83    /// Read-write access. Scripts can modify files on the host filesystem.
84    ///
85    /// # Warning
86    ///
87    /// This breaks the sandbox boundary. Only use when the script is trusted
88    /// and the root directory is scoped appropriately.
89    ReadWrite,
90}
91
92/// Real filesystem backend scoped to a root directory.
93///
94/// Wraps host filesystem access with path containment and optional readonly
95/// enforcement. Use with [`PosixFs`](super::PosixFs) for POSIX semantics.
96///
97/// # Example
98///
99/// ```rust,no_run
100/// use bashkit::{RealFs, RealFsMode};
101/// use bashkit::PosixFs;
102/// use std::sync::Arc;
103///
104/// let backend = RealFs::new("/tmp", RealFsMode::ReadOnly).unwrap();
105/// let fs = Arc::new(PosixFs::new(backend));
106/// let bash = bashkit::Bash::builder().fs(fs).build();
107/// ```
108pub struct RealFs {
109    /// Canonicalized root directory on the host.
110    root: PathBuf,
111    mode: RealFsMode,
112}
113
114impl RealFs {
115    /// Create a new RealFs backend rooted at the given directory.
116    ///
117    /// The root path is canonicalized on creation. Returns an error if the
118    /// path does not exist or is not a directory.
119    pub fn new(root: impl AsRef<Path>, mode: RealFsMode) -> std::io::Result<Self> {
120        let root = std::fs::canonicalize(root.as_ref())?;
121        if !root.is_dir() {
122            return Err(IoError::new(
123                ErrorKind::NotADirectory,
124                format!("realfs root is not a directory: {}", root.display()),
125            ));
126        }
127        Ok(Self { root, mode })
128    }
129
130    /// Resolve a virtual path to a real host path, ensuring it stays under root.
131    ///
132    /// Virtual paths are absolute (e.g. `/foo/bar`). We strip the leading `/`
133    /// and join onto the root. Then we canonicalize the full path (for
134    /// existing paths) or the nearest existing ancestor (for new paths) to
135    /// prevent traversal and symlink escapes before attaching the missing
136    /// suffix.
137    fn resolve(&self, vpath: &Path) -> std::io::Result<PathBuf> {
138        let normalized = normalize_vpath(vpath);
139        // Strip leading "/" to make it relative
140        let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
141
142        // For root path itself
143        if relative == Path::new("") {
144            return Ok(self.root.clone());
145        }
146
147        let joined = self.root.join(relative);
148
149        // If the path exists, canonicalize and check
150        if joined.exists() {
151            let canon = std::fs::canonicalize(&joined)?;
152            if !canon.starts_with(&self.root) {
153                return Err(IoError::new(
154                    ErrorKind::PermissionDenied,
155                    "path escapes realfs root",
156                ));
157            }
158            return Ok(canon);
159        }
160
161        // THREAT[TM-ESC-003]: New host paths still need containment checks.
162        // Canonicalize the nearest existing ancestor first so symlink hops in
163        // any existing prefix cannot redirect creation outside the mount root.
164        let mut nearest_existing = joined.as_path();
165        while !nearest_existing.exists() {
166            nearest_existing = nearest_existing.parent().ok_or_else(|| {
167                IoError::new(ErrorKind::PermissionDenied, "path escapes realfs root")
168            })?;
169        }
170
171        let canon_existing = std::fs::canonicalize(nearest_existing)?;
172        if !canon_existing.starts_with(&self.root) {
173            return Err(IoError::new(
174                ErrorKind::PermissionDenied,
175                "path escapes realfs root",
176            ));
177        }
178
179        let suffix = joined
180            .strip_prefix(nearest_existing)
181            .map_err(|_| IoError::new(ErrorKind::PermissionDenied, "path escapes realfs root"))?;
182        let candidate = normalize_host_path(&canon_existing.join(suffix));
183
184        if !candidate.starts_with(&self.root) {
185            return Err(IoError::new(
186                ErrorKind::PermissionDenied,
187                "path escapes realfs root",
188            ));
189        }
190
191        Ok(candidate)
192    }
193
194    /// Resolve a virtual path *without* dereferencing the final path
195    /// component, used by operations that act on the directory entry itself
196    /// (`stat`, `read_link`, `remove`).
197    ///
198    /// Issue #1578: the standard `resolve()` canonicalizes the full path,
199    /// so a symlink at the leaf was always followed — `stat('/link')`
200    /// reported the target, `read_link('/link')` failed, and
201    /// `remove('/link', recursive=true)` could `remove_dir_all` the target
202    /// tree. Here we canonicalize only the parent, verify it stays under
203    /// root, then append the basename verbatim. The caller must then use
204    /// `symlink_metadata`/`read_link`/`remove_file` on the result.
205    fn resolve_no_follow(&self, vpath: &Path) -> std::io::Result<PathBuf> {
206        let normalized = normalize_vpath(vpath);
207        let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
208
209        if relative == Path::new("") {
210            return Ok(self.root.clone());
211        }
212
213        let joined = self.root.join(relative);
214        let parent = joined
215            .parent()
216            .ok_or_else(|| IoError::new(ErrorKind::PermissionDenied, "path escapes realfs root"))?;
217        let file_name = joined
218            .file_name()
219            .ok_or_else(|| IoError::new(ErrorKind::InvalidInput, "path has no final component"))?;
220
221        let canon_parent = std::fs::canonicalize(parent)?;
222        if !canon_parent.starts_with(&self.root) {
223            return Err(IoError::new(
224                ErrorKind::PermissionDenied,
225                "path escapes realfs root",
226            ));
227        }
228
229        Ok(canon_parent.join(file_name))
230    }
231
232    /// Resolve a virtual path for a *creating write* (write/append/copy
233    /// destination). Rejects paths whose final component is a symlink —
234    /// dangling or otherwise — so that the kernel cannot redirect the
235    /// open(2) to a target outside the mount root (issue #1575).
236    ///
237    /// `Path::exists()` follows symlinks and treats dangling links as
238    /// missing, so the standard `resolve()` fallback would happily produce
239    /// a host path whose leaf is still a symlink to outside the root.
240    /// `symlink_metadata` describes the link itself, catching that case.
241    fn resolve_for_create(&self, vpath: &Path) -> std::io::Result<PathBuf> {
242        let normalized = normalize_vpath(vpath);
243        let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
244
245        if relative != Path::new("") {
246            let joined = self.root.join(relative);
247            if matches!(
248                std::fs::symlink_metadata(&joined),
249                Ok(m) if m.file_type().is_symlink()
250            ) {
251                return Err(IoError::new(
252                    ErrorKind::PermissionDenied,
253                    "refusing to create or write through a symlink (sandbox security)",
254                ));
255            }
256        }
257
258        self.resolve(vpath)
259    }
260
261    /// Check that the mode allows writes. Returns PermissionDenied if readonly.
262    fn check_writable(&self) -> std::io::Result<()> {
263        if self.mode == RealFsMode::ReadOnly {
264            return Err(IoError::new(
265                ErrorKind::PermissionDenied,
266                "realfs is mounted readonly",
267            ));
268        }
269        Ok(())
270    }
271
272    /// Get the root directory path.
273    pub fn root(&self) -> &Path {
274        &self.root
275    }
276
277    /// Get the access mode.
278    pub fn mode(&self) -> RealFsMode {
279        self.mode
280    }
281}
282
283impl std::fmt::Debug for RealFs {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct("RealFs")
286            .field("root", &self.root)
287            .field("mode", &self.mode)
288            .finish()
289    }
290}
291
292fn file_type_from_std(ft: std::fs::FileType) -> FileType {
293    if ft.is_dir() {
294        FileType::Directory
295    } else if ft.is_symlink() {
296        FileType::Symlink
297    } else {
298        FileType::File
299    }
300}
301
302fn metadata_from_std(m: &std::fs::Metadata) -> Metadata {
303    let file_type = file_type_from_std(m.file_type());
304    let size = if file_type.is_dir() { 0 } else { m.len() };
305    #[cfg(unix)]
306    let mode = {
307        use std::os::unix::fs::PermissionsExt;
308        m.permissions().mode() & 0o7777
309    };
310    #[cfg(not(unix))]
311    let mode = if m.permissions().readonly() {
312        0o444
313    } else {
314        0o644
315    };
316    Metadata {
317        file_type,
318        size,
319        mode,
320        modified: m.modified().unwrap_or(SystemTime::UNIX_EPOCH),
321        created: m.created().unwrap_or(SystemTime::UNIX_EPOCH),
322    }
323}
324
325/// Normalize a host path by logically resolving `.` and `..` components.
326///
327/// Unlike `std::fs::canonicalize`, this does not touch the filesystem, so it
328/// works for paths whose parents don't exist yet. Used in the `resolve()`
329/// fallback to validate containment without a TOCTOU window.
330fn normalize_host_path(path: &Path) -> PathBuf {
331    let mut components = Vec::new();
332    for component in path.components() {
333        match component {
334            std::path::Component::ParentDir => {
335                // Only pop Normal components; never pop RootDir or Prefix
336                if matches!(components.last(), Some(std::path::Component::Normal(_))) {
337                    components.pop();
338                }
339            }
340            std::path::Component::CurDir => {}
341            c => components.push(c),
342        }
343    }
344    if components.is_empty() {
345        PathBuf::from("/")
346    } else {
347        components.iter().collect()
348    }
349}
350
351/// Normalize a virtual path: collapse `.` and `..`, ensure absolute.
352fn normalize_vpath(path: &Path) -> PathBuf {
353    let mut components = Vec::new();
354    for component in path.components() {
355        match component {
356            std::path::Component::RootDir => {
357                components.clear();
358                components.push(std::path::Component::RootDir);
359            }
360            std::path::Component::CurDir => {}
361            std::path::Component::ParentDir => {
362                if components.len() > 1 {
363                    components.pop();
364                }
365            }
366            c => components.push(c),
367        }
368    }
369    if components.is_empty() {
370        PathBuf::from("/")
371    } else {
372        components.iter().collect()
373    }
374}
375
376#[async_trait]
377impl FsBackend for RealFs {
378    async fn read(&self, path: &Path) -> Result<Vec<u8>> {
379        let real = self.resolve(path)?;
380        let data = tokio::fs::read(&real).await?;
381        Ok(data)
382    }
383
384    async fn write(&self, path: &Path, content: &[u8]) -> Result<()> {
385        self.check_writable()?;
386        // Issue #1575: refuse to follow a leaf symlink (dangling or
387        // otherwise) so the kernel can't be tricked into creating a file
388        // outside the mount root.
389        let real = self.resolve_for_create(path)?;
390        if let Some(parent) = real.parent() {
391            tokio::fs::create_dir_all(parent).await?;
392        }
393        tokio::fs::write(&real, content).await?;
394        Ok(())
395    }
396
397    async fn append(&self, path: &Path, content: &[u8]) -> Result<()> {
398        self.check_writable()?;
399        // Issue #1575: same leaf-symlink rejection as write().
400        let real = self.resolve_for_create(path)?;
401        use tokio::io::AsyncWriteExt;
402        let mut file = tokio::fs::OpenOptions::new()
403            .create(true)
404            .append(true)
405            .open(&real)
406            .await?;
407        file.write_all(content).await?;
408        file.flush().await?;
409        Ok(())
410    }
411
412    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
413        self.check_writable()?;
414        let real = self.resolve(path)?;
415        if recursive {
416            tokio::fs::create_dir_all(&real).await?;
417        } else {
418            tokio::fs::create_dir(&real).await?;
419        }
420        Ok(())
421    }
422
423    async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
424        self.check_writable()?;
425        // Issue #1578: use no-follow resolution + symlink_metadata so
426        // `remove('/link', recursive=true)` unlinks the symlink instead of
427        // recursively wiping its target.
428        let real = self.resolve_no_follow(path)?;
429        let meta = tokio::fs::symlink_metadata(&real).await?;
430        let ft = meta.file_type();
431        if ft.is_symlink() {
432            tokio::fs::remove_file(&real).await?;
433        } else if ft.is_dir() {
434            if recursive {
435                tokio::fs::remove_dir_all(&real).await?;
436            } else {
437                tokio::fs::remove_dir(&real).await?;
438            }
439        } else {
440            tokio::fs::remove_file(&real).await?;
441        }
442        Ok(())
443    }
444
445    async fn stat(&self, path: &Path) -> Result<Metadata> {
446        // Issue #1578: don't dereference a final symlink — stat must
447        // describe the link itself.
448        let real = self.resolve_no_follow(path)?;
449        let meta = tokio::fs::symlink_metadata(&real).await?;
450        Ok(metadata_from_std(&meta))
451    }
452
453    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
454        let real = self.resolve(path)?;
455        let mut entries = Vec::new();
456        let mut dir = tokio::fs::read_dir(&real).await?;
457        while let Some(entry) = dir.next_entry().await? {
458            let name = entry.file_name().to_string_lossy().to_string();
459            let meta = entry.metadata().await?;
460            entries.push(DirEntry {
461                name,
462                metadata: metadata_from_std(&meta),
463            });
464        }
465        // Sort for deterministic output
466        entries.sort_by(|a, b| a.name.cmp(&b.name));
467        Ok(entries)
468    }
469
470    async fn exists(&self, path: &Path) -> Result<bool> {
471        let real = self.resolve(path)?;
472        Ok(tokio::fs::try_exists(&real).await.unwrap_or(false))
473    }
474
475    async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
476        self.check_writable()?;
477        let real_from = self.resolve(from)?;
478        let real_to = self.resolve(to)?;
479        tokio::fs::rename(&real_from, &real_to).await?;
480        Ok(())
481    }
482
483    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
484        self.check_writable()?;
485        let real_from = self.resolve(from)?;
486        // Issue #1575: refuse to copy through a leaf symlink at the
487        // destination — that would let an attacker write outside root.
488        let real_to = self.resolve_for_create(to)?;
489        tokio::fs::copy(&real_from, &real_to).await?;
490        Ok(())
491    }
492
493    /// THREAT[TM-ESC-003]: Symlink creation in RealFs is allowed only in
494    /// ReadWrite mode. The OS resolves symlink targets on the host filesystem,
495    /// so we must validate that the effective target stays within the mount
496    /// root on disk. Absolute targets are rejected. Relative targets are
497    /// resolved through the nearest existing host ancestor so existing symlink
498    /// components cannot redirect outside root.
499    async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
500        self.check_writable()?;
501        let real_link = self.resolve(link)?;
502
503        // Absolute targets always escape the mount root on disk
504        if target.is_absolute() {
505            return Err(IoError::new(
506                ErrorKind::PermissionDenied,
507                "symlink with absolute target not allowed in RealFs (sandbox security)",
508            )
509            .into());
510        }
511
512        // Relative RealFs symlinks must not contain `..`. The stored bytes are
513        // reinterpreted by external host processes after later renames, so a
514        // creation-time containment proof is only stable for child-only paths.
515        if target
516            .components()
517            .any(|component| matches!(component, std::path::Component::ParentDir))
518        {
519            return Err(IoError::new(
520                ErrorKind::PermissionDenied,
521                "symlink target with parent components not allowed in RealFs (sandbox security)",
522            )
523            .into());
524        }
525
526        // Relative targets: resolve against the link's host-side parent.
527        // Canonicalize nearest existing ancestor of the effective path so
528        // existing symlink components in the target are enforced.
529        let link_parent = real_link.parent().unwrap_or(&self.root);
530        let joined = normalize_host_path(&link_parent.join(target));
531        let mut nearest_existing = joined.as_path();
532        while !nearest_existing.exists() {
533            nearest_existing = nearest_existing.parent().ok_or_else(|| {
534                IoError::new(
535                    ErrorKind::PermissionDenied,
536                    "symlink target escapes realfs root (sandbox security)",
537                )
538            })?;
539        }
540
541        let canon_existing = std::fs::canonicalize(nearest_existing)?;
542        if !canon_existing.starts_with(&self.root) {
543            return Err(IoError::new(
544                ErrorKind::PermissionDenied,
545                "symlink target escapes realfs root (sandbox security)",
546            )
547            .into());
548        }
549        let suffix = joined.strip_prefix(nearest_existing).map_err(|_| {
550            IoError::new(
551                ErrorKind::PermissionDenied,
552                "symlink target escapes realfs root (sandbox security)",
553            )
554        })?;
555        let effective = normalize_host_path(&canon_existing.join(suffix));
556        if !effective.starts_with(&self.root) {
557            return Err(IoError::new(
558                ErrorKind::PermissionDenied,
559                "symlink target escapes realfs root (sandbox security)",
560            )
561            .into());
562        }
563
564        #[cfg(unix)]
565        {
566            tokio::fs::symlink(target, &real_link).await?;
567        }
568        #[cfg(not(unix))]
569        {
570            let _ = target;
571            tokio::fs::write(&real_link, "").await?;
572        }
573        Ok(())
574    }
575
576    async fn read_link(&self, path: &Path) -> Result<PathBuf> {
577        // Issue #1578: keep the link's basename intact so read_link
578        // reports the symlink's own target rather than failing on a
579        // canonicalized non-symlink path.
580        let real = self.resolve_no_follow(path)?;
581        let target = tokio::fs::read_link(&real).await?;
582        Ok(target)
583    }
584
585    async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
586        self.check_writable()?;
587        let real = self.resolve(path)?;
588        #[cfg(unix)]
589        {
590            use std::os::unix::fs::PermissionsExt;
591            let perms = std::fs::Permissions::from_mode(mode);
592            tokio::fs::set_permissions(&real, perms).await?;
593        }
594        #[cfg(not(unix))]
595        {
596            let _ = (mode, &real);
597        }
598        Ok(())
599    }
600
601    async fn set_modified_time(&self, path: &Path, time: SystemTime) -> Result<()> {
602        self.check_writable()?;
603        let real = self.resolve(path)?;
604        let file = std::fs::File::open(&real)?;
605        file.set_modified(time)?;
606        Ok(())
607    }
608
609    fn usage(&self) -> FsUsage {
610        // Could walk the real directory, but that's expensive. Return zeros.
611        FsUsage::default()
612    }
613
614    fn limits(&self) -> FsLimits {
615        FsLimits::unlimited()
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use tempfile::TempDir;
623
624    fn setup() -> TempDir {
625        let dir = tempfile::tempdir().unwrap();
626        // Create some test files
627        std::fs::write(dir.path().join("hello.txt"), b"hello world").unwrap();
628        std::fs::create_dir(dir.path().join("subdir")).unwrap();
629        std::fs::write(dir.path().join("subdir/nested.txt"), b"nested content").unwrap();
630        dir
631    }
632
633    #[tokio::test]
634    async fn read_file() {
635        let dir = setup();
636        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
637        let data = fs.read(Path::new("/hello.txt")).await.unwrap();
638        assert_eq!(data, b"hello world");
639    }
640
641    #[tokio::test]
642    async fn read_nested() {
643        let dir = setup();
644        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
645        let data = fs.read(Path::new("/subdir/nested.txt")).await.unwrap();
646        assert_eq!(data, b"nested content");
647    }
648
649    #[tokio::test]
650    async fn read_root_dir() {
651        let dir = setup();
652        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
653        let entries = fs.read_dir(Path::new("/")).await.unwrap();
654        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
655        assert!(names.contains(&"hello.txt"));
656        assert!(names.contains(&"subdir"));
657    }
658
659    #[tokio::test]
660    async fn stat_file() {
661        let dir = setup();
662        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
663        let meta = fs.stat(Path::new("/hello.txt")).await.unwrap();
664        assert!(meta.file_type.is_file());
665        assert_eq!(meta.size, 11); // "hello world"
666    }
667
668    #[tokio::test]
669    async fn stat_dir() {
670        let dir = setup();
671        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
672        let meta = fs.stat(Path::new("/subdir")).await.unwrap();
673        assert!(meta.file_type.is_dir());
674        assert_eq!(meta.size, 0);
675    }
676
677    #[tokio::test]
678    async fn exists_checks() {
679        let dir = setup();
680        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
681        assert!(fs.exists(Path::new("/hello.txt")).await.unwrap());
682        assert!(fs.exists(Path::new("/subdir")).await.unwrap());
683        assert!(fs.exists(Path::new("/")).await.unwrap());
684        assert!(!fs.exists(Path::new("/nope")).await.unwrap());
685    }
686
687    #[tokio::test]
688    async fn readonly_rejects_write() {
689        let dir = setup();
690        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
691        let err = fs.write(Path::new("/new.txt"), b"data").await;
692        assert!(err.is_err());
693        let msg = format!("{}", err.unwrap_err());
694        assert!(msg.contains("readonly"), "error was: {msg}");
695    }
696
697    #[tokio::test]
698    async fn readonly_rejects_mkdir() {
699        let dir = setup();
700        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
701        let err = fs.mkdir(Path::new("/newdir"), false).await;
702        assert!(err.is_err());
703    }
704
705    #[tokio::test]
706    async fn readonly_rejects_remove() {
707        let dir = setup();
708        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
709        let err = fs.remove(Path::new("/hello.txt"), false).await;
710        assert!(err.is_err());
711    }
712
713    #[tokio::test]
714    async fn readwrite_can_write() {
715        let dir = setup();
716        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
717        fs.write(Path::new("/new.txt"), b"new data").await.unwrap();
718        let data = fs.read(Path::new("/new.txt")).await.unwrap();
719        assert_eq!(data, b"new data");
720    }
721
722    #[tokio::test]
723    async fn readwrite_can_mkdir() {
724        let dir = setup();
725        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
726        fs.mkdir(Path::new("/newdir"), false).await.unwrap();
727        assert!(fs.exists(Path::new("/newdir")).await.unwrap());
728    }
729
730    #[tokio::test]
731    async fn readwrite_can_remove() {
732        let dir = setup();
733        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
734        fs.remove(Path::new("/hello.txt"), false).await.unwrap();
735        assert!(!fs.exists(Path::new("/hello.txt")).await.unwrap());
736    }
737
738    #[tokio::test]
739    async fn readwrite_append() {
740        let dir = setup();
741        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
742        fs.append(Path::new("/hello.txt"), b" appended")
743            .await
744            .unwrap();
745        let data = fs.read(Path::new("/hello.txt")).await.unwrap();
746        assert_eq!(data, b"hello world appended");
747    }
748
749    #[tokio::test]
750    async fn path_traversal_blocked() {
751        let dir = setup();
752        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
753        // Attempt to read outside root via ..
754        let result = fs.read(Path::new("/../../../etc/passwd")).await;
755        // Should either fail with permission denied or not found (depending on
756        // whether /etc/passwd exists), but must not succeed in reading it
757        if let Ok(data) = &result {
758            // If it somehow succeeded, the content must not be /etc/passwd
759            assert!(
760                data == b"hello world" || data.is_empty(),
761                "path traversal should not leak host files"
762            );
763        }
764    }
765
766    #[tokio::test]
767    async fn normalize_collapses_dots() {
768        let dir = setup();
769        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
770        let data = fs.read(Path::new("/subdir/../hello.txt")).await.unwrap();
771        assert_eq!(data, b"hello world");
772    }
773
774    #[tokio::test]
775    async fn rename_readwrite() {
776        let dir = setup();
777        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
778        fs.rename(Path::new("/hello.txt"), Path::new("/renamed.txt"))
779            .await
780            .unwrap();
781        assert!(!fs.exists(Path::new("/hello.txt")).await.unwrap());
782        let data = fs.read(Path::new("/renamed.txt")).await.unwrap();
783        assert_eq!(data, b"hello world");
784    }
785
786    #[tokio::test]
787    async fn copy_readwrite() {
788        let dir = setup();
789        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
790        fs.copy(Path::new("/hello.txt"), Path::new("/copied.txt"))
791            .await
792            .unwrap();
793        let data = fs.read(Path::new("/copied.txt")).await.unwrap();
794        assert_eq!(data, b"hello world");
795        // Original still exists
796        assert!(fs.exists(Path::new("/hello.txt")).await.unwrap());
797    }
798
799    #[test]
800    fn new_rejects_nonexistent() {
801        let result = RealFs::new(
802            "/nonexistent/path/that/does/not/exist",
803            RealFsMode::ReadOnly,
804        );
805        assert!(result.is_err());
806    }
807
808    #[test]
809    fn new_rejects_file_as_root() {
810        let dir = setup();
811        let file_path = dir.path().join("hello.txt");
812        let result = RealFs::new(&file_path, RealFsMode::ReadOnly);
813        assert!(result.is_err());
814    }
815
816    // --- Security tests for issue #980: TOCTOU fallback sandbox escape ---
817
818    #[test]
819    fn normalize_host_path_resolves_dotdot() {
820        let p = normalize_host_path(Path::new("/a/b/../c"));
821        assert_eq!(p, PathBuf::from("/a/c"));
822
823        let p = normalize_host_path(Path::new("/a/b/../../c"));
824        assert_eq!(p, PathBuf::from("/c"));
825
826        // Can't go above root
827        let p = normalize_host_path(Path::new("/a/../../../x"));
828        assert_eq!(p, PathBuf::from("/x"));
829    }
830
831    #[test]
832    fn normalize_host_path_preserves_absolute() {
833        let p = normalize_host_path(Path::new("/tmp/sandbox/./foo/../bar"));
834        assert_eq!(p, PathBuf::from("/tmp/sandbox/bar"));
835    }
836
837    #[test]
838    fn resolve_fallback_validates_containment() {
839        // When the parent doesn't exist, resolve must still validate
840        // that the path stays under root (defense-in-depth).
841        let dir = setup();
842        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
843
844        // Valid non-existent path under root — should succeed
845        let result = fs.resolve(Path::new("/newdir/newfile.txt"));
846        assert!(
847            result.is_ok(),
848            "valid non-existent path under root should succeed"
849        );
850        let resolved = result.unwrap();
851        assert!(
852            resolved.starts_with(fs.root()),
853            "resolved path must be under root"
854        );
855    }
856
857    #[test]
858    fn resolve_fallback_returns_normalized_path() {
859        // The fallback must return a normalized path, not the raw joined path.
860        // This ensures no stale `..` or `.` components leak through.
861        let dir = setup();
862        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
863
864        let result = fs.resolve(Path::new("/a/b/../c/file.txt"));
865        assert!(result.is_ok());
866        let resolved = result.unwrap();
867        // The resolved path should not contain ".."
868        assert!(
869            !resolved.to_string_lossy().contains(".."),
870            "fallback path must be normalized, got: {}",
871            resolved.display()
872        );
873        assert!(resolved.starts_with(fs.root()));
874    }
875
876    #[tokio::test]
877    async fn security_traversal_blocked_all_paths() {
878        // Comprehensive traversal test: all traversal attempts must fail,
879        // regardless of which code path in resolve() handles them.
880        let dir = setup();
881        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
882
883        let traversal_paths = [
884            "/../../../etc/passwd",
885            "/../../etc/shadow",
886            "/subdir/../../etc/passwd",
887            "/./../../etc/passwd",
888        ];
889        for vpath in &traversal_paths {
890            let result = fs.read(Path::new(vpath)).await;
891            // normalize_vpath collapses these to root-relative, so they
892            // resolve under root. What matters: no actual /etc/passwd content.
893            if let Ok(data) = &result {
894                let data_str = String::from_utf8_lossy(data);
895                assert!(
896                    !data_str.contains("root:"),
897                    "traversal leaked /etc/passwd via path {vpath}"
898                );
899            }
900        }
901    }
902
903    #[tokio::test]
904    async fn security_nonexistent_nested_stays_under_root() {
905        // Write to deeply nested non-existent path should create under root,
906        // not escape via fallback.
907        let dir = setup();
908        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
909
910        // This goes through the fallback (parent doesn't exist).
911        // The resolved path must be under root.
912        let result = fs
913            .write(Path::new("/deep/nested/dir/file.txt"), b"safe")
914            .await;
915        // Should succeed (write creates parent dirs) and file must be under root
916        if result.is_ok() {
917            let expected = dir.path().join("deep/nested/dir/file.txt");
918            assert!(expected.exists(), "file must be created under root");
919        }
920    }
921
922    #[cfg(unix)]
923    #[tokio::test]
924    async fn security_write_rejects_symlink_escape_with_missing_parent() {
925        use std::os::unix::fs::symlink;
926
927        let root = tempfile::tempdir().unwrap();
928        let outside = tempfile::tempdir().unwrap();
929        symlink(outside.path(), root.path().join("link")).unwrap();
930
931        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
932        let result = fs
933            .write(Path::new("/link/newdir/pwned.txt"), b"owned")
934            .await;
935
936        assert!(result.is_err(), "write through symlink escape must fail");
937        assert!(
938            !outside.path().join("newdir/pwned.txt").exists(),
939            "must not create file outside realfs root"
940        );
941    }
942
943    #[cfg(unix)]
944    #[tokio::test]
945    async fn security_symlink_rejects_parent_components_before_move_escape() {
946        let root = tempfile::tempdir().unwrap();
947        std::fs::create_dir_all(root.path().join("deep/a/b/c")).unwrap();
948
949        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
950        let result = fs
951            .symlink(
952                Path::new("../../../etc/passwd"),
953                Path::new("/deep/a/b/c/escape"),
954            )
955            .await;
956
957        assert!(
958            result.is_err(),
959            "relative symlink targets with parent components can escape after rename"
960        );
961        assert!(
962            !root.path().join("deep/a/b/c/escape").exists(),
963            "must not create movable symlink with unstable containment"
964        );
965    }
966
967    #[cfg(unix)]
968    #[tokio::test]
969    async fn security_symlink_rejects_existing_symlink_component_escape() {
970        use std::os::unix::fs::symlink;
971
972        let root = tempfile::tempdir().unwrap();
973        let outside = tempfile::tempdir().unwrap();
974        symlink(outside.path(), root.path().join("link")).unwrap();
975
976        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
977        let result = fs
978            .symlink(Path::new("link/secret.txt"), Path::new("/escape-link"))
979            .await;
980
981        assert!(
982            result.is_err(),
983            "symlink target traversing host symlink component must fail"
984        );
985        assert!(
986            !root.path().join("escape-link").exists(),
987            "must not create link when target escapes realfs root"
988        );
989    }
990
991    // --- Regression tests for issue #1575 ---
992
993    #[cfg(unix)]
994    #[tokio::test]
995    async fn write_through_dangling_symlink_to_outside_blocked() {
996        use std::os::unix::fs::symlink;
997
998        let root = tempfile::tempdir().unwrap();
999        let outside = tempfile::tempdir().unwrap();
1000        let outside_target = outside.path().join("newfile.txt");
1001        // Dangling symlink: target does not yet exist but its parent does,
1002        // so a naive open(O_CREAT) would create outside the mount.
1003        symlink(&outside_target, root.path().join("link")).unwrap();
1004
1005        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1006        let result = fs.write(Path::new("/link"), b"pwned").await;
1007        assert!(
1008            result.is_err(),
1009            "write through dangling symlink must fail (#1575)"
1010        );
1011        assert!(
1012            !outside_target.exists(),
1013            "no file must be created outside the realfs root"
1014        );
1015    }
1016
1017    #[cfg(unix)]
1018    #[tokio::test]
1019    async fn append_through_leaf_symlink_blocked() {
1020        use std::os::unix::fs::symlink;
1021
1022        let root = tempfile::tempdir().unwrap();
1023        let outside = tempfile::tempdir().unwrap();
1024        let outside_target = outside.path().join("appendme.txt");
1025        symlink(&outside_target, root.path().join("link")).unwrap();
1026
1027        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1028        let result = fs.append(Path::new("/link"), b"x").await;
1029        assert!(result.is_err(), "append through leaf symlink must fail");
1030        assert!(!outside_target.exists());
1031    }
1032
1033    #[cfg(unix)]
1034    #[tokio::test]
1035    async fn copy_to_leaf_symlink_blocked() {
1036        use std::os::unix::fs::symlink;
1037
1038        let root = tempfile::tempdir().unwrap();
1039        std::fs::write(root.path().join("src.txt"), b"src body").unwrap();
1040        let outside = tempfile::tempdir().unwrap();
1041        let outside_target = outside.path().join("escaped.txt");
1042        symlink(&outside_target, root.path().join("dst")).unwrap();
1043
1044        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1045        let result = fs.copy(Path::new("/src.txt"), Path::new("/dst")).await;
1046        assert!(result.is_err(), "copy through leaf symlink must fail");
1047        assert!(!outside_target.exists());
1048    }
1049
1050    #[cfg(unix)]
1051    #[tokio::test]
1052    async fn write_through_leaf_symlink_to_inside_root_also_blocked() {
1053        // Conservative: even when the leaf symlink points inside root we
1054        // refuse the write so attackers can't use a per-tenant link to
1055        // overwrite a co-tenant file.
1056        use std::os::unix::fs::symlink;
1057
1058        let root = tempfile::tempdir().unwrap();
1059        std::fs::write(root.path().join("victim.txt"), b"original").unwrap();
1060        symlink("victim.txt", root.path().join("link")).unwrap();
1061
1062        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1063        let result = fs.write(Path::new("/link"), b"new").await;
1064        assert!(
1065            result.is_err(),
1066            "write through any leaf symlink must fail, even inside root"
1067        );
1068        assert_eq!(
1069            std::fs::read(root.path().join("victim.txt")).unwrap(),
1070            b"original",
1071            "victim file must not be modified through the symlink"
1072        );
1073    }
1074
1075    #[tokio::test]
1076    async fn write_to_plain_path_still_works() {
1077        // Sanity: the leaf-symlink check must not break ordinary writes.
1078        let dir = setup();
1079        let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
1080        fs.write(Path::new("/new.txt"), b"plain").await.unwrap();
1081        assert_eq!(std::fs::read(dir.path().join("new.txt")).unwrap(), b"plain");
1082    }
1083
1084    #[test]
1085    fn debug_display() {
1086        let dir = setup();
1087        let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
1088        let dbg = format!("{:?}", fs);
1089        assert!(dbg.contains("RealFs"));
1090        assert!(dbg.contains("ReadOnly"));
1091    }
1092
1093    // --- Regression tests for issue #1578 ---
1094
1095    #[cfg(unix)]
1096    #[tokio::test]
1097    async fn stat_describes_symlink_not_target() {
1098        use std::os::unix::fs::symlink;
1099
1100        let root = tempfile::tempdir().unwrap();
1101        std::fs::write(root.path().join("target.txt"), b"target body").unwrap();
1102        symlink("target.txt", root.path().join("link")).unwrap();
1103
1104        let fs = RealFs::new(root.path(), RealFsMode::ReadOnly).unwrap();
1105        let meta = fs.stat(Path::new("/link")).await.unwrap();
1106        assert!(
1107            meta.file_type.is_symlink(),
1108            "stat on a symlink must describe the link, not its target"
1109        );
1110    }
1111
1112    #[cfg(unix)]
1113    #[tokio::test]
1114    async fn read_link_returns_target_for_link_path() {
1115        use std::os::unix::fs::symlink;
1116
1117        let root = tempfile::tempdir().unwrap();
1118        std::fs::write(root.path().join("target.txt"), b"target body").unwrap();
1119        symlink("target.txt", root.path().join("link")).unwrap();
1120
1121        let fs = RealFs::new(root.path(), RealFsMode::ReadOnly).unwrap();
1122        let target = fs.read_link(Path::new("/link")).await.unwrap();
1123        assert_eq!(target, PathBuf::from("target.txt"));
1124    }
1125
1126    #[cfg(unix)]
1127    #[tokio::test]
1128    async fn remove_unlinks_symlink_without_following() {
1129        use std::os::unix::fs::symlink;
1130
1131        // Critical: with `recursive=true`, the previous implementation
1132        // would `remove_dir_all` the *target* directory tree.
1133        let root = tempfile::tempdir().unwrap();
1134        std::fs::create_dir(root.path().join("real_dir")).unwrap();
1135        std::fs::write(root.path().join("real_dir/inside.txt"), b"keep").unwrap();
1136        symlink("real_dir", root.path().join("dir_link")).unwrap();
1137
1138        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1139        fs.remove(Path::new("/dir_link"), true).await.unwrap();
1140
1141        // Symlink unlinked …
1142        assert!(
1143            !root.path().join("dir_link").is_symlink() && !root.path().join("dir_link").exists(),
1144            "the dangling symlink should be gone"
1145        );
1146        // … but the target tree must still be intact.
1147        assert!(
1148            root.path().join("real_dir/inside.txt").exists(),
1149            "remove(symlink, recursive=true) must NOT wipe the target tree (#1578)"
1150        );
1151    }
1152
1153    #[cfg(unix)]
1154    #[tokio::test]
1155    async fn remove_symlink_with_recursive_flag_outside_target_intact() {
1156        use std::os::unix::fs::symlink;
1157
1158        // Even when the symlink points outside the realfs root, removing
1159        // the link itself stays inside root and must not touch the target.
1160        let root = tempfile::tempdir().unwrap();
1161        let outside = tempfile::tempdir().unwrap();
1162        std::fs::write(outside.path().join("victim.txt"), b"important").unwrap();
1163        symlink(outside.path(), root.path().join("escape_link")).unwrap();
1164
1165        let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1166        // Removing the link itself should always succeed and never wipe the
1167        // outside tree. (resolve_no_follow keeps the leaf in-root because
1168        // only the parent is canonicalized.)
1169        fs.remove(Path::new("/escape_link"), true).await.unwrap();
1170        assert!(
1171            outside.path().join("victim.txt").exists(),
1172            "removing a symlink must never delete the target tree (#1578)"
1173        );
1174    }
1175}