Skip to main content

arct_core/
guard.rs

1//! Safety guard for real-filesystem lesson practice ("Playground" mode).
2//!
3//! When a lesson runs in real practice mode, commands execute against the
4//! real shell inside `~/ArcAcademy/playground`. This guard sits in front of
5//! that shell and refuses:
6//!
7//! 1. Commands that reference paths outside the playground (absolute paths,
8//!    `~`, or enough `..` to climb out given the tracked working directory).
9//! 2. A small denylist of catastrophic patterns (`rm -rf /`, `mkfs`,
10//!    `dd of=/dev/...`, fork bombs, `chmod -R 777 /`, redirects into `/dev`,
11//!    and anything run via `sudo`) — each refusal explains WHY the pattern
12//!    is dangerous, because that is itself a teaching moment.
13//! 3. Constructs we cannot reason about safely (command substitution,
14//!    environment-variable expansion, unbalanced quotes). The heuristics are
15//!    deliberately conservative: when in doubt, refuse politely.
16//!
17//! The same guard protects the free shell when `shell.safe_mode` is on (the
18//! default), rooted at `~/ArcAcademy/home`. With safe mode off, the free shell
19//! still runs every command through [`PlaygroundGuard::check_catastrophic`].
20
21use std::path::{Component, Path, PathBuf};
22
23/// Human-readable location of the playground, used in refusal messages.
24pub const PLAYGROUND_DISPLAY: &str = "~/ArcAcademy/playground";
25
26/// Outcome of checking a command against the playground guard.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum GuardVerdict {
29    /// Command is allowed to run in the real shell.
30    Allow,
31    /// Command is refused; `reason` is a friendly, novice-facing explanation.
32    Refuse { reason: String },
33}
34
35impl GuardVerdict {
36    pub fn is_allowed(&self) -> bool {
37        matches!(self, GuardVerdict::Allow)
38    }
39
40    fn refuse(reason: impl Into<String>) -> Self {
41        GuardVerdict::Refuse {
42            reason: reason.into(),
43        }
44    }
45}
46
47/// Guard that keeps real-mode lesson commands inside the playground.
48pub struct PlaygroundGuard {
49    /// Absolute path of the playground root on the real filesystem.
50    root: PathBuf,
51    /// How the root is shown to the learner in refusal messages.
52    display: String,
53}
54
55/// What a sed script can reach beyond the text it edits
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum SedReach {
58    /// Only commands that edit, print or delete lines
59    Harmless,
60    /// w, W, r, R or e somewhere (or s///w, s///e): files or programs
61    FilesOrPrograms,
62    /// Not understood: left to the ordinary path check
63    Unknown,
64}
65
66/// Classify a sed script by reading it the way sed does: addresses
67/// (a number, $, /regex/ or \cregexc, a range, !), then a command. Only
68/// a short list of commands counts as harmless.
69fn sed_script_reach(script: &str) -> SedReach {
70    let chars: Vec<char> = script.chars().collect();
71    let mut i = 0;
72    // Read a delimited part (regex or replacement) ending at an unescaped
73    // `delim`; returns the index after it
74    let read_part = |mut i: usize, delim: char| -> Option<usize> {
75        while i < chars.len() {
76            match chars[i] {
77                '\\' => i += 2,
78                c if c == delim => return Some(i + 1),
79                '\n' => return None,
80                _ => i += 1,
81            }
82        }
83        None
84    };
85    let read_address = |i: usize| -> Option<usize> {
86        match chars.get(i) {
87            Some(c) if c.is_ascii_digit() => {
88                let mut j = i;
89                while chars.get(j).is_some_and(|c| c.is_ascii_digit()) {
90                    j += 1;
91                }
92                Some(j)
93            }
94            Some('$') => Some(i + 1),
95            Some('/') => read_part(i + 1, '/'),
96            Some('\\') => {
97                let delim = *chars.get(i + 1)?;
98                read_part(i + 2, delim)
99            }
100            _ => Some(i),
101        }
102    };
103    let mut reach = SedReach::Harmless;
104    loop {
105        while chars.get(i).is_some_and(|c| c.is_whitespace() || *c == ';') {
106            i += 1;
107        }
108        if i >= chars.len() {
109            return reach;
110        }
111        let Some(after) = read_address(i) else { return SedReach::Unknown };
112        i = after;
113        if chars.get(i) == Some(&',') {
114            let Some(after) = read_address(i + 1) else { return SedReach::Unknown };
115            if after == i + 1 {
116                return SedReach::Unknown;
117            }
118            i = after;
119        }
120        while chars.get(i).is_some_and(|c| c.is_whitespace() || *c == '!') {
121            i += 1;
122        }
123        let Some(&command) = chars.get(i) else { return SedReach::Unknown };
124        i += 1;
125        match command {
126            'w' | 'W' | 'r' | 'R' | 'e' => return SedReach::FilesOrPrograms,
127            'd' | 'D' | 'p' | 'P' | 'n' | 'N' | 'q' | 'Q' | '=' | 'g' | 'G' | 'h' | 'H'
128            | 'x' | 'l' | 'z' | '{' | '}' => {}
129            's' | 'y' => {
130                let Some(&delim) = chars.get(i) else { return SedReach::Unknown };
131                if delim == '\\' || delim == '\n' || delim.is_whitespace() {
132                    return SedReach::Unknown;
133                }
134                let Some(after) = read_part(i + 1, delim) else { return SedReach::Unknown };
135                let Some(after) = read_part(after, delim) else { return SedReach::Unknown };
136                i = after;
137                if command == 's' {
138                    while let Some(&flag) = chars.get(i) {
139                        match flag {
140                            'w' | 'e' => return SedReach::FilesOrPrograms,
141                            'g' | 'p' | 'i' | 'I' | 'm' | 'M' => i += 1,
142                            c if c.is_ascii_digit() => i += 1,
143                            _ => break,
144                        }
145                    }
146                }
147            }
148            _ => reach = SedReach::Unknown,
149        }
150        if reach == SedReach::Unknown {
151            return reach;
152        }
153        while chars.get(i).is_some_and(|c| *c == ' ' || *c == '\t') {
154            i += 1;
155        }
156        match chars.get(i) {
157            None => return reach,
158            Some(';') | Some('\n') | Some('}') => {}
159            Some(_) => return SedReach::Unknown,
160        }
161    }
162}
163
164impl PlaygroundGuard {
165    /// Create a guard for the given playground root (absolute path).
166    pub fn new(root: impl Into<PathBuf>) -> Self {
167        Self::with_display(root, PLAYGROUND_DISPLAY)
168    }
169
170    /// Create a guard whose refusal messages name the root as `display`
171    /// (e.g. `~/ArcAcademy/home`).
172    pub fn with_display(root: impl Into<PathBuf>, display: impl Into<String>) -> Self {
173        Self {
174            root: root.into(),
175            display: display.into(),
176        }
177    }
178
179    /// Check only the catastrophic-pattern denylist, with no containment.
180    /// Used by the free shell when safe mode is off: commands may touch the
181    /// whole filesystem, but never the handful that destroy it.
182    pub fn check_catastrophic(command: &str) -> GuardVerdict {
183        let trimmed = command.trim();
184        if trimmed.is_empty() {
185            return GuardVerdict::Allow;
186        }
187        Self::check_denylist(trimmed).unwrap_or(GuardVerdict::Allow)
188    }
189
190    /// Check a command line before it is sent to the real shell.
191    ///
192    /// `cwd` is the tracked working directory for the practice session; it
193    /// must be inside the playground root. The check is purely lexical — it
194    /// never touches the filesystem.
195    pub fn check(&self, command: &str, cwd: &Path) -> GuardVerdict {
196        self.check_line(command, cwd, false)
197    }
198
199    /// Check a line the app has already expanded and quoted itself (see
200    /// `shell_syntax::render`): every word is a literal, and the only `$`
201    /// left is inside single quotes or the shell's own `"$?"`. Such a line
202    /// shows exactly what will run, so `$` is no reason to refuse it.
203    /// Command substitution is still refused.
204    pub fn check_expanded(&self, command: &str, cwd: &Path) -> GuardVerdict {
205        // Trust the contract only after checking it
206        if !Self::dollars_are_rendered(command) {
207            return GuardVerdict::refuse(
208                "That command still has a '$' the shell would fill in, so it can't be \
209                 checked safely.",
210            );
211        }
212        self.check_line(command, cwd, true)
213    }
214
215    /// Every `$` sits inside single quotes, or is exactly `"$?"`, and the
216    /// only backslash is the `\'` that writes a quote inside a quoted word
217    fn dollars_are_rendered(command: &str) -> bool {
218        let mut rest = command;
219        let mut quoted = false;
220        while let Some(c) = rest.chars().next() {
221            if quoted {
222                quoted = c != '\'';
223            } else if c == '\'' {
224                quoted = true;
225            } else if rest.starts_with("\\'") {
226                // the quote inside a quoted word, as in 'it'\''s'
227                rest = &rest[2..];
228                continue;
229            } else if c == '\\' {
230                // nothing else in a rendered line is escaped
231                return false;
232            } else if rest.starts_with("\"$?\"") {
233                rest = &rest[4..];
234                continue;
235            } else if c == '$' {
236                return false;
237            }
238            rest = &rest[c.len_utf8()..];
239        }
240        true
241    }
242
243    fn check_line(&self, command: &str, cwd: &Path, expanded: bool) -> GuardVerdict {
244        let trimmed = command.trim();
245        if trimmed.is_empty() {
246            return GuardVerdict::Allow;
247        }
248
249        // --- 1. Catastrophic-pattern denylist (checked first so the user
250        // gets the specific teaching-moment explanation, not a generic
251        // path message) ---
252        if let Some(verdict) = Self::check_denylist(trimmed) {
253            return verdict;
254        }
255
256        // --- 2. Constructs we can't reason about safely ---
257        if trimmed.contains('`') || trimmed.contains("$(") {
258            return GuardVerdict::refuse(
259                "Command substitution (backticks or $(...)) runs a hidden inner command, \
260                 so there's no way to check where it will reach. Type the command \
261                 directly instead.",
262            );
263        }
264        if trimmed.contains('$') && !expanded {
265            return GuardVerdict::refuse(
266                "Environment variables like $HOME or $PATH can expand to locations outside \
267                 the folder you're practising in, so commands using '$' are blocked. Try \
268                 typing the actual path or value instead.",
269            );
270        }
271
272        // --- 3. Tokenize (handles quoted paths like "cat '/etc/passwd'") ---
273        let tokens = match shellwords::split(trimmed) {
274            Ok(tokens) => tokens,
275            Err(_) => {
276                return GuardVerdict::refuse(
277                    "That command has unbalanced quotes, so it can't be checked safely. \
278                     Close the quote and try again.",
279                );
280            }
281        };
282
283        // --- 4. Per-token path containment ---
284        // A sed script such as '/debug/d' starts with '/' but names no file.
285        // Scripts made only of commands that can't reach a file or program
286        // are left out of the path check; scripts with w, r or e (which do)
287        // are refused; anything not understood is checked as a path.
288        let mut sed_scripts = std::collections::HashSet::new();
289        for (index, script) in Self::sed_script_tokens(&tokens) {
290            match sed_script_reach(script) {
291                SedReach::Harmless => {
292                    sed_scripts.insert(index);
293                }
294                SedReach::FilesOrPrograms => {
295                    return GuardVerdict::refuse(
296                        "sed's w, r and e commands write files, read files or run programs \
297                         from inside the script, where the guard can't follow them, so \
298                         they're turned off here. Redirect sed's output with > instead.",
299                    );
300                }
301                SedReach::Unknown => {}
302            }
303        }
304        let tokens: Vec<String> = tokens
305            .into_iter()
306            .enumerate()
307            .filter(|(index, _)| !sed_scripts.contains(index))
308            .map(|(_, token)| token)
309            .collect();
310        let mut total_climb: usize = 0;
311        // Strip redirect prefixes so `>/etc/passwd` or `2>/tmp/x` are
312        // analyzed as paths, and check what follows `=` too, so
313        // `of=/dev/disk2` or `--output=/etc/x` is seen as the path it names
314        let candidates = tokens.iter().flat_map(|raw_token| {
315            let digits_stripped = raw_token.trim_start_matches(|c: char| c.is_ascii_digit());
316            let token = if digits_stripped.starts_with(['<', '>', '&']) {
317                digits_stripped.trim_start_matches(['<', '>', '&'])
318            } else {
319                raw_token.as_str()
320            };
321            let value = token.split_once('=').map(|(_, value)| value);
322            std::iter::once(token).chain(value)
323        });
324        for token in candidates {
325            if token.is_empty() {
326                continue;
327            }
328
329            // Tilde expansion points at the home directory. `~/<root>/...` is
330            // allowed only if it still resolves inside the root after `..`.
331            if token.contains('~') {
332                let inside = self
333                    .display
334                    .strip_prefix("~/")
335                    .and_then(|_| token.strip_prefix(self.display.as_str()))
336                    .filter(|rest| rest.is_empty() || rest.starts_with('/'))
337                    .and_then(|rest| {
338                        let joined = format!("{}{}", self.root.display(), rest);
339                        Self::lexical_normalize(Path::new(&joined))
340                    })
341                    .is_some_and(|normalized| normalized.starts_with(&self.root));
342                if inside {
343                    continue;
344                }
345                return GuardVerdict::refuse(format!(
346                    "Commands here stay inside {} — '~' points at your real home \
347                     directory. Try a relative path instead.",
348                    self.display
349                ));
350            }
351
352            // Absolute paths must stay inside the playground root
353            if token.starts_with('/') {
354                match Self::lexical_normalize(Path::new(token)) {
355                    Some(normalized) if normalized.starts_with(&self.root) => continue,
356                    _ => {
357                        return GuardVerdict::refuse(format!(
358                            "Commands here stay inside {} — '{token}' points outside \
359                             it. Try a relative path instead.",
360                            self.display
361                        ));
362                    }
363                }
364            }
365
366            // Relative paths: accumulate the deepest upward climb (`..`)
367            total_climb += Self::upward_climb(Path::new(token));
368        }
369
370        // --- 5. `..` escape budget given the tracked cwd depth ---
371        let depth = cwd
372            .strip_prefix(&self.root)
373            .map(|rel| rel.components().count())
374            .unwrap_or(0);
375        if total_climb > depth {
376            return GuardVerdict::refuse(format!(
377                "That command uses enough '..' to climb out of {}. Commands here stay \
378                 inside it — try a relative path.",
379                self.display
380            ));
381        }
382
383        GuardVerdict::Allow
384    }
385
386    /// Denylist of catastrophic patterns, refused regardless of paths.
387    /// Each refusal explains WHY the pattern is dangerous.
388    fn check_denylist(command: &str) -> Option<GuardVerdict> {
389        // Fork bomb: :(){ :|:& };:  (whitespace-insensitive)
390        let squashed: String = command.chars().filter(|c| !c.is_whitespace()).collect();
391        if squashed.contains(":(){") || squashed.contains(":|:&") {
392            return Some(GuardVerdict::refuse(
393                "That's a fork bomb — a tiny function that endlessly launches copies of \
394                 itself until the computer runs out of memory and freezes. It looks like \
395                 line noise, which is exactly why people get tricked into running it.",
396            ));
397        }
398
399        // Commands in a chain, split two ways: at every ; | & (catches a
400        // quote that hides a split, like `true';' sudo x`), and only where
401        // the shell splits (catches a quoted ';' that would otherwise make
402        // the segment fail to tokenize, like `rm -rf ~ ';'`)
403        let mut segments: Vec<String> = command
404            .split([';', '|', '&'])
405            .map(str::to_string)
406            .collect();
407        segments.extend(Self::split_unquoted(command));
408
409        // sudo anywhere in the pipeline/sequence
410        for segment in &segments {
411            let first = shellwords::split(segment)
412                .ok()
413                .and_then(|t| t.into_iter().next())
414                .or_else(|| segment.split_whitespace().next().map(str::to_string));
415            if first.as_deref() == Some("sudo") {
416                return Some(GuardVerdict::refuse(
417                    "'sudo' runs a command with full administrator power, where a single \
418                     typo can change or delete critical system files. arct never needs \
419                     admin rights, so sudo is blocked here; when you truly need it, use \
420                     your own terminal and read the command twice first.",
421                ));
422            }
423        }
424
425        // Every word, as typed and as the shell reads it after quotes, so
426        // 'of=/dev/disk2' is seen as of=/dev/disk2
427        let words: Vec<String> = command
428            .split_whitespace()
429            .map(str::to_string)
430            .chain(segments.iter().filter_map(|s| shellwords::split(s).ok()).flatten())
431            .collect();
432
433        // mkfs formats disks
434        if words.iter().any(|t| t == "mkfs" || t.starts_with("mkfs.")) {
435            return Some(GuardVerdict::refuse(
436                "'mkfs' formats a disk partition, instantly erasing every file on it. \
437                 It's used to prepare brand-new drives — never something to practice with.",
438            ));
439        }
440
441        // dd writing to a raw device
442        if words.iter().any(|t| t.starts_with("of=/dev/")) {
443            return Some(GuardVerdict::refuse(
444                "'dd' writing to a /dev device bypasses the filesystem and overwrites the \
445                 raw disk underneath your files — it can wipe an entire drive in seconds \
446                 with no confirmation and no undo.",
447            ));
448        }
449
450        // Redirecting output into a device (e.g. `> /dev/sda`). /dev/null,
451        // the "throw it away" file, and the terminal's own streams are fine
452        const HARMLESS: &[&str] = &["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"];
453        let into_device =
454            regex::Regex::new(r#">>?\s*['"]?(/dev/[^\s;|&<>'"]*)"#).expect("valid pattern");
455        if into_device
456            .captures_iter(command)
457            .any(|c| !HARMLESS.contains(&c.get(1).map_or("", |m| m.as_str())))
458        {
459            return Some(GuardVerdict::refuse(
460                "Redirecting output into /dev writes bytes directly to a hardware device. \
461                 Aimed at a disk like /dev/sda, that corrupts the filesystem and destroys \
462                 data immediately.",
463            ));
464        }
465
466        // rm -rf / chmod -R 777 — checked in every segment of a chain like
467        // `cd / && rm -rf *` or `true; rm -rf ~`
468        for segment in &segments {
469            if let Some(verdict) = Self::check_segment(segment.trim()) {
470                return Some(verdict);
471            }
472        }
473
474        None
475    }
476
477    /// Split a line at the `;`, `|` and `&` the shell would act on: not
478    /// inside quotes, not after a backslash
479    fn split_unquoted(command: &str) -> Vec<String> {
480        let mut segments = vec![String::new()];
481        let mut quote: Option<char> = None;
482        let mut chars = command.chars();
483        while let Some(c) = chars.next() {
484            match (quote, c) {
485                (None, '\'' | '"') => quote = Some(c),
486                (Some(q), _) if c == q => quote = None,
487                (None | Some('"'), '\\') => {
488                    segments.last_mut().expect("never empty").push(c);
489                    if let Some(next) = chars.next() {
490                        segments.last_mut().expect("never empty").push(next);
491                    }
492                    continue;
493                }
494                (None, ';' | '|' | '&') => {
495                    segments.push(String::new());
496                    continue;
497                }
498                _ => {}
499            }
500            segments.last_mut().expect("never empty").push(c);
501        }
502        segments
503    }
504
505    /// Destructive-target checks for one simple command (no `;`/`|`/`&`).
506    fn check_segment(segment: &str) -> Option<GuardVerdict> {
507        let tokens = shellwords::split(segment).ok()?;
508        let program = tokens.first().map(String::as_str)?;
509        let is_root = |t: &str| matches!(t, "/" | "/*");
510        let is_home = |t: &str| {
511            matches!(
512                t.trim_end_matches('/'),
513                "~" | "~/*" | "$HOME" | "${HOME}" | "$HOME/*" | "${HOME}/*"
514            )
515        };
516
517        if program == "rm" {
518            let mut recursive = false;
519            let mut no_preserve_root = false;
520            let mut targets_root = false;
521            let mut targets_home = false;
522            for t in &tokens[1..] {
523                if t == "--no-preserve-root" {
524                    no_preserve_root = true;
525                } else if t == "--recursive" {
526                    recursive = true;
527                } else if t.starts_with('-') && !t.starts_with("--") {
528                    recursive |= t.contains('r') || t.contains('R');
529                } else if is_root(t) {
530                    targets_root = true;
531                } else if is_home(t) {
532                    targets_home = true;
533                }
534            }
535            if recursive && (targets_root || no_preserve_root) {
536                return Some(GuardVerdict::refuse(
537                    "'rm -rf /' recursively force-deletes EVERYTHING starting from the \
538                     root of the filesystem — the operating system, your applications, \
539                     and all your files — with no confirmation and no undo. This is the \
540                     single most destructive command on a Unix system.",
541                ));
542            }
543            if recursive && targets_home {
544                return Some(GuardVerdict::refuse(
545                    "'rm -r ~' deletes your whole home folder: every document, photo, \
546                     project and setting you own, with no trash can and no undo. To \
547                     clean up, delete one named folder at a time and run 'ls' first to \
548                     see what's inside.",
549                ));
550            }
551        }
552
553        if program == "chmod" {
554            let recursive = tokens.iter().any(|t| t == "-R" || t == "-r");
555            let mode_777 = tokens.iter().any(|t| t == "777");
556            let targets_root = tokens.iter().any(|t| is_root(t));
557            if recursive && mode_777 && targets_root {
558                return Some(GuardVerdict::refuse(
559                    "'chmod -R 777 /' makes every file on the system readable, writable \
560                     and executable by everyone — a massive security hole that also \
561                     breaks programs that refuse to run with unsafe permissions.",
562                ));
563            }
564        }
565
566        None
567    }
568
569    /// Lexically resolve `.`/`..` in an absolute path (no filesystem access).
570    /// Returns None if the path climbs above `/`.
571    /// Indices of the tokens that are sed scripts: after `-e`, in
572    /// `--expression=`, or else sed's first argument that isn't an option.
573    /// sed must be the command (first word, or after ; | & && ||).
574    fn sed_script_tokens(tokens: &[String]) -> Vec<(usize, &str)> {
575        let ends_command = |t: &str| t.ends_with([';', '|', '&']);
576        let mut found = Vec::new();
577        let mut i = 0;
578        while i < tokens.len() {
579            let at_command = i == 0 || ends_command(&tokens[i - 1]);
580            if !(at_command && tokens[i] == "sed") {
581                i += 1;
582                continue;
583            }
584            let mut j = i + 1;
585            let mut saw_expression = false;
586            let mut first_operand = None;
587            while j < tokens.len() {
588                let t = tokens[j].as_str();
589                let last = ends_command(t);
590                if t == "-e" || t == "--expression" {
591                    if let Some(next) = tokens.get(j + 1) {
592                        found.push((j + 1, next.as_str()));
593                    }
594                    saw_expression = true;
595                    j += 2;
596                    continue;
597                }
598                if let Some(script) = t.strip_prefix("--expression=") {
599                    // the same as -e: the whole token is the script
600                    found.push((j, script));
601                    saw_expression = true;
602                } else if t == "-f" || t == "--file" {
603                    // a script file is a path (checked as one), and with a
604                    // script given, every operand after it is data: a path
605                    saw_expression = true;
606                    j += 2;
607                    continue;
608                } else if !t.starts_with('-') && first_operand.is_none() {
609                    first_operand = Some(j);
610                }
611                j += 1;
612                if last {
613                    break;
614                }
615            }
616            if let (false, Some(k)) = (saw_expression, first_operand) {
617                found.push((k, tokens[k].as_str()));
618            }
619            i = j;
620        }
621        found
622    }
623
624    fn lexical_normalize(path: &Path) -> Option<PathBuf> {
625        let mut out = PathBuf::from("/");
626        for component in path.components() {
627            match component {
628                Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
629                Component::ParentDir => {
630                    if !out.pop() {
631                        return None;
632                    }
633                }
634                Component::Normal(part) => out.push(part),
635            }
636        }
637        Some(out)
638    }
639
640    /// Deepest upward climb of a relative path: how many levels above the
641    /// starting directory it can reach (e.g. `a/../../b` climbs 1, `../..`
642    /// climbs 2). Summed across tokens, this is a conservative bound on how
643    /// far a command can escape.
644    fn upward_climb(path: &Path) -> usize {
645        let mut depth: isize = 0;
646        let mut min_depth: isize = 0;
647        for component in path.components() {
648            match component {
649                Component::ParentDir => {
650                    depth -= 1;
651                    min_depth = min_depth.min(depth);
652                }
653                Component::Normal(_) => depth += 1,
654                _ => {}
655            }
656        }
657        (-min_depth).max(0) as usize
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    fn guard() -> PlaygroundGuard {
666        PlaygroundGuard::new("/home/user/ArcAcademy/playground")
667    }
668
669    /// cwd = playground/<lesson-id> (depth 1)
670    fn lesson_cwd() -> PathBuf {
671        PathBuf::from("/home/user/ArcAcademy/playground/file-mgmt")
672    }
673
674    /// cwd = playground root (depth 0)
675    fn root_cwd() -> PathBuf {
676        PathBuf::from("/home/user/ArcAcademy/playground")
677    }
678
679    fn assert_allowed(cmd: &str, cwd: &Path) {
680        let verdict = guard().check(cmd, cwd);
681        assert!(verdict.is_allowed(), "expected Allow for {cmd:?}, got {verdict:?}");
682    }
683
684    fn assert_refused(cmd: &str, cwd: &Path) -> String {
685        match guard().check(cmd, cwd) {
686            GuardVerdict::Refuse { reason } => reason,
687            GuardVerdict::Allow => panic!("expected Refuse for {cmd:?}, got Allow"),
688        }
689    }
690
691    // --- sed scripts are scripts, not paths ---
692
693    #[test]
694    fn sed_scripts_that_only_edit_text_are_not_paths() {
695        let cwd = lesson_cwd();
696        for cmd in [
697            "sed '/debug/d' app.log",
698            "sed -n '/error/p' app.log",
699            "sed -e '/a/d' -e 's/x/y/g' notes.txt",
700            "sed '1,/start/d;s|/usr|/opt|g' notes.txt",
701            "sed -i.bak '/^#/d' notes.txt",
702            "grep x f | sed '/debug/d'",
703            "sed --expression='/debug/d' app.log",
704        ] {
705            assert_allowed(cmd, &cwd);
706        }
707        // the same line as the real-files path renders it
708        assert!(guard().check_expanded("sed '/debug/d' 'app.log'", &cwd).is_allowed());
709    }
710
711    #[test]
712    fn sed_commands_that_reach_files_or_programs_are_refused() {
713        let cwd = lesson_cwd();
714        for cmd in [
715            "sed '/x/w out.txt' notes.txt",
716            "sed 's/a/b/w /tmp/x' notes.txt",
717            "sed 's/a/date/e' notes.txt",
718            "sed '1e date' notes.txt",
719            "sed '/x/r /etc/passwd' notes.txt",
720            "ls; sed -n '2R /etc/hosts' notes.txt",
721            "sed -e 's/a/b/' -e 'W copy' notes.txt",
722            "sed --expression='w out' notes.txt",
723        ] {
724            let reason = assert_refused(cmd, &cwd);
725            assert!(reason.contains("w, r and e"), "{cmd}: {reason}");
726        }
727    }
728
729    #[test]
730    fn with_a_script_file_every_sed_operand_is_a_path() {
731        let cwd = lesson_cwd();
732        // /var/x and /etc/p would read as harmless sed commands (x, p): they
733        // are files here, because -f already gave the script
734        for cmd in [
735            "sed -f script.sed /var/x",
736            "sed -f script.sed /etc/p",
737            "sed -i -f script.sed /var/x",
738            "sed --file script.sed /var/x",
739        ] {
740            assert_refused(cmd, &cwd);
741        }
742        assert_allowed("sed -f script.sed notes.txt", &cwd);
743    }
744
745    #[test]
746    fn files_given_to_sed_are_still_checked() {
747        let cwd = lesson_cwd();
748        assert_refused("sed '/x/d' /etc/passwd", &cwd);
749        assert_refused("sed -f /etc/script.sed notes.txt", &cwd);
750        assert_refused("sed -i '/x/d' ../../../etc/hosts", &cwd);
751        // a script the guard doesn't understand is checked as a path
752        assert_refused("sed '/x/{w /etc/y}' notes.txt", &cwd);
753        // only sed's script is spared: other commands' /words are paths
754        assert_refused("cat /debug/d", &cwd);
755        assert_refused("echo hi; cat /etc/passwd '/debug/d'", &cwd);
756    }
757
758    // --- Everyday lesson commands pass through untouched ---
759
760    #[test]
761    fn test_allows_common_lesson_commands() {
762        let cwd = lesson_cwd();
763        for cmd in [
764            "pwd",
765            "ls",
766            "ls -lh",
767            "ls -a",
768            "mkdir practice",
769            "touch test.txt",
770            "cp test.txt test-backup.txt",
771            "mv test-backup.txt backup.txt",
772            "rm -i test.txt",
773            "cat notes.txt",
774            "grep http server.log",
775            "grep -i SSH server.log",
776            "head -n 5 server.log",
777            "tail server.log",
778            "wc -l access.log",
779            "echo hello > greeting.txt",
780            "echo more >> greeting.txt",
781            "cut -d':' -f1 fruits.txt | sort | uniq -c",
782            "ls -l | grep txt",
783            "cat \"my file.txt\"",
784        ] {
785            assert_allowed(cmd, &cwd);
786        }
787    }
788
789    #[test]
790    fn test_allows_dotdot_within_budget() {
791        // depth 1: one level up is still inside the playground
792        assert_allowed("cd ..", &lesson_cwd());
793        assert_allowed("cat ../notes.txt", &lesson_cwd());
794        assert_allowed("ls ..", &lesson_cwd());
795        // depth 2: two independent climbs of one level each
796        let deep = lesson_cwd().join("sub");
797        assert_allowed("cd .. && cat ../notes.txt", &deep);
798    }
799
800    #[test]
801    fn test_allows_absolute_paths_inside_playground() {
802        assert_allowed(
803            "cat /home/user/ArcAcademy/playground/file-mgmt/notes.txt",
804            &lesson_cwd(),
805        );
806        assert_allowed("ls ~/ArcAcademy/playground/file-mgmt", &lesson_cwd());
807    }
808
809    // --- Path escapes are refused with a friendly message ---
810
811    #[test]
812    fn test_refuses_absolute_paths_outside_playground() {
813        let reason = assert_refused("cat /etc/passwd", &lesson_cwd());
814        assert!(reason.contains("~/ArcAcademy/playground"), "reason: {reason}");
815        assert_refused("ls /", &lesson_cwd());
816        assert_refused("rm /tmp/somefile", &lesson_cwd());
817        assert_refused("echo x > /tmp/evil", &lesson_cwd());
818    }
819
820    #[test]
821    fn test_refuses_quoted_absolute_paths() {
822        assert_refused("cat \"/etc/passwd\"", &lesson_cwd());
823        assert_refused("cat '/etc/shadow'", &lesson_cwd());
824    }
825
826    #[test]
827    fn test_refuses_absolute_path_inside_root_that_climbs_out() {
828        // Lexically inside the playground prefix, but the .. escapes it
829        assert_refused(
830            "cat /home/user/ArcAcademy/playground/../../.ssh/id_rsa",
831            &lesson_cwd(),
832        );
833    }
834
835    #[test]
836    fn test_refuses_tilde_outside_playground() {
837        let reason = assert_refused("ls ~", &lesson_cwd());
838        assert!(reason.contains("~/ArcAcademy/playground"));
839        assert_refused("cd ~/Documents", &lesson_cwd());
840        assert_refused("cat ~/.bashrc", &lesson_cwd());
841        assert_refused("rm -rf ~", &lesson_cwd());
842    }
843
844    #[test]
845    fn test_refuses_dotdot_escape() {
846        // depth 0: any climb escapes
847        assert_refused("cd ..", &root_cwd());
848        assert_refused("cat ../secrets.txt", &root_cwd());
849        // depth 1: two levels climbs out
850        assert_refused("cat ../../outside.txt", &lesson_cwd());
851        // combined climb across a compound command
852        assert_refused("cd .. && rm -rf ../important", &lesson_cwd());
853        // sneaky mixed form
854        assert_refused("cat foo/../../../etc/hostname", &lesson_cwd());
855    }
856
857    #[test]
858    fn test_refuses_env_var_tricks() {
859        let reason = assert_refused("cat $HOME/.ssh/id_rsa", &lesson_cwd());
860        assert!(reason.to_lowercase().contains("environment"));
861        assert_refused("cd $HOME", &lesson_cwd());
862        assert_refused("ls ${HOME}", &lesson_cwd());
863        assert_refused("echo $PATH", &lesson_cwd());
864    }
865
866    #[test]
867    fn test_refuses_command_substitution() {
868        assert_refused("cat $(echo /etc/passwd)", &lesson_cwd());
869        assert_refused("cat `echo /etc/passwd`", &lesson_cwd());
870    }
871
872    #[test]
873    fn test_refuses_unbalanced_quotes() {
874        assert_refused("cat \"unclosed", &lesson_cwd());
875    }
876
877    // --- Catastrophic denylist, refused regardless of path rules ---
878
879    #[test]
880    fn test_refuses_rm_rf_root_with_explanation() {
881        let reason = assert_refused("rm -rf /", &lesson_cwd());
882        assert!(reason.contains("no undo"), "reason: {reason}");
883        assert_refused("rm -fr /", &lesson_cwd());
884        assert_refused("rm -r -f /", &lesson_cwd());
885        assert_refused("rm -rf /*", &lesson_cwd());
886        assert_refused("rm --recursive --force /", &lesson_cwd());
887    }
888
889    #[test]
890    fn test_refuses_mkfs() {
891        let reason = assert_refused("mkfs /dev/sda1", &lesson_cwd());
892        assert!(reason.contains("format"), "reason: {reason}");
893        assert_refused("mkfs.ext4 /dev/sda1", &lesson_cwd());
894    }
895
896    #[test]
897    fn test_refuses_dd_to_device() {
898        let reason = assert_refused("dd if=image.iso of=/dev/sda", &lesson_cwd());
899        assert!(reason.contains("raw disk"), "reason: {reason}");
900    }
901
902    #[test]
903    fn test_refuses_fork_bomb() {
904        let reason = assert_refused(":(){ :|:& };:", &lesson_cwd());
905        assert!(reason.contains("fork bomb"), "reason: {reason}");
906        assert_refused(":(){:|:&};:", &lesson_cwd());
907    }
908
909    #[test]
910    fn test_refuses_chmod_777_root() {
911        let reason = assert_refused("chmod -R 777 /", &lesson_cwd());
912        assert!(reason.contains("security"), "reason: {reason}");
913    }
914
915    #[test]
916    fn test_refuses_redirect_to_dev() {
917        let reason = assert_refused("echo garbage > /dev/sda", &lesson_cwd());
918        assert!(reason.contains("/dev"), "reason: {reason}");
919        assert_refused("cat file >/dev/sdb1", &lesson_cwd());
920        assert_refused("ls > /dev/null; echo x >> /dev/sda", &lesson_cwd());
921    }
922
923    #[test]
924    fn test_allows_dev_null() {
925        for ok in ["cat missing.txt 2>/dev/null", "ls > /dev/null", "grep x f >/dev/null 2>&1"] {
926            assert!(
927                matches!(PlaygroundGuard::check_catastrophic(ok), GuardVerdict::Allow),
928                "{ok} should be allowed"
929            );
930        }
931    }
932
933    #[test]
934    fn test_refuses_sudo_anything() {
935        let reason = assert_refused("sudo ls", &lesson_cwd());
936        assert!(reason.contains("administrator"), "reason: {reason}");
937        assert_refused("sudo rm -rf /tmp", &lesson_cwd());
938        assert_refused("sudo apt install cowsay", &lesson_cwd());
939        // sudo hidden behind a separator
940        assert_refused("ls; sudo whoami", &lesson_cwd());
941        assert_refused("ls && sudo whoami", &lesson_cwd());
942        assert_refused("ls | sudo tee /etc/x", &lesson_cwd());
943    }
944
945    #[test]
946    fn test_empty_command_is_allowed() {
947        assert_allowed("", &lesson_cwd());
948        assert_allowed("   ", &lesson_cwd());
949    }
950
951    #[test]
952    fn test_refuses_tilde_path_that_climbs_out() {
953        let reason = assert_refused(
954            "cat ~/ArcAcademy/playground/../../.ssh/id_rsa",
955            &lesson_cwd(),
956        );
957        assert!(reason.contains("~/ArcAcademy/playground"), "reason: {reason}");
958    }
959
960    #[test]
961    fn test_allows_tilde_path_inside_root() {
962        assert_allowed("ls ~/ArcAcademy/playground", &lesson_cwd());
963        assert_allowed("cat ~/ArcAcademy/playground/nav-basics/notes.txt", &lesson_cwd());
964    }
965
966    #[test]
967    fn test_refuses_tilde_lookalike_prefix() {
968        assert_refused("ls ~/ArcAcademy/playground-evil", &lesson_cwd());
969    }
970
971    #[test]
972    fn test_refuses_destruction_inside_a_chain() {
973        let reason = assert_refused("cd docs && rm -rf /", &lesson_cwd());
974        assert!(reason.contains("no undo"), "reason: {reason}");
975        assert!(!PlaygroundGuard::check_catastrophic("true; rm -r /*").is_allowed());
976    }
977
978    #[test]
979    fn test_refuses_recursive_delete_of_home() {
980        for cmd in ["rm -rf ~", "rm -r ~/", "rm -rf $HOME", "rm -fr ~/*", "rm -R ${HOME}"] {
981            let verdict = PlaygroundGuard::check_catastrophic(cmd);
982            match verdict {
983                GuardVerdict::Refuse { reason } => {
984                    assert!(reason.contains("home folder"), "{cmd}: {reason}")
985                }
986                GuardVerdict::Allow => panic!("{cmd} should be refused"),
987            }
988        }
989    }
990
991    #[test]
992    fn test_refuses_rm_r_root_without_force() {
993        assert!(!PlaygroundGuard::check_catastrophic("rm -r /").is_allowed());
994        assert!(!PlaygroundGuard::check_catastrophic("rm -rf --no-preserve-root /").is_allowed());
995    }
996
997    #[test]
998    fn test_catastrophic_check_allows_everyday_commands() {
999        for cmd in [
1000            "ls -la /etc",
1001            "rm notes.txt",
1002            "rm -r build",
1003            "cat ~/.zshrc",
1004            "echo $HOME",
1005            "grep -r TODO .",
1006            "chmod 644 index.html",
1007        ] {
1008            assert!(
1009                PlaygroundGuard::check_catastrophic(cmd).is_allowed(),
1010                "{cmd} should be allowed"
1011            );
1012        }
1013    }
1014
1015    #[test]
1016    fn test_messages_name_the_configured_root() {
1017        let guard = PlaygroundGuard::with_display(root_cwd(), "~/ArcAcademy/home");
1018        match guard.check("cat /etc/hosts", &root_cwd()) {
1019            GuardVerdict::Refuse { reason } => {
1020                assert!(reason.contains("~/ArcAcademy/home"), "reason: {reason}")
1021            }
1022            GuardVerdict::Allow => panic!("should be refused"),
1023        }
1024    }
1025
1026    #[test]
1027    fn test_quotes_do_not_hide_catastrophic_commands() {
1028        for cmd in [
1029            // a quoted ; or | used to make the segment fail to tokenize
1030            "rm -rf ~ ';'",
1031            "rm -rf / '|'",
1032            "rm -rf \"$HOME\" '&&'",
1033            // quoting the program or the target
1034            "'sudo' ls",
1035            "true; \"sudo\" ls",
1036            "dd if=x 'of=/dev/disk2'",
1037            "echo x > '/dev/disk2'",
1038            "echo x >> \"/dev/sda\"",
1039            "'mkfs.ext4' disk.img",
1040        ] {
1041            assert!(
1042                !PlaygroundGuard::check_catastrophic(cmd).is_allowed(),
1043                "{cmd} should be refused"
1044            );
1045        }
1046        // quoted ; and | in ordinary commands are still fine
1047        for cmd in ["grep -E 'a|b' notes.txt", "echo 'one; two'", "echo x > /dev/null"] {
1048            assert!(PlaygroundGuard::check_catastrophic(cmd).is_allowed(), "{cmd} should be allowed");
1049        }
1050    }
1051
1052    #[test]
1053    fn test_refuses_paths_after_equals() {
1054        let cwd = lesson_cwd();
1055        assert_refused("dd if=notes.txt of=/etc/x", &cwd);
1056        assert_refused("cp notes.txt --target-directory=../..", &cwd);
1057        assert_refused("tar --file=~/stuff.tar -c notes", &cwd);
1058        assert_allowed("ls --color=auto", &cwd);
1059        assert_allowed("sort --output=sorted.txt notes.txt", &cwd);
1060    }
1061
1062    #[test]
1063    fn test_expanded_lines_may_carry_quoted_dollars() {
1064        let cwd = lesson_cwd();
1065        // the app's own rendering: $ only inside single quotes, or "$?"
1066        let line = "echo 'echo $1' > 'greet.sh'";
1067        assert!(!guard().check(line, &cwd).is_allowed());
1068        assert!(guard().check_expanded(line, &cwd).is_allowed());
1069        assert!(guard().check_expanded("false; echo 'status '\"$?\"", &cwd).is_allowed());
1070        // still contained, still no command substitution
1071        assert!(!guard().check_expanded("cat '/etc/hosts'", &cwd).is_allowed());
1072        assert!(!guard().check_expanded("echo '$(date)'", &cwd).is_allowed());
1073        assert!(!guard().check_expanded("rm -rf '/'", &cwd).is_allowed());
1074    }
1075
1076    #[test]
1077    fn test_expanded_lines_must_keep_the_contract() {
1078        let cwd = lesson_cwd();
1079        // hand-made lines a render bug could produce: a $ the shell would fill in
1080        for line in [
1081            "echo $HOME",
1082            "echo \"$HOME\"",
1083            "echo 'a'$X'b'",
1084            "echo \\$HOME",
1085            "echo \"$?x\"",
1086            "echo \"$? \"",
1087            "cat ${F}",
1088            "echo 'ok' $",
1089        ] {
1090            assert!(!guard().check_expanded(line, &cwd).is_allowed(), "{line} should be refused");
1091        }
1092        for line in ["echo '$HOME'", "echo 'it'\\''s $5'", "false; echo \"$?\"", "echo 'a'\"$?\"'b'"] {
1093            assert!(guard().check_expanded(line, &cwd).is_allowed(), "{line} should be allowed");
1094        }
1095    }
1096}