Skip to main content

btrfs_backup/
lib.rs

1// Copyright (C) 2022-2026 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).with_context(|| {
124      format!(
125        "failed to canonicalize subvolume path `{}`",
126        subvol.display()
127      )
128    })?;
129    Result::<(), Error>::Ok(())
130  })?;
131
132  let dst = create_repo(&destination, remote_command)?;
133
134  with_repo_and_subvols(source.as_deref(), subvolumes.as_slice(), |src, subvol| {
135    let _snapshot = backup_subvol(src, &dst, subvol, &tag)
136      .with_context(|| format!("failed to backup subvolume {}", subvol.display()))?;
137    Ok(())
138  })
139}
140
141
142/// Handler for the `restore` sub-command.
143fn restore(restore: Restore) -> Result<()> {
144  let Restore {
145    mut subvolumes,
146    destination,
147    source,
148    remote_command: RemoteCommand { remote_command },
149    snapshots_only,
150  } = restore;
151
152  let () = subvolumes.iter_mut().try_for_each(|subvol| {
153    *subvol = canonicalize_non_strict(subvol)?;
154    Result::<(), Error>::Ok(())
155  })?;
156
157  let src = create_repo(&source, remote_command)?;
158
159  with_repo_and_subvols(
160    destination.as_deref(),
161    subvolumes.as_slice(),
162    |dst, subvol| {
163      let _snapshot = restore_subvol(&src, dst, subvol, snapshots_only)
164        .with_context(|| format!("failed to restore subvolume {}", subvol.display()))?;
165      Ok(())
166    },
167  )
168}
169
170
171/// Handler for the `purge` sub-command.
172fn purge(purge: Purge) -> Result<()> {
173  let Purge {
174    mut subvolumes,
175    source,
176    destination,
177    tag: Tag { tag },
178    remote_command: RemoteCommand { remote_command },
179    keep_for,
180  } = purge;
181
182  let () = subvolumes.iter_mut().try_for_each(|subvol| {
183    *subvol = canonicalize_non_strict(subvol)?;
184    Result::<(), Error>::Ok(())
185  })?;
186
187  if let Some(destination) = destination {
188    let repo = create_repo(&destination, remote_command)?;
189
190    let () = subvolumes
191      .iter()
192      .try_for_each(|subvol| purge_subvol(&repo, subvol, &tag, keep_for))?;
193  }
194
195  // TODO: This logic is arguably a bit sub-optimal for the single-repo
196  //       case, because we list snapshots for each subvolume.
197  with_repo_and_subvols(source.as_deref(), subvolumes.as_slice(), |repo, subvol| {
198    purge_subvol(repo, subvol, &tag, keep_for)
199  })
200}
201
202
203/// Handler for the `snapshot` sub-command.
204fn snapshot(snapshot: Snapshot) -> Result<()> {
205  let Snapshot {
206    repository,
207    mut subvolumes,
208    tag: Tag { tag },
209  } = snapshot;
210
211  let () = subvolumes.iter_mut().try_for_each(|subvol| {
212    *subvol = canonicalize(&*subvol)?;
213    Result::<(), Error>::Ok(())
214  })?;
215
216  with_repo_and_subvols(
217    repository.as_deref(),
218    subvolumes.as_slice(),
219    |repo, subvol| {
220      let _snapshot = repo
221        .snapshot(subvol, &tag)
222        .with_context(|| format!("failed to snapshot subvolume {}", subvol.display()))?;
223      Ok(())
224    },
225  )
226}
227
228
229/// Run the program and report errors, if any.
230pub fn run<A, T>(args: A) -> Result<()>
231where
232  A: IntoIterator<Item = T>,
233  T: Into<OsString> + Clone,
234{
235  let args = match Args::try_parse_from(args) {
236    Ok(args) => args,
237    Err(err) => match err.kind() {
238      ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
239        print!("{err}");
240        return Ok(())
241      },
242      _ => return Err(err.into()),
243    },
244  };
245
246  if args.trace {
247    let () = trace_commands();
248  }
249
250  match args.command {
251    Command::Backup(backup) => self::backup(backup),
252    Command::Purge(purge) => self::purge(purge),
253    Command::Restore(restore) => self::restore(restore),
254    Command::Snapshot(snapshot) => self::snapshot(snapshot),
255  }
256}
257
258
259#[cfg(test)]
260mod tests {
261  use super::*;
262
263  use std::ffi::OsStr;
264
265
266  /// Check that we do not error out on the --version option.
267  #[test]
268  fn version() {
269    let args = [OsStr::new("btrfs-backup"), OsStr::new("--version")];
270    let () = run(args).unwrap();
271  }
272
273  /// Check that we do not error out on the --help option.
274  #[test]
275  fn help() {
276    let args = [OsStr::new("btrfs-backup"), OsStr::new("--help")];
277    let () = run(args).unwrap();
278  }
279}