Download
216 lines 7.3 KiBRustModified 2026-10-29 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
use std::path::{Component, Path, PathBuf};
use std::process::Command;

use serde::Serialize;

use crate::backup::list_snapshot_names;

/// One place on the drive where the path to purge exists.
#[derive(Debug, Clone, Serialize)]
pub struct PurgeTarget {
    /// `None` for the live (most recent) copy at the drive root.
    pub snapshot: Option<String>,
    pub is_dir: bool,
}

#[derive(Debug, Clone, Serialize)]
pub struct PurgeResult {
    pub log_lines: Vec<String>,
    pub purged: usize,
    pub failed: usize,
}

/// Check that `rel` is a non-empty relative path made only of plain names.
pub fn validate_rel(rel: &Path) -> Result<(), String> {
    if rel.as_os_str().is_empty() {
        return Err("Path to purge must not be empty".to_owned());
    }
    for comp in rel.components() {
        if !matches!(comp, Component::Normal(_)) {
            return Err(format!(
                "Invalid path \"{}\": must use only plain names (no '..', '.', or leading '/')",
                rel.display()
            ));
        }
    }
    Ok(())
}

/// Whether `rel` exists under `base`. Errors if any parent component is a symlink: rsync
/// preserves symlinks, so a backed-up link could otherwise send `rm` outside the drive.
fn locate(base: &Path, rel: &Path) -> Result<Option<bool>, String> {
    let comps: Vec<Component> = rel.components().collect();
    let mut cur = base.to_path_buf();
    for (i, comp) in comps.iter().enumerate() {
        cur.push(comp);
        let meta = match std::fs::symlink_metadata(&cur) {
            Ok(m) => m,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(format!("{}: {e}", cur.display())),
        };
        if i + 1 == comps.len() {
            return Ok(Some(meta.is_dir()));
        }
        if meta.file_type().is_symlink() {
            return Err(format!(
                "Refusing to purge through symlink {}",
                cur.display()
            ));
        }
        if !meta.is_dir() {
            return Ok(None);
        }
    }
    Ok(None)
}

fn snapshot_dir(drive_root: &Path, snap: &str) -> PathBuf {
    drive_root.join("snapshots").join(snap)
}

/// Every location on the drive (live copy and each snapshot) that contains `rel`.
pub fn find_targets(drive_root: &Path, rel: &Path) -> Result<Vec<PurgeTarget>, String> {
    validate_rel(rel)?;
    let mut targets = Vec::new();
    if let Some(is_dir) = locate(drive_root, rel)? {
        targets.push(PurgeTarget {
            snapshot: None,
            is_dir,
        });
    }
    for snap in list_snapshot_names(drive_root) {
        if let Some(is_dir) = locate(&snapshot_dir(drive_root, &snap), rel)? {
            targets.push(PurgeTarget {
                snapshot: Some(snap),
                is_dir,
            });
        }
    }
    Ok(targets)
}

fn run_doas(args: &[&str]) -> Result<String, String> {
    let out = Command::new("doas")
        .args(args)
        .output()
        .map_err(|e| format!("failed to run doas: {e}"))?;
    if out.status.success() {
        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
    } else {
        let stderr = String::from_utf8_lossy(&out.stderr);
        let stderr = stderr.trim();
        Err(if stderr.is_empty() {
            format!("exited {}", out.status.code().unwrap_or(-1))
        } else {
            stderr.to_owned()
        })
    }
}

fn remove(path: &Path) -> Result<(), String> {
    let p = path.to_str().ok_or("path is not valid UTF-8")?;
    run_doas(&["rm", "-rf", "--", p]).map(|_| ())
}

fn is_readonly_subvolume(snap: &str) -> Result<bool, String> {
    let out = run_doas(&["btrfs", "property", "get", "-ts", snap, "ro"])?;
    Ok(out.trim() == "ro=true")
}

fn set_readonly(snap: &str, ro: bool) -> Result<(), String> {
    let val = if ro { "true" } else { "false" };
    run_doas(&["btrfs", "property", "set", "-ts", snap, "ro", val]).map(|_| ())
}

/// Delete `rel` from a snapshot, temporarily lifting its read-only flag if it has one.
fn purge_from_snapshot(drive_root: &Path, snap: &str, rel: &Path) -> Result<(), String> {
    let dir = snapshot_dir(drive_root, snap);
    let dir_str = dir.to_str().ok_or("snapshot path is not valid UTF-8")?;
    let was_ro = is_readonly_subvolume(dir_str)?;
    if was_ro {
        set_readonly(dir_str, false).map_err(|e| format!("could not make writable: {e}"))?;
    }
    let removed = remove(&dir.join(rel));
    if was_ro {
        if let Err(e) = set_readonly(dir_str, true) {
            return Err(format!(
                "could not restore read-only flag ({e}){}",
                match removed {
                    Ok(()) => " — file was removed".to_owned(),
                    Err(re) => format!("; removal also failed: {re}"),
                }
            ));
        }
    }
    removed
}

/// Delete `rel` from the live copy and every snapshot. Locations are re-discovered here rather
/// than trusted from the caller.
pub fn purge(drive_root: &Path, rel: &Path) -> Result<PurgeResult, String> {
    let targets = find_targets(drive_root, rel)?;
    let mut result = PurgeResult {
        log_lines: Vec::new(),
        purged: 0,
        failed: 0,
    };
    if targets.is_empty() {
        result
            .log_lines
            .push(format!("{} was not found on the drive.", rel.display()));
        return Ok(result);
    }
    for t in &targets {
        let (label, outcome) = match &t.snapshot {
            None => ("current backup".to_owned(), remove(&drive_root.join(rel))),
            Some(snap) => (
                format!("snapshot {snap}"),
                purge_from_snapshot(drive_root, snap, rel),
            ),
        };
        match outcome {
            Ok(()) => {
                result.purged += 1;
                result.log_lines.push(format!("Removed from {label}"));
            }
            Err(e) => {
                result.failed += 1;
                result.log_lines.push(format!("FAILED for {label}: {e}"));
            }
        }
    }
    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validate_rel_rejects_escapes() {
        assert!(validate_rel(Path::new("home/a.txt")).is_ok());
        assert!(validate_rel(Path::new("")).is_err());
        assert!(validate_rel(Path::new("/etc/passwd")).is_err());
        assert!(validate_rel(Path::new("home/../x")).is_err());
        assert!(validate_rel(Path::new("./x")).is_err());
    }

    #[test]
    fn locate_refuses_symlinked_parents() {
        let tmp = std::env::temp_dir().join(format!("purge-test-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(tmp.join("real/sub")).unwrap();
        std::fs::write(tmp.join("real/sub/f"), b"x").unwrap();
        std::os::unix::fs::symlink(tmp.join("real"), tmp.join("link")).unwrap();

        assert_eq!(locate(&tmp, Path::new("real/sub/f")).unwrap(), Some(false));
        assert_eq!(locate(&tmp, Path::new("real/sub")).unwrap(), Some(true));
        assert_eq!(locate(&tmp, Path::new("real/missing")).unwrap(), None);
        assert_eq!(locate(&tmp, Path::new("real/sub/f/below")).unwrap(), None);
        // A symlink as the final component is fine: rm removes the link itself.
        assert_eq!(locate(&tmp, Path::new("link")).unwrap(), Some(false));
        assert!(locate(&tmp, Path::new("link/sub/f")).is_err());

        std::fs::remove_dir_all(&tmp).unwrap();
    }
}