1use std::path::{Component, Path, PathBuf};
22
23pub const PLAYGROUND_DISPLAY: &str = "~/ArcAcademy/playground";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum GuardVerdict {
29 Allow,
31 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
47pub struct PlaygroundGuard {
49 root: PathBuf,
51 display: String,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57enum SedReach {
58 Harmless,
60 FilesOrPrograms,
62 Unknown,
64}
65
66fn sed_script_reach(script: &str) -> SedReach {
70 let chars: Vec<char> = script.chars().collect();
71 let mut i = 0;
72 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 pub fn new(root: impl Into<PathBuf>) -> Self {
167 Self::with_display(root, PLAYGROUND_DISPLAY)
168 }
169
170 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 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 pub fn check(&self, command: &str, cwd: &Path) -> GuardVerdict {
196 self.check_line(command, cwd, false)
197 }
198
199 pub fn check_expanded(&self, command: &str, cwd: &Path) -> GuardVerdict {
205 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 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 rest = &rest[2..];
228 continue;
229 } else if c == '\\' {
230 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 if let Some(verdict) = Self::check_denylist(trimmed) {
253 return verdict;
254 }
255
256 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 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 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 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 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 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 total_climb += Self::upward_climb(Path::new(token));
368 }
369
370 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 fn check_denylist(command: &str) -> Option<GuardVerdict> {
389 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 let mut segments: Vec<String> = command
404 .split([';', '|', '&'])
405 .map(str::to_string)
406 .collect();
407 segments.extend(Self::split_unquoted(command));
408
409 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 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 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 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 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 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 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 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 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 found.push((j, script));
601 saw_expression = true;
602 } else if t == "-f" || t == "--file" {
603 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 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 fn lesson_cwd() -> PathBuf {
671 PathBuf::from("/home/user/ArcAcademy/playground/file-mgmt")
672 }
673
674 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 #[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 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 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 assert_refused("sed '/x/{w /etc/y}' notes.txt", &cwd);
753 assert_refused("cat /debug/d", &cwd);
755 assert_refused("echo hi; cat /etc/passwd '/debug/d'", &cwd);
756 }
757
758 #[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 assert_allowed("cd ..", &lesson_cwd());
793 assert_allowed("cat ../notes.txt", &lesson_cwd());
794 assert_allowed("ls ..", &lesson_cwd());
795 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 #[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 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 assert_refused("cd ..", &root_cwd());
848 assert_refused("cat ../secrets.txt", &root_cwd());
849 assert_refused("cat ../../outside.txt", &lesson_cwd());
851 assert_refused("cd .. && rm -rf ../important", &lesson_cwd());
853 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 #[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 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 "rm -rf ~ ';'",
1031 "rm -rf / '|'",
1032 "rm -rf \"$HOME\" '&&'",
1033 "'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 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 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 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 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}