Skip to main content

arct_core/
virtual_fs.rs

1//! Virtual filesystem for lesson sandboxing
2//!
3//! Provides a safe, isolated filesystem for lessons so users can practice
4//! without affecting their real system.
5
6use anyhow::{Context, Result};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Virtual filesystem manager for lessons
11pub struct VirtualFileSystem {
12    /// Root directory of the virtual filesystem
13    root: PathBuf,
14    /// Current working directory (relative to root)
15    current_dir: PathBuf,
16    /// Session ID for cleanup and debugging
17    #[allow(dead_code)]
18    session_id: String,
19}
20
21impl VirtualFileSystem {
22    /// Create a new virtual filesystem for a lesson
23    pub fn new(lesson_id: &str, session_id: &str) -> Result<Self> {
24        let root = Self::create_temp_root(lesson_id, session_id)?;
25        let current_dir = PathBuf::from("/lesson-home");
26
27        let mut vfs = Self {
28            root,
29            current_dir,
30            session_id: session_id.to_string(),
31        };
32
33        // Initialize the filesystem structure
34        vfs.initialize_structure()?;
35
36        Ok(vfs)
37    }
38
39    /// Create temporary root directory
40    fn create_temp_root(lesson_id: &str, session_id: &str) -> Result<PathBuf> {
41        let temp_dir = std::env::temp_dir();
42        let root = temp_dir.join(format!("arct-lesson-{}-{}", lesson_id, session_id));
43
44        fs::create_dir_all(&root)
45            .context("Failed to create virtual filesystem root")?;
46
47        Ok(root)
48    }
49
50    /// Initialize the virtual filesystem structure
51    fn initialize_structure(&mut self) -> Result<()> {
52        let home = self.root.join("lesson-home");
53        fs::create_dir_all(&home)?;
54
55        // Create standard directories
56        let dirs = [
57            "Documents",
58            "Documents/homework",
59            "Downloads",
60            "Pictures",
61            "Pictures/family",
62            "Music",
63            "Videos",
64            "projects",
65            "projects/website",
66        ];
67
68        for dir in &dirs {
69            fs::create_dir_all(home.join(dir))?;
70        }
71
72        // Create sample files
73        self.create_file("Documents/report.txt", "# Quarterly Report\n\nThis is a sample document.\n")?;
74        self.create_file("Documents/notes.md", "# Notes\n\n- Learn Linux commands\n- Practice navigation\n")?;
75        self.create_file("Downloads/software.zip", "[Binary data - sample file]\n")?;
76        self.create_file("Pictures/vacation.jpg", "[Image data - sample file]\n")?;
77        self.create_file("Pictures/family/photo.png", "[Image data - sample file]\n")?;
78        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")?;
79        self.create_file("projects/website/style.css", "body { font-family: Arial; }\n")?;
80        self.create_file(".bashrc", "# Bash configuration\nexport PS1='$ '\n")?;
81
82        Ok(())
83    }
84
85    /// Create a file with content
86    fn create_file(&self, path: &str, content: &str) -> Result<()> {
87        let full_path = self.root.join("lesson-home").join(path);
88        fs::write(full_path, content)?;
89        Ok(())
90    }
91
92    /// Build the absolute *virtual* path (rooted at /lesson-home) for user input,
93    /// without resolving `.` or `..` components yet.
94    fn virtual_target(&self, virtual_path: &str) -> PathBuf {
95        if virtual_path == "~" || virtual_path.is_empty() {
96            return PathBuf::from("/lesson-home");
97        }
98
99        let path = Path::new(virtual_path);
100
101        // If absolute path starting with /lesson-home
102        if path.starts_with("/lesson-home") {
103            return path.to_path_buf();
104        }
105
106        // If absolute path starting with /
107        if path.is_absolute() {
108            return PathBuf::from("/lesson-home").join(path.strip_prefix("/").unwrap_or(path));
109        }
110
111        // Relative path - join with current dir
112        self.current_dir.join(path)
113    }
114
115    /// Lexically resolve `.` and `..` components of an absolute virtual path.
116    ///
117    /// This is deliberately lexical (no `fs::canonicalize`) so it works for
118    /// paths that don't exist yet (touch, mkdir). Returns an error if the
119    /// path would escape the `/lesson-home` sandbox.
120    fn normalize_virtual(path: &Path) -> Result<PathBuf> {
121        use std::path::Component;
122
123        let mut normalized = PathBuf::from("/");
124        for component in path.components() {
125            match component {
126                Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
127                Component::ParentDir => {
128                    if !normalized.pop() {
129                        anyhow::bail!("path escapes lesson sandbox");
130                    }
131                }
132                Component::Normal(part) => normalized.push(part),
133            }
134        }
135
136        if !normalized.starts_with("/lesson-home") {
137            anyhow::bail!("path escapes lesson sandbox");
138        }
139
140        Ok(normalized)
141    }
142
143    /// Get the real filesystem path for a virtual path.
144    ///
145    /// All `.`/`..` components are resolved lexically and the result is
146    /// guaranteed to be contained within the sandbox root; paths that would
147    /// escape it (e.g. `../../../../etc/hostname`) return an error.
148    pub fn resolve_path(&self, virtual_path: &str) -> Result<PathBuf> {
149        let virtual_abs = Self::normalize_virtual(&self.virtual_target(virtual_path))?;
150        let relative = virtual_abs.strip_prefix("/").unwrap_or(&virtual_abs);
151        let real = self.root.join(relative);
152
153        // Defense in depth: never hand out a path outside the sandbox root.
154        if !real.starts_with(&self.root) {
155            anyhow::bail!("path escapes lesson sandbox");
156        }
157
158        Ok(real)
159    }
160
161    /// Get current directory (virtual path)
162    pub fn get_current_dir(&self) -> &Path {
163        &self.current_dir
164    }
165
166    /// Get current directory (real filesystem path)
167    pub fn get_current_dir_real(&self) -> PathBuf {
168        if self.current_dir == PathBuf::from("/lesson-home") {
169            self.root.join("lesson-home")
170        } else {
171            let relative = self.current_dir.strip_prefix("/lesson-home").unwrap_or(&self.current_dir);
172            self.root.join("lesson-home").join(relative)
173        }
174    }
175
176    /// Change directory (returns new virtual path)
177    pub fn change_directory(&mut self, path: &str) -> Result<String> {
178        let target = if path == ".." && self.current_dir == Path::new("/lesson-home") {
179            // 'cd ..' at the sandbox root clamps to the root instead of escaping
180            PathBuf::from("/lesson-home")
181        } else {
182            // Resolve '.'/'..' lexically; errors if the path escapes the sandbox
183            Self::normalize_virtual(&self.virtual_target(path))?
184        };
185
186        // Verify the directory exists
187        let relative = target.strip_prefix("/").unwrap_or(&target);
188        let real_path = self.root.join(relative);
189        if !real_path.exists() {
190            anyhow::bail!("Directory not found: {}", path);
191        }
192        if !real_path.is_dir() {
193            anyhow::bail!("Not a directory: {}", path);
194        }
195
196        self.current_dir = target.clone();
197        Ok(target.to_string_lossy().to_string())
198    }
199
200    /// List directory contents
201    pub fn list_directory(&self, path: Option<&str>) -> Result<Vec<DirEntry>> {
202        let real_path = if let Some(p) = path {
203            self.resolve_path(p)?
204        } else {
205            self.get_current_dir_real()
206        };
207
208        let mut entries = Vec::new();
209
210        for entry in fs::read_dir(&real_path)
211            .context("Failed to read directory")?
212        {
213            let entry = entry?;
214            let metadata = entry.metadata()?;
215
216            entries.push(DirEntry {
217                name: entry.file_name().to_string_lossy().to_string(),
218                is_dir: metadata.is_dir(),
219                size: metadata.len(),
220            });
221        }
222
223        // Sort: directories first, then alphabetically
224        entries.sort_by(|a, b| {
225            match (a.is_dir, b.is_dir) {
226                (true, false) => std::cmp::Ordering::Less,
227                (false, true) => std::cmp::Ordering::Greater,
228                _ => a.name.cmp(&b.name),
229            }
230        });
231
232        Ok(entries)
233    }
234
235    /// Get directory tree structure
236    pub fn get_tree(&self, max_depth: usize) -> Vec<TreeNode> {
237        let root = self.root.join("lesson-home");
238        self.build_tree(&root, 0, max_depth)
239    }
240
241    /// Recursively build tree structure
242    fn build_tree(&self, path: &Path, depth: usize, max_depth: usize) -> Vec<TreeNode> {
243        if depth >= max_depth {
244            return vec![];
245        }
246
247        let mut nodes = Vec::new();
248
249        if let Ok(entries) = fs::read_dir(path) {
250            for entry in entries.flatten() {
251                let metadata = entry.metadata().ok();
252                let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
253                let name = entry.file_name().to_string_lossy().to_string();
254
255                let children = if is_dir {
256                    self.build_tree(&entry.path(), depth + 1, max_depth)
257                } else {
258                    vec![]
259                };
260
261                nodes.push(TreeNode {
262                    name,
263                    is_dir,
264                    children,
265                    is_current: self.get_current_dir_real() == entry.path(),
266                });
267            }
268        }
269
270        // Sort: directories first
271        nodes.sort_by(|a, b| {
272            match (a.is_dir, b.is_dir) {
273                (true, false) => std::cmp::Ordering::Less,
274                (false, true) => std::cmp::Ordering::Greater,
275                _ => a.name.cmp(&b.name),
276            }
277        });
278
279        nodes
280    }
281
282    /// Read file contents (cat command)
283    pub fn read_file(&self, path: &str) -> Result<String> {
284        let real_path = self.resolve_path(path)?;
285
286        if !real_path.exists() {
287            anyhow::bail!("No such file or directory: {}", path);
288        }
289
290        if real_path.is_dir() {
291            anyhow::bail!("Is a directory: {}", path);
292        }
293
294        fs::read_to_string(&real_path)
295            .context(format!("Failed to read file: {}", path))
296    }
297
298    /// Create directory (mkdir command)
299    pub fn create_directory(&self, path: &str, parents: bool) -> Result<()> {
300        let real_path = self.resolve_path(path)?;
301
302        if real_path.exists() {
303            anyhow::bail!("File or directory already exists: {}", path);
304        }
305
306        if parents {
307            fs::create_dir_all(&real_path)
308        } else {
309            fs::create_dir(&real_path)
310        }
311        .context(format!("Failed to create directory: {}", path))
312    }
313
314    /// Create or update file (touch command)
315    pub fn touch_file(&self, path: &str) -> Result<()> {
316        let real_path = self.resolve_path(path)?;
317
318        if real_path.exists() {
319            // Update modification time
320            let metadata = fs::metadata(&real_path)?;
321            let permissions = metadata.permissions();
322            fs::set_permissions(&real_path, permissions)?;
323        } else {
324            // Create empty file
325            fs::write(&real_path, "")?;
326        }
327
328        Ok(())
329    }
330
331    /// Remove file or directory (rm command)
332    pub fn remove(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
333        let real_path = self.resolve_path(path)?;
334
335        if !real_path.exists() {
336            if force {
337                return Ok(()); // -f flag ignores non-existent files
338            }
339            anyhow::bail!("No such file or directory: {}", path);
340        }
341
342        if real_path.is_dir() {
343            if !recursive {
344                anyhow::bail!("Is a directory (use -r to remove): {}", path);
345            }
346            fs::remove_dir_all(&real_path)
347        } else {
348            fs::remove_file(&real_path)
349        }
350        .context(format!("Failed to remove: {}", path))
351    }
352
353    /// Move or rename file/directory (mv command)
354    pub fn move_item(&self, source: &str, destination: &str) -> Result<()> {
355        let source_path = self.resolve_path(source)?;
356        let dest_path = self.resolve_path(destination)?;
357
358        if !source_path.exists() {
359            anyhow::bail!("No such file or directory: {}", source);
360        }
361
362        // If destination is a directory, move into it with same name
363        let final_dest = if dest_path.exists() && dest_path.is_dir() {
364            dest_path.join(source_path.file_name().unwrap_or_default())
365        } else {
366            dest_path
367        };
368
369        fs::rename(&source_path, &final_dest)
370            .context(format!("Failed to move {} to {}", source, destination))
371    }
372
373    /// Copy file or directory (cp command)
374    pub fn copy(&self, source: &str, destination: &str, recursive: bool) -> Result<()> {
375        let source_path = self.resolve_path(source)?;
376        let dest_path = self.resolve_path(destination)?;
377
378        if !source_path.exists() {
379            anyhow::bail!("No such file or directory: {}", source);
380        }
381
382        if source_path.is_dir() {
383            if !recursive {
384                anyhow::bail!("Is a directory (use -r to copy): {}", source);
385            }
386
387            // Recursive directory copy
388            self.copy_dir_recursive(&source_path, &dest_path)?;
389        } else {
390            // File copy
391            let final_dest = if dest_path.exists() && dest_path.is_dir() {
392                dest_path.join(source_path.file_name().unwrap_or_default())
393            } else {
394                dest_path
395            };
396
397            fs::copy(&source_path, &final_dest)
398                .context(format!("Failed to copy {} to {}", source, destination))?;
399        }
400
401        Ok(())
402    }
403
404    /// Helper: Recursively copy directory
405    fn copy_dir_recursive(&self, source: &Path, destination: &Path) -> Result<()> {
406        fs::create_dir_all(destination)?;
407
408        for entry in fs::read_dir(source)? {
409            let entry = entry?;
410            let file_type = entry.file_type()?;
411            let dest_path = destination.join(entry.file_name());
412
413            if file_type.is_dir() {
414                self.copy_dir_recursive(&entry.path(), &dest_path)?;
415            } else {
416                fs::copy(entry.path(), dest_path)?;
417            }
418        }
419
420        Ok(())
421    }
422
423    /// Search a file for lines containing `pattern` (grep command).
424    ///
425    /// Returns the matching lines as `(line_number, line)` pairs (line
426    /// numbers are 1-based). Matching is plain substring search; pass
427    /// `case_insensitive` for `-i` behavior.
428    pub fn grep_file(
429        &self,
430        pattern: &str,
431        path: &str,
432        case_insensitive: bool,
433    ) -> Result<Vec<(usize, String)>> {
434        let content = self.read_file(path)?;
435        let needle = if case_insensitive {
436            pattern.to_lowercase()
437        } else {
438            pattern.to_string()
439        };
440
441        let mut matches = Vec::new();
442        for (idx, line) in content.lines().enumerate() {
443            let haystack = if case_insensitive {
444                line.to_lowercase()
445            } else {
446                line.to_string()
447            };
448            if haystack.contains(&needle) {
449                matches.push((idx + 1, line.to_string()));
450            }
451        }
452        Ok(matches)
453    }
454
455    /// Return the first `n` lines of a file (head command)
456    pub fn head_file(&self, path: &str, n: usize) -> Result<String> {
457        let content = self.read_file(path)?;
458        let mut out = String::new();
459        for line in content.lines().take(n) {
460            out.push_str(line);
461            out.push('\n');
462        }
463        Ok(out)
464    }
465
466    /// Return the last `n` lines of a file (tail command)
467    pub fn tail_file(&self, path: &str, n: usize) -> Result<String> {
468        let content = self.read_file(path)?;
469        let lines: Vec<&str> = content.lines().collect();
470        let start = lines.len().saturating_sub(n);
471        let mut out = String::new();
472        for line in &lines[start..] {
473            out.push_str(line);
474            out.push('\n');
475        }
476        Ok(out)
477    }
478
479    /// Count lines, words and bytes of a file (wc command).
480    ///
481    /// Returns `(lines, words, bytes)`.
482    pub fn wc_file(&self, path: &str) -> Result<(usize, usize, usize)> {
483        let content = self.read_file(path)?;
484        let lines = content.lines().count();
485        let words = content.split_whitespace().count();
486        let bytes = content.len();
487        Ok((lines, words, bytes))
488    }
489
490    /// Write content to file (for echo redirection, etc.)
491    pub fn write_file(&self, path: &str, content: &str, append: bool) -> Result<()> {
492        let real_path = self.resolve_path(path)?;
493
494        if append {
495            use std::io::Write;
496            let mut file = fs::OpenOptions::new()
497                .create(true)
498                .append(true)
499                .open(&real_path)?;
500            file.write_all(content.as_bytes())?;
501        } else {
502            fs::write(&real_path, content)?;
503        }
504
505        Ok(())
506    }
507
508    /// Seed lesson starter files (the lesson's `setup` list) into the
509    /// sandbox, creating parent directories as needed. Paths are resolved
510    /// through the same containment checks as every other operation, so a
511    /// malicious setup path cannot escape the sandbox.
512    pub fn seed_setup(&self, files: &[crate::lesson::SetupFile]) -> Result<()> {
513        for file in files {
514            let real = self.resolve_path(&file.path)?;
515            if let Some(parent) = real.parent() {
516                fs::create_dir_all(parent)?;
517            }
518            fs::write(&real, &file.contents)
519                .with_context(|| format!("Failed to seed lesson file: {}", file.path))?;
520        }
521        Ok(())
522    }
523
524    /// Clean up virtual filesystem
525    pub fn cleanup(&self) -> Result<()> {
526        if self.root.exists() {
527            fs::remove_dir_all(&self.root)
528                .context("Failed to cleanup virtual filesystem")?;
529        }
530        Ok(())
531    }
532}
533
534impl Drop for VirtualFileSystem {
535    fn drop(&mut self) {
536        let _ = self.cleanup();
537    }
538}
539
540/// Directory entry
541#[derive(Debug, Clone)]
542pub struct DirEntry {
543    pub name: String,
544    pub is_dir: bool,
545    pub size: u64,
546}
547
548/// Tree node for visualization
549#[derive(Debug, Clone)]
550pub struct TreeNode {
551    pub name: String,
552    pub is_dir: bool,
553    pub children: Vec<TreeNode>,
554    pub is_current: bool,
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn test_create_vfs() {
563        let vfs = VirtualFileSystem::new("test", "session-123").unwrap();
564        assert!(vfs.root.exists());
565        assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
566    }
567
568    #[test]
569    fn test_change_directory() {
570        let mut vfs = VirtualFileSystem::new("test", "session-456").unwrap();
571
572        // Change to Documents
573        let new_path = vfs.change_directory("Documents").unwrap();
574        assert_eq!(new_path, "/lesson-home/Documents");
575
576        // Go back up
577        let new_path = vfs.change_directory("..").unwrap();
578        assert_eq!(new_path, "/lesson-home");
579    }
580
581    #[test]
582    fn test_list_directory() {
583        let vfs = VirtualFileSystem::new("test", "session-789").unwrap();
584        let entries = vfs.list_directory(None).unwrap();
585
586        // Should have Documents, Downloads, Pictures, Music, Videos, projects, .bashrc
587        assert!(entries.len() >= 7);
588
589        // Directories should be first
590        assert!(entries[0].is_dir || entries[0].name == ".bashrc");
591    }
592
593    fn assert_escape_err<T: std::fmt::Debug>(result: Result<T>) {
594        let err = result.expect_err("operation should have been rejected");
595        assert!(
596            err.to_string().contains("escapes lesson sandbox"),
597            "unexpected error: {}",
598            err
599        );
600    }
601
602    #[test]
603    fn test_cat_traversal_escape_rejected() {
604        let vfs = VirtualFileSystem::new("test", "session-cat-escape").unwrap();
605        assert_escape_err(vfs.read_file("../../../../etc/hostname"));
606        assert_escape_err(vfs.read_file("/lesson-home/../../etc/hostname"));
607    }
608
609    #[test]
610    fn test_rm_traversal_escape_rejected() {
611        let vfs = VirtualFileSystem::new("test", "session-rm-escape").unwrap();
612        // Even with recursive + force, escaping paths must error, not silently pass
613        assert_escape_err(vfs.remove("../../..", true, true));
614        assert_escape_err(vfs.remove("../outside.txt", false, false));
615    }
616
617    #[test]
618    fn test_cd_traversal_escape_rejected() {
619        let mut vfs = VirtualFileSystem::new("test", "session-cd-escape").unwrap();
620        assert_escape_err(vfs.change_directory("../.."));
621        assert_escape_err(vfs.change_directory("Documents/../../.."));
622
623        // Current directory must be unchanged after rejected attempts
624        assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
625    }
626
627    #[test]
628    fn test_mv_traversal_escape_rejected() {
629        let vfs = VirtualFileSystem::new("test", "session-mv-escape").unwrap();
630        assert_escape_err(vfs.move_item("Documents/report.txt", "../../stolen.txt"));
631        assert_escape_err(vfs.move_item("../../../etc/hostname", "here.txt"));
632    }
633
634    #[test]
635    fn test_cp_traversal_escape_rejected() {
636        let vfs = VirtualFileSystem::new("test", "session-cp-escape").unwrap();
637        assert_escape_err(vfs.copy("Documents/report.txt", "../../stolen.txt", false));
638        assert_escape_err(vfs.copy("../../../etc/hostname", "here.txt", false));
639    }
640
641    #[test]
642    fn test_mkdir_touch_write_traversal_escape_rejected() {
643        let vfs = VirtualFileSystem::new("test", "session-mk-escape").unwrap();
644        assert_escape_err(vfs.create_directory("../evil-dir", true));
645        assert_escape_err(vfs.touch_file("../evil.txt"));
646        assert_escape_err(vfs.write_file("../evil.txt", "payload", false));
647        assert_escape_err(vfs.list_directory(Some("../..")));
648    }
649
650    #[test]
651    fn test_cd_dotdot_at_root_clamps() {
652        let mut vfs = VirtualFileSystem::new("test", "session-cd-root").unwrap();
653
654        // 'cd ..' at the sandbox root stays at the root (does not escape)
655        let path = vfs.change_directory("..").unwrap();
656        assert_eq!(path, "/lesson-home");
657        assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
658    }
659
660    #[test]
661    fn test_grep_file() {
662        let vfs = VirtualFileSystem::new("test", "session-grep").unwrap();
663        vfs.write_file("log.txt", "ok line\nERROR one\nfine\nerror two\n", false)
664            .unwrap();
665
666        // Case-sensitive: only the lowercase match
667        let matches = vfs.grep_file("error", "log.txt", false).unwrap();
668        assert_eq!(matches, vec![(4, "error two".to_string())]);
669
670        // Case-insensitive (-i): both matches, with 1-based line numbers
671        let matches = vfs.grep_file("error", "log.txt", true).unwrap();
672        assert_eq!(
673            matches,
674            vec![(2, "ERROR one".to_string()), (4, "error two".to_string())]
675        );
676
677        // grep must not escape the sandbox
678        assert_escape_err(vfs.grep_file("root", "../../../../etc/passwd", false));
679    }
680
681    #[test]
682    fn test_head_and_tail_file() {
683        let vfs = VirtualFileSystem::new("test", "session-headtail").unwrap();
684        let body = (1..=20).map(|i| format!("line{}\n", i)).collect::<String>();
685        vfs.write_file("nums.txt", &body, false).unwrap();
686
687        let head = vfs.head_file("nums.txt", 3).unwrap();
688        assert_eq!(head, "line1\nline2\nline3\n");
689
690        let tail = vfs.tail_file("nums.txt", 2).unwrap();
691        assert_eq!(tail, "line19\nline20\n");
692
693        // Requesting more lines than exist returns the whole file
694        let all = vfs.head_file("nums.txt", 100).unwrap();
695        assert_eq!(all.lines().count(), 20);
696        let all = vfs.tail_file("nums.txt", 100).unwrap();
697        assert_eq!(all.lines().count(), 20);
698    }
699
700    #[test]
701    fn test_wc_file() {
702        let vfs = VirtualFileSystem::new("test", "session-wc").unwrap();
703        vfs.write_file("data.txt", "one two\nthree\n", false).unwrap();
704
705        let (lines, words, bytes) = vfs.wc_file("data.txt").unwrap();
706        assert_eq!(lines, 2);
707        assert_eq!(words, 3);
708        assert_eq!(bytes, 14);
709    }
710
711    #[test]
712    fn test_legitimate_relative_paths_still_work() {
713        let mut vfs = VirtualFileSystem::new("test", "session-legit").unwrap();
714
715        // Dotted paths that stay inside the sandbox are fine
716        let content = vfs.read_file("Documents/../Documents/report.txt").unwrap();
717        assert!(content.contains("Quarterly Report"));
718
719        // cd into a nested dir, then navigate with ..
720        vfs.change_directory("Pictures/family").unwrap();
721        let path = vfs.change_directory("..").unwrap();
722        assert_eq!(path, "/lesson-home/Pictures");
723
724        // Relative operations from a subdirectory work and stay contained
725        vfs.create_directory("holiday", false).unwrap();
726        vfs.touch_file("holiday/list.txt").unwrap();
727        vfs.copy("family/../vacation.jpg", "holiday/copy.jpg", false).unwrap();
728        vfs.move_item("holiday/copy.jpg", "holiday/moved.jpg").unwrap();
729        assert!(vfs.resolve_path("holiday/moved.jpg").unwrap().exists());
730
731        // Absolute virtual paths resolve inside the sandbox root
732        let resolved = vfs.resolve_path("/lesson-home/Documents").unwrap();
733        assert!(resolved.starts_with(&vfs.root));
734    }
735}