1use anyhow::{Context, Result};
13use std::collections::BTreeMap;
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17use std::sync::Mutex;
18
19const HOME: &str = "/lesson-home";
21
22pub struct VirtualFileSystem {
24 store: Store,
26 current_dir: PathBuf,
28 #[allow(dead_code)]
30 session_id: String,
31}
32
33enum Store {
35 Disk { root: PathBuf },
37 Memory(Mutex<MemTree>),
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Meta {
44 pub is_dir: bool,
45 pub len: u64,
48 pub mode: Option<u32>,
50}
51
52pub(crate) struct Child {
54 name: String,
55 is_dir: bool,
56 is_symlink: bool,
57 len: u64,
58}
59
60impl VirtualFileSystem {
61 pub fn new(lesson_id: &str, session_id: &str) -> Result<Self> {
64 let vfs = Self::empty(lesson_id, session_id)?;
65
66 vfs.seed_generic()?;
68
69 Ok(vfs)
70 }
71
72 pub fn empty(lesson_id: &str, session_id: &str) -> Result<Self> {
74 let root = Self::create_temp_root(lesson_id, session_id)?;
75 fs::create_dir_all(root.join("lesson-home"))?;
76
77 Ok(Self {
78 store: Store::Disk { root },
79 current_dir: PathBuf::from(HOME),
80 session_id: session_id.to_string(),
81 })
82 }
83
84 pub fn in_memory() -> Self {
87 let mut tree = MemTree::default();
88 tree.nodes.insert(HOME.to_string(), Node::Dir { mode: DIR_MODE });
89 Self {
90 store: Store::Memory(Mutex::new(tree)),
91 current_dir: PathBuf::from(HOME),
92 session_id: String::new(),
93 }
94 }
95
96 pub fn for_lesson(lesson: &crate::lesson::Lesson, session_id: &str) -> Result<Self> {
101 let vfs = Self::empty(&lesson.id, session_id)?;
102 vfs.prepare_for(lesson)?;
103 Ok(vfs)
104 }
105
106 pub fn for_lesson_in_memory(lesson: &crate::lesson::Lesson) -> Result<Self> {
108 let vfs = Self::in_memory();
109 vfs.prepare_for(lesson)?;
110 Ok(vfs)
111 }
112
113 fn prepare_for(&self, lesson: &crate::lesson::Lesson) -> Result<()> {
114 if lesson.track == crate::lesson::Track::Operator {
115 self.seed_generic()?;
116 }
117 if let Err(e) = self.seed_setup(&lesson.setup) {
118 tracing::warn!("Failed to seed lesson setup files: {}", e);
119 }
120 Ok(())
121 }
122
123 pub fn is_in_memory(&self) -> bool {
125 matches!(self.store, Store::Memory(_))
126 }
127
128 fn create_temp_root(lesson_id: &str, session_id: &str) -> Result<PathBuf> {
130 let temp_dir = std::env::temp_dir();
131 let root = temp_dir.join(format!("arct-lesson-{}-{}", lesson_id, session_id));
132
133 fs::create_dir_all(&root)
134 .context("Failed to create virtual filesystem root")?;
135
136 Ok(root)
137 }
138
139 pub fn seed_generic(&self) -> Result<()> {
142 let dirs = [
144 "Documents",
145 "Documents/homework",
146 "Downloads",
147 "Pictures",
148 "Pictures/family",
149 "Music",
150 "Videos",
151 "projects",
152 "projects/website",
153 ];
154
155 for dir in &dirs {
156 self.prim_create_dir_all(&self.home_path(dir))?;
157 }
158
159 self.create_file("Documents/report.txt", "# Quarterly Report\n\nThis is a sample document.\n")?;
161 self.create_file("Documents/notes.md", "# Notes\n\n- Learn Linux commands\n- Practice navigation\n")?;
162 self.create_file("Downloads/software.zip", "[Binary data - sample file]\n")?;
163 self.create_file("Pictures/vacation.jpg", "[Image data - sample file]\n")?;
164 self.create_file("Pictures/family/photo.png", "[Image data - sample file]\n")?;
165 self.create_file("projects/website/index.html", "<!DOCTYPE html>\n<html>\n<head><title>My Site</title></head>\n<body><h1>Hello World</h1></body>\n</html>\n")?;
166 self.create_file("projects/website/style.css", "body { font-family: Arial; }\n")?;
167 self.create_file(".bashrc", "# Bash configuration\nexport PS1='$ '\n")?;
168
169 Ok(())
170 }
171
172 fn home_path(&self, path: &str) -> PathBuf {
174 Path::new(HOME).join(path)
175 }
176
177 fn create_file(&self, path: &str, content: &str) -> Result<()> {
179 self.prim_write(&self.home_path(path), content.as_bytes(), false)?;
180 Ok(())
181 }
182
183 fn virtual_target(&self, virtual_path: &str) -> PathBuf {
186 if virtual_path == "~" || virtual_path.is_empty() {
187 return PathBuf::from(HOME);
188 }
189
190 let path = Path::new(virtual_path);
191
192 if path.starts_with(HOME) {
194 return path.to_path_buf();
195 }
196
197 if path.is_absolute() {
199 return PathBuf::from(HOME).join(path.strip_prefix("/").unwrap_or(path));
200 }
201
202 self.current_dir.join(path)
204 }
205
206 fn normalize_virtual(path: &Path) -> Result<PathBuf> {
212 use std::path::Component;
213
214 let mut normalized = PathBuf::from("/");
215 for component in path.components() {
216 match component {
217 Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
218 Component::ParentDir => {
219 if !normalized.pop() {
220 anyhow::bail!("path escapes lesson sandbox");
221 }
222 }
223 Component::Normal(part) => normalized.push(part),
224 }
225 }
226
227 if !normalized.starts_with(HOME) {
228 anyhow::bail!("path escapes lesson sandbox");
229 }
230
231 Ok(normalized)
232 }
233
234 pub(crate) fn virtual_path(&self, path: &str) -> Result<PathBuf> {
237 Self::normalize_virtual(&self.virtual_target(path))
238 }
239
240 fn real_of(&self, virtual_abs: &Path) -> Result<PathBuf> {
243 match &self.store {
244 Store::Disk { root } => {
245 let relative = virtual_abs.strip_prefix("/").unwrap_or(virtual_abs);
246 let real = root.join(relative);
247
248 if !real.starts_with(root) {
250 anyhow::bail!("path escapes lesson sandbox");
251 }
252
253 Ok(real)
254 }
255 Store::Memory(_) => Ok(virtual_abs.to_path_buf()),
256 }
257 }
258
259 pub fn resolve_path(&self, virtual_path: &str) -> Result<PathBuf> {
268 let virtual_abs = self.virtual_path(virtual_path)?;
269 self.real_of(&virtual_abs)
270 }
271
272 pub fn get_current_dir(&self) -> &Path {
274 &self.current_dir
275 }
276
277 pub fn get_current_dir_real(&self) -> PathBuf {
280 self.real_of(&self.current_dir)
281 .unwrap_or_else(|_| self.current_dir.clone())
282 }
283
284 pub fn metadata(&self, path: &str) -> Option<Meta> {
287 self.prim_stat(&self.virtual_path(path).ok()?)
288 }
289
290 pub fn exists(&self, path: &str) -> bool {
292 self.metadata(path).is_some()
293 }
294
295 pub fn is_dir(&self, path: &str) -> bool {
297 self.metadata(path).is_some_and(|m| m.is_dir)
298 }
299
300 pub fn is_file(&self, path: &str) -> bool {
302 self.metadata(path).is_some_and(|m| !m.is_dir)
303 }
304
305 pub fn change_directory(&mut self, path: &str) -> Result<String> {
307 let target = if path == ".." && self.current_dir == Path::new(HOME) {
308 PathBuf::from(HOME)
310 } else {
311 Self::normalize_virtual(&self.virtual_target(path))?
313 };
314
315 match self.prim_stat(&target) {
317 None => anyhow::bail!("Directory not found: {}", path),
318 Some(meta) if !meta.is_dir => anyhow::bail!("Not a directory: {}", path),
319 Some(_) => {}
320 }
321
322 self.current_dir = target.clone();
323 Ok(target.to_string_lossy().to_string())
324 }
325
326 pub fn list_directory(&self, path: Option<&str>) -> Result<Vec<DirEntry>> {
328 let virtual_abs = match path {
329 Some(p) => self.virtual_path(p)?,
330 None => self.current_dir.clone(),
331 };
332
333 let mut entries: Vec<DirEntry> = self
334 .prim_list(&virtual_abs)
335 .context("Failed to read directory")?
336 .into_iter()
337 .map(|child| DirEntry {
338 name: child.name,
339 is_dir: child.is_dir,
340 size: child.len,
341 })
342 .collect();
343
344 entries.sort_by(|a, b| {
346 match (a.is_dir, b.is_dir) {
347 (true, false) => std::cmp::Ordering::Less,
348 (false, true) => std::cmp::Ordering::Greater,
349 _ => a.name.cmp(&b.name),
350 }
351 });
352
353 Ok(entries)
354 }
355
356 pub fn get_tree(&self, max_depth: usize) -> Vec<TreeNode> {
358 self.build_tree(Path::new(HOME), 0, max_depth)
359 }
360
361 fn build_tree(&self, path: &Path, depth: usize, max_depth: usize) -> Vec<TreeNode> {
363 if depth >= max_depth {
364 return vec![];
365 }
366
367 let mut nodes = Vec::new();
368
369 if let Ok(entries) = self.prim_list(path) {
370 for entry in entries {
371 let child = path.join(&entry.name);
372 let children = if entry.is_dir {
373 self.build_tree(&child, depth + 1, max_depth)
374 } else {
375 vec![]
376 };
377
378 nodes.push(TreeNode {
379 name: entry.name,
380 is_dir: entry.is_dir,
381 children,
382 is_current: self.current_dir == child,
383 });
384 }
385 }
386
387 nodes.sort_by(|a, b| {
389 match (a.is_dir, b.is_dir) {
390 (true, false) => std::cmp::Ordering::Less,
391 (false, true) => std::cmp::Ordering::Greater,
392 _ => a.name.cmp(&b.name),
393 }
394 });
395
396 nodes
397 }
398
399 pub fn read_file(&self, path: &str) -> Result<String> {
401 let virtual_abs = self.virtual_path(path)?;
402
403 match self.prim_stat(&virtual_abs) {
404 None => anyhow::bail!("No such file or directory: {}", path),
405 Some(meta) if meta.is_dir => anyhow::bail!("Is a directory: {}", path),
406 Some(_) => {}
407 }
408
409 self.prim_read(&virtual_abs)
410 .map_err(anyhow::Error::from)
411 .and_then(|bytes| String::from_utf8(bytes).map_err(anyhow::Error::from))
412 .context(format!("Failed to read file: {}", path))
413 }
414
415 pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>> {
417 Ok(self.prim_read(&self.virtual_path(path)?)?)
418 }
419
420 pub fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<()> {
422 Ok(self.prim_write(&self.virtual_path(path)?, bytes, false)?)
423 }
424
425 pub fn set_mode(&self, path: &str, mode: u32) -> Result<()> {
428 Ok(self.prim_set_mode(&self.virtual_path(path)?, mode)?)
429 }
430
431 pub fn create_directory(&self, path: &str, parents: bool) -> Result<()> {
433 let virtual_abs = self.virtual_path(path)?;
434 let existing = self.prim_stat(&virtual_abs);
435
436 if parents && existing.is_some_and(|m| m.is_dir) {
438 return Ok(());
439 }
440 if existing.is_some() {
441 anyhow::bail!("File or directory already exists: {}", path);
442 }
443
444 if parents {
445 self.prim_create_dir_all(&virtual_abs)
446 } else {
447 self.prim_create_dir(&virtual_abs)
448 }
449 .context(format!("Failed to create directory: {}", path))
450 }
451
452 pub fn touch_file(&self, path: &str) -> Result<()> {
454 let virtual_abs = self.virtual_path(path)?;
455
456 if self.prim_stat(&virtual_abs).is_none() {
459 self.prim_write(&virtual_abs, b"", false)?;
460 }
461
462 Ok(())
463 }
464
465 pub fn remove(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
467 let virtual_abs = self.virtual_path(path)?;
468
469 let Some(meta) = self.prim_stat(&virtual_abs) else {
470 if force {
471 return Ok(()); }
473 anyhow::bail!("No such file or directory: {}", path);
474 };
475
476 if meta.is_dir {
477 if !recursive {
478 anyhow::bail!("Is a directory (use -r to remove): {}", path);
479 }
480 self.prim_remove_dir_all(&virtual_abs)
481 } else {
482 self.prim_remove_file(&virtual_abs)
483 }
484 .context(format!("Failed to remove: {}", path))
485 }
486
487 pub fn move_item(&self, source: &str, destination: &str) -> Result<()> {
489 let source_path = self.virtual_path(source)?;
490 let dest_path = self.virtual_path(destination)?;
491
492 if self.prim_stat(&source_path).is_none() {
493 anyhow::bail!("No such file or directory: {}", source);
494 }
495
496 let final_dest = if self.prim_stat(&dest_path).is_some_and(|m| m.is_dir) {
498 dest_path.join(source_path.file_name().unwrap_or_default())
499 } else {
500 dest_path
501 };
502
503 self.prim_rename(&source_path, &final_dest)
504 .context(format!("Failed to move {} to {}", source, destination))
505 }
506
507 pub fn copy(&self, source: &str, destination: &str, recursive: bool) -> Result<()> {
509 let source_path = self.virtual_path(source)?;
510 let dest_path = self.virtual_path(destination)?;
511
512 let Some(meta) = self.prim_stat(&source_path) else {
513 anyhow::bail!("No such file or directory: {}", source);
514 };
515
516 if meta.is_dir {
517 if !recursive {
518 anyhow::bail!("Is a directory (use -r to copy): {}", source);
519 }
520 if dest_path.starts_with(&source_path) {
522 anyhow::bail!(
523 "cannot copy a directory, '{}', into itself, '{}'",
524 source,
525 destination
526 );
527 }
528
529 self.copy_dir_recursive(&source_path, &dest_path)?;
531 } else {
532 let final_dest = if self.prim_stat(&dest_path).is_some_and(|m| m.is_dir) {
534 dest_path.join(source_path.file_name().unwrap_or_default())
535 } else {
536 dest_path
537 };
538
539 self.prim_copy_file(&source_path, &final_dest)
540 .context(format!("Failed to copy {} to {}", source, destination))?;
541 }
542
543 Ok(())
544 }
545
546 fn copy_dir_recursive(&self, source: &Path, destination: &Path) -> Result<()> {
548 self.prim_create_dir_all(destination)?;
549
550 for entry in self.prim_list(source)? {
551 let from = source.join(&entry.name);
552 let to = destination.join(&entry.name);
553
554 if entry.is_dir {
555 self.copy_dir_recursive(&from, &to)?;
556 } else {
557 self.prim_copy_file(&from, &to)?;
558 }
559 }
560
561 Ok(())
562 }
563
564 pub fn grep_file(
570 &self,
571 pattern: &str,
572 path: &str,
573 case_insensitive: bool,
574 ) -> Result<Vec<(usize, String)>> {
575 let content = self.read_file(path)?;
576 let needle = if case_insensitive {
577 pattern.to_lowercase()
578 } else {
579 pattern.to_string()
580 };
581
582 let mut matches = Vec::new();
583 for (idx, line) in content.lines().enumerate() {
584 let haystack = if case_insensitive {
585 line.to_lowercase()
586 } else {
587 line.to_string()
588 };
589 if haystack.contains(&needle) {
590 matches.push((idx + 1, line.to_string()));
591 }
592 }
593 Ok(matches)
594 }
595
596 pub fn head_file(&self, path: &str, n: usize) -> Result<String> {
598 let content = self.read_file(path)?;
599 let mut out = String::new();
600 for line in content.lines().take(n) {
601 out.push_str(line);
602 out.push('\n');
603 }
604 Ok(out)
605 }
606
607 pub fn tail_file(&self, path: &str, n: usize) -> Result<String> {
609 let content = self.read_file(path)?;
610 let lines: Vec<&str> = content.lines().collect();
611 let start = lines.len().saturating_sub(n);
612 let mut out = String::new();
613 for line in &lines[start..] {
614 out.push_str(line);
615 out.push('\n');
616 }
617 Ok(out)
618 }
619
620 pub fn wc_file(&self, path: &str) -> Result<(usize, usize, usize)> {
624 let content = self.read_file(path)?;
625 let lines = content.lines().count();
626 let words = content.split_whitespace().count();
627 let bytes = content.len();
628 Ok((lines, words, bytes))
629 }
630
631 pub fn write_file(&self, path: &str, content: &str, append: bool) -> Result<()> {
633 let virtual_abs = self.virtual_path(path)?;
634 self.prim_write(&virtual_abs, content.as_bytes(), append)?;
635 Ok(())
636 }
637
638 pub fn seed_setup(&self, files: &[crate::lesson::SetupFile]) -> Result<()> {
643 for file in files {
644 let virtual_abs = self.virtual_path(&file.path)?;
645 if let Some(parent) = virtual_abs.parent() {
646 self.prim_create_dir_all(parent)?;
647 }
648 self.prim_write(&virtual_abs, file.contents.as_bytes(), false)
649 .with_context(|| format!("Failed to seed lesson file: {}", file.path))?;
650 }
651 Ok(())
652 }
653
654 pub fn cleanup(&self) -> Result<()> {
656 match &self.store {
657 Store::Disk { root } => {
658 if root.exists() {
659 fs::remove_dir_all(root)
660 .context("Failed to cleanup virtual filesystem")?;
661 }
662 }
663 Store::Memory(tree) => tree_lock(tree).nodes.clear(),
664 }
665 Ok(())
666 }
667}
668
669const FILE_MODE: u32 = 0o644;
676const DIR_MODE: u32 = 0o755;
677const DIR_SIZE: u64 = 4096;
679
680fn tree_lock(tree: &Mutex<MemTree>) -> std::sync::MutexGuard<'_, MemTree> {
681 tree.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
682}
683
684impl VirtualFileSystem {
685 pub(crate) fn prim_stat(&self, path: &Path) -> Option<Meta> {
686 match &self.store {
687 Store::Disk { .. } => {
688 let meta = fs::metadata(self.real_of(path).ok()?).ok()?;
689 #[cfg(unix)]
690 let mode = {
691 use std::os::unix::fs::PermissionsExt;
692 Some(meta.permissions().mode())
693 };
694 #[cfg(not(unix))]
695 let mode = None;
696 Some(Meta {
697 is_dir: meta.is_dir(),
698 len: meta.len(),
699 mode,
700 })
701 }
702 Store::Memory(tree) => tree_lock(tree).stat(&key(path)),
703 }
704 }
705
706 pub(crate) fn prim_lstat(&self, path: &Path) -> Option<Meta> {
709 match &self.store {
710 Store::Disk { .. } => {
711 let meta = fs::symlink_metadata(self.real_of(path).ok()?).ok()?;
712 #[cfg(unix)]
713 let mode = {
714 use std::os::unix::fs::PermissionsExt;
715 Some(meta.permissions().mode())
716 };
717 #[cfg(not(unix))]
718 let mode = None;
719 Some(Meta {
720 is_dir: meta.is_dir(),
721 len: meta.len(),
722 mode,
723 })
724 }
725 Store::Memory(tree) => tree_lock(tree).stat(&key(path)),
726 }
727 }
728
729 pub(crate) fn prim_read(&self, path: &Path) -> io::Result<Vec<u8>> {
730 match &self.store {
731 Store::Disk { .. } => fs::read(self.real_io(path)?),
732 Store::Memory(tree) => tree_lock(tree).read(&key(path)),
733 }
734 }
735
736 pub(crate) fn prim_write(&self, path: &Path, bytes: &[u8], append: bool) -> io::Result<()> {
737 match &self.store {
738 Store::Disk { .. } => {
739 let real = self.real_io(path)?;
740 if append {
741 use std::io::Write;
742 let mut file = fs::OpenOptions::new()
743 .create(true)
744 .append(true)
745 .open(&real)?;
746 file.write_all(bytes)
747 } else {
748 fs::write(&real, bytes)
749 }
750 }
751 Store::Memory(tree) => tree_lock(tree).write(&key(path), bytes, append),
752 }
753 }
754
755 pub(crate) fn prim_list(&self, path: &Path) -> io::Result<Vec<Child>> {
756 match &self.store {
757 Store::Disk { .. } => {
758 let mut out = Vec::new();
759 for entry in fs::read_dir(self.real_io(path)?)? {
760 let entry = entry?;
761 let meta = entry.metadata()?;
763 out.push(Child {
764 name: entry.file_name().to_string_lossy().to_string(),
765 is_dir: meta.is_dir(),
766 is_symlink: meta.file_type().is_symlink(),
767 len: meta.len(),
768 });
769 }
770 Ok(out)
771 }
772 Store::Memory(tree) => tree_lock(tree).list(&key(path)),
773 }
774 }
775
776 pub(crate) fn prim_create_dir(&self, path: &Path) -> io::Result<()> {
777 match &self.store {
778 Store::Disk { .. } => fs::create_dir(self.real_io(path)?),
779 Store::Memory(tree) => tree_lock(tree).create_dir(&key(path)),
780 }
781 }
782
783 pub(crate) fn prim_create_dir_all(&self, path: &Path) -> io::Result<()> {
784 match &self.store {
785 Store::Disk { .. } => fs::create_dir_all(self.real_io(path)?),
786 Store::Memory(tree) => tree_lock(tree).create_dir_all(&key(path)),
787 }
788 }
789
790 pub(crate) fn prim_remove_file(&self, path: &Path) -> io::Result<()> {
791 match &self.store {
792 Store::Disk { .. } => fs::remove_file(self.real_io(path)?),
793 Store::Memory(tree) => tree_lock(tree).remove_file(&key(path)),
794 }
795 }
796
797 pub(crate) fn prim_remove_dir_all(&self, path: &Path) -> io::Result<()> {
798 match &self.store {
799 Store::Disk { .. } => fs::remove_dir_all(self.real_io(path)?),
800 Store::Memory(tree) => tree_lock(tree).remove_dir_all(&key(path)),
801 }
802 }
803
804 pub(crate) fn prim_rename(&self, from: &Path, to: &Path) -> io::Result<()> {
805 match &self.store {
806 Store::Disk { .. } => fs::rename(self.real_io(from)?, self.real_io(to)?),
807 Store::Memory(tree) => tree_lock(tree).rename(&key(from), &key(to)),
808 }
809 }
810
811 pub(crate) fn prim_copy_file(&self, from: &Path, to: &Path) -> io::Result<()> {
813 match &self.store {
814 Store::Disk { .. } => fs::copy(self.real_io(from)?, self.real_io(to)?).map(|_| ()),
815 Store::Memory(tree) => tree_lock(tree).copy_file(&key(from), &key(to)),
816 }
817 }
818
819 pub(crate) fn prim_set_mode(&self, path: &Path, mode: u32) -> io::Result<()> {
820 match &self.store {
821 Store::Disk { .. } => {
822 #[cfg(unix)]
823 {
824 use std::os::unix::fs::PermissionsExt;
825 fs::set_permissions(self.real_io(path)?, fs::Permissions::from_mode(mode))
826 }
827 #[cfg(not(unix))]
828 {
829 let _ = (path, mode);
830 Ok(())
831 }
832 }
833 Store::Memory(tree) => tree_lock(tree).set_mode(&key(path), mode),
834 }
835 }
836
837 fn real_io(&self, path: &Path) -> io::Result<PathBuf> {
839 self.real_of(path)
840 .map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))
841 }
842
843 pub(crate) fn walk_children(&self, dir: &Path) -> Vec<(String, PathBuf, bool)> {
846 let mut out: Vec<(String, PathBuf, bool)> = self
847 .prim_list(dir)
848 .unwrap_or_default()
849 .into_iter()
850 .filter(|child| !child.is_symlink)
851 .map(|child| {
852 let path = dir.join(&child.name);
853 (child.name, path, child.is_dir)
854 })
855 .collect();
856 out.sort_by(|a, b| a.0.cmp(&b.0));
857 out
858 }
859
860 pub(crate) fn tar_append_file<W: io::Write>(
863 &self,
864 builder: &mut tar::Builder<W>,
865 path: &Path,
866 name: &Path,
867 ) -> Result<()> {
868 match &self.store {
869 Store::Disk { .. } => builder.append_path_with_name(self.real_of(path)?, name)?,
870 Store::Memory(_) => {
871 let data = self.prim_read(path)?;
872 let mode = self.prim_stat(path).and_then(|m| m.mode).unwrap_or(FILE_MODE);
873 let mut header = tar::Header::new_gnu();
874 header.set_entry_type(tar::EntryType::Regular);
875 header.set_size(data.len() as u64);
876 header.set_mode(mode);
877 header.set_mtime(0);
878 builder.append_data(&mut header, name, &data[..])?;
879 }
880 }
881 Ok(())
882 }
883
884 pub(crate) fn tar_append_dir<W: io::Write>(
886 &self,
887 builder: &mut tar::Builder<W>,
888 path: &Path,
889 name: &Path,
890 ) -> Result<()> {
891 match &self.store {
892 Store::Disk { .. } => builder.append_dir(name, self.real_of(path)?)?,
893 Store::Memory(_) => {
894 let mode = self.prim_stat(path).and_then(|m| m.mode).unwrap_or(DIR_MODE);
895 let mut header = tar::Header::new_gnu();
896 header.set_entry_type(tar::EntryType::Directory);
897 header.set_size(0);
898 header.set_mode(mode);
899 header.set_mtime(0);
900 builder.append_data(&mut header, name, io::empty())?;
901 }
902 }
903 Ok(())
904 }
905}
906
907#[derive(Debug, Clone)]
913enum Node {
914 Dir { mode: u32 },
915 File { data: Vec<u8>, mode: u32 },
916}
917
918#[derive(Debug, Default)]
921struct MemTree {
922 nodes: BTreeMap<String, Node>,
923}
924
925const MEM_MAX_FILE: usize = 4 * 1024 * 1024;
928const MEM_MAX_TOTAL: usize = 32 * 1024 * 1024;
929
930fn no_space() -> io::Error {
931 os_error("No space left on device")
932}
933
934fn key(path: &Path) -> String {
935 path.to_string_lossy().to_string()
936}
937
938fn not_found() -> io::Error {
939 io::Error::new(io::ErrorKind::NotFound, "No such file or directory")
940}
941
942fn os_error(message: &str) -> io::Error {
943 io::Error::other(message.to_string())
944}
945
946fn parent_key(key: &str) -> &str {
948 match key.rsplit_once('/') {
949 Some(("", _)) | None => "/",
950 Some((parent, _)) => parent,
951 }
952}
953
954impl MemTree {
955 fn stat(&self, key: &str) -> Option<Meta> {
956 Some(match self.nodes.get(key)? {
957 Node::Dir { mode } => Meta { is_dir: true, len: DIR_SIZE, mode: Some(*mode) },
958 Node::File { data, mode } => Meta {
959 is_dir: false,
960 len: data.len() as u64,
961 mode: Some(*mode),
962 },
963 })
964 }
965
966 fn check_parent(&self, key: &str) -> io::Result<()> {
968 let parent = parent_key(key);
969 if parent == "/" {
970 return Ok(());
971 }
972 match self.nodes.get(parent) {
973 Some(Node::Dir { .. }) => Ok(()),
974 Some(Node::File { .. }) => Err(os_error("Not a directory")),
975 None => Err(not_found()),
976 }
977 }
978
979 fn descendants(&self, key: &str) -> Vec<String> {
981 let prefix = format!("{key}/");
982 self.nodes
983 .range(prefix.clone()..)
984 .take_while(|(k, _)| k.starts_with(&prefix))
985 .map(|(k, _)| k.clone())
986 .collect()
987 }
988
989 fn read(&self, key: &str) -> io::Result<Vec<u8>> {
990 match self.nodes.get(key) {
991 Some(Node::File { data, .. }) => Ok(data.clone()),
992 Some(Node::Dir { .. }) => Err(os_error("Is a directory")),
993 None => Err(not_found()),
994 }
995 }
996
997 fn write(&mut self, key: &str, bytes: &[u8], append: bool) -> io::Result<()> {
998 let current = match self.nodes.get(key) {
999 Some(Node::File { data, .. }) => data.len(),
1000 _ => 0,
1001 };
1002 let new_len = if append { current.saturating_add(bytes.len()) } else { bytes.len() };
1003 let total: usize = self
1004 .nodes
1005 .values()
1006 .map(|n| match n {
1007 Node::File { data, .. } => data.len(),
1008 _ => 0,
1009 })
1010 .sum();
1011 if new_len > MEM_MAX_FILE || total - current + new_len > MEM_MAX_TOTAL {
1012 return Err(no_space());
1013 }
1014 match self.nodes.get_mut(key) {
1015 Some(Node::File { data, .. }) => {
1016 if !append {
1017 data.clear();
1018 }
1019 data.extend_from_slice(bytes);
1020 Ok(())
1021 }
1022 Some(Node::Dir { .. }) => Err(os_error("Is a directory")),
1023 None => {
1024 self.check_parent(key)?;
1025 self.nodes.insert(
1026 key.to_string(),
1027 Node::File { data: bytes.to_vec(), mode: FILE_MODE },
1028 );
1029 Ok(())
1030 }
1031 }
1032 }
1033
1034 fn list(&self, key: &str) -> io::Result<Vec<Child>> {
1035 match self.nodes.get(key) {
1036 Some(Node::Dir { .. }) => {}
1037 Some(Node::File { .. }) => return Err(os_error("Not a directory")),
1038 None => return Err(not_found()),
1039 }
1040 let prefix = format!("{key}/");
1041 Ok(self
1042 .nodes
1043 .range(prefix.clone()..)
1044 .take_while(|(k, _)| k.starts_with(&prefix))
1045 .filter(|(k, _)| !k[prefix.len()..].contains('/'))
1046 .map(|(k, node)| Child {
1047 name: k[prefix.len()..].to_string(),
1048 is_dir: matches!(node, Node::Dir { .. }),
1049 is_symlink: false,
1050 len: match node {
1051 Node::Dir { .. } => DIR_SIZE,
1052 Node::File { data, .. } => data.len() as u64,
1053 },
1054 })
1055 .collect())
1056 }
1057
1058 fn create_dir(&mut self, key: &str) -> io::Result<()> {
1059 if self.nodes.contains_key(key) {
1060 return Err(os_error("File exists"));
1061 }
1062 self.check_parent(key)?;
1063 self.nodes.insert(key.to_string(), Node::Dir { mode: DIR_MODE });
1064 Ok(())
1065 }
1066
1067 fn create_dir_all(&mut self, key: &str) -> io::Result<()> {
1068 if key == "/" || key.is_empty() {
1069 return Ok(());
1070 }
1071 match self.nodes.get(key) {
1072 Some(Node::Dir { .. }) => Ok(()),
1073 Some(Node::File { .. }) => Err(os_error("File exists")),
1074 None => {
1075 self.create_dir_all(parent_key(key))?;
1076 self.nodes.insert(key.to_string(), Node::Dir { mode: DIR_MODE });
1077 Ok(())
1078 }
1079 }
1080 }
1081
1082 fn remove_file(&mut self, key: &str) -> io::Result<()> {
1083 match self.nodes.get(key) {
1084 Some(Node::File { .. }) => {
1085 self.nodes.remove(key);
1086 Ok(())
1087 }
1088 Some(Node::Dir { .. }) => Err(os_error("Is a directory")),
1089 None => Err(not_found()),
1090 }
1091 }
1092
1093 fn remove_dir_all(&mut self, key: &str) -> io::Result<()> {
1094 match self.nodes.get(key) {
1095 Some(Node::Dir { .. }) => {
1096 for inner in self.descendants(key) {
1097 self.nodes.remove(&inner);
1098 }
1099 self.nodes.remove(key);
1100 Ok(())
1101 }
1102 Some(Node::File { .. }) => Err(os_error("Not a directory")),
1103 None => Err(not_found()),
1104 }
1105 }
1106
1107 fn rename(&mut self, from: &str, to: &str) -> io::Result<()> {
1109 let Some(moving) = self.nodes.get(from).cloned() else {
1110 return Err(not_found());
1111 };
1112 if from == to {
1113 return Ok(());
1114 }
1115 if to.starts_with(&format!("{from}/")) {
1116 return Err(os_error("Invalid argument"));
1117 }
1118 self.check_parent(to)?;
1119 match (&moving, self.nodes.get(to)) {
1120 (Node::File { .. }, Some(Node::Dir { .. })) => return Err(os_error("Is a directory")),
1121 (Node::Dir { .. }, Some(Node::File { .. })) => return Err(os_error("Not a directory")),
1122 (Node::Dir { .. }, Some(Node::Dir { .. })) if !self.descendants(to).is_empty() => {
1123 return Err(os_error("Directory not empty"))
1124 }
1125 _ => {}
1126 }
1127 let inner = self.descendants(from);
1128 self.nodes.remove(from);
1129 self.nodes.insert(to.to_string(), moving);
1130 for old in inner {
1131 if let Some(node) = self.nodes.remove(&old) {
1132 self.nodes.insert(format!("{to}{}", &old[from.len()..]), node);
1133 }
1134 }
1135 Ok(())
1136 }
1137
1138 fn copy_file(&mut self, from: &str, to: &str) -> io::Result<()> {
1139 let (data, mode) = match self.nodes.get(from) {
1140 Some(Node::File { data, mode }) => (data.clone(), *mode),
1141 Some(Node::Dir { .. }) => return Err(os_error("Is a directory")),
1142 None => return Err(not_found()),
1143 };
1144 match self.nodes.get(to) {
1145 Some(Node::Dir { .. }) => return Err(os_error("Is a directory")),
1146 Some(Node::File { .. }) => {}
1147 None => self.check_parent(to)?,
1148 }
1149 self.nodes.insert(to.to_string(), Node::File { data, mode });
1150 Ok(())
1151 }
1152
1153 fn set_mode(&mut self, key: &str, new_mode: u32) -> io::Result<()> {
1154 match self.nodes.get_mut(key) {
1155 Some(Node::Dir { mode }) | Some(Node::File { mode, .. }) => {
1156 *mode = new_mode & 0o7777;
1157 Ok(())
1158 }
1159 None => Err(not_found()),
1160 }
1161 }
1162}
1163
1164impl Drop for VirtualFileSystem {
1165 fn drop(&mut self) {
1166 let _ = self.cleanup();
1167 }
1168}
1169
1170#[derive(Debug, Clone)]
1172pub struct DirEntry {
1173 pub name: String,
1174 pub is_dir: bool,
1175 pub size: u64,
1176}
1177
1178#[derive(Debug, Clone)]
1180pub struct TreeNode {
1181 pub name: String,
1182 pub is_dir: bool,
1183 pub children: Vec<TreeNode>,
1184 pub is_current: bool,
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190
1191 #[test]
1192 fn the_in_memory_world_has_a_size_limit() {
1193 let vfs = VirtualFileSystem::in_memory();
1194 let mb = "x".repeat(1024 * 1024);
1195 let mut err = None;
1197 for _ in 0..(MEM_MAX_FILE / mb.len() + 2) {
1198 if let Err(e) = vfs.write_file("big.txt", &mb, true) {
1199 err = Some(e.to_string());
1200 break;
1201 }
1202 }
1203 assert!(err.unwrap().contains("No space left on device"));
1204 let mut written = 0;
1206 for i in 0..(MEM_MAX_TOTAL / mb.len() + 2) {
1207 if vfs.write_file(&format!("f{i}.txt"), &mb, false).is_err() {
1208 break;
1209 }
1210 written += 1;
1211 }
1212 assert!(written * mb.len() <= MEM_MAX_TOTAL);
1213 assert!(vfs.write_file("f0.txt", "small", false).is_ok());
1215 }
1216
1217 use super::*;
1218
1219 fn both(session: &str) -> [VirtualFileSystem; 2] {
1221 let memory = VirtualFileSystem::in_memory();
1222 memory.seed_generic().unwrap();
1223 [VirtualFileSystem::new("test", session).unwrap(), memory]
1224 }
1225
1226 #[test]
1227 fn test_create_vfs() {
1228 let vfs = VirtualFileSystem::new("test", "session-123").unwrap();
1229 assert!(vfs.resolve_path("~").unwrap().exists());
1230 assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
1231 let vfs = VirtualFileSystem::in_memory();
1232 assert!(vfs.is_dir("~"));
1233 assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
1234 }
1235
1236 #[test]
1237 fn test_change_directory() {
1238 for mut vfs in both("session-456") {
1239 let new_path = vfs.change_directory("Documents").unwrap();
1241 assert_eq!(new_path, "/lesson-home/Documents");
1242
1243 let new_path = vfs.change_directory("..").unwrap();
1245 assert_eq!(new_path, "/lesson-home");
1246 }
1247 }
1248
1249 #[test]
1250 fn test_list_directory() {
1251 for vfs in both("session-789") {
1252 let entries = vfs.list_directory(None).unwrap();
1253
1254 assert!(entries.len() >= 7);
1256
1257 assert!(entries[0].is_dir || entries[0].name == ".bashrc");
1259 }
1260 }
1261
1262 fn assert_escape_err<T: std::fmt::Debug>(result: Result<T>) {
1263 let err = result.expect_err("operation should have been rejected");
1264 assert!(
1265 err.to_string().contains("escapes lesson sandbox"),
1266 "unexpected error: {}",
1267 err
1268 );
1269 }
1270
1271 #[test]
1272 fn test_cat_traversal_escape_rejected() {
1273 for vfs in both("session-cat-escape") {
1274 assert_escape_err(vfs.read_file("../../../../etc/hostname"));
1275 assert_escape_err(vfs.read_file("/lesson-home/../../etc/hostname"));
1276 }
1277 }
1278
1279 #[test]
1280 fn test_rm_traversal_escape_rejected() {
1281 for vfs in both("session-rm-escape") {
1282 assert_escape_err(vfs.remove("../../..", true, true));
1284 assert_escape_err(vfs.remove("../outside.txt", false, false));
1285 }
1286 }
1287
1288 #[test]
1289 fn test_cd_traversal_escape_rejected() {
1290 for mut vfs in both("session-cd-escape") {
1291 assert_escape_err(vfs.change_directory("../.."));
1292 assert_escape_err(vfs.change_directory("Documents/../../.."));
1293
1294 assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
1296 }
1297 }
1298
1299 #[test]
1300 fn test_mv_traversal_escape_rejected() {
1301 for vfs in both("session-mv-escape") {
1302 assert_escape_err(vfs.move_item("Documents/report.txt", "../../stolen.txt"));
1303 assert_escape_err(vfs.move_item("../../../etc/hostname", "here.txt"));
1304 }
1305 }
1306
1307 #[test]
1308 fn test_cp_traversal_escape_rejected() {
1309 for vfs in both("session-cp-escape") {
1310 assert_escape_err(vfs.copy("Documents/report.txt", "../../stolen.txt", false));
1311 assert_escape_err(vfs.copy("../../../etc/hostname", "here.txt", false));
1312 }
1313 }
1314
1315 #[test]
1316 fn test_mkdir_touch_write_traversal_escape_rejected() {
1317 for vfs in both("session-mk-escape") {
1318 assert_escape_err(vfs.create_directory("../evil-dir", true));
1319 assert_escape_err(vfs.touch_file("../evil.txt"));
1320 assert_escape_err(vfs.write_file("../evil.txt", "payload", false));
1321 assert_escape_err(vfs.list_directory(Some("../..")));
1322 }
1323 }
1324
1325 #[test]
1326 fn test_cd_dotdot_at_root_clamps() {
1327 for mut vfs in both("session-cd-root") {
1328 let path = vfs.change_directory("..").unwrap();
1330 assert_eq!(path, "/lesson-home");
1331 assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
1332 }
1333 }
1334
1335 #[test]
1336 fn test_grep_file() {
1337 for vfs in both("session-grep") {
1338 vfs.write_file("log.txt", "ok line\nERROR one\nfine\nerror two\n", false)
1339 .unwrap();
1340
1341 let matches = vfs.grep_file("error", "log.txt", false).unwrap();
1343 assert_eq!(matches, vec![(4, "error two".to_string())]);
1344
1345 let matches = vfs.grep_file("error", "log.txt", true).unwrap();
1347 assert_eq!(
1348 matches,
1349 vec![(2, "ERROR one".to_string()), (4, "error two".to_string())]
1350 );
1351
1352 assert_escape_err(vfs.grep_file("root", "../../../../etc/passwd", false));
1354 }
1355 }
1356
1357 #[test]
1358 fn test_head_and_tail_file() {
1359 for vfs in both("session-headtail") {
1360 let body = (1..=20).map(|i| format!("line{}\n", i)).collect::<String>();
1361 vfs.write_file("nums.txt", &body, false).unwrap();
1362
1363 let head = vfs.head_file("nums.txt", 3).unwrap();
1364 assert_eq!(head, "line1\nline2\nline3\n");
1365
1366 let tail = vfs.tail_file("nums.txt", 2).unwrap();
1367 assert_eq!(tail, "line19\nline20\n");
1368
1369 let all = vfs.head_file("nums.txt", 100).unwrap();
1371 assert_eq!(all.lines().count(), 20);
1372 let all = vfs.tail_file("nums.txt", 100).unwrap();
1373 assert_eq!(all.lines().count(), 20);
1374 }
1375 }
1376
1377 #[test]
1378 fn test_wc_file() {
1379 for vfs in both("session-wc") {
1380 vfs.write_file("data.txt", "one two\nthree\n", false).unwrap();
1381
1382 let (lines, words, bytes) = vfs.wc_file("data.txt").unwrap();
1383 assert_eq!(lines, 2);
1384 assert_eq!(words, 3);
1385 assert_eq!(bytes, 14);
1386 }
1387 }
1388
1389 #[test]
1390 fn test_legitimate_relative_paths_still_work() {
1391 for mut vfs in both("session-legit") {
1392 let content = vfs.read_file("Documents/../Documents/report.txt").unwrap();
1394 assert!(content.contains("Quarterly Report"));
1395
1396 vfs.change_directory("Pictures/family").unwrap();
1398 let path = vfs.change_directory("..").unwrap();
1399 assert_eq!(path, "/lesson-home/Pictures");
1400
1401 vfs.create_directory("holiday", false).unwrap();
1403 vfs.touch_file("holiday/list.txt").unwrap();
1404 vfs.copy("family/../vacation.jpg", "holiday/copy.jpg", false).unwrap();
1405 vfs.move_item("holiday/copy.jpg", "holiday/moved.jpg").unwrap();
1406 assert!(vfs.is_file("holiday/moved.jpg"));
1407 assert!(!vfs.exists("holiday/copy.jpg"));
1408 }
1409
1410 let vfs = VirtualFileSystem::new("test", "session-legit-root").unwrap();
1412 let resolved = vfs.resolve_path("/lesson-home/Documents").unwrap();
1413 assert!(resolved.starts_with(vfs.resolve_path("~").unwrap().parent().unwrap()));
1414 assert!(resolved.exists());
1415 }
1416
1417 #[test]
1418 fn memory_tree_moves_copies_and_removes_like_the_disk() {
1419 for vfs in both("session-tree") {
1420 vfs.create_directory("a/b/c", true).unwrap();
1421 vfs.write_file("a/b/c/f.txt", "x\n", false).unwrap();
1422 vfs.write_file("a/top.txt", "t\n", false).unwrap();
1423
1424 vfs.move_item("a", "z").unwrap();
1426 assert_eq!(vfs.read_file("z/b/c/f.txt").unwrap(), "x\n");
1427 assert!(!vfs.exists("a"));
1428
1429 vfs.create_directory("box", false).unwrap();
1431 vfs.move_item("z/top.txt", "box").unwrap();
1432 assert!(vfs.is_file("box/top.txt"));
1433
1434 assert!(vfs.move_item("z", "z/b/inner").is_err());
1436 assert!(vfs.copy("z", "z/b/copy", true).is_err());
1437
1438 vfs.copy("z", "y", true).unwrap();
1440 assert_eq!(vfs.read_file("y/b/c/f.txt").unwrap(), "x\n");
1441 assert!(vfs.remove("y", false, false).is_err());
1442 vfs.remove("y", true, false).unwrap();
1443 assert!(!vfs.exists("y/b/c/f.txt"));
1444 assert!(vfs.exists("z/b/c/f.txt"));
1445
1446 assert!(vfs.write_file("nowhere/x.txt", "x", false).is_err());
1448 assert!(vfs.create_directory("p/q", false).is_err());
1449 assert!(vfs.create_directory("box", false).is_err());
1450 assert!(vfs.create_directory("box", true).is_ok());
1451
1452 vfs.write_file("box/top.txt", "u\n", true).unwrap();
1454 assert_eq!(vfs.read_file("box/top.txt").unwrap(), "t\nu\n");
1455 vfs.set_mode("box/top.txt", 0o700).unwrap();
1456 vfs.copy("box/top.txt", "copied.txt", false).unwrap();
1457 if let Some(mode) = vfs.metadata("copied.txt").unwrap().mode {
1458 assert_eq!(mode & 0o777, 0o700);
1459 }
1460
1461 vfs.write_bytes("bin.dat", &[0xff, 0xfe, 0x00]).unwrap();
1463 assert_eq!(vfs.read_bytes("bin.dat").unwrap(), vec![0xff, 0xfe, 0x00]);
1464 assert!(vfs.read_file("bin.dat").unwrap_err().to_string().contains("Failed to read file"));
1465 }
1466 }
1467
1468 #[test]
1469 fn explorer_lessons_see_only_their_own_files() {
1470 let library = crate::lesson::LessonLibrary::new();
1471 let explorer = library.get("explorer-look-around").unwrap();
1472 let operator = library.get("nav-basics").unwrap();
1473 let names = |vfs: &VirtualFileSystem| -> Vec<String> {
1474 vfs.list_directory(None).unwrap().into_iter().map(|e| e.name).collect()
1475 };
1476 let disk = VirtualFileSystem::for_lesson(explorer, "session-world").unwrap();
1477 let memory = VirtualFileSystem::for_lesson_in_memory(explorer).unwrap();
1478 let mut tops: Vec<String> = explorer
1479 .setup
1480 .iter()
1481 .map(|f| f.path.split('/').next().unwrap().to_string())
1482 .collect();
1483 tops.dedup();
1484 assert_eq!(names(&disk), tops);
1485 assert_eq!(names(&memory), tops);
1486
1487 let disk = VirtualFileSystem::for_lesson(operator, "session-world-op").unwrap();
1488 let memory = VirtualFileSystem::for_lesson_in_memory(operator).unwrap();
1489 for vfs in [&disk, &memory] {
1490 assert!(vfs.is_dir("Documents") && vfs.is_file(".bashrc"));
1491 assert!(operator.setup.iter().all(|f| vfs.is_file(&f.path)));
1492 }
1493 assert_eq!(names(&disk), names(&memory));
1494 }
1495
1496 #[test]
1497 fn tree_shows_folders_first_and_the_current_one() {
1498 for mut vfs in both("session-tree-view") {
1499 vfs.change_directory("Documents").unwrap();
1500 let tree = vfs.get_tree(2);
1501 assert!(tree.first().unwrap().is_dir);
1502 let docs = tree.iter().find(|n| n.name == "Documents").unwrap();
1503 assert!(docs.is_current);
1504 assert!(docs.children.iter().any(|n| n.name == "report.txt"));
1505 }
1506 }
1507}
1508
1509impl crate::lesson::PracticeWorld for VirtualFileSystem {
1510 fn locate(&self, lesson_path: &str) -> Option<PathBuf> {
1511 let path = lesson_path.trim_start_matches('/');
1512 self.resolve_path(&format!("/lesson-home/{path}")).ok()
1513 }
1514
1515 fn cwd_relative(&self) -> String {
1516 self.current_dir
1517 .strip_prefix("/lesson-home")
1518 .map(|rel| rel.to_string_lossy().to_string())
1519 .unwrap_or_default()
1520 }
1521
1522 fn kind_at(&self, lesson_path: &str) -> Option<crate::lesson::EntryKind> {
1523 let path = lesson_path.trim_start_matches('/');
1524 let virtual_abs = self.virtual_path(&format!("/lesson-home/{path}")).ok()?;
1525 Some(match self.prim_stat(&virtual_abs) {
1526 None => crate::lesson::EntryKind::Missing,
1527 Some(meta) if meta.is_dir => crate::lesson::EntryKind::Dir,
1528 Some(_) => crate::lesson::EntryKind::File,
1529 })
1530 }
1531
1532 fn read_text(&self, lesson_path: &str) -> Option<String> {
1533 let path = lesson_path.trim_start_matches('/');
1534 self.read_file(&format!("/lesson-home/{path}")).ok()
1535 }
1536}