chroot_deploy/
lib.rs

1// Copyright (C) 2023-2024 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4#![allow(clippy::let_and_return, clippy::let_unit_value)]
5#![warn(clippy::dbg_macro, clippy::unwrap_used)]
6
7mod args;
8
9use std::env;
10use std::env::temp_dir;
11use std::ffi::OsStr;
12use std::ffi::OsString;
13use std::fs;
14use std::future::ready;
15use std::future::Future;
16use std::io;
17use std::mem::size_of;
18use std::ops::Deref;
19use std::ops::DerefMut;
20use std::path::Path;
21use std::process::ExitStatus;
22use std::process::Stdio;
23use std::str;
24use std::str::FromStr as _;
25use std::time::Duration;
26
27use anyhow::Context as _;
28use anyhow::Result;
29
30use clap::error::ErrorKind;
31use clap::Parser as _;
32
33use futures_util::join;
34use futures_util::TryFutureExt as _;
35
36use fs4::lock_contended_error;
37use fs4::tokio::AsyncFileExt as _;
38
39use tar::Archive;
40
41use tokio::fs::canonicalize;
42use tokio::fs::copy;
43use tokio::fs::create_dir_all;
44use tokio::fs::remove_dir_all;
45use tokio::fs::remove_file;
46use tokio::fs::try_exists;
47use tokio::fs::File;
48use tokio::io::AsyncReadExt as _;
49use tokio::io::AsyncSeekExt as _;
50use tokio::io::AsyncWriteExt as _;
51use tokio::process::Command as Process;
52use tokio::task::spawn_blocking;
53use tokio::time::sleep;
54
55use xz2::read::XzDecoder;
56
57use crate::args::Args;
58use crate::args::Command;
59use crate::args::Deploy;
60
61
62/// Extract the file stem of a path.
63///
64/// Contrary to `Path::file_stem`, this function return the file name
65/// part before *any* extensions, not just the last one.
66fn file_stem(path: &Path) -> Result<&OsStr> {
67  let mut last_stem = path.as_os_str();
68
69  loop {
70    let stem = Path::new(last_stem)
71      .file_stem()
72      .with_context(|| format!("failed to extract file stem of path `{}`", path.display()))?;
73
74    if stem == last_stem {
75      break Ok(stem)
76    }
77
78    last_stem = stem;
79  }
80}
81
82
83#[derive(Debug)]
84struct FileLockGuard<'file> {
85  /// The locked file.
86  file: Option<&'file mut File>,
87}
88
89impl<'file> FileLockGuard<'file> {
90  async fn lock(file: &'file mut File) -> Result<Self> {
91    let locked_err = lock_contended_error();
92    loop {
93      // Really the freakin' `lock_exclusive` API should be returning a
94      // future, but that seems to be too much to ask and so we roll our
95      // own poor man's future here by trying and retrying after a delay.
96      match file.try_lock_exclusive() {
97        Ok(()) => {
98          let slf = Self { file: Some(file) };
99          break Ok(slf)
100        },
101        Err(err) => {
102          if err.kind() == locked_err.kind() {
103            let () = sleep(Duration::from_millis(100)).await;
104          } else {
105            break Err(err).context("failed to lock file")
106          }
107        },
108      }
109    }
110  }
111
112  #[cfg(test)]
113  fn unlock(mut self) -> Result<&'file mut File> {
114    let file = self.file.take().expect("lock guard without a locked file");
115    let () = file.unlock().context("failed to unlock file")?;
116    Ok(file)
117  }
118}
119
120impl Deref for FileLockGuard<'_> {
121  type Target = File;
122
123  fn deref(&self) -> &Self::Target {
124    self
125      .file
126      .as_ref()
127      .expect("lock guard without a locked file")
128  }
129}
130
131impl DerefMut for FileLockGuard<'_> {
132  fn deref_mut(&mut self) -> &mut Self::Target {
133    self
134      .file
135      .as_mut()
136      .expect("lock guard without a locked file")
137  }
138}
139
140impl Drop for FileLockGuard<'_> {
141  fn drop(&mut self) {
142    if let Some(file) = self.file.as_ref() {
143      // TODO: Should not unwrap.
144      let () = file.unlock().expect("failed to unlock file");
145    }
146  }
147}
148
149
150async fn read_ref_cnt(file: &mut FileLockGuard<'_>) -> Result<usize> {
151  let _offset = file.rewind().await?;
152  let mut buffer = [0u8; size_of::<usize>()];
153  let count = file.read(&mut buffer).await?;
154  if count == 0 {
155    Ok(0)
156  } else {
157    let data = buffer.get(0..count).expect("read returned invalid count");
158    let data = str::from_utf8(data).context("reference count file data is not valid UTF-8")?;
159    let ref_cnt = usize::from_str(data)
160      .context("reference count file does not contain a valid reference count")?;
161    Ok(ref_cnt)
162  }
163}
164
165
166async fn write_ref_cnt(file: &mut FileLockGuard<'_>, ref_cnt: usize) -> Result<()> {
167  let _offset = file.rewind().await?;
168  let ref_cnt = ref_cnt.to_string();
169  let () = file
170    .write_all(ref_cnt.as_bytes())
171    .await
172    .context("failed to write reference count")?;
173  Ok(())
174}
175
176
177/// # Returns
178/// This function returns a lock guard if the reference count is one, in
179/// which case callers may want to perform a one-time initialization.
180async fn inc_ref_cnt(ref_file: &mut File) -> Result<Option<FileLockGuard<'_>>> {
181  let mut guard = FileLockGuard::lock(ref_file).await?;
182  let ref_cnt = read_ref_cnt(&mut guard).await?;
183  let () = write_ref_cnt(&mut guard, ref_cnt + 1).await?;
184  let guard = if ref_cnt == 0 { Some(guard) } else { None };
185  Ok(guard)
186}
187
188/// # Returns
189/// This function returns a lock guard if the reference count reached
190/// zero.
191async fn dec_ref_cnt(ref_file: &mut File) -> Result<Option<FileLockGuard<'_>>> {
192  let mut guard = FileLockGuard::lock(ref_file).await?;
193  let ref_cnt = read_ref_cnt(&mut guard).await?;
194  let () = write_ref_cnt(
195    &mut guard,
196    ref_cnt
197      .checked_sub(1)
198      .expect("cannot decrease reference count of zero"),
199  )
200  .await?;
201
202  let guard = if ref_cnt == 1 { Some(guard) } else { None };
203  Ok(guard)
204}
205
206
207async fn with_ref<S, FutS, B, FutB, C, FutC>(
208  ref_path: &Path,
209  setup: S,
210  body: B,
211  cleanup: C,
212) -> Result<()>
213where
214  S: FnOnce() -> FutS,
215  FutS: Future<Output = Result<()>>,
216  B: FnOnce() -> FutB,
217  FutB: Future<Output = Result<()>>,
218  C: FnOnce() -> FutC,
219  FutC: Future<Output = Result<()>>,
220{
221  let mut ref_file = File::options()
222    .create(true)
223    .read(true)
224    .write(true)
225    .truncate(false)
226    .open(ref_path)
227    .await
228    .with_context(|| format!("failed to open `{}`", ref_path.display()))?;
229
230  let guard = inc_ref_cnt(&mut ref_file).await?;
231  if guard.is_some() {
232    let setup_result = setup().await;
233    let () = drop(guard);
234
235    // NB: We never concluded the setup code so we do not invoke the
236    //     cleanup on any of the error paths.
237    if let Err(setup_err) = setup_result {
238      match dec_ref_cnt(&mut ref_file).await {
239        Ok(Some(_guard)) => {
240          // We treat lock file removal as optional and ignore errors.
241          let _result = remove_file(ref_path).await;
242          return Err(setup_err)
243        },
244        Ok(None) => return Err(setup_err),
245        Err(inner_err) => return Err(setup_err.context(inner_err)),
246      }
247    }
248  } else {
249    let () = drop(guard);
250  }
251
252
253  let body_result = body().await;
254  let result = match dec_ref_cnt(&mut ref_file).await {
255    Ok(Some(guard)) => {
256      let cleanup_result = cleanup().await;
257      // We treat lock file removal as optional and ignore errors.
258      let _result = remove_file(ref_path).await;
259      let () = drop(guard);
260      body_result.and(cleanup_result)
261    },
262    Ok(None) => body_result,
263    Err(inner_err) => {
264      // NB: If we fail to decrement the reference count it's not safe
265      //     to invoke any cleanup, because we have no idea how many
266      //     outstanding references there may be. All we can do is
267      //     short-circuit here.
268      body_result.map_err(|err| err.context(inner_err))
269    },
270  };
271
272  let () = drop(ref_file);
273  result
274}
275
276
277/// Unpack a compressed tar archive.
278async fn unpack_compressed_tar(archive: &Path, dst: &Path) -> Result<()> {
279  let () = create_dir_all(dst)
280    .await
281    .with_context(|| format!("failed to create directory `{}`", dst.display()))?;
282  let archive = archive.to_path_buf();
283  let dst = dst.to_path_buf();
284
285  // TODO: Need to support different compression algorithms.
286  let result = spawn_blocking(move || {
287    let file = fs::File::open(&archive).context("failed to open archive")?;
288    let decoder = XzDecoder::new_multi_decoder(file);
289    let mut extracter = Archive::new(decoder);
290    let () = extracter.set_overwrite(true);
291    let () = extracter.unpack(dst).context("failed to unpack archive")?;
292    Ok(())
293  })
294  .await?;
295
296  result
297}
298
299
300/// Format a command with the given list of arguments as a string.
301fn format_command<C, A, S>(command: C, args: A) -> String
302where
303  C: AsRef<OsStr>,
304  A: IntoIterator<Item = S>,
305  S: AsRef<OsStr>,
306{
307  args.into_iter().fold(
308    command.as_ref().to_string_lossy().into_owned(),
309    |mut cmd, arg| {
310      cmd += " ";
311      cmd += arg.as_ref().to_string_lossy().deref();
312      cmd
313    },
314  )
315}
316
317fn evaluate<C, A, S>(
318  status: ExitStatus,
319  command: C,
320  args: A,
321  stderr: Option<&[u8]>,
322) -> io::Result<()>
323where
324  C: AsRef<OsStr>,
325  A: IntoIterator<Item = S>,
326  S: AsRef<OsStr>,
327{
328  if !status.success() {
329    let code = if let Some(code) = status.code() {
330      format!(" ({code})")
331    } else {
332      " (terminated by signal)".to_string()
333    };
334
335    let stderr = String::from_utf8_lossy(stderr.unwrap_or(&[]));
336    let stderr = stderr.trim_end();
337    let stderr = if !stderr.is_empty() {
338      format!(": {stderr}")
339    } else {
340      String::new()
341    };
342
343    Err(io::Error::new(
344      io::ErrorKind::Other,
345      format!(
346        "`{}` reported non-zero exit-status{code}{stderr}",
347        format_command(command, args)
348      ),
349    ))
350  } else {
351    Ok(())
352  }
353}
354
355/// Run a command with the provided arguments.
356async fn run_command<C, A, S>(command: C, args: A) -> io::Result<()>
357where
358  C: AsRef<OsStr>,
359  A: IntoIterator<Item = S> + Clone,
360  S: AsRef<OsStr>,
361{
362  let output = Process::new(command.as_ref())
363    .stdin(Stdio::inherit())
364    .stdout(Stdio::inherit())
365    .stderr(Stdio::inherit())
366    .env_clear()
367    .envs(env::vars().filter(|(k, _)| k == "PATH"))
368    .args(args.clone())
369    .output()
370    .await
371    .map_err(|err| {
372      io::Error::new(
373        io::ErrorKind::Other,
374        format!(
375          "failed to run `{}`: {err}",
376          format_command(command.as_ref(), args.clone())
377        ),
378      )
379    })?;
380
381  let () = evaluate(output.status, command, args, Some(&output.stderr))?;
382  Ok(())
383}
384
385/// Run a command with the provided arguments.
386async fn check_command<C, A, S>(command: C, args: A) -> io::Result<()>
387where
388  C: AsRef<OsStr>,
389  A: IntoIterator<Item = S> + Clone,
390  S: AsRef<OsStr>,
391{
392  let status = Process::new(command.as_ref())
393    .stdin(Stdio::inherit())
394    .stdout(Stdio::inherit())
395    .stderr(Stdio::inherit())
396    .env_clear()
397    .envs(env::vars().filter(|(k, _)| k == "PATH"))
398    .args(args.clone())
399    .status()
400    .await
401    .map_err(|err| {
402      io::Error::new(
403        io::ErrorKind::Other,
404        format!(
405          "failed to run `{}`: {err}",
406          format_command(command.as_ref(), args.clone())
407        ),
408      )
409    })?;
410
411  let () = evaluate(status, command, args, None)?;
412  Ok(())
413}
414
415
416async fn setup_chroot(archive: &Path, chroot: &Path) -> Result<()> {
417  let present = try_exists(chroot)
418    .await
419    .with_context(|| format!("failed to check existence of `{}`", chroot.display()))?;
420
421  if !present {
422    let () = unpack_compressed_tar(archive, chroot)
423      .await
424      .with_context(|| {
425        format!(
426          "failed to extract archive `{}` into chroot `{}`",
427          archive.display(),
428          chroot.display()
429        )
430      })?;
431  }
432
433  let proc = chroot.join("proc");
434  let proc = run_command(
435    "mount",
436    [
437      OsStr::new("-t"),
438      OsStr::new("proc"),
439      OsStr::new("proc"),
440      proc.as_os_str(),
441    ],
442  );
443  let dev = chroot.join("dev");
444  let dev = create_dir_all(&dev).and_then(|()| {
445    run_command(
446      "mount",
447      [OsStr::new("--rbind"), OsStr::new("/dev"), dev.as_os_str()],
448    )
449  });
450  let sys = chroot.join("sys");
451  let sys = create_dir_all(&sys).and_then(|()| {
452    run_command(
453      "mount",
454      [OsStr::new("--rbind"), OsStr::new("/sys"), sys.as_os_str()],
455    )
456  });
457  let repos = chroot.join("var").join("db").join("repos");
458  let repos = create_dir_all(&repos).and_then(|()| {
459    run_command(
460      "mount",
461      [
462        OsStr::new("--bind"),
463        OsStr::new("/var/db/repos"),
464        repos.as_os_str(),
465      ],
466    )
467  });
468  let tmp = chroot.join("tmp");
469  let tmp = create_dir_all(&tmp).and_then(|()| {
470    run_command(
471      "mount",
472      [OsStr::new("--bind"), OsStr::new("/tmp"), tmp.as_os_str()],
473    )
474  });
475  let run = chroot.join("run");
476  let run = create_dir_all(&run).and_then(|()| {
477    run_command(
478      "mount",
479      [OsStr::new("--bind"), OsStr::new("/run"), run.as_os_str()],
480    )
481  });
482  let resolve = canonicalize(Path::new("/etc/resolv.conf"))
483    .and_then(|resolve| copy(resolve, chroot.join("etc").join("resolv.conf")))
484    .and_then(|_count| ready(Ok(())));
485  let results = <[_; 7]>::from(join!(proc, dev, sys, repos, tmp, run, resolve));
486  let () = results.into_iter().try_for_each(|result| result)?;
487  Ok(())
488}
489
490async fn chroot(chroot: &Path, command: Option<&[OsString]>, user: Option<&OsStr>) -> Result<()> {
491  let args = [
492    OsStr::new("/bin/su"),
493    OsStr::new("--login"),
494    user.unwrap_or_else(|| OsStr::new("root")),
495  ];
496  let args = args.as_slice();
497
498  let command = if let Some(command) = command {
499    let mut iter = command.iter();
500    format_command(iter.next().context("no command given")?, iter)
501  } else {
502    let args = [
503      r#"PS1="(chroot) \[\033[01;32m\]\u@\h\[\033[01;34m\] \w \$\[\033[00m\] ""#,
504      "bash",
505      "--norc",
506      "-i",
507    ];
508    format_command("/bin/env", args)
509  };
510
511  let () = check_command(
512    "chroot",
513    [chroot.as_os_str()]
514      .as_slice()
515      .iter()
516      .chain(args)
517      .chain([OsStr::new("--session-command"), OsStr::new(&command)].as_slice()),
518  )
519  .await?;
520  Ok(())
521}
522
523async fn cleanup_chroot(chroot: &Path, remove: bool) -> Result<()> {
524  let run = run_command("umount", [chroot.join("run")]);
525  let tmp = run_command("umount", [chroot.join("tmp")]);
526  let repos = run_command("umount", [chroot.join("var").join("db").join("repos")]);
527  let proc = run_command("umount", [chroot.join("proc")]);
528  let sys = run_command(
529    "umount",
530    [
531      OsString::from("--recursive"),
532      chroot.join("sys").into_os_string(),
533    ],
534  );
535  let dev = run_command(
536    "umount",
537    [
538      OsString::from("--recursive"),
539      chroot.join("dev").into_os_string(),
540    ],
541  );
542
543  let results = join!(dev, sys, repos, tmp, run);
544  // There exists some kind of a dependency causing the `proc` unmount
545  // to occasionally fail when run in parallel to the others. So make
546  // sure to run it strictly afterwards.
547  let result = proc.await;
548  let () = <[_; 5]>::from(results)
549    .into_iter()
550    .chain([result])
551    .try_for_each(|result| result)?;
552
553  if remove {
554    let () = remove_dir_all(chroot).await?;
555  }
556  Ok(())
557}
558
559/// Handler for the `deploy` sub-command.
560async fn deploy(deploy: Deploy) -> Result<()> {
561  let Deploy {
562    archive,
563    command,
564    user,
565    remove,
566  } = deploy;
567
568  let tmp = temp_dir();
569  let stem = file_stem(&archive).with_context(|| {
570    format!(
571      "failed to extract file stem of path `{}`",
572      archive.display()
573    )
574  })?;
575
576  let chroot_dir = tmp.join(stem);
577  let ref_path = tmp.join(stem).with_extension("lck");
578
579  let setup = || setup_chroot(&archive, &chroot_dir);
580  let chroot = || chroot(&chroot_dir, command.as_deref(), user.as_deref());
581  let cleanup = || cleanup_chroot(&chroot_dir, remove);
582
583  with_ref(&ref_path, setup, chroot, cleanup).await
584}
585
586
587/// Run the program and report errors, if any.
588pub async fn run<A, T>(args: A) -> Result<()>
589where
590  A: IntoIterator<Item = T>,
591  T: Into<OsString> + Clone,
592{
593  let args = match Args::try_parse_from(args) {
594    Ok(args) => args,
595    Err(err) => match err.kind() {
596      ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
597        print!("{}", err);
598        return Ok(())
599      },
600      _ => return Err(err.into()),
601    },
602  };
603
604  match args.command {
605    Command::Deploy(deploy) => self::deploy(deploy).await,
606  }
607}
608
609
610#[cfg(test)]
611mod tests {
612  use super::*;
613
614  use std::ffi::OsStr;
615
616  use anyhow::anyhow;
617
618  use tempfile::tempfile;
619  use tempfile::NamedTempFile;
620
621  use tokio::select;
622
623
624  /// Check that we can extract a file's stem properly.
625  #[test]
626  fn file_stem_extraction() {
627    let path = Path::new("/tmp/stage3-amd64-openrc-20240211T161834Z.tar.xz");
628    let stem = file_stem(path).unwrap();
629    assert_eq!(stem, OsStr::new("stage3-amd64-openrc-20240211T161834Z"));
630  }
631
632  /// Check that we can increment the reference count of a file.
633  #[tokio::test]
634  async fn lock_file_ref_cnt_inc() {
635    let mut file = File::from_std(tempfile().unwrap());
636
637    let mut guard = inc_ref_cnt(&mut file).await.unwrap().unwrap();
638    let ref_cnt = read_ref_cnt(&mut guard).await.unwrap();
639    assert_eq!(ref_cnt, 1);
640  }
641
642  /// Check that we can increment the reference count of a file multiple
643  /// times.
644  #[tokio::test]
645  async fn lock_file_ref_cnt_inc_multi() {
646    let mut file = File::from_std(tempfile().unwrap());
647
648    let guard = inc_ref_cnt(&mut file).await.unwrap();
649    assert!(guard.is_some());
650    drop(guard);
651
652    let guard = inc_ref_cnt(&mut file).await.unwrap();
653    assert!(guard.is_none());
654    drop(guard);
655
656    let guard = inc_ref_cnt(&mut file).await.unwrap();
657    assert!(guard.is_none());
658    drop(guard);
659
660    let guard = dec_ref_cnt(&mut file).await.unwrap();
661    assert!(guard.is_none());
662    drop(guard);
663
664    let guard = dec_ref_cnt(&mut file).await.unwrap();
665    assert!(guard.is_none());
666    drop(guard);
667
668    let guard = dec_ref_cnt(&mut file).await.unwrap();
669    assert!(guard.is_some());
670  }
671
672  /// Check that we can decrement the reference count of a file.
673  #[tokio::test]
674  async fn lock_file_ref_cnt_dec() {
675    let mut file = File::from_std(tempfile().unwrap());
676
677    let guard = inc_ref_cnt(&mut file).await.unwrap().unwrap();
678    let file = guard.unlock().unwrap();
679
680    let mut guard = dec_ref_cnt(file).await.unwrap().unwrap();
681    let ref_cnt = read_ref_cnt(&mut guard).await.unwrap();
682    assert_eq!(ref_cnt, 0);
683  }
684
685  /// Check that file locking ensures mutual exclusion as expected.
686  #[tokio::test]
687  async fn lock_file_lock() {
688    // We need to work with a named file here, because we should not
689    // lock a single `File` instance multiple times. So we open the file
690    // multiple times by path instead.
691    let file = NamedTempFile::new().unwrap();
692    let mut file1 = File::open(file.path()).await.unwrap();
693    let _guard1 = inc_ref_cnt(&mut file1).await.unwrap().unwrap();
694
695    let mut file2 = File::open(file.path()).await.unwrap();
696    let inc = inc_ref_cnt(&mut file2);
697    let timeout = sleep(Duration::from_millis(10));
698
699    select! {
700      result = inc => panic!("should not be able to increment reference count but got: {result:?}"),
701      () = timeout => (),
702    }
703  }
704
705  /// Check that a setup failure is handled as expected by `with_ref`.
706  #[tokio::test]
707  async fn with_ref_setup_failure() {
708    let file = NamedTempFile::new().unwrap();
709    let path = file.path();
710
711    let setup = || async { Err(anyhow!("setup fail")) };
712    let body = || async { unreachable!() };
713    let cleanup = || async { unreachable!() };
714
715    let result = with_ref(path, setup, body, cleanup).await;
716    assert_eq!(result.unwrap_err().to_string(), "setup fail");
717    assert!(!try_exists(path).await.unwrap());
718  }
719
720  /// Check that a body failure is handled as expected by `with_ref`.
721  #[tokio::test]
722  async fn with_ref_body_failure() {
723    let file = NamedTempFile::new().unwrap();
724    let path = file.path();
725
726    let setup = || async { Ok(()) };
727    let body = || async { Err(anyhow!("body fail")) };
728    let cleanup = || async { Ok(()) };
729
730    let result = with_ref(path, setup, body, cleanup).await;
731    assert_eq!(result.unwrap_err().to_string(), "body fail");
732    assert!(!try_exists(path).await.unwrap());
733  }
734
735  /// Check that a body failure in conjunction with a cleanup is handled
736  /// as expected by `with_ref`.
737  #[tokio::test]
738  async fn with_ref_body_cleanup_failure() {
739    let file = NamedTempFile::new().unwrap();
740    let path = file.path();
741
742    let setup = || async { Ok(()) };
743    let body = || async { Err(anyhow!("body fail")) };
744    let cleanup = || async { Err(anyhow!("cleanup fail")) };
745
746    let result = with_ref(path, setup, body, cleanup).await;
747    assert_eq!(result.unwrap_err().to_string(), "body fail");
748    assert!(!try_exists(path).await.unwrap());
749  }
750
751  /// Check that a cleanup failure is handled as expected by `with_ref`.
752  #[tokio::test]
753  async fn with_ref_cleanup_failure() {
754    let file = NamedTempFile::new().unwrap();
755    let path = file.path();
756
757    let setup = || async { Ok(()) };
758    let body = || async { Ok(()) };
759    let cleanup = || async { Err(anyhow!("cleanup fail")) };
760
761    let result = with_ref(path, setup, body, cleanup).await;
762    assert_eq!(result.unwrap_err().to_string(), "cleanup fail");
763    assert!(!try_exists(path).await.unwrap());
764  }
765
766  /// Check that we do not error out on the --version option.
767  #[tokio::test]
768  async fn version() {
769    let args = [OsStr::new("chroot-deploy"), OsStr::new("--version")];
770    let () = run(args).await.unwrap();
771  }
772
773  /// Check that we do not error out on the --help option.
774  #[tokio::test]
775  async fn help() {
776    let args = [OsStr::new("chroot-deploy"), OsStr::new("--help")];
777    let () = run(args).await.unwrap();
778  }
779}