#![deny(warnings)]
#![deny(missing_docs)]
#[cfg(target_os = "linux")]
mod api;
mod driver;
#[cfg(target_os = "linux")]
pub use api::server::run_daemon;
#[cfg(target_os = "linux")]
use driver::external;
use driver::{btrfs::Btrfs, zfs::Zfs};
use ruc::*;
use std::{fmt, result::Result as StdResult, str::FromStr};
pub const CAP_MAX: u64 = 4096;
pub const STEP_CNT: usize = 10;
#[derive(Clone, Debug)]
pub struct BtmCfg {
pub itv: u64,
pub cap: u64,
pub cap_clean_kept: usize,
pub mode: SnapMode,
pub algo: SnapAlgo,
pub volume: String,
}
impl BtmCfg {
#[inline(always)]
pub fn new(volume: &str, mode: Option<&str>) -> Result<Self> {
Self::validate_volume(volume).c(d!())?;
let mode = if let Some(m) = mode {
SnapMode::from_str(m).map_err(|e| eg!(e))?
} else {
SnapMode::guess(volume).c(d!())?
};
Ok(Self {
itv: 10,
cap: 100,
cap_clean_kept: 0,
mode,
algo: SnapAlgo::Fair,
volume: volume.to_owned(),
})
}
fn validate_volume(volume: &str) -> Result<()> {
if volume.is_empty() {
return Err(eg!("volume name cannot be empty"));
}
if volume.starts_with('-') {
return Err(eg!("volume name must not start with '-'"));
}
if !volume
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.'))
{
return Err(eg!(
"invalid volume name: only alphanumeric, '/', '-', '_', '.' are allowed"
));
}
Ok(())
}
pub fn validate_params(&self) -> Result<()> {
Self::validate_volume(&self.volume).c(d!())?;
if self.itv < 1 {
return Err(eg!("itv must be >= 1"));
}
if self.cap < 1 {
return Err(eg!("cap must be >= 1"));
}
self.itv
.checked_pow(STEP_CNT as u32)
.c(d!("itv is too large, causes overflow"))?;
Ok(())
}
pub fn snapshot(&self, idx: u64) -> Result<()> {
self.validate_params().c(d!())?;
if cfg!(not(target_os = "linux")) {
static WARN_ONCE: std::sync::Once = std::sync::Once::new();
WARN_ONCE.call_once(|| {
eprintln!(
"btm: snapshots are only supported on Linux, `snapshot()` is a no-op on this platform"
);
});
return Ok(());
}
self.sync_volume();
match self.mode {
SnapMode::Zfs => driver::gen_snapshot::<Zfs>(self, idx).c(d!()),
SnapMode::Btrfs => driver::gen_snapshot::<Btrfs>(self, idx).c(d!()),
#[cfg(target_os = "linux")]
SnapMode::External => external::gen_snapshot(self, idx).c(d!()),
#[cfg(not(target_os = "linux"))]
SnapMode::External => Err(eg!("`External` mode requires Linux")),
}
}
fn sync_volume(&self) {
#[cfg(target_os = "linux")]
{
let synced = match self.mode {
SnapMode::Zfs => {
cmd::exec(&format!("zfs get -H -o value mountpoint {}", &self.volume))
.ok()
.map(|mp| mp.trim().to_owned())
.filter(|mp| mp.starts_with('/'))
.is_some_and(|mp| syncfs_path(&mp).is_ok())
}
SnapMode::Btrfs => syncfs_path(&self.volume).is_ok(),
SnapMode::External => false,
};
if !synced {
nix::unistd::sync();
}
}
}
#[inline(always)]
pub fn rollback(&self, idx: Option<i128>, strict: bool) -> Result<()> {
self.validate_params().c(d!())?;
match self.mode {
SnapMode::Zfs => driver::rollback::<Zfs>(self, idx, strict).c(d!()),
SnapMode::Btrfs => driver::rollback::<Btrfs>(self, idx, strict).c(d!()),
SnapMode::External => Err(eg!("please use the `btm` tool in `External` mode")),
}
}
#[inline(always)]
pub fn get_sorted_snapshots(&self) -> Result<Vec<u64>> {
self.validate_params().c(d!())?;
match self.mode {
SnapMode::Zfs => driver::sorted_snapshots::<Zfs>(self).c(d!()),
SnapMode::Btrfs => driver::sorted_snapshots::<Btrfs>(self).c(d!()),
SnapMode::External => Err(eg!("please use `btm` tool in `External` mode")),
}
}
#[inline(always)]
fn get_cap(&self) -> u64 {
if self.cap > CAP_MAX {
CAP_MAX
} else {
self.cap
}
}
pub fn list_snapshots(&self) -> Result<()> {
println!("Available snapshots are listed below:");
self.get_sorted_snapshots().c(d!()).map(|list| {
list.into_iter().rev().for_each(|h| {
println!(" {}", h);
})
})
}
pub fn clean_snapshots(&self) -> Result<()> {
self.validate_params().c(d!())?;
match self.mode {
SnapMode::Zfs => driver::clean_all::<Zfs>(self, self.cap_clean_kept).c(d!()),
SnapMode::Btrfs => driver::clean_all::<Btrfs>(self, self.cap_clean_kept).c(d!()),
SnapMode::External => Err(eg!(
"Unsupported driver: External mode does not support clean_snapshots"
)),
}
}
}
#[cfg(target_os = "linux")]
fn syncfs_path(path: &str) -> Result<()> {
let f = std::fs::File::open(path).c(d!())?;
nix::unistd::syncfs(&f).c(d!())
}
#[derive(Clone, Copy, Debug)]
pub enum SnapMode {
Zfs,
Btrfs,
External,
}
impl SnapMode {
pub fn guess(volume: &str) -> Result<Self> {
BtmCfg::validate_volume(volume).c(d!())?;
driver::check::<Zfs>(volume)
.c(d!())
.map(|_| SnapMode::Zfs)
.or_else(|e| {
driver::check::<Btrfs>(volume)
.c(d!(e))
.map(|_| SnapMode::Btrfs)
})
}
}
impl fmt::Display for SnapMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let contents = match self {
Self::Zfs => "Zfs",
Self::Btrfs => "Btrfs",
Self::External => "External",
};
write!(f, "{}", contents)
}
}
impl FromStr for SnapMode {
type Err = String;
fn from_str(s: &str) -> StdResult<Self, Self::Err> {
match s.to_lowercase().as_str() {
"zfs" => Ok(Self::Zfs),
"btrfs" => Ok(Self::Btrfs),
"external" => Ok(Self::External),
_ => Err(format!("unknown snap mode: '{}'", s)),
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub enum SnapAlgo {
#[default]
Fair,
Fade,
}
impl fmt::Display for SnapAlgo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let contents = match self {
Self::Fair => "Fair",
Self::Fade => "Fade",
};
write!(f, "{}", contents)
}
}
impl FromStr for SnapAlgo {
type Err = String;
fn from_str(s: &str) -> StdResult<Self, Self::Err> {
match s.to_lowercase().as_str() {
"fair" => Ok(Self::Fair),
"fade" => Ok(Self::Fade),
_ => Err(format!("unknown snap algo: '{}'", s)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn volume_validation() {
assert!(BtmCfg::validate_volume("tank/igp24-v1_data.0").is_ok());
assert!(BtmCfg::validate_volume("/btrfs/data").is_ok());
assert!(BtmCfg::validate_volume("").is_err());
assert!(BtmCfg::validate_volume("-o").is_err());
assert!(BtmCfg::validate_volume("-tank/data").is_err());
assert!(BtmCfg::validate_volume("tank/data; rm -rf /").is_err());
assert!(BtmCfg::validate_volume("tank/data$(reboot)").is_err());
assert!(BtmCfg::validate_volume("tank/data\u{4e2d}").is_err());
}
#[test]
fn params_validation() {
let mut cfg = BtmCfg {
itv: 1,
cap: 100,
cap_clean_kept: 0,
mode: SnapMode::Zfs,
algo: SnapAlgo::Fade,
volume: "tank/data".to_owned(),
};
assert!(cfg.validate_params().is_ok());
cfg.itv = 0;
assert!(cfg.validate_params().is_err());
cfg.itv = 90;
assert!(cfg.validate_params().is_err());
cfg.itv = 80;
assert!(cfg.validate_params().is_ok());
cfg.cap = 0;
assert!(cfg.validate_params().is_err());
}
#[test]
fn enums_from_str() {
assert!(matches!("zfs".parse(), Ok(SnapMode::Zfs)));
assert!(matches!("BTRFS".parse(), Ok(SnapMode::Btrfs)));
assert!(matches!("External".parse(), Ok(SnapMode::External)));
assert!("xfs".parse::<SnapMode>().is_err());
assert!(matches!("fair".parse(), Ok(SnapAlgo::Fair)));
assert!(matches!("Fade".parse(), Ok(SnapAlgo::Fade)));
assert!("fibonacci".parse::<SnapAlgo>().is_err());
}
}