1use 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub struct FindQuery {
18 pub name: Option<String>,
20 pub ignore_case: bool,
21 pub kind: Option<char>,
23 pub max_depth: Option<usize>,
25}
26
27impl FindQuery {
28 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 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
89fn join_display(base: &str, name: &str) -> String {
91 format!("{}/{}", base.trim_end_matches('/'), name)
92}
93
94fn 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 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 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 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 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 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 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 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 }
331 Ok(names)
332 }
333
334 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 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 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 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 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 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 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}