Skip to main content

diskr/analyzer/
extra.rs

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); // running right now beats every other signal
255            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
313// most GUI apps have no systemd unit, so journalctl_last_seen almost never
314// fires for them - this is the signal that actually covers chrome/code/steam/etc.
315// dpkg tracks every file a package installed; we only care about the ones
316// that look like something you'd actually launch.
317fn 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
332// snap wraps every app as a launcher script under /snap/bin/<name> - not
333// tracked by dpkg, but its atime still updates each time you run the app
334fn 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
339// freshest access time across a package's binaries, in days ago. relies on
340// atime being tracked (relatime, the linux default, is good enough for a
341// 90-day-idle threshold) - degrades to None on a noatime mount, same as
342// every other signal here, so it just falls back to "usage unknown".
343fn 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
350// the one signal with zero guesswork: if it's running right now, it isn't
351// idle, full stop. one /proc walk, then an O(1) lookup per app.
352fn 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}