1use std::path::{Component, Path, PathBuf};
21
22pub const PLAYGROUND_DISPLAY: &str = "~/ArcAcademy/playground";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum GuardVerdict {
28 Allow,
30 Refuse { reason: String },
32}
33
34impl GuardVerdict {
35 pub fn is_allowed(&self) -> bool {
36 matches!(self, GuardVerdict::Allow)
37 }
38
39 fn refuse(reason: impl Into<String>) -> Self {
40 GuardVerdict::Refuse {
41 reason: reason.into(),
42 }
43 }
44}
45
46pub struct PlaygroundGuard {
48 root: PathBuf,
50}
51
52impl PlaygroundGuard {
53 pub fn new(root: impl Into<PathBuf>) -> Self {
55 Self { root: root.into() }
56 }
57
58 pub fn check(&self, command: &str, cwd: &Path) -> GuardVerdict {
64 let trimmed = command.trim();
65 if trimmed.is_empty() {
66 return GuardVerdict::Allow;
67 }
68
69 if let Some(verdict) = Self::check_denylist(trimmed) {
73 return verdict;
74 }
75
76 if trimmed.contains('`') || trimmed.contains("$(") {
78 return GuardVerdict::refuse(
79 "Command substitution (backticks or $(...)) runs a hidden inner command, \
80 so there's no way to check where it will reach. In practice mode, type \
81 the command directly instead.",
82 );
83 }
84 if trimmed.contains('$') {
85 return GuardVerdict::refuse(
86 "Environment variables like $HOME or $PATH can expand to locations outside \
87 the playground, so commands using '$' are blocked in practice mode. Try \
88 typing the actual path or value instead.",
89 );
90 }
91
92 let tokens = match shellwords::split(trimmed) {
94 Ok(tokens) => tokens,
95 Err(_) => {
96 return GuardVerdict::refuse(
97 "That command has unbalanced quotes, so it can't be checked safely. \
98 Close the quote and try again.",
99 );
100 }
101 };
102
103 let mut total_climb: usize = 0;
105 for raw_token in &tokens {
106 let token = {
109 let digits_stripped = raw_token.trim_start_matches(|c: char| c.is_ascii_digit());
110 if digits_stripped.starts_with(['<', '>', '&']) {
111 digits_stripped.trim_start_matches(['<', '>', '&'])
112 } else {
113 raw_token.as_str()
114 }
115 };
116 if token.is_empty() {
117 continue;
118 }
119
120 if token.contains('~') {
122 if token.starts_with("~/ArcAcademy/playground") {
123 continue;
124 }
125 return GuardVerdict::refuse(format!(
126 "In practice mode, commands stay inside {PLAYGROUND_DISPLAY} — '~' points \
127 at your real home directory. Try a relative path instead."
128 ));
129 }
130
131 if token.starts_with('/') {
133 match Self::lexical_normalize(Path::new(token)) {
134 Some(normalized) if normalized.starts_with(&self.root) => continue,
135 _ => {
136 return GuardVerdict::refuse(format!(
137 "In practice mode, commands stay inside {PLAYGROUND_DISPLAY} — \
138 '{token}' points outside it. Try a relative path instead."
139 ));
140 }
141 }
142 }
143
144 total_climb += Self::upward_climb(Path::new(token));
146 }
147
148 let depth = cwd
150 .strip_prefix(&self.root)
151 .map(|rel| rel.components().count())
152 .unwrap_or(0);
153 if total_climb > depth {
154 return GuardVerdict::refuse(format!(
155 "That command uses enough '..' to climb out of {PLAYGROUND_DISPLAY}. In \
156 practice mode, commands stay inside the playground — try a relative path."
157 ));
158 }
159
160 GuardVerdict::Allow
161 }
162
163 fn check_denylist(command: &str) -> Option<GuardVerdict> {
166 let squashed: String = command.chars().filter(|c| !c.is_whitespace()).collect();
168 if squashed.contains(":(){") || squashed.contains(":|:&") {
169 return Some(GuardVerdict::refuse(
170 "That's a fork bomb — a tiny function that endlessly launches copies of \
171 itself until the computer runs out of memory and freezes. It looks like \
172 line noise, which is exactly why people get tricked into running it.",
173 ));
174 }
175
176 for segment in command.split(|c| c == ';' || c == '|' || c == '&') {
178 if segment.split_whitespace().next() == Some("sudo") {
179 return Some(GuardVerdict::refuse(
180 "'sudo' runs a command with full administrator power, where a single \
181 typo can change or delete critical system files. Practicing in the \
182 playground never needs admin rights, so sudo is blocked here.",
183 ));
184 }
185 }
186
187 if command
189 .split_whitespace()
190 .any(|t| t == "mkfs" || t.starts_with("mkfs."))
191 {
192 return Some(GuardVerdict::refuse(
193 "'mkfs' formats a disk partition, instantly erasing every file on it. \
194 It's used to prepare brand-new drives — never something to practice with.",
195 ));
196 }
197
198 if command.split_whitespace().any(|t| t.starts_with("of=/dev/")) {
200 return Some(GuardVerdict::refuse(
201 "'dd' writing to a /dev device bypasses the filesystem and overwrites the \
202 raw disk underneath your files — it can wipe an entire drive in seconds \
203 with no confirmation and no undo.",
204 ));
205 }
206
207 if command.contains('>') && command.contains("/dev/") {
209 return Some(GuardVerdict::refuse(
210 "Redirecting output into /dev writes bytes directly to a hardware device. \
211 Aimed at a disk like /dev/sda, that corrupts the filesystem and destroys \
212 data immediately.",
213 ));
214 }
215
216 if let Ok(tokens) = shellwords::split(command) {
218 if tokens.first().map(String::as_str) == Some("rm") {
219 let mut recursive = false;
220 let mut force = false;
221 let mut targets_root = false;
222 for t in &tokens[1..] {
223 if t.starts_with('-') && !t.starts_with("--") {
224 recursive |= t.contains('r') || t.contains('R');
225 force |= t.contains('f');
226 } else if t == "--recursive" {
227 recursive = true;
228 } else if t == "--force" {
229 force = true;
230 } else if t == "/" || t == "/*" {
231 targets_root = true;
232 }
233 }
234 if recursive && force && targets_root {
235 return Some(GuardVerdict::refuse(
236 "'rm -rf /' recursively force-deletes EVERYTHING starting from the \
237 root of the filesystem — the operating system, your applications, \
238 and all your files — with no confirmation and no undo. This is the \
239 single most destructive command on a Unix system.",
240 ));
241 }
242 }
243
244 if tokens.first().map(String::as_str) == Some("chmod") {
246 let recursive = tokens.iter().any(|t| t == "-R" || t == "-r");
247 let mode_777 = tokens.iter().any(|t| t == "777");
248 let targets_root = tokens.iter().any(|t| t == "/" || t == "/*");
249 if recursive && mode_777 && targets_root {
250 return Some(GuardVerdict::refuse(
251 "'chmod -R 777 /' makes every file on the system readable, writable \
252 and executable by everyone — a massive security hole that also \
253 breaks programs that refuse to run with unsafe permissions.",
254 ));
255 }
256 }
257 }
258
259 None
260 }
261
262 fn lexical_normalize(path: &Path) -> Option<PathBuf> {
265 let mut out = PathBuf::from("/");
266 for component in path.components() {
267 match component {
268 Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
269 Component::ParentDir => {
270 if !out.pop() {
271 return None;
272 }
273 }
274 Component::Normal(part) => out.push(part),
275 }
276 }
277 Some(out)
278 }
279
280 fn upward_climb(path: &Path) -> usize {
285 let mut depth: isize = 0;
286 let mut min_depth: isize = 0;
287 for component in path.components() {
288 match component {
289 Component::ParentDir => {
290 depth -= 1;
291 min_depth = min_depth.min(depth);
292 }
293 Component::Normal(_) => depth += 1,
294 _ => {}
295 }
296 }
297 (-min_depth).max(0) as usize
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 fn guard() -> PlaygroundGuard {
306 PlaygroundGuard::new("/home/user/ArcAcademy/playground")
307 }
308
309 fn lesson_cwd() -> PathBuf {
311 PathBuf::from("/home/user/ArcAcademy/playground/file-mgmt")
312 }
313
314 fn root_cwd() -> PathBuf {
316 PathBuf::from("/home/user/ArcAcademy/playground")
317 }
318
319 fn assert_allowed(cmd: &str, cwd: &Path) {
320 let verdict = guard().check(cmd, cwd);
321 assert!(verdict.is_allowed(), "expected Allow for {cmd:?}, got {verdict:?}");
322 }
323
324 fn assert_refused(cmd: &str, cwd: &Path) -> String {
325 match guard().check(cmd, cwd) {
326 GuardVerdict::Refuse { reason } => reason,
327 GuardVerdict::Allow => panic!("expected Refuse for {cmd:?}, got Allow"),
328 }
329 }
330
331 #[test]
334 fn test_allows_common_lesson_commands() {
335 let cwd = lesson_cwd();
336 for cmd in [
337 "pwd",
338 "ls",
339 "ls -lh",
340 "ls -a",
341 "mkdir practice",
342 "touch test.txt",
343 "cp test.txt test-backup.txt",
344 "mv test-backup.txt backup.txt",
345 "rm -i test.txt",
346 "cat notes.txt",
347 "grep http server.log",
348 "grep -i SSH server.log",
349 "head -n 5 server.log",
350 "tail server.log",
351 "wc -l access.log",
352 "echo hello > greeting.txt",
353 "echo more >> greeting.txt",
354 "cut -d':' -f1 fruits.txt | sort | uniq -c",
355 "ls -l | grep txt",
356 "cat \"my file.txt\"",
357 ] {
358 assert_allowed(cmd, &cwd);
359 }
360 }
361
362 #[test]
363 fn test_allows_dotdot_within_budget() {
364 assert_allowed("cd ..", &lesson_cwd());
366 assert_allowed("cat ../notes.txt", &lesson_cwd());
367 assert_allowed("ls ..", &lesson_cwd());
368 let deep = lesson_cwd().join("sub");
370 assert_allowed("cd .. && cat ../notes.txt", &deep);
371 }
372
373 #[test]
374 fn test_allows_absolute_paths_inside_playground() {
375 assert_allowed(
376 "cat /home/user/ArcAcademy/playground/file-mgmt/notes.txt",
377 &lesson_cwd(),
378 );
379 assert_allowed("ls ~/ArcAcademy/playground/file-mgmt", &lesson_cwd());
380 }
381
382 #[test]
385 fn test_refuses_absolute_paths_outside_playground() {
386 let reason = assert_refused("cat /etc/passwd", &lesson_cwd());
387 assert!(reason.contains("~/ArcAcademy/playground"), "reason: {reason}");
388 assert_refused("ls /", &lesson_cwd());
389 assert_refused("rm /tmp/somefile", &lesson_cwd());
390 assert_refused("echo x > /tmp/evil", &lesson_cwd());
391 }
392
393 #[test]
394 fn test_refuses_quoted_absolute_paths() {
395 assert_refused("cat \"/etc/passwd\"", &lesson_cwd());
396 assert_refused("cat '/etc/shadow'", &lesson_cwd());
397 }
398
399 #[test]
400 fn test_refuses_absolute_path_inside_root_that_climbs_out() {
401 assert_refused(
403 "cat /home/user/ArcAcademy/playground/../../.ssh/id_rsa",
404 &lesson_cwd(),
405 );
406 }
407
408 #[test]
409 fn test_refuses_tilde_outside_playground() {
410 let reason = assert_refused("ls ~", &lesson_cwd());
411 assert!(reason.contains("~/ArcAcademy/playground"));
412 assert_refused("cd ~/Documents", &lesson_cwd());
413 assert_refused("cat ~/.bashrc", &lesson_cwd());
414 assert_refused("rm -rf ~", &lesson_cwd());
415 }
416
417 #[test]
418 fn test_refuses_dotdot_escape() {
419 assert_refused("cd ..", &root_cwd());
421 assert_refused("cat ../secrets.txt", &root_cwd());
422 assert_refused("cat ../../outside.txt", &lesson_cwd());
424 assert_refused("cd .. && rm -rf ../important", &lesson_cwd());
426 assert_refused("cat foo/../../../etc/hostname", &lesson_cwd());
428 }
429
430 #[test]
431 fn test_refuses_env_var_tricks() {
432 let reason = assert_refused("cat $HOME/.ssh/id_rsa", &lesson_cwd());
433 assert!(reason.to_lowercase().contains("environment"));
434 assert_refused("cd $HOME", &lesson_cwd());
435 assert_refused("ls ${HOME}", &lesson_cwd());
436 assert_refused("echo $PATH", &lesson_cwd());
437 }
438
439 #[test]
440 fn test_refuses_command_substitution() {
441 assert_refused("cat $(echo /etc/passwd)", &lesson_cwd());
442 assert_refused("cat `echo /etc/passwd`", &lesson_cwd());
443 }
444
445 #[test]
446 fn test_refuses_unbalanced_quotes() {
447 assert_refused("cat \"unclosed", &lesson_cwd());
448 }
449
450 #[test]
453 fn test_refuses_rm_rf_root_with_explanation() {
454 let reason = assert_refused("rm -rf /", &lesson_cwd());
455 assert!(reason.contains("no undo"), "reason: {reason}");
456 assert_refused("rm -fr /", &lesson_cwd());
457 assert_refused("rm -r -f /", &lesson_cwd());
458 assert_refused("rm -rf /*", &lesson_cwd());
459 assert_refused("rm --recursive --force /", &lesson_cwd());
460 }
461
462 #[test]
463 fn test_refuses_mkfs() {
464 let reason = assert_refused("mkfs /dev/sda1", &lesson_cwd());
465 assert!(reason.contains("format"), "reason: {reason}");
466 assert_refused("mkfs.ext4 /dev/sda1", &lesson_cwd());
467 }
468
469 #[test]
470 fn test_refuses_dd_to_device() {
471 let reason = assert_refused("dd if=image.iso of=/dev/sda", &lesson_cwd());
472 assert!(reason.contains("raw disk"), "reason: {reason}");
473 }
474
475 #[test]
476 fn test_refuses_fork_bomb() {
477 let reason = assert_refused(":(){ :|:& };:", &lesson_cwd());
478 assert!(reason.contains("fork bomb"), "reason: {reason}");
479 assert_refused(":(){:|:&};:", &lesson_cwd());
480 }
481
482 #[test]
483 fn test_refuses_chmod_777_root() {
484 let reason = assert_refused("chmod -R 777 /", &lesson_cwd());
485 assert!(reason.contains("security"), "reason: {reason}");
486 }
487
488 #[test]
489 fn test_refuses_redirect_to_dev() {
490 let reason = assert_refused("echo garbage > /dev/sda", &lesson_cwd());
491 assert!(reason.contains("/dev"), "reason: {reason}");
492 assert_refused("cat file >/dev/sdb1", &lesson_cwd());
493 }
494
495 #[test]
496 fn test_refuses_sudo_anything() {
497 let reason = assert_refused("sudo ls", &lesson_cwd());
498 assert!(reason.contains("administrator"), "reason: {reason}");
499 assert_refused("sudo rm -rf /tmp", &lesson_cwd());
500 assert_refused("sudo apt install cowsay", &lesson_cwd());
501 assert_refused("ls; sudo whoami", &lesson_cwd());
503 assert_refused("ls && sudo whoami", &lesson_cwd());
504 assert_refused("ls | sudo tee /etc/x", &lesson_cwd());
505 }
506
507 #[test]
508 fn test_empty_command_is_allowed() {
509 assert_allowed("", &lesson_cwd());
510 assert_allowed(" ", &lesson_cwd());
511 }
512}