btrfs_backup/
lib.rs

1// Copyright (C) 2022-2025 Daniel Mueller <deso@posteo.net>
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! A program for backup & restoration of btrfs subvolumes.
5
6#[macro_use]
7mod redefine;
8
9mod args;
10#[doc(hidden)]
11pub mod btrfs;
12mod ops;
13mod repo;
14#[doc(hidden)]
15pub mod snapshot;
16#[allow(missing_docs, clippy::unwrap_used)]
17#[cfg(any(test, feature = "test"))]
18pub mod test;
19#[doc(hidden)]
20pub mod util;
21
22use std::borrow::Cow;
23use std::ffi::OsString;
24use std::fs::canonicalize;
25use std::path::Path;
26use std::path::PathBuf;
27
28use anyhow::Context as _;
29use anyhow::Error;
30use anyhow::Result;
31
32use clap::error::ErrorKind;
33use clap::Parser as _;
34
35use crate::args::Args;
36use crate::args::Backup;
37use crate::args::Command;
38use crate::args::Purge;
39use crate::args::RemoteCommand;
40use crate::args::Restore;
41use crate::args::Snapshot;
42use crate::args::Tag;
43use crate::btrfs::trace_commands;
44use crate::btrfs::Btrfs;
45use crate::ops::RemoteOps;
46use crate::repo::backup as backup_subvol;
47use crate::repo::purge as purge_subvol;
48use crate::repo::restore as restore_subvol;
49use crate::repo::Repo;
50use crate::util::canonicalize_non_strict;
51
52
53/// A helper function for creating a btrfs repository in the provided
54/// directory, taking care of all error annotations.
55fn create_repo(directory: &Path, remote_command: Option<(String, Vec<String>)>) -> Result<Repo> {
56  let mut builder = Repo::builder();
57
58  if let Some((command, args)) = remote_command {
59    let ops = RemoteOps::new(command.clone(), args.clone());
60    let () = builder.set_file_ops(ops);
61
62    let btrfs = Btrfs::with_command_prefix(command, args);
63    let () = builder.set_btrfs_ops(btrfs);
64  }
65
66  let repo = builder.build(directory).with_context(|| {
67    format!(
68      "failed to create btrfs snapshot repository at {}",
69      directory.display()
70    )
71  })?;
72
73  Ok(repo)
74}
75
76
77/// Perform an operation on a bunch of subvolumes, providing either a
78/// single repository at the provided path or create a new one
79/// co-located to each subvolume.
80fn with_repo_and_subvols<F>(repo_path: Option<&Path>, subvols: &[PathBuf], f: F) -> Result<()>
81where
82  F: FnMut(&Repo, &Path) -> Result<()>,
83{
84  let mut f = f;
85
86  let repo = if let Some(repo_path) = repo_path {
87    Some(create_repo(repo_path, None)?)
88  } else {
89    None
90  };
91
92  let () = subvols.iter().try_for_each::<_, Result<()>>(|subvol| {
93    let repo = if let Some(repo) = &repo {
94      Cow::Borrowed(repo)
95    } else {
96      let directory = subvol.parent().with_context(|| {
97        format!(
98          "subvolume {} does not have a parent; need repository path",
99          subvol.display()
100        )
101      })?;
102      Cow::Owned(create_repo(directory, None)?)
103    };
104
105    f(&repo, subvol)
106  })?;
107
108  Ok(())
109}
110
111
112/// Handler for the `backup` sub-command.
113fn backup(backup: Backup) -> Result<()> {
114  let Backup {
115    mut subvolumes,
116    destination,
117    source,
118    tag: Tag { tag },
119    remote_command: RemoteCommand { remote_command },
120  } = backup;
121
122  let () = subvolumes.iter_mut().try_for_each(|subvol| {
123    *subvol = canonicalize(&*subvol)?;
124    Result::<(), Error>::Ok(())
125  })?;
126
127  let dst = create_repo(&destination, remote_command)?;
128
129  with_repo_and_subvols(source.as_deref(), subvolumes.as_slice(), |src, subvol| {
130    let _snapshot = backup_subvol(src, &dst, subvol, &tag)
131      .with_context(|| format!("failed to backup subvolume {}", subvol.display()))?;
132    Ok(())
133  })
134}
135
136
137/// Handler for the `restore` sub-command.
138fn restore(restore: Restore) -> Result<()> {
139  let Restore {
140    mut subvolumes,
141    destination,
142    source,
143    remote_command: RemoteCommand { remote_command },
144    snapshots_only,
145  } = restore;
146
147  let () = subvolumes.iter_mut().try_for_each(|subvol| {
148    *subvol = canonicalize_non_strict(subvol)?;
149    Result::<(), Error>::Ok(())
150  })?;
151
152  let src = create_repo(&source, remote_command)?;
153
154  with_repo_and_subvols(
155    destination.as_deref(),
156    subvolumes.as_slice(),
157    |dst, subvol| {
158      let _snapshot = restore_subvol(&src, dst, subvol, snapshots_only)
159        .with_context(|| format!("failed to restore subvolume {}", subvol.display()))?;
160      Ok(())
161    },
162  )
163}
164
165
166/// Handler for the `purge` sub-command.
167fn purge(purge: Purge) -> Result<()> {
168  let Purge {
169    mut subvolumes,
170    source,
171    destination,
172    tag: Tag { tag },
173    remote_command: RemoteCommand { remote_command },
174    keep_for,
175  } = purge;
176
177  let () = subvolumes.iter_mut().try_for_each(|subvol| {
178    *subvol = canonicalize_non_strict(subvol)?;
179    Result::<(), Error>::Ok(())
180  })?;
181
182  if let Some(destination) = destination {
183    let repo = create_repo(&destination, remote_command)?;
184
185    let () = subvolumes
186      .iter()
187      .try_for_each(|subvol| purge_subvol(&repo, subvol, &tag, keep_for))?;
188  }
189
190  // TODO: This logic is arguably a bit sub-optimal for the single-repo
191  //       case, because we list snapshots for each subvolume.
192  with_repo_and_subvols(source.as_deref(), subvolumes.as_slice(), |repo, subvol| {
193    purge_subvol(repo, subvol, &tag, keep_for)
194  })
195}
196
197
198/// Handler for the `snapshot` sub-command.
199fn snapshot(snapshot: Snapshot) -> Result<()> {
200  let Snapshot {
201    repository,
202    mut subvolumes,
203    tag: Tag { tag },
204  } = snapshot;
205
206  let () = subvolumes.iter_mut().try_for_each(|subvol| {
207    *subvol = canonicalize(&*subvol)?;
208    Result::<(), Error>::Ok(())
209  })?;
210
211  with_repo_and_subvols(
212    repository.as_deref(),
213    subvolumes.as_slice(),
214    |repo, subvol| {
215      let _snapshot = repo
216        .snapshot(subvol, &tag)
217        .with_context(|| format!("failed to snapshot subvolume {}", subvol.display()))?;
218      Ok(())
219    },
220  )
221}
222
223
224/// Run the program and report errors, if any.
225pub fn run<A, T>(args: A) -> Result<()>
226where
227  A: IntoIterator<Item = T>,
228  T: Into<OsString> + Clone,
229{
230  let args = match Args::try_parse_from(args) {
231    Ok(args) => args,
232    Err(err) => match err.kind() {
233      ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
234        print!("{err}");
235        return Ok(())
236      },
237      _ => return Err(err.into()),
238    },
239  };
240
241  if args.trace {
242    let () = trace_commands();
243  }
244
245  match args.command {
246    Command::Backup(backup) => self::backup(backup),
247    Command::Purge(purge) => self::purge(purge),
248    Command::Restore(restore) => self::restore(restore),
249    Command::Snapshot(snapshot) => self::snapshot(snapshot),
250  }
251}
252
253
254#[cfg(test)]
255mod tests {
256  use super::*;
257
258  use std::ffi::OsStr;
259
260
261  /// Check that we do not error out on the --version option.
262  #[test]
263  fn version() {
264    let args = [OsStr::new("btrfs-backup"), OsStr::new("--version")];
265    let () = run(args).unwrap();
266  }
267
268  /// Check that we do not error out on the --help option.
269  #[test]
270  fn help() {
271    let args = [OsStr::new("btrfs-backup"), OsStr::new("--help")];
272    let () = run(args).unwrap();
273  }
274}