Download
714 lines 23.4 KiBRustModified 2026-10-16 raw
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]\\*\\?");
    }
}