1use super::traits::{DirEntry, DirEntryKind, Filesystem};
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 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 => {} _ => {}
128 }
129 }
130
131 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 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 #[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 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 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 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 (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 let meta = fs::metadata(&full_path).await?;
224
225 let kind = if meta.is_dir() {
226 DirEntryKind::Directory
227 } else {
228 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, })
247 }
248
249 async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
250 let full_path = self.resolve_no_follow(path)?;
252
253 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 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 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 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 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 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 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 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 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 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 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 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}