Skip to main content

chroot_deploy/
lib.rs

1// Copyright (C) 2023-2026 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::ops::Deref;
18use std::ops::DerefMut;
19use std::path::Path;
20use std::process;
21use std::process::ExitStatus;
22use std::process::Stdio;
23use std::ptr;
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::tokio::AsyncFileExt as _;
37
38use tar::Archive;
39
40use tokio::fs::canonicalize;
41use tokio::fs::copy;
42use tokio::fs::create_dir_all;
43use tokio::fs::remove_dir_all;
44use tokio::fs::remove_file;
45use tokio::fs::try_exists;
46use tokio::fs::File;
47use tokio::io::AsyncReadExt as _;
48use tokio::io::AsyncSeekExt as _;
49use tokio::io::AsyncWriteExt as _;
50use tokio::process::Command as Process;
51use tokio::task::spawn_blocking;
52use tokio::time::sleep;
53
54use xz2::read::XzDecoder;
55
56use crate::args::Args;
57use crate::args::Command;
58use crate::args::Deploy;
59
60
61/// Extract the file stem of a path.
62///
63/// Contrary to `Path::file_stem`, this function return the file name
64/// part before *any* extensions, not just the last one.
65fn file_stem(path: &Path) -> Result<&OsStr> {
66  let mut last_stem = path.as_os_str();
67
68  loop {
69    let stem = Path::new(last_stem)
70      .file_stem()
71      .with_context(|| format!("failed to extract file stem of path `{}`", path.display()))?;
72
73    if stem == last_stem {
74      break Ok(stem)
75    }
76
77    last_stem = stem;
78  }
79}
80
81
82#[derive(Debug)]
83struct FileLockGuard<'file> {
84  /// The locked file.
85  file: Option<&'file mut File>,
86}
87
88impl<'file> FileLockGuard<'file> {
89  async fn lock(file: &'file mut File) -> Result<Self> {
90    loop {
91      // Really the freakin' `lock_exclusive` API should be returning a
92      // future, but that seems to be too much to ask and so we roll our
93      // own poor man's future here by trying and retrying after a delay.
94      let locked = file.try_lock_exclusive().context("failed to lock file")?;
95      if locked {
96        let slf = Self { file: Some(file) };
97        break Ok(slf)
98      } else {
99        let () = sleep(Duration::from_millis(100)).await;
100      }
101    }
102  }
103
104  #[cfg(test)]
105  fn unlock(mut self) -> Result<&'file mut File> {
106    let file = self.file.take().expect("lock guard without a locked file");
107    let () = file.unlock().context("failed to unlock file")?;
108    Ok(file)
109  }
110}
111
112impl Deref for FileLockGuard<'_> {
113  type Target = File;
114
115  fn deref(&self) -> &Self::Target {
116    self
117      .file
118      .as_ref()
119      .expect("lock guard without a locked file")
120  }
121}
122
123impl DerefMut for FileLockGuard<'_> {
124  fn deref_mut(&mut self) -> &mut Self::Target {
125    self
126      .file
127      .as_mut()
128      .expect("lock guard without a locked file")
129  }
130}
131
132impl Drop for FileLockGuard<'_> {
133  fn drop(&mut self) {
134    if let Some(file) = self.file.as_ref() {
135      // TODO: Should not unwrap.
136      let () = file.unlock().expect("failed to unlock file");
137    }
138  }
139}
140
141
142async fn read_pids(file: &mut FileLockGuard<'_>) -> Result<Vec<u32>> {
143  let _offset = file.rewind().await?;
144  let mut data = String::new();
145  let _count = file.read_to_string(&mut data).await?;
146
147  if data.is_empty() {
148    Ok(Vec::new())
149  } else {
150    let mut pids = data
151      .lines()
152      .filter(|line| !line.is_empty())
153      .filter_map(|line| {
154        // Transparently ignore lines that are not actual PIDs. The file
155        // we read from is effectively user controlled.
156        u32::from_str(line).ok()
157      })
158      .collect::<Vec<u32>>();
159
160    let () = pids.sort_unstable();
161    Ok(pids)
162  }
163}
164
165
166async fn write_pids(file: &mut FileLockGuard<'_>, pids: &[u32]) -> Result<()> {
167  let _offset = file.rewind().await?;
168  let () = file
169    .set_len(0)
170    .await
171    .context("failed to truncate PID file")?;
172
173  let content = pids
174    .iter()
175    .map(|pid| pid.to_string())
176    .collect::<Vec<_>>()
177    .join("\n");
178  let () = file
179    .write_all(content.as_bytes())
180    .await
181    .context("failed to write PIDs")?;
182  Ok(())
183}
184
185
186/// Result of adding a PID to the reference file.
187#[derive(Debug)]
188enum AddPidResult<'file> {
189  /// Caller owns the PID file and holds the lock for setup.
190  #[allow(dead_code)]
191  OwnPid(FileLockGuard<'file>),
192  /// Caller should join the namespace of an existing process with this
193  /// PID.
194  ExistingPid(u32),
195}
196
197async fn add_pid(ref_file: &mut File, pid: u32) -> Result<AddPidResult<'_>> {
198  let mut guard = FileLockGuard::lock(ref_file).await?;
199  let mut pids = read_pids(&mut guard).await?;
200  let first_pid = pids.first().copied();
201
202  if let Err(idx) = pids.binary_search(&pid) {
203    let () = pids.insert(idx, pid);
204  }
205
206  let () = write_pids(&mut guard, &pids).await?;
207  if let Some(pid) = first_pid {
208    Ok(AddPidResult::ExistingPid(pid))
209  } else {
210    Ok(AddPidResult::OwnPid(guard))
211  }
212}
213
214/// # Returns
215/// This function returns a lock guard if the PID list became empty
216/// after removal.
217async fn remove_pid(ref_file: &mut File, pid: u32) -> Result<Option<FileLockGuard<'_>>> {
218  let mut guard = FileLockGuard::lock(ref_file).await?;
219  let mut pids = read_pids(&mut guard).await?;
220
221  if let Ok(idx) = pids.binary_search(&pid) {
222    let _pid = pids.remove(idx);
223  } else {
224    // Strictly speaking it's an invariant violation if the PID isn't
225    // found. Except that the file we work on is effectively user
226    // controlled. So just ignore the problem, which should correct
227    // itself once we write out the updated PID list.
228  }
229
230  let () = write_pids(&mut guard, &pids).await?;
231  let guard = if pids.is_empty() { Some(guard) } else { None };
232  Ok(guard)
233}
234
235
236async fn with_ref<S, FutS, B, FutB, C, FutC>(
237  ref_path: &Path,
238  setup: S,
239  body: B,
240  cleanup: C,
241) -> Result<()>
242where
243  S: FnOnce() -> FutS,
244  FutS: Future<Output = Result<()>>,
245  B: FnOnce(Option<u32>) -> FutB,
246  FutB: Future<Output = Result<()>>,
247  C: FnOnce(bool) -> FutC,
248  FutC: Future<Output = Result<()>>,
249{
250  let mut ref_file = File::options()
251    .create(true)
252    .read(true)
253    .write(true)
254    .truncate(false)
255    .open(ref_path)
256    .await
257    .with_context(|| format!("failed to open `{}`", ref_path.display()))?;
258
259  let pid = process::id();
260  let add_result = add_pid(&mut ref_file, pid).await?;
261  let (is_first_user, ns_pid) = match &add_result {
262    AddPidResult::OwnPid(_) => (true, None),
263    AddPidResult::ExistingPid(pid) => (false, Some(*pid)),
264  };
265
266  if is_first_user {
267    let setup_result = setup().await;
268    let () = drop(add_result);
269
270    // NB: We never concluded the setup code so we do not invoke the
271    //     cleanup on any of the error paths.
272    if let Err(setup_err) = setup_result {
273      match remove_pid(&mut ref_file, pid).await {
274        Ok(Some(_guard)) => {
275          // We treat lock file removal as optional and ignore errors.
276          let _result = remove_file(ref_path).await;
277          return Err(setup_err)
278        },
279        Ok(None) => return Err(setup_err),
280        Err(inner_err) => return Err(setup_err.context(inner_err)),
281      }
282    }
283  } else {
284    let () = drop(add_result);
285  }
286
287
288  let body_result = body(ns_pid).await;
289  let result = match remove_pid(&mut ref_file, pid).await {
290    Ok(Some(guard)) => {
291      let cleanup_result = cleanup(is_first_user).await;
292      // We treat lock file removal as optional and ignore errors.
293      let _result = remove_file(ref_path).await;
294      let () = drop(guard);
295      body_result.and(cleanup_result)
296    },
297    Ok(None) => body_result,
298    Err(inner_err) => {
299      // NB: If we fail to remove our PID it's not safe to invoke any
300      //     cleanup, because we have no idea how many outstanding
301      //     references there may be. All we can do is short-circuit
302      //     here.
303      body_result.map_err(|err| err.context(inner_err))
304    },
305  };
306
307  let () = drop(ref_file);
308  result
309}
310
311
312/// Unpack a compressed tar archive.
313async fn unpack_compressed_tar(archive: &Path, dst: &Path) -> Result<()> {
314  let () = create_dir_all(dst)
315    .await
316    .with_context(|| format!("failed to create directory `{}`", dst.display()))?;
317  let archive = archive.to_path_buf();
318  let dst = dst.to_path_buf();
319
320  // TODO: Need to support different compression algorithms.
321  let result = spawn_blocking(move || {
322    let file = fs::File::open(&archive).context("failed to open archive")?;
323    let decoder = XzDecoder::new_multi_decoder(file);
324    let mut extracter = Archive::new(decoder);
325    let () = extracter.set_overwrite(true);
326    let () = extracter.set_preserve_ownerships(true);
327    let () = extracter.set_preserve_permissions(true);
328    let () = extracter.unpack(dst).context("failed to unpack archive")?;
329    Ok(())
330  })
331  .await?;
332
333  result
334}
335
336
337/// Concatenate a command and its arguments into a single string.
338fn concat_command<C, A, S>(command: C, args: A) -> OsString
339where
340  C: AsRef<OsStr>,
341  A: IntoIterator<Item = S>,
342  S: AsRef<OsStr>,
343{
344  args
345    .into_iter()
346    .fold(command.as_ref().to_os_string(), |mut cmd, arg| {
347      cmd.push(OsStr::new(" "));
348      cmd.push(arg.as_ref());
349      cmd
350    })
351}
352
353
354/// Format a command with the given list of arguments as a string.
355fn format_command<C, A, S>(command: C, args: A) -> String
356where
357  C: AsRef<OsStr>,
358  A: IntoIterator<Item = S>,
359  S: AsRef<OsStr>,
360{
361  concat_command(command, args).to_string_lossy().to_string()
362}
363
364fn evaluate<C, A, S>(
365  status: ExitStatus,
366  command: C,
367  args: A,
368  stderr: Option<&[u8]>,
369) -> io::Result<()>
370where
371  C: AsRef<OsStr>,
372  A: IntoIterator<Item = S>,
373  S: AsRef<OsStr>,
374{
375  if !status.success() {
376    let code = if let Some(code) = status.code() {
377      format!(" ({code})")
378    } else {
379      " (terminated by signal)".to_string()
380    };
381
382    let stderr = String::from_utf8_lossy(stderr.unwrap_or(&[]));
383    let stderr = stderr.trim_end();
384    let stderr = if !stderr.is_empty() {
385      format!(": {stderr}")
386    } else {
387      String::new()
388    };
389
390    Err(io::Error::other(format!(
391      "`{}` reported non-zero exit-status{code}{stderr}",
392      format_command(command, args)
393    )))
394  } else {
395    Ok(())
396  }
397}
398
399/// Run a command with the provided arguments.
400async fn run_command<C, A, S>(command: C, args: A) -> io::Result<()>
401where
402  C: AsRef<OsStr>,
403  A: IntoIterator<Item = S> + Clone,
404  S: AsRef<OsStr>,
405{
406  let output = Process::new(command.as_ref())
407    .stdin(Stdio::inherit())
408    .stdout(Stdio::inherit())
409    .stderr(Stdio::inherit())
410    .env_clear()
411    .envs(env::vars().filter(|(k, _)| k == "PATH"))
412    .args(args.clone())
413    .output()
414    .await
415    .map_err(|err| {
416      io::Error::other(format!(
417        "failed to run `{}`: {err}",
418        format_command(command.as_ref(), args.clone())
419      ))
420    })?;
421
422  let () = evaluate(output.status, command, args, Some(&output.stderr))?;
423  Ok(())
424}
425
426/// Run a command with the provided arguments.
427async fn check_command<C, A, S>(command: C, args: A) -> io::Result<()>
428where
429  C: AsRef<OsStr>,
430  A: IntoIterator<Item = S> + Clone,
431  S: AsRef<OsStr>,
432{
433  let status = Process::new(command.as_ref())
434    .stdin(Stdio::inherit())
435    .stdout(Stdio::inherit())
436    .stderr(Stdio::inherit())
437    .env_clear()
438    .envs(env::vars().filter(|(k, _)| k == "PATH"))
439    .args(args.clone())
440    .status()
441    .await
442    .map_err(|err| {
443      io::Error::other(format!(
444        "failed to run `{}`: {err}",
445        format_command(command.as_ref(), args.clone())
446      ))
447    })?;
448
449  let () = evaluate(status, command, args, None)?;
450  Ok(())
451}
452
453
454/// Create a new mount namespace for the current process.
455///
456/// This function calls `unshare(CLONE_NEWNS)` to create a new mount
457/// namespace and sets the root mount propagation to private to prevent
458/// mount events from leaking to the host.
459fn create_namespace() -> Result<()> {
460  // Create new mount namespace (process-wide)
461  let rc = unsafe { libc::unshare(libc::CLONE_NEWNS) };
462  if rc != 0 {
463    return Err(io::Error::last_os_error()).context("failed to create mount namespace");
464  }
465
466  // Set propagation to private (prevent mount events leaking to host).
467  let rc = unsafe {
468    libc::mount(
469      ptr::null(),
470      c"/".as_ptr(),
471      ptr::null(),
472      libc::MS_REC | libc::MS_PRIVATE,
473      ptr::null(),
474    )
475  };
476  if rc != 0 {
477    return Err(io::Error::last_os_error()).context("failed to set mount propagation to private");
478  }
479
480  Ok(())
481}
482
483
484async fn setup_chroot(archive: &Path, chroot: &Path) -> Result<()> {
485  // Create mount namespace first (before any mounts).
486  let () = create_namespace()?;
487
488  let present = try_exists(chroot)
489    .await
490    .with_context(|| format!("failed to check existence of `{}`", chroot.display()))?;
491
492  if !present {
493    let () = unpack_compressed_tar(archive, chroot)
494      .await
495      .with_context(|| {
496        format!(
497          "failed to extract archive `{}` into chroot `{}`",
498          archive.display(),
499          chroot.display()
500        )
501      })?;
502  }
503
504  let proc = chroot.join("proc");
505  let proc = run_command(
506    "mount",
507    [
508      OsStr::new("-t"),
509      OsStr::new("proc"),
510      OsStr::new("proc"),
511      proc.as_os_str(),
512    ],
513  );
514  let dev = chroot.join("dev");
515  let dev = create_dir_all(&dev).and_then(|()| {
516    run_command(
517      "mount",
518      [OsStr::new("--rbind"), OsStr::new("/dev"), dev.as_os_str()],
519    )
520  });
521  let sys = chroot.join("sys");
522  let sys = create_dir_all(&sys).and_then(|()| {
523    run_command(
524      "mount",
525      [OsStr::new("--rbind"), OsStr::new("/sys"), sys.as_os_str()],
526    )
527  });
528  let repos = chroot.join("var").join("db").join("repos");
529  let repos = create_dir_all(&repos).and_then(|()| {
530    run_command(
531      "mount",
532      [
533        OsStr::new("--bind"),
534        OsStr::new("/var/db/repos"),
535        repos.as_os_str(),
536      ],
537    )
538  });
539  let tmp = chroot.join("tmp");
540  let tmp = create_dir_all(&tmp).and_then(|()| {
541    run_command(
542      "mount",
543      [OsStr::new("--bind"), OsStr::new("/tmp"), tmp.as_os_str()],
544    )
545  });
546  let run = chroot.join("run");
547  let run = create_dir_all(&run).and_then(|()| {
548    run_command(
549      "mount",
550      [OsStr::new("--bind"), OsStr::new("/run"), run.as_os_str()],
551    )
552  });
553  let resolve = canonicalize(Path::new("/etc/resolv.conf"))
554    .and_then(|resolve| copy(resolve, chroot.join("etc").join("resolv.conf")))
555    .and_then(|_count| ready(Ok(())));
556  let results = <[_; 7]>::from(join!(proc, dev, sys, repos, tmp, run, resolve));
557  let () = results.into_iter().try_for_each(|result| result)?;
558  Ok(())
559}
560
561async fn chroot(
562  chroot_dir: &Path,
563  ns_pid: Option<u32>,
564  command: Option<&[OsString]>,
565  user: Option<&OsStr>,
566) -> Result<()> {
567  let su_args = [
568    OsStr::new("/bin/su"),
569    OsStr::new("--login"),
570    user.unwrap_or_else(|| OsStr::new("root")),
571  ];
572  let su_args = su_args.as_slice();
573
574  let session_command = if let Some([cmd, args @ ..]) = command {
575    format_command(cmd, args)
576  } else {
577    let args = [
578      r#"PS1="(chroot) \[\033[01;32m\]\u@\h\[\033[01;34m\] \w \$\[\033[00m\] ""#,
579      "bash",
580      "--norc",
581      "-i",
582    ];
583    format_command("/bin/env", args)
584  };
585
586  if let Some(pid) = ns_pid {
587    // Join namespace of the first process using `nsenter`.
588    let target_arg = format!("--target={pid}");
589
590    let () = check_command(
591      "nsenter",
592      [
593        OsStr::new(&target_arg),
594        OsStr::new("--mount"),
595        OsStr::new("--"),
596      ]
597      .into_iter()
598      .chain(
599        [OsStr::new("chroot"), chroot_dir.as_os_str()]
600          .into_iter()
601          .chain(su_args.iter().copied())
602          .chain([
603            OsStr::new("--session-command"),
604            OsStr::new(&session_command),
605          ]),
606      ),
607    )
608    .await?;
609  } else {
610    // Already in namespace from setup phase, run `chroot` directly.
611    let () = check_command(
612      "chroot",
613      [chroot_dir.as_os_str()]
614        .as_slice()
615        .iter()
616        .chain(su_args)
617        .chain(
618          [
619            OsStr::new("--session-command"),
620            OsStr::new(&session_command),
621          ]
622          .as_slice(),
623        ),
624    )
625    .await?;
626  }
627  Ok(())
628}
629
630async fn cleanup_chroot(chroot: &Path, is_first_user: bool, remove: bool) -> Result<()> {
631  // Only unmount if we're the first user (i.e., our process is in the
632  // namespace). Non-first users only had their `nsenter` child process
633  // enter the namespace temporarily; their main process (running this
634  // cleanup code) remains in the host namespace where these mounts are
635  // not visible.
636  if is_first_user {
637    let run = run_command("umount", [chroot.join("run")]);
638    let tmp = run_command("umount", [chroot.join("tmp")]);
639    let repos = run_command("umount", [chroot.join("var").join("db").join("repos")]);
640    let proc = run_command("umount", [chroot.join("proc")]);
641    let sys = run_command(
642      "umount",
643      [
644        OsString::from("--recursive"),
645        chroot.join("sys").into_os_string(),
646      ],
647    );
648    let dev = run_command(
649      "umount",
650      [
651        OsString::from("--recursive"),
652        chroot.join("dev").into_os_string(),
653      ],
654    );
655
656    let results = join!(dev, sys, repos, tmp, run);
657    // There exists some kind of a dependency causing the `proc` unmount
658    // to occasionally fail when run in parallel to the others. So make
659    // sure to run it strictly afterwards.
660    let result = proc.await;
661    let () = <[_; 5]>::from(results)
662      .into_iter()
663      .chain([result])
664      .try_for_each(|result| result)?;
665  }
666
667  if remove {
668    let () = remove_dir_all(chroot).await?;
669  }
670  Ok(())
671}
672
673/// Handler for the `deploy` sub-command.
674async fn deploy(deploy: Deploy) -> Result<()> {
675  let Deploy {
676    archive,
677    command,
678    user,
679    remove,
680  } = deploy;
681
682  let tmp = temp_dir();
683  let stem = file_stem(&archive).with_context(|| {
684    format!(
685      "failed to extract file stem of path `{}`",
686      archive.display()
687    )
688  })?;
689
690  let chroot_dir = tmp.join(stem);
691  let ref_path = tmp.join(stem).with_extension("lck");
692
693  let setup = || setup_chroot(&archive, &chroot_dir);
694  let chroot = |ns_pid| chroot(&chroot_dir, ns_pid, command.as_deref(), user.as_deref());
695  let cleanup = |is_first_user| cleanup_chroot(&chroot_dir, is_first_user, remove);
696
697  with_ref(&ref_path, setup, chroot, cleanup).await
698}
699
700
701/// Run the program and report errors, if any.
702pub async fn run<A, T>(args: A) -> Result<()>
703where
704  A: IntoIterator<Item = T>,
705  T: Into<OsString> + Clone,
706{
707  let args = match Args::try_parse_from(args) {
708    Ok(args) => args,
709    Err(err) => match err.kind() {
710      ErrorKind::DisplayVersion
711      | ErrorKind::DisplayHelp
712      | ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand => {
713        print!("{}", err);
714        return Ok(())
715      },
716      _ => return Err(err.into()),
717    },
718  };
719
720  match args.command {
721    Command::Deploy(deploy) => self::deploy(deploy).await,
722  }
723}
724
725
726#[cfg(test)]
727mod tests {
728  use super::*;
729
730  use std::ffi::OsStr;
731
732  use anyhow::anyhow;
733
734  use tempfile::tempfile;
735  use tempfile::NamedTempFile;
736
737  use tokio::select;
738
739
740  /// Check that we can extract a file's stem properly.
741  #[test]
742  fn file_stem_extraction() {
743    let path = Path::new("/tmp/stage3-amd64-openrc-20240211T161834Z.tar.xz");
744    let stem = file_stem(path).unwrap();
745    assert_eq!(stem, OsStr::new("stage3-amd64-openrc-20240211T161834Z"));
746  }
747
748  /// Check that we can add a PID to a file.
749  #[tokio::test]
750  async fn lock_file_add_pid() {
751    let mut file = File::from_std(tempfile().unwrap());
752
753    let AddPidResult::OwnPid(mut guard) = add_pid(&mut file, 1234).await.unwrap() else {
754      panic!("expected OwnPid");
755    };
756    let pids = read_pids(&mut guard).await.unwrap();
757    assert_eq!(pids, vec![1234]);
758  }
759
760  /// Check that we can add multiple PIDs to a file.
761  #[tokio::test]
762  async fn lock_file_add_pid_multi() {
763    let mut file = File::from_std(tempfile().unwrap());
764
765    let result = add_pid(&mut file, 1000).await.unwrap();
766    assert!(matches!(result, AddPidResult::OwnPid(_)));
767    drop(result);
768
769    let result = add_pid(&mut file, 2000).await.unwrap();
770    assert!(matches!(result, AddPidResult::ExistingPid(1000)));
771    drop(result);
772
773    let result = add_pid(&mut file, 3000).await.unwrap();
774    assert!(matches!(result, AddPidResult::ExistingPid(1000)));
775    drop(result);
776
777    let guard = remove_pid(&mut file, 2000).await.unwrap();
778    assert!(guard.is_none());
779    drop(guard);
780
781    let guard = remove_pid(&mut file, 1000).await.unwrap();
782    assert!(guard.is_none());
783    drop(guard);
784
785    let guard = remove_pid(&mut file, 3000).await.unwrap();
786    assert!(guard.is_some());
787  }
788
789  /// Check that we can remove a PID from a file.
790  #[tokio::test]
791  async fn lock_file_remove_pid() {
792    let mut file = File::from_std(tempfile().unwrap());
793
794    let AddPidResult::OwnPid(guard) = add_pid(&mut file, 1234).await.unwrap() else {
795      panic!("expected OwnPid");
796    };
797    let file = guard.unlock().unwrap();
798
799    let mut guard = remove_pid(file, 1234).await.unwrap().unwrap();
800    let pids = read_pids(&mut guard).await.unwrap();
801    assert!(pids.is_empty());
802  }
803
804  /// Check that PIDs are stored sorted.
805  #[tokio::test]
806  async fn lock_file_pids_sorted() {
807    let mut file = File::from_std(tempfile().unwrap());
808
809    let result = add_pid(&mut file, 3000).await.unwrap();
810    drop(result);
811    let result = add_pid(&mut file, 1000).await.unwrap();
812    drop(result);
813    let result = add_pid(&mut file, 2000).await.unwrap();
814    drop(result);
815
816    let mut guard = FileLockGuard::lock(&mut file).await.unwrap();
817    let pids = read_pids(&mut guard).await.unwrap();
818    assert_eq!(pids, vec![1000, 2000, 3000]);
819  }
820
821  /// Check that file locking ensures mutual exclusion as expected.
822  #[tokio::test]
823  async fn lock_file_lock() {
824    // We need to work with a named file here, because we should not
825    // lock a single `File` instance multiple times. So we open the file
826    // multiple times by path instead.
827    let file = NamedTempFile::new().unwrap();
828    let mut file1 = File::options()
829      .read(true)
830      .write(true)
831      .open(file.path())
832      .await
833      .unwrap();
834    let AddPidResult::OwnPid(_guard1) = add_pid(&mut file1, 1000).await.unwrap() else {
835      panic!("expected OwnPid");
836    };
837
838    let mut file2 = File::options()
839      .read(true)
840      .write(true)
841      .open(file.path())
842      .await
843      .unwrap();
844    let add = add_pid(&mut file2, 2000);
845    let timeout = sleep(Duration::from_millis(10));
846
847    select! {
848      result = add => panic!("should not be able to add PID but got: {result:?}"),
849      () = timeout => (),
850    }
851  }
852
853  /// Check that a setup failure is handled as expected by `with_ref`.
854  #[tokio::test]
855  async fn with_ref_setup_failure() {
856    let file = NamedTempFile::new().unwrap();
857    let path = file.path();
858
859    let setup = || async { Err(anyhow!("setup fail")) };
860    let body = |_ns_pid| async { unreachable!() };
861    let cleanup = |_is_first_user| async { unreachable!() };
862
863    let result = with_ref(path, setup, body, cleanup).await;
864    assert_eq!(result.unwrap_err().to_string(), "setup fail");
865    assert!(!try_exists(path).await.unwrap());
866  }
867
868  /// Check that a body failure is handled as expected by `with_ref`.
869  #[tokio::test]
870  async fn with_ref_body_failure() {
871    let file = NamedTempFile::new().unwrap();
872    let path = file.path();
873
874    let setup = || async { Ok(()) };
875    let body = |_ns_pid| async { Err(anyhow!("body fail")) };
876    let cleanup = |_is_first_user| async { Ok(()) };
877
878    let result = with_ref(path, setup, body, cleanup).await;
879    assert_eq!(result.unwrap_err().to_string(), "body fail");
880    assert!(!try_exists(path).await.unwrap());
881  }
882
883  /// Check that a body failure in conjunction with a cleanup is handled
884  /// as expected by `with_ref`.
885  #[tokio::test]
886  async fn with_ref_body_cleanup_failure() {
887    let file = NamedTempFile::new().unwrap();
888    let path = file.path();
889
890    let setup = || async { Ok(()) };
891    let body = |_ns_pid| async { Err(anyhow!("body fail")) };
892    let cleanup = |_is_first_user| async { Err(anyhow!("cleanup fail")) };
893
894    let result = with_ref(path, setup, body, cleanup).await;
895    assert_eq!(result.unwrap_err().to_string(), "body fail");
896    assert!(!try_exists(path).await.unwrap());
897  }
898
899  /// Check that a cleanup failure is handled as expected by `with_ref`.
900  #[tokio::test]
901  async fn with_ref_cleanup_failure() {
902    let file = NamedTempFile::new().unwrap();
903    let path = file.path();
904
905    let setup = || async { Ok(()) };
906    let body = |_ns_pid| async { Ok(()) };
907    let cleanup = |_is_first_user| async { Err(anyhow!("cleanup fail")) };
908
909    let result = with_ref(path, setup, body, cleanup).await;
910    assert_eq!(result.unwrap_err().to_string(), "cleanup fail");
911    assert!(!try_exists(path).await.unwrap());
912  }
913
914  /// Check that we do not error out on the --version option.
915  #[tokio::test]
916  async fn version() {
917    let args = [OsStr::new("chroot-deploy"), OsStr::new("--version")];
918    let () = run(args).await.unwrap();
919  }
920
921  /// Check that we do not error out on the --help option.
922  #[tokio::test]
923  async fn help() {
924    let args = [OsStr::new("chroot-deploy"), OsStr::new("--help")];
925    let () = run(args).await.unwrap();
926  }
927
928  /// Check that we do not error out when the user didn't provide any
929  /// arguments.
930  #[tokio::test]
931  async fn no_args() {
932    let args = [OsStr::new("chroot-deploy")];
933    let () = run(args).await.unwrap();
934  }
935}