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//!
6//! The practice files live in one of two places: a private temp directory
7//! on disk (the terminal app), or a tree held in memory (the browser, which
8//! has no filesystem). Every path goes through the same lexical containment
9//! check first, and every operation below is written once on top of a few
10//! primitives (stat, read, write, list, ...), so the two behave the same.
11
12use anyhow::{Context, Result};
13use std::collections::BTreeMap;
14use std::fs;
15use std::io;
16use std::path::{Path, PathBuf};
17use std::sync::Mutex;
18
19/// The virtual path of the lesson home, where every sandbox starts
20const HOME: &str = "/lesson-home";
21
22/// Virtual filesystem manager for lessons
23pub struct VirtualFileSystem {
24    /// Where the files are kept
25    store: Store,
26    /// Current working directory (relative to root)
27    current_dir: PathBuf,
28    /// Session ID for cleanup and debugging
29    #[allow(dead_code)]
30    session_id: String,
31}
32
33/// Where a sandbox keeps its files
34enum Store {
35    /// Under a private temp directory, removed when the sandbox is dropped
36    Disk { root: PathBuf },
37    /// In memory, keyed by virtual path (`/lesson-home/...`)
38    Memory(Mutex<MemTree>),
39}
40
41/// What is at a sandbox path
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Meta {
44    pub is_dir: bool,
45    /// Size in bytes. For folders, whatever the backend reports (the disk
46    /// reports its own; the memory backend says 4096, as ext4 does).
47    pub len: u64,
48    /// Permission bits (`0o755`), where the platform keeps them
49    pub mode: Option<u32>,
50}
51
52/// One entry of a folder, as the primitives list it
53pub(crate) struct Child {
54    name: String,
55    is_dir: bool,
56    is_symlink: bool,
57    len: u64,
58}
59
60impl VirtualFileSystem {
61    /// Create a new virtual filesystem for a lesson, on disk, holding the
62    /// generic practice home (see [`VirtualFileSystem::seed_generic`])
63    pub fn new(lesson_id: &str, session_id: &str) -> Result<Self> {
64        let vfs = Self::empty(lesson_id, session_id)?;
65
66        // Initialize the filesystem structure
67        vfs.seed_generic()?;
68
69        Ok(vfs)
70    }
71
72    /// Create an empty virtual filesystem on disk: just the lesson home
73    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    /// Create an empty virtual filesystem held in memory: just the lesson
85    /// home. Nothing is ever written to disk.
86    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    /// A sandbox on disk for `lesson`: its own setup files, plus the
97    /// generic practice home for Operator lessons. Explorer lessons see
98    /// only the files they set up. A setup file that can't be written is
99    /// logged and skipped.
100    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    /// [`VirtualFileSystem::for_lesson`], held in memory
107    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    /// Whether this sandbox is held in memory (rather than on disk)
124    pub fn is_in_memory(&self) -> bool {
125        matches!(self.store, Store::Memory(_))
126    }
127
128    /// Create temporary root directory
129    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    /// Seed the generic practice home: Documents, Downloads, Pictures,
140    /// Music, Videos, projects and a `.bashrc`, with a few sample files
141    pub fn seed_generic(&self) -> Result<()> {
142        // Create standard directories
143        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        // Create sample files
160        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    /// Virtual path of a path under the lesson home
173    fn home_path(&self, path: &str) -> PathBuf {
174        Path::new(HOME).join(path)
175    }
176
177    /// Create a file with content
178    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    /// Build the absolute *virtual* path (rooted at /lesson-home) for user input,
184    /// without resolving `.` or `..` components yet.
185    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 absolute path starting with /lesson-home
193        if path.starts_with(HOME) {
194            return path.to_path_buf();
195        }
196
197        // If absolute path starting with /
198        if path.is_absolute() {
199            return PathBuf::from(HOME).join(path.strip_prefix("/").unwrap_or(path));
200        }
201
202        // Relative path - join with current dir
203        self.current_dir.join(path)
204    }
205
206    /// Lexically resolve `.` and `..` components of an absolute virtual path.
207    ///
208    /// This is deliberately lexical (no `fs::canonicalize`) so it works for
209    /// paths that don't exist yet (touch, mkdir). Returns an error if the
210    /// path would escape the `/lesson-home` sandbox.
211    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    /// The normalized virtual path for user input, or an error if it would
235    /// leave the sandbox. Every operation starts here.
236    pub(crate) fn virtual_path(&self, path: &str) -> Result<PathBuf> {
237        Self::normalize_virtual(&self.virtual_target(path))
238    }
239
240    /// Where a normalized virtual path lives: a real path for the disk
241    /// backend, the virtual path itself for the memory backend
242    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                // Defense in depth: never hand out a path outside the sandbox root.
249                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    /// Get the real filesystem path for a virtual path.
260    ///
261    /// All `.`/`..` components are resolved lexically and the result is
262    /// guaranteed to be contained within the sandbox root; paths that would
263    /// escape it (e.g. `../../../../etc/hostname`) return an error. A
264    /// sandbox held in memory has no real paths: it returns the normalized
265    /// virtual path (`/lesson-home/...`), so use [`VirtualFileSystem::metadata`]
266    /// and friends rather than `std::fs` on the result.
267    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    /// Get current directory (virtual path)
273    pub fn get_current_dir(&self) -> &Path {
274        &self.current_dir
275    }
276
277    /// Get current directory (real filesystem path; the virtual path for a
278    /// sandbox held in memory)
279    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    /// What is at a path, or `None` when nothing is (or the path would
285    /// leave the sandbox)
286    pub fn metadata(&self, path: &str) -> Option<Meta> {
287        self.prim_stat(&self.virtual_path(path).ok()?)
288    }
289
290    /// Something exists at this path
291    pub fn exists(&self, path: &str) -> bool {
292        self.metadata(path).is_some()
293    }
294
295    /// A folder exists at this path
296    pub fn is_dir(&self, path: &str) -> bool {
297        self.metadata(path).is_some_and(|m| m.is_dir)
298    }
299
300    /// A file exists at this path
301    pub fn is_file(&self, path: &str) -> bool {
302        self.metadata(path).is_some_and(|m| !m.is_dir)
303    }
304
305    /// Change directory (returns new virtual path)
306    pub fn change_directory(&mut self, path: &str) -> Result<String> {
307        let target = if path == ".." && self.current_dir == Path::new(HOME) {
308            // 'cd ..' at the sandbox root clamps to the root instead of escaping
309            PathBuf::from(HOME)
310        } else {
311            // Resolve '.'/'..' lexically; errors if the path escapes the sandbox
312            Self::normalize_virtual(&self.virtual_target(path))?
313        };
314
315        // Verify the directory exists
316        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    /// List directory contents
327    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        // Sort: directories first, then alphabetically
345        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    /// Get directory tree structure
357    pub fn get_tree(&self, max_depth: usize) -> Vec<TreeNode> {
358        self.build_tree(Path::new(HOME), 0, max_depth)
359    }
360
361    /// Recursively build tree structure
362    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        // Sort: directories first
388        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    /// Read file contents (cat command)
400    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    /// Read a file's raw bytes
416    pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>> {
417        Ok(self.prim_read(&self.virtual_path(path)?)?)
418    }
419
420    /// Write raw bytes to a file, replacing what it held
421    pub fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<()> {
422        Ok(self.prim_write(&self.virtual_path(path)?, bytes, false)?)
423    }
424
425    /// Set a path's permission bits. On a platform without them (a disk
426    /// sandbox outside Unix) this does nothing.
427    pub fn set_mode(&self, path: &str, mode: u32) -> Result<()> {
428        Ok(self.prim_set_mode(&self.virtual_path(path)?, mode)?)
429    }
430
431    /// Create directory (mkdir command)
432    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        // mkdir -p is fine with a folder that's already there
437        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    /// Create or update file (touch command)
453    pub fn touch_file(&self, path: &str) -> Result<()> {
454        let virtual_abs = self.virtual_path(path)?;
455
456        // An existing file is left as it is (the sandbox keeps no times);
457        // a new one is created empty
458        if self.prim_stat(&virtual_abs).is_none() {
459            self.prim_write(&virtual_abs, b"", false)?;
460        }
461
462        Ok(())
463    }
464
465    /// Remove file or directory (rm command)
466    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(()); // -f flag ignores non-existent files
472            }
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    /// Move or rename file/directory (mv command)
488    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        // If destination is a directory, move into it with same name
497        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    /// Copy file or directory (cp command)
508    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            // A folder copied into itself would never finish
521            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            // Recursive directory copy
530            self.copy_dir_recursive(&source_path, &dest_path)?;
531        } else {
532            // File copy
533            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    /// Helper: Recursively copy directory
547    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    /// Search a file for lines containing `pattern` (grep command).
565    ///
566    /// Returns the matching lines as `(line_number, line)` pairs (line
567    /// numbers are 1-based). Matching is plain substring search; pass
568    /// `case_insensitive` for `-i` behavior.
569    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    /// Return the first `n` lines of a file (head command)
597    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    /// Return the last `n` lines of a file (tail command)
608    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    /// Count lines, words and bytes of a file (wc command).
621    ///
622    /// Returns `(lines, words, bytes)`.
623    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    /// Write content to file (for echo redirection, etc.)
632    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    /// Seed lesson starter files (the lesson's `setup` list) into the
639    /// sandbox, creating parent directories as needed. Paths are resolved
640    /// through the same containment checks as every other operation, so a
641    /// malicious setup path cannot escape the sandbox.
642    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    /// Clean up virtual filesystem
655    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
669// ----------------------------------------------------------------------------
670// Primitives: the only code that knows where the files are. Paths here are
671// normalized virtual paths (`/lesson-home/...`), already checked.
672// ----------------------------------------------------------------------------
673
674/// Mode of a new file and a new folder (as with umask 022)
675const FILE_MODE: u32 = 0o644;
676const DIR_MODE: u32 = 0o755;
677/// What the memory backend reports as a folder's size
678const 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    /// Like prim_stat, but a symbolic link on disk is described as itself,
707    /// never followed (find, du and tar start here)
708    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                    // Not followed: a link is listed as itself
762                    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    /// Copy a file's contents and permission bits, as `std::fs::copy` does
812    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    /// The disk path for a virtual path, as an io error when it can't be had
838    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    /// A folder's children for walking (find, du, tar): sorted by name,
844    /// links left out (never followed)
845    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    /// Write a tar entry for a file. On disk the header comes from the
861    /// file itself; in memory from what the tree keeps (no times or owners).
862    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    /// Write a tar entry for a folder (its contents are added separately)
885    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// ----------------------------------------------------------------------------
908// The in-memory tree
909// ----------------------------------------------------------------------------
910
911/// A file or folder held in memory
912#[derive(Debug, Clone)]
913enum Node {
914    Dir { mode: u32 },
915    File { data: Vec<u8>, mode: u32 },
916}
917
918/// Every path in a sandbox held in memory, by virtual path. Errors read
919/// like the operating system's (without its error numbers).
920#[derive(Debug, Default)]
921struct MemTree {
922    nodes: BTreeMap<String, Node>,
923}
924
925/// Largest file the in-memory world holds, and all files together: a
926/// browser tab has to stay usable whatever a learner types
927const 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
946/// The folder a key is in ("/" for a top-level key)
947fn 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    /// The parent folder must exist for something to be made in it
967    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    /// Keys of everything inside a folder, at any depth
980    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    /// `rename(2)`: replaces a file, or an empty folder with a folder
1108    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/// Directory entry
1171#[derive(Debug, Clone)]
1172pub struct DirEntry {
1173    pub name: String,
1174    pub is_dir: bool,
1175    pub size: u64,
1176}
1177
1178/// Tree node for visualization
1179#[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        // one file stops growing at the per-file limit
1196        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        // many files stop at the total limit
1205        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        // rewriting a file in place still works at the limit
1214        assert!(vfs.write_file("f0.txt", "small", false).is_ok());
1215    }
1216
1217    use super::*;
1218
1219    /// The same sandbox on disk and in memory, both with the generic home
1220    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            // Change to Documents
1240            let new_path = vfs.change_directory("Documents").unwrap();
1241            assert_eq!(new_path, "/lesson-home/Documents");
1242
1243            // Go back up
1244            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            // Should have Documents, Downloads, Pictures, Music, Videos, projects, .bashrc
1255            assert!(entries.len() >= 7);
1256
1257            // Directories should be first
1258            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            // Even with recursive + force, escaping paths must error, not silently pass
1283            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            // Current directory must be unchanged after rejected attempts
1295            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            // 'cd ..' at the sandbox root stays at the root (does not escape)
1329            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            // Case-sensitive: only the lowercase match
1342            let matches = vfs.grep_file("error", "log.txt", false).unwrap();
1343            assert_eq!(matches, vec![(4, "error two".to_string())]);
1344
1345            // Case-insensitive (-i): both matches, with 1-based line numbers
1346            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            // grep must not escape the sandbox
1353            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            // Requesting more lines than exist returns the whole file
1370            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            // Dotted paths that stay inside the sandbox are fine
1393            let content = vfs.read_file("Documents/../Documents/report.txt").unwrap();
1394            assert!(content.contains("Quarterly Report"));
1395
1396            // cd into a nested dir, then navigate with ..
1397            vfs.change_directory("Pictures/family").unwrap();
1398            let path = vfs.change_directory("..").unwrap();
1399            assert_eq!(path, "/lesson-home/Pictures");
1400
1401            // Relative operations from a subdirectory work and stay contained
1402            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        // Absolute virtual paths resolve inside the sandbox root
1411        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            // A folder moves with everything in it
1425            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            // Into an existing folder: under the same name
1430            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            // A folder can't go inside itself, and a copy can't either
1435            assert!(vfs.move_item("z", "z/b/inner").is_err());
1436            assert!(vfs.copy("z", "z/b/copy", true).is_err());
1437
1438            // Recursive copy, then remove
1439            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            // Writing needs the folder to exist; mkdir without -p needs the parent
1447            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            // Append, and cp copies the mode
1453            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            // Non-text files read as bytes, and cat says it can't
1462            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}