Skip to main content

bashkit/fs/
posix.rs

1//! POSIX-compatible filesystem wrapper.
2//!
3//! This module provides [`PosixFs`], a wrapper that adds POSIX-like semantics
4//! on top of any [`FsBackend`] implementation.
5//!
6//! # Overview
7//!
8//! `PosixFs` takes a simple storage backend and adds:
9//!
10//! | Check | Description |
11//! |-------|-------------|
12//! | Type-safe writes | `write_file` fails with "is a directory" if path is a directory |
13//! | Type-safe mkdir | `mkdir` fails with "file exists" if path is a file |
14//! | Parent directory | Write operations require parent directory to exist |
15//! | read_dir validation | Fails if path is not a directory |
16//!
17//! # Example
18//!
19//! ```rust,ignore
20//! use bashkit::{Bash, FsBackend, PosixFs};
21//! use std::sync::Arc;
22//!
23//! // 1. Implement FsBackend for your storage
24//! struct MyStorage { /* ... */ }
25//! impl FsBackend for MyStorage { /* ... */ }
26//!
27//! // 2. Wrap with PosixFs
28//! let backend = MyStorage::new();
29//! let fs = Arc::new(PosixFs::new(backend));
30//!
31//! // 3. Use with Bash
32//! let mut bash = Bash::builder().fs(fs).build();
33//!
34//! // POSIX semantics are automatically enforced:
35//! bash.exec("mkdir /tmp/dir").await?;
36//! let result = bash.exec("echo test > /tmp/dir 2>&1").await?;
37//! // ^ This fails with "is a directory"
38//! ```
39//!
40//! # When to Use
41//!
42//! Use `PosixFs` when:
43//! - You have a simple storage backend that doesn't enforce POSIX rules
44//! - You want automatic type checking without implementing it yourself
45//! - You're bridging to an external storage system (database, cloud, etc.)
46//!
47//! See [`FsBackend`](super::FsBackend) for how to implement a backend.
48
49use crate::time_compat::SystemTime;
50use async_trait::async_trait;
51use std::io::Error as IoError;
52use std::path::{Path, PathBuf};
53use std::sync::Arc;
54
55use super::backend::FsBackend;
56use super::limits::{FsLimits, FsUsage};
57use super::normalize_path;
58use super::traits::{DirEntry, FileSystem, FileSystemExt, Metadata, fs_errors};
59use crate::error::Result;
60
61/// POSIX-compatible filesystem wrapper.
62///
63/// Wraps any [`FsBackend`] and enforces POSIX-like semantics.
64///
65/// # Semantics Enforced
66///
67/// | Operation | Check |
68/// |-----------|-------|
69/// | `write_file` | Fails if path is a directory |
70/// | `append_file` | Fails if path is a directory |
71/// | `mkdir` | Fails if path exists as file (always) or dir (unless recursive) |
72/// | `read_dir` | Fails if path is not a directory |
73/// | `copy` | Fails if source is a directory |
74///
75/// # Example
76///
77/// ```rust,ignore
78/// use bashkit::{FsBackend, PosixFs, Bash};
79/// use std::sync::Arc;
80///
81/// // Your simple storage backend
82/// let backend = MyStorage::new();
83///
84/// // Wrap with PosixFs for POSIX semantics
85/// let fs = Arc::new(PosixFs::new(backend));
86///
87/// // Use with Bash interpreter
88/// let mut bash = Bash::builder().fs(fs).build();
89/// ```
90pub struct PosixFs<B: FsBackend> {
91    backend: B,
92}
93
94impl<B: FsBackend> PosixFs<B> {
95    /// Create a new POSIX-compatible filesystem wrapper.
96    pub fn new(backend: B) -> Self {
97        Self { backend }
98    }
99
100    /// Get a reference to the underlying backend.
101    pub fn backend(&self) -> &B {
102        &self.backend
103    }
104
105    /// Normalize a path for consistent lookups.
106    fn normalize(path: &Path) -> PathBuf {
107        normalize_path(path)
108    }
109
110    /// Check if parent directory exists.
111    async fn check_parent_exists(&self, path: &Path) -> Result<()> {
112        if let Some(parent) = path.parent()
113            && parent != Path::new("/")
114            && parent != Path::new("")
115            && !self.backend.exists(parent).await?
116        {
117            return Err(fs_errors::parent_not_found());
118        }
119        Ok(())
120    }
121}
122
123#[async_trait]
124impl<B: FsBackend + 'static> FileSystem for PosixFs<B> {
125    async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
126        let path = Self::normalize(path);
127        // Check if it's a directory
128        if let Ok(meta) = self.backend.stat(&path).await
129            && meta.file_type.is_dir()
130        {
131            return Err(fs_errors::is_a_directory());
132        }
133        self.backend.read(&path).await
134    }
135
136    async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
137        let path = Self::normalize(path);
138        // Check parent exists
139        self.check_parent_exists(&path).await?;
140
141        // Check if path is a directory
142        if let Ok(meta) = self.backend.stat(&path).await
143            && meta.file_type.is_dir()
144        {
145            return Err(fs_errors::is_a_directory());
146        }
147
148        self.backend.write(&path, content).await
149    }
150
151    async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
152        let path = Self::normalize(path);
153        // Check parent exists
154        self.check_parent_exists(&path).await?;
155
156        // Check if path is a directory
157        if let Ok(meta) = self.backend.stat(&path).await
158            && meta.file_type.is_dir()
159        {
160            return Err(fs_errors::is_a_directory());
161        }
162
163        self.backend.append(&path, content).await
164    }
165
166    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
167        let path = Self::normalize(path);
168        // Check if something already exists at this path
169        if let Ok(meta) = self.backend.stat(&path).await {
170            if meta.file_type.is_dir() {
171                // Directory exists
172                if recursive {
173                    return Ok(()); // mkdir -p on existing dir is OK
174                } else {
175                    return Err(fs_errors::already_exists("directory exists"));
176                }
177            } else {
178                // File or symlink exists - always error
179                return Err(fs_errors::already_exists("file exists"));
180            }
181        }
182
183        if recursive {
184            // Check each component in path for file conflicts
185            if let Some(parent) = path.parent() {
186                let mut current = PathBuf::from("/");
187                for component in parent.components().skip(1) {
188                    current.push(component);
189                    if let Ok(meta) = self.backend.stat(&current).await
190                        && !meta.file_type.is_dir()
191                    {
192                        return Err(fs_errors::already_exists("file exists"));
193                    }
194                }
195            }
196        } else {
197            // Non-recursive: parent must exist
198            self.check_parent_exists(&path).await?;
199        }
200
201        self.backend.mkdir(&path, recursive).await
202    }
203
204    async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
205        let path = Self::normalize(path);
206        self.backend.remove(&path, recursive).await
207    }
208
209    async fn stat(&self, path: &Path) -> Result<Metadata> {
210        let path = Self::normalize(path);
211        self.backend.stat(&path).await
212    }
213
214    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
215        let path = Self::normalize(path);
216        // Check if it's actually a directory
217        if let Ok(meta) = self.backend.stat(&path).await
218            && !meta.file_type.is_dir()
219        {
220            return Err(fs_errors::not_a_directory());
221        }
222        self.backend.read_dir(&path).await
223    }
224
225    async fn exists(&self, path: &Path) -> Result<bool> {
226        let path = Self::normalize(path);
227        self.backend.exists(&path).await
228    }
229
230    async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
231        let from = Self::normalize(from);
232        let to = Self::normalize(to);
233        self.backend.rename(&from, &to).await
234    }
235
236    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
237        let from = Self::normalize(from);
238        let to = Self::normalize(to);
239        // Check source is not a directory
240        if let Ok(meta) = self.backend.stat(&from).await
241            && meta.file_type.is_dir()
242        {
243            return Err(IoError::other("cannot copy directory").into());
244        }
245        self.backend.copy(&from, &to).await
246    }
247
248    async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
249        // Don't normalize target: symlink targets are stored as-is on disk.
250        // Normalizing a relative target to absolute would break containment checks.
251        let link = Self::normalize(link);
252        self.backend.symlink(target, &link).await
253    }
254
255    async fn read_link(&self, path: &Path) -> Result<PathBuf> {
256        let path = Self::normalize(path);
257        self.backend.read_link(&path).await
258    }
259
260    async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
261        let path = Self::normalize(path);
262        self.backend.chmod(&path, mode).await
263    }
264
265    async fn set_modified_time(&self, path: &Path, time: SystemTime) -> Result<()> {
266        let path = Self::normalize(path);
267        self.backend.set_modified_time(&path, time).await
268    }
269}
270
271#[async_trait]
272impl<B: FsBackend + 'static> FileSystemExt for PosixFs<B> {
273    fn usage(&self) -> FsUsage {
274        self.backend.usage()
275    }
276
277    fn limits(&self) -> FsLimits {
278        self.backend.limits()
279    }
280}
281
282// Allow Arc<PosixFs<B>> to be used where Arc<dyn FileSystem> is expected
283impl<B: FsBackend + 'static> From<PosixFs<B>> for Arc<dyn FileSystem> {
284    fn from(fs: PosixFs<B>) -> Self {
285        Arc::new(fs)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::error::Result;
293    use crate::fs::InMemoryFs;
294    use crate::fs::{DirEntry, FileType, FsBackend};
295    use std::collections::HashSet;
296    use std::path::{Path, PathBuf};
297    use std::sync::Mutex;
298
299    struct AppendCreatesFileBackend {
300        files: Mutex<HashSet<PathBuf>>,
301    }
302
303    impl AppendCreatesFileBackend {
304        fn new() -> Self {
305            let mut files = HashSet::new();
306            files.insert(PathBuf::from("/"));
307            files.insert(PathBuf::from("/tmp"));
308            Self {
309                files: Mutex::new(files),
310            }
311        }
312    }
313
314    #[async_trait]
315    impl FsBackend for AppendCreatesFileBackend {
316        async fn read(&self, _path: &Path) -> Result<Vec<u8>> {
317            Ok(Vec::new())
318        }
319
320        async fn write(&self, path: &Path, _content: &[u8]) -> Result<()> {
321            self.files
322                .lock()
323                .expect("backend lock poisoned")
324                .insert(path.to_path_buf());
325            Ok(())
326        }
327
328        async fn append(&self, path: &Path, content: &[u8]) -> Result<()> {
329            self.write(path, content).await
330        }
331
332        async fn mkdir(&self, path: &Path, _recursive: bool) -> Result<()> {
333            self.files
334                .lock()
335                .expect("backend lock poisoned")
336                .insert(path.to_path_buf());
337            Ok(())
338        }
339
340        async fn remove(&self, _path: &Path, _recursive: bool) -> Result<()> {
341            Ok(())
342        }
343
344        async fn stat(&self, path: &Path) -> Result<Metadata> {
345            if self
346                .files
347                .lock()
348                .expect("backend lock poisoned")
349                .contains(path)
350            {
351                Ok(Metadata {
352                    file_type: FileType::File,
353                    ..Metadata::default()
354                })
355            } else {
356                Err(std::io::Error::from(std::io::ErrorKind::NotFound).into())
357            }
358        }
359
360        async fn read_dir(&self, _path: &Path) -> Result<Vec<DirEntry>> {
361            Ok(Vec::new())
362        }
363
364        async fn exists(&self, path: &Path) -> Result<bool> {
365            Ok(self
366                .files
367                .lock()
368                .expect("backend lock poisoned")
369                .contains(path))
370        }
371
372        async fn rename(&self, _from: &Path, _to: &Path) -> Result<()> {
373            Ok(())
374        }
375
376        async fn copy(&self, _from: &Path, _to: &Path) -> Result<()> {
377            Ok(())
378        }
379
380        async fn symlink(&self, _target: &Path, _link: &Path) -> Result<()> {
381            Ok(())
382        }
383
384        async fn read_link(&self, _path: &Path) -> Result<PathBuf> {
385            Err(std::io::Error::from(std::io::ErrorKind::NotFound).into())
386        }
387
388        async fn chmod(&self, _path: &Path, _mode: u32) -> Result<()> {
389            Ok(())
390        }
391    }
392
393    #[tokio::test]
394    async fn test_posix_write_to_directory_fails() {
395        // InMemoryFs already implements FileSystem with checks,
396        // but we can test PosixFs wrapping a raw backend
397        let fs = InMemoryFs::new();
398
399        // Create a directory
400        fs.mkdir(Path::new("/tmp/testdir"), false)
401            .await
402            .expect("mkdir should succeed");
403
404        // Writing to it should fail
405        let result = fs.write_file(Path::new("/tmp/testdir"), b"test").await;
406        assert!(result.is_err());
407        assert!(
408            result
409                .expect_err("write_file should fail")
410                .to_string()
411                .contains("directory")
412        );
413    }
414
415    #[tokio::test]
416    async fn test_posix_mkdir_on_file_fails() {
417        let fs = InMemoryFs::new();
418
419        // Create a file
420        fs.write_file(Path::new("/tmp/testfile"), b"test")
421            .await
422            .expect("write_file should succeed");
423
424        // mkdir on it should fail
425        let result = fs.mkdir(Path::new("/tmp/testfile"), false).await;
426        assert!(result.is_err());
427    }
428
429    #[tokio::test]
430    async fn test_posix_normalize_dot_slash_prefix() {
431        // Issue #1114: paths with ./ prefix should resolve correctly
432        let fs = InMemoryFs::new();
433
434        // Create /tmp/dir and a file
435        fs.mkdir(Path::new("/tmp/dir"), true).await.unwrap();
436        fs.write_file(Path::new("/tmp/dir/file.txt"), b"content")
437            .await
438            .unwrap();
439
440        // Access via ./ style path (as if cwd.join("./file.txt"))
441        let dot_path = Path::new("/tmp/dir/./file.txt");
442        assert!(
443            fs.exists(dot_path).await.unwrap(),
444            "exists with ./ should work"
445        );
446
447        let content = fs.read_file(dot_path).await.unwrap();
448        assert_eq!(content, b"content");
449
450        // stat with ./ prefix
451        let meta = fs.stat(dot_path).await;
452        assert!(meta.is_ok(), "stat with ./ should work");
453
454        // write via ./ prefix
455        fs.write_file(Path::new("/tmp/dir/./new.txt"), b"new")
456            .await
457            .unwrap();
458        let content = fs.read_file(Path::new("/tmp/dir/new.txt")).await.unwrap();
459        assert_eq!(content, b"new");
460    }
461
462    #[tokio::test]
463    async fn test_posix_normalize_preserves_semantics() {
464        // Verify normalization doesn't break parent-exists checks
465        let fs = InMemoryFs::new();
466
467        // /tmp exists, /tmp/nonexistent does not
468        let result = fs
469            .write_file(Path::new("/tmp/nonexistent/./file.txt"), b"content")
470            .await;
471        assert!(result.is_err(), "should fail when parent doesn't exist");
472    }
473
474    #[tokio::test]
475    async fn test_posix_append_requires_parent_directory() {
476        let fs = PosixFs::new(AppendCreatesFileBackend::new());
477        let result = fs
478            .append_file(Path::new("/tmp/missing-parent/file.txt"), b"content")
479            .await;
480        assert!(
481            result.is_err(),
482            "append should fail when parent doesn't exist"
483        );
484    }
485}