Skip to main content

kaish_kernel/vfs/
local.rs

1//! Local filesystem backend.
2//!
3//! Provides access to real filesystem paths, with optional read-only mode.
4
5use super::traits::{DirEntry, EntryType, Filesystem, Metadata};
6use async_trait::async_trait;
7use std::io;
8use std::path::{Path, PathBuf};
9use tokio::fs;
10
11/// Local filesystem backend.
12///
13/// All operations are relative to `root`. For example, if `root` is
14/// `/home/amy/project`, then `read("src/main.rs")` reads
15/// `/home/amy/project/src/main.rs`.
16#[derive(Debug, Clone)]
17pub struct LocalFs {
18    root: PathBuf,
19    read_only: bool,
20}
21
22impl LocalFs {
23    /// Create a new local filesystem rooted at the given path.
24    ///
25    /// The path must exist and be a directory.
26    pub fn new(root: impl Into<PathBuf>) -> Self {
27        Self {
28            root: root.into(),
29            read_only: false,
30        }
31    }
32
33    /// Create a read-only local filesystem.
34    pub fn read_only(root: impl Into<PathBuf>) -> Self {
35        Self {
36            root: root.into(),
37            read_only: true,
38        }
39    }
40
41    /// Set whether this filesystem is read-only.
42    pub fn set_read_only(&mut self, read_only: bool) {
43        self.read_only = read_only;
44    }
45
46    /// Get the root path.
47    pub fn root(&self) -> &Path {
48        &self.root
49    }
50
51    /// Resolve a relative path to an absolute path within the root.
52    ///
53    /// Returns an error if the path escapes the root (via `..`).
54    fn resolve(&self, path: &Path) -> io::Result<PathBuf> {
55        // Strip leading slash if present
56        let path = path.strip_prefix("/").unwrap_or(path);
57
58        // Join with root
59        let full = self.root.join(path);
60
61        // Canonicalize to resolve symlinks and ..
62        // For non-existent paths, we need to check parent
63        let canonical = if full.exists() {
64            full.canonicalize()?
65        } else {
66            // For new files, canonicalize parent and append filename
67            let parent = full.parent().ok_or_else(|| {
68                io::Error::new(io::ErrorKind::InvalidInput, "invalid path")
69            })?;
70            let filename = full.file_name().ok_or_else(|| {
71                io::Error::new(io::ErrorKind::InvalidInput, "invalid path")
72            })?;
73
74            if parent.exists() {
75                parent.canonicalize()?.join(filename)
76            } else {
77                // Parent doesn't exist, just use the path as-is
78                // (will fail on actual operation)
79                full
80            }
81        };
82
83        // Verify we haven't escaped the root
84        let canonical_root = self.root.canonicalize().unwrap_or_else(|_| self.root.clone());
85        if !canonical.starts_with(&canonical_root) {
86            return Err(io::Error::new(
87                io::ErrorKind::PermissionDenied,
88                format!(
89                    "path escapes root: {} is not under {}",
90                    canonical.display(),
91                    canonical_root.display()
92                ),
93            ));
94        }
95
96        Ok(canonical)
97    }
98
99    /// Check if write operations are allowed.
100    fn check_writable(&self) -> io::Result<()> {
101        if self.read_only {
102            Err(io::Error::new(
103                io::ErrorKind::PermissionDenied,
104                "filesystem is read-only",
105            ))
106        } else {
107            Ok(())
108        }
109    }
110}
111
112#[async_trait]
113impl Filesystem for LocalFs {
114    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
115        let full_path = self.resolve(path)?;
116        fs::read(&full_path).await
117    }
118
119    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
120        self.check_writable()?;
121        let full_path = self.resolve(path)?;
122
123        // Ensure parent directory exists
124        if let Some(parent) = full_path.parent() {
125            fs::create_dir_all(parent).await?;
126        }
127
128        fs::write(&full_path, data).await
129    }
130
131    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
132        let full_path = self.resolve(path)?;
133        let mut entries = Vec::new();
134        let mut dir = fs::read_dir(&full_path).await?;
135
136        while let Some(entry) = dir.next_entry().await? {
137            // Use symlink_metadata to detect symlinks without following them
138            let metadata = fs::symlink_metadata(entry.path()).await?;
139            let file_type = metadata.file_type();
140
141            let (entry_type, symlink_target) = if file_type.is_symlink() {
142                // Read the symlink target
143                let target = fs::read_link(entry.path()).await.ok();
144                (EntryType::Symlink, target)
145            } else if file_type.is_dir() {
146                (EntryType::Directory, None)
147            } else {
148                (EntryType::File, None)
149            };
150
151            entries.push(DirEntry {
152                name: entry.file_name().to_string_lossy().into_owned(),
153                entry_type,
154                size: metadata.len(),
155                symlink_target,
156            });
157        }
158
159        entries.sort_by(|a, b| a.name.cmp(&b.name));
160        Ok(entries)
161    }
162
163    async fn stat(&self, path: &Path) -> io::Result<Metadata> {
164        let full_path = self.resolve(path)?;
165        // stat follows symlinks
166        let meta = fs::metadata(&full_path).await?;
167
168        Ok(Metadata {
169            is_dir: meta.is_dir(),
170            is_file: meta.is_file(),
171            is_symlink: false, // stat follows symlinks, so the target is never a symlink
172            size: meta.len(),
173            modified: meta.modified().ok(),
174        })
175    }
176
177    async fn lstat(&self, path: &Path) -> io::Result<Metadata> {
178        // lstat doesn't follow symlinks - we need to avoid canonicalizing the path
179        let path = path.strip_prefix("/").unwrap_or(path);
180        let full_path = self.root.join(path);
181
182        // Use symlink_metadata which doesn't follow symlinks
183        let meta = fs::symlink_metadata(&full_path).await?;
184
185        Ok(Metadata {
186            is_dir: meta.is_dir(),
187            is_file: meta.is_file(),
188            is_symlink: meta.file_type().is_symlink(),
189            size: meta.len(),
190            modified: meta.modified().ok(),
191        })
192    }
193
194    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
195        let path = path.strip_prefix("/").unwrap_or(path);
196        let full_path = self.root.join(path);
197        fs::read_link(&full_path).await
198    }
199
200    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
201        self.check_writable()?;
202        let path = link.strip_prefix("/").unwrap_or(link);
203        let link_path = self.root.join(path);
204
205        // Ensure parent directory exists
206        if let Some(parent) = link_path.parent() {
207            fs::create_dir_all(parent).await?;
208        }
209
210        #[cfg(unix)]
211        {
212            tokio::fs::symlink(target, &link_path).await
213        }
214        #[cfg(windows)]
215        {
216            // Windows needs to know if target is a file or directory
217            // Default to file symlink; for directories use symlink_dir
218            tokio::fs::symlink_file(target, &link_path).await
219        }
220    }
221
222    async fn mkdir(&self, path: &Path) -> io::Result<()> {
223        self.check_writable()?;
224        let full_path = self.resolve(path)?;
225        fs::create_dir_all(&full_path).await
226    }
227
228    async fn remove(&self, path: &Path) -> io::Result<()> {
229        self.check_writable()?;
230        let full_path = self.resolve(path)?;
231        let meta = fs::metadata(&full_path).await?;
232
233        if meta.is_dir() {
234            fs::remove_dir(&full_path).await
235        } else {
236            fs::remove_file(&full_path).await
237        }
238    }
239
240    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
241        self.check_writable()?;
242        let from_path = self.resolve(from)?;
243        let to_path = self.resolve(to)?;
244
245        // Ensure parent directory exists for destination
246        if let Some(parent) = to_path.parent() {
247            fs::create_dir_all(parent).await?;
248        }
249
250        fs::rename(&from_path, &to_path).await
251    }
252
253    fn read_only(&self) -> bool {
254        self.read_only
255    }
256
257    fn real_path(&self, path: &Path) -> Option<PathBuf> {
258        self.resolve(path).ok()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::env;
266    use std::sync::atomic::{AtomicU64, Ordering};
267
268    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
269
270    fn temp_dir() -> PathBuf {
271        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
272        env::temp_dir().join(format!("kaish-test-{}-{}", std::process::id(), id))
273    }
274
275    async fn setup() -> (LocalFs, PathBuf) {
276        let dir = temp_dir();
277        let _ = fs::remove_dir_all(&dir).await;
278        fs::create_dir_all(&dir).await.unwrap();
279        (LocalFs::new(&dir), dir)
280    }
281
282    async fn cleanup(dir: &Path) {
283        let _ = fs::remove_dir_all(dir).await;
284    }
285
286    #[tokio::test]
287    async fn test_write_and_read() {
288        let (fs, dir) = setup().await;
289
290        fs.write(Path::new("test.txt"), b"hello").await.unwrap();
291        let data = fs.read(Path::new("test.txt")).await.unwrap();
292        assert_eq!(data, b"hello");
293
294        cleanup(&dir).await;
295    }
296
297    #[tokio::test]
298    async fn test_nested_write() {
299        let (fs, dir) = setup().await;
300
301        fs.write(Path::new("a/b/c.txt"), b"nested").await.unwrap();
302        let data = fs.read(Path::new("a/b/c.txt")).await.unwrap();
303        assert_eq!(data, b"nested");
304
305        cleanup(&dir).await;
306    }
307
308    #[tokio::test]
309    async fn test_read_only() {
310        let (_, dir) = setup().await;
311        let fs = LocalFs::read_only(&dir);
312
313        let result = fs.write(Path::new("test.txt"), b"data").await;
314        assert!(result.is_err());
315        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
316
317        cleanup(&dir).await;
318    }
319
320    #[tokio::test]
321    async fn test_list() {
322        let (fs, dir) = setup().await;
323
324        fs.write(Path::new("a.txt"), b"a").await.unwrap();
325        fs.write(Path::new("b.txt"), b"b").await.unwrap();
326        fs.mkdir(Path::new("subdir")).await.unwrap();
327
328        let entries = fs.list(Path::new("")).await.unwrap();
329        assert_eq!(entries.len(), 3);
330
331        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
332        assert!(names.contains(&&"a.txt".to_string()));
333        assert!(names.contains(&&"b.txt".to_string()));
334        assert!(names.contains(&&"subdir".to_string()));
335
336        cleanup(&dir).await;
337    }
338
339    #[tokio::test]
340    async fn test_stat() {
341        let (fs, dir) = setup().await;
342
343        fs.write(Path::new("file.txt"), b"content").await.unwrap();
344        fs.mkdir(Path::new("dir")).await.unwrap();
345
346        let file_meta = fs.stat(Path::new("file.txt")).await.unwrap();
347        assert!(file_meta.is_file);
348        assert!(!file_meta.is_dir);
349        assert_eq!(file_meta.size, 7);
350
351        let dir_meta = fs.stat(Path::new("dir")).await.unwrap();
352        assert!(dir_meta.is_dir);
353        assert!(!dir_meta.is_file);
354
355        cleanup(&dir).await;
356    }
357
358    #[tokio::test]
359    async fn test_remove() {
360        let (fs, dir) = setup().await;
361
362        fs.write(Path::new("file.txt"), b"data").await.unwrap();
363        assert!(fs.exists(Path::new("file.txt")).await);
364
365        fs.remove(Path::new("file.txt")).await.unwrap();
366        assert!(!fs.exists(Path::new("file.txt")).await);
367
368        cleanup(&dir).await;
369    }
370
371    #[tokio::test]
372    async fn test_path_escape_blocked() {
373        let (fs, dir) = setup().await;
374
375        // Trying to escape via .. should fail
376        let result = fs.read(Path::new("../../../etc/passwd")).await;
377        assert!(result.is_err());
378
379        cleanup(&dir).await;
380    }
381}