1use 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 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
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 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 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 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 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, size: meta.len(),
215 modified: meta.modified().ok(),
216 })
217 }
218
219 async fn lstat(&self, path: &Path) -> io::Result<Metadata> {
220 let full_path = self.resolve_no_follow(path)?;
222
223 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 if target.is_absolute() {
245 self.resolve(target)?;
246 }
247
248 let link_path = self.resolve_no_follow(link)?;
249
250 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 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 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 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 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 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 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 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 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}