Skip to main content

diskr/cleaner/
safe.rs

1use anyhow::{bail, Result};
2use std::collections::HashMap;
3use std::fs;
4use std::os::unix::fs::MetadataExt;
5use std::path::{Path, PathBuf};
6
7const PROTECTED_PREFIXES: &[&str] = &[
8    "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", "/root",
9    "/run", "/sbin", "/sys", "/usr", "/var/lib", "/var/log",
10];
11
12const NEVER_CLEAN_FRAGMENTS: &[&str] = &[
13    "/.steam/",
14    "/Steam/",
15    "/.local/share/Steam/",
16    "/lutris/",
17    "/.local/share/lutris/",
18    "/.local/share/flatpak/",
19    "/.var/app/",
20    "/snap/",
21    "/.local/share/snap/",
22    "/var/lib/snapd/",
23];
24
25pub fn is_game_or_store_dir(path: &Path) -> bool {
26    let s = path.to_string_lossy();
27    NEVER_CLEAN_FRAGMENTS.iter().any(|f| s.contains(f))
28}
29
30pub fn is_protected(path: &Path) -> bool {
31    let canon = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
32    if canon.components().count() <= 1 {
33        return true;
34    }
35    PROTECTED_PREFIXES.iter().any(|p| canon.starts_with(Path::new(p)))
36}
37
38// who currently has a path open, keyed by the resolved target path.
39// built once from /proc, so checking a candidate against it is just a
40// hashmap/starts_with lookup instead of spawning lsof per file.
41pub struct OpenBy {
42    pub pid: i32,
43    pub process: String,
44}
45
46fn current_uid() -> u32 {
47    // no libc dep needed - /proc/self/status has it in plain text
48    std::fs::read_to_string("/proc/self/status")
49        .ok()
50        .and_then(|s| s.lines().find(|l| l.starts_with("Uid:")).map(|l| l.to_string()))
51        .and_then(|l| l.split_whitespace().nth(1).map(|s| s.to_string()))
52        .and_then(|s| s.parse().ok())
53        .unwrap_or(0)
54}
55
56fn scan_open_files() -> HashMap<PathBuf, Vec<OpenBy>> {
57    let mut open: HashMap<PathBuf, Vec<OpenBy>> = HashMap::new();
58    let Ok(procs) = fs::read_dir("/proc") else { return open };
59    for entry in procs.flatten() {
60        let pid_dir = entry.path();
61        let Some(pid_str) = pid_dir.file_name().and_then(|n| n.to_str()) else { continue };
62        let Ok(pid) = pid_str.parse::<i32>() else { continue }; // skip non-pid entries like /proc/self
63        let process = fs::read_to_string(pid_dir.join("comm")).unwrap_or_else(|_| "?".into()).trim().to_string();
64
65        // open file descriptors, plus the exe/cwd of the process itself -
66        // a deleted-but-running exe is still a "this will break something" case
67        let mut targets: Vec<PathBuf> = Vec::new();
68        if let Ok(fds) = fs::read_dir(pid_dir.join("fd")) {
69            for fd in fds.flatten() {
70                if let Ok(t) = fs::read_link(fd.path()) { targets.push(t); }
71            }
72        }
73        if let Ok(t) = fs::read_link(pid_dir.join("exe")) { targets.push(t); }
74        if let Ok(t) = fs::read_link(pid_dir.join("cwd")) { targets.push(t); }
75
76        for t in targets {
77            if t.is_absolute() {
78                open.entry(t).or_default().push(OpenBy { pid, process: process.clone() });
79            }
80        }
81    }
82    open
83}
84
85// path itself is open, or (for a directory candidate) something inside it is
86fn who_has_open(path: &Path, open: &HashMap<PathBuf, Vec<OpenBy>>) -> Option<String> {
87    let mut names: Vec<String> = Vec::new();
88    for (p, users) in open {
89        if p == path || p.starts_with(path) {
90            for u in users {
91                let label = format!("{} (pid {})", u.process, u.pid);
92                if !names.contains(&label) { names.push(label); }
93            }
94        }
95    }
96    if names.is_empty() { None } else { Some(names.join(", ")) }
97}
98
99pub struct AtRiskEntry {
100    pub path: PathBuf,
101    pub reason: String,
102}
103
104pub struct CleanPlan {
105    pub keep: Vec<PathBuf>,
106    pub remove: Vec<PathBuf>,
107    pub total_size: u64,
108    pub blocked: Vec<PathBuf>,
109    // looked like a normal candidate but is owned by someone else or
110    // currently in use - skipped so we don't break a running program
111    pub at_risk: Vec<AtRiskEntry>,
112}
113
114pub fn build_plan(candidates: Vec<(PathBuf, u64)>) -> CleanPlan {
115    let my_uid = current_uid();
116    let open_files = scan_open_files();
117
118    let mut remove = Vec::new();
119    let mut blocked = Vec::new();
120    let mut at_risk = Vec::new();
121    let mut total_size = 0u64;
122
123    for (path, size) in candidates {
124        if is_protected(&path) {
125            blocked.push(path);
126            continue;
127        }
128        if let Ok(meta) = fs::metadata(&path) {
129            if meta.uid() != my_uid {
130                at_risk.push(AtRiskEntry { path, reason: format!("owned by another user (uid {})", meta.uid()) });
131                continue;
132            }
133        }
134        if let Some(who) = who_has_open(&path, &open_files) {
135            at_risk.push(AtRiskEntry { path, reason: format!("currently open by {who}") });
136            continue;
137        }
138        total_size += size;
139        remove.push(path);
140    }
141    CleanPlan { keep: Vec::new(), remove, total_size, blocked, at_risk }
142}
143
144pub fn execute_plan(plan: &CleanPlan, dry_run: bool) -> Result<usize> {
145    if dry_run {
146        return Ok(plan.remove.len());
147    }
148    if !plan.blocked.is_empty() {
149        bail!("{} protected paths were excluded from this plan and must not be removed", plan.blocked.len());
150    }
151    let refs: Vec<&Path> = plan.remove.iter().map(|p| p.as_path()).collect();
152    super::trash::move_to_trash(&refs)
153}
154
155pub fn dedup_with_hardlink(keep: &Path, remove: &Path) -> Result<()> {
156    if is_protected(keep) || is_protected(remove) {
157        bail!("refusing to touch a protected path");
158    }
159    fs::remove_file(remove)?;
160    fs::hard_link(keep, remove)?;
161    Ok(())
162}