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() {
297 self.resolve(target)?;
298 }
299
300 let link_path = self.resolve_no_follow(link)?;
301
302 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 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 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 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 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 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 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 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 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}