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, DirEntryKind, Filesystem};
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    /// Resolve a path within the root WITHOUT following symlinks.
100    ///
101    /// Used by `lstat()` and `read_link()` which must not follow symlinks.
102    /// Validates that the path stays within the sandbox by normalizing
103    /// path components (resolving `.` and `..`) without canonicalization.
104    fn resolve_no_follow(&self, path: &Path) -> io::Result<PathBuf> {
105        let path = path.strip_prefix("/").unwrap_or(path);
106
107        let mut normalized = self.root.clone();
108        for component in path.components() {
109            match component {
110                std::path::Component::ParentDir => {
111                    if normalized == self.root {
112                        return Err(io::Error::new(
113                            io::ErrorKind::PermissionDenied,
114                            "path escapes root",
115                        ));
116                    }
117                    normalized.pop();
118                    if !normalized.starts_with(&self.root) {
119                        return Err(io::Error::new(
120                            io::ErrorKind::PermissionDenied,
121                            "path escapes root",
122                        ));
123                    }
124                }
125                std::path::Component::Normal(c) => normalized.push(c),
126                std::path::Component::CurDir => {} // skip
127                _ => {}
128            }
129        }
130
131        // Final containment check
132        if !normalized.starts_with(&self.root) {
133            return Err(io::Error::new(
134                io::ErrorKind::PermissionDenied,
135                "path escapes root",
136            ));
137        }
138        Ok(normalized)
139    }
140
141    /// Check if write operations are allowed.
142    fn check_writable(&self) -> io::Result<()> {
143        if self.read_only {
144            Err(io::Error::new(
145                io::ErrorKind::PermissionDenied,
146                "filesystem is read-only",
147            ))
148        } else {
149            Ok(())
150        }
151    }
152
153    /// Extract permissions from std::fs::Metadata (unix only).
154    #[cfg(unix)]
155    fn extract_permissions(meta: &std::fs::Metadata) -> Option<u32> {
156        use std::os::unix::fs::PermissionsExt;
157        Some(meta.permissions().mode())
158    }
159
160    #[cfg(not(unix))]
161    fn extract_permissions(_meta: &std::fs::Metadata) -> Option<u32> {
162        None
163    }
164}
165
166#[async_trait]
167impl Filesystem for LocalFs {
168    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
169        let full_path = self.resolve(path)?;
170        fs::read(&full_path).await
171    }
172
173    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
174        self.check_writable()?;
175        let full_path = self.resolve(path)?;
176
177        // Ensure parent directory exists
178        if let Some(parent) = full_path.parent() {
179            fs::create_dir_all(parent).await?;
180        }
181
182        fs::write(&full_path, data).await
183    }
184
185    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
186        let full_path = self.resolve(path)?;
187        let mut entries = Vec::new();
188        let mut dir = fs::read_dir(&full_path).await?;
189
190        while let Some(entry) = dir.next_entry().await? {
191            // Use symlink_metadata to detect symlinks without following them
192            let metadata = fs::symlink_metadata(entry.path()).await?;
193            let file_type = metadata.file_type();
194
195            let (kind, symlink_target) = if file_type.is_symlink() {
196                // Read the symlink target
197                let target = fs::read_link(entry.path()).await.ok();
198                (DirEntryKind::Symlink, target)
199            } else if file_type.is_dir() {
200                (DirEntryKind::Directory, None)
201            } else {
202                // Special files (sockets, pipes, devices) → File. See stat() comment.
203                (DirEntryKind::File, None)
204            };
205
206            entries.push(DirEntry {
207                name: entry.file_name().to_string_lossy().into_owned(),
208                kind,
209                size: metadata.len(),
210                modified: metadata.modified().ok(),
211                permissions: Self::extract_permissions(&metadata),
212                symlink_target,
213            });
214        }
215
216        entries.sort_by(|a, b| a.name.cmp(&b.name));
217        Ok(entries)
218    }
219
220    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
221        let full_path = self.resolve(path)?;
222        // stat follows symlinks
223        let meta = fs::metadata(&full_path).await?;
224
225        let kind = if meta.is_dir() {
226            DirEntryKind::Directory
227        } else {
228            // Unix special files (sockets, pipes, block/char devices) are classified
229            // as File. kaish doesn't operate on special files, and adding a variant
230            // would force match-arm changes everywhere for no practical benefit.
231            DirEntryKind::File
232        };
233
234        let name = path
235            .file_name()
236            .map(|n| n.to_string_lossy().into_owned())
237            .unwrap_or_else(|| "/".to_string());
238
239        Ok(DirEntry {
240            name,
241            kind,
242            size: meta.len(),
243            modified: meta.modified().ok(),
244            permissions: Self::extract_permissions(&meta),
245            symlink_target: None, // stat follows symlinks
246        })
247    }
248
249    async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
250        // lstat doesn't follow symlinks - validate containment without canonicalization
251        let full_path = self.resolve_no_follow(path)?;
252
253        // Use symlink_metadata which doesn't follow symlinks
254        let meta = fs::symlink_metadata(&full_path).await?;
255
256        let file_type = meta.file_type();
257        let kind = if file_type.is_symlink() {
258            DirEntryKind::Symlink
259        } else if meta.is_dir() {
260            DirEntryKind::Directory
261        } else {
262            // Special files (sockets, pipes, devices) → File. See stat() comment.
263            DirEntryKind::File
264        };
265
266        let symlink_target = if file_type.is_symlink() {
267            fs::read_link(&full_path).await.ok()
268        } else {
269            None
270        };
271
272        let name = path
273            .file_name()
274            .map(|n| n.to_string_lossy().into_owned())
275            .unwrap_or_else(|| "/".to_string());
276
277        Ok(DirEntry {
278            name,
279            kind,
280            size: meta.len(),
281            modified: meta.modified().ok(),
282            permissions: Self::extract_permissions(&meta),
283            symlink_target,
284        })
285    }
286
287    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
288        let full_path = self.resolve_no_follow(path)?;
289        fs::read_link(&full_path).await
290    }
291
292    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
293        self.check_writable()?;
294
295        // Validate absolute symlink targets stay within sandbox.
296        // `resolve` would strip the leading slash and treat `/etc/passwd` as
297        // root-relative, so `<root>/etc/passwd` always "contains". For symlink
298        // targets the OS follows the literal absolute path, so compare the
299        // canonical target (or the literal path if it doesn't exist yet) to
300        // the canonical root.
301        if target.is_absolute() {
302            let canonical_root = self.root.canonicalize().unwrap_or_else(|_| self.root.clone());
303            let canonical_target = target.canonicalize().unwrap_or_else(|_| target.to_path_buf());
304            if !canonical_target.starts_with(&canonical_root) {
305                return Err(io::Error::new(
306                    io::ErrorKind::PermissionDenied,
307                    format!("symlink target escapes root: {}", target.display()),
308                ));
309            }
310        }
311
312        let link_path = self.resolve_no_follow(link)?;
313
314        // Ensure parent directory exists
315        if let Some(parent) = link_path.parent() {
316            fs::create_dir_all(parent).await?;
317        }
318
319        #[cfg(unix)]
320        {
321            tokio::fs::symlink(target, &link_path).await
322        }
323        #[cfg(windows)]
324        {
325            // Windows needs to know if target is a file or directory
326            // Default to file symlink; for directories use symlink_dir
327            tokio::fs::symlink_file(target, &link_path).await
328        }
329    }
330
331    async fn mkdir(&self, path: &Path) -> io::Result<()> {
332        self.check_writable()?;
333        let full_path = self.resolve(path)?;
334        fs::create_dir_all(&full_path).await
335    }
336
337    async fn remove(&self, path: &Path) -> io::Result<()> {
338        self.check_writable()?;
339        let full_path = self.resolve(path)?;
340        let meta = fs::metadata(&full_path).await?;
341
342        if meta.is_dir() {
343            fs::remove_dir(&full_path).await
344        } else {
345            fs::remove_file(&full_path).await
346        }
347    }
348
349    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
350        self.check_writable()?;
351        let from_path = self.resolve(from)?;
352        let to_path = self.resolve(to)?;
353
354        // Ensure parent directory exists for destination
355        if let Some(parent) = to_path.parent() {
356            fs::create_dir_all(parent).await?;
357        }
358
359        fs::rename(&from_path, &to_path).await
360    }
361
362    fn read_only(&self) -> bool {
363        self.read_only
364    }
365
366    fn real_path(&self, path: &Path) -> Option<PathBuf> {
367        self.resolve(path).ok()
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use std::env;
375    use std::sync::atomic::{AtomicU64, Ordering};
376
377    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
378
379    fn temp_dir() -> PathBuf {
380        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
381        env::temp_dir().join(format!("kaish-test-{}-{}", std::process::id(), id))
382    }
383
384    async fn setup() -> (LocalFs, PathBuf) {
385        let dir = temp_dir();
386        let _ = fs::remove_dir_all(&dir).await;
387        fs::create_dir_all(&dir).await.unwrap();
388        (LocalFs::new(&dir), dir)
389    }
390
391    async fn cleanup(dir: &Path) {
392        let _ = fs::remove_dir_all(dir).await;
393    }
394
395    #[tokio::test]
396    async fn test_write_and_read() {
397        let (fs, dir) = setup().await;
398
399        fs.write(Path::new("test.txt"), b"hello").await.unwrap();
400        let data = fs.read(Path::new("test.txt")).await.unwrap();
401        assert_eq!(data, b"hello");
402
403        cleanup(&dir).await;
404    }
405
406    #[tokio::test]
407    async fn test_nested_write() {
408        let (fs, dir) = setup().await;
409
410        fs.write(Path::new("a/b/c.txt"), b"nested").await.unwrap();
411        let data = fs.read(Path::new("a/b/c.txt")).await.unwrap();
412        assert_eq!(data, b"nested");
413
414        cleanup(&dir).await;
415    }
416
417    #[tokio::test]
418    async fn test_read_only() {
419        let (_, dir) = setup().await;
420        let fs = LocalFs::read_only(&dir);
421
422        let result = fs.write(Path::new("test.txt"), b"data").await;
423        assert!(result.is_err());
424        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
425
426        cleanup(&dir).await;
427    }
428
429    #[tokio::test]
430    async fn test_list() {
431        let (fs, dir) = setup().await;
432
433        fs.write(Path::new("a.txt"), b"a").await.unwrap();
434        fs.write(Path::new("b.txt"), b"b").await.unwrap();
435        fs.mkdir(Path::new("subdir")).await.unwrap();
436
437        let entries = fs.list(Path::new("")).await.unwrap();
438        assert_eq!(entries.len(), 3);
439
440        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
441        assert!(names.contains(&&"a.txt".to_string()));
442        assert!(names.contains(&&"b.txt".to_string()));
443        assert!(names.contains(&&"subdir".to_string()));
444
445        cleanup(&dir).await;
446    }
447
448    #[tokio::test]
449    async fn test_stat() {
450        let (fs, dir) = setup().await;
451
452        fs.write(Path::new("file.txt"), b"content").await.unwrap();
453        fs.mkdir(Path::new("dir")).await.unwrap();
454
455        let file_entry = fs.stat(Path::new("file.txt")).await.unwrap();
456        assert!(file_entry.is_file());
457        assert_eq!(file_entry.size, 7);
458
459        let dir_entry = fs.stat(Path::new("dir")).await.unwrap();
460        assert!(dir_entry.is_dir());
461
462        cleanup(&dir).await;
463    }
464
465    #[tokio::test]
466    async fn test_remove() {
467        let (fs, dir) = setup().await;
468
469        fs.write(Path::new("file.txt"), b"data").await.unwrap();
470        assert!(fs.exists(Path::new("file.txt")).await);
471
472        fs.remove(Path::new("file.txt")).await.unwrap();
473        assert!(!fs.exists(Path::new("file.txt")).await);
474
475        cleanup(&dir).await;
476    }
477
478    #[tokio::test]
479    async fn test_path_escape_blocked() {
480        let (fs, dir) = setup().await;
481
482        // Trying to escape via .. should fail
483        let result = fs.read(Path::new("../../../etc/passwd")).await;
484        assert!(result.is_err());
485
486        cleanup(&dir).await;
487    }
488
489    #[tokio::test]
490    async fn test_lstat_path_escape_blocked() {
491        // Bug H: lstat must validate path containment
492        let (fs, dir) = setup().await;
493
494        let result = fs.lstat(Path::new("../../etc/passwd")).await;
495        assert!(result.is_err());
496        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
497
498        cleanup(&dir).await;
499    }
500
501    #[tokio::test]
502    async fn test_read_link_path_escape_blocked() {
503        // Bug H: read_link must validate path containment
504        let (fs, dir) = setup().await;
505
506        let result = fs.read_link(Path::new("../../etc/passwd")).await;
507        assert!(result.is_err());
508        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
509
510        cleanup(&dir).await;
511    }
512
513    #[cfg(unix)]
514    #[tokio::test]
515    async fn test_lstat_on_valid_symlink() {
516        // Regression: lstat should still work for valid symlinks
517        let (fs, dir) = setup().await;
518
519        fs.write(Path::new("target.txt"), b"content").await.unwrap();
520        fs.symlink(Path::new("target.txt"), Path::new("link.txt"))
521            .await
522            .unwrap();
523
524        let entry = fs.lstat(Path::new("link.txt")).await.unwrap();
525        assert!(entry.is_symlink(), "lstat should report symlink kind");
526
527        cleanup(&dir).await;
528    }
529
530    #[cfg(unix)]
531    #[tokio::test]
532    async fn test_symlink_absolute_target_escape_blocked() {
533        // Bug I: absolute symlink targets must stay within sandbox
534        let (fs, dir) = setup().await;
535
536        let result = fs
537            .symlink(Path::new("/etc/passwd"), Path::new("escape_link"))
538            .await;
539        assert!(result.is_err());
540        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
541
542        cleanup(&dir).await;
543    }
544
545    #[cfg(unix)]
546    #[tokio::test]
547    async fn test_symlink_relative_target_allowed() {
548        // Regression: relative symlink targets should still be allowed
549        let (fs, dir) = setup().await;
550
551        fs.write(Path::new("target.txt"), b"content").await.unwrap();
552        let result = fs
553            .symlink(Path::new("target.txt"), Path::new("rel_link"))
554            .await;
555        assert!(result.is_ok());
556
557        cleanup(&dir).await;
558    }
559}