Skip to main content

arct_core/
virtual_fs_tools.rs

1//! More sandbox commands that walk the practice files: glob expansion,
2//! `find`, `du`, `tar`, `gzip`, and practice SSH keys.
3//!
4//! Every path goes through the sandbox's containment check (the one
5//! [`VirtualFileSystem::resolve_path`] uses), so nothing here can reach
6//! outside the lesson sandbox. Symbolic links are never followed or
7//! created. All of it works the same on disk and in memory.
8
9use crate::shell_syntax::{fnmatch, glob_match};
10use crate::virtual_fs::VirtualFileSystem;
11use anyhow::{bail, Context, Result};
12use std::io::Read;
13use std::path::{Component, Path, PathBuf};
14
15/// What `find` looks for
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct FindQuery {
18    /// `-name PATTERN` (or `-iname` when `ignore_case`)
19    pub name: Option<String>,
20    pub ignore_case: bool,
21    /// `-type f` or `-type d`
22    pub kind: Option<char>,
23    /// `-maxdepth N`
24    pub max_depth: Option<usize>,
25}
26
27impl FindQuery {
28    /// Parse find's arguments into (start paths, query)
29    pub fn parse(args: &[String]) -> Result<(Vec<String>, FindQuery), String> {
30        let mut paths = Vec::new();
31        let mut query = FindQuery::default();
32        let mut iter = args.iter();
33        while let Some(arg) = iter.next() {
34            let mut value = |what: &str| {
35                iter.next()
36                    .cloned()
37                    .ok_or_else(|| format!("find: {arg} needs {what}"))
38            };
39            match arg.as_str() {
40                "-name" => query.name = Some(value("a pattern, like -name '*.txt'")?),
41                "-iname" => {
42                    query.name = Some(value("a pattern, like -iname '*.txt'")?);
43                    query.ignore_case = true;
44                }
45                "-type" => {
46                    let kind = value("f (files) or d (folders)")?;
47                    match kind.as_str() {
48                        "f" => query.kind = Some('f'),
49                        "d" => query.kind = Some('d'),
50                        _ => return Err("find: -type takes f (files) or d (folders)".to_string()),
51                    }
52                }
53                "-maxdepth" => {
54                    let n = value("a number")?;
55                    query.max_depth =
56                        Some(n.parse().map_err(|_| "find: -maxdepth needs a number".to_string())?);
57                }
58                "-print" => {}
59                other if other.starts_with('-') && other.len() > 1 => {
60                    return Err(format!(
61                        "find: {other} isn't available in the sandbox (try -name, -iname, -type, -maxdepth)"
62                    ))
63                }
64                path if query == FindQuery::default() => paths.push(path.to_string()),
65                other => return Err(format!("find: paths go before the tests, not after ({other})")),
66            }
67        }
68        if paths.is_empty() {
69            paths.push(".".to_string());
70        }
71        Ok((paths, query))
72    }
73
74    fn matches(&self, name: &str, is_dir: bool) -> bool {
75        if let Some(kind) = self.kind {
76            if (kind == 'd') != is_dir {
77                return false;
78            }
79        }
80        // find's -name, unlike the shell, lets * match a leading dot
81        match &self.name {
82            Some(pattern) if self.ignore_case => fnmatch(&pattern.to_lowercase(), &name.to_lowercase()),
83            Some(pattern) => fnmatch(pattern, name),
84            None => true,
85        }
86    }
87}
88
89/// Join a path as typed with a child name: "." + "a" -> "./a", "docs/" + "a" -> "docs/a"
90fn join_display(base: &str, name: &str) -> String {
91    format!("{}/{}", base.trim_end_matches('/'), name)
92}
93
94/// Reject archive entry paths that are absolute or climb with `..`
95fn safe_relative(path: &Path) -> Result<PathBuf> {
96    let mut out = PathBuf::new();
97    for c in path.components() {
98        match c {
99            Component::Normal(part) => out.push(part),
100            Component::CurDir => {}
101            _ => bail!("'{}' would land outside the folder", path.display()),
102        }
103    }
104    if out.as_os_str().is_empty() {
105        bail!("empty path in archive");
106    }
107    Ok(out)
108}
109
110impl VirtualFileSystem {
111    /// Expand a glob against the sandbox (`*.txt`, `logs/day?.log`).
112    /// Only the last part of the path may hold a pattern. Returns the
113    /// sorted matches, or nothing when none match.
114    pub fn glob(&self, pattern: &str) -> Vec<String> {
115        let (dir, file_pattern) = match pattern.rsplit_once('/') {
116            Some((dir, file)) => (Some(if dir.is_empty() { "/" } else { dir }), file),
117            None => (None, pattern),
118        };
119        if dir.is_some_and(|d| d.contains(['*', '?', '['])) {
120            return Vec::new();
121        }
122        let Ok(entries) = self.list_directory(dir) else {
123            return Vec::new();
124        };
125        let mut names: Vec<String> = entries
126            .into_iter()
127            .map(|e| e.name)
128            .filter(|name| glob_match(file_pattern, name))
129            .map(|name| match pattern.rsplit_once('/') {
130                Some((d, _)) => format!("{d}/{name}"),
131                None => name,
132            })
133            .collect();
134        names.sort();
135        names
136    }
137
138    /// `find`: walk each start path and list what matches, as paths
139    /// written from the start path the way it was typed
140    pub fn find(&self, start: &str, query: &FindQuery) -> Result<Vec<String>> {
141        let at = self.virtual_path(start)?;
142        let meta = self
143            .prim_lstat(&at)
144            .with_context(|| format!("'{start}': No such file or directory"))?;
145        let mut out = Vec::new();
146        let start_name = Path::new(start)
147            .file_name()
148            .map(|n| n.to_string_lossy().to_string())
149            .unwrap_or_else(|| start.to_string());
150        if query.matches(&start_name, meta.is_dir) {
151            out.push(start.to_string());
152        }
153        if meta.is_dir {
154            self.find_in(&at, start, 1, query, &mut out);
155        }
156        Ok(out)
157    }
158
159    fn find_in(&self, dir: &Path, display: &str, depth: usize, query: &FindQuery, out: &mut Vec<String>) {
160        if query.max_depth.is_some_and(|max| depth > max) {
161            return;
162        }
163        for (name, path, is_dir) in self.walk_children(dir) {
164            let shown = join_display(display, &name);
165            if query.matches(&name, is_dir) {
166                out.push(shown.clone());
167            }
168            if is_dir {
169                self.find_in(&path, &shown, depth + 1, query, out);
170            }
171        }
172    }
173
174    /// `du`: size in bytes of each folder under `path` (deepest first),
175    /// or only `path` itself when `summarize`
176    pub fn disk_usage(&self, path: &str, summarize: bool) -> Result<Vec<(String, u64)>> {
177        let at = self.virtual_path(path)?;
178        let meta = self
179            .prim_lstat(&at)
180            .with_context(|| format!("cannot access '{path}': No such file or directory"))?;
181        if !meta.is_dir {
182            return Ok(vec![(path.to_string(), meta.len)]);
183        }
184        let mut out = Vec::new();
185        let total = self.du_walk(&at, path, summarize, &mut out);
186        out.push((path.to_string(), total));
187        Ok(out)
188    }
189
190    fn du_walk(&self, dir: &Path, display: &str, summarize: bool, out: &mut Vec<(String, u64)>) -> u64 {
191        let mut total = 0;
192        for (name, path, is_dir) in self.walk_children(dir) {
193            if is_dir {
194                let shown = join_display(display, &name);
195                let size = self.du_walk(&path, &shown, summarize, out);
196                if !summarize {
197                    out.push((shown, size));
198                }
199                total += size;
200            } else {
201                total += self.prim_stat(&path).map(|m| m.len).unwrap_or(0);
202            }
203        }
204        total
205    }
206
207    /// `tar -c`: pack files and folders into an archive. Returns the
208    /// entries written, for `-v`.
209    pub fn tar_create(&self, archive: &str, inputs: &[String], gzip: bool) -> Result<Vec<String>> {
210        if inputs.is_empty() {
211            bail!("tar: say what to pack, like: tar -czf notes.tar.gz notes");
212        }
213        let mut names = Vec::new();
214        let mut buffer = Vec::new();
215        {
216            let mut builder = tar::Builder::new(&mut buffer);
217            builder.follow_symlinks(false);
218            for input in inputs {
219                let rel = safe_relative(Path::new(input))
220                    .with_context(|| format!("tar: pack files from here down ({input})"))?;
221                let at = self.virtual_path(input)?;
222                let meta = self
223                    .prim_lstat(&at)
224                    .with_context(|| format!("tar: {input}: No such file or directory"))?;
225                if meta.is_dir {
226                    self.tar_dir(&mut builder, &at, &rel, &mut names)?;
227                } else {
228                    self.tar_append_file(&mut builder, &at, &rel)?;
229                    names.push(rel.to_string_lossy().to_string());
230                }
231            }
232            builder.finish()?;
233        }
234        let bytes = if gzip {
235            let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
236            std::io::Write::write_all(&mut encoder, &buffer)?;
237            encoder.finish()?
238        } else {
239            buffer
240        };
241        self.prim_write(&self.virtual_path(archive)?, &bytes, false)
242            .with_context(|| format!("tar: can't write {archive}"))?;
243        Ok(names)
244    }
245
246    fn tar_dir<W: std::io::Write>(
247        &self,
248        builder: &mut tar::Builder<W>,
249        at: &Path,
250        rel: &Path,
251        names: &mut Vec<String>,
252    ) -> Result<()> {
253        self.tar_append_dir(builder, at, rel)?;
254        names.push(format!("{}/", rel.to_string_lossy()));
255        for (name, path, is_dir) in self.walk_children(at) {
256            let child = rel.join(&name);
257            if is_dir {
258                self.tar_dir(builder, &path, &child, names)?;
259            } else {
260                self.tar_append_file(builder, &path, &child)?;
261                names.push(child.to_string_lossy().to_string());
262            }
263        }
264        Ok(())
265    }
266
267    fn tar_bytes(&self, archive: &str) -> Result<Vec<u8>> {
268        let raw = self
269            .prim_read(&self.virtual_path(archive)?)
270            .with_context(|| format!("tar: {archive}: No such file"))?;
271        // Unpack gzip whenever the file is gzip, as GNU tar does
272        if raw.starts_with(&[0x1f, 0x8b]) {
273            let mut out = Vec::new();
274            flate2::read::GzDecoder::new(&raw[..])
275                .read_to_end(&mut out)
276                .with_context(|| format!("tar: {archive} is damaged"))?;
277            Ok(out)
278        } else {
279            Ok(raw)
280        }
281    }
282
283    /// `tar -t`: list an archive
284    pub fn tar_list(&self, archive: &str) -> Result<Vec<String>> {
285        let bytes = self.tar_bytes(archive)?;
286        let mut names = Vec::new();
287        for entry in tar::Archive::new(&bytes[..])
288            .entries()
289            .with_context(|| format!("tar: {archive} isn't an archive"))?
290        {
291            let entry = entry?;
292            let mut name = entry.path()?.to_string_lossy().to_string();
293            if entry.header().entry_type().is_dir() && !name.ends_with('/') {
294                name.push('/');
295            }
296            names.push(name);
297        }
298        Ok(names)
299    }
300
301    /// `tar -x`: unpack into `dest` (files and folders only; nothing may
302    /// land outside `dest`). Returns the entries written.
303    pub fn tar_extract(&self, archive: &str, dest: &str) -> Result<Vec<String>> {
304        let bytes = self.tar_bytes(archive)?;
305        let mut names = Vec::new();
306        for entry in tar::Archive::new(&bytes[..])
307            .entries()
308            .with_context(|| format!("tar: {archive} isn't an archive"))?
309        {
310            let mut entry = entry?;
311            let rel = safe_relative(&entry.path()?)?;
312            let target = self.virtual_path(&join_display(dest, &rel.to_string_lossy()))?;
313            let kind = entry.header().entry_type();
314            if kind.is_dir() {
315                self.prim_create_dir_all(&target)?;
316                names.push(format!("{}/", rel.to_string_lossy()));
317            } else if kind.is_file() {
318                if let Some(parent) = target.parent() {
319                    self.prim_create_dir_all(parent)?;
320                }
321                let mut data = Vec::new();
322                entry.read_to_end(&mut data)?;
323                self.prim_write(&target, &data, false)?;
324                if let Ok(mode) = entry.header().mode() {
325                    let _ = self.prim_set_mode(&target, mode & 0o755);
326                }
327                names.push(rel.to_string_lossy().to_string());
328            }
329            // Links and devices are skipped
330        }
331        Ok(names)
332    }
333
334    /// `gzip FILE`: compress to FILE.gz and remove FILE
335    pub fn gzip(&self, path: &str) -> Result<String> {
336        let at = self.virtual_path(path)?;
337        if path.ends_with(".gz") {
338            bail!("gzip: {path} already has the .gz suffix");
339        }
340        let data = self.prim_read(&at).with_context(|| format!("gzip: {path}: No such file"))?;
341        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
342        std::io::Write::write_all(&mut encoder, &data)?;
343        let out = format!("{path}.gz");
344        self.prim_write(&self.virtual_path(&out)?, &encoder.finish()?, false)?;
345        self.prim_remove_file(&at)?;
346        Ok(out)
347    }
348
349    /// `gunzip FILE.gz`: decompress to FILE and remove FILE.gz
350    pub fn gunzip(&self, path: &str) -> Result<String> {
351        let Some(out) = path.strip_suffix(".gz") else {
352            bail!("gunzip: {path}: the name doesn't end in .gz");
353        };
354        let at = self.virtual_path(path)?;
355        let raw = self.prim_read(&at).with_context(|| format!("gunzip: {path}: No such file"))?;
356        let mut data = Vec::new();
357        flate2::read::GzDecoder::new(&raw[..])
358            .read_to_end(&mut data)
359            .with_context(|| format!("gunzip: {path} isn't gzip data"))?;
360        self.prim_write(&self.virtual_path(out)?, &data, false)?;
361        self.prim_remove_file(&at)?;
362        Ok(out.to_string())
363    }
364
365    /// `ssh-keygen -f PATH -C COMMENT`: write a PRACTICE key pair. The
366    /// files have the real shape (a private key readable only by you, a
367    /// one-line public key) but hold no key material.
368    pub fn practice_ssh_key(&self, path: &str, kind: &str, comment: &str) -> Result<(String, String)> {
369        let public = format!("{path}.pub");
370        for p in [path, public.as_str()] {
371            if self.prim_stat(&self.virtual_path(p)?).is_some() {
372                bail!("{p} already exists. Remove it first, or pick another name with -f");
373            }
374        }
375        let private_at = self.virtual_path(path)?;
376        if let Some(parent) = private_at.parent() {
377            self.prim_create_dir_all(parent)?;
378        }
379        let private_text = format!(
380            "-----BEGIN OPENSSH PRIVATE KEY-----\n\
381             (practice key made in the arct sandbox: it opens nothing)\n\
382             -----END OPENSSH PRIVATE KEY-----\n"
383        );
384        let algo = if kind == "rsa" { "ssh-rsa" } else { "ssh-ed25519" };
385        let public_text = format!("{algo} AAAAPRACTICEKEYxARCTxSANDBOXxNOTxREAL {comment}\n");
386        self.prim_write(&private_at, private_text.as_bytes(), false)?;
387        self.prim_write(&self.virtual_path(&public)?, public_text.as_bytes(), false)?;
388        self.prim_set_mode(&private_at, 0o600)?;
389        Ok((path.to_string(), public))
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    /// The same practice files on disk and in memory
398    fn both(name: &str) -> [VirtualFileSystem; 2] {
399        let filled = |vfs: VirtualFileSystem| {
400            vfs.create_directory("proj/src", true).unwrap();
401            vfs.write_file("proj/README.md", "hello\n", false).unwrap();
402            vfs.write_file("proj/src/main.txt", "abcdef\n", false).unwrap();
403            vfs.write_file("proj/src/.hidden.txt", "x\n", false).unwrap();
404            vfs
405        };
406        [
407            filled(VirtualFileSystem::new("tools", name).unwrap()),
408            filled(VirtualFileSystem::in_memory()),
409        ]
410    }
411
412    fn strings(v: &[&str]) -> Vec<String> {
413        v.iter().map(|s| s.to_string()).collect()
414    }
415
416    #[test]
417    fn glob_expands_in_folders() {
418        for v in both("glob") {
419            assert_eq!(v.glob("proj/src/*.txt"), ["proj/src/main.txt"]);
420            assert_eq!(v.glob("proj/src/.*.txt"), ["proj/src/.hidden.txt"]);
421            assert!(v.glob("proj/*.nothing").is_empty());
422            assert!(v.glob("p*/src/*.txt").is_empty(), "patterns only in the last part");
423        }
424    }
425
426    #[test]
427    fn find_by_name_type_and_depth() {
428        for v in both("find") {
429            let (paths, q) = FindQuery::parse(&strings(&["proj", "-name", "*.txt"])).unwrap();
430            assert_eq!(paths, ["proj"]);
431            assert_eq!(v.find("proj", &q).unwrap(), ["proj/src/.hidden.txt", "proj/src/main.txt"]);
432
433            let (_, q) = FindQuery::parse(&strings(&["-type", "d"])).unwrap();
434            v.create_directory("solo", false).unwrap();
435            let found = v.find("proj", &q).unwrap();
436            assert_eq!(found, ["proj", "proj/src"]);
437
438            let (_, q) = FindQuery::parse(&strings(&["-maxdepth", "1"])).unwrap();
439            assert_eq!(v.find("proj", &q).unwrap(), ["proj", "proj/README.md", "proj/src"]);
440
441            let (_, q) = FindQuery::parse(&strings(&["-iname", "readme*"])).unwrap();
442            assert_eq!(v.find("proj", &q).unwrap(), ["proj/README.md"]);
443
444            assert!(FindQuery::parse(&strings(&["-exec", "rm"])).is_err());
445            assert!(v.find("nowhere", &FindQuery::default()).is_err());
446        }
447    }
448
449    #[test]
450    fn find_from_dot_lists_everything_under_it() {
451        for mut v in both("finddot") {
452            v.change_directory("proj").unwrap();
453            let found = v.find(".", &FindQuery::default()).unwrap();
454            assert_eq!(found[0], ".");
455            assert!(found.contains(&"./src/main.txt".to_string()));
456        }
457    }
458
459    #[test]
460    fn du_counts_bytes() {
461        for v in both("du") {
462            let all = v.disk_usage("proj", false).unwrap();
463            assert_eq!(all.last().unwrap(), &("proj".to_string(), 6 + 7 + 2));
464            assert_eq!(all[0], ("proj/src".to_string(), 9));
465            assert_eq!(v.disk_usage("proj", true).unwrap().len(), 1);
466        }
467    }
468
469    #[test]
470    fn tar_round_trip() {
471        for v in both("tar") {
472            let packed = v.tar_create("proj.tar.gz", &strings(&["proj"]), true).unwrap();
473            assert!(packed.contains(&"proj/src/main.txt".to_string()));
474            assert!(v.tar_list("proj.tar.gz").unwrap().contains(&"proj/README.md".to_string()));
475
476            v.create_directory("restore", false).unwrap();
477            v.tar_extract("proj.tar.gz", "restore").unwrap();
478            assert_eq!(v.read_file("restore/proj/src/main.txt").unwrap(), "abcdef\n");
479
480            // Plain (not gzipped) archives too
481            v.tar_create("plain.tar", &strings(&["proj/README.md"]), false).unwrap();
482            assert_eq!(v.tar_list("plain.tar").unwrap(), ["proj/README.md"]);
483        }
484    }
485
486    #[test]
487    fn tar_keeps_modes_across_backends() {
488        let [disk, memory] = both("tarmodes");
489        for v in [&disk, &memory] {
490            v.write_file("proj/run.sh", "echo hi\n", false).unwrap();
491            v.set_mode("proj/run.sh", 0o755).unwrap();
492        }
493        // An archive made in memory unpacks on disk, and the other way round
494        memory.tar_create("m.tar", &strings(&["proj"]), false).unwrap();
495        disk.write_bytes("m.tar", &memory.read_bytes("m.tar").unwrap()).unwrap();
496        disk.tar_create("d.tar.gz", &strings(&["proj"]), true).unwrap();
497        memory.write_bytes("d.tar.gz", &disk.read_bytes("d.tar.gz").unwrap()).unwrap();
498        for (v, archive) in [(&disk, "m.tar"), (&memory, "d.tar.gz")] {
499            assert_eq!(v.tar_list(archive).unwrap(), memory.tar_list("m.tar").unwrap());
500            v.create_directory("out", false).unwrap();
501            v.tar_extract(archive, "out").unwrap();
502            assert_eq!(v.read_file("out/proj/run.sh").unwrap(), "echo hi\n");
503            if let Some(mode) = v.metadata("out/proj/run.sh").unwrap().mode {
504                assert_eq!(mode & 0o777, 0o755);
505            }
506        }
507    }
508
509    #[test]
510    fn tar_refuses_paths_that_climb_out() {
511        for v in both("tarclimb") {
512            assert!(v.tar_create("x.tar", &strings(&["../proj"]), false).is_err());
513            assert!(v.tar_create("x.tar", &strings(&["/lesson-home/proj"]), false).is_err());
514
515            // A hand-made archive with ../ in an entry is refused on extract
516            let mut raw = Vec::new();
517            {
518                let mut b = tar::Builder::new(&mut raw);
519                let mut header = tar::Header::new_gnu();
520                header.set_size(3);
521                header.set_mode(0o644);
522                header.set_entry_type(tar::EntryType::Regular);
523                {
524                    let name = b"../escape.txt";
525                    let gnu = header.as_gnu_mut().unwrap();
526                    gnu.name[..name.len()].copy_from_slice(name);
527                }
528                header.set_cksum();
529                b.append(&header, &b"bad"[..]).unwrap();
530                b.finish().unwrap();
531            }
532            v.write_bytes("evil.tar", &raw).unwrap();
533            assert!(v.tar_extract("evil.tar", "proj").is_err());
534            assert!(!v.exists("escape.txt"));
535        }
536    }
537
538    #[test]
539    fn gzip_round_trip() {
540        for v in both("gzip") {
541            assert_eq!(v.gzip("proj/README.md").unwrap(), "proj/README.md.gz");
542            assert!(!v.exists("proj/README.md"));
543            assert_eq!(v.gunzip("proj/README.md.gz").unwrap(), "proj/README.md");
544            assert_eq!(v.read_file("proj/README.md").unwrap(), "hello\n");
545        }
546    }
547
548    #[test]
549    fn practice_keys_have_the_real_shape() {
550        for v in both("ssh") {
551            let (private, public) = v.practice_ssh_key(".ssh/id_ed25519", "ed25519", "me@laptop").unwrap();
552            assert_eq!(public, ".ssh/id_ed25519.pub");
553            assert!(v.read_file(&private).unwrap().contains("PRIVATE KEY"));
554            assert!(v.read_file(&public).unwrap().starts_with("ssh-ed25519 "));
555            if let Some(mode) = v.metadata(&private).unwrap().mode {
556                assert_eq!(mode & 0o777, 0o600);
557            }
558            assert!(v.practice_ssh_key(".ssh/id_ed25519", "ed25519", "x").is_err(), "never overwrites");
559        }
560    }
561}