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    /// 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
154#[async_trait]
155impl Filesystem for LocalFs {
156    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
157        let full_path = self.resolve(path)?;
158        fs::read(&full_path).await
159    }
160
161    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
162        self.check_writable()?;
163        let full_path = self.resolve(path)?;
164
165        // Ensure parent directory exists
166        if let Some(parent) = full_path.parent() {
167            fs::create_dir_all(parent).await?;
168        }
169
170        fs::write(&full_path, data).await
171    }
172
173    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
174        let full_path = self.resolve(path)?;
175        let mut entries = Vec::new();
176        let mut dir = fs::read_dir(&full_path).await?;
177
178        while let Some(entry) = dir.next_entry().await? {
179            // Use symlink_metadata to detect symlinks without following them
180            let metadata = fs::symlink_metadata(entry.path()).await?;
181            let file_type = metadata.file_type();
182
183            let (entry_type, symlink_target) = if file_type.is_symlink() {
184                // Read the symlink target
185                let target = fs::read_link(entry.path()).await.ok();
186                (EntryType::Symlink, target)
187            } else if file_type.is_dir() {
188                (EntryType::Directory, None)
189            } else {
190                (EntryType::File, None)
191            };
192
193            entries.push(DirEntry {
194                name: entry.file_name().to_string_lossy().into_owned(),
195                entry_type,
196                size: metadata.len(),
197                symlink_target,
198            });
199        }
200
201        entries.sort_by(|a, b| a.name.cmp(&b.name));
202        Ok(entries)
203    }
204
205    async fn stat(&self, path: &Path) -> io::Result<Metadata> {
206        let full_path = self.resolve(path)?;
207        // stat follows symlinks
208        let meta = fs::metadata(&full_path).await?;
209
210        Ok(Metadata {
211            is_dir: meta.is_dir(),
212            is_file: meta.is_file(),
213            is_symlink: false, // stat follows symlinks, so the target is never a symlink
214            size: meta.len(),
215            modified: meta.modified().ok(),
216        })
217    }
218
219    async fn lstat(&self, path: &Path) -> io::Result<Metadata> {
220        // lstat doesn't follow symlinks - validate containment without canonicalization
221        let full_path = self.resolve_no_follow(path)?;
222
223        // Use symlink_metadata which doesn't follow symlinks
224        let meta = fs::symlink_metadata(&full_path).await?;
225
226        Ok(Metadata {
227            is_dir: meta.is_dir(),
228            is_file: meta.is_file(),
229            is_symlink: meta.file_type().is_symlink(),
230            size: meta.len(),
231            modified: meta.modified().ok(),
232        })
233    }
234
235    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
236        let full_path = self.resolve_no_follow(path)?;
237        fs::read_link(&full_path).await
238    }
239
240    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
241        self.check_writable()?;
242
243        // Validate absolute symlink targets stay within sandbox
244        if target.is_absolute() {
245            self.resolve(target)?;
246        }
247
248        let link_path = self.resolve_no_follow(link)?;
249
250        // Ensure parent directory exists
251        if let Some(parent) = link_path.parent() {
252            fs::create_dir_all(parent).await?;
253        }
254
255        #[cfg(unix)]
256        {
257            tokio::fs::symlink(target, &link_path).await
258        }
259        #[cfg(windows)]
260        {
261            // Windows needs to know if target is a file or directory
262            // Default to file symlink; for directories use symlink_dir
263            tokio::fs::symlink_file(target, &link_path).await
264        }
265    }
266
267    async fn mkdir(&self, path: &Path) -> io::Result<()> {
268        self.check_writable()?;
269        let full_path = self.resolve(path)?;
270        fs::create_dir_all(&full_path).await
271    }
272
273    async fn remove(&self, path: &Path) -> io::Result<()> {
274        self.check_writable()?;
275        let full_path = self.resolve(path)?;
276        let meta = fs::metadata(&full_path).await?;
277
278        if meta.is_dir() {
279            fs::remove_dir(&full_path).await
280        } else {
281            fs::remove_file(&full_path).await
282        }
283    }
284
285    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
286        self.check_writable()?;
287        let from_path = self.resolve(from)?;
288        let to_path = self.resolve(to)?;
289
290        // Ensure parent directory exists for destination
291        if let Some(parent) = to_path.parent() {
292            fs::create_dir_all(parent).await?;
293        }
294
295        fs::rename(&from_path, &to_path).await
296    }
297
298    fn read_only(&self) -> bool {
299        self.read_only
300    }
301
302    fn real_path(&self, path: &Path) -> Option<PathBuf> {
303        self.resolve(path).ok()
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use std::env;
311    use std::sync::atomic::{AtomicU64, Ordering};
312
313    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
314
315    fn temp_dir() -> PathBuf {
316        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
317        env::temp_dir().join(format!("kaish-test-{}-{}", std::process::id(), id))
318    }
319
320    async fn setup() -> (LocalFs, PathBuf) {
321        let dir = temp_dir();
322        let _ = fs::remove_dir_all(&dir).await;
323        fs::create_dir_all(&dir).await.unwrap();
324        (LocalFs::new(&dir), dir)
325    }
326
327    async fn cleanup(dir: &Path) {
328        let _ = fs::remove_dir_all(dir).await;
329    }
330
331    #[tokio::test]
332    async fn test_write_and_read() {
333        let (fs, dir) = setup().await;
334
335        fs.write(Path::new("test.txt"), b"hello").await.unwrap();
336        let data = fs.read(Path::new("test.txt")).await.unwrap();
337        assert_eq!(data, b"hello");
338
339        cleanup(&dir).await;
340    }
341
342    #[tokio::test]
343    async fn test_nested_write() {
344        let (fs, dir) = setup().await;
345
346        fs.write(Path::new("a/b/c.txt"), b"nested").await.unwrap();
347        let data = fs.read(Path::new("a/b/c.txt")).await.unwrap();
348        assert_eq!(data, b"nested");
349
350        cleanup(&dir).await;
351    }
352
353    #[tokio::test]
354    async fn test_read_only() {
355        let (_, dir) = setup().await;
356        let fs = LocalFs::read_only(&dir);
357
358        let result = fs.write(Path::new("test.txt"), b"data").await;
359        assert!(result.is_err());
360        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
361
362        cleanup(&dir).await;
363    }
364
365    #[tokio::test]
366    async fn test_list() {
367        let (fs, dir) = setup().await;
368
369        fs.write(Path::new("a.txt"), b"a").await.unwrap();
370        fs.write(Path::new("b.txt"), b"b").await.unwrap();
371        fs.mkdir(Path::new("subdir")).await.unwrap();
372
373        let entries = fs.list(Path::new("")).await.unwrap();
374        assert_eq!(entries.len(), 3);
375
376        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
377        assert!(names.contains(&&"a.txt".to_string()));
378        assert!(names.contains(&&"b.txt".to_string()));
379        assert!(names.contains(&&"subdir".to_string()));
380
381        cleanup(&dir).await;
382    }
383
384    #[tokio::test]
385    async fn test_stat() {
386        let (fs, dir) = setup().await;
387
388        fs.write(Path::new("file.txt"), b"content").await.unwrap();
389        fs.mkdir(Path::new("dir")).await.unwrap();
390
391        let file_meta = fs.stat(Path::new("file.txt")).await.unwrap();
392        assert!(file_meta.is_file);
393        assert!(!file_meta.is_dir);
394        assert_eq!(file_meta.size, 7);
395
396        let dir_meta = fs.stat(Path::new("dir")).await.unwrap();
397        assert!(dir_meta.is_dir);
398        assert!(!dir_meta.is_file);
399
400        cleanup(&dir).await;
401    }
402
403    #[tokio::test]
404    async fn test_remove() {
405        let (fs, dir) = setup().await;
406
407        fs.write(Path::new("file.txt"), b"data").await.unwrap();
408        assert!(fs.exists(Path::new("file.txt")).await);
409
410        fs.remove(Path::new("file.txt")).await.unwrap();
411        assert!(!fs.exists(Path::new("file.txt")).await);
412
413        cleanup(&dir).await;
414    }
415
416    #[tokio::test]
417    async fn test_path_escape_blocked() {
418        let (fs, dir) = setup().await;
419
420        // Trying to escape via .. should fail
421        let result = fs.read(Path::new("../../../etc/passwd")).await;
422        assert!(result.is_err());
423
424        cleanup(&dir).await;
425    }
426
427    #[tokio::test]
428    async fn test_lstat_path_escape_blocked() {
429        // Bug H: lstat must validate path containment
430        let (fs, dir) = setup().await;
431
432        let result = fs.lstat(Path::new("../../etc/passwd")).await;
433        assert!(result.is_err());
434        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
435
436        cleanup(&dir).await;
437    }
438
439    #[tokio::test]
440    async fn test_read_link_path_escape_blocked() {
441        // Bug H: read_link must validate path containment
442        let (fs, dir) = setup().await;
443
444        let result = fs.read_link(Path::new("../../etc/passwd")).await;
445        assert!(result.is_err());
446        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
447
448        cleanup(&dir).await;
449    }
450
451    #[cfg(unix)]
452    #[tokio::test]
453    async fn test_lstat_on_valid_symlink() {
454        // Regression: lstat should still work for valid symlinks
455        let (fs, dir) = setup().await;
456
457        fs.write(Path::new("target.txt"), b"content").await.unwrap();
458        fs.symlink(Path::new("target.txt"), Path::new("link.txt"))
459            .await
460            .unwrap();
461
462        let meta = fs.lstat(Path::new("link.txt")).await.unwrap();
463        assert!(meta.is_symlink, "lstat should report symlink type");
464
465        cleanup(&dir).await;
466    }
467
468    #[cfg(unix)]
469    #[tokio::test]
470    async fn test_symlink_absolute_target_escape_blocked() {
471        // Bug I: absolute symlink targets must stay within sandbox
472        let (fs, dir) = setup().await;
473
474        let result = fs
475            .symlink(Path::new("/etc/passwd"), Path::new("escape_link"))
476            .await;
477        assert!(result.is_err());
478        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
479
480        cleanup(&dir).await;
481    }
482
483    #[cfg(unix)]
484    #[tokio::test]
485    async fn test_symlink_relative_target_allowed() {
486        // Regression: relative symlink targets should still be allowed
487        let (fs, dir) = setup().await;
488
489        fs.write(Path::new("target.txt"), b"content").await.unwrap();
490        let result = fs
491            .symlink(Path::new("target.txt"), Path::new("rel_link"))
492            .await;
493        assert!(result.is_ok());
494
495        cleanup(&dir).await;
496    }
497}