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