use std::borrow::Cow;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::iter;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr as _;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use anyhow::Context as _;
use anyhow::Result;
use once_cell::sync::Lazy;
use regex::Regex;
use crate::ops::FileOps;
use crate::util::bytes_to_path;
use crate::util::check;
use crate::util::format_command;
use crate::util::output;
use crate::util::pipeline;
use crate::util::run;
use crate::util::Either;
use super::commands;
const BTRFS: &str = "btrfs";
const NUMS_STRING: &str = r"[0-9]+";
const PATH_STRING: &str = r".+";
static SNAPSHOTS_LINE_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"^ID {NUMS_STRING} gen (?P<gen>{NUMS_STRING}) top level {NUMS_STRING} path (?P<path>{PATH_STRING})$"
)).expect("failed to create snapshot regular expression")
});
static DIFF_END_MARKER: &[u8] = b"transid marker";
static TRACE_COMMANDS: AtomicBool = AtomicBool::new(false);
#[inline]
pub fn trace_commands() {
TRACE_COMMANDS.store(true, Ordering::Relaxed)
}
#[derive(Clone, Debug, Default)]
pub struct Btrfs {
command: Option<(OsString, Vec<OsString>)>,
}
impl Btrfs {
pub fn new() -> Self {
Self { command: None }
}
pub fn with_command_prefix<C, A, S>(command: C, args: A) -> Self
where
C: AsRef<OsStr>,
A: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let command = command.as_ref().to_os_string();
let args = args
.into_iter()
.map(|arg| arg.as_ref().to_os_string())
.collect();
Self {
command: Some((command, args)),
}
}
fn command<'slf, A, S>(
&'slf self,
args: A,
) -> (
impl AsRef<OsStr> + Clone + 'slf,
impl IntoIterator<Item = impl AsRef<OsStr> + Clone + 'slf> + Clone + 'slf,
)
where
A: IntoIterator<Item = S> + 'slf,
A::IntoIter: Clone,
S: AsRef<OsStr> + Clone + 'slf,
{
let prefix_command = self.command.as_ref().map(|(command, _args)| command);
let prefix_args = self.command.as_ref().map(|(_command, args)| args);
let mut iter = prefix_command
.into_iter()
.map(AsRef::<OsStr>::as_ref)
.chain(prefix_args.into_iter().flatten().map(OsString::as_ref))
.map(Either::Left)
.chain(iter::once(Either::Right(Either::Left(BTRFS))))
.chain(args.into_iter().map(Either::Right).map(Either::Right));
let command = iter.next().expect("constructed btrfs command is empty");
(command, iter)
}
fn maybe_print<A, S>(&self, args: A)
where
A: IntoIterator<Item = S> + Clone,
A::IntoIter: Clone,
S: AsRef<OsStr> + Clone,
{
if TRACE_COMMANDS.load(Ordering::Relaxed) {
let (command, args) = self.command(args);
println!("{}", format_command(command, args))
}
}
pub fn is_btrfs(&self, filesystem: &Path) -> Result<bool> {
let args = commands::show_filesystem(filesystem);
let () = self.maybe_print(args);
let (command, args) = self.command(args);
check(command, args)
}
pub fn create_subvol(&self, subvolume: &Path) -> Result<()> {
let args = commands::create(subvolume);
let () = self.maybe_print(args);
let (command, args) = self.command(args);
run(command, args)
}
pub fn delete_subvol(&self, subvolume: &Path) -> Result<()> {
let args = commands::delete(subvolume);
let () = self.maybe_print(args);
let (command, args) = self.command(args);
run(command, args)
}
pub fn snapshot(&self, source: &Path, destination: &Path, readonly: bool) -> Result<()> {
let args = commands::snapshot(source, destination, readonly);
let () = self.maybe_print(args.clone());
let (command, args) = self.command(args);
run(command, args)
}
pub fn sync(&self, filesystem: &Path) -> Result<()> {
let args = commands::sync(filesystem);
let () = self.maybe_print(args);
let (command, args) = self.command(args);
run(command, args)
}
fn subvolumes_impl(&self, directory: &Path, readonly: bool) -> Result<Vec<(PathBuf, usize)>> {
let args = commands::subvolumes(directory, readonly);
let () = self.maybe_print(args.clone());
let (command, args) = self.command(args);
let output = output(command.clone(), args.clone())?;
let output = String::from_utf8(output).with_context(|| {
format!(
"failed to read `{}` output as UTF-8 string",
format_command(command, args.clone())
)
})?;
let vec = output
.lines()
.map(|line| {
let captures = SNAPSHOTS_LINE_REGEX
.captures(line)
.with_context(|| format!("failed to parse snapshot output line: `{line}`"))?;
let gen = &captures["gen"];
let gen = usize::from_str(gen)
.with_context(|| format!("failed to parse generation string `{gen}` as integer"))?;
let path = PathBuf::from(&captures["path"]);
Ok((path, gen))
})
.collect::<Result<_>>()?;
Ok(vec)
}
fn find_subvol_path(&self, directory: &Path) -> Result<PathBuf> {
let id = self.subvol_id(directory)?;
let path = self.resolve_id(id, directory)?;
Ok(path)
}
pub fn subvolumes(
&self,
ops: &dyn FileOps,
root: &Path,
directory: Option<&Path>,
readonly: bool,
) -> Result<Vec<(PathBuf, usize)>> {
let root = ops.canonicalize(root)?;
let directory = if let Some(directory) = directory {
assert!(
directory.is_relative(),
"directory path {} needs to be relative",
directory.display()
);
Cow::Owned(ops.canonicalize(&root.join(directory))?)
} else {
Cow::Borrowed(&root)
};
let make_absolute = |subvol: &Path| root.join(subvol);
let subvol_path = self.find_subvol_path(&root)?;
let subvols = self
.subvolumes_impl(&directory, readonly)?
.into_iter()
.filter_map(|(subvol, gen)| {
subvol
.strip_prefix(&subvol_path)
.ok()
.map(Path::to_path_buf)
.map(|subvol| (subvol, gen))
})
.map(|(subvol, gen)| (make_absolute(&subvol), gen))
.filter_map(|(subvol, gen)| {
subvol
.strip_prefix(directory.as_ref())
.ok()
.map(Path::to_path_buf)
.map(|subvol| (subvol, gen))
})
.collect();
Ok(subvols)
}
pub fn has_changes(&self, subvolume: &Path, generation: usize) -> Result<bool> {
let args = commands::diff(subvolume, generation + 1);
let () = self.maybe_print(args.clone());
let (command, args) = self.command(args);
let output = output(command, args)?;
let result = !output.starts_with(DIFF_END_MARKER);
Ok(result)
}
pub fn subvol_id(&self, path: &Path) -> Result<usize> {
let args = commands::root_id(path);
let () = self.maybe_print(args);
let (command, args) = self.command(args);
let output = output(command.clone(), args.clone())?;
let output = String::from_utf8(output).with_context(|| {
format!(
"failed to read `{}` output as UTF-8 string",
format_command(command.clone(), args.clone())
)
})?;
let id = usize::from_str(&output[..output.len().saturating_sub(1)]).with_context(|| {
format!(
"failed to convert `{}` output to ID",
format_command(command, args)
)
})?;
Ok(id)
}
pub fn resolve_id(&self, id: usize, root: &Path) -> Result<PathBuf> {
let args = commands::resolve_id(id, root);
let () = self.maybe_print(args.clone());
let (command, args) = self.command(args);
let output = output(command, args)?;
let path = bytes_to_path(&output[..output.len().saturating_sub(1)]);
Ok(path.to_path_buf())
}
pub fn make_subvol_readonly(&self, subvol: &Path) -> Result<()> {
let args = commands::set_readonly(subvol);
let () = self.maybe_print(args.clone());
let (command, args) = self.command(args);
run(command, args)
}
pub fn send_recv<'input, I>(
&self,
send_subvolume: &'input Path,
send_parents: I,
recv: &Btrfs,
recv_destination: &Path,
) -> Result<()>
where
I: IntoIterator<Item = &'input OsStr>,
I::IntoIter: Clone,
{
let src_args = commands::serialize(send_subvolume, send_parents);
let (src_cmd, src_args) = self.command(src_args);
let dst_args = commands::deserialize(recv_destination);
let (dst_cmd, dst_args) = recv.command(dst_args);
if TRACE_COMMANDS.load(Ordering::Relaxed) {
println!(
"{} | {}",
format_command(src_cmd.clone(), src_args.clone()),
format_command(dst_cmd.clone(), dst_args.clone())
)
}
pipeline(src_cmd, src_args, dst_cmd, dst_args)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs::create_dir_all as create_dirs;
use std::fs::write;
use serial_test::serial;
use crate::ops::LocalOps;
use crate::test::with_btrfs;
use crate::test::BtrfsDev;
use crate::test::Mount;
#[test]
#[serial]
fn filesystem_check() {
with_btrfs(|root| {
let btrfs = Btrfs::new();
let result = btrfs.is_btrfs(root).unwrap();
assert!(result);
let file = root.join("file");
let () = write(&file, b"content").unwrap();
let result = btrfs.is_btrfs(&file).unwrap();
assert!(!result);
let subvol = root.join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let result = btrfs.is_btrfs(&subvol).unwrap();
assert!(!result);
})
}
#[test]
#[serial]
fn filesystem_sync() {
with_btrfs(|root| {
let btrfs = Btrfs::new();
let () = btrfs.sync(root).unwrap();
})
}
#[test]
#[serial]
fn subvol_creation_deletion() {
with_btrfs(|root| {
let subvol = root.join("subvol");
let btrfs = Btrfs::new();
let () = btrfs.create_subvol(&subvol).unwrap();
let () = btrfs.delete_subvol(&subvol).unwrap();
})
}
#[test]
#[serial]
fn subvol_id_resolution() {
with_btrfs(|root| {
let subvol_name = OsStr::new("subvol");
let subvol_path = root.join(subvol_name);
let btrfs = Btrfs::new();
let () = btrfs.create_subvol(&subvol_path).unwrap();
let id = btrfs.subvol_id(&subvol_path).unwrap();
assert_ne!(id, 0);
let subvol_path = btrfs.resolve_id(id, root).unwrap();
assert_eq!(subvol_path, subvol_name);
})
}
#[test]
#[serial]
fn subvolumes() {
with_btrfs(|root| {
let ops = LocalOps::default();
let btrfs = Btrfs::new();
let readonly = true;
let subvolumes = btrfs.subvolumes(&ops, root, None, readonly).unwrap();
assert!(subvolumes.is_empty());
let subvolumes = btrfs.subvolumes(&ops, root, None, !readonly).unwrap();
assert!(subvolumes.is_empty());
let subvol_name = OsStr::new("subvol");
let subvol_path = root.join(subvol_name);
let () = btrfs.create_subvol(&subvol_path).unwrap();
let snapshot_name = OsStr::new("snapshot");
let snapshot_path = root.join(snapshot_name);
let () = btrfs
.snapshot(&subvol_path, &snapshot_path, readonly)
.unwrap();
let mut subvolumes = btrfs
.subvolumes(&ops, root, None, readonly)
.unwrap()
.into_iter();
assert_eq!(subvolumes.len(), 1);
let next = subvolumes.next().unwrap();
assert_eq!(next.0, snapshot_name);
assert_ne!(next.1, 0);
let mut subvolumes = btrfs.subvolumes(&ops, root, None, !readonly).unwrap();
let () = subvolumes.sort();
let mut subvolumes = subvolumes.into_iter();
assert_eq!(subvolumes.len(), 2);
let next = subvolumes.next().unwrap();
assert_eq!(next.0, snapshot_name);
assert_ne!(next.1, 0);
let next = subvolumes.next().unwrap();
assert_eq!(next.0, subvol_name);
assert_ne!(next.1, 0);
})
}
#[test]
#[serial]
fn subvolumes_in_subdir() {
with_btrfs(|root| {
let ops = LocalOps::default();
let btrfs = Btrfs::new();
let subvol_name = OsStr::new("subvol");
let subvol_dir = root.join("test-dir");
let subvol_path = subvol_dir.join(subvol_name);
let () = create_dirs(&subvol_dir).unwrap();
let () = btrfs.create_subvol(&subvol_path).unwrap();
let readonly = true;
let mut subvolumes = btrfs
.subvolumes(&ops, root, Some(Path::new("test-dir")), !readonly)
.unwrap()
.into_iter();
assert_eq!(subvolumes.len(), 1);
let next = subvolumes.next().unwrap();
assert_eq!(next.0, subvol_name);
assert_ne!(next.1, 0);
})
}
#[test]
#[serial]
fn subvol_changes() {
with_btrfs(|root| {
let ops = LocalOps::default();
let btrfs = Btrfs::new();
let subvol = root.join("root");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), b"content").unwrap();
let snapshot_name = OsStr::new("root-snapshot");
let snapshot_path = root.join(snapshot_name);
let readonly = true;
let () = btrfs.snapshot(&subvol, &snapshot_path, !readonly).unwrap();
let subvolumes = btrfs
.subvolumes(&ops, root, None, !readonly)
.unwrap()
.into_iter()
.collect::<HashMap<_, _>>();
let root_snap_gen = *subvolumes
.get(AsRef::<Path>::as_ref(snapshot_name))
.unwrap();
assert!(!btrfs.has_changes(&snapshot_path, root_snap_gen).unwrap());
let () = write(snapshot_path.join("file2"), b"content2").unwrap();
assert!(btrfs.has_changes(&snapshot_path, root_snap_gen).unwrap());
})
}
#[test]
#[serial]
fn snapshot_send_recv() {
let loopdev1 = BtrfsDev::with_default().unwrap();
let mount1 = Mount::new(loopdev1.path()).unwrap();
let loopdev2 = BtrfsDev::with_default().unwrap();
let mount2 = Mount::new(loopdev2.path()).unwrap();
let btrfs = Btrfs::new();
let subvol = mount1.path().join("subvol");
let () = btrfs.create_subvol(&subvol).unwrap();
let snapshot = mount1.path().join("snapshot");
let readonly = true;
let () = btrfs.snapshot(&subvol, &snapshot, readonly).unwrap();
let () = btrfs
.send_recv(&snapshot, [], &btrfs, mount2.path())
.unwrap();
assert!(mount2.path().join("snapshot").exists());
}
#[test]
#[serial]
fn subvolume_readonly() {
with_btrfs(|root| {
let btrfs = Btrfs::new();
let subvol = root.join("root");
let () = btrfs.create_subvol(&subvol).unwrap();
let () = write(subvol.join("file"), b"content").unwrap();
let () = btrfs.make_subvol_readonly(&subvol).unwrap();
let err = write(subvol.join("file"), b"changed-content").unwrap_err();
assert!(
err
.to_string()
.to_lowercase()
.contains("read-only file system"),
"{err}"
);
})
}
}