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    /// Get the real filesystem path for a virtual path
93    pub fn resolve_path(&self, virtual_path: &str) -> PathBuf {
94        if virtual_path == "~" || virtual_path == "" {
95            return self.root.join("lesson-home");
96        }
97
98        let path = PathBuf::from(virtual_path);
99
100        // If absolute path starting with /lesson-home
101        if let Ok(stripped) = path.strip_prefix("/lesson-home") {
102            return self.root.join("lesson-home").join(stripped);
103        }
104
105        // If absolute path starting with /
106        if path.is_absolute() {
107            return self.root.join("lesson-home").join(path.strip_prefix("/").unwrap_or(&path));
108        }
109
110        // Relative path - join with current dir
111        let current = self.get_current_dir_real();
112        current.join(virtual_path)
113    }
114
115    /// Get current directory (virtual path)
116    pub fn get_current_dir(&self) -> &Path {
117        &self.current_dir
118    }
119
120    /// Get current directory (real filesystem path)
121    pub fn get_current_dir_real(&self) -> PathBuf {
122        if self.current_dir == PathBuf::from("/lesson-home") {
123            self.root.join("lesson-home")
124        } else {
125            let relative = self.current_dir.strip_prefix("/lesson-home").unwrap_or(&self.current_dir);
126            self.root.join("lesson-home").join(relative)
127        }
128    }
129
130    /// Change directory (returns new virtual path)
131    pub fn change_directory(&mut self, path: &str) -> Result<String> {
132        let target = if path == "~" || path.is_empty() {
133            PathBuf::from("/lesson-home")
134        } else if path == ".." {
135            // Go up one level
136            if self.current_dir == PathBuf::from("/lesson-home") {
137                // Already at root
138                PathBuf::from("/lesson-home")
139            } else {
140                self.current_dir.parent()
141                    .map(|p| p.to_path_buf())
142                    .unwrap_or_else(|| PathBuf::from("/lesson-home"))
143            }
144        } else if path.starts_with('/') {
145            // Absolute path - ensure it's under /lesson-home
146            if path.starts_with("/lesson-home") {
147                PathBuf::from(path)
148            } else {
149                PathBuf::from("/lesson-home").join(path.trim_start_matches('/'))
150            }
151        } else {
152            // Relative path
153            self.current_dir.join(path)
154        };
155
156        // Verify the directory exists
157        let real_path = self.resolve_path(target.to_str().unwrap_or("/lesson-home"));
158        if !real_path.exists() {
159            anyhow::bail!("Directory not found: {}", path);
160        }
161        if !real_path.is_dir() {
162            anyhow::bail!("Not a directory: {}", path);
163        }
164
165        self.current_dir = target.clone();
166        Ok(target.to_string_lossy().to_string())
167    }
168
169    /// List directory contents
170    pub fn list_directory(&self, path: Option<&str>) -> Result<Vec<DirEntry>> {
171        let real_path = if let Some(p) = path {
172            self.resolve_path(p)
173        } else {
174            self.get_current_dir_real()
175        };
176
177        let mut entries = Vec::new();
178
179        for entry in fs::read_dir(&real_path)
180            .context("Failed to read directory")?
181        {
182            let entry = entry?;
183            let metadata = entry.metadata()?;
184
185            entries.push(DirEntry {
186                name: entry.file_name().to_string_lossy().to_string(),
187                is_dir: metadata.is_dir(),
188                size: metadata.len(),
189            });
190        }
191
192        // Sort: directories first, then alphabetically
193        entries.sort_by(|a, b| {
194            match (a.is_dir, b.is_dir) {
195                (true, false) => std::cmp::Ordering::Less,
196                (false, true) => std::cmp::Ordering::Greater,
197                _ => a.name.cmp(&b.name),
198            }
199        });
200
201        Ok(entries)
202    }
203
204    /// Get directory tree structure
205    pub fn get_tree(&self, max_depth: usize) -> Vec<TreeNode> {
206        let root = self.root.join("lesson-home");
207        self.build_tree(&root, 0, max_depth)
208    }
209
210    /// Recursively build tree structure
211    fn build_tree(&self, path: &Path, depth: usize, max_depth: usize) -> Vec<TreeNode> {
212        if depth >= max_depth {
213            return vec![];
214        }
215
216        let mut nodes = Vec::new();
217
218        if let Ok(entries) = fs::read_dir(path) {
219            for entry in entries.flatten() {
220                let metadata = entry.metadata().ok();
221                let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
222                let name = entry.file_name().to_string_lossy().to_string();
223
224                let children = if is_dir {
225                    self.build_tree(&entry.path(), depth + 1, max_depth)
226                } else {
227                    vec![]
228                };
229
230                nodes.push(TreeNode {
231                    name,
232                    is_dir,
233                    children,
234                    is_current: self.get_current_dir_real() == entry.path(),
235                });
236            }
237        }
238
239        // Sort: directories first
240        nodes.sort_by(|a, b| {
241            match (a.is_dir, b.is_dir) {
242                (true, false) => std::cmp::Ordering::Less,
243                (false, true) => std::cmp::Ordering::Greater,
244                _ => a.name.cmp(&b.name),
245            }
246        });
247
248        nodes
249    }
250
251    /// Read file contents (cat command)
252    pub fn read_file(&self, path: &str) -> Result<String> {
253        let real_path = self.resolve_path(path);
254
255        if !real_path.exists() {
256            anyhow::bail!("No such file or directory: {}", path);
257        }
258
259        if real_path.is_dir() {
260            anyhow::bail!("Is a directory: {}", path);
261        }
262
263        fs::read_to_string(&real_path)
264            .context(format!("Failed to read file: {}", path))
265    }
266
267    /// Create directory (mkdir command)
268    pub fn create_directory(&self, path: &str, parents: bool) -> Result<()> {
269        let real_path = self.resolve_path(path);
270
271        if real_path.exists() {
272            anyhow::bail!("File or directory already exists: {}", path);
273        }
274
275        if parents {
276            fs::create_dir_all(&real_path)
277        } else {
278            fs::create_dir(&real_path)
279        }
280        .context(format!("Failed to create directory: {}", path))
281    }
282
283    /// Create or update file (touch command)
284    pub fn touch_file(&self, path: &str) -> Result<()> {
285        let real_path = self.resolve_path(path);
286
287        if real_path.exists() {
288            // Update modification time
289            let metadata = fs::metadata(&real_path)?;
290            let permissions = metadata.permissions();
291            fs::set_permissions(&real_path, permissions)?;
292        } else {
293            // Create empty file
294            fs::write(&real_path, "")?;
295        }
296
297        Ok(())
298    }
299
300    /// Remove file or directory (rm command)
301    pub fn remove(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
302        let real_path = self.resolve_path(path);
303
304        if !real_path.exists() {
305            if force {
306                return Ok(()); // -f flag ignores non-existent files
307            }
308            anyhow::bail!("No such file or directory: {}", path);
309        }
310
311        if real_path.is_dir() {
312            if !recursive {
313                anyhow::bail!("Is a directory (use -r to remove): {}", path);
314            }
315            fs::remove_dir_all(&real_path)
316        } else {
317            fs::remove_file(&real_path)
318        }
319        .context(format!("Failed to remove: {}", path))
320    }
321
322    /// Move or rename file/directory (mv command)
323    pub fn move_item(&self, source: &str, destination: &str) -> Result<()> {
324        let source_path = self.resolve_path(source);
325        let dest_path = self.resolve_path(destination);
326
327        if !source_path.exists() {
328            anyhow::bail!("No such file or directory: {}", source);
329        }
330
331        // If destination is a directory, move into it with same name
332        let final_dest = if dest_path.exists() && dest_path.is_dir() {
333            dest_path.join(source_path.file_name().unwrap_or_default())
334        } else {
335            dest_path
336        };
337
338        fs::rename(&source_path, &final_dest)
339            .context(format!("Failed to move {} to {}", source, destination))
340    }
341
342    /// Copy file or directory (cp command)
343    pub fn copy(&self, source: &str, destination: &str, recursive: bool) -> Result<()> {
344        let source_path = self.resolve_path(source);
345        let dest_path = self.resolve_path(destination);
346
347        if !source_path.exists() {
348            anyhow::bail!("No such file or directory: {}", source);
349        }
350
351        if source_path.is_dir() {
352            if !recursive {
353                anyhow::bail!("Is a directory (use -r to copy): {}", source);
354            }
355
356            // Recursive directory copy
357            self.copy_dir_recursive(&source_path, &dest_path)?;
358        } else {
359            // File copy
360            let final_dest = if dest_path.exists() && dest_path.is_dir() {
361                dest_path.join(source_path.file_name().unwrap_or_default())
362            } else {
363                dest_path
364            };
365
366            fs::copy(&source_path, &final_dest)
367                .context(format!("Failed to copy {} to {}", source, destination))?;
368        }
369
370        Ok(())
371    }
372
373    /// Helper: Recursively copy directory
374    fn copy_dir_recursive(&self, source: &Path, destination: &Path) -> Result<()> {
375        fs::create_dir_all(destination)?;
376
377        for entry in fs::read_dir(source)? {
378            let entry = entry?;
379            let file_type = entry.file_type()?;
380            let dest_path = destination.join(entry.file_name());
381
382            if file_type.is_dir() {
383                self.copy_dir_recursive(&entry.path(), &dest_path)?;
384            } else {
385                fs::copy(entry.path(), dest_path)?;
386            }
387        }
388
389        Ok(())
390    }
391
392    /// Write content to file (for echo redirection, etc.)
393    pub fn write_file(&self, path: &str, content: &str, append: bool) -> Result<()> {
394        let real_path = self.resolve_path(path);
395
396        if append {
397            use std::io::Write;
398            let mut file = fs::OpenOptions::new()
399                .create(true)
400                .append(true)
401                .open(&real_path)?;
402            file.write_all(content.as_bytes())?;
403        } else {
404            fs::write(&real_path, content)?;
405        }
406
407        Ok(())
408    }
409
410    /// Clean up virtual filesystem
411    pub fn cleanup(&self) -> Result<()> {
412        if self.root.exists() {
413            fs::remove_dir_all(&self.root)
414                .context("Failed to cleanup virtual filesystem")?;
415        }
416        Ok(())
417    }
418}
419
420impl Drop for VirtualFileSystem {
421    fn drop(&mut self) {
422        let _ = self.cleanup();
423    }
424}
425
426/// Directory entry
427#[derive(Debug, Clone)]
428pub struct DirEntry {
429    pub name: String,
430    pub is_dir: bool,
431    pub size: u64,
432}
433
434/// Tree node for visualization
435#[derive(Debug, Clone)]
436pub struct TreeNode {
437    pub name: String,
438    pub is_dir: bool,
439    pub children: Vec<TreeNode>,
440    pub is_current: bool,
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_create_vfs() {
449        let vfs = VirtualFileSystem::new("test", "session-123").unwrap();
450        assert!(vfs.root.exists());
451        assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
452    }
453
454    #[test]
455    fn test_change_directory() {
456        let mut vfs = VirtualFileSystem::new("test", "session-456").unwrap();
457
458        // Change to Documents
459        let new_path = vfs.change_directory("Documents").unwrap();
460        assert_eq!(new_path, "/lesson-home/Documents");
461
462        // Go back up
463        let new_path = vfs.change_directory("..").unwrap();
464        assert_eq!(new_path, "/lesson-home");
465    }
466
467    #[test]
468    fn test_list_directory() {
469        let vfs = VirtualFileSystem::new("test", "session-789").unwrap();
470        let entries = vfs.list_directory(None).unwrap();
471
472        // Should have Documents, Downloads, Pictures, Music, Videos, projects, .bashrc
473        assert!(entries.len() >= 7);
474
475        // Directories should be first
476        assert!(entries[0].is_dir || entries[0].name == ".bashrc");
477    }
478}