kaish_kernel/vfs/
local.rs1use super::traits::{DirEntry, EntryType, Filesystem, Metadata};
6use async_trait::async_trait;
7use std::io;
8use std::path::{Path, PathBuf};
9use tokio::fs;
10
11#[derive(Debug, Clone)]
17pub struct LocalFs {
18 root: PathBuf,
19 read_only: bool,
20}
21
22impl LocalFs {
23 pub fn new(root: impl Into<PathBuf>) -> Self {
27 Self {
28 root: root.into(),
29 read_only: false,
30 }
31 }
32
33 pub fn read_only(root: impl Into<PathBuf>) -> Self {
35 Self {
36 root: root.into(),
37 read_only: true,
38 }
39 }
40
41 pub fn set_read_only(&mut self, read_only: bool) {
43 self.read_only = read_only;
44 }
45
46 pub fn root(&self) -> &Path {
48 &self.root
49 }
50
51 fn resolve(&self, path: &Path) -> io::Result<PathBuf> {
55 let path = path.strip_prefix("/").unwrap_or(path);
57
58 let full = self.root.join(path);
60
61 let canonical = if full.exists() {
64 full.canonicalize()?
65 } else {
66 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 full
80 }
81 };
82
83 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 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 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 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 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 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, size: meta.len(),
173 modified: meta.modified().ok(),
174 })
175 }
176
177 async fn lstat(&self, path: &Path) -> io::Result<Metadata> {
178 let path = path.strip_prefix("/").unwrap_or(path);
180 let full_path = self.root.join(path);
181
182 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 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 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 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 let result = fs.read(Path::new("../../../etc/passwd")).await;
377 assert!(result.is_err());
378
379 cleanup(&dir).await;
380 }
381}