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;
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}