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        if target.is_absolute() {
297            self.resolve(target)?;
298        }
299
300        let link_path = self.resolve_no_follow(link)?;
301
302        // Ensure parent directory exists
303        if let Some(parent) = link_path.parent() {
304            fs::create_dir_all(parent).await?;
305        }
306
307        #[cfg(unix)]
308        {
309            tokio::fs::symlink(target, &link_path).await
310        }
311        #[cfg(windows)]
312        {
313            // Windows needs to know if target is a file or directory
314            // Default to file symlink; for directories use symlink_dir
315            tokio::fs::symlink_file(target, &link_path).await
316        }
317    }
318
319    async fn mkdir(&self, path: &Path) -> io::Result<()> {
320        self.check_writable()?;
321        let full_path = self.resolve(path)?;
322        fs::create_dir_all(&full_path).await
323    }
324
325    async fn remove(&self, path: &Path) -> io::Result<()> {
326        self.check_writable()?;
327        let full_path = self.resolve(path)?;
328        let meta = fs::metadata(&full_path).await?;
329
330        if meta.is_dir() {
331            fs::remove_dir(&full_path).await
332        } else {
333            fs::remove_file(&full_path).await
334        }
335    }
336
337    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
338        self.check_writable()?;
339        let from_path = self.resolve(from)?;
340        let to_path = self.resolve(to)?;
341
342        // Ensure parent directory exists for destination
343        if let Some(parent) = to_path.parent() {
344            fs::create_dir_all(parent).await?;
345        }
346
347        fs::rename(&from_path, &to_path).await
348    }
349
350    fn read_only(&self) -> bool {
351        self.read_only
352    }
353
354    fn real_path(&self, path: &Path) -> Option<PathBuf> {
355        self.resolve(path).ok()
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::env;
363    use std::sync::atomic::{AtomicU64, Ordering};
364
365    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
366
367    fn temp_dir() -> PathBuf {
368        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
369        env::temp_dir().join(format!("kaish-test-{}-{}", std::process::id(), id))
370    }
371
372    async fn setup() -> (LocalFs, PathBuf) {
373        let dir = temp_dir();
374        let _ = fs::remove_dir_all(&dir).await;
375        fs::create_dir_all(&dir).await.unwrap();
376        (LocalFs::new(&dir), dir)
377    }
378
379    async fn cleanup(dir: &Path) {
380        let _ = fs::remove_dir_all(dir).await;
381    }
382
383    #[tokio::test]
384    async fn test_write_and_read() {
385        let (fs, dir) = setup().await;
386
387        fs.write(Path::new("test.txt"), b"hello").await.unwrap();
388        let data = fs.read(Path::new("test.txt")).await.unwrap();
389        assert_eq!(data, b"hello");
390
391        cleanup(&dir).await;
392    }
393
394    #[tokio::test]
395    async fn test_nested_write() {
396        let (fs, dir) = setup().await;
397
398        fs.write(Path::new("a/b/c.txt"), b"nested").await.unwrap();
399        let data = fs.read(Path::new("a/b/c.txt")).await.unwrap();
400        assert_eq!(data, b"nested");
401
402        cleanup(&dir).await;
403    }
404
405    #[tokio::test]
406    async fn test_read_only() {
407        let (_, dir) = setup().await;
408        let fs = LocalFs::read_only(&dir);
409
410        let result = fs.write(Path::new("test.txt"), b"data").await;
411        assert!(result.is_err());
412        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
413
414        cleanup(&dir).await;
415    }
416
417    #[tokio::test]
418    async fn test_list() {
419        let (fs, dir) = setup().await;
420
421        fs.write(Path::new("a.txt"), b"a").await.unwrap();
422        fs.write(Path::new("b.txt"), b"b").await.unwrap();
423        fs.mkdir(Path::new("subdir")).await.unwrap();
424
425        let entries = fs.list(Path::new("")).await.unwrap();
426        assert_eq!(entries.len(), 3);
427
428        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
429        assert!(names.contains(&&"a.txt".to_string()));
430        assert!(names.contains(&&"b.txt".to_string()));
431        assert!(names.contains(&&"subdir".to_string()));
432
433        cleanup(&dir).await;
434    }
435
436    #[tokio::test]
437    async fn test_stat() {
438        let (fs, dir) = setup().await;
439
440        fs.write(Path::new("file.txt"), b"content").await.unwrap();
441        fs.mkdir(Path::new("dir")).await.unwrap();
442
443        let file_entry = fs.stat(Path::new("file.txt")).await.unwrap();
444        assert!(file_entry.is_file());
445        assert_eq!(file_entry.size, 7);
446
447        let dir_entry = fs.stat(Path::new("dir")).await.unwrap();
448        assert!(dir_entry.is_dir());
449
450        cleanup(&dir).await;
451    }
452
453    #[tokio::test]
454    async fn test_remove() {
455        let (fs, dir) = setup().await;
456
457        fs.write(Path::new("file.txt"), b"data").await.unwrap();
458        assert!(fs.exists(Path::new("file.txt")).await);
459
460        fs.remove(Path::new("file.txt")).await.unwrap();
461        assert!(!fs.exists(Path::new("file.txt")).await);
462
463        cleanup(&dir).await;
464    }
465
466    #[tokio::test]
467    async fn test_path_escape_blocked() {
468        let (fs, dir) = setup().await;
469
470        // Trying to escape via .. should fail
471        let result = fs.read(Path::new("../../../etc/passwd")).await;
472        assert!(result.is_err());
473
474        cleanup(&dir).await;
475    }
476
477    #[tokio::test]
478    async fn test_lstat_path_escape_blocked() {
479        // Bug H: lstat must validate path containment
480        let (fs, dir) = setup().await;
481
482        let result = fs.lstat(Path::new("../../etc/passwd")).await;
483        assert!(result.is_err());
484        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
485
486        cleanup(&dir).await;
487    }
488
489    #[tokio::test]
490    async fn test_read_link_path_escape_blocked() {
491        // Bug H: read_link must validate path containment
492        let (fs, dir) = setup().await;
493
494        let result = fs.read_link(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    #[cfg(unix)]
502    #[tokio::test]
503    async fn test_lstat_on_valid_symlink() {
504        // Regression: lstat should still work for valid symlinks
505        let (fs, dir) = setup().await;
506
507        fs.write(Path::new("target.txt"), b"content").await.unwrap();
508        fs.symlink(Path::new("target.txt"), Path::new("link.txt"))
509            .await
510            .unwrap();
511
512        let entry = fs.lstat(Path::new("link.txt")).await.unwrap();
513        assert!(entry.is_symlink(), "lstat should report symlink kind");
514
515        cleanup(&dir).await;
516    }
517
518    #[cfg(unix)]
519    #[tokio::test]
520    async fn test_symlink_absolute_target_escape_blocked() {
521        // Bug I: absolute symlink targets must stay within sandbox
522        let (fs, dir) = setup().await;
523
524        let result = fs
525            .symlink(Path::new("/etc/passwd"), Path::new("escape_link"))
526            .await;
527        assert!(result.is_err());
528        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
529
530        cleanup(&dir).await;
531    }
532
533    #[cfg(unix)]
534    #[tokio::test]
535    async fn test_symlink_relative_target_allowed() {
536        // Regression: relative symlink targets should still be allowed
537        let (fs, dir) = setup().await;
538
539        fs.write(Path::new("target.txt"), b"content").await.unwrap();
540        let result = fs
541            .symlink(Path::new("target.txt"), Path::new("rel_link"))
542            .await;
543        assert!(result.is_ok());
544
545        cleanup(&dir).await;
546    }
547}