use crate::scanner::FsNode;
use once_cell::sync::Lazy;
use regex::Regex;
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
#[derive(Debug, Clone, Serialize)]
pub struct DockerFinding {
pub path: PathBuf,
pub size: u64,
pub label: String,
}
pub fn find_docker_bloat(root: &FsNode) -> Vec<DockerFinding> {
let mut out = Vec::new();
walk_docker(root, &mut out);
out
}
fn walk_docker(node: &FsNode, out: &mut Vec<DockerFinding>) {
if !node.is_dir { return; }
let path_str = node.path.to_string_lossy();
if path_str.contains("/docker/overlay2") || path_str.contains("/docker/volumes") {
out.push(DockerFinding { path: node.path.clone(), size: node.size, label: "docker storage".into() });
return;
}
for c in &node.children {
walk_docker(c, out);
}
}
pub fn docker_prune_suggested() -> bool {
Command::new("docker").arg("info").output().map(|o| o.status.success()).unwrap_or(false)
}
pub fn run_docker_prune(volumes: bool) -> Result<String, std::io::Error> {
let mut cmd = Command::new("docker");
cmd.arg("system").arg("prune").arg("-f");
if volumes {
cmd.arg("--volumes");
}
let out = cmd.output()?;
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
static SENSITIVE_NAME: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(id_rsa|id_ed25519|id_ecdsa|credentials\.json|\.env|.*\.pem|.*\.key|.*\.p12|.*\.pfx)$").unwrap()
});
static UNSAFE_DIRS: Lazy<Vec<&'static str>> = Lazy::new(|| vec!["Downloads", "Desktop", "Public", "tmp", "Temp"]);
const VENDORED_DIR_FRAGMENTS: &[&str] = &[
"/site-packages/",
"/venv/",
"/.venv/",
"/node_modules/",
"/vendor/",
"/.cargo/registry/",
"/target/",
"/.git/",
"/dist/",
"/build/",
"/.local/share/flatpak/",
"/.var/app/",
"/.cache/",
"/.local/share/Steam/",
"/steamapps/",
"/steamrt",
];
fn is_in_vendored_dir(path: &std::path::Path) -> bool {
let s = path.to_string_lossy();
VENDORED_DIR_FRAGMENTS.iter().any(|f| s.contains(f))
}
fn pem_is_private_key(path: &std::path::Path) -> bool {
match std::fs::read_to_string(path) {
Err(_) => false,
Ok(text) => text.contains("PRIVATE KEY"),
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SensitiveFinding {
pub path: PathBuf,
pub reason: String,
}
pub fn scan_sensitive_files(root: &FsNode) -> Vec<SensitiveFinding> {
let mut files = Vec::new();
root.flatten_files(&mut files);
files
.into_iter()
.filter(|f| !is_in_vendored_dir(&f.path))
.filter_map(|f| {
let name = f.path.file_name()?.to_str()?;
if !SENSITIVE_NAME.is_match(name) {
return None;
}
if name.to_ascii_lowercase().ends_with(".pem") && !pem_is_private_key(&f.path) {
return None;
}
let in_unsafe_dir = f.path.components().any(|c| {
UNSAFE_DIRS.iter().any(|u| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(u))
});
let reason = if in_unsafe_dir {
format!("credential-like file '{name}' in an exposed directory")
} else {
format!("credential-like file '{name}'")
};
Some(SensitiveFinding { path: f.path.clone(), reason })
})
.collect()
}
#[derive(Debug, Clone, Serialize)]
pub struct HardlinkGroup {
pub inode: u64,
pub paths: Vec<PathBuf>,
pub size: u64,
}
pub fn find_hardlinks(root: &FsNode) -> Vec<HardlinkGroup> {
let mut files = Vec::new();
root.flatten_files(&mut files);
let mut by_inode: HashMap<u64, Vec<&FsNode>> = HashMap::new();
for f in files.iter().filter(|f| f.nlink > 1) {
by_inode.entry(f.inode).or_default().push(f);
}
by_inode.into_iter()
.filter(|(_, v)| v.len() > 1)
.map(|(inode, v)| HardlinkGroup {
inode,
size: v[0].size,
paths: v.into_iter().map(|f| f.path.clone()).collect(),
})
.collect()
}
#[derive(Debug, Clone, Serialize)]
pub struct AppUsage {
pub name: String,
pub last_used_days: Option<i64>,
pub package_manager: String,
}
fn manually_installed_apt_packages() -> Option<HashMap<String, ()>> {
let out = Command::new("apt-mark").arg("showmanual").output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let set: HashMap<String, ()> = text.lines().map(|l| (l.trim().to_string(), ())).collect();
if set.is_empty() { None } else { Some(set) }
}
fn packages_with_desktop_entries() -> Option<HashMap<String, ()>> {
let dirs = ["/usr/share/applications", "/var/lib/snapd/desktop/applications"];
let mut desktop_files: Vec<PathBuf> = Vec::new();
for dir in dirs {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("desktop") {
desktop_files.push(path);
}
}
}
}
if desktop_files.is_empty() {
return None;
}
let out = Command::new("dpkg").arg("-S").args(&desktop_files).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let mut set: HashMap<String, ()> = HashMap::new();
for line in text.lines() {
let Some((pkgs, _path)) = line.split_once(':') else { continue };
for pkg in pkgs.split(',') {
let pkg = pkg.trim();
if !pkg.is_empty() {
set.insert(pkg.to_string(), ());
}
}
}
if set.is_empty() { None } else { Some(set) }
}
static NON_APP_PACKAGE: Lazy<Regex> = Lazy::new(|| {
Regex::new(concat!(
r"(?i)^(",
r"lib.*|.*-dev|.*-dbg|.*-doc|.*-common|.*-data|.*-dbgsym|",
r"linux-.*|.*-firmware|locales.*|.*-l10n|.*-locale-.*|language-pack-.*|",
r"fonts-.*|hunspell-.*|mythes-.*|gir1\.2-.*|python3.*|",
r"xserver-.*|systemd.*|nvidia-.*|system76-.*|",
r"dpkg|apt|apt-.*|base-files|base-passwd|coreutils|util-linux|",
r"bash|grep|sed|tar|gzip|login|mount|udev|sudo|grub-.*|initramfs-tools.*",
r")$"
))
.unwrap()
});
static SNAP_RUNTIME: Lazy<Regex> = Lazy::new(|| {
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()
});
pub fn find_unused_apps(min_idle_days: i64) -> Vec<AppUsage> {
let mut apps = Vec::new();
let manual = manually_installed_apt_packages();
let gui = packages_with_desktop_entries();
if let Ok(out) = Command::new("apt").arg("list").arg("--installed").output() {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines().skip(1) {
if let Some(name) = line.split('/').next() {
if name.is_empty() || NON_APP_PACKAGE.is_match(name) {
continue;
}
if let Some(gui) = &gui {
if !gui.contains_key(name) {
continue;
}
}
if let Some(manual) = &manual {
if !manual.contains_key(name) {
continue;
}
}
apps.push(AppUsage { name: name.to_string(), last_used_days: None, package_manager: "apt".into() });
}
}
}
if let Ok(out) = Command::new("snap").arg("list").output() {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines().skip(1) {
if let Some(name) = line.split_whitespace().next() {
if SNAP_RUNTIME.is_match(name) {
continue;
}
apps.push(AppUsage { name: name.to_string(), last_used_days: None, package_manager: "snap".into() });
}
}
}
let xbel_seen = parse_recently_used_xbel();
for app in apps.iter_mut() {
let signals = [
journalctl_last_seen(&app.name),
xbel_seen.get(&app.name).copied(),
config_dir_last_seen(&app.name),
];
app.last_used_days = signals.into_iter().flatten().min();
}
apps.into_iter()
.filter(|a| match a.last_used_days {
Some(d) => d >= min_idle_days,
None => true,
})
.collect()
}
fn parse_recently_used_xbel() -> HashMap<String, i64> {
let mut out: HashMap<String, i64> = HashMap::new();
let Some(home) = dirs::home_dir() else { return out };
let Ok(text) = std::fs::read_to_string(home.join(".local/share/recently-used.xbel")) else { return out };
static BOOKMARK: Lazy<Regex> = Lazy::new(|| Regex::new(r#"(?s)<bookmark\b[^>]*\bvisited="([^"]+)"[^>]*>(.*?)</bookmark>"#).unwrap());
static APP_NAME: Lazy<Regex> = Lazy::new(|| Regex::new(r#"<bookmark:application\b[^>]*\bname="([^"]+)""#).unwrap());
for cap in BOOKMARK.captures_iter(&text) {
let Ok(visited) = chrono::DateTime::parse_from_rfc3339(&cap[1]) else { continue };
let days = (chrono::Utc::now() - visited.with_timezone(&chrono::Utc)).num_days();
for app_cap in APP_NAME.captures_iter(&cap[2]) {
let name = app_cap[1].to_lowercase();
out.entry(name).and_modify(|d| { if days < *d { *d = days; } }).or_insert(days);
}
}
out
}
fn config_dir_last_seen(app_name: &str) -> Option<i64> {
let home = dirs::home_dir()?;
let candidates = [home.join(".config").join(app_name), home.join(".config").join(app_name.to_lowercase())];
candidates.iter().find_map(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok()).map(crate::scanner::walker::age_days)
}
fn journalctl_last_seen(unit: &str) -> Option<i64> {
let out = Command::new("journalctl")
.arg("-u").arg(unit)
.arg("-n").arg("1")
.arg("--output=short-iso")
.output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let line = text.lines().next()?;
let ts_str = line.split_whitespace().next()?;
let ts = chrono::DateTime::parse_from_rfc3339(ts_str).ok()?;
let days = (chrono::Utc::now() - ts.with_timezone(&chrono::Utc)).num_days();
Some(days)
}
pub fn generate_cleanup_script(paths: &[PathBuf], use_trash: bool) -> String {
let mut script = String::new();
script.push_str("#!/usr/bin/env bash\n");
script.push_str("set -euo pipefail\n\n");
script.push_str("# Generated by diskr. Review each line before running.\n");
script.push_str(&format!("# Items: {}\n\n", paths.len()));
for p in paths {
let display = p.display();
if use_trash {
script.push_str(&format!("trash-put \"{display}\"\n"));
} else {
script.push_str(&format!("rm -rf -- \"{display}\"\n"));
}
}
script
}