1#![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
62fn 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 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 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 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
177async 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
188async 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 if let Err(setup_err) = setup_result {
238 match dec_ref_cnt(&mut ref_file).await {
239 Ok(Some(_guard)) => {
240 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 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 body_result.map_err(|err| err.context(inner_err))
269 },
270 };
271
272 let () = drop(ref_file);
273 result
274}
275
276
277async 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 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
300fn 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
355async 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
385async 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 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
559async 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
587pub 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 #[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 #[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 #[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 #[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 #[tokio::test]
687 async fn lock_file_lock() {
688 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 #[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 #[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 #[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 #[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 #[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 #[tokio::test]
775 async fn help() {
776 let args = [OsStr::new("chroot-deploy"), OsStr::new("--help")];
777 let () = run(args).await.unwrap();
778 }
779}