Skip to main content

arct_core/
sandbox.rs

1//! The simulated sandbox's shell, with no user interface: what happens to
2//! a typed line before and around each command. Variables, globs,
3//! redirection, pipes, `&&` / `||` / `;`, scripts with arguments, and
4//! `PATH` lookups, all inside the lesson's practice files
5//! ([`VirtualFileSystem`], on disk or in memory). The file and text
6//! commands themselves are in `sandbox_commands.rs`.
7//!
8//! The terminal app and the browser both run lessons through this. A
9//! [`Sandbox`] borrows the state it works on (the shell's variables, the
10//! practice files) for one line at a time, so the host keeps owning them.
11
12use crate::command::CommandAnalyzer;
13use crate::guard::{GuardVerdict, PlaygroundGuard};
14use crate::shell_syntax::{self, Joiner, Pipeline, Redirect, Word};
15use crate::text_filter;
16use crate::virtual_fs::VirtualFileSystem;
17use std::collections::{BTreeMap, BTreeSet};
18
19/// The marks at the start of message lines: errors, warnings, hints and
20/// confirmations, as opposed to a command's own output. The host picks
21/// them (the terminal app can use Nerd Font glyphs); each includes the
22/// space after it.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Marks {
25    pub success: String,
26    pub error: String,
27    pub warning: String,
28    pub info: String,
29    pub hint: String,
30    pub folder: String,
31    pub file: String,
32}
33
34impl Marks {
35    /// Plain one-cell symbols, the terminal app's default
36    pub fn plain() -> Self {
37        Self {
38            success: "✓  ".to_string(),
39            error: "✗  ".to_string(),
40            warning: "!  ".to_string(),
41            info: "i  ".to_string(),
42            hint: "*  ".to_string(),
43            folder: "▸  ".to_string(),
44            file: "·  ".to_string(),
45        }
46    }
47
48    /// Whether output reports a failure: an error line, a guard refusal,
49    /// or a timeout
50    pub fn reports_failure(&self, output: &str) -> bool {
51        output.lines().any(|line| {
52            line.starts_with(self.error.as_str())
53                || line.starts_with(self.warning.as_str())
54                || line.contains("timed out")
55        })
56    }
57
58    /// Lines that are messages about the command (errors, warnings, hints,
59    /// confirmations) rather than its output
60    fn is_message(&self, line: &str) -> bool {
61        [&self.error, &self.warning, &self.hint, &self.success]
62            .iter()
63            .any(|mark| line.starts_with(mark.as_str()))
64    }
65
66    fn is_error(&self, line: &str) -> bool {
67        line.starts_with(self.error.as_str()) || line.starts_with(self.warning.as_str())
68    }
69
70    /// Split a command's printout into (output, errors, notes)
71    fn split_streams(&self, text: &str) -> (String, String, String) {
72        let (mut out, mut err, mut notes) = (String::new(), String::new(), String::new());
73        for line in text.split_inclusive('\n') {
74            if self.is_error(line) {
75                err.push_str(line);
76            } else if self.is_message(line) {
77                notes.push_str(line);
78            } else {
79                out.push_str(line);
80            }
81        }
82        (out, err, notes)
83    }
84
85    /// An error line
86    pub fn error(&self, message: impl std::fmt::Display) -> String {
87        format!("{}{}\n", self.error, message)
88    }
89}
90
91impl Default for Marks {
92    fn default() -> Self {
93        Self::plain()
94    }
95}
96
97/// The sandbox shell's state: variables, script arguments, last status
98#[derive(Debug, Clone)]
99pub struct ShellState {
100    vars: BTreeMap<String, String>,
101    exported: BTreeSet<String>,
102    /// `$1`, `$2`, ... inside a script
103    args: Vec<String>,
104    /// `$?`: 0 when the last command worked
105    status: i32,
106    /// How many scripts deep we are (scripts can't start scripts)
107    depth: usize,
108    /// Set by `exit` inside a script
109    exiting: bool,
110    /// Exit status a command chose that its printout can't show: diff
111    /// finding differences, grep finding nothing (both exit 1 in sh)
112    pending_status: Option<i32>,
113}
114
115impl Default for ShellState {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121impl ShellState {
122    pub fn new() -> Self {
123        let mut shell = Self {
124            vars: BTreeMap::new(),
125            exported: BTreeSet::new(),
126            args: Vec::new(),
127            status: 0,
128            depth: 0,
129            exiting: false,
130            pending_status: None,
131        };
132        for (k, v) in [
133            ("HOME", "/lesson-home"),
134            ("USER", "learner"),
135            ("SHELL", "/bin/sh"),
136            ("PATH", "/usr/local/bin:/usr/bin:/bin"),
137            ("LANG", "en_US.UTF-8"),
138        ] {
139            shell.vars.insert(k.to_string(), v.to_string());
140            shell.exported.insert(k.to_string());
141        }
142        shell
143    }
144
145    /// `$?`: the last command's status
146    pub fn status(&self) -> i32 {
147        self.status
148    }
149
150    /// A shell variable's value
151    pub fn var(&self, name: &str) -> Option<&str> {
152        self.vars.get(name).map(String::as_str)
153    }
154
155    fn lookup(&self, name: &str, pwd: &str) -> Option<String> {
156        match name {
157            "?" => Some(self.status.to_string()),
158            "#" => Some(self.args.len().to_string()),
159            "@" | "*" => Some(self.args.join(" ")),
160            "0" => Some("sh".to_string()),
161            "PWD" => Some(pwd.to_string()),
162            n if n.chars().all(|c| c.is_ascii_digit()) => {
163                let i: usize = n.parse().ok()?;
164                self.args.get(i.checked_sub(1)?).cloned()
165            }
166            n => self.vars.get(n).cloned(),
167        }
168    }
169
170    fn set(&mut self, name: &str, value: &str) {
171        self.vars.insert(name.to_string(), value.to_string());
172    }
173}
174
175/// Commands the sandbox runs, for `which` and `type`
176pub const PROGRAMS: &[&str] = &[
177    "ls", "cat", "head", "tail", "wc", "grep", "sort", "uniq", "cut", "sed", "tr", "mkdir",
178    "touch", "rm", "mv", "cp", "chmod", "find", "du", "tar", "gzip", "gunzip", "diff", "tee",
179    "env", "printenv", "which", "sh", "bash", "ssh-keygen",
180];
181/// Builtins of the sandbox shell
182pub const BUILTINS: &[&str] = &["cd", "echo", "pwd", "export", "unset", "type", "exit"];
183/// Shell keywords (the sandbox runs one command per line, so none of these)
184pub const KEYWORDS: &[&str] = &["if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac", "function"];
185
186enum Dest {
187    Terminal,
188    Null,
189    File { path: String, append: bool },
190}
191
192impl Dest {
193    fn of(target: &str, append: bool) -> Self {
194        if target == "/dev/null" {
195            Dest::Null
196        } else {
197            Dest::File { path: target.to_string(), append }
198        }
199    }
200}
201
202/// The simulated shell at work on one host's state: run typed lines with
203/// [`Sandbox::run_line`]
204pub struct Sandbox<'a> {
205    pub(crate) shell: &'a mut ShellState,
206    pub(crate) vfs: &'a mut VirtualFileSystem,
207    pub(crate) analyzer: &'a CommandAnalyzer,
208    pub(crate) marks: &'a Marks,
209    guard_refusals: usize,
210}
211
212impl<'a> Sandbox<'a> {
213    pub fn new(
214        shell: &'a mut ShellState,
215        vfs: &'a mut VirtualFileSystem,
216        analyzer: &'a CommandAnalyzer,
217        marks: &'a Marks,
218    ) -> Self {
219        Self {
220            shell,
221            vfs,
222            analyzer,
223            marks,
224            guard_refusals: 0,
225        }
226    }
227
228    /// How many commands the safety guard refused while this sandbox ran
229    pub fn guard_refusals(&self) -> usize {
230        self.guard_refusals
231    }
232
233    pub(crate) fn error(&self, message: impl std::fmt::Display) -> String {
234        self.marks.error(message)
235    }
236
237    /// Run one typed line: everything it printed, errors included
238    pub fn run_line(&mut self, line: &str) -> String {
239        let mut output = String::new();
240        for (joiner, part) in shell_syntax::split_list(line) {
241            if self.shell.exiting {
242                break;
243            }
244            let run = match joiner {
245                Joiner::Always => true,
246                Joiner::IfOk => self.shell.status == 0,
247                Joiner::IfFailed => self.shell.status != 0,
248            };
249            let pwd = self.vfs.get_current_dir().display().to_string();
250            let shell = &self.shell;
251            let parsed = shell_syntax::parse(&part, &|name| shell.lookup(name, &pwd));
252            let pipeline = match parsed {
253                Ok(list) if list.is_empty() && joiner != Joiner::Always => {
254                    output.push_str(&self.error("syntax error: a command is missing after && or ||"));
255                    self.shell.status = 2;
256                    break;
257                }
258                Ok(list) if list.is_empty() => continue,
259                Ok(mut list) => list.remove(0).1,
260                Err(message) => {
261                    output.push_str(&self.error(message));
262                    self.shell.status = 2;
263                    break;
264                }
265            };
266            if !run {
267                continue;
268            }
269            self.shell.pending_status = None;
270            let text = self.run_pipeline(&pipeline);
271            let pending = self.shell.pending_status.take();
272            self.shell.status = if self.marks.reports_failure(&text) { 1 } else { pending.unwrap_or(0) };
273            output.push_str(&text);
274        }
275        output
276    }
277
278    fn expand_globs(&self, words: &[Word]) -> Vec<String> {
279        let mut out = Vec::new();
280        for word in words {
281            let matches = if word.glob { self.vfs.glob(&word.text) } else { Vec::new() };
282            if matches.is_empty() {
283                out.push(word.text.clone());
284            } else {
285                out.extend(matches);
286            }
287        }
288        out
289    }
290
291    fn run_pipeline(&mut self, pipeline: &Pipeline) -> String {
292        let mut terminal = String::new();
293        let mut piped: Option<String> = None;
294        let last = pipeline.stages.len() - 1;
295        for (i, stage) in pipeline.stages.iter().enumerate() {
296            if stage.words.is_empty() {
297                for (name, value) in &stage.assignments {
298                    self.shell.set(name, value);
299                }
300                continue;
301            }
302            let words = self.expand_globs(&stage.words);
303
304            // The guard checks the command as it will actually run, after
305            // variables and globs: `X=/; rm -rf $X` is still rm -rf /
306            let expanded = shellwords::join(&words.iter().map(String::as_str).collect::<Vec<_>>());
307            if let GuardVerdict::Refuse { reason } = PlaygroundGuard::check_catastrophic(&expanded) {
308                self.guard_refusals += 1;
309                terminal.push_str(&format!("{}Blocked. Nothing ran.\n\n{}\n", self.marks.warning, reason));
310                return terminal;
311            }
312
313            let mut stdin = piped.take();
314            for redirect in &stage.redirects {
315                if let Redirect::In(file) = redirect {
316                    match self.vfs.read_file(file) {
317                        Ok(text) => stdin = Some(text),
318                        Err(_) => {
319                            terminal.push_str(&self.error(format!("{file}: No such file or directory")));
320                            return terminal;
321                        }
322                    }
323                }
324            }
325
326            // NAME=value before a command applies to that command only
327            // (and is in that command's environment, as if exported)
328            let saved: Vec<(String, Option<String>, bool)> = stage
329                .assignments
330                .iter()
331                .map(|(name, value)| {
332                    let old = self.shell.vars.get(name).cloned();
333                    let was_exported = !self.shell.exported.insert(name.clone());
334                    self.shell.set(name, value);
335                    (name.clone(), old, was_exported)
336                })
337                .collect();
338
339            // > and 2> empty their file before the command runs (so
340            // `sort x > x` leaves x empty, as in sh)
341            for redirect in &stage.redirects {
342                if let Redirect::Out { target, append: false } | Redirect::Err { target, append: false } = redirect {
343                    if target != "/dev/null" {
344                        let _ = self.vfs.write_file(target, "", false);
345                    }
346                }
347            }
348            // A pipeline's status is its last command's
349            self.shell.pending_status = None;
350            let printed = self.run_stage(&words, stdin);
351            match words[0].as_str() {
352                "diff" if !printed.is_empty() && !self.marks.reports_failure(&printed) => {
353                    self.shell.pending_status = Some(1)
354                }
355                "grep" if printed.trim().is_empty() || printed.starts_with("(no lines matched") => {
356                    self.shell.pending_status = Some(1)
357                }
358                _ => {}
359            }
360            for (name, old, was_exported) in saved {
361                if !was_exported {
362                    self.shell.exported.remove(&name);
363                }
364                match old {
365                    Some(value) => self.shell.set(&name, &value),
366                    None => {
367                        self.shell.vars.remove(&name);
368                    }
369                }
370            }
371            // Errors count even when 2> sends them away
372            if self.marks.reports_failure(&printed) {
373                self.shell.pending_status = Some(1);
374            }
375
376            // Last command, nothing redirected: show it exactly as printed
377            if i == last && stage.redirects.iter().all(|r| matches!(r, Redirect::In(_))) {
378                terminal.push_str(&printed);
379                continue;
380            }
381            let (out, err, notes) = self.marks.split_streams(&printed);
382            let (out, err) = self.route(&stage.redirects, out, err, &mut terminal);
383            terminal.push_str(&err);
384            terminal.push_str(&notes);
385            if i < last {
386                piped = Some(out);
387            } else {
388                terminal.push_str(&out);
389            }
390        }
391        terminal
392    }
393
394    /// Send output and errors where the redirections say. Returns what is
395    /// left for the next command or the screen.
396    fn route(&mut self, redirects: &[Redirect], out: String, err: String, terminal: &mut String) -> (String, String) {
397        let mut out_dest = Dest::Terminal;
398        let mut err_dest = Dest::Terminal;
399        for redirect in redirects {
400            match redirect {
401                Redirect::Out { target, append } => out_dest = Dest::of(target, *append),
402                Redirect::Err { target, append } => err_dest = Dest::of(target, *append),
403                Redirect::ErrToOut => {
404                    err_dest = match &out_dest {
405                        Dest::Terminal => Dest::Terminal,
406                        Dest::Null => Dest::Null,
407                        Dest::File { path, append } => Dest::File { path: path.clone(), append: *append },
408                    }
409                }
410                Redirect::In(_) => {}
411            }
412        }
413        let same_file = matches!((&out_dest, &err_dest), (Dest::File { path: a, .. }, Dest::File { path: b, .. }) if a == b);
414        let (mut keep_out, mut keep_err) = (String::new(), String::new());
415        match out_dest {
416            Dest::Terminal => keep_out = out.clone(),
417            Dest::Null => {}
418            Dest::File { ref path, append } => {
419                let body = if same_file { format!("{out}{err}") } else { out.clone() };
420                self.write_redirect(path, &body, append, terminal);
421            }
422        }
423        match err_dest {
424            Dest::Terminal => keep_err = err,
425            Dest::Null => {}
426            Dest::File { ref path, append } if !same_file => self.write_redirect(path, &err, append, terminal),
427            Dest::File { .. } => {}
428        }
429        (keep_out, keep_err)
430    }
431
432    fn write_redirect(&mut self, path: &str, body: &str, append: bool, terminal: &mut String) {
433        match self.vfs.write_file(path, body, append) {
434            Ok(()) => terminal.push_str(&format!(
435                "{}{} {}\n",
436                self.marks.success,
437                if append { "Appended to" } else { "Wrote" },
438                path
439            )),
440            Err(e) => terminal.push_str(&self.error(format!("{path}: {e}"))),
441        }
442    }
443
444    /// Run one command of a pipeline
445    fn run_stage(&mut self, words: &[String], stdin: Option<String>) -> String {
446        let program = words[0].as_str();
447        let args = &words[1..];
448
449        if KEYWORDS.contains(&program) {
450            return self.error(format!(
451                "{program}: the sandbox runs one command per line. if, for and while need a real shell (switch lessons to real files in settings)"
452            ));
453        }
454        // A name with a slash in it is a path to a script, as in sh
455        if matches!(program, "sh" | "bash") || program.contains('/') {
456            return self.run_script(words);
457        }
458
459        match program {
460            "export" => return self.builtin_export(args),
461            "unset" => {
462                for name in args {
463                    self.shell.vars.remove(name);
464                    self.shell.exported.remove(name);
465                }
466                return String::new();
467            }
468            "env" | "printenv" if args.is_empty() => {
469                return self
470                    .shell
471                    .exported
472                    .iter()
473                    .filter_map(|k| self.shell.vars.get(k).map(|v| format!("{k}={v}\n")))
474                    .collect();
475            }
476            "printenv" => {
477                return args
478                    .iter()
479                    .filter_map(|k| {
480                        self.shell
481                            .exported
482                            .contains(k)
483                            .then(|| self.shell.vars.get(k))
484                            .flatten()
485                            .map(|v| format!("{v}\n"))
486                    })
487                    .collect();
488            }
489            "which" => return self.builtin_which(args, false),
490            "type" => return self.builtin_which(args, true),
491            "exit" => {
492                if self.shell.depth > 0 {
493                    self.shell.exiting = true;
494                    return String::new();
495                }
496                return format!("{}exit only ends scripts here; to leave a lesson press Ctrl+L\n", self.marks.hint);
497            }
498            "tee" => {
499                let Some(text) = stdin else {
500                    return self.error("tee needs something piped into it, like: ls | tee list.txt");
501                };
502                let append = args.iter().any(|a| a == "-a");
503                let mut notes = String::new();
504                for file in args.iter().filter(|a| !a.starts_with('-')) {
505                    if let Err(e) = self.vfs.write_file(file, &text, append) {
506                        notes.push_str(&self.error(format!("tee: {file}: {e}")));
507                    }
508                }
509                return format!("{text}{notes}");
510            }
511            _ => {}
512        }
513
514        // Text from a pipe or `<` goes to filters that weren't given a file
515        if let Some(text) = &stdin {
516            let ops = text_filter::operands(program, args);
517            let has_file = match program {
518                "grep" | "sed" => ops.len() > 1,
519                "tr" => false,
520                _ => !ops.is_empty(),
521            };
522            if !has_file {
523                if program == "cat" {
524                    return text.clone();
525                }
526                if text_filter::FILTERS.contains(&program) {
527                    return match text_filter::apply(program, args, text) {
528                        Ok(out) => out,
529                        Err(message) => self.error(message),
530                    };
531                }
532            }
533        }
534
535        match program {
536            "sed" => return self.sandbox_sed(args),
537            "tr" => return self.error("tr reads from a pipe, like: cat names.txt | tr a-z A-Z"),
538            "find" => return self.sandbox_find(args),
539            "du" => return self.sandbox_du(args),
540            "diff" => return self.sandbox_diff(args),
541            "tar" => return self.sandbox_tar(args),
542            "gzip" | "gunzip" => return self.sandbox_gzip(program, args),
543            "ssh-keygen" => return self.sandbox_ssh_keygen(args),
544            _ => {}
545        }
546
547        // A program found on PATH inside the practice files runs as a script
548        if !program.contains('/') && !PROGRAMS.contains(&program) && !BUILTINS.contains(&program) {
549            if let Some(path) = self.find_on_path(program) {
550                let mut script = vec![path];
551                script.extend(args.iter().cloned());
552                return self.run_script(&script);
553            }
554        }
555
556        let line = shellwords::join(&words.iter().map(String::as_str).collect::<Vec<_>>());
557        let cmd = match self.analyzer.parse(&line) {
558            Ok(cmd) => cmd,
559            Err(e) => return self.error(e),
560        };
561        self.run_command(&cmd)
562    }
563
564    fn builtin_export(&mut self, args: &[String]) -> String {
565        if args.is_empty() {
566            return self
567                .shell
568                .exported
569                .iter()
570                .filter_map(|k| self.shell.vars.get(k).map(|v| format!("export {k}=\"{v}\"\n")))
571                .collect();
572        }
573        let mut out = String::new();
574        for arg in args {
575            let (name, value) = match arg.split_once('=') {
576                Some((name, value)) => (name, Some(value)),
577                None => (arg.as_str(), None),
578            };
579            if !shell_syntax::is_name(name) {
580                out.push_str(&self.error(format!("export: '{arg}': not a valid name")));
581                continue;
582            }
583            if let Some(value) = value {
584                self.shell.set(name, value);
585            }
586            self.shell.exported.insert(name.to_string());
587        }
588        out
589    }
590
591    /// Folders on PATH that live in the practice files, in order
592    fn find_on_path(&self, program: &str) -> Option<String> {
593        let path = self.shell.vars.get("PATH")?;
594        path.split(':')
595            .filter(|dir| dir.starts_with("/lesson-home"))
596            .map(|dir| format!("{}/{}", dir.trim_end_matches('/'), program))
597            .find(|candidate| {
598                // A platform without modes counts every file as runnable
599                self.vfs
600                    .metadata(candidate)
601                    .is_some_and(|m| !m.is_dir && m.mode.is_none_or(|bits| bits & 0o111 != 0))
602            })
603    }
604
605    fn builtin_which(&self, args: &[String], verbose: bool) -> String {
606        let mut out = String::new();
607        for name in args.iter().filter(|a| !a.starts_with('-')) {
608            let found = if BUILTINS.contains(&name.as_str()) {
609                if verbose {
610                    out.push_str(&format!("{name} is a shell builtin\n"));
611                    continue;
612                }
613                None
614            } else if let Some(path) = self.find_on_path(name) {
615                Some(path)
616            } else if PROGRAMS.contains(&name.as_str()) {
617                Some(format!("/usr/bin/{name}"))
618            } else {
619                None
620            };
621            match (found, verbose) {
622                (Some(path), true) => out.push_str(&format!("{name} is {path}\n")),
623                (Some(path), false) => out.push_str(&format!("{path}\n")),
624                (None, _) => out.push_str(&self.error(format!("{name}: not found on PATH"))),
625            }
626        }
627        out
628    }
629
630    fn run_script(&mut self, words: &[String]) -> String {
631        let program = words[0].as_str();
632        let (name, direct, args) = if matches!(program, "sh" | "bash") {
633            let rest: Vec<&String> = words[1..].iter().filter(|a| !a.starts_with('-')).collect();
634            match rest.split_first() {
635                Some((file, args)) => ((*file).clone(), false, args.iter().map(|a| (*a).clone()).collect()),
636                None => return self.error(format!("{program}: which script? Try: {program} hello.sh")),
637            }
638        } else {
639            (program.trim_start_matches("./").to_string(), true, words[1..].to_vec())
640        };
641        if self.shell.depth > 0 {
642            return self.error("scripts can't start other scripts in the sandbox");
643        }
644        if let Err(e) = self.vfs.resolve_path(&name) {
645            return self.error(format!("{name}: {e}"));
646        }
647        if direct {
648            let meta = self.vfs.metadata(&name);
649            // A platform without modes counts every file as runnable
650            let executable = meta.and_then(|m| m.mode).is_none_or(|bits| bits & 0o111 != 0);
651            if meta.is_some_and(|m| !m.is_dir) && !executable {
652                return format!(
653                    "{}{}: permission denied\n\n{}A file must be marked executable before it can run: chmod +x {}\n",
654                    self.marks.error, program, self.marks.hint, name
655                );
656            }
657        }
658        let script = match self.vfs.read_file(&name) {
659            Ok(text) => text,
660            Err(_) => return self.error(format!("{name}: no such script")),
661        };
662
663        // A script is a new shell: it sees exported variables only, and
664        // what it sets doesn't come back
665        let saved_args = std::mem::replace(&mut self.shell.args, args);
666        let saved_vars = self.shell.vars.clone();
667        let saved_exported = self.shell.exported.clone();
668        let exported = &self.shell.exported;
669        self.shell.vars.retain(|k, _| exported.contains(k));
670        self.shell.depth += 1;
671        let mut output = String::new();
672        for line in script.lines().map(str::trim) {
673            if line.is_empty() || line.starts_with('#') {
674                continue;
675            }
676            output.push_str(&self.run_line(line));
677            if self.shell.exiting {
678                break;
679            }
680        }
681        self.shell.depth -= 1;
682        self.shell.exiting = false;
683        self.shell.args = saved_args;
684        self.shell.vars = saved_vars;
685        self.shell.exported = saved_exported;
686        if output.is_empty() {
687            output.push_str("(the script ran and printed nothing)\n");
688        }
689        output
690    }
691
692    fn sandbox_sed(&mut self, args: &[String]) -> String {
693        // -i edits the file; -i.bak (a suffix right after -i) keeps the
694        // original beside it, as sed does on Linux and on a Mac
695        let in_place = args.iter().find(|a| a.starts_with("-i")).map(|a| a[2..].to_string());
696        let ops = text_filter::operands("sed", args);
697        let Some((_, files)) = ops.split_first() else {
698            return self.error("sed needs a script, like: sed 's/old/new/' notes.txt");
699        };
700        if files.is_empty() {
701            return self.error("sed needs a file here, or text piped in: cat notes.txt | sed 's/old/new/'");
702        }
703        let filter_args: Vec<String> =
704            args.iter().filter(|a| !a.starts_with("-i") && !files.contains(a)).cloned().collect();
705        let mut out = String::new();
706        for file in files {
707            let text = match self.vfs.read_file(file) {
708                Ok(text) => text,
709                Err(_) => {
710                    out.push_str(&self.error(format!("sed: {file}: No such file or directory")));
711                    continue;
712                }
713            };
714            match text_filter::apply("sed", &filter_args, &text) {
715                Ok(changed) if in_place.is_some() => {
716                    let suffix = in_place.as_deref().unwrap_or_default();
717                    if !suffix.is_empty() {
718                        if let Err(e) = self.vfs.write_file(&format!("{file}{suffix}"), &text, false) {
719                            out.push_str(&self.error(format!("sed: {file}{suffix}: {e}")));
720                            continue;
721                        }
722                    }
723                    if let Err(e) = self.vfs.write_file(file, &changed, false) {
724                        out.push_str(&self.error(format!("sed: {file}: {e}")));
725                    }
726                }
727                Ok(changed) => out.push_str(&changed),
728                Err(message) => return self.error(message),
729            }
730        }
731        out
732    }
733
734    fn sandbox_find(&self, args: &[String]) -> String {
735        let (paths, query) = match crate::virtual_fs_tools::FindQuery::parse(args) {
736            Ok(parsed) => parsed,
737            Err(message) => return self.error(message),
738        };
739        let mut out = String::new();
740        for path in paths {
741            match self.vfs.find(&path, &query) {
742                Ok(found) => found.iter().for_each(|f| {
743                    out.push_str(f);
744                    out.push('\n');
745                }),
746                Err(e) => out.push_str(&self.error(format!("find: {e}"))),
747            }
748        }
749        out
750    }
751
752    fn sandbox_du(&self, args: &[String]) -> String {
753        let summarize = args.iter().any(|a| a.starts_with('-') && a.contains('s'));
754        let human = args.iter().any(|a| a.starts_with('-') && a.contains('h'));
755        let mut paths: Vec<String> = args.iter().filter(|a| !a.starts_with('-')).cloned().collect();
756        if paths.is_empty() {
757            paths.push(".".to_string());
758        }
759        let size = |bytes: u64| {
760            if human {
761                match bytes {
762                    b if b < 1024 => format!("{b}B"),
763                    b if b < 1024 * 1024 => format!("{:.1}K", b as f64 / 1024.0),
764                    b => format!("{:.1}M", b as f64 / (1024.0 * 1024.0)),
765                }
766            } else {
767                // Like du: kilobytes, rounded up
768                bytes.div_ceil(1024).max(u64::from(bytes > 0)).to_string()
769            }
770        };
771        let mut out = String::new();
772        for path in paths {
773            match self.vfs.disk_usage(&path, summarize) {
774                Ok(rows) => rows.iter().for_each(|(p, b)| out.push_str(&format!("{}\t{}\n", size(*b), p))),
775                Err(e) => out.push_str(&self.error(format!("du: {e}"))),
776            }
777        }
778        out
779    }
780
781    fn sandbox_diff(&self, args: &[String]) -> String {
782        let files: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect();
783        let [a, b] = files[..] else {
784            return self.error("diff compares two files, like: diff old.txt new.txt");
785        };
786        let read = |f: &str| self.vfs.read_file(f).map_err(|_| self.error(format!("diff: {f}: No such file or directory")));
787        match (read(a), read(b)) {
788            (Ok(x), Ok(y)) => text_filter::diff(&x, &y),
789            (Err(e), _) | (_, Err(e)) => e,
790        }
791    }
792
793    fn sandbox_tar(&self, args: &[String]) -> String {
794        let (mut create, mut extract, mut list, mut gzip, mut verbose) = (false, false, false, false, false);
795        let mut archive: Option<String> = None;
796        let mut dest = ".".to_string();
797        let mut inputs = Vec::new();
798        let mut iter = args.iter().enumerate();
799        while let Some((i, arg)) = iter.next() {
800            let bundle = arg.strip_prefix('-').filter(|s| !s.is_empty()).or_else(|| {
801                (i == 0 && arg.chars().all(|c| "cxtzvf".contains(c))).then_some(arg.as_str())
802            });
803            let Some(letters) = bundle else {
804                inputs.push(arg.clone());
805                continue;
806            };
807            if letters == "C" {
808                match iter.next() {
809                    Some((_, d)) => dest = d.clone(),
810                    None => return self.error("tar: -C needs a folder"),
811                }
812                continue;
813            }
814            let letters: Vec<char> = letters.chars().collect();
815            for (k, &letter) in letters.iter().enumerate() {
816                // In -cfz, f takes the rest of the bundle ("z") as the
817                // archive's name, as in real tar: f goes last
818                if letter == 'f' && k + 1 < letters.len() {
819                    archive = Some(letters[k + 1..].iter().collect());
820                    break;
821                }
822                match letter {
823                    'c' => create = true,
824                    'x' => extract = true,
825                    't' => list = true,
826                    'z' => gzip = true,
827                    'v' => verbose = true,
828                    'f' => match iter.next() {
829                        Some((_, name)) => archive = Some(name.clone()),
830                        None => return self.error("tar: f needs the archive's name, like: tar -czf notes.tar.gz notes"),
831                    },
832                    other => return self.error(format!("tar: the {other} option isn't available in the sandbox (try c, x, t, z, v, f)")),
833                }
834            }
835        }
836        let Some(archive) = archive else {
837            return self.error("tar: name the archive with f, like: tar -czf notes.tar.gz notes");
838        };
839        let result = match (create, extract, list) {
840            (true, false, false) => self.vfs.tar_create(&archive, &inputs, gzip).map(|names| (names, verbose)),
841            (false, true, false) => self.vfs.tar_extract(&archive, &dest).map(|names| (names, verbose)),
842            (false, false, true) => self.vfs.tar_list(&archive).map(|names| (names, true)),
843            _ => return self.error("tar: choose one of c (create), x (extract) or t (list)"),
844        };
845        match result {
846            Ok((names, show)) if show => names.iter().map(|n| format!("{n}\n")).collect(),
847            Ok(_) => String::new(),
848            Err(e) => self.error(e),
849        }
850    }
851
852    fn sandbox_gzip(&self, program: &str, args: &[String]) -> String {
853        let unzip = program == "gunzip" || args.iter().any(|a| a == "-d");
854        let keep = args.iter().any(|a| a == "-k" || a == "--keep");
855        let files: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect();
856        if files.is_empty() {
857            return self.error(format!("{program}: name a file, like: {program} notes.txt{}", if unzip { ".gz" } else { "" }));
858        }
859        let mut out = String::new();
860        for file in files {
861            // -k keeps the original: copy it back after the swap
862            let backup = keep.then(|| self.vfs.read_bytes(file).ok()).flatten();
863            let result = if unzip { self.vfs.gunzip(file) } else { self.vfs.gzip(file) };
864            match result {
865                Ok(_) => {
866                    if let Some(bytes) = backup {
867                        let _ = self.vfs.write_bytes(file, &bytes);
868                    }
869                }
870                Err(e) => out.push_str(&self.error(e)),
871            }
872        }
873        out
874    }
875
876    fn sandbox_ssh_keygen(&self, args: &[String]) -> String {
877        let mut kind = "ed25519".to_string();
878        let mut file: Option<String> = None;
879        let mut comment = "learner@arc-academy".to_string();
880        let mut iter = args.iter();
881        while let Some(arg) = iter.next() {
882            match arg.as_str() {
883                "-t" => kind = iter.next().cloned().unwrap_or_default(),
884                "-f" => file = iter.next().cloned(),
885                "-C" => comment = iter.next().cloned().unwrap_or_default(),
886                "-N" => {
887                    iter.next();
888                }
889                "-q" => {}
890                other => return self.error(format!("ssh-keygen: {other} isn't available in the sandbox (try -t, -f, -C, -N)")),
891            }
892        }
893        if !matches!(kind.as_str(), "ed25519" | "rsa") {
894            return self.error("ssh-keygen: -t takes ed25519 (recommended) or rsa");
895        }
896        let file = file.unwrap_or_else(|| format!("/lesson-home/.ssh/id_{kind}"));
897        match self.vfs.practice_ssh_key(&file, &kind, &comment) {
898            Ok((private, public)) => format!(
899                "Generating public/private {kind} key pair.\n\
900                 Your identification has been saved in {private}\n\
901                 Your public key has been saved in {public}\n\
902                 {}This is a practice key made in the sandbox. It opens nothing.\n",
903                self.marks.hint
904            ),
905            Err(e) => self.error(format!("ssh-keygen: {e}")),
906        }
907    }
908}