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
38pub struct OpenBy {
42 pub pid: i32,
43 pub process: String,
44}
45
46fn current_uid() -> u32 {
47 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 }; let process = fs::read_to_string(pid_dir.join("comm")).unwrap_or_else(|_| "?".into()).trim().to_string();
64
65 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
85fn 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 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}