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 async_trait::async_trait;
50use std::io::Error as IoError;
51use std::path::{Path, PathBuf};
52use std::sync::Arc;
53
54use super::backend::FsBackend;
55use super::limits::{FsLimits, FsUsage};
56use super::traits::{DirEntry, FileSystem, FileSystemExt, Metadata, fs_errors};
57use crate::error::Result;
58
59/// POSIX-compatible filesystem wrapper.
60///
61/// Wraps any [`FsBackend`] and enforces POSIX-like semantics.
62///
63/// # Semantics Enforced
64///
65/// | Operation | Check |
66/// |-----------|-------|
67/// | `write_file` | Fails if path is a directory |
68/// | `append_file` | Fails if path is a directory |
69/// | `mkdir` | Fails if path exists as file (always) or dir (unless recursive) |
70/// | `read_dir` | Fails if path is not a directory |
71/// | `copy` | Fails if source is a directory |
72///
73/// # Example
74///
75/// ```rust,ignore
76/// use bashkit::{FsBackend, PosixFs, Bash};
77/// use std::sync::Arc;
78///
79/// // Your simple storage backend
80/// let backend = MyStorage::new();
81///
82/// // Wrap with PosixFs for POSIX semantics
83/// let fs = Arc::new(PosixFs::new(backend));
84///
85/// // Use with Bash interpreter
86/// let mut bash = Bash::builder().fs(fs).build();
87/// ```
88pub struct PosixFs<B: FsBackend> {
89    backend: B,
90}
91
92impl<B: FsBackend> PosixFs<B> {
93    /// Create a new POSIX-compatible filesystem wrapper.
94    pub fn new(backend: B) -> Self {
95        Self { backend }
96    }
97
98    /// Get a reference to the underlying backend.
99    pub fn backend(&self) -> &B {
100        &self.backend
101    }
102
103    /// Check if parent directory exists.
104    async fn check_parent_exists(&self, path: &Path) -> Result<()> {
105        if let Some(parent) = path.parent()
106            && parent != Path::new("/")
107            && parent != Path::new("")
108            && !self.backend.exists(parent).await?
109        {
110            return Err(fs_errors::parent_not_found());
111        }
112        Ok(())
113    }
114}
115
116#[async_trait]
117impl<B: FsBackend + 'static> FileSystem for PosixFs<B> {
118    async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
119        // Check if it's a directory
120        if let Ok(meta) = self.backend.stat(path).await
121            && meta.file_type.is_dir()
122        {
123            return Err(fs_errors::is_a_directory());
124        }
125        self.backend.read(path).await
126    }
127
128    async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
129        // Check parent exists
130        self.check_parent_exists(path).await?;
131
132        // Check if path is a directory
133        if let Ok(meta) = self.backend.stat(path).await
134            && meta.file_type.is_dir()
135        {
136            return Err(fs_errors::is_a_directory());
137        }
138
139        self.backend.write(path, content).await
140    }
141
142    async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
143        // Check if path is a directory
144        if let Ok(meta) = self.backend.stat(path).await
145            && meta.file_type.is_dir()
146        {
147            return Err(fs_errors::is_a_directory());
148        }
149
150        self.backend.append(path, content).await
151    }
152
153    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
154        // Check if something already exists at this path
155        if let Ok(meta) = self.backend.stat(path).await {
156            if meta.file_type.is_dir() {
157                // Directory exists
158                if recursive {
159                    return Ok(()); // mkdir -p on existing dir is OK
160                } else {
161                    return Err(fs_errors::already_exists("directory exists"));
162                }
163            } else {
164                // File or symlink exists - always error
165                return Err(fs_errors::already_exists("file exists"));
166            }
167        }
168
169        if recursive {
170            // Check each component in path for file conflicts
171            if let Some(parent) = path.parent() {
172                let mut current = PathBuf::from("/");
173                for component in parent.components().skip(1) {
174                    current.push(component);
175                    if let Ok(meta) = self.backend.stat(&current).await
176                        && !meta.file_type.is_dir()
177                    {
178                        return Err(fs_errors::already_exists("file exists"));
179                    }
180                }
181            }
182        } else {
183            // Non-recursive: parent must exist
184            self.check_parent_exists(path).await?;
185        }
186
187        self.backend.mkdir(path, recursive).await
188    }
189
190    async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
191        self.backend.remove(path, recursive).await
192    }
193
194    async fn stat(&self, path: &Path) -> Result<Metadata> {
195        self.backend.stat(path).await
196    }
197
198    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
199        // Check if it's actually a directory
200        if let Ok(meta) = self.backend.stat(path).await
201            && !meta.file_type.is_dir()
202        {
203            return Err(fs_errors::not_a_directory());
204        }
205        self.backend.read_dir(path).await
206    }
207
208    async fn exists(&self, path: &Path) -> Result<bool> {
209        self.backend.exists(path).await
210    }
211
212    async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
213        self.backend.rename(from, to).await
214    }
215
216    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
217        // Check source is not a directory
218        if let Ok(meta) = self.backend.stat(from).await
219            && meta.file_type.is_dir()
220        {
221            return Err(IoError::other("cannot copy directory").into());
222        }
223        self.backend.copy(from, to).await
224    }
225
226    async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
227        self.backend.symlink(target, link).await
228    }
229
230    async fn read_link(&self, path: &Path) -> Result<PathBuf> {
231        self.backend.read_link(path).await
232    }
233
234    async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
235        self.backend.chmod(path, mode).await
236    }
237}
238
239#[async_trait]
240impl<B: FsBackend + 'static> FileSystemExt for PosixFs<B> {
241    fn usage(&self) -> FsUsage {
242        self.backend.usage()
243    }
244
245    fn limits(&self) -> FsLimits {
246        self.backend.limits()
247    }
248}
249
250// Allow Arc<PosixFs<B>> to be used where Arc<dyn FileSystem> is expected
251impl<B: FsBackend + 'static> From<PosixFs<B>> for Arc<dyn FileSystem> {
252    fn from(fs: PosixFs<B>) -> Self {
253        Arc::new(fs)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::fs::InMemoryFs;
261    use std::path::Path;
262
263    #[tokio::test]
264    async fn test_posix_write_to_directory_fails() {
265        // InMemoryFs already implements FileSystem with checks,
266        // but we can test PosixFs wrapping a raw backend
267        let fs = InMemoryFs::new();
268
269        // Create a directory
270        fs.mkdir(Path::new("/tmp/testdir"), false)
271            .await
272            .expect("mkdir should succeed");
273
274        // Writing to it should fail
275        let result = fs.write_file(Path::new("/tmp/testdir"), b"test").await;
276        assert!(result.is_err());
277        assert!(
278            result
279                .expect_err("write_file should fail")
280                .to_string()
281                .contains("directory")
282        );
283    }
284
285    #[tokio::test]
286    async fn test_posix_mkdir_on_file_fails() {
287        let fs = InMemoryFs::new();
288
289        // Create a file
290        fs.write_file(Path::new("/tmp/testfile"), b"test")
291            .await
292            .expect("write_file should succeed");
293
294        // mkdir on it should fail
295        let result = fs.mkdir(Path::new("/tmp/testfile"), false).await;
296        assert!(result.is_err());
297    }
298}