use anyhow::{Context, Result};
use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const HOME: &str = "/lesson-home";
pub struct VirtualFileSystem {
store: Store,
current_dir: PathBuf,
#[allow(dead_code)]
session_id: String,
}
enum Store {
Disk { root: PathBuf },
Memory(Mutex<MemTree>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Meta {
pub is_dir: bool,
pub len: u64,
pub mode: Option<u32>,
}
pub(crate) struct Child {
name: String,
is_dir: bool,
is_symlink: bool,
len: u64,
}
impl VirtualFileSystem {
pub fn new(lesson_id: &str, session_id: &str) -> Result<Self> {
let vfs = Self::empty(lesson_id, session_id)?;
vfs.seed_generic()?;
Ok(vfs)
}
pub fn empty(lesson_id: &str, session_id: &str) -> Result<Self> {
let root = Self::create_temp_root(lesson_id, session_id)?;
fs::create_dir_all(root.join("lesson-home"))?;
Ok(Self {
store: Store::Disk { root },
current_dir: PathBuf::from(HOME),
session_id: session_id.to_string(),
})
}
pub fn in_memory() -> Self {
let mut tree = MemTree::default();
tree.nodes.insert(HOME.to_string(), Node::Dir { mode: DIR_MODE });
Self {
store: Store::Memory(Mutex::new(tree)),
current_dir: PathBuf::from(HOME),
session_id: String::new(),
}
}
pub fn for_lesson(lesson: &crate::lesson::Lesson, session_id: &str) -> Result<Self> {
let vfs = Self::empty(&lesson.id, session_id)?;
vfs.prepare_for(lesson)?;
Ok(vfs)
}
pub fn for_lesson_in_memory(lesson: &crate::lesson::Lesson) -> Result<Self> {
let vfs = Self::in_memory();
vfs.prepare_for(lesson)?;
Ok(vfs)
}
fn prepare_for(&self, lesson: &crate::lesson::Lesson) -> Result<()> {
if lesson.track == crate::lesson::Track::Operator {
self.seed_generic()?;
}
if let Err(e) = self.seed_setup(&lesson.setup) {
tracing::warn!("Failed to seed lesson setup files: {}", e);
}
Ok(())
}
pub fn is_in_memory(&self) -> bool {
matches!(self.store, Store::Memory(_))
}
fn create_temp_root(lesson_id: &str, session_id: &str) -> Result<PathBuf> {
let temp_dir = std::env::temp_dir();
let root = temp_dir.join(format!("arct-lesson-{}-{}", lesson_id, session_id));
fs::create_dir_all(&root)
.context("Failed to create virtual filesystem root")?;
Ok(root)
}
pub fn seed_generic(&self) -> Result<()> {
let dirs = [
"Documents",
"Documents/homework",
"Downloads",
"Pictures",
"Pictures/family",
"Music",
"Videos",
"projects",
"projects/website",
];
for dir in &dirs {
self.prim_create_dir_all(&self.home_path(dir))?;
}
self.create_file("Documents/report.txt", "# Quarterly Report\n\nThis is a sample document.\n")?;
self.create_file("Documents/notes.md", "# Notes\n\n- Learn Linux commands\n- Practice navigation\n")?;
self.create_file("Downloads/software.zip", "[Binary data - sample file]\n")?;
self.create_file("Pictures/vacation.jpg", "[Image data - sample file]\n")?;
self.create_file("Pictures/family/photo.png", "[Image data - sample file]\n")?;
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")?;
self.create_file("projects/website/style.css", "body { font-family: Arial; }\n")?;
self.create_file(".bashrc", "# Bash configuration\nexport PS1='$ '\n")?;
Ok(())
}
fn home_path(&self, path: &str) -> PathBuf {
Path::new(HOME).join(path)
}
fn create_file(&self, path: &str, content: &str) -> Result<()> {
self.prim_write(&self.home_path(path), content.as_bytes(), false)?;
Ok(())
}
fn virtual_target(&self, virtual_path: &str) -> PathBuf {
if virtual_path == "~" || virtual_path.is_empty() {
return PathBuf::from(HOME);
}
let path = Path::new(virtual_path);
if path.starts_with(HOME) {
return path.to_path_buf();
}
if path.is_absolute() {
return PathBuf::from(HOME).join(path.strip_prefix("/").unwrap_or(path));
}
self.current_dir.join(path)
}
fn normalize_virtual(path: &Path) -> Result<PathBuf> {
use std::path::Component;
let mut normalized = PathBuf::from("/");
for component in path.components() {
match component {
Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
anyhow::bail!("path escapes lesson sandbox");
}
}
Component::Normal(part) => normalized.push(part),
}
}
if !normalized.starts_with(HOME) {
anyhow::bail!("path escapes lesson sandbox");
}
Ok(normalized)
}
pub(crate) fn virtual_path(&self, path: &str) -> Result<PathBuf> {
Self::normalize_virtual(&self.virtual_target(path))
}
fn real_of(&self, virtual_abs: &Path) -> Result<PathBuf> {
match &self.store {
Store::Disk { root } => {
let relative = virtual_abs.strip_prefix("/").unwrap_or(virtual_abs);
let real = root.join(relative);
if !real.starts_with(root) {
anyhow::bail!("path escapes lesson sandbox");
}
Ok(real)
}
Store::Memory(_) => Ok(virtual_abs.to_path_buf()),
}
}
pub fn resolve_path(&self, virtual_path: &str) -> Result<PathBuf> {
let virtual_abs = self.virtual_path(virtual_path)?;
self.real_of(&virtual_abs)
}
pub fn get_current_dir(&self) -> &Path {
&self.current_dir
}
pub fn get_current_dir_real(&self) -> PathBuf {
self.real_of(&self.current_dir)
.unwrap_or_else(|_| self.current_dir.clone())
}
pub fn metadata(&self, path: &str) -> Option<Meta> {
self.prim_stat(&self.virtual_path(path).ok()?)
}
pub fn exists(&self, path: &str) -> bool {
self.metadata(path).is_some()
}
pub fn is_dir(&self, path: &str) -> bool {
self.metadata(path).is_some_and(|m| m.is_dir)
}
pub fn is_file(&self, path: &str) -> bool {
self.metadata(path).is_some_and(|m| !m.is_dir)
}
pub fn change_directory(&mut self, path: &str) -> Result<String> {
let target = if path == ".." && self.current_dir == Path::new(HOME) {
PathBuf::from(HOME)
} else {
Self::normalize_virtual(&self.virtual_target(path))?
};
match self.prim_stat(&target) {
None => anyhow::bail!("Directory not found: {}", path),
Some(meta) if !meta.is_dir => anyhow::bail!("Not a directory: {}", path),
Some(_) => {}
}
self.current_dir = target.clone();
Ok(target.to_string_lossy().to_string())
}
pub fn list_directory(&self, path: Option<&str>) -> Result<Vec<DirEntry>> {
let virtual_abs = match path {
Some(p) => self.virtual_path(p)?,
None => self.current_dir.clone(),
};
let mut entries: Vec<DirEntry> = self
.prim_list(&virtual_abs)
.context("Failed to read directory")?
.into_iter()
.map(|child| DirEntry {
name: child.name,
is_dir: child.is_dir,
size: child.len,
})
.collect();
entries.sort_by(|a, b| {
match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
}
});
Ok(entries)
}
pub fn get_tree(&self, max_depth: usize) -> Vec<TreeNode> {
self.build_tree(Path::new(HOME), 0, max_depth)
}
fn build_tree(&self, path: &Path, depth: usize, max_depth: usize) -> Vec<TreeNode> {
if depth >= max_depth {
return vec![];
}
let mut nodes = Vec::new();
if let Ok(entries) = self.prim_list(path) {
for entry in entries {
let child = path.join(&entry.name);
let children = if entry.is_dir {
self.build_tree(&child, depth + 1, max_depth)
} else {
vec![]
};
nodes.push(TreeNode {
name: entry.name,
is_dir: entry.is_dir,
children,
is_current: self.current_dir == child,
});
}
}
nodes.sort_by(|a, b| {
match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
}
});
nodes
}
pub fn read_file(&self, path: &str) -> Result<String> {
let virtual_abs = self.virtual_path(path)?;
match self.prim_stat(&virtual_abs) {
None => anyhow::bail!("No such file or directory: {}", path),
Some(meta) if meta.is_dir => anyhow::bail!("Is a directory: {}", path),
Some(_) => {}
}
self.prim_read(&virtual_abs)
.map_err(anyhow::Error::from)
.and_then(|bytes| String::from_utf8(bytes).map_err(anyhow::Error::from))
.context(format!("Failed to read file: {}", path))
}
pub fn read_bytes(&self, path: &str) -> Result<Vec<u8>> {
Ok(self.prim_read(&self.virtual_path(path)?)?)
}
pub fn write_bytes(&self, path: &str, bytes: &[u8]) -> Result<()> {
Ok(self.prim_write(&self.virtual_path(path)?, bytes, false)?)
}
pub fn set_mode(&self, path: &str, mode: u32) -> Result<()> {
Ok(self.prim_set_mode(&self.virtual_path(path)?, mode)?)
}
pub fn create_directory(&self, path: &str, parents: bool) -> Result<()> {
let virtual_abs = self.virtual_path(path)?;
let existing = self.prim_stat(&virtual_abs);
if parents && existing.is_some_and(|m| m.is_dir) {
return Ok(());
}
if existing.is_some() {
anyhow::bail!("File or directory already exists: {}", path);
}
if parents {
self.prim_create_dir_all(&virtual_abs)
} else {
self.prim_create_dir(&virtual_abs)
}
.context(format!("Failed to create directory: {}", path))
}
pub fn touch_file(&self, path: &str) -> Result<()> {
let virtual_abs = self.virtual_path(path)?;
if self.prim_stat(&virtual_abs).is_none() {
self.prim_write(&virtual_abs, b"", false)?;
}
Ok(())
}
pub fn remove(&self, path: &str, recursive: bool, force: bool) -> Result<()> {
let virtual_abs = self.virtual_path(path)?;
let Some(meta) = self.prim_stat(&virtual_abs) else {
if force {
return Ok(()); }
anyhow::bail!("No such file or directory: {}", path);
};
if meta.is_dir {
if !recursive {
anyhow::bail!("Is a directory (use -r to remove): {}", path);
}
self.prim_remove_dir_all(&virtual_abs)
} else {
self.prim_remove_file(&virtual_abs)
}
.context(format!("Failed to remove: {}", path))
}
pub fn move_item(&self, source: &str, destination: &str) -> Result<()> {
let source_path = self.virtual_path(source)?;
let dest_path = self.virtual_path(destination)?;
if self.prim_stat(&source_path).is_none() {
anyhow::bail!("No such file or directory: {}", source);
}
let final_dest = if self.prim_stat(&dest_path).is_some_and(|m| m.is_dir) {
dest_path.join(source_path.file_name().unwrap_or_default())
} else {
dest_path
};
self.prim_rename(&source_path, &final_dest)
.context(format!("Failed to move {} to {}", source, destination))
}
pub fn copy(&self, source: &str, destination: &str, recursive: bool) -> Result<()> {
let source_path = self.virtual_path(source)?;
let dest_path = self.virtual_path(destination)?;
let Some(meta) = self.prim_stat(&source_path) else {
anyhow::bail!("No such file or directory: {}", source);
};
if meta.is_dir {
if !recursive {
anyhow::bail!("Is a directory (use -r to copy): {}", source);
}
if dest_path.starts_with(&source_path) {
anyhow::bail!(
"cannot copy a directory, '{}', into itself, '{}'",
source,
destination
);
}
self.copy_dir_recursive(&source_path, &dest_path)?;
} else {
let final_dest = if self.prim_stat(&dest_path).is_some_and(|m| m.is_dir) {
dest_path.join(source_path.file_name().unwrap_or_default())
} else {
dest_path
};
self.prim_copy_file(&source_path, &final_dest)
.context(format!("Failed to copy {} to {}", source, destination))?;
}
Ok(())
}
fn copy_dir_recursive(&self, source: &Path, destination: &Path) -> Result<()> {
self.prim_create_dir_all(destination)?;
for entry in self.prim_list(source)? {
let from = source.join(&entry.name);
let to = destination.join(&entry.name);
if entry.is_dir {
self.copy_dir_recursive(&from, &to)?;
} else {
self.prim_copy_file(&from, &to)?;
}
}
Ok(())
}
pub fn grep_file(
&self,
pattern: &str,
path: &str,
case_insensitive: bool,
) -> Result<Vec<(usize, String)>> {
let content = self.read_file(path)?;
let needle = if case_insensitive {
pattern.to_lowercase()
} else {
pattern.to_string()
};
let mut matches = Vec::new();
for (idx, line) in content.lines().enumerate() {
let haystack = if case_insensitive {
line.to_lowercase()
} else {
line.to_string()
};
if haystack.contains(&needle) {
matches.push((idx + 1, line.to_string()));
}
}
Ok(matches)
}
pub fn head_file(&self, path: &str, n: usize) -> Result<String> {
let content = self.read_file(path)?;
let mut out = String::new();
for line in content.lines().take(n) {
out.push_str(line);
out.push('\n');
}
Ok(out)
}
pub fn tail_file(&self, path: &str, n: usize) -> Result<String> {
let content = self.read_file(path)?;
let lines: Vec<&str> = content.lines().collect();
let start = lines.len().saturating_sub(n);
let mut out = String::new();
for line in &lines[start..] {
out.push_str(line);
out.push('\n');
}
Ok(out)
}
pub fn wc_file(&self, path: &str) -> Result<(usize, usize, usize)> {
let content = self.read_file(path)?;
let lines = content.lines().count();
let words = content.split_whitespace().count();
let bytes = content.len();
Ok((lines, words, bytes))
}
pub fn write_file(&self, path: &str, content: &str, append: bool) -> Result<()> {
let virtual_abs = self.virtual_path(path)?;
self.prim_write(&virtual_abs, content.as_bytes(), append)?;
Ok(())
}
pub fn seed_setup(&self, files: &[crate::lesson::SetupFile]) -> Result<()> {
for file in files {
let virtual_abs = self.virtual_path(&file.path)?;
if let Some(parent) = virtual_abs.parent() {
self.prim_create_dir_all(parent)?;
}
self.prim_write(&virtual_abs, file.contents.as_bytes(), false)
.with_context(|| format!("Failed to seed lesson file: {}", file.path))?;
}
Ok(())
}
pub fn cleanup(&self) -> Result<()> {
match &self.store {
Store::Disk { root } => {
if root.exists() {
fs::remove_dir_all(root)
.context("Failed to cleanup virtual filesystem")?;
}
}
Store::Memory(tree) => tree_lock(tree).nodes.clear(),
}
Ok(())
}
}
const FILE_MODE: u32 = 0o644;
const DIR_MODE: u32 = 0o755;
const DIR_SIZE: u64 = 4096;
fn tree_lock(tree: &Mutex<MemTree>) -> std::sync::MutexGuard<'_, MemTree> {
tree.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
impl VirtualFileSystem {
pub(crate) fn prim_stat(&self, path: &Path) -> Option<Meta> {
match &self.store {
Store::Disk { .. } => {
let meta = fs::metadata(self.real_of(path).ok()?).ok()?;
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
Some(meta.permissions().mode())
};
#[cfg(not(unix))]
let mode = None;
Some(Meta {
is_dir: meta.is_dir(),
len: meta.len(),
mode,
})
}
Store::Memory(tree) => tree_lock(tree).stat(&key(path)),
}
}
pub(crate) fn prim_lstat(&self, path: &Path) -> Option<Meta> {
match &self.store {
Store::Disk { .. } => {
let meta = fs::symlink_metadata(self.real_of(path).ok()?).ok()?;
#[cfg(unix)]
let mode = {
use std::os::unix::fs::PermissionsExt;
Some(meta.permissions().mode())
};
#[cfg(not(unix))]
let mode = None;
Some(Meta {
is_dir: meta.is_dir(),
len: meta.len(),
mode,
})
}
Store::Memory(tree) => tree_lock(tree).stat(&key(path)),
}
}
pub(crate) fn prim_read(&self, path: &Path) -> io::Result<Vec<u8>> {
match &self.store {
Store::Disk { .. } => fs::read(self.real_io(path)?),
Store::Memory(tree) => tree_lock(tree).read(&key(path)),
}
}
pub(crate) fn prim_write(&self, path: &Path, bytes: &[u8], append: bool) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => {
let real = self.real_io(path)?;
if append {
use std::io::Write;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&real)?;
file.write_all(bytes)
} else {
fs::write(&real, bytes)
}
}
Store::Memory(tree) => tree_lock(tree).write(&key(path), bytes, append),
}
}
pub(crate) fn prim_list(&self, path: &Path) -> io::Result<Vec<Child>> {
match &self.store {
Store::Disk { .. } => {
let mut out = Vec::new();
for entry in fs::read_dir(self.real_io(path)?)? {
let entry = entry?;
let meta = entry.metadata()?;
out.push(Child {
name: entry.file_name().to_string_lossy().to_string(),
is_dir: meta.is_dir(),
is_symlink: meta.file_type().is_symlink(),
len: meta.len(),
});
}
Ok(out)
}
Store::Memory(tree) => tree_lock(tree).list(&key(path)),
}
}
pub(crate) fn prim_create_dir(&self, path: &Path) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => fs::create_dir(self.real_io(path)?),
Store::Memory(tree) => tree_lock(tree).create_dir(&key(path)),
}
}
pub(crate) fn prim_create_dir_all(&self, path: &Path) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => fs::create_dir_all(self.real_io(path)?),
Store::Memory(tree) => tree_lock(tree).create_dir_all(&key(path)),
}
}
pub(crate) fn prim_remove_file(&self, path: &Path) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => fs::remove_file(self.real_io(path)?),
Store::Memory(tree) => tree_lock(tree).remove_file(&key(path)),
}
}
pub(crate) fn prim_remove_dir_all(&self, path: &Path) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => fs::remove_dir_all(self.real_io(path)?),
Store::Memory(tree) => tree_lock(tree).remove_dir_all(&key(path)),
}
}
pub(crate) fn prim_rename(&self, from: &Path, to: &Path) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => fs::rename(self.real_io(from)?, self.real_io(to)?),
Store::Memory(tree) => tree_lock(tree).rename(&key(from), &key(to)),
}
}
pub(crate) fn prim_copy_file(&self, from: &Path, to: &Path) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => fs::copy(self.real_io(from)?, self.real_io(to)?).map(|_| ()),
Store::Memory(tree) => tree_lock(tree).copy_file(&key(from), &key(to)),
}
}
pub(crate) fn prim_set_mode(&self, path: &Path, mode: u32) -> io::Result<()> {
match &self.store {
Store::Disk { .. } => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(self.real_io(path)?, fs::Permissions::from_mode(mode))
}
#[cfg(not(unix))]
{
let _ = (path, mode);
Ok(())
}
}
Store::Memory(tree) => tree_lock(tree).set_mode(&key(path), mode),
}
}
fn real_io(&self, path: &Path) -> io::Result<PathBuf> {
self.real_of(path)
.map_err(|e| io::Error::new(io::ErrorKind::PermissionDenied, e.to_string()))
}
pub(crate) fn walk_children(&self, dir: &Path) -> Vec<(String, PathBuf, bool)> {
let mut out: Vec<(String, PathBuf, bool)> = self
.prim_list(dir)
.unwrap_or_default()
.into_iter()
.filter(|child| !child.is_symlink)
.map(|child| {
let path = dir.join(&child.name);
(child.name, path, child.is_dir)
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
pub(crate) fn tar_append_file<W: io::Write>(
&self,
builder: &mut tar::Builder<W>,
path: &Path,
name: &Path,
) -> Result<()> {
match &self.store {
Store::Disk { .. } => builder.append_path_with_name(self.real_of(path)?, name)?,
Store::Memory(_) => {
let data = self.prim_read(path)?;
let mode = self.prim_stat(path).and_then(|m| m.mode).unwrap_or(FILE_MODE);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Regular);
header.set_size(data.len() as u64);
header.set_mode(mode);
header.set_mtime(0);
builder.append_data(&mut header, name, &data[..])?;
}
}
Ok(())
}
pub(crate) fn tar_append_dir<W: io::Write>(
&self,
builder: &mut tar::Builder<W>,
path: &Path,
name: &Path,
) -> Result<()> {
match &self.store {
Store::Disk { .. } => builder.append_dir(name, self.real_of(path)?)?,
Store::Memory(_) => {
let mode = self.prim_stat(path).and_then(|m| m.mode).unwrap_or(DIR_MODE);
let mut header = tar::Header::new_gnu();
header.set_entry_type(tar::EntryType::Directory);
header.set_size(0);
header.set_mode(mode);
header.set_mtime(0);
builder.append_data(&mut header, name, io::empty())?;
}
}
Ok(())
}
}
#[derive(Debug, Clone)]
enum Node {
Dir { mode: u32 },
File { data: Vec<u8>, mode: u32 },
}
#[derive(Debug, Default)]
struct MemTree {
nodes: BTreeMap<String, Node>,
}
const MEM_MAX_FILE: usize = 4 * 1024 * 1024;
const MEM_MAX_TOTAL: usize = 32 * 1024 * 1024;
fn no_space() -> io::Error {
os_error("No space left on device")
}
fn key(path: &Path) -> String {
path.to_string_lossy().to_string()
}
fn not_found() -> io::Error {
io::Error::new(io::ErrorKind::NotFound, "No such file or directory")
}
fn os_error(message: &str) -> io::Error {
io::Error::other(message.to_string())
}
fn parent_key(key: &str) -> &str {
match key.rsplit_once('/') {
Some(("", _)) | None => "/",
Some((parent, _)) => parent,
}
}
impl MemTree {
fn stat(&self, key: &str) -> Option<Meta> {
Some(match self.nodes.get(key)? {
Node::Dir { mode } => Meta { is_dir: true, len: DIR_SIZE, mode: Some(*mode) },
Node::File { data, mode } => Meta {
is_dir: false,
len: data.len() as u64,
mode: Some(*mode),
},
})
}
fn check_parent(&self, key: &str) -> io::Result<()> {
let parent = parent_key(key);
if parent == "/" {
return Ok(());
}
match self.nodes.get(parent) {
Some(Node::Dir { .. }) => Ok(()),
Some(Node::File { .. }) => Err(os_error("Not a directory")),
None => Err(not_found()),
}
}
fn descendants(&self, key: &str) -> Vec<String> {
let prefix = format!("{key}/");
self.nodes
.range(prefix.clone()..)
.take_while(|(k, _)| k.starts_with(&prefix))
.map(|(k, _)| k.clone())
.collect()
}
fn read(&self, key: &str) -> io::Result<Vec<u8>> {
match self.nodes.get(key) {
Some(Node::File { data, .. }) => Ok(data.clone()),
Some(Node::Dir { .. }) => Err(os_error("Is a directory")),
None => Err(not_found()),
}
}
fn write(&mut self, key: &str, bytes: &[u8], append: bool) -> io::Result<()> {
let current = match self.nodes.get(key) {
Some(Node::File { data, .. }) => data.len(),
_ => 0,
};
let new_len = if append { current.saturating_add(bytes.len()) } else { bytes.len() };
let total: usize = self
.nodes
.values()
.map(|n| match n {
Node::File { data, .. } => data.len(),
_ => 0,
})
.sum();
if new_len > MEM_MAX_FILE || total - current + new_len > MEM_MAX_TOTAL {
return Err(no_space());
}
match self.nodes.get_mut(key) {
Some(Node::File { data, .. }) => {
if !append {
data.clear();
}
data.extend_from_slice(bytes);
Ok(())
}
Some(Node::Dir { .. }) => Err(os_error("Is a directory")),
None => {
self.check_parent(key)?;
self.nodes.insert(
key.to_string(),
Node::File { data: bytes.to_vec(), mode: FILE_MODE },
);
Ok(())
}
}
}
fn list(&self, key: &str) -> io::Result<Vec<Child>> {
match self.nodes.get(key) {
Some(Node::Dir { .. }) => {}
Some(Node::File { .. }) => return Err(os_error("Not a directory")),
None => return Err(not_found()),
}
let prefix = format!("{key}/");
Ok(self
.nodes
.range(prefix.clone()..)
.take_while(|(k, _)| k.starts_with(&prefix))
.filter(|(k, _)| !k[prefix.len()..].contains('/'))
.map(|(k, node)| Child {
name: k[prefix.len()..].to_string(),
is_dir: matches!(node, Node::Dir { .. }),
is_symlink: false,
len: match node {
Node::Dir { .. } => DIR_SIZE,
Node::File { data, .. } => data.len() as u64,
},
})
.collect())
}
fn create_dir(&mut self, key: &str) -> io::Result<()> {
if self.nodes.contains_key(key) {
return Err(os_error("File exists"));
}
self.check_parent(key)?;
self.nodes.insert(key.to_string(), Node::Dir { mode: DIR_MODE });
Ok(())
}
fn create_dir_all(&mut self, key: &str) -> io::Result<()> {
if key == "/" || key.is_empty() {
return Ok(());
}
match self.nodes.get(key) {
Some(Node::Dir { .. }) => Ok(()),
Some(Node::File { .. }) => Err(os_error("File exists")),
None => {
self.create_dir_all(parent_key(key))?;
self.nodes.insert(key.to_string(), Node::Dir { mode: DIR_MODE });
Ok(())
}
}
}
fn remove_file(&mut self, key: &str) -> io::Result<()> {
match self.nodes.get(key) {
Some(Node::File { .. }) => {
self.nodes.remove(key);
Ok(())
}
Some(Node::Dir { .. }) => Err(os_error("Is a directory")),
None => Err(not_found()),
}
}
fn remove_dir_all(&mut self, key: &str) -> io::Result<()> {
match self.nodes.get(key) {
Some(Node::Dir { .. }) => {
for inner in self.descendants(key) {
self.nodes.remove(&inner);
}
self.nodes.remove(key);
Ok(())
}
Some(Node::File { .. }) => Err(os_error("Not a directory")),
None => Err(not_found()),
}
}
fn rename(&mut self, from: &str, to: &str) -> io::Result<()> {
let Some(moving) = self.nodes.get(from).cloned() else {
return Err(not_found());
};
if from == to {
return Ok(());
}
if to.starts_with(&format!("{from}/")) {
return Err(os_error("Invalid argument"));
}
self.check_parent(to)?;
match (&moving, self.nodes.get(to)) {
(Node::File { .. }, Some(Node::Dir { .. })) => return Err(os_error("Is a directory")),
(Node::Dir { .. }, Some(Node::File { .. })) => return Err(os_error("Not a directory")),
(Node::Dir { .. }, Some(Node::Dir { .. })) if !self.descendants(to).is_empty() => {
return Err(os_error("Directory not empty"))
}
_ => {}
}
let inner = self.descendants(from);
self.nodes.remove(from);
self.nodes.insert(to.to_string(), moving);
for old in inner {
if let Some(node) = self.nodes.remove(&old) {
self.nodes.insert(format!("{to}{}", &old[from.len()..]), node);
}
}
Ok(())
}
fn copy_file(&mut self, from: &str, to: &str) -> io::Result<()> {
let (data, mode) = match self.nodes.get(from) {
Some(Node::File { data, mode }) => (data.clone(), *mode),
Some(Node::Dir { .. }) => return Err(os_error("Is a directory")),
None => return Err(not_found()),
};
match self.nodes.get(to) {
Some(Node::Dir { .. }) => return Err(os_error("Is a directory")),
Some(Node::File { .. }) => {}
None => self.check_parent(to)?,
}
self.nodes.insert(to.to_string(), Node::File { data, mode });
Ok(())
}
fn set_mode(&mut self, key: &str, new_mode: u32) -> io::Result<()> {
match self.nodes.get_mut(key) {
Some(Node::Dir { mode }) | Some(Node::File { mode, .. }) => {
*mode = new_mode & 0o7777;
Ok(())
}
None => Err(not_found()),
}
}
}
impl Drop for VirtualFileSystem {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub name: String,
pub is_dir: bool,
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct TreeNode {
pub name: String,
pub is_dir: bool,
pub children: Vec<TreeNode>,
pub is_current: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_in_memory_world_has_a_size_limit() {
let vfs = VirtualFileSystem::in_memory();
let mb = "x".repeat(1024 * 1024);
let mut err = None;
for _ in 0..(MEM_MAX_FILE / mb.len() + 2) {
if let Err(e) = vfs.write_file("big.txt", &mb, true) {
err = Some(e.to_string());
break;
}
}
assert!(err.unwrap().contains("No space left on device"));
let mut written = 0;
for i in 0..(MEM_MAX_TOTAL / mb.len() + 2) {
if vfs.write_file(&format!("f{i}.txt"), &mb, false).is_err() {
break;
}
written += 1;
}
assert!(written * mb.len() <= MEM_MAX_TOTAL);
assert!(vfs.write_file("f0.txt", "small", false).is_ok());
}
use super::*;
fn both(session: &str) -> [VirtualFileSystem; 2] {
let memory = VirtualFileSystem::in_memory();
memory.seed_generic().unwrap();
[VirtualFileSystem::new("test", session).unwrap(), memory]
}
#[test]
fn test_create_vfs() {
let vfs = VirtualFileSystem::new("test", "session-123").unwrap();
assert!(vfs.resolve_path("~").unwrap().exists());
assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
let vfs = VirtualFileSystem::in_memory();
assert!(vfs.is_dir("~"));
assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
}
#[test]
fn test_change_directory() {
for mut vfs in both("session-456") {
let new_path = vfs.change_directory("Documents").unwrap();
assert_eq!(new_path, "/lesson-home/Documents");
let new_path = vfs.change_directory("..").unwrap();
assert_eq!(new_path, "/lesson-home");
}
}
#[test]
fn test_list_directory() {
for vfs in both("session-789") {
let entries = vfs.list_directory(None).unwrap();
assert!(entries.len() >= 7);
assert!(entries[0].is_dir || entries[0].name == ".bashrc");
}
}
fn assert_escape_err<T: std::fmt::Debug>(result: Result<T>) {
let err = result.expect_err("operation should have been rejected");
assert!(
err.to_string().contains("escapes lesson sandbox"),
"unexpected error: {}",
err
);
}
#[test]
fn test_cat_traversal_escape_rejected() {
for vfs in both("session-cat-escape") {
assert_escape_err(vfs.read_file("../../../../etc/hostname"));
assert_escape_err(vfs.read_file("/lesson-home/../../etc/hostname"));
}
}
#[test]
fn test_rm_traversal_escape_rejected() {
for vfs in both("session-rm-escape") {
assert_escape_err(vfs.remove("../../..", true, true));
assert_escape_err(vfs.remove("../outside.txt", false, false));
}
}
#[test]
fn test_cd_traversal_escape_rejected() {
for mut vfs in both("session-cd-escape") {
assert_escape_err(vfs.change_directory("../.."));
assert_escape_err(vfs.change_directory("Documents/../../.."));
assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
}
}
#[test]
fn test_mv_traversal_escape_rejected() {
for vfs in both("session-mv-escape") {
assert_escape_err(vfs.move_item("Documents/report.txt", "../../stolen.txt"));
assert_escape_err(vfs.move_item("../../../etc/hostname", "here.txt"));
}
}
#[test]
fn test_cp_traversal_escape_rejected() {
for vfs in both("session-cp-escape") {
assert_escape_err(vfs.copy("Documents/report.txt", "../../stolen.txt", false));
assert_escape_err(vfs.copy("../../../etc/hostname", "here.txt", false));
}
}
#[test]
fn test_mkdir_touch_write_traversal_escape_rejected() {
for vfs in both("session-mk-escape") {
assert_escape_err(vfs.create_directory("../evil-dir", true));
assert_escape_err(vfs.touch_file("../evil.txt"));
assert_escape_err(vfs.write_file("../evil.txt", "payload", false));
assert_escape_err(vfs.list_directory(Some("../..")));
}
}
#[test]
fn test_cd_dotdot_at_root_clamps() {
for mut vfs in both("session-cd-root") {
let path = vfs.change_directory("..").unwrap();
assert_eq!(path, "/lesson-home");
assert_eq!(vfs.get_current_dir(), Path::new("/lesson-home"));
}
}
#[test]
fn test_grep_file() {
for vfs in both("session-grep") {
vfs.write_file("log.txt", "ok line\nERROR one\nfine\nerror two\n", false)
.unwrap();
let matches = vfs.grep_file("error", "log.txt", false).unwrap();
assert_eq!(matches, vec![(4, "error two".to_string())]);
let matches = vfs.grep_file("error", "log.txt", true).unwrap();
assert_eq!(
matches,
vec![(2, "ERROR one".to_string()), (4, "error two".to_string())]
);
assert_escape_err(vfs.grep_file("root", "../../../../etc/passwd", false));
}
}
#[test]
fn test_head_and_tail_file() {
for vfs in both("session-headtail") {
let body = (1..=20).map(|i| format!("line{}\n", i)).collect::<String>();
vfs.write_file("nums.txt", &body, false).unwrap();
let head = vfs.head_file("nums.txt", 3).unwrap();
assert_eq!(head, "line1\nline2\nline3\n");
let tail = vfs.tail_file("nums.txt", 2).unwrap();
assert_eq!(tail, "line19\nline20\n");
let all = vfs.head_file("nums.txt", 100).unwrap();
assert_eq!(all.lines().count(), 20);
let all = vfs.tail_file("nums.txt", 100).unwrap();
assert_eq!(all.lines().count(), 20);
}
}
#[test]
fn test_wc_file() {
for vfs in both("session-wc") {
vfs.write_file("data.txt", "one two\nthree\n", false).unwrap();
let (lines, words, bytes) = vfs.wc_file("data.txt").unwrap();
assert_eq!(lines, 2);
assert_eq!(words, 3);
assert_eq!(bytes, 14);
}
}
#[test]
fn test_legitimate_relative_paths_still_work() {
for mut vfs in both("session-legit") {
let content = vfs.read_file("Documents/../Documents/report.txt").unwrap();
assert!(content.contains("Quarterly Report"));
vfs.change_directory("Pictures/family").unwrap();
let path = vfs.change_directory("..").unwrap();
assert_eq!(path, "/lesson-home/Pictures");
vfs.create_directory("holiday", false).unwrap();
vfs.touch_file("holiday/list.txt").unwrap();
vfs.copy("family/../vacation.jpg", "holiday/copy.jpg", false).unwrap();
vfs.move_item("holiday/copy.jpg", "holiday/moved.jpg").unwrap();
assert!(vfs.is_file("holiday/moved.jpg"));
assert!(!vfs.exists("holiday/copy.jpg"));
}
let vfs = VirtualFileSystem::new("test", "session-legit-root").unwrap();
let resolved = vfs.resolve_path("/lesson-home/Documents").unwrap();
assert!(resolved.starts_with(vfs.resolve_path("~").unwrap().parent().unwrap()));
assert!(resolved.exists());
}
#[test]
fn memory_tree_moves_copies_and_removes_like_the_disk() {
for vfs in both("session-tree") {
vfs.create_directory("a/b/c", true).unwrap();
vfs.write_file("a/b/c/f.txt", "x\n", false).unwrap();
vfs.write_file("a/top.txt", "t\n", false).unwrap();
vfs.move_item("a", "z").unwrap();
assert_eq!(vfs.read_file("z/b/c/f.txt").unwrap(), "x\n");
assert!(!vfs.exists("a"));
vfs.create_directory("box", false).unwrap();
vfs.move_item("z/top.txt", "box").unwrap();
assert!(vfs.is_file("box/top.txt"));
assert!(vfs.move_item("z", "z/b/inner").is_err());
assert!(vfs.copy("z", "z/b/copy", true).is_err());
vfs.copy("z", "y", true).unwrap();
assert_eq!(vfs.read_file("y/b/c/f.txt").unwrap(), "x\n");
assert!(vfs.remove("y", false, false).is_err());
vfs.remove("y", true, false).unwrap();
assert!(!vfs.exists("y/b/c/f.txt"));
assert!(vfs.exists("z/b/c/f.txt"));
assert!(vfs.write_file("nowhere/x.txt", "x", false).is_err());
assert!(vfs.create_directory("p/q", false).is_err());
assert!(vfs.create_directory("box", false).is_err());
assert!(vfs.create_directory("box", true).is_ok());
vfs.write_file("box/top.txt", "u\n", true).unwrap();
assert_eq!(vfs.read_file("box/top.txt").unwrap(), "t\nu\n");
vfs.set_mode("box/top.txt", 0o700).unwrap();
vfs.copy("box/top.txt", "copied.txt", false).unwrap();
if let Some(mode) = vfs.metadata("copied.txt").unwrap().mode {
assert_eq!(mode & 0o777, 0o700);
}
vfs.write_bytes("bin.dat", &[0xff, 0xfe, 0x00]).unwrap();
assert_eq!(vfs.read_bytes("bin.dat").unwrap(), vec![0xff, 0xfe, 0x00]);
assert!(vfs.read_file("bin.dat").unwrap_err().to_string().contains("Failed to read file"));
}
}
#[test]
fn explorer_lessons_see_only_their_own_files() {
let library = crate::lesson::LessonLibrary::new();
let explorer = library.get("explorer-look-around").unwrap();
let operator = library.get("nav-basics").unwrap();
let names = |vfs: &VirtualFileSystem| -> Vec<String> {
vfs.list_directory(None).unwrap().into_iter().map(|e| e.name).collect()
};
let disk = VirtualFileSystem::for_lesson(explorer, "session-world").unwrap();
let memory = VirtualFileSystem::for_lesson_in_memory(explorer).unwrap();
let mut tops: Vec<String> = explorer
.setup
.iter()
.map(|f| f.path.split('/').next().unwrap().to_string())
.collect();
tops.dedup();
assert_eq!(names(&disk), tops);
assert_eq!(names(&memory), tops);
let disk = VirtualFileSystem::for_lesson(operator, "session-world-op").unwrap();
let memory = VirtualFileSystem::for_lesson_in_memory(operator).unwrap();
for vfs in [&disk, &memory] {
assert!(vfs.is_dir("Documents") && vfs.is_file(".bashrc"));
assert!(operator.setup.iter().all(|f| vfs.is_file(&f.path)));
}
assert_eq!(names(&disk), names(&memory));
}
#[test]
fn tree_shows_folders_first_and_the_current_one() {
for mut vfs in both("session-tree-view") {
vfs.change_directory("Documents").unwrap();
let tree = vfs.get_tree(2);
assert!(tree.first().unwrap().is_dir);
let docs = tree.iter().find(|n| n.name == "Documents").unwrap();
assert!(docs.is_current);
assert!(docs.children.iter().any(|n| n.name == "report.txt"));
}
}
}
impl crate::lesson::PracticeWorld for VirtualFileSystem {
fn locate(&self, lesson_path: &str) -> Option<PathBuf> {
let path = lesson_path.trim_start_matches('/');
self.resolve_path(&format!("/lesson-home/{path}")).ok()
}
fn cwd_relative(&self) -> String {
self.current_dir
.strip_prefix("/lesson-home")
.map(|rel| rel.to_string_lossy().to_string())
.unwrap_or_default()
}
fn kind_at(&self, lesson_path: &str) -> Option<crate::lesson::EntryKind> {
let path = lesson_path.trim_start_matches('/');
let virtual_abs = self.virtual_path(&format!("/lesson-home/{path}")).ok()?;
Some(match self.prim_stat(&virtual_abs) {
None => crate::lesson::EntryKind::Missing,
Some(meta) if meta.is_dir => crate::lesson::EntryKind::Dir,
Some(_) => crate::lesson::EntryKind::File,
})
}
fn read_text(&self, lesson_path: &str) -> Option<String> {
let path = lesson_path.trim_start_matches('/');
self.read_file(&format!("/lesson-home/{path}")).ok()
}
}