1use anyhow::{Context, Result};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub struct VirtualFileSystem {
12 root: PathBuf,
14 current_dir: PathBuf,
16 #[allow(dead_code)]
18 session_id: String,
19}
20
21impl VirtualFileSystem {
22 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 vfs.initialize_structure()?;
35
36 Ok(vfs)
37 }
38
39 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 fn initialize_structure(&mut self) -> Result<()> {
52 let home = self.root.join("lesson-home");
53 fs::create_dir_all(&home)?;
54
55 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 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 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 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 path.starts_with("/lesson-home") {
103 return path.to_path_buf();
104 }
105
106 if path.is_absolute() {
108 return PathBuf::from("/lesson-home").join(path.strip_prefix("/").unwrap_or(path));
109 }
110
111 self.current_dir.join(path)
113 }
114
115 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 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 if !real.starts_with(&self.root) {
155 anyhow::bail!("path escapes lesson sandbox");
156 }
157
158 Ok(real)
159 }
160
161 pub fn get_current_dir(&self) -> &Path {
163 &self.current_dir
164 }
165
166 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 pub fn change_directory(&mut self, path: &str) -> Result<String> {
178 let target = if path == ".." && self.current_dir == Path::new("/lesson-home") {
179 PathBuf::from("/lesson-home")
181 } else {
182 Self::normalize_virtual(&self.virtual_target(path))?
184 };
185
186 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 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 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 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 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 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 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 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 pub fn touch_file(&self, path: &str) -> Result<()> {
316 let real_path = self.resolve_path(path)?;
317
318 if real_path.exists() {
319 let metadata = fs::metadata(&real_path)?;
321 let permissions = metadata.permissions();
322 fs::set_permissions(&real_path, permissions)?;
323 } else {
324 fs::write(&real_path, "")?;
326 }
327
328 Ok(())
329 }
330
331 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(()); }
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 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 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 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 self.copy_dir_recursive(&source_path, &dest_path)?;
389 } else {
390 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
542pub struct DirEntry {
543 pub name: String,
544 pub is_dir: bool,
545 pub size: u64,
546}
547
548#[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 let new_path = vfs.change_directory("Documents").unwrap();
574 assert_eq!(new_path, "/lesson-home/Documents");
575
576 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 assert!(entries.len() >= 7);
588
589 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 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 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 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 let matches = vfs.grep_file("error", "log.txt", false).unwrap();
668 assert_eq!(matches, vec![(4, "error two".to_string())]);
669
670 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 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 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 let content = vfs.read_file("Documents/../Documents/report.txt").unwrap();
717 assert!(content.contains("Quarterly Report"));
718
719 vfs.change_directory("Pictures/family").unwrap();
721 let path = vfs.change_directory("..").unwrap();
722 assert_eq!(path, "/lesson-home/Pictures");
723
724 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 let resolved = vfs.resolve_path("/lesson-home/Documents").unwrap();
733 assert!(resolved.starts_with(&vfs.root));
734 }
735}