1use crate::time_compat::SystemTime;
69use async_trait::async_trait;
70use std::io::{Error as IoError, ErrorKind};
71use std::path::{Path, PathBuf};
72
73use super::backend::FsBackend;
74use super::limits::{FsLimits, FsUsage};
75use super::traits::{DirEntry, FileType, Metadata};
76use crate::error::Result;
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum RealFsMode {
81 ReadOnly,
83 ReadWrite,
90}
91
92pub struct RealFs {
109 root: PathBuf,
111 mode: RealFsMode,
112}
113
114impl RealFs {
115 pub fn new(root: impl AsRef<Path>, mode: RealFsMode) -> std::io::Result<Self> {
120 let root = std::fs::canonicalize(root.as_ref())?;
121 if !root.is_dir() {
122 return Err(IoError::new(
123 ErrorKind::NotADirectory,
124 format!("realfs root is not a directory: {}", root.display()),
125 ));
126 }
127 Ok(Self { root, mode })
128 }
129
130 fn resolve(&self, vpath: &Path) -> std::io::Result<PathBuf> {
138 let normalized = normalize_vpath(vpath);
139 let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
141
142 if relative == Path::new("") {
144 return Ok(self.root.clone());
145 }
146
147 let joined = self.root.join(relative);
148
149 if joined.exists() {
151 let canon = std::fs::canonicalize(&joined)?;
152 if !canon.starts_with(&self.root) {
153 return Err(IoError::new(
154 ErrorKind::PermissionDenied,
155 "path escapes realfs root",
156 ));
157 }
158 return Ok(canon);
159 }
160
161 let mut nearest_existing = joined.as_path();
165 while !nearest_existing.exists() {
166 nearest_existing = nearest_existing.parent().ok_or_else(|| {
167 IoError::new(ErrorKind::PermissionDenied, "path escapes realfs root")
168 })?;
169 }
170
171 let canon_existing = std::fs::canonicalize(nearest_existing)?;
172 if !canon_existing.starts_with(&self.root) {
173 return Err(IoError::new(
174 ErrorKind::PermissionDenied,
175 "path escapes realfs root",
176 ));
177 }
178
179 let suffix = joined
180 .strip_prefix(nearest_existing)
181 .map_err(|_| IoError::new(ErrorKind::PermissionDenied, "path escapes realfs root"))?;
182 let candidate = normalize_host_path(&canon_existing.join(suffix));
183
184 if !candidate.starts_with(&self.root) {
185 return Err(IoError::new(
186 ErrorKind::PermissionDenied,
187 "path escapes realfs root",
188 ));
189 }
190
191 Ok(candidate)
192 }
193
194 fn resolve_no_follow(&self, vpath: &Path) -> std::io::Result<PathBuf> {
206 let normalized = normalize_vpath(vpath);
207 let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
208
209 if relative == Path::new("") {
210 return Ok(self.root.clone());
211 }
212
213 let joined = self.root.join(relative);
214 let parent = joined
215 .parent()
216 .ok_or_else(|| IoError::new(ErrorKind::PermissionDenied, "path escapes realfs root"))?;
217 let file_name = joined
218 .file_name()
219 .ok_or_else(|| IoError::new(ErrorKind::InvalidInput, "path has no final component"))?;
220
221 let canon_parent = std::fs::canonicalize(parent)?;
222 if !canon_parent.starts_with(&self.root) {
223 return Err(IoError::new(
224 ErrorKind::PermissionDenied,
225 "path escapes realfs root",
226 ));
227 }
228
229 Ok(canon_parent.join(file_name))
230 }
231
232 fn resolve_for_create(&self, vpath: &Path) -> std::io::Result<PathBuf> {
242 let normalized = normalize_vpath(vpath);
243 let relative = normalized.strip_prefix("/").unwrap_or(&normalized);
244
245 if relative != Path::new("") {
246 let joined = self.root.join(relative);
247 if matches!(
248 std::fs::symlink_metadata(&joined),
249 Ok(m) if m.file_type().is_symlink()
250 ) {
251 return Err(IoError::new(
252 ErrorKind::PermissionDenied,
253 "refusing to create or write through a symlink (sandbox security)",
254 ));
255 }
256 }
257
258 self.resolve(vpath)
259 }
260
261 fn check_writable(&self) -> std::io::Result<()> {
263 if self.mode == RealFsMode::ReadOnly {
264 return Err(IoError::new(
265 ErrorKind::PermissionDenied,
266 "realfs is mounted readonly",
267 ));
268 }
269 Ok(())
270 }
271
272 pub fn root(&self) -> &Path {
274 &self.root
275 }
276
277 pub fn mode(&self) -> RealFsMode {
279 self.mode
280 }
281}
282
283impl std::fmt::Debug for RealFs {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.debug_struct("RealFs")
286 .field("root", &self.root)
287 .field("mode", &self.mode)
288 .finish()
289 }
290}
291
292fn file_type_from_std(ft: std::fs::FileType) -> FileType {
293 if ft.is_dir() {
294 FileType::Directory
295 } else if ft.is_symlink() {
296 FileType::Symlink
297 } else {
298 FileType::File
299 }
300}
301
302fn metadata_from_std(m: &std::fs::Metadata) -> Metadata {
303 let file_type = file_type_from_std(m.file_type());
304 let size = if file_type.is_dir() { 0 } else { m.len() };
305 #[cfg(unix)]
306 let mode = {
307 use std::os::unix::fs::PermissionsExt;
308 m.permissions().mode() & 0o7777
309 };
310 #[cfg(not(unix))]
311 let mode = if m.permissions().readonly() {
312 0o444
313 } else {
314 0o644
315 };
316 Metadata {
317 file_type,
318 size,
319 mode,
320 modified: m.modified().unwrap_or(SystemTime::UNIX_EPOCH),
321 created: m.created().unwrap_or(SystemTime::UNIX_EPOCH),
322 }
323}
324
325fn normalize_host_path(path: &Path) -> PathBuf {
331 let mut components = Vec::new();
332 for component in path.components() {
333 match component {
334 std::path::Component::ParentDir => {
335 if matches!(components.last(), Some(std::path::Component::Normal(_))) {
337 components.pop();
338 }
339 }
340 std::path::Component::CurDir => {}
341 c => components.push(c),
342 }
343 }
344 if components.is_empty() {
345 PathBuf::from("/")
346 } else {
347 components.iter().collect()
348 }
349}
350
351fn normalize_vpath(path: &Path) -> PathBuf {
353 let mut components = Vec::new();
354 for component in path.components() {
355 match component {
356 std::path::Component::RootDir => {
357 components.clear();
358 components.push(std::path::Component::RootDir);
359 }
360 std::path::Component::CurDir => {}
361 std::path::Component::ParentDir => {
362 if components.len() > 1 {
363 components.pop();
364 }
365 }
366 c => components.push(c),
367 }
368 }
369 if components.is_empty() {
370 PathBuf::from("/")
371 } else {
372 components.iter().collect()
373 }
374}
375
376#[async_trait]
377impl FsBackend for RealFs {
378 async fn read(&self, path: &Path) -> Result<Vec<u8>> {
379 let real = self.resolve(path)?;
380 let data = tokio::fs::read(&real).await?;
381 Ok(data)
382 }
383
384 async fn write(&self, path: &Path, content: &[u8]) -> Result<()> {
385 self.check_writable()?;
386 let real = self.resolve_for_create(path)?;
390 if let Some(parent) = real.parent() {
391 tokio::fs::create_dir_all(parent).await?;
392 }
393 tokio::fs::write(&real, content).await?;
394 Ok(())
395 }
396
397 async fn append(&self, path: &Path, content: &[u8]) -> Result<()> {
398 self.check_writable()?;
399 let real = self.resolve_for_create(path)?;
401 use tokio::io::AsyncWriteExt;
402 let mut file = tokio::fs::OpenOptions::new()
403 .create(true)
404 .append(true)
405 .open(&real)
406 .await?;
407 file.write_all(content).await?;
408 file.flush().await?;
409 Ok(())
410 }
411
412 async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
413 self.check_writable()?;
414 let real = self.resolve(path)?;
415 if recursive {
416 tokio::fs::create_dir_all(&real).await?;
417 } else {
418 tokio::fs::create_dir(&real).await?;
419 }
420 Ok(())
421 }
422
423 async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
424 self.check_writable()?;
425 let real = self.resolve_no_follow(path)?;
429 let meta = tokio::fs::symlink_metadata(&real).await?;
430 let ft = meta.file_type();
431 if ft.is_symlink() {
432 tokio::fs::remove_file(&real).await?;
433 } else if ft.is_dir() {
434 if recursive {
435 tokio::fs::remove_dir_all(&real).await?;
436 } else {
437 tokio::fs::remove_dir(&real).await?;
438 }
439 } else {
440 tokio::fs::remove_file(&real).await?;
441 }
442 Ok(())
443 }
444
445 async fn stat(&self, path: &Path) -> Result<Metadata> {
446 let real = self.resolve_no_follow(path)?;
449 let meta = tokio::fs::symlink_metadata(&real).await?;
450 Ok(metadata_from_std(&meta))
451 }
452
453 async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
454 let real = self.resolve(path)?;
455 let mut entries = Vec::new();
456 let mut dir = tokio::fs::read_dir(&real).await?;
457 while let Some(entry) = dir.next_entry().await? {
458 let name = entry.file_name().to_string_lossy().to_string();
459 let meta = entry.metadata().await?;
460 entries.push(DirEntry {
461 name,
462 metadata: metadata_from_std(&meta),
463 });
464 }
465 entries.sort_by(|a, b| a.name.cmp(&b.name));
467 Ok(entries)
468 }
469
470 async fn exists(&self, path: &Path) -> Result<bool> {
471 let real = self.resolve(path)?;
472 Ok(tokio::fs::try_exists(&real).await.unwrap_or(false))
473 }
474
475 async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
476 self.check_writable()?;
477 let real_from = self.resolve(from)?;
478 let real_to = self.resolve(to)?;
479 tokio::fs::rename(&real_from, &real_to).await?;
480 Ok(())
481 }
482
483 async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
484 self.check_writable()?;
485 let real_from = self.resolve(from)?;
486 let real_to = self.resolve_for_create(to)?;
489 tokio::fs::copy(&real_from, &real_to).await?;
490 Ok(())
491 }
492
493 async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
500 self.check_writable()?;
501 let real_link = self.resolve(link)?;
502
503 if target.is_absolute() {
505 return Err(IoError::new(
506 ErrorKind::PermissionDenied,
507 "symlink with absolute target not allowed in RealFs (sandbox security)",
508 )
509 .into());
510 }
511
512 if target
516 .components()
517 .any(|component| matches!(component, std::path::Component::ParentDir))
518 {
519 return Err(IoError::new(
520 ErrorKind::PermissionDenied,
521 "symlink target with parent components not allowed in RealFs (sandbox security)",
522 )
523 .into());
524 }
525
526 let link_parent = real_link.parent().unwrap_or(&self.root);
530 let joined = normalize_host_path(&link_parent.join(target));
531 let mut nearest_existing = joined.as_path();
532 while !nearest_existing.exists() {
533 nearest_existing = nearest_existing.parent().ok_or_else(|| {
534 IoError::new(
535 ErrorKind::PermissionDenied,
536 "symlink target escapes realfs root (sandbox security)",
537 )
538 })?;
539 }
540
541 let canon_existing = std::fs::canonicalize(nearest_existing)?;
542 if !canon_existing.starts_with(&self.root) {
543 return Err(IoError::new(
544 ErrorKind::PermissionDenied,
545 "symlink target escapes realfs root (sandbox security)",
546 )
547 .into());
548 }
549 let suffix = joined.strip_prefix(nearest_existing).map_err(|_| {
550 IoError::new(
551 ErrorKind::PermissionDenied,
552 "symlink target escapes realfs root (sandbox security)",
553 )
554 })?;
555 let effective = normalize_host_path(&canon_existing.join(suffix));
556 if !effective.starts_with(&self.root) {
557 return Err(IoError::new(
558 ErrorKind::PermissionDenied,
559 "symlink target escapes realfs root (sandbox security)",
560 )
561 .into());
562 }
563
564 #[cfg(unix)]
565 {
566 tokio::fs::symlink(target, &real_link).await?;
567 }
568 #[cfg(not(unix))]
569 {
570 let _ = target;
571 tokio::fs::write(&real_link, "").await?;
572 }
573 Ok(())
574 }
575
576 async fn read_link(&self, path: &Path) -> Result<PathBuf> {
577 let real = self.resolve_no_follow(path)?;
581 let target = tokio::fs::read_link(&real).await?;
582 Ok(target)
583 }
584
585 async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
586 self.check_writable()?;
587 let real = self.resolve(path)?;
588 #[cfg(unix)]
589 {
590 use std::os::unix::fs::PermissionsExt;
591 let perms = std::fs::Permissions::from_mode(mode);
592 tokio::fs::set_permissions(&real, perms).await?;
593 }
594 #[cfg(not(unix))]
595 {
596 let _ = (mode, &real);
597 }
598 Ok(())
599 }
600
601 async fn set_modified_time(&self, path: &Path, time: SystemTime) -> Result<()> {
602 self.check_writable()?;
603 let real = self.resolve(path)?;
604 let file = std::fs::File::open(&real)?;
605 file.set_modified(time)?;
606 Ok(())
607 }
608
609 fn usage(&self) -> FsUsage {
610 FsUsage::default()
612 }
613
614 fn limits(&self) -> FsLimits {
615 FsLimits::unlimited()
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use tempfile::TempDir;
623
624 fn setup() -> TempDir {
625 let dir = tempfile::tempdir().unwrap();
626 std::fs::write(dir.path().join("hello.txt"), b"hello world").unwrap();
628 std::fs::create_dir(dir.path().join("subdir")).unwrap();
629 std::fs::write(dir.path().join("subdir/nested.txt"), b"nested content").unwrap();
630 dir
631 }
632
633 #[tokio::test]
634 async fn read_file() {
635 let dir = setup();
636 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
637 let data = fs.read(Path::new("/hello.txt")).await.unwrap();
638 assert_eq!(data, b"hello world");
639 }
640
641 #[tokio::test]
642 async fn read_nested() {
643 let dir = setup();
644 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
645 let data = fs.read(Path::new("/subdir/nested.txt")).await.unwrap();
646 assert_eq!(data, b"nested content");
647 }
648
649 #[tokio::test]
650 async fn read_root_dir() {
651 let dir = setup();
652 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
653 let entries = fs.read_dir(Path::new("/")).await.unwrap();
654 let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
655 assert!(names.contains(&"hello.txt"));
656 assert!(names.contains(&"subdir"));
657 }
658
659 #[tokio::test]
660 async fn stat_file() {
661 let dir = setup();
662 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
663 let meta = fs.stat(Path::new("/hello.txt")).await.unwrap();
664 assert!(meta.file_type.is_file());
665 assert_eq!(meta.size, 11); }
667
668 #[tokio::test]
669 async fn stat_dir() {
670 let dir = setup();
671 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
672 let meta = fs.stat(Path::new("/subdir")).await.unwrap();
673 assert!(meta.file_type.is_dir());
674 assert_eq!(meta.size, 0);
675 }
676
677 #[tokio::test]
678 async fn exists_checks() {
679 let dir = setup();
680 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
681 assert!(fs.exists(Path::new("/hello.txt")).await.unwrap());
682 assert!(fs.exists(Path::new("/subdir")).await.unwrap());
683 assert!(fs.exists(Path::new("/")).await.unwrap());
684 assert!(!fs.exists(Path::new("/nope")).await.unwrap());
685 }
686
687 #[tokio::test]
688 async fn readonly_rejects_write() {
689 let dir = setup();
690 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
691 let err = fs.write(Path::new("/new.txt"), b"data").await;
692 assert!(err.is_err());
693 let msg = format!("{}", err.unwrap_err());
694 assert!(msg.contains("readonly"), "error was: {msg}");
695 }
696
697 #[tokio::test]
698 async fn readonly_rejects_mkdir() {
699 let dir = setup();
700 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
701 let err = fs.mkdir(Path::new("/newdir"), false).await;
702 assert!(err.is_err());
703 }
704
705 #[tokio::test]
706 async fn readonly_rejects_remove() {
707 let dir = setup();
708 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
709 let err = fs.remove(Path::new("/hello.txt"), false).await;
710 assert!(err.is_err());
711 }
712
713 #[tokio::test]
714 async fn readwrite_can_write() {
715 let dir = setup();
716 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
717 fs.write(Path::new("/new.txt"), b"new data").await.unwrap();
718 let data = fs.read(Path::new("/new.txt")).await.unwrap();
719 assert_eq!(data, b"new data");
720 }
721
722 #[tokio::test]
723 async fn readwrite_can_mkdir() {
724 let dir = setup();
725 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
726 fs.mkdir(Path::new("/newdir"), false).await.unwrap();
727 assert!(fs.exists(Path::new("/newdir")).await.unwrap());
728 }
729
730 #[tokio::test]
731 async fn readwrite_can_remove() {
732 let dir = setup();
733 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
734 fs.remove(Path::new("/hello.txt"), false).await.unwrap();
735 assert!(!fs.exists(Path::new("/hello.txt")).await.unwrap());
736 }
737
738 #[tokio::test]
739 async fn readwrite_append() {
740 let dir = setup();
741 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
742 fs.append(Path::new("/hello.txt"), b" appended")
743 .await
744 .unwrap();
745 let data = fs.read(Path::new("/hello.txt")).await.unwrap();
746 assert_eq!(data, b"hello world appended");
747 }
748
749 #[tokio::test]
750 async fn path_traversal_blocked() {
751 let dir = setup();
752 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
753 let result = fs.read(Path::new("/../../../etc/passwd")).await;
755 if let Ok(data) = &result {
758 assert!(
760 data == b"hello world" || data.is_empty(),
761 "path traversal should not leak host files"
762 );
763 }
764 }
765
766 #[tokio::test]
767 async fn normalize_collapses_dots() {
768 let dir = setup();
769 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
770 let data = fs.read(Path::new("/subdir/../hello.txt")).await.unwrap();
771 assert_eq!(data, b"hello world");
772 }
773
774 #[tokio::test]
775 async fn rename_readwrite() {
776 let dir = setup();
777 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
778 fs.rename(Path::new("/hello.txt"), Path::new("/renamed.txt"))
779 .await
780 .unwrap();
781 assert!(!fs.exists(Path::new("/hello.txt")).await.unwrap());
782 let data = fs.read(Path::new("/renamed.txt")).await.unwrap();
783 assert_eq!(data, b"hello world");
784 }
785
786 #[tokio::test]
787 async fn copy_readwrite() {
788 let dir = setup();
789 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
790 fs.copy(Path::new("/hello.txt"), Path::new("/copied.txt"))
791 .await
792 .unwrap();
793 let data = fs.read(Path::new("/copied.txt")).await.unwrap();
794 assert_eq!(data, b"hello world");
795 assert!(fs.exists(Path::new("/hello.txt")).await.unwrap());
797 }
798
799 #[test]
800 fn new_rejects_nonexistent() {
801 let result = RealFs::new(
802 "/nonexistent/path/that/does/not/exist",
803 RealFsMode::ReadOnly,
804 );
805 assert!(result.is_err());
806 }
807
808 #[test]
809 fn new_rejects_file_as_root() {
810 let dir = setup();
811 let file_path = dir.path().join("hello.txt");
812 let result = RealFs::new(&file_path, RealFsMode::ReadOnly);
813 assert!(result.is_err());
814 }
815
816 #[test]
819 fn normalize_host_path_resolves_dotdot() {
820 let p = normalize_host_path(Path::new("/a/b/../c"));
821 assert_eq!(p, PathBuf::from("/a/c"));
822
823 let p = normalize_host_path(Path::new("/a/b/../../c"));
824 assert_eq!(p, PathBuf::from("/c"));
825
826 let p = normalize_host_path(Path::new("/a/../../../x"));
828 assert_eq!(p, PathBuf::from("/x"));
829 }
830
831 #[test]
832 fn normalize_host_path_preserves_absolute() {
833 let p = normalize_host_path(Path::new("/tmp/sandbox/./foo/../bar"));
834 assert_eq!(p, PathBuf::from("/tmp/sandbox/bar"));
835 }
836
837 #[test]
838 fn resolve_fallback_validates_containment() {
839 let dir = setup();
842 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
843
844 let result = fs.resolve(Path::new("/newdir/newfile.txt"));
846 assert!(
847 result.is_ok(),
848 "valid non-existent path under root should succeed"
849 );
850 let resolved = result.unwrap();
851 assert!(
852 resolved.starts_with(fs.root()),
853 "resolved path must be under root"
854 );
855 }
856
857 #[test]
858 fn resolve_fallback_returns_normalized_path() {
859 let dir = setup();
862 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
863
864 let result = fs.resolve(Path::new("/a/b/../c/file.txt"));
865 assert!(result.is_ok());
866 let resolved = result.unwrap();
867 assert!(
869 !resolved.to_string_lossy().contains(".."),
870 "fallback path must be normalized, got: {}",
871 resolved.display()
872 );
873 assert!(resolved.starts_with(fs.root()));
874 }
875
876 #[tokio::test]
877 async fn security_traversal_blocked_all_paths() {
878 let dir = setup();
881 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
882
883 let traversal_paths = [
884 "/../../../etc/passwd",
885 "/../../etc/shadow",
886 "/subdir/../../etc/passwd",
887 "/./../../etc/passwd",
888 ];
889 for vpath in &traversal_paths {
890 let result = fs.read(Path::new(vpath)).await;
891 if let Ok(data) = &result {
894 let data_str = String::from_utf8_lossy(data);
895 assert!(
896 !data_str.contains("root:"),
897 "traversal leaked /etc/passwd via path {vpath}"
898 );
899 }
900 }
901 }
902
903 #[tokio::test]
904 async fn security_nonexistent_nested_stays_under_root() {
905 let dir = setup();
908 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
909
910 let result = fs
913 .write(Path::new("/deep/nested/dir/file.txt"), b"safe")
914 .await;
915 if result.is_ok() {
917 let expected = dir.path().join("deep/nested/dir/file.txt");
918 assert!(expected.exists(), "file must be created under root");
919 }
920 }
921
922 #[cfg(unix)]
923 #[tokio::test]
924 async fn security_write_rejects_symlink_escape_with_missing_parent() {
925 use std::os::unix::fs::symlink;
926
927 let root = tempfile::tempdir().unwrap();
928 let outside = tempfile::tempdir().unwrap();
929 symlink(outside.path(), root.path().join("link")).unwrap();
930
931 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
932 let result = fs
933 .write(Path::new("/link/newdir/pwned.txt"), b"owned")
934 .await;
935
936 assert!(result.is_err(), "write through symlink escape must fail");
937 assert!(
938 !outside.path().join("newdir/pwned.txt").exists(),
939 "must not create file outside realfs root"
940 );
941 }
942
943 #[cfg(unix)]
944 #[tokio::test]
945 async fn security_symlink_rejects_parent_components_before_move_escape() {
946 let root = tempfile::tempdir().unwrap();
947 std::fs::create_dir_all(root.path().join("deep/a/b/c")).unwrap();
948
949 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
950 let result = fs
951 .symlink(
952 Path::new("../../../etc/passwd"),
953 Path::new("/deep/a/b/c/escape"),
954 )
955 .await;
956
957 assert!(
958 result.is_err(),
959 "relative symlink targets with parent components can escape after rename"
960 );
961 assert!(
962 !root.path().join("deep/a/b/c/escape").exists(),
963 "must not create movable symlink with unstable containment"
964 );
965 }
966
967 #[cfg(unix)]
968 #[tokio::test]
969 async fn security_symlink_rejects_existing_symlink_component_escape() {
970 use std::os::unix::fs::symlink;
971
972 let root = tempfile::tempdir().unwrap();
973 let outside = tempfile::tempdir().unwrap();
974 symlink(outside.path(), root.path().join("link")).unwrap();
975
976 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
977 let result = fs
978 .symlink(Path::new("link/secret.txt"), Path::new("/escape-link"))
979 .await;
980
981 assert!(
982 result.is_err(),
983 "symlink target traversing host symlink component must fail"
984 );
985 assert!(
986 !root.path().join("escape-link").exists(),
987 "must not create link when target escapes realfs root"
988 );
989 }
990
991 #[cfg(unix)]
994 #[tokio::test]
995 async fn write_through_dangling_symlink_to_outside_blocked() {
996 use std::os::unix::fs::symlink;
997
998 let root = tempfile::tempdir().unwrap();
999 let outside = tempfile::tempdir().unwrap();
1000 let outside_target = outside.path().join("newfile.txt");
1001 symlink(&outside_target, root.path().join("link")).unwrap();
1004
1005 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1006 let result = fs.write(Path::new("/link"), b"pwned").await;
1007 assert!(
1008 result.is_err(),
1009 "write through dangling symlink must fail (#1575)"
1010 );
1011 assert!(
1012 !outside_target.exists(),
1013 "no file must be created outside the realfs root"
1014 );
1015 }
1016
1017 #[cfg(unix)]
1018 #[tokio::test]
1019 async fn append_through_leaf_symlink_blocked() {
1020 use std::os::unix::fs::symlink;
1021
1022 let root = tempfile::tempdir().unwrap();
1023 let outside = tempfile::tempdir().unwrap();
1024 let outside_target = outside.path().join("appendme.txt");
1025 symlink(&outside_target, root.path().join("link")).unwrap();
1026
1027 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1028 let result = fs.append(Path::new("/link"), b"x").await;
1029 assert!(result.is_err(), "append through leaf symlink must fail");
1030 assert!(!outside_target.exists());
1031 }
1032
1033 #[cfg(unix)]
1034 #[tokio::test]
1035 async fn copy_to_leaf_symlink_blocked() {
1036 use std::os::unix::fs::symlink;
1037
1038 let root = tempfile::tempdir().unwrap();
1039 std::fs::write(root.path().join("src.txt"), b"src body").unwrap();
1040 let outside = tempfile::tempdir().unwrap();
1041 let outside_target = outside.path().join("escaped.txt");
1042 symlink(&outside_target, root.path().join("dst")).unwrap();
1043
1044 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1045 let result = fs.copy(Path::new("/src.txt"), Path::new("/dst")).await;
1046 assert!(result.is_err(), "copy through leaf symlink must fail");
1047 assert!(!outside_target.exists());
1048 }
1049
1050 #[cfg(unix)]
1051 #[tokio::test]
1052 async fn write_through_leaf_symlink_to_inside_root_also_blocked() {
1053 use std::os::unix::fs::symlink;
1057
1058 let root = tempfile::tempdir().unwrap();
1059 std::fs::write(root.path().join("victim.txt"), b"original").unwrap();
1060 symlink("victim.txt", root.path().join("link")).unwrap();
1061
1062 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1063 let result = fs.write(Path::new("/link"), b"new").await;
1064 assert!(
1065 result.is_err(),
1066 "write through any leaf symlink must fail, even inside root"
1067 );
1068 assert_eq!(
1069 std::fs::read(root.path().join("victim.txt")).unwrap(),
1070 b"original",
1071 "victim file must not be modified through the symlink"
1072 );
1073 }
1074
1075 #[tokio::test]
1076 async fn write_to_plain_path_still_works() {
1077 let dir = setup();
1079 let fs = RealFs::new(dir.path(), RealFsMode::ReadWrite).unwrap();
1080 fs.write(Path::new("/new.txt"), b"plain").await.unwrap();
1081 assert_eq!(std::fs::read(dir.path().join("new.txt")).unwrap(), b"plain");
1082 }
1083
1084 #[test]
1085 fn debug_display() {
1086 let dir = setup();
1087 let fs = RealFs::new(dir.path(), RealFsMode::ReadOnly).unwrap();
1088 let dbg = format!("{:?}", fs);
1089 assert!(dbg.contains("RealFs"));
1090 assert!(dbg.contains("ReadOnly"));
1091 }
1092
1093 #[cfg(unix)]
1096 #[tokio::test]
1097 async fn stat_describes_symlink_not_target() {
1098 use std::os::unix::fs::symlink;
1099
1100 let root = tempfile::tempdir().unwrap();
1101 std::fs::write(root.path().join("target.txt"), b"target body").unwrap();
1102 symlink("target.txt", root.path().join("link")).unwrap();
1103
1104 let fs = RealFs::new(root.path(), RealFsMode::ReadOnly).unwrap();
1105 let meta = fs.stat(Path::new("/link")).await.unwrap();
1106 assert!(
1107 meta.file_type.is_symlink(),
1108 "stat on a symlink must describe the link, not its target"
1109 );
1110 }
1111
1112 #[cfg(unix)]
1113 #[tokio::test]
1114 async fn read_link_returns_target_for_link_path() {
1115 use std::os::unix::fs::symlink;
1116
1117 let root = tempfile::tempdir().unwrap();
1118 std::fs::write(root.path().join("target.txt"), b"target body").unwrap();
1119 symlink("target.txt", root.path().join("link")).unwrap();
1120
1121 let fs = RealFs::new(root.path(), RealFsMode::ReadOnly).unwrap();
1122 let target = fs.read_link(Path::new("/link")).await.unwrap();
1123 assert_eq!(target, PathBuf::from("target.txt"));
1124 }
1125
1126 #[cfg(unix)]
1127 #[tokio::test]
1128 async fn remove_unlinks_symlink_without_following() {
1129 use std::os::unix::fs::symlink;
1130
1131 let root = tempfile::tempdir().unwrap();
1134 std::fs::create_dir(root.path().join("real_dir")).unwrap();
1135 std::fs::write(root.path().join("real_dir/inside.txt"), b"keep").unwrap();
1136 symlink("real_dir", root.path().join("dir_link")).unwrap();
1137
1138 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1139 fs.remove(Path::new("/dir_link"), true).await.unwrap();
1140
1141 assert!(
1143 !root.path().join("dir_link").is_symlink() && !root.path().join("dir_link").exists(),
1144 "the dangling symlink should be gone"
1145 );
1146 assert!(
1148 root.path().join("real_dir/inside.txt").exists(),
1149 "remove(symlink, recursive=true) must NOT wipe the target tree (#1578)"
1150 );
1151 }
1152
1153 #[cfg(unix)]
1154 #[tokio::test]
1155 async fn remove_symlink_with_recursive_flag_outside_target_intact() {
1156 use std::os::unix::fs::symlink;
1157
1158 let root = tempfile::tempdir().unwrap();
1161 let outside = tempfile::tempdir().unwrap();
1162 std::fs::write(outside.path().join("victim.txt"), b"important").unwrap();
1163 symlink(outside.path(), root.path().join("escape_link")).unwrap();
1164
1165 let fs = RealFs::new(root.path(), RealFsMode::ReadWrite).unwrap();
1166 fs.remove(Path::new("/escape_link"), true).await.unwrap();
1170 assert!(
1171 outside.path().join("victim.txt").exists(),
1172 "removing a symlink must never delete the target tree (#1578)"
1173 );
1174 }
1175}