use std::path::{Component, Path, PathBuf};
pub const PLAYGROUND_DISPLAY: &str = "~/ArcAcademy/playground";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuardVerdict {
Allow,
Refuse { reason: String },
}
impl GuardVerdict {
pub fn is_allowed(&self) -> bool {
matches!(self, GuardVerdict::Allow)
}
fn refuse(reason: impl Into<String>) -> Self {
GuardVerdict::Refuse {
reason: reason.into(),
}
}
}
pub struct PlaygroundGuard {
root: PathBuf,
display: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SedReach {
Harmless,
FilesOrPrograms,
Unknown,
}
fn sed_script_reach(script: &str) -> SedReach {
let chars: Vec<char> = script.chars().collect();
let mut i = 0;
let read_part = |mut i: usize, delim: char| -> Option<usize> {
while i < chars.len() {
match chars[i] {
'\\' => i += 2,
c if c == delim => return Some(i + 1),
'\n' => return None,
_ => i += 1,
}
}
None
};
let read_address = |i: usize| -> Option<usize> {
match chars.get(i) {
Some(c) if c.is_ascii_digit() => {
let mut j = i;
while chars.get(j).is_some_and(|c| c.is_ascii_digit()) {
j += 1;
}
Some(j)
}
Some('$') => Some(i + 1),
Some('/') => read_part(i + 1, '/'),
Some('\\') => {
let delim = *chars.get(i + 1)?;
read_part(i + 2, delim)
}
_ => Some(i),
}
};
let mut reach = SedReach::Harmless;
loop {
while chars.get(i).is_some_and(|c| c.is_whitespace() || *c == ';') {
i += 1;
}
if i >= chars.len() {
return reach;
}
let Some(after) = read_address(i) else { return SedReach::Unknown };
i = after;
if chars.get(i) == Some(&',') {
let Some(after) = read_address(i + 1) else { return SedReach::Unknown };
if after == i + 1 {
return SedReach::Unknown;
}
i = after;
}
while chars.get(i).is_some_and(|c| c.is_whitespace() || *c == '!') {
i += 1;
}
let Some(&command) = chars.get(i) else { return SedReach::Unknown };
i += 1;
match command {
'w' | 'W' | 'r' | 'R' | 'e' => return SedReach::FilesOrPrograms,
'd' | 'D' | 'p' | 'P' | 'n' | 'N' | 'q' | 'Q' | '=' | 'g' | 'G' | 'h' | 'H'
| 'x' | 'l' | 'z' | '{' | '}' => {}
's' | 'y' => {
let Some(&delim) = chars.get(i) else { return SedReach::Unknown };
if delim == '\\' || delim == '\n' || delim.is_whitespace() {
return SedReach::Unknown;
}
let Some(after) = read_part(i + 1, delim) else { return SedReach::Unknown };
let Some(after) = read_part(after, delim) else { return SedReach::Unknown };
i = after;
if command == 's' {
while let Some(&flag) = chars.get(i) {
match flag {
'w' | 'e' => return SedReach::FilesOrPrograms,
'g' | 'p' | 'i' | 'I' | 'm' | 'M' => i += 1,
c if c.is_ascii_digit() => i += 1,
_ => break,
}
}
}
}
_ => reach = SedReach::Unknown,
}
if reach == SedReach::Unknown {
return reach;
}
while chars.get(i).is_some_and(|c| *c == ' ' || *c == '\t') {
i += 1;
}
match chars.get(i) {
None => return reach,
Some(';') | Some('\n') | Some('}') => {}
Some(_) => return SedReach::Unknown,
}
}
}
impl PlaygroundGuard {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self::with_display(root, PLAYGROUND_DISPLAY)
}
pub fn with_display(root: impl Into<PathBuf>, display: impl Into<String>) -> Self {
Self {
root: root.into(),
display: display.into(),
}
}
pub fn check_catastrophic(command: &str) -> GuardVerdict {
let trimmed = command.trim();
if trimmed.is_empty() {
return GuardVerdict::Allow;
}
Self::check_denylist(trimmed).unwrap_or(GuardVerdict::Allow)
}
pub fn check(&self, command: &str, cwd: &Path) -> GuardVerdict {
self.check_line(command, cwd, false)
}
pub fn check_expanded(&self, command: &str, cwd: &Path) -> GuardVerdict {
if !Self::dollars_are_rendered(command) {
return GuardVerdict::refuse(
"That command still has a '$' the shell would fill in, so it can't be \
checked safely.",
);
}
self.check_line(command, cwd, true)
}
fn dollars_are_rendered(command: &str) -> bool {
let mut rest = command;
let mut quoted = false;
while let Some(c) = rest.chars().next() {
if quoted {
quoted = c != '\'';
} else if c == '\'' {
quoted = true;
} else if rest.starts_with("\\'") {
rest = &rest[2..];
continue;
} else if c == '\\' {
return false;
} else if rest.starts_with("\"$?\"") {
rest = &rest[4..];
continue;
} else if c == '$' {
return false;
}
rest = &rest[c.len_utf8()..];
}
true
}
fn check_line(&self, command: &str, cwd: &Path, expanded: bool) -> GuardVerdict {
let trimmed = command.trim();
if trimmed.is_empty() {
return GuardVerdict::Allow;
}
if let Some(verdict) = Self::check_denylist(trimmed) {
return verdict;
}
if trimmed.contains('`') || trimmed.contains("$(") {
return GuardVerdict::refuse(
"Command substitution (backticks or $(...)) runs a hidden inner command, \
so there's no way to check where it will reach. Type the command \
directly instead.",
);
}
if trimmed.contains('$') && !expanded {
return GuardVerdict::refuse(
"Environment variables like $HOME or $PATH can expand to locations outside \
the folder you're practising in, so commands using '$' are blocked. Try \
typing the actual path or value instead.",
);
}
let tokens = match shellwords::split(trimmed) {
Ok(tokens) => tokens,
Err(_) => {
return GuardVerdict::refuse(
"That command has unbalanced quotes, so it can't be checked safely. \
Close the quote and try again.",
);
}
};
let mut sed_scripts = std::collections::HashSet::new();
for (index, script) in Self::sed_script_tokens(&tokens) {
match sed_script_reach(script) {
SedReach::Harmless => {
sed_scripts.insert(index);
}
SedReach::FilesOrPrograms => {
return GuardVerdict::refuse(
"sed's w, r and e commands write files, read files or run programs \
from inside the script, where the guard can't follow them, so \
they're turned off here. Redirect sed's output with > instead.",
);
}
SedReach::Unknown => {}
}
}
let tokens: Vec<String> = tokens
.into_iter()
.enumerate()
.filter(|(index, _)| !sed_scripts.contains(index))
.map(|(_, token)| token)
.collect();
let mut total_climb: usize = 0;
let candidates = tokens.iter().flat_map(|raw_token| {
let digits_stripped = raw_token.trim_start_matches(|c: char| c.is_ascii_digit());
let token = if digits_stripped.starts_with(['<', '>', '&']) {
digits_stripped.trim_start_matches(['<', '>', '&'])
} else {
raw_token.as_str()
};
let value = token.split_once('=').map(|(_, value)| value);
std::iter::once(token).chain(value)
});
for token in candidates {
if token.is_empty() {
continue;
}
if token.contains('~') {
let inside = self
.display
.strip_prefix("~/")
.and_then(|_| token.strip_prefix(self.display.as_str()))
.filter(|rest| rest.is_empty() || rest.starts_with('/'))
.and_then(|rest| {
let joined = format!("{}{}", self.root.display(), rest);
Self::lexical_normalize(Path::new(&joined))
})
.is_some_and(|normalized| normalized.starts_with(&self.root));
if inside {
continue;
}
return GuardVerdict::refuse(format!(
"Commands here stay inside {} — '~' points at your real home \
directory. Try a relative path instead.",
self.display
));
}
if token.starts_with('/') {
match Self::lexical_normalize(Path::new(token)) {
Some(normalized) if normalized.starts_with(&self.root) => continue,
_ => {
return GuardVerdict::refuse(format!(
"Commands here stay inside {} — '{token}' points outside \
it. Try a relative path instead.",
self.display
));
}
}
}
total_climb += Self::upward_climb(Path::new(token));
}
let depth = cwd
.strip_prefix(&self.root)
.map(|rel| rel.components().count())
.unwrap_or(0);
if total_climb > depth {
return GuardVerdict::refuse(format!(
"That command uses enough '..' to climb out of {}. Commands here stay \
inside it — try a relative path.",
self.display
));
}
GuardVerdict::Allow
}
fn check_denylist(command: &str) -> Option<GuardVerdict> {
let squashed: String = command.chars().filter(|c| !c.is_whitespace()).collect();
if squashed.contains(":(){") || squashed.contains(":|:&") {
return Some(GuardVerdict::refuse(
"That's a fork bomb — a tiny function that endlessly launches copies of \
itself until the computer runs out of memory and freezes. It looks like \
line noise, which is exactly why people get tricked into running it.",
));
}
let mut segments: Vec<String> = command
.split([';', '|', '&'])
.map(str::to_string)
.collect();
segments.extend(Self::split_unquoted(command));
for segment in &segments {
let first = shellwords::split(segment)
.ok()
.and_then(|t| t.into_iter().next())
.or_else(|| segment.split_whitespace().next().map(str::to_string));
if first.as_deref() == Some("sudo") {
return Some(GuardVerdict::refuse(
"'sudo' runs a command with full administrator power, where a single \
typo can change or delete critical system files. arct never needs \
admin rights, so sudo is blocked here; when you truly need it, use \
your own terminal and read the command twice first.",
));
}
}
let words: Vec<String> = command
.split_whitespace()
.map(str::to_string)
.chain(segments.iter().filter_map(|s| shellwords::split(s).ok()).flatten())
.collect();
if words.iter().any(|t| t == "mkfs" || t.starts_with("mkfs.")) {
return Some(GuardVerdict::refuse(
"'mkfs' formats a disk partition, instantly erasing every file on it. \
It's used to prepare brand-new drives — never something to practice with.",
));
}
if words.iter().any(|t| t.starts_with("of=/dev/")) {
return Some(GuardVerdict::refuse(
"'dd' writing to a /dev device bypasses the filesystem and overwrites the \
raw disk underneath your files — it can wipe an entire drive in seconds \
with no confirmation and no undo.",
));
}
const HARMLESS: &[&str] = &["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"];
let into_device =
regex::Regex::new(r#">>?\s*['"]?(/dev/[^\s;|&<>'"]*)"#).expect("valid pattern");
if into_device
.captures_iter(command)
.any(|c| !HARMLESS.contains(&c.get(1).map_or("", |m| m.as_str())))
{
return Some(GuardVerdict::refuse(
"Redirecting output into /dev writes bytes directly to a hardware device. \
Aimed at a disk like /dev/sda, that corrupts the filesystem and destroys \
data immediately.",
));
}
for segment in &segments {
if let Some(verdict) = Self::check_segment(segment.trim()) {
return Some(verdict);
}
}
None
}
fn split_unquoted(command: &str) -> Vec<String> {
let mut segments = vec![String::new()];
let mut quote: Option<char> = None;
let mut chars = command.chars();
while let Some(c) = chars.next() {
match (quote, c) {
(None, '\'' | '"') => quote = Some(c),
(Some(q), _) if c == q => quote = None,
(None | Some('"'), '\\') => {
segments.last_mut().expect("never empty").push(c);
if let Some(next) = chars.next() {
segments.last_mut().expect("never empty").push(next);
}
continue;
}
(None, ';' | '|' | '&') => {
segments.push(String::new());
continue;
}
_ => {}
}
segments.last_mut().expect("never empty").push(c);
}
segments
}
fn check_segment(segment: &str) -> Option<GuardVerdict> {
let tokens = shellwords::split(segment).ok()?;
let program = tokens.first().map(String::as_str)?;
let is_root = |t: &str| matches!(t, "/" | "/*");
let is_home = |t: &str| {
matches!(
t.trim_end_matches('/'),
"~" | "~/*" | "$HOME" | "${HOME}" | "$HOME/*" | "${HOME}/*"
)
};
if program == "rm" {
let mut recursive = false;
let mut no_preserve_root = false;
let mut targets_root = false;
let mut targets_home = false;
for t in &tokens[1..] {
if t == "--no-preserve-root" {
no_preserve_root = true;
} else if t == "--recursive" {
recursive = true;
} else if t.starts_with('-') && !t.starts_with("--") {
recursive |= t.contains('r') || t.contains('R');
} else if is_root(t) {
targets_root = true;
} else if is_home(t) {
targets_home = true;
}
}
if recursive && (targets_root || no_preserve_root) {
return Some(GuardVerdict::refuse(
"'rm -rf /' recursively force-deletes EVERYTHING starting from the \
root of the filesystem — the operating system, your applications, \
and all your files — with no confirmation and no undo. This is the \
single most destructive command on a Unix system.",
));
}
if recursive && targets_home {
return Some(GuardVerdict::refuse(
"'rm -r ~' deletes your whole home folder: every document, photo, \
project and setting you own, with no trash can and no undo. To \
clean up, delete one named folder at a time and run 'ls' first to \
see what's inside.",
));
}
}
if program == "chmod" {
let recursive = tokens.iter().any(|t| t == "-R" || t == "-r");
let mode_777 = tokens.iter().any(|t| t == "777");
let targets_root = tokens.iter().any(|t| is_root(t));
if recursive && mode_777 && targets_root {
return Some(GuardVerdict::refuse(
"'chmod -R 777 /' makes every file on the system readable, writable \
and executable by everyone — a massive security hole that also \
breaks programs that refuse to run with unsafe permissions.",
));
}
}
None
}
fn sed_script_tokens(tokens: &[String]) -> Vec<(usize, &str)> {
let ends_command = |t: &str| t.ends_with([';', '|', '&']);
let mut found = Vec::new();
let mut i = 0;
while i < tokens.len() {
let at_command = i == 0 || ends_command(&tokens[i - 1]);
if !(at_command && tokens[i] == "sed") {
i += 1;
continue;
}
let mut j = i + 1;
let mut saw_expression = false;
let mut first_operand = None;
while j < tokens.len() {
let t = tokens[j].as_str();
let last = ends_command(t);
if t == "-e" || t == "--expression" {
if let Some(next) = tokens.get(j + 1) {
found.push((j + 1, next.as_str()));
}
saw_expression = true;
j += 2;
continue;
}
if let Some(script) = t.strip_prefix("--expression=") {
found.push((j, script));
saw_expression = true;
} else if t == "-f" || t == "--file" {
saw_expression = true;
j += 2;
continue;
} else if !t.starts_with('-') && first_operand.is_none() {
first_operand = Some(j);
}
j += 1;
if last {
break;
}
}
if let (false, Some(k)) = (saw_expression, first_operand) {
found.push((k, tokens[k].as_str()));
}
i = j;
}
found
}
fn lexical_normalize(path: &Path) -> Option<PathBuf> {
let mut out = PathBuf::from("/");
for component in path.components() {
match component {
Component::RootDir | Component::Prefix(_) | Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
return None;
}
}
Component::Normal(part) => out.push(part),
}
}
Some(out)
}
fn upward_climb(path: &Path) -> usize {
let mut depth: isize = 0;
let mut min_depth: isize = 0;
for component in path.components() {
match component {
Component::ParentDir => {
depth -= 1;
min_depth = min_depth.min(depth);
}
Component::Normal(_) => depth += 1,
_ => {}
}
}
(-min_depth).max(0) as usize
}
}
#[cfg(test)]
mod tests {
use super::*;
fn guard() -> PlaygroundGuard {
PlaygroundGuard::new("/home/user/ArcAcademy/playground")
}
fn lesson_cwd() -> PathBuf {
PathBuf::from("/home/user/ArcAcademy/playground/file-mgmt")
}
fn root_cwd() -> PathBuf {
PathBuf::from("/home/user/ArcAcademy/playground")
}
fn assert_allowed(cmd: &str, cwd: &Path) {
let verdict = guard().check(cmd, cwd);
assert!(verdict.is_allowed(), "expected Allow for {cmd:?}, got {verdict:?}");
}
fn assert_refused(cmd: &str, cwd: &Path) -> String {
match guard().check(cmd, cwd) {
GuardVerdict::Refuse { reason } => reason,
GuardVerdict::Allow => panic!("expected Refuse for {cmd:?}, got Allow"),
}
}
#[test]
fn sed_scripts_that_only_edit_text_are_not_paths() {
let cwd = lesson_cwd();
for cmd in [
"sed '/debug/d' app.log",
"sed -n '/error/p' app.log",
"sed -e '/a/d' -e 's/x/y/g' notes.txt",
"sed '1,/start/d;s|/usr|/opt|g' notes.txt",
"sed -i.bak '/^#/d' notes.txt",
"grep x f | sed '/debug/d'",
"sed --expression='/debug/d' app.log",
] {
assert_allowed(cmd, &cwd);
}
assert!(guard().check_expanded("sed '/debug/d' 'app.log'", &cwd).is_allowed());
}
#[test]
fn sed_commands_that_reach_files_or_programs_are_refused() {
let cwd = lesson_cwd();
for cmd in [
"sed '/x/w out.txt' notes.txt",
"sed 's/a/b/w /tmp/x' notes.txt",
"sed 's/a/date/e' notes.txt",
"sed '1e date' notes.txt",
"sed '/x/r /etc/passwd' notes.txt",
"ls; sed -n '2R /etc/hosts' notes.txt",
"sed -e 's/a/b/' -e 'W copy' notes.txt",
"sed --expression='w out' notes.txt",
] {
let reason = assert_refused(cmd, &cwd);
assert!(reason.contains("w, r and e"), "{cmd}: {reason}");
}
}
#[test]
fn with_a_script_file_every_sed_operand_is_a_path() {
let cwd = lesson_cwd();
for cmd in [
"sed -f script.sed /var/x",
"sed -f script.sed /etc/p",
"sed -i -f script.sed /var/x",
"sed --file script.sed /var/x",
] {
assert_refused(cmd, &cwd);
}
assert_allowed("sed -f script.sed notes.txt", &cwd);
}
#[test]
fn files_given_to_sed_are_still_checked() {
let cwd = lesson_cwd();
assert_refused("sed '/x/d' /etc/passwd", &cwd);
assert_refused("sed -f /etc/script.sed notes.txt", &cwd);
assert_refused("sed -i '/x/d' ../../../etc/hosts", &cwd);
assert_refused("sed '/x/{w /etc/y}' notes.txt", &cwd);
assert_refused("cat /debug/d", &cwd);
assert_refused("echo hi; cat /etc/passwd '/debug/d'", &cwd);
}
#[test]
fn test_allows_common_lesson_commands() {
let cwd = lesson_cwd();
for cmd in [
"pwd",
"ls",
"ls -lh",
"ls -a",
"mkdir practice",
"touch test.txt",
"cp test.txt test-backup.txt",
"mv test-backup.txt backup.txt",
"rm -i test.txt",
"cat notes.txt",
"grep http server.log",
"grep -i SSH server.log",
"head -n 5 server.log",
"tail server.log",
"wc -l access.log",
"echo hello > greeting.txt",
"echo more >> greeting.txt",
"cut -d':' -f1 fruits.txt | sort | uniq -c",
"ls -l | grep txt",
"cat \"my file.txt\"",
] {
assert_allowed(cmd, &cwd);
}
}
#[test]
fn test_allows_dotdot_within_budget() {
assert_allowed("cd ..", &lesson_cwd());
assert_allowed("cat ../notes.txt", &lesson_cwd());
assert_allowed("ls ..", &lesson_cwd());
let deep = lesson_cwd().join("sub");
assert_allowed("cd .. && cat ../notes.txt", &deep);
}
#[test]
fn test_allows_absolute_paths_inside_playground() {
assert_allowed(
"cat /home/user/ArcAcademy/playground/file-mgmt/notes.txt",
&lesson_cwd(),
);
assert_allowed("ls ~/ArcAcademy/playground/file-mgmt", &lesson_cwd());
}
#[test]
fn test_refuses_absolute_paths_outside_playground() {
let reason = assert_refused("cat /etc/passwd", &lesson_cwd());
assert!(reason.contains("~/ArcAcademy/playground"), "reason: {reason}");
assert_refused("ls /", &lesson_cwd());
assert_refused("rm /tmp/somefile", &lesson_cwd());
assert_refused("echo x > /tmp/evil", &lesson_cwd());
}
#[test]
fn test_refuses_quoted_absolute_paths() {
assert_refused("cat \"/etc/passwd\"", &lesson_cwd());
assert_refused("cat '/etc/shadow'", &lesson_cwd());
}
#[test]
fn test_refuses_absolute_path_inside_root_that_climbs_out() {
assert_refused(
"cat /home/user/ArcAcademy/playground/../../.ssh/id_rsa",
&lesson_cwd(),
);
}
#[test]
fn test_refuses_tilde_outside_playground() {
let reason = assert_refused("ls ~", &lesson_cwd());
assert!(reason.contains("~/ArcAcademy/playground"));
assert_refused("cd ~/Documents", &lesson_cwd());
assert_refused("cat ~/.bashrc", &lesson_cwd());
assert_refused("rm -rf ~", &lesson_cwd());
}
#[test]
fn test_refuses_dotdot_escape() {
assert_refused("cd ..", &root_cwd());
assert_refused("cat ../secrets.txt", &root_cwd());
assert_refused("cat ../../outside.txt", &lesson_cwd());
assert_refused("cd .. && rm -rf ../important", &lesson_cwd());
assert_refused("cat foo/../../../etc/hostname", &lesson_cwd());
}
#[test]
fn test_refuses_env_var_tricks() {
let reason = assert_refused("cat $HOME/.ssh/id_rsa", &lesson_cwd());
assert!(reason.to_lowercase().contains("environment"));
assert_refused("cd $HOME", &lesson_cwd());
assert_refused("ls ${HOME}", &lesson_cwd());
assert_refused("echo $PATH", &lesson_cwd());
}
#[test]
fn test_refuses_command_substitution() {
assert_refused("cat $(echo /etc/passwd)", &lesson_cwd());
assert_refused("cat `echo /etc/passwd`", &lesson_cwd());
}
#[test]
fn test_refuses_unbalanced_quotes() {
assert_refused("cat \"unclosed", &lesson_cwd());
}
#[test]
fn test_refuses_rm_rf_root_with_explanation() {
let reason = assert_refused("rm -rf /", &lesson_cwd());
assert!(reason.contains("no undo"), "reason: {reason}");
assert_refused("rm -fr /", &lesson_cwd());
assert_refused("rm -r -f /", &lesson_cwd());
assert_refused("rm -rf /*", &lesson_cwd());
assert_refused("rm --recursive --force /", &lesson_cwd());
}
#[test]
fn test_refuses_mkfs() {
let reason = assert_refused("mkfs /dev/sda1", &lesson_cwd());
assert!(reason.contains("format"), "reason: {reason}");
assert_refused("mkfs.ext4 /dev/sda1", &lesson_cwd());
}
#[test]
fn test_refuses_dd_to_device() {
let reason = assert_refused("dd if=image.iso of=/dev/sda", &lesson_cwd());
assert!(reason.contains("raw disk"), "reason: {reason}");
}
#[test]
fn test_refuses_fork_bomb() {
let reason = assert_refused(":(){ :|:& };:", &lesson_cwd());
assert!(reason.contains("fork bomb"), "reason: {reason}");
assert_refused(":(){:|:&};:", &lesson_cwd());
}
#[test]
fn test_refuses_chmod_777_root() {
let reason = assert_refused("chmod -R 777 /", &lesson_cwd());
assert!(reason.contains("security"), "reason: {reason}");
}
#[test]
fn test_refuses_redirect_to_dev() {
let reason = assert_refused("echo garbage > /dev/sda", &lesson_cwd());
assert!(reason.contains("/dev"), "reason: {reason}");
assert_refused("cat file >/dev/sdb1", &lesson_cwd());
assert_refused("ls > /dev/null; echo x >> /dev/sda", &lesson_cwd());
}
#[test]
fn test_allows_dev_null() {
for ok in ["cat missing.txt 2>/dev/null", "ls > /dev/null", "grep x f >/dev/null 2>&1"] {
assert!(
matches!(PlaygroundGuard::check_catastrophic(ok), GuardVerdict::Allow),
"{ok} should be allowed"
);
}
}
#[test]
fn test_refuses_sudo_anything() {
let reason = assert_refused("sudo ls", &lesson_cwd());
assert!(reason.contains("administrator"), "reason: {reason}");
assert_refused("sudo rm -rf /tmp", &lesson_cwd());
assert_refused("sudo apt install cowsay", &lesson_cwd());
assert_refused("ls; sudo whoami", &lesson_cwd());
assert_refused("ls && sudo whoami", &lesson_cwd());
assert_refused("ls | sudo tee /etc/x", &lesson_cwd());
}
#[test]
fn test_empty_command_is_allowed() {
assert_allowed("", &lesson_cwd());
assert_allowed(" ", &lesson_cwd());
}
#[test]
fn test_refuses_tilde_path_that_climbs_out() {
let reason = assert_refused(
"cat ~/ArcAcademy/playground/../../.ssh/id_rsa",
&lesson_cwd(),
);
assert!(reason.contains("~/ArcAcademy/playground"), "reason: {reason}");
}
#[test]
fn test_allows_tilde_path_inside_root() {
assert_allowed("ls ~/ArcAcademy/playground", &lesson_cwd());
assert_allowed("cat ~/ArcAcademy/playground/nav-basics/notes.txt", &lesson_cwd());
}
#[test]
fn test_refuses_tilde_lookalike_prefix() {
assert_refused("ls ~/ArcAcademy/playground-evil", &lesson_cwd());
}
#[test]
fn test_refuses_destruction_inside_a_chain() {
let reason = assert_refused("cd docs && rm -rf /", &lesson_cwd());
assert!(reason.contains("no undo"), "reason: {reason}");
assert!(!PlaygroundGuard::check_catastrophic("true; rm -r /*").is_allowed());
}
#[test]
fn test_refuses_recursive_delete_of_home() {
for cmd in ["rm -rf ~", "rm -r ~/", "rm -rf $HOME", "rm -fr ~/*", "rm -R ${HOME}"] {
let verdict = PlaygroundGuard::check_catastrophic(cmd);
match verdict {
GuardVerdict::Refuse { reason } => {
assert!(reason.contains("home folder"), "{cmd}: {reason}")
}
GuardVerdict::Allow => panic!("{cmd} should be refused"),
}
}
}
#[test]
fn test_refuses_rm_r_root_without_force() {
assert!(!PlaygroundGuard::check_catastrophic("rm -r /").is_allowed());
assert!(!PlaygroundGuard::check_catastrophic("rm -rf --no-preserve-root /").is_allowed());
}
#[test]
fn test_catastrophic_check_allows_everyday_commands() {
for cmd in [
"ls -la /etc",
"rm notes.txt",
"rm -r build",
"cat ~/.zshrc",
"echo $HOME",
"grep -r TODO .",
"chmod 644 index.html",
] {
assert!(
PlaygroundGuard::check_catastrophic(cmd).is_allowed(),
"{cmd} should be allowed"
);
}
}
#[test]
fn test_messages_name_the_configured_root() {
let guard = PlaygroundGuard::with_display(root_cwd(), "~/ArcAcademy/home");
match guard.check("cat /etc/hosts", &root_cwd()) {
GuardVerdict::Refuse { reason } => {
assert!(reason.contains("~/ArcAcademy/home"), "reason: {reason}")
}
GuardVerdict::Allow => panic!("should be refused"),
}
}
#[test]
fn test_quotes_do_not_hide_catastrophic_commands() {
for cmd in [
"rm -rf ~ ';'",
"rm -rf / '|'",
"rm -rf \"$HOME\" '&&'",
"'sudo' ls",
"true; \"sudo\" ls",
"dd if=x 'of=/dev/disk2'",
"echo x > '/dev/disk2'",
"echo x >> \"/dev/sda\"",
"'mkfs.ext4' disk.img",
] {
assert!(
!PlaygroundGuard::check_catastrophic(cmd).is_allowed(),
"{cmd} should be refused"
);
}
for cmd in ["grep -E 'a|b' notes.txt", "echo 'one; two'", "echo x > /dev/null"] {
assert!(PlaygroundGuard::check_catastrophic(cmd).is_allowed(), "{cmd} should be allowed");
}
}
#[test]
fn test_refuses_paths_after_equals() {
let cwd = lesson_cwd();
assert_refused("dd if=notes.txt of=/etc/x", &cwd);
assert_refused("cp notes.txt --target-directory=../..", &cwd);
assert_refused("tar --file=~/stuff.tar -c notes", &cwd);
assert_allowed("ls --color=auto", &cwd);
assert_allowed("sort --output=sorted.txt notes.txt", &cwd);
}
#[test]
fn test_expanded_lines_may_carry_quoted_dollars() {
let cwd = lesson_cwd();
let line = "echo 'echo $1' > 'greet.sh'";
assert!(!guard().check(line, &cwd).is_allowed());
assert!(guard().check_expanded(line, &cwd).is_allowed());
assert!(guard().check_expanded("false; echo 'status '\"$?\"", &cwd).is_allowed());
assert!(!guard().check_expanded("cat '/etc/hosts'", &cwd).is_allowed());
assert!(!guard().check_expanded("echo '$(date)'", &cwd).is_allowed());
assert!(!guard().check_expanded("rm -rf '/'", &cwd).is_allowed());
}
#[test]
fn test_expanded_lines_must_keep_the_contract() {
let cwd = lesson_cwd();
for line in [
"echo $HOME",
"echo \"$HOME\"",
"echo 'a'$X'b'",
"echo \\$HOME",
"echo \"$?x\"",
"echo \"$? \"",
"cat ${F}",
"echo 'ok' $",
] {
assert!(!guard().check_expanded(line, &cwd).is_allowed(), "{line} should be refused");
}
for line in ["echo '$HOME'", "echo 'it'\\''s $5'", "false; echo \"$?\"", "echo 'a'\"$?\"'b'"] {
assert!(guard().check_expanded(line, &cwd).is_allowed(), "{line} should be allowed");
}
}
}