1use crate::scanner::FsNode;
2use once_cell::sync::Lazy;
3use regex::Regex;
4use serde::Serialize;
5use std::collections::{HashMap, HashSet};
6use std::os::unix::fs::PermissionsExt;
7use std::path::PathBuf;
8use std::process::Command;
9
10#[derive(Debug, Clone, Serialize)]
11pub struct DockerFinding {
12 pub path: PathBuf,
13 pub size: u64,
14 pub label: String,
15}
16
17pub fn find_docker_bloat(root: &FsNode) -> Vec<DockerFinding> {
18 let mut out = Vec::new();
19 walk_docker(root, &mut out);
20 out
21}
22
23fn walk_docker(node: &FsNode, out: &mut Vec<DockerFinding>) {
24 if !node.is_dir { return; }
25 let path_str = node.path.to_string_lossy();
26 if path_str.contains("/docker/overlay2") || path_str.contains("/docker/volumes") {
27 out.push(DockerFinding { path: node.path.clone(), size: node.size, label: "docker storage".into() });
28 return;
29 }
30 for c in &node.children {
31 walk_docker(c, out);
32 }
33}
34
35pub fn docker_prune_suggested() -> bool {
36 Command::new("docker").arg("info").output().map(|o| o.status.success()).unwrap_or(false)
37}
38
39pub fn run_docker_prune(volumes: bool) -> Result<String, std::io::Error> {
40 let mut cmd = Command::new("docker");
41 cmd.arg("system").arg("prune").arg("-f");
42 if volumes {
43 cmd.arg("--volumes");
44 }
45 let out = cmd.output()?;
46 Ok(String::from_utf8_lossy(&out.stdout).to_string())
47}
48
49static SENSITIVE_NAME: Lazy<Regex> = Lazy::new(|| {
50 Regex::new(r"(?i)^(id_rsa|id_ed25519|id_ecdsa|credentials\.json|\.env|.*\.pem|.*\.key|.*\.p12|.*\.pfx)$").unwrap()
51});
52
53static UNSAFE_DIRS: Lazy<Vec<&'static str>> = Lazy::new(|| vec!["Downloads", "Desktop", "Public", "tmp", "Temp"]);
54
55const VENDORED_DIR_FRAGMENTS: &[&str] = &[
56 "/site-packages/",
57 "/venv/",
58 "/.venv/",
59 "/node_modules/",
60 "/vendor/",
61 "/.cargo/registry/",
62 "/target/",
63 "/.git/",
64 "/dist/",
65 "/build/",
66 "/.local/share/flatpak/",
67 "/.var/app/",
68 "/.cache/",
69 "/.local/share/Steam/",
70 "/steamapps/",
71 "/steamrt",
72];
73
74fn is_in_vendored_dir(path: &std::path::Path) -> bool {
75 let s = path.to_string_lossy();
76 VENDORED_DIR_FRAGMENTS.iter().any(|f| s.contains(f))
77}
78
79fn pem_is_private_key(path: &std::path::Path) -> bool {
80 match std::fs::read_to_string(path) {
81 Err(_) => false,
82 Ok(text) => text.contains("PRIVATE KEY"),
83 }
84}
85
86#[derive(Debug, Clone, Serialize)]
87pub struct SensitiveFinding {
88 pub path: PathBuf,
89 pub reason: String,
90}
91
92pub fn scan_sensitive_files(root: &FsNode) -> Vec<SensitiveFinding> {
93 let mut files = Vec::new();
94 root.flatten_files(&mut files);
95 files
96 .into_iter()
97 .filter(|f| !is_in_vendored_dir(&f.path))
98 .filter_map(|f| {
99 let name = f.path.file_name()?.to_str()?;
100 if !SENSITIVE_NAME.is_match(name) {
101 return None;
102 }
103 if name.to_ascii_lowercase().ends_with(".pem") && !pem_is_private_key(&f.path) {
104 return None;
105 }
106 let in_unsafe_dir = f.path.components().any(|c| {
107 UNSAFE_DIRS.iter().any(|u| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(u))
108 });
109 let reason = if in_unsafe_dir {
110 format!("credential-like file '{name}' in an exposed directory")
111 } else {
112 format!("credential-like file '{name}'")
113 };
114 Some(SensitiveFinding { path: f.path.clone(), reason })
115 })
116 .collect()
117}
118
119#[derive(Debug, Clone, Serialize)]
120pub struct HardlinkGroup {
121 pub inode: u64,
122 pub paths: Vec<PathBuf>,
123 pub size: u64,
124}
125
126pub fn find_hardlinks(root: &FsNode) -> Vec<HardlinkGroup> {
127 let mut files = Vec::new();
128 root.flatten_files(&mut files);
129 let mut by_inode: HashMap<u64, Vec<&FsNode>> = HashMap::new();
130 for f in files.iter().filter(|f| f.nlink > 1) {
131 by_inode.entry(f.inode).or_default().push(f);
132 }
133 by_inode.into_iter()
134 .filter(|(_, v)| v.len() > 1)
135 .map(|(inode, v)| HardlinkGroup {
136 inode,
137 size: v[0].size,
138 paths: v.into_iter().map(|f| f.path.clone()).collect(),
139 })
140 .collect()
141}
142
143#[derive(Debug, Clone, Serialize)]
144pub struct AppUsage {
145 pub name: String,
146 pub last_used_days: Option<i64>,
147 pub package_manager: String,
148}
149
150fn manually_installed_apt_packages() -> Option<HashMap<String, ()>> {
151 let out = Command::new("apt-mark").arg("showmanual").output().ok()?;
152 if !out.status.success() {
153 return None;
154 }
155 let text = String::from_utf8_lossy(&out.stdout);
156 let set: HashMap<String, ()> = text.lines().map(|l| (l.trim().to_string(), ())).collect();
157 if set.is_empty() { None } else { Some(set) }
158}
159
160fn packages_with_desktop_entries() -> Option<HashMap<String, ()>> {
161 let dirs = ["/usr/share/applications", "/var/lib/snapd/desktop/applications"];
162 let mut desktop_files: Vec<PathBuf> = Vec::new();
163 for dir in dirs {
164 if let Ok(entries) = std::fs::read_dir(dir) {
165 for entry in entries.flatten() {
166 let path = entry.path();
167 if path.extension().and_then(|e| e.to_str()) == Some("desktop") {
168 desktop_files.push(path);
169 }
170 }
171 }
172 }
173 if desktop_files.is_empty() {
174 return None;
175 }
176 let out = Command::new("dpkg").arg("-S").args(&desktop_files).output().ok()?;
177 let text = String::from_utf8_lossy(&out.stdout);
178 let mut set: HashMap<String, ()> = HashMap::new();
179 for line in text.lines() {
180 let Some((pkgs, _path)) = line.split_once(':') else { continue };
181 for pkg in pkgs.split(',') {
182 let pkg = pkg.trim();
183 if !pkg.is_empty() {
184 set.insert(pkg.to_string(), ());
185 }
186 }
187 }
188 if set.is_empty() { None } else { Some(set) }
189}
190
191static NON_APP_PACKAGE: Lazy<Regex> = Lazy::new(|| {
192 Regex::new(concat!(
193 r"(?i)^(",
194 r"lib.*|.*-dev|.*-dbg|.*-doc|.*-common|.*-data|.*-dbgsym|",
195 r"linux-.*|.*-firmware|locales.*|.*-l10n|.*-locale-.*|language-pack-.*|",
196 r"fonts-.*|hunspell-.*|mythes-.*|gir1\.2-.*|python3.*|",
197 r"xserver-.*|systemd.*|nvidia-.*|system76-.*|",
198 r"dpkg|apt|apt-.*|base-files|base-passwd|coreutils|util-linux|",
199 r"bash|grep|sed|tar|gzip|login|mount|udev|sudo|grub-.*|initramfs-tools.*",
200 r")$"
201 ))
202 .unwrap()
203});
204
205static SNAP_RUNTIME: Lazy<Regex> = Lazy::new(|| {
206 Regex::new(r"^(core[0-9]*|bare|snapd|gtk-common-themes|gnome-[0-9]+-[0-9]+|gnome-[0-9x]+-sdk|kde-frameworks-[0-9.]+-.*)$").unwrap()
207});
208
209pub fn find_unused_apps(min_idle_days: i64) -> Vec<AppUsage> {
210 let mut apps = Vec::new();
211 let manual = manually_installed_apt_packages();
212 let gui = packages_with_desktop_entries();
213 if let Ok(out) = Command::new("apt").arg("list").arg("--installed").output() {
214 let text = String::from_utf8_lossy(&out.stdout);
215 for line in text.lines().skip(1) {
216 if let Some(name) = line.split('/').next() {
217 if name.is_empty() || NON_APP_PACKAGE.is_match(name) {
218 continue;
219 }
220 if let Some(gui) = &gui {
221 if !gui.contains_key(name) {
222 continue;
223 }
224 }
225 if let Some(manual) = &manual {
226 if !manual.contains_key(name) {
227 continue;
228 }
229 }
230 apps.push(AppUsage { name: name.to_string(), last_used_days: None, package_manager: "apt".into() });
231 }
232 }
233 }
234 if let Ok(out) = Command::new("snap").arg("list").output() {
235 let text = String::from_utf8_lossy(&out.stdout);
236 for line in text.lines().skip(1) {
237 if let Some(name) = line.split_whitespace().next() {
238 if SNAP_RUNTIME.is_match(name) {
239 continue;
240 }
241 apps.push(AppUsage { name: name.to_string(), last_used_days: None, package_manager: "snap".into() });
242 }
243 }
244 }
245 let xbel_seen = parse_recently_used_xbel();
246 let running = running_executables();
247 for app in apps.iter_mut() {
248 let bins = match app.package_manager.as_str() {
249 "apt" => dpkg_binaries(&app.name),
250 "snap" => snap_binary(&app.name),
251 _ => Vec::new(),
252 };
253 if bins.iter().any(|b| running.contains(b)) {
254 app.last_used_days = Some(0); continue;
256 }
257 let signals = [
258 journalctl_last_seen(&app.name),
259 xbel_seen.get(&app.name).copied(),
260 config_dir_last_seen(&app.name),
261 binary_last_used_days(&bins),
262 ];
263 app.last_used_days = signals.into_iter().flatten().min();
264 }
265
266 apps.into_iter()
267 .filter(|a| match a.last_used_days {
268 Some(d) => d >= min_idle_days,
269 None => true,
270 })
271 .collect()
272}
273
274fn parse_recently_used_xbel() -> HashMap<String, i64> {
275 let mut out: HashMap<String, i64> = HashMap::new();
276 let Some(home) = dirs::home_dir() else { return out };
277 let Ok(text) = std::fs::read_to_string(home.join(".local/share/recently-used.xbel")) else { return out };
278
279 static BOOKMARK: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?s)<bookmark\b[^>]*\bvisited="([^"]+)"[^>]*>(.*?)</bookmark>"#).unwrap());
280 static APP_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r#"<bookmark:application\b[^>]*\bname="([^"]+)""#).unwrap());
281
282 for cap in BOOKMARK.captures_iter(&text) {
283 let Ok(visited) = chrono::DateTime::parse_from_rfc3339(&cap[1]) else { continue };
284 let days = (chrono::Utc::now() - visited.with_timezone(&chrono::Utc)).num_days();
285 for app_cap in APP_NAME.captures_iter(&cap[2]) {
286 let name = app_cap[1].to_lowercase();
287 out.entry(name).and_modify(|d| { if days < *d { *d = days; } }).or_insert(days);
288 }
289 }
290 out
291}
292
293fn config_dir_last_seen(app_name: &str) -> Option<i64> {
294 let home = dirs::home_dir()?;
295 let candidates = [home.join(".config").join(app_name), home.join(".config").join(app_name.to_lowercase())];
296 candidates.iter().find_map(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok()).map(crate::scanner::walker::age_days)
297}
298
299fn journalctl_last_seen(unit: &str) -> Option<i64> {
300 let out = Command::new("journalctl")
301 .arg("-u").arg(unit)
302 .arg("-n").arg("1")
303 .arg("--output=short-iso")
304 .output().ok()?;
305 let text = String::from_utf8_lossy(&out.stdout);
306 let line = text.lines().next()?;
307 let ts_str = line.split_whitespace().next()?;
308 let ts = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?;
309 let days = (chrono::Utc::now() - ts.with_timezone(&chrono::Utc)).num_days();
310 Some(days)
311}
312
313fn dpkg_binaries(pkg: &str) -> Vec<PathBuf> {
318 let Ok(text) = std::fs::read_to_string(format!("/var/lib/dpkg/info/{pkg}.list")) else {
319 return Vec::new();
320 };
321 text.lines()
322 .filter(|l| l.starts_with("/usr/bin/") || l.starts_with("/usr/games/") || l.starts_with("/opt/"))
323 .map(PathBuf::from)
324 .filter(|p| {
325 std::fs::metadata(p)
326 .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
327 .unwrap_or(false)
328 })
329 .collect()
330}
331
332fn snap_binary(name: &str) -> Vec<PathBuf> {
335 let p = PathBuf::from(format!("/snap/bin/{name}"));
336 if p.exists() { vec![p] } else { Vec::new() }
337}
338
339fn binary_last_used_days(bins: &[PathBuf]) -> Option<i64> {
344 bins.iter()
345 .filter_map(|p| std::fs::metadata(p).ok()?.accessed().ok())
346 .map(crate::scanner::walker::age_days)
347 .min()
348}
349
350fn running_executables() -> HashSet<PathBuf> {
353 let mut set = HashSet::new();
354 let Ok(procs) = std::fs::read_dir("/proc") else { return set };
355 for entry in procs.flatten() {
356 let pid_dir = entry.path();
357 let is_pid = pid_dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.chars().all(|c| c.is_ascii_digit()));
358 if !is_pid { continue; }
359 if let Ok(exe) = std::fs::read_link(pid_dir.join("exe")) {
360 set.insert(exe);
361 }
362 }
363 set
364}
365
366pub fn generate_cleanup_script(paths: &[PathBuf], use_trash: bool) -> String {
367 let mut script = String::new();
368 script.push_str("#!/usr/bin/env bash\n");
369 script.push_str("set -euo pipefail\n\n");
370 script.push_str("# Generated by diskr. Review each line before running.\n");
371 script.push_str(&format!("# Items: {}\n\n", paths.len()));
372 for p in paths {
373 let display = p.display();
374 if use_trash {
375 script.push_str(&format!("trash-put \"{display}\"\n"));
376 } else {
377 script.push_str(&format!("rm -rf -- \"{display}\"\n"));
378 }
379 }
380 script
381}