1use 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#[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 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 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 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 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 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#[derive(Debug, Clone)]
99pub struct ShellState {
100 vars: BTreeMap<String, String>,
101 exported: BTreeSet<String>,
102 args: Vec<String>,
104 status: i32,
106 depth: usize,
108 exiting: bool,
110 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 pub fn status(&self) -> i32 {
147 self.status
148 }
149
150 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
175pub 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];
181pub const BUILTINS: &[&str] = &["cd", "echo", "pwd", "export", "unset", "type", "exit"];
183pub 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
202pub 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 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 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 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 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 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 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 if self.marks.reports_failure(&printed) {
373 self.shell.pending_status = Some(1);
374 }
375
376 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(¬es);
385 if i < last {
386 piped = Some(out);
387 } else {
388 terminal.push_str(&out);
389 }
390 }
391 terminal
392 }
393
394 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 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 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 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 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 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 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 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 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 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 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 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 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}