Skip to main content

arct_core/
playground.rs

1//! Real-filesystem practice playground for lessons.
2//!
3//! When `lessons.practice_mode = "real"`, lesson commands run against the
4//! real shell inside a dedicated directory (by default
5//! `~/ArcAcademy/playground`). Each lesson gets its own subdirectory
6//! (`<playground>/<lesson-id>/`) where its starter files (the lesson's
7//! `setup` list) are materialized.
8//!
9//! Safety properties:
10//! - `change_directory` canonicalizes and refuses to leave the playground.
11//! - `reset_lesson` only ever deletes inside the playground root, and
12//!   verifies the canonicalized path prefix before removing anything.
13//! - Setup file paths are validated to be relative and free of `..`.
14
15use crate::lesson::SetupFile;
16use anyhow::{Context, Result};
17use std::fs;
18use std::path::{Component, Path, PathBuf};
19
20/// A real practice directory with a tracked working directory per session.
21pub struct Playground {
22    /// Canonicalized playground root on the real filesystem.
23    root: PathBuf,
24    /// Active lesson id (subdirectory name under the root), if any.
25    lesson_id: Option<String>,
26    /// Starter files for the active lesson (kept for `reset_lesson`).
27    setup: Vec<SetupFile>,
28    /// Tracked working directory (absolute, always inside `root`).
29    cwd: PathBuf,
30    /// How the root is shown to the learner (e.g. `~/ArcAcademy/playground`).
31    display: String,
32}
33
34impl Playground {
35    /// Open (creating on demand) the playground at `root`.
36    pub fn open(root: PathBuf) -> Result<Self> {
37        Self::open_with_display(root, crate::guard::PLAYGROUND_DISPLAY)
38    }
39
40    /// Open a playground whose paths are shown to the learner under
41    /// `display` (e.g. `~/ArcAcademy/home`).
42    pub fn open_with_display(root: PathBuf, display: impl Into<String>) -> Result<Self> {
43        fs::create_dir_all(&root)
44            .with_context(|| format!("Failed to create playground at {}", root.display()))?;
45        let root = root
46            .canonicalize()
47            .context("Failed to canonicalize playground root")?;
48        Ok(Self {
49            cwd: root.clone(),
50            root,
51            lesson_id: None,
52            setup: Vec::new(),
53            display: display.into(),
54        })
55    }
56
57    /// The canonicalized playground root.
58    pub fn root(&self) -> &Path {
59        &self.root
60    }
61
62    /// The tracked working directory (absolute real path inside the root).
63    pub fn cwd(&self) -> &Path {
64        &self.cwd
65    }
66
67    /// Whether a lesson directory is currently active.
68    pub fn has_lesson(&self) -> bool {
69        self.lesson_id.is_some()
70    }
71
72    /// Friendly display path for the tracked cwd, e.g.
73    /// `~/ArcAcademy/playground/file-mgmt/practice`.
74    pub fn display_cwd(&self) -> String {
75        match self.cwd.strip_prefix(&self.root) {
76            Ok(rel) if rel.as_os_str().is_empty() => self.display.clone(),
77            Ok(rel) => format!("{}/{}", self.display, rel.display()),
78            Err(_) => self.cwd.display().to_string(),
79        }
80    }
81
82    /// Enter a lesson: create `<root>/<lesson-id>/`, materialize its starter
83    /// files (without clobbering files the learner already changed), and cd
84    /// there. Returns the lesson directory.
85    pub fn enter_lesson(&mut self, lesson_id: &str, setup: &[SetupFile]) -> Result<PathBuf> {
86        anyhow::ensure!(
87            !lesson_id.is_empty()
88                && lesson_id
89                    .chars()
90                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
91            "Invalid lesson id for playground directory: {lesson_id}"
92        );
93
94        let dir = self.root.join(lesson_id);
95        fs::create_dir_all(&dir)
96            .with_context(|| format!("Failed to create lesson playground: {}", dir.display()))?;
97        Self::materialize(&dir, setup, false)?;
98
99        self.lesson_id = Some(lesson_id.to_string());
100        self.setup = setup.to_vec();
101        self.cwd = dir
102            .canonicalize()
103            .context("Failed to canonicalize lesson playground directory")?;
104        Ok(self.cwd.clone())
105    }
106
107    /// Wipe and re-materialize the active lesson's playground directory.
108    ///
109    /// rm safety: only ever deletes inside the playground root — the target
110    /// is canonicalized and its prefix verified before removal.
111    pub fn reset_lesson(&mut self) -> Result<PathBuf> {
112        let lesson_id = self
113            .lesson_id
114            .clone()
115            .ok_or_else(|| anyhow::anyhow!("No active lesson to reset"))?;
116
117        let dir = self.root.join(&lesson_id);
118        if dir.exists() {
119            let canonical = dir
120                .canonicalize()
121                .context("Failed to canonicalize lesson directory before reset")?;
122            // Never delete the root itself, and never delete anything whose
123            // canonical path is not strictly inside the playground root.
124            anyhow::ensure!(
125                canonical != self.root && canonical.starts_with(&self.root),
126                "Refusing to reset: {} is not inside the playground",
127                canonical.display()
128            );
129            fs::remove_dir_all(&canonical).with_context(|| {
130                format!("Failed to remove lesson playground: {}", canonical.display())
131            })?;
132        }
133
134        fs::create_dir_all(&dir)?;
135        let setup = self.setup.clone();
136        Self::materialize(&dir, &setup, true)?;
137        self.cwd = dir
138            .canonicalize()
139            .context("Failed to canonicalize lesson playground directory")?;
140        Ok(self.cwd.clone())
141    }
142
143    /// Change the tracked working directory (the `cd` builtin for real
144    /// practice mode). The target is canonicalized and must remain inside
145    /// the playground. Returns the new display path.
146    pub fn change_directory(&mut self, target: &str) -> Result<String> {
147        let target = target.trim();
148        let candidate = if target.is_empty() || target == "~" {
149            // "cd" with no argument goes to the lesson directory (or root)
150            match &self.lesson_id {
151                Some(id) => self.root.join(id),
152                None => self.root.clone(),
153            }
154        } else if target.starts_with('/') {
155            PathBuf::from(target)
156        } else {
157            self.cwd.join(target)
158        };
159
160        let canonical = candidate
161            .canonicalize()
162            .map_err(|_| anyhow::anyhow!("No such directory: {target}"))?;
163
164        anyhow::ensure!(
165            canonical.starts_with(&self.root),
166            "'{target}' leaves {} — commands here stay inside it",
167            self.display
168        );
169        anyhow::ensure!(canonical.is_dir(), "Not a directory: {target}");
170
171        self.cwd = canonical;
172        Ok(self.display_cwd())
173    }
174
175    /// Write starter files under `dir`. Paths must be relative and must not
176    /// contain `..`. With `overwrite = false`, existing files are preserved
177    /// (so re-entering a lesson doesn't clobber the learner's work).
178    fn materialize(dir: &Path, files: &[SetupFile], overwrite: bool) -> Result<()> {
179        for file in files {
180            let rel = Path::new(&file.path);
181            let safe = rel.components().all(|c| matches!(c, Component::Normal(_)))
182                && !rel.as_os_str().is_empty();
183            anyhow::ensure!(safe, "Unsafe setup file path in lesson: {}", file.path);
184
185            let dest = dir.join(rel);
186            if let Some(parent) = dest.parent() {
187                fs::create_dir_all(parent)?;
188            }
189            if overwrite || !dest.exists() {
190                fs::write(&dest, &file.contents)
191                    .with_context(|| format!("Failed to write setup file: {}", dest.display()))?;
192            }
193        }
194        Ok(())
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn temp_root(name: &str) -> PathBuf {
203        let dir = std::env::temp_dir().join(format!(
204            "arct-playground-test-{}-{}",
205            name,
206            std::process::id()
207        ));
208        let _ = fs::remove_dir_all(&dir);
209        dir
210    }
211
212    fn sample_setup() -> Vec<SetupFile> {
213        vec![
214            SetupFile {
215                path: "notes.txt".to_string(),
216                contents: "starter\n".to_string(),
217            },
218            SetupFile {
219                path: "logs/server.log".to_string(),
220                contents: "line1\nline2\n".to_string(),
221            },
222        ]
223    }
224
225    #[test]
226    fn test_open_creates_root_and_enter_materializes_setup() {
227        let root = temp_root("enter");
228        let mut pg = Playground::open(root.clone()).unwrap();
229        assert!(pg.root().exists());
230        assert_eq!(pg.cwd(), pg.root());
231
232        let dir = pg.enter_lesson("file-mgmt", &sample_setup()).unwrap();
233        assert!(dir.ends_with("file-mgmt"));
234        assert_eq!(pg.cwd(), dir.as_path());
235        assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "starter\n");
236        assert_eq!(
237            fs::read_to_string(dir.join("logs/server.log")).unwrap(),
238            "line1\nline2\n"
239        );
240        assert_eq!(pg.display_cwd(), "~/ArcAcademy/playground/file-mgmt");
241
242        let _ = fs::remove_dir_all(&root);
243    }
244
245    #[test]
246    fn test_reenter_preserves_learner_changes_but_reset_wipes() {
247        let root = temp_root("reset");
248        let mut pg = Playground::open(root.clone()).unwrap();
249        let dir = pg.enter_lesson("file-viewing", &sample_setup()).unwrap();
250
251        // Learner edits a starter file and adds a new one
252        fs::write(dir.join("notes.txt"), "my edits\n").unwrap();
253        fs::write(dir.join("extra.txt"), "keep?\n").unwrap();
254
255        // Re-entering must NOT clobber the learner's work
256        pg.enter_lesson("file-viewing", &sample_setup()).unwrap();
257        assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "my edits\n");
258        assert!(dir.join("extra.txt").exists());
259
260        // Reset wipes and re-materializes pristine starter files
261        pg.reset_lesson().unwrap();
262        assert_eq!(fs::read_to_string(dir.join("notes.txt")).unwrap(), "starter\n");
263        assert!(!dir.join("extra.txt").exists());
264        assert!(dir.join("logs/server.log").exists());
265
266        let _ = fs::remove_dir_all(&root);
267    }
268
269    #[test]
270    fn test_reset_without_lesson_errors() {
271        let root = temp_root("reset-none");
272        let mut pg = Playground::open(root.clone()).unwrap();
273        assert!(pg.reset_lesson().is_err());
274        let _ = fs::remove_dir_all(&root);
275    }
276
277    #[test]
278    fn test_change_directory_stays_contained() {
279        let root = temp_root("cd");
280        let mut pg = Playground::open(root.clone()).unwrap();
281        pg.enter_lesson("nav", &sample_setup()).unwrap();
282
283        // Into a subdirectory and back up
284        pg.change_directory("logs").unwrap();
285        assert!(pg.display_cwd().ends_with("nav/logs"));
286        pg.change_directory("..").unwrap();
287        assert!(pg.display_cwd().ends_with("nav"));
288
289        // Up to the playground root is allowed
290        pg.change_directory("..").unwrap();
291        assert_eq!(pg.cwd(), pg.root());
292
293        // Escaping above the root is refused, cwd unchanged
294        assert!(pg.change_directory("..").is_err());
295        assert_eq!(pg.cwd(), pg.root());
296        assert!(pg.change_directory("/etc").is_err());
297        assert!(pg.change_directory("../..").is_err());
298
299        // Nonexistent directory errors
300        assert!(pg.change_directory("nope").is_err());
301
302        // Bare `cd` returns to the lesson directory
303        pg.change_directory("").unwrap();
304        assert!(pg.display_cwd().ends_with("nav"));
305
306        let _ = fs::remove_dir_all(&root);
307    }
308
309    #[test]
310    fn test_unsafe_setup_paths_rejected() {
311        let root = temp_root("unsafe");
312        let mut pg = Playground::open(root.clone()).unwrap();
313
314        let evil = vec![SetupFile {
315            path: "../escape.txt".to_string(),
316            contents: "nope".to_string(),
317        }];
318        assert!(pg.enter_lesson("evil", &evil).is_err());
319        assert!(!root.join("escape.txt").exists());
320
321        let abs = vec![SetupFile {
322            path: "/tmp/abs-escape.txt".to_string(),
323            contents: "nope".to_string(),
324        }];
325        assert!(pg.enter_lesson("evil2", &abs).is_err());
326
327        let _ = fs::remove_dir_all(&root);
328    }
329
330    #[test]
331    fn test_invalid_lesson_id_rejected() {
332        let root = temp_root("badid");
333        let mut pg = Playground::open(root.clone()).unwrap();
334        assert!(pg.enter_lesson("../outside", &[]).is_err());
335        assert!(pg.enter_lesson("", &[]).is_err());
336        assert!(pg.enter_lesson("a/b", &[]).is_err());
337        let _ = fs::remove_dir_all(&root);
338    }
339}
340
341impl crate::lesson::PracticeWorld for Playground {
342    fn locate(&self, lesson_path: &str) -> Option<PathBuf> {
343        let base = match &self.lesson_id {
344            Some(id) => self.root.join(id),
345            None => self.root.clone(),
346        };
347        let rel = Path::new(lesson_path.trim_start_matches('/'));
348        if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
349            return None;
350        }
351        Some(base.join(rel))
352    }
353
354    fn cwd_relative(&self) -> String {
355        let base = match &self.lesson_id {
356            Some(id) => self.root.join(id),
357            None => self.root.clone(),
358        };
359        self.cwd
360            .strip_prefix(&base)
361            .map(|rel| rel.to_string_lossy().to_string())
362            .unwrap_or_default()
363    }
364}