123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714 | use serde::Serialize;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use crate::config::{Config, SyncJob, SyncMode};
#[derive(Debug, Clone, Default, Serialize)]
pub struct BackupProgress {
pub current_job: usize,
pub total_jobs: usize,
pub job_name: String,
pub files_transferred: u64,
pub files_total: u64,
pub bytes_transferred: u64,
pub bytes_total: u64,
pub current_file: String,
pub elapsed_secs: f64,
pub estimated_total_secs: Option<f64>,
pub finished: bool,
pub cancelled: bool,
pub error: Option<String>,
pub log_lines: Vec<String>,
pub child_pid: Option<u32>,
pub paused: bool,
}
impl BackupProgress {
pub fn fraction(&self) -> f32 {
if self.bytes_total == 0 {
0.0
} else {
(self.bytes_transferred as f32 / self.bytes_total as f32).clamp(0.0, 1.0)
}
}
pub fn overall_fraction(&self) -> f32 {
if self.total_jobs == 0 {
return 0.0;
}
let per_job = 1.0 / self.total_jobs as f32;
let completed = self.current_job as f32 * per_job;
completed + self.fraction() * per_job
}
pub fn eta_string(&self) -> String {
if let Some(total) = self.estimated_total_secs {
let remaining = (total - self.elapsed_secs).max(0.0);
format_duration(remaining as u64)
} else {
"calculating…".to_owned()
}
}
}
pub fn format_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m {}s", secs / 60, secs % 60)
} else {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}
pub type SharedProgress = Arc<Mutex<BackupProgress>>;
pub fn list_snapshot_names(drive_root: &Path) -> Vec<String> {
let snapshots_dir = drive_root.join("snapshots");
let mut names: Vec<String> = match std::fs::read_dir(&snapshots_dir) {
Ok(rd) => rd
.flatten()
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect(),
Err(_) => vec![],
};
names.sort();
names
}
pub fn rsync_restore_args(
job: &SyncJob,
drive_root: &Path,
snapshot: Option<&str>,
delete_extra: bool,
subpath: Option<&str>,
) -> Vec<String> {
let mut args: Vec<String> = vec![
"-av".to_owned(),
"--info=progress2".to_owned(),
"--no-inc-recursive".to_owned(),
"--partial-dir=.rsync-partial".to_owned(),
];
if delete_extra {
args.push("--delete".to_owned());
}
let mut src_dir = match snapshot {
Some(snap) => drive_root
.join("snapshots")
.join(snap)
.join(&job.destination),
None => drive_root.join(&job.destination),
};
let mut dst_dir = job.source.clone();
if let Some(sub) = subpath.filter(|s| !s.is_empty()) {
src_dir = src_dir.join(sub);
dst_dir = dst_dir.join(sub);
}
args.push(format!("{}/", src_dir.display()));
args.push(format!("{}/", dst_dir.display()));
args
}
pub fn run_restore(
jobs: Vec<SyncJob>,
subpaths: Vec<Option<String>>,
drive_root: PathBuf,
snapshot: Option<String>,
delete_extra: bool,
progress: SharedProgress,
) -> std::thread::JoinHandle<()> {
let total_jobs = jobs.len();
std::thread::spawn(move || {
{
let mut p = progress.lock().unwrap();
p.total_jobs = total_jobs;
p.current_job = 0;
p.finished = false;
p.cancelled = false;
p.error = None;
p.child_pid = None;
p.paused = false;
}
let start = std::time::Instant::now();
for (idx, (job, subpath)) in jobs.iter().zip(subpaths.iter()).enumerate() {
if progress.lock().unwrap().cancelled {
break;
}
{
let mut p = progress.lock().unwrap();
p.current_job = idx;
p.job_name = job.name.clone();
p.files_transferred = 0;
p.files_total = 0;
p.bytes_transferred = 0;
p.bytes_total = 0;
p.current_file.clear();
}
let args = rsync_restore_args(
job,
&drive_root,
snapshot.as_deref(),
delete_extra,
subpath.as_deref(),
);
let mut child = match Command::new("rsync")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let mut p = progress.lock().unwrap();
p.error = Some(format!("failed to spawn rsync: {e}"));
return;
}
};
progress.lock().unwrap().child_pid = Some(child.id());
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let Ok(line) = line else { continue };
let elapsed = start.elapsed().as_secs_f64();
for seg in line.split('\r') {
let seg = seg.trim();
if !seg.is_empty() {
parse_rsync_line(seg, &progress, elapsed);
}
}
let display = line
.split('\r')
.map(str::trim)
.filter(|s| !s.is_empty())
.last()
.unwrap_or("")
.to_owned();
if !display.is_empty() {
let mut p = progress.lock().unwrap();
p.elapsed_secs = elapsed;
if p.log_lines.len() > 500 {
p.log_lines.remove(0);
}
p.log_lines.push(display);
}
}
}
let wait_result = child.wait();
let cancelled = {
let mut p = progress.lock().unwrap();
p.child_pid = None;
p.cancelled
};
let status = match wait_result {
Ok(s) => s,
Err(e) => {
if cancelled {
break;
}
let mut p = progress.lock().unwrap();
p.error = Some(format!("rsync wait error: {e}"));
return;
}
};
if cancelled {
break;
}
if !status.success() {
let code = status.code().unwrap_or(-1);
// exit code 24 = partial transfer due to vanished source files; treat as success
if code != 24 {
let mut p = progress.lock().unwrap();
p.error = Some(format!(
"rsync exited with code {code} for job \"{}\"",
job.name
));
return;
}
}
}
let elapsed = start.elapsed().as_secs_f64();
let mut p = progress.lock().unwrap();
p.finished = true;
p.elapsed_secs = elapsed;
p.current_job = total_jobs;
})
}
/// Signature that must begin a CACHEDIR.TAG file, per the Cache Directory Tagging Specification.
const CACHEDIR_TAG_SIGNATURE: &[u8] = b"Signature: 8a477f597d28d172789f06886806bc55";
fn is_valid_cachedir_tag(path: &Path) -> bool {
use std::io::Read;
let Ok(mut f) = std::fs::File::open(path) else {
return false;
};
let mut buf = [0u8; 43];
matches!(f.read_exact(&mut buf), Ok(())) && buf == CACHEDIR_TAG_SIGNATURE
}
/// Walk `source` (without following symlinks) and return the paths of all directories containing
/// a valid CACHEDIR.TAG, relative to `source`. Tagged directories are not descended into.
pub fn find_cache_dirs(source: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![source.to_path_buf()];
while let Some(dir) = stack.pop() {
if is_valid_cachedir_tag(&dir.join("CACHEDIR.TAG")) {
if let Ok(rel) = dir.strip_prefix(source) {
found.push(rel.to_path_buf());
}
continue;
}
let Ok(rd) = std::fs::read_dir(&dir) else {
continue;
};
for entry in rd.flatten() {
// file_type() does not follow symlinks, so symlinked dirs are skipped (as rsync -a does).
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
stack.push(entry.path());
}
}
}
found.sort();
found
}
/// Escape rsync pattern metacharacters so a literal path matches only itself.
fn rsync_escape_pattern(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if matches!(c, '*' | '?' | '[' | '\\') {
out.push('\\');
}
out.push(c);
}
out
}
/// Filter rules that skip a cache directory's contents while keeping the directory and its tag,
/// matching GNU tar's `--exclude-caches` behaviour.
pub fn cache_dir_rules(cache_dirs: &[PathBuf]) -> Vec<String> {
let mut rules = Vec::new();
for dir in cache_dirs {
let rel = rsync_escape_pattern(&dir.to_string_lossy());
let prefix = if rel.is_empty() {
String::new()
} else {
format!("/{rel}")
};
rules.push(format!("--include={prefix}/CACHEDIR.TAG"));
rules.push(format!("--exclude={prefix}/*"));
}
rules
}
pub fn rsync_args(job: &SyncJob, drive_root: &Path, cache_dirs: &[PathBuf]) -> Vec<String> {
let mut args: Vec<String> = vec![
"-av".to_owned(),
"--info=progress2".to_owned(),
"--no-inc-recursive".to_owned(),
"--partial-dir=.rsync-partial".to_owned(),
];
if job.mode == SyncMode::Backup {
args.push("--delete".to_owned());
}
for excl in &job.excludes {
args.push(format!("--exclude={excl}"));
}
args.extend(cache_dir_rules(cache_dirs));
let dest = drive_root.join(&job.destination);
args.push(format!("{}/", job.source.display()));
args.push(format!("{}/", dest.display()));
args
}
/// Human-readable command for the preview screen. Cache directories are discovered at run time,
/// so they are shown as a placeholder rather than scanned here.
pub fn rsync_command_string(job: &SyncJob, drive_root: &Path) -> String {
let args = rsync_args(job, drive_root, &[]);
let mut quoted: Vec<String> = args.iter().map(|a| shell_quote(a)).collect();
if job.exclude_caches {
let n = quoted.len() - 2;
quoted.insert(n, "[--exclude for each CACHEDIR.TAG directory]".to_owned());
}
format!("rsync {}", quoted.join(" "))
}
fn shell_quote(s: &str) -> String {
if s.contains(|c: char| {
c.is_whitespace()
|| matches!(
c,
'"' | '\''
| '\\'
| '$'
| '`'
| '('
| ')'
| '&'
| '|'
| ';'
| '<'
| '>'
| '!'
| '#'
| '~'
| '*'
| '?'
)
}) {
format!("'{}'", s.replace('\'', "'\\''"))
} else {
s.to_owned()
}
}
pub fn run_backup(
config: &Config,
drive_root: &Path,
progress: SharedProgress,
) -> std::thread::JoinHandle<()> {
let jobs: Vec<SyncJob> = config.jobs.iter().filter(|j| j.enabled).cloned().collect();
let drive_root = drive_root.to_path_buf();
let total_jobs = jobs.len();
std::thread::spawn(move || {
{
let mut p = progress.lock().unwrap();
p.total_jobs = total_jobs;
p.current_job = 0;
p.finished = false;
p.cancelled = false;
p.error = None;
p.child_pid = None;
p.paused = false;
}
let start = std::time::Instant::now();
for (idx, job) in jobs.iter().enumerate() {
if progress.lock().unwrap().cancelled {
break;
}
{
let mut p = progress.lock().unwrap();
p.current_job = idx;
p.job_name = job.name.clone();
p.files_transferred = 0;
p.files_total = 0;
p.bytes_transferred = 0;
p.bytes_total = 0;
p.current_file.clear();
}
let dest = drive_root.join(&job.destination);
if let Err(e) = std::fs::create_dir_all(&dest) {
let mut p = progress.lock().unwrap();
p.error = Some(format!("mkdir {}: {e}", dest.display()));
return;
}
let cache_dirs = if job.exclude_caches {
find_cache_dirs(&job.source)
} else {
vec![]
};
if !cache_dirs.is_empty() {
let mut p = progress.lock().unwrap();
p.log_lines.push(format!(
"Skipping {} cache director{} (CACHEDIR.TAG):",
cache_dirs.len(),
if cache_dirs.len() == 1 { "y" } else { "ies" }
));
for d in &cache_dirs {
p.log_lines.push(format!(" {}", d.display()));
}
}
let args = rsync_args(job, &drive_root, &cache_dirs);
let mut child = match Command::new("rsync")
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let mut p = progress.lock().unwrap();
p.error = Some(format!("failed to spawn rsync: {e}"));
return;
}
};
progress.lock().unwrap().child_pid = Some(child.id());
if let Some(stdout) = child.stdout.take() {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let Ok(line) = line else { continue };
let elapsed = start.elapsed().as_secs_f64();
for seg in line.split('\r') {
let seg = seg.trim();
if !seg.is_empty() {
parse_rsync_line(seg, &progress, elapsed);
}
}
let display = line
.split('\r')
.map(str::trim)
.filter(|s| !s.is_empty())
.last()
.unwrap_or("")
.to_owned();
if !display.is_empty() {
let mut p = progress.lock().unwrap();
p.elapsed_secs = elapsed;
if p.log_lines.len() > 500 {
p.log_lines.remove(0);
}
p.log_lines.push(display);
}
}
}
let wait_result = child.wait();
let cancelled = {
let mut p = progress.lock().unwrap();
p.child_pid = None;
p.cancelled
};
let status = match wait_result {
Ok(s) => s,
Err(e) => {
if cancelled {
break;
}
let mut p = progress.lock().unwrap();
p.error = Some(format!("rsync wait error: {e}"));
return;
}
};
if cancelled {
break;
}
if !status.success() {
let code = status.code().unwrap_or(-1);
// exit code 24 = partial transfer due to vanished source files; treat as success
if code != 24 {
let mut p = progress.lock().unwrap();
p.error = Some(format!(
"rsync exited with code {code} for job \"{}\"",
job.name
));
return;
}
}
}
if progress.lock().unwrap().cancelled {
let elapsed = start.elapsed().as_secs_f64();
let mut p = progress.lock().unwrap();
p.elapsed_secs = elapsed;
p.current_job = total_jobs;
return;
}
if !is_btrfs(&drive_root) {
let elapsed = start.elapsed().as_secs_f64();
let mut p = progress.lock().unwrap();
p.finished = true;
p.elapsed_secs = elapsed;
p.current_job = total_jobs;
return;
}
let snapshot_name = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string();
let snapshots_dir = drive_root.join("snapshots");
let _ = std::fs::create_dir_all(&snapshots_dir);
let snap_output = Command::new("doas")
.args([
"btrfs",
"subvolume",
"snapshot",
"-r",
".",
snapshots_dir.join(&snapshot_name).to_str().unwrap_or("."),
])
.current_dir(&drive_root)
.output();
let elapsed = start.elapsed().as_secs_f64();
let mut p = progress.lock().unwrap();
p.finished = true;
p.elapsed_secs = elapsed;
p.current_job = total_jobs;
match snap_output {
Err(e) => {
p.log_lines
.push(format!("Warning: btrfs snapshot failed: {e}"));
}
Ok(out) if !out.status.success() => {
let stderr = String::from_utf8_lossy(&out.stderr);
let stderr = stderr.trim();
if stderr.is_empty() {
p.log_lines.push(format!(
"Warning: btrfs snapshot exited {}",
out.status.code().unwrap_or(-1)
));
} else {
p.log_lines
.push(format!("Warning: btrfs snapshot failed: {stderr}"));
}
if let Ok(mnt) = Command::new("findmnt")
.args([
"--output=TARGET,OPTIONS",
"--target",
drive_root.to_str().unwrap_or("."),
])
.output()
{
let out = String::from_utf8_lossy(&mnt.stdout);
let out = out.trim();
if !out.is_empty() {
p.log_lines.push(format!(" mount: {out}"));
}
}
if let Ok(sv) = Command::new("doas")
.args([
"btrfs",
"subvolume",
"show",
drive_root.to_str().unwrap_or("."),
])
.output()
{
let out = String::from_utf8_lossy(&sv.stdout);
for line in out.lines().take(6) {
p.log_lines.push(format!(" {line}"));
}
}
}
Ok(_) => {
p.log_lines
.push(format!("Snapshot created: {snapshot_name}"));
}
}
})
}
fn is_btrfs(path: &Path) -> bool {
Command::new("findmnt")
.args(["--output=FSTYPE", "--noheadings", "--target"])
.arg(path)
.output()
.map(|out| String::from_utf8_lossy(&out.stdout).trim() == "btrfs")
.unwrap_or(false)
}
fn parse_rsync_line(line: &str, progress: &SharedProgress, elapsed: f64) {
let trimmed = line.trim();
if trimmed.contains("to-chk=") || trimmed.contains("xfr#") {
if let Some(pct_pos) = trimmed.find('%') {
let before_pct: &str = &trimmed[..pct_pos];
let parts: Vec<&str> = before_pct.split_whitespace().collect();
if parts.len() >= 2 {
let bytes_str = parts[parts.len() - 2].replace(',', "");
let pct_str = parts[parts.len() - 1];
if let (Ok(bytes), Ok(pct)) = (bytes_str.parse::<u64>(), pct_str.parse::<f64>()) {
let mut p = progress.lock().unwrap();
p.bytes_transferred = bytes;
if pct > 0.0 {
p.bytes_total = (bytes as f64 * 100.0 / pct) as u64;
}
if pct > 1.0 {
let estimated = elapsed / (pct / 100.0);
p.estimated_total_secs = Some(estimated);
}
}
}
}
if let Some(pos) = trimmed.find("to-chk=") {
let rest = &trimmed[pos + 7..];
let nums: Vec<&str> = rest.split('/').collect();
if nums.len() >= 2 {
let remaining: u64 = nums[0].trim_end_matches(')').parse().unwrap_or(0);
let total: u64 = nums[1]
.trim_end_matches(')')
.split_whitespace()
.next()
.unwrap_or("0")
.parse()
.unwrap_or(0);
let mut p = progress.lock().unwrap();
p.files_total = total;
p.files_transferred = total.saturating_sub(remaining);
}
}
return;
}
if trimmed.contains("ir-chk=") {
return;
}
if !trimmed.is_empty()
&& !trimmed.starts_with("sending")
&& !trimmed.starts_with("receiving")
&& !trimmed.starts_with("sent")
&& !trimmed.starts_with("total")
&& !trimmed.contains('%')
{
let mut p = progress.lock().unwrap();
p.current_file = trimmed.to_owned();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_tagged_dirs_and_prunes_them() {
let tmp = std::env::temp_dir().join(format!("bu-cachetest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("a/cache/inner")).unwrap();
std::fs::create_dir_all(tmp.join("b/fake")).unwrap();
std::fs::write(tmp.join("a/cache/CACHEDIR.TAG"), CACHEDIR_TAG_SIGNATURE).unwrap();
std::fs::write(
tmp.join("a/cache/inner/CACHEDIR.TAG"),
CACHEDIR_TAG_SIGNATURE,
)
.unwrap();
std::fs::write(tmp.join("b/fake/CACHEDIR.TAG"), b"not a real tag").unwrap();
let found = find_cache_dirs(&tmp);
assert_eq!(found, vec![PathBuf::from("a/cache")]);
assert_eq!(
cache_dir_rules(&found),
vec!["--include=/a/cache/CACHEDIR.TAG", "--exclude=/a/cache/*"]
);
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn escapes_rsync_metacharacters() {
assert_eq!(rsync_escape_pattern("x[1]*?"), "x\\[1]\\*\\?");
}
}
|